Skip to content

第9章 让工作真正循环起来——Agent Loop ​

小a给 Agent 接上了工具,却发现它只会“干一步”:让它修一个测试,它读完失败信息就停住了,安静地等着下一步指示。

“它不是有工具了吗?为什么不会自己往下干?”小a问。

“工具给了模型受限的行动入口,但一次调用只能提出一次请求。”老z说,“真实任务从来不是一步——读取、判断、执行、再验证,是一轮接一轮的往复。把它串起来,才是 Agent。”

本章讨论 Agent Loop、消息流、ReAct、停止条件和并发边界;不把循环等同于自主或可靠。

先把名字对齐 ​

中文术语英文全称或缩写本书工作定义不等于什么
Agent 循环Agent Loop反复请求模型、处理工具结果、更新状态的控制流模型自动拥有执行权
回合turn一次模型响应及其后续工具结果的状态单元一个用户输入必然只请求一次模型
ReActReasoning and Acting推理轨迹与动作交替的研究方法所有 Agent 的强制实现规范
停止条件stop condition预算、完成、失败、取消等终止转移模型输出“完成”这句话
事件/状态机event/state machine可观测事实和允许转移的控制模型仅用于 UI 的日志
取消cancellation通过 signal/控制器请求停止当前工作已发生副作用的自动回滚

9.1 循环的最小职责 ​

“那这个‘串起来’最少要做什么?”小a问。

“Agent Loop 维护消息状态,调用模型,执行获准的工具,并把结果作为下一轮输入。模型决定候选下一步,程序决定是否执行及何时停止。”老z说,“你可以把它想成一次手术的巡回:医生(模型)提出下一步操作,麻醉与护理团队(程序)确认、执行、记录,再回到医生那里决定下一步。”

text
初始化消息
    ↓
请求模型 ── 无工具请求 ──> 结束或请求澄清
    ↓ 有工具请求
校验、审批、执行工具
    ↓
将结果写入消息状态 ──> 下一轮请求模型

“就这么简单?”小a问。

“这是简化示意。生产实现还需处理流、取消、持久化、预算和事件订阅。”

“那模型第一次开口前,先要准备什么?”小a问。

“至少三样:系统规则、工具清单(含签名和描述)、以及已有的消息历史。”老z说,“这些要在第一轮请求前就装配好。工具清单如果和真实执行器对不上——比如签名写错、描述过时——模型要么不调用,要么调用后校验失败,第一轮就卡住。装配上下文是一层独立的代码,它的职责是‘这一轮给模型看什么’,循环本体的职责是‘这一轮之后往哪走’,两者经常被写进同一个函数,排障时反而分不清是谁的问题。”

“那怎么判断该往哪走?”

“看这一轮输出里有没有工具调用。”老z说,“有,进入校验、审批、执行、回写;没有,就检查停止条件。这两条分支的成本差很多:只走模型的一条分支,花的是生成费用;带工具的分支还要叠上执行开销和副作用。以‘有没有工具请求’作为轮次分界,比让模型用自然语言说‘我还要继续’可靠得多。”

“那为什么不把整条任务一次想完,非要一圈圈转?”小a问。

“因为模型一次能看到和执行的是有限的一步。”老z说,“多步任务里,后面几步依赖前面工具的真实结果——文件改完长什么样、测试跑完通过没有,这些只有执行之后才知道。想提前‘在脑子里’把这些全部算完,等于让模型在没有任何外部证据的情况下编造后续,正是幻觉最容易冒出来的地方。循环的本质不是重复,而是让每一步决策都基于上一步的真实结果。”

“那一圈圈转,时间不是更长吗?”

“确实更长。”老z说,“每轮至少一次模型往返,加上工具执行时间,一个五步的任务比一次问答慢很多。换来的是可审计、可中断、可恢复——任务可以在任意一圈停下来而不丢失进展。这个取舍要提前算清楚:适合循环的是需要外部验证的多步任务,简单一步能答完的,硬套循环反而绕远路。”

比喻:手术巡回

模型提方案,程序把方案变成受控的动作;每一轮都记录,下一次决策基于真实发生的状态,而不是模型嘴上说的“完成了”。

9.2 消息是唯一可审计的状态 ​

“那每一轮的结果都记在哪?”小a问。

“常见消息包含系统规则、用户输入、模型响应和工具结果。”老z说,“具体角色名称与字段因协议而异,但必须保留谁提出了什么调用、哪个执行器返回了哪个结果、结果是否成功或被拒绝的关联关系。不要只把自然语言摘要留在历史中,否则无法可靠恢复或审计副作用。”

“模型不是已经看过工具结果了吗,为什么还要保存?”小a问。

“因为下一轮、重启恢复和事故审计都需要同一事实记录。模型上下文是临时工作台,应用状态才是控制循环的依据。”

“那保存的是原始结果还是摘要?”小a追问。

“两者都要,但要分开存。”老z说,“原始记录用于审计和排障——它告诉你‘当时到底发生了什么’;进入模型上下文的是受限的摘要或截断版,用于控制预算。不能用摘要替代原始记录,因为摘要可能丢失‘失败原因’‘截断标记’这些排障时最需要的信息;也不能把原始记录全塞进上下文,那会撑爆预算。”

“那如果上下文被截断了,历史还能对得上吗?”

“这正是消息状态和上下文要分开的原因。”老z说,“消息状态是完整的、持久的,不随上下文截断而丢失;上下文是消息状态的一个受限视图,会被截断或压缩。截断上下文,不等于删除历史——历史还在消息状态里,只是这一轮没让它进入模型的视野。”

“那每条消息至少得记哪些字段?”小a问。

“够还原事件的就够了。”老z说,“角色或来源、内容、工具调用标识、对应结果、结果状态(成功、失败、拒绝)、时间戳和所用模型版本。标识关联是命门:没有调用标识,就分不清哪条结果属于哪个请求;没有状态字段,就审计不出‘当时到底执行没有’。”

字段作用缺失时的问题
角色或来源区分系统、用户、模型、工具无法重建对话脉络
工具调用标识关联请求与对应结果分不清结果属于哪个请求
结果状态记录成功、失败、拒绝审计时还原不了事实
时间戳与模型版本定位事故、确认重放基线说不清当时用了哪个模型

“那这些记录会占用多少空间?”

“和上下文预算不是一回事。”老z说,“持久化消息通常落文件或数据库,成本远低于上下文 token;真正贵的是把全文喂给模型。所以两者分开存:消息状态按审计需要存,上下文视图按预算需要裁。五十轮对话的完整日志可能几千行,进入模型的只有摘要加最近几轮原文。”

“那存原始结果会不会有泄露风险?”

“会。工具结果可能含密钥、内部路径或敏感数据。”老z说,“所以消息状态和日志要分层授权、脱敏或加密:给模型看的摘要在装配时处理敏感字段,审计库保留原始记录但限制访问。把敏感信息直接写进上下文,相当于把权限交给模型输出,想回收就晚了。”

“那这些消息状态能不能用来‘重放’?”小a问。

“能,重放是审计最常用的一招。”老z说,“把消息状态按时间顺序导出来,逐条核对:‘这一轮模型请求了什么、工具返回了什么、宿主批准没有、结果写回没有’。如果状态里缺了某一条,重放就会在那一环断掉——断点所在,往往就是问题所在。重放的价值不在跑一遍,而在让缺口显形。”

“那消息状态记录到哪个粒度?”小a问。

“记录到‘动作’级就够了。”老z说,“每个工具调用、每次审批决定、每轮模型请求都算一个动作。粒度再细到字符级没有意义;粒度再粗,比如只记‘这轮完成了’,恢复时就不知道完成了什么。粒度跟着审计和恢复的需求走,两者需要什么就记什么。”

9.3 ReAct 是理解框架,不是强制流程 ​

“我听说有个叫 ReAct 的东西,是必须照做的吗?”小a问。

“不是。ReAct 原始论文研究推理轨迹与任务动作的交替,并让动作从外部来源取得额外信息。”老z说,“它可帮助理解工具结果如何改变下一步,但论文不能证明每一轮都必须输出可见‘思考’,也不能推出所有 Agent 都遵循同一顺序。计划驱动、工作流路由和人机协作都可以嵌入或替代这类循环。”

“那 ReAct 到底给了我们什么?”小a追问。

“一个理解循环形态的框架:观察(工具结果)如何影响下一步行动(模型决策)。”老z说,“它让你意识到,每一轮不是孤立的——上一轮的工具结果,是这一轮模型决策的输入。但‘理解循环形态’和‘必须按 ReAct 格式输出’是两回事。很多循环根本不输出可见的‘思考文本’,它们的推理藏在模型内部,外部只看到动作。”

“那不输出思考,会不会更不可控?”

“可见性是另一个维度。”老z说,“有些模型支持把推理过程显式输出(思维链),有些不支持。输出可见推理有助于审查,但不改变‘停止判定权在宿主’这个分工——不管模型想没想、想得对不对,何时算完成都由宿主控制。”

“那除了 ReAct,还有哪些循环形态?”小a问。

“常见的至少四种,别把它们当成同一件事。”老z列了一张表:

形态关键特征典型代价
ReAct 式观察工具结果后交替决策每轮一次模型请求,token 随轮数线性涨
计划驱动先产出计划,再逐步执行并校验计划可能与现实脱节,需中途重规划
工作流路由按阶段把任务路由到固定流程阶段间信息交接容易丢细节
人机协作关键节点停下等人确认延迟高,副作用风险最低

“那能混着用吗?”

“能,而且是常态。”老z说,“很多系统把工作流路由当骨架,把 ReAct 式循环嵌在某一阶段里,再把高风险动作交给人机协作收口。选哪种形态取决于任务的稳定性:步骤固定的任务适合路由,探索性任务适合观察—行动往复,不可逆动作留给人确认。‘必须照 ReAct 输出’不是设计原则,只是多种可能中的一种。”

“那 ReAct 式循环最容易在哪一步失控?”小a问。

“在‘行动’和‘观察’之间的缝隙。”老z说,“模型提了一个动作,工具执行了,但结果被忽略或没写回——下一轮模型还当它没发生,就会重复请求同一个动作。ReAct 的循环本身不会自动修复这种断开,把工具结果完整写回消息状态,比让模型‘记得’更重要。”

“那输出可见思考有没有成本?”

“有。思考文本也要算 token,越长越贵;而且在循环里,模型上一轮写下的可见推理会进入下一轮上下文,占用空间,也可能引导后续步骤往同一个方向走。”老z说,“可见性是一种可选的工程选择,不是默认的免费增益。”

9.4 一个正常路径与一个失败路径 ​

“那一个真实任务在循环里长什么样?”小a问。

“下面是文件修复任务的简化事件序列。”老z敲出一段:

text
用户:修复测试失败
模型:请求运行测试
工具:返回失败摘要
模型:请求读取相关文件
工具:返回受限文件内容
模型:请求修改并再次测试
工具:返回修改结果和测试通过
模型:报告完成

“那如果读文件失败了呢?”小a问。

“若读取失败,正确路径不是伪造内容,而是返回‘路径不存在’或‘权限拒绝’,让模型改路径、请求帮助或停止。”老z说,“若同一失败重复出现,循环应识别重复状态并终止或升级给人,而非继续消耗资源。”

“怎么判断‘重复’?”小a追问。

“看状态是否在原地踏步。”老z说,“如果连续几轮模型请求的都是同一个工具、同样的参数、得到同样的失败,这就是重复状态。检测它需要一个简单的计数器或状态指纹——比较‘这一轮的请求+结果’和上一轮是否实质相同。重复检测的价值不是惩罚模型,而是止损:一个陷入重复的循环,继续转下去只会烧预算,不会产出结果。”

“那‘升级给人’具体指什么?”

“把控制权交还给人。”老z说,“循环暂停,记录‘已尝试 N 轮,连续 K 次相同失败’,等待人决定是修改任务、调整工具,还是放弃。升级不是失败,是一种有控制的停止——它比无限循环或静默出错都更可取。”

“除了‘路径不存在’,还有哪些典型的失败?”小a追问。

“至少四类,表现和处理各不相同。”老z列了一张反例表:

失败类型表面现象应如何处理
目标不存在反复返回“路径不存在”计数并停止或升级,别无限重试
权限拒绝读取或写入被拒换路径、换身份或请求审批,避免循环重试
内容异常文件读到了,但内容不是预期的校验内容再使用,别把“读到了”当“读对了”
超时或中断工具没在时限内返回标记未完成,决定重试、降级或停止

“那‘读到了文件’算不算成功?”小a问。

“算工具成功,不算任务成功。”老z说,“工具成功只表示执行器完成了动作,比如返回了内容;任务成功还要模型校验内容是否符合预期。这两层经常被混成一个状态——工具返回正常就被当作任务完成,排障时日志里全是‘成功’,却说不清成功在哪一步失效了。”

“那正常路径上还有没有要留意的?”

“有。正常路径也要防两件事。”老z说,“一是模型报告的‘完成’未经验证——它说测试通过了,但循环没去确认测试结果,就把任务标成 completed;二是工具返回了部分数据——比如只读了文件的前 100 行,模型把‘读到了’当成‘读全了’,结论建立在残缺输入上。失败路径要处理异常,正常路径也要校验‘正常’是不是真的正常。”

9.5 停止与并发是安全条件 ​

“怎么防止它无限循环下去?”小a问。

“循环至少应限制总轮数、总 token 或费用、单轮超时、连续失败次数和用户取消。”老z说,“达到任一限制时,记录明确停止原因与已完成动作。模型的‘完成’文本不能覆盖这些外部约束。”

“那一次多调几个工具,是不是更快?”小a问。

“并发工具调用也不是默认优化。”老z说,“独立只读操作可并发;共享文件写入、数据库变更、部署和外部通信需要串行、锁、幂等策略或审批。并发前先画出资源和副作用,而不是只看模型一次提出了几个调用。”

“那每个停止条件,最后落到什么状态?”小a问。

“每个停止原因都应映射到一个明确的退出状态和一句可记录的说明。”老z列了一张表:

停止原因建议的退出状态循环留下什么记录
任务完成completed已完成动作列表 + 最终消息摘要
预算耗尽budget_exhausted用掉多少 token/费用、完成到哪一步
超时timeout超时前最后的动作与时间点
连续失败failed失败次数、最后一轮的请求与结果
用户取消cancelled取消来源、取消时正在执行的动作

“那用户取消是怎么生效的?”

“协作式,不是强杀。”老z说,“宿主向正在运行的调用或子进程发出 signal,请求它停下来;工具是否响应、多久响应,取决于工具的实现。取消是‘请求停止’而不是‘保证停止’——工具不响应时,宿主只能等待、设置宽限时间或最后强制终止,并把实际结果如实记录。”

“那记录里该写什么?”

“写两件事:‘取消已请求’和‘实际停止时间’。”老z说,“时间线对不上,审计就会误判——是工具响应了取消,还是它自己跑完的,全靠记录区分。示意图如下:”

text
宿主发出取消 ──> 工具收到 signal
                    ├─ 及时响应:执行中断,记录 cancelled
                    └─ 不响应:等待宽限 ──> 强制终止 ──> 记录 force_terminated

“那‘资源独立才能并发’具体怎么判断?”小a问。

“看读写对象。”老z列了一张矩阵:

操作组合是否可并发需要什么保障
读文件 + 读文件可并发无(只读)
写文件 + 写同一文件不可并发串行或文件锁
写库 + 写同一张表不可并发事务或锁
外部通信 + 外部通信视目标而定幂等或审批
读 + 写同一资源不可并发按序执行或版本检查

“这矩阵是死的吗?”

“是判断的起点,不是结论。”老z说,“同名文件、同一条数据库记录才是真正的冲突点;不同文件、不同记录之间通常互不相干。画资源依赖图时,粒度要落到具体对象,而不是工具名字。”

“那停止条件和预算冲突时听谁的?”

“听先到的那一个。”老z说,“比如任务完成了,但 token 也刚好快耗尽——按‘完成’走 completed,同时把剩余预算一并记录;反过来预算先耗尽、任务没完成,就按 budget_exhausted 走。两个条件都触发时,状态机只转移一次,别让两套逻辑同时写状态。”

要点

循环必须有停止条件和并发边界。前者防失控,后者防冲突——都不来自模型“自觉”。

9.6 循环应被建模为状态机 ​

“我怎么保证循环不乱转?”小a问。

“把循环写成状态机,能避免‘文本到了就继续’的隐式控制流。”老z说,“最小状态可包括 idle、requesting_model、awaiting_approval、running_tools、completed、failed 与 cancelled;只允许预先定义的转移。”

text
idle → requesting_model → running_tools → requesting_model
                 │              │
                 ├→ completed   ├→ awaiting_approval → running_tools
                 └→ failed      └→ failed / cancelled

“状态机有什么好处?”小a问。

“状态机的代价是实现和事件记录更复杂;收益是可以定义恢复规则、测试非法转移,并准确说明一个任务在中断时已经做了什么。”老z说,“并发工具应由资源依赖图决定,而不是由模型是否一次请求多个调用决定。”

“那状态机本身会出什么错?”小a问。

“三类常见错误。”老z说,“一是非法转移——工具还没执行完就跳进 completed,通常源于把‘模型说要完成’当成了转移条件;二是漏事件——某个工具返回没有触发任何转移,循环卡在原地;三是状态陈旧——并发工具的结果晚到,用的还是旧快照。前两类靠定义转移表堵住,第三类靠给每个事件带上来源和顺序标记。”

“那状态是不是越少越好?”

“没有定论,看恢复需求。”老z说,“状态少了,实现简单,但中断时说不清做到哪一步;状态多了,每条转移都要测试。一个务实的起点是本章那张最小集合,缺的再按任务补。”

“那每次转移之前要不要查预算?”

“要,检查点放在转移上,而不是放在文本里。”老z说,“比如 requesting_model → running_tools 之前查剩余预算,running_tools → requesting_model 之前查总轮数。预算检查挂在转移上,就不会出现‘这一轮超了但循环还在转’的情况。”

“那这张状态机能画得更细一点,带上守卫条件吗?”小a问。

“可以。”老z画了一版带检查点的:

text
idle ──> requesting_model(校验工具清单、预算 ≥ 0)
requesting_model ── 有工具请求 ──> validating_tools ──> running_tools
              │ 无工具请求
              ├─> 检查停止条件 ──> completed / failed
running_tools ── 结果写回 ──> requesting_model(轮数 +1)
              └─ 用户取消 ──> cancelled

“这个版本多了什么?”

“多了转移条件和每个状态的可观察产物。”老z说,“validating_tools 专管校验——签名对不对、权限够不够、是否要审批;校验结果本身也是一条事件,进审计。状态机不是越复杂越好,但该有的检查点缺失,比状态机复杂更危险。”

示意: 用户取消发生在工具运行中时,系统先请求中止,再记录“取消请求已发出”;若子进程不能立刻停止,不能伪称任务已取消。类似地,模型返回最终文本后仍需确认没有待处理的工具调用,才进入 completed。

9.7 一个受预算约束的状态机 ​

“能不能给我一张带预算的版本?”小a问。

“可以。”老z画了一张:

text
idle → requesting_model → awaiting_tool_decision → running_tools
  ↑             │                    │                   │
  └─────────────┴────────────────────┴───────────────────┘
requesting_model → completed | failed | cancelled | budget_exhausted

“那修复测试的正常路径和反例呢?”小a问。

“工作场景中,修复测试的正常路径是读取失败摘要、选择文件、修改、重跑验证,所有 tool result 都进入下一回合。”老z说,“反例一是连续返回同一‘路径不存在’:状态机应计数并停止或升级,而不是无限循环。反例二是取消发生在子进程执行时:记录取消已请求;若进程不能立即停,不能伪称已经回滚。”

“预算只有 token 一种吗?”小a问。

“不是。token/费用、轮数、墙钟时间和连续失败次数是不同的 budget;任一耗尽都应形成明确 stop condition。”老z说,“事件流让 UI、持久化和审计看到相同转移,代价是并发、取消和恢复实现更复杂。多个调用只有在资源独立时才可并发;同一文件写入或外部事务要串行、加锁或用幂等策略。”

“那这几种预算各自怎么耗?”小a问。

“触发条件不同,消耗节奏也不同。”老z列了一张表:

预算类型什么会耗尽它耗尽的典型后果
token/费用长输入、多轮重试、长输出请求失败或成本失控
轮数陷入循环、来回试探任务迟迟不收敛
墙钟时间单轮等待、工具执行慢用户等不到结果
连续失败次数同一错误反复出现资源被反复烧掉

“那预算应该在什么粒度上检查?”

“至少两个粒度。”老z说,“每轮开始前查总预算,工具调用前后查本次额度;budget_exhausted 对 UI、审计和恢复逻辑应是同一个可观测事实,而不是各自在日志里猜。把预算当成一等状态,而不是顺带的 if 判断,循环的边界才说得清。”

老z画了一条完整时间线:

text
t0  idle
t1  requesting_model(查总预算,通过)
t2  running_tools(工具 A 执行,返回结果)
t3  requesting_model(轮数 +1,仍有余量)
t4  running_tools(工具 B 超时,进入 failed)
t5  记录 stop reason=timeout,动作清单写入消息状态

“如果每一步都留下事件,恢复时能还原到什么程度?”

“能还原‘走到哪一步、为什么停、还剩什么没做’。”老z说,“要完全重放当时的中间状态,还要额外保存工具调用前后的一致性快照;没有快照,恢复只能从最后一条持久化消息继续。审计是知道发生了什么,恢复是能重新接上——这两个层次别混淆。”

“那恢复有没有优先级?”

“有,按副作用排。”老z说,“已经执行的写操作、已经发出去的外部通信,优先级高于尚未执行的计划——恢复时先记录‘哪些副作用已经发生’,再决定下一步是从断点重跑还是整体回退。恢复要回答的永远是‘已经发生了什么’,而不是‘模型打算做什么’。”

“那持久化本身放在循环的哪个位置?”

“放在每个状态转移完成之后,而不是每轮结束才写。”老z说,“工具执行完、结果写回消息状态,紧接着就该落一次持久化;模型还在请求中时不需要写。持久化的频率决定了断电能恢复到哪一圈——越频繁,丢得越少,代价是 IO 越多,两者要按任务价值取舍。”

小结 ​

Agent Loop 把"模型输出、工具执行、消息更新"串成一条能自我延续的反馈回路,而单次问答算不上 Agent——循环才是它区别于普通聊天机器人的本质。一个回合是一次模型响应加上它触发的所有工具结果;循环就在回合之间反复推进,直到某个停止条件被满足。停止条件不是可有可无的装饰:预算耗尽、任务完成、发生失败、外部取消,任何一种都会让循环停下来。一个没有明确停止条件的循环,最现实的下场就是在死循环里烧掉账单。事件和状态机让循环变得可观测、可调试,取消则通过 signal 协作式地请求停止——注意是"请求",如果工具不响应 signal,取消就不会真的生效。

这一章最关键的认知纠偏是:别信模型自称的"完成"。ReAct 的"观察—行动"往复有助于理解循环的形态,但循环的可靠性来自外部状态、失败路径设计、预算控制和并发管理,而不是模型说一句"我做完了"。模型说自己完成了,不代表它真的完成了——何时算完成,要由宿主的停止判定来控制。把这个判定权交给模型,等于把循环的刹车也交了出去,这是 Agent 设计里最危险的让步。

消息状态是循环唯一的审计依据,上下文只是它的一份受限视图。截断上下文不等于删除历史:完整记录仍留在消息状态里,只是没进入这一轮模型的视野。审计需要的是"谁提出了哪个调用、哪个执行器返回了什么、结果成功与否"的关联关系,连同时间戳和模型版本;原始记录与进入上下文的摘要要分开存,敏感字段还要单独授权或脱敏。ReAct 也只是循环形态中的一种,计划驱动、工作流路由、人机协作各有适用场景,多数系统把它们组合使用——选哪种取决于任务本身的稳定性,而不是模型输出了什么格式。

停止条件要映射成明确的退出状态,预算要分类型记账:token/费用、轮数、墙钟时间、连续失败次数各自有各自的耗尽节奏,检查点挂在状态机转移上,预算事件本身进事件流。并发只在资源独立时才成立,共享写入要串行、加锁或走幂等策略。取消是协作式请求:信号发出去,工具未必立刻响应,记录里要写"取消已请求"和"实际停止时间"两件事,不能把两者混成同一个"已取消"。模型说"做完了"、工具返回"正常",都不等于任务真的完成——工具成功只代表执行器完成了动作,任务成功还需要宿主对结果做验证。

动手核验 ​

为一个两工具的玩具 Agent 记录每轮事件:输入、模型请求、校验结果、工具结果和停止原因。刻意让工具返回“路径不存在”,确认它被写回状态且循环在达到重复失败阈值后退出。再尝试两个写同一文件的调用,确认执行器不会并发写入。