跳至主要内容
Agent 是一个闭环控制系统

Agent 是一个闭环控制系统

我最早写的那一版 Agent,代码里只有一个 while 循环,跑 demo 时很漂亮:问它「查一下明天上海天气,顺便看看三号会议室空不空」,它调两次工具,最后吐出一段通顺的结论。上线第三天出事了——某个查询接口返回 200 和空数组,模型把「查不到」理解成「没有限制」,接着往下做了一串不该做的动作。日志里没有一行异常,链路是通的。

那次之后我不再用「跑通」评价 Agent。一次 LLM 调用近似纯函数,Agent 是带状态的闭环控制系统。控制系统该关心的不是某一步输出对不对,而是误差会不会被反馈放大。这个视角是后面十四篇的起点。

本系列第 1/15 篇 · 下一篇:规划器:任务分解与可校验计划

一次调用和一个循环,差的是状态

单个 LLM 请求可以抽象成 text_out = f(prompt, weights):给定同样的输入,输出分布基本一致,没有副作用,失败表现为超时、限流或格式错误。这类错误好查,因为它有明确的信号。

Agent 多出来的东西是环境。它每一步产生一个动作,动作改变外部环境或内部状态,环境把结果作为新的观测喂回模型,模型再决定下一个动作。写成递推关系就是:

TEXT3 行
1
2
3
o_t = E(s_{t-1}, a_{t-1})              环境把上一步动作的结果变成新观测
h_t = C(h_{t-1}, a_{t-1}, o_t) 上下文构造:决定这一轮给模型看什么
a_t = M(h_t) 模型基于上下文产出下一个动作

其中 E 是环境(工具、数据库、文件系统、外部 API),M 是模型,C 是上下文构造策略,h 是送进模型的那一段上下文,s 是真实世界状态。三个符号里最容易被忽略的是 C:绝大多数「Agent 变笨了」的问题,不是模型退化,而是 h 被构造坏了——历史被截断、观测被压得太狠、错误信息和正常结果混在一起。

这里有个很实际的推论:状态 s 和上下文 h 不是一回事。h 只是 s 的一张有损投影,而且每次循环都重新投影一次。把全部历史消息无脑拼进 prompt,看起来「状态都在」,实际上既没有结构也没有可查询性,还会在长任务里被窗口挤掉关键字段。我的做法是把状态外置成结构化记录,上下文按当前步骤需要去投影,后面第 13 篇讲记忆时会展开。

ReAct 循环的骨架,控制权必须在代码手里

ReAct 常被讲成一种提示词技巧,但工程上它是一个循环骨架:模型输出 Thought / Action,代码解析并执行 Action,把执行结果作为 Observation 回灌。关键在于循环、终止、重试、超时的控制权全在代码侧,模型只负责给出下一步动作。让模型自己决定「我是不是该停了」,等于把终止条件交给了一个概率输出。

PYTHON18 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def run_agent(task, max_steps=12, timeout_s=120):
state = State(task_id=new_id(), plan=None, effects=[], observations=[])
deadline = time.monotonic() + timeout_s
for step in range(max_steps):
if time.monotonic() > deadline:
return halt(state, reason="timeout")
ctx = build_context(state) # 投影,不是全量历史
action = model.decide(ctx, tools=TOOL_SCHEMA)
action = validate(action) # 类型、范围、权限
if action.type == "final":
return halt(state, reason="final", answer=action.answer)
if is_repeated(state.recent_actions, action):
inject_hint(state, "上一步动作重复,换一种策略")
continue
obs = execute(action, idempotency_key=make_key(state, action))
state.observations.append(compact(obs)) # 观测也要限长
trace.write(step, action, obs)
return halt(state, reason="step_limit")

这段骨架里有几个我认为不能省的细节。validate 在动作执行之前,不在模型输出之后;is_repeated 要比对归一化后的参数,否则带时间戳或随机 ID 的调用每次看起来都不一样,实际是同一个动作在原地打转;compact 是对观测做截断或摘要,一次数据库查询返回几万行时,直接回灌会瞬间吃掉窗口。

终止条件我一般显式列四类:模型给出 final、步数上限、总超时、以及外部中断(用户取消、预算耗尽)。另外还有一类隐藏条件:连续 N 步没有产生新的有效信息,就该提前停。步数上限不是兜底装饰,它是成本上界——没有它,一个「再查一次就能查到」的幻觉可以烧掉几十万 token。

状态机视角:把「跑偏」变成可检测的事件

把 Agent 写成四元组 (S, A, T, H):S 状态、A 动作集合、T 转移函数、H 停机谓词。这样做的好处是,很多含糊的问题会变成可检查的约束。比如「任务做完了吗」可以换成「S 里的 required_fields 是否全部非空且通过校验」;「能不能执行这个动作」可以换成「当前 phase 是否允许该动作类型」。

我存的状态大致长这样:

JSON10 行
1
2
3
4
5
6
7
8
9
10
{
"task_id": "t_8f31",
"phase": "collecting",
"plan_version": 3,
"cursor": 4,
"required_fields": {"date": null, "room_id": null, "approval": null},
"effect_ledger": [{"key": "t_8f31/s3/notify", "status": "committed"}],
"budget": {"tokens_used": 18400, "tokens_left": 11600},
"replan_count": 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,如果我把它的工具全部替换成返回空结果的桩,它会停下来,还是继续自信地往下走?

系列目录

  1. Agent 是一个闭环控制系统(本篇)
  2. 规划器:任务分解与可校验计划
  3. 工具调用协议:从 Schema 到幂等
  4. 拆开一条 RAG 链路
  5. 分块不是切字符,是切语义边界
  6. Embedding 选型:别只看榜单分数
  7. 向量数据库选型的四个坐标轴
  8. 混合检索:稀疏、稠密与分数融合
  9. 重排序:两阶段架构的收益与代价
  10. 上下文构建:召回之后才刚开始
  11. RAG 评测:没有评测集就没有优化
  12. Agentic RAG:把检索交给 Agent 调度
  13. Agent 记忆系统的读写与遗忘
  14. 多 Agent 协作:拓扑、通信与终止
  15. 上线之后:可观测性、成本与护栏

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#Agent #ReAct #架构

评论