我最早写的那一版 Agent,代码里只有一个 while 循环,跑 demo 时很漂亮:问它「查一下明天上海天气,顺便看看三号会议室空不空」,它调两次工具,最后吐出一段通顺的结论。上线第三天出事了——某个查询接口返回 200 和空数组,模型把「查不到」理解成「没有限制」,接着往下做了一串不该做的动作。日志里没有一行异常,链路是通的。
那次之后我不再用「跑通」评价 Agent。一次 LLM 调用近似纯函数,Agent 是带状态的闭环控制系统。控制系统该关心的不是某一步输出对不对,而是误差会不会被反馈放大。这个视角是后面十四篇的起点。
本系列第 1/15 篇 · 下一篇:规划器:任务分解与可校验计划
一次调用和一个循环,差的是状态
单个 LLM 请求可以抽象成 text_out = f(prompt, weights):给定同样的输入,输出分布基本一致,没有副作用,失败表现为超时、限流或格式错误。这类错误好查,因为它有明确的信号。
Agent 多出来的东西是环境。它每一步产生一个动作,动作改变外部环境或内部状态,环境把结果作为新的观测喂回模型,模型再决定下一个动作。写成递推关系就是:
1 | o_t = E(s_{t-1}, a_{t-1}) 环境把上一步动作的结果变成新观测 |
其中 E 是环境(工具、数据库、文件系统、外部 API),M 是模型,C 是上下文构造策略,h 是送进模型的那一段上下文,s 是真实世界状态。三个符号里最容易被忽略的是 C:绝大多数「Agent 变笨了」的问题,不是模型退化,而是 h 被构造坏了——历史被截断、观测被压得太狠、错误信息和正常结果混在一起。
这里有个很实际的推论:状态 s 和上下文 h 不是一回事。h 只是 s 的一张有损投影,而且每次循环都重新投影一次。把全部历史消息无脑拼进 prompt,看起来「状态都在」,实际上既没有结构也没有可查询性,还会在长任务里被窗口挤掉关键字段。我的做法是把状态外置成结构化记录,上下文按当前步骤需要去投影,后面第 13 篇讲记忆时会展开。
ReAct 循环的骨架,控制权必须在代码手里
ReAct 常被讲成一种提示词技巧,但工程上它是一个循环骨架:模型输出 Thought / Action,代码解析并执行 Action,把执行结果作为 Observation 回灌。关键在于循环、终止、重试、超时的控制权全在代码侧,模型只负责给出下一步动作。让模型自己决定「我是不是该停了」,等于把终止条件交给了一个概率输出。
1 | def run_agent(task, max_steps=12, timeout_s=120): |
这段骨架里有几个我认为不能省的细节。validate 在动作执行之前,不在模型输出之后;is_repeated 要比对归一化后的参数,否则带时间戳或随机 ID 的调用每次看起来都不一样,实际是同一个动作在原地打转;compact 是对观测做截断或摘要,一次数据库查询返回几万行时,直接回灌会瞬间吃掉窗口。
终止条件我一般显式列四类:模型给出 final、步数上限、总超时、以及外部中断(用户取消、预算耗尽)。另外还有一类隐藏条件:连续 N 步没有产生新的有效信息,就该提前停。步数上限不是兜底装饰,它是成本上界——没有它,一个「再查一次就能查到」的幻觉可以烧掉几十万 token。
状态机视角:把「跑偏」变成可检测的事件
把 Agent 写成四元组 (S, A, T, H):S 状态、A 动作集合、T 转移函数、H 停机谓词。这样做的好处是,很多含糊的问题会变成可检查的约束。比如「任务做完了吗」可以换成「S 里的 required_fields 是否全部非空且通过校验」;「能不能执行这个动作」可以换成「当前 phase 是否允许该动作类型」。
我存的状态大致长这样:
1 | { |
required_fields 这种字段的价值在于:它把「完成条件」从自然语言判断变成了代码里的布尔表达式。模型负责填值,代码负责判定。我在第 2 篇会把它扩展成可校验的计划。
从状态机角度再看开头那个事故:接口返回空数组,是一个合法的观测,但转移函数把它映射到了错误的下一状态。问题不出在 M,出在 T 缺少一条「空结果需要显式确认」的守卫。修法不是改提示词,而是在工具层把 [] 包装成带语义的观测对象,比如 {"status": "empty", "meaning": "查询无匹配,不代表无限制", "raw": []}。观测的语义要由工程侧定义,不能指望模型猜。
误差是怎么被反馈放大的
闭环系统最危险的性质是正反馈。第 t 步一个轻微误判,经过动作写进环境,第 t+1 步的观测就已经是「被污染的事实」,模型会在这个错误前提上继续做完全合理的推理。越往后,日志看起来越自洽,越难发现起点错了。
我遇到过三类典型的误差放大路径:一是语义误读,把 []、null、0、404 当成「没有约束」;二是不可逆副作用,动作已经发邮件、写库、下单,之后发现前面理解错了,回滚成本远高于重算;三是上下文漂移,长任务里早期约束被后来的大段观测挤出窗口,模型开始违反自己第 2 步定下的规则。
对应的护栏也分三层,且都必须前移到动作执行之前:
| 层级 | 检查对象 | 手段 |
|---|---|---|
| 输出层 | 模型给出的动作 | JSON Schema 严格校验、枚举约束、参数范围 |
| 语义层 | 动作与当前状态是否一致 | 完成条件、依赖检查、与用户原始约束对账 |
| 执行层 | 动作对外部世界的影响 | 幂等键、副作用账本、危险动作人工确认 |
第三层是唯一能防止「错得不可逆」的。分类、摘要、生成草稿这类动作重算最多花点 token;通知、支付、状态变更重放一次就是事故。我习惯在动手之前先列一张副作用表,把每个动作标成可重算、可覆盖、只能一次、不可逆,再决定检查点切在哪。
三种控制粒度的取舍
同一个任务,控制粒度可以差很多,选错粒度比选错模型贵得多。
单循环 ReAct:模型每步都看最新观测再决策,环境变化快、路径不确定时最稳。代价是每步一次模型调用,延迟和 token 随步数线性涨,而且没有全局视图,容易在局部反复试探。适合探索型任务,比如排查一个未知故障、在陌生代码库里定位问题。
Plan-and-Execute:先一次性生成结构化计划,执行层按计划跑,模型只在重规划时介入。省 token、可并行、可预测,但计划错了不容易中途回头。适合步骤明确、依赖清楚的任务,比如按固定模板生成报告、批量处理结构化数据。
分层控制:规划器、执行器、评审器分开,评审器只判断「这一步结果是否满足完成条件」。多一层模型调用,换来的是可恢复性——执行失败时可以在局部重试,而不是整体重跑。适合长任务、多 Agent 协作(第 14 篇)。
| 维度 | 单循环 ReAct | Plan-and-Execute | 分层控制 |
|---|---|---|---|
| 单步延迟 | 高 | 低 | 中 |
| 总 token | 随步数线性 | 较省 | 较高 |
| 中途纠偏 | 天然支持 | 需显式重规划 | 局部支持 |
| 可并行 | 弱 | 强 | 中 |
| 主要风险 | 原地打转 | 计划一次性错 | 层间状态不一致 |
我现在的默认选择是:短任务(预计 5 步内)用单循环,长任务先出计划再执行,只有涉及不可逆变更时才上评审层。别一上来就分层,多数「复杂」其实是工具没设计好,不是控制结构不够花哨。
这套视角的边界
把 Agent 当控制系统,收益是可观测、可限界、可恢复:每一步都有明确的动作、观测和状态快照,出问题能定位到具体转移。代价是样板代码变多,而且要维护一套与 prompt 平行的结构化状态,两边不一致时会很痛。对于一次就能答完的问答、纯生成类任务,这套东西纯属负担——没有环境反馈,也就没有闭环可言。
还有一种情况我会故意不闭环:只读的分析任务。让模型一次性读完材料给结论,比让它边查边想更便宜,也更少出错。判断标准很简单:这一步的动作会改变环境吗?不会,就别加循环。
这篇只把「循环」本身拆开了,循环里那个「下一步做什么」由谁定、怎么校验,是下一篇的事。你现在手里的 Agent,如果我把它的工具全部替换成返回空结果的桩,它会停下来,还是继续自信地往下走?
评论