吃透 AI Agent 开发 · 第 07 篇 · 第二章 · Agent Loop
当 Loop 加入中断、重试、并发和后台任务后,单个布尔值 done 已经不够。显式状态能让行为可测试。
当 Loop 加入中断、重试、并发和后台任务后,单个布尔值 done 已经不够。显式状态能让行为可测试。
“正在处理”其实包含很多意思
用户按下 Ctrl+C 时,界面上的“处理中”可能指模型还没返回、工具已经启动、结果正在写文件,或者主流程已经收到取消但后台进程没停。一个 isRunning = true 无法承载这些差异,最后所有分支都只能猜。
状态机的价值不在于画出漂亮的圆圈,而是把“现在允许做什么”写成可验证的协议。状态越具体,代码越容易拒绝非法跳转;事件越明确,日志越容易重放。
先定义状态,再定义动作
stateDiagram-v2
[*] --> idle
idle --> requesting: submit
requesting --> waiting_tool: tool_call
requesting --> completed: final_text
waiting_tool --> executing: policy_allow
waiting_tool --> rejected: policy_block
executing --> requesting: tool_result
executing --> unknown: timeout
unknown --> executing: inspect_status
executing --> cancelled: abort
requesting --> cancelled: abort
completed --> [*]
rejected --> [*]
cancelled --> [*]
这里的 unknown 很重要。外部命令超时,不等于命令没有执行;把它直接标成 failed,重试按钮就可能造成重复副作用。状态名应该描述证据能证明的事实,而不是描述程序最希望发生的结果。
转移表比大段 if 更可靠
| 当前状态 | 事件 | 下一个状态 | 必须保留的字段 |
|---|---|---|---|
requesting |
模型给出工具调用 | waiting_tool |
call id、工具名、原始参数 |
waiting_tool |
策略拒绝 | rejected |
规则编号、拒绝理由 |
executing |
外部返回成功 | requesting |
结果摘要、耗时、资源版本 |
executing |
超时 | unknown |
请求 id、目标、最后观测时间 |
unknown |
查询确认未执行 | requesting |
查询证据、可重试标记 |
type LoopStatus = 'idle' | 'requesting' | 'waiting_tool' | 'executing' | 'unknown' | 'completed' | 'rejected' | 'cancelled'
interface TransitionEvent {
type: 'submit' | 'tool_call' | 'policy_allow' | 'policy_block' | 'tool_result' | 'timeout' | 'inspect_status' | 'abort'
at: number
callId?: string
}
function canMove(from: LoopStatus, event: TransitionEvent['type']): boolean {
return new Set([
'idle:submit', 'requesting:tool_call', 'requesting:submit',
'waiting_tool:policy_allow', 'waiting_tool:policy_block',
'executing:tool_result', 'executing:timeout', 'unknown:inspect_status',
'requesting:abort', 'executing:abort'
]).has(`${from}:${event}`)
}
切片中没有偷偷完成状态修改,只有一个“这个事件是否允许出现”的判断。真正的状态转移应同时记录旧状态、新状态、事件和证据,避免出现日志说“已完成”而持久化仍停在执行中的矛盾。
两个经常被忽略的边界
第一,取消不是失败。用户主动取消时,要知道子进程是否已收到信号;如果没有,最终状态应该是“取消请求已发出,等待确认”,而不是立即显示完成。第二,重试不是回到起点。重试模型请求和重试写操作需要不同的幂等条件,状态机必须把它们分开。
追踪 q-code 的状态证据
| 追踪步骤 | 路径 | 要问的问题 |
|---|---|---|
| 1. Loop 主循环 | src/agent/loop.ts |
哪个事件推动下一次模型请求 |
| 2. 终端事件 | src/terminal/events.ts |
状态如何被界面消费 |
| 3. Agent 类型 | src/agents/types.ts |
后台任务有哪些终态和中间态 |
失败反例:异常路径直接 return
异常路径直接 return,留下未关闭的任务、半条消息或无法恢复的 UI 状态。短期看这是少写几行清理代码,长期看会变成幽灵任务:用户已经看不到它,后台仍在写文件,下一轮恢复又重复处理。
修复时先列出每个退出点的证据要求:模型请求是否结束、工具是否启动、外部动作能否查询、会话是否写入。只有证据齐全,状态才允许进入终态;证据不全就进入 unknown,把查询动作暴露给用户或恢复流程。
动手验证一张状态图
练习:为自己的 Agent 画出至少八个状态,并为每条箭头写事件名。然后写一个表驱动测试,故意发送三条非法事件:在 idle 直接发 tool_result、在 completed 再发 submit、在 unknown 直接重试写操作。测试结果应该能指出拒绝原因,而不是抛出一个无法定位的通用异常。
最后保存一份真实运行 trace,确认每个状态转换都能回到一条事件和一份证据。状态机的完成标准不是“图画完了”,而是故障发生后你知道下一步只能做什么。
把状态、事件和副作用拆成三样东西
状态机最危险的写法,是 reducer 里直接发网络请求:进入 executing 时顺手调用工具,调用失败又在 reducer 里重试。这样相同事件重放一次,就会重复副作用,也无法单独测试转移。
更清楚的模型是 reducer 只计算“新状态和待执行命令”,外层解释命令:
interface Reduction {
state: LoopState
commands: Array<
| { type: 'request-model'; requestId: string }
| { type: 'execute-tool'; callId: string }
| { type: 'inspect-effect'; callId: string }
| { type: 'persist-checkpoint' }
>
}
function reduce(state: LoopState, event: LoopEvent): Reduction {
if (state.kind === 'waiting-policy' && event.type === 'policy-allowed') {
return {
state: { kind: 'executing', call: state.call },
commands: [{ type: 'execute-tool', callId: state.call.id }]
}
}
return rejectIllegalTransition(state, event)
}
测试 reducer 时不执行真实工具,只断言事件产生什么命令;测试命令解释器时则用固定 callId 验证幂等和错误映射。状态机负责秩序,执行器负责现实,两边通过事件重新汇合。
unknown 不是错误桶,而是恢复入口
许多实现只有 completed 和 failed,于是凡是说不清的情况都掉进 failed。外部写操作超时后,failed 会诱导“再试一次”;事实上请求可能已经成功,只是响应在路上丢了。
进入 unknown 时必须保存能够核验的字段:操作目标、请求或幂等 ID、发起时间、最后一次收到的远端状态、允许的查询工具。恢复界面不应只给“重试”,而应先提供“检查状态”。
例如创建工单超时:系统使用 idempotency key 查询服务端;若找到工单,补写成功结果;若明确未创建,才允许重试;若服务端也不可用,继续保持 unknown。状态机承认不知道,反而比一个武断的失败状态更安全。
持久状态和界面状态不要共用枚举
UI 可能显示 thinking、running_tool、compacting,这些是用户体验状态;持久执行层则关心 model-requested、effect-started、effect-unknown、checkpointed。两套状态可以映射,但不必一一相等。
后台工具执行时,界面因为用户切换页面而变成 idle,不代表执行状态结束;进程恢复时,执行层还在 unknown,界面可以显示 recovering。若直接把 UI 的 status 写进 checkpoint,恢复逻辑会依赖某个组件的命名。
可以把映射保持为纯函数:
function toTerminalStatus(state: LoopState): TerminalStatus {
switch (state.kind) {
case 'model-requested': return 'thinking'
case 'executing': return 'running_tool'
case 'recovering': return 'recovering'
case 'failed': return 'error'
default: return 'idle'
}
}
这样增加 Web UI 只需另一份 projection,核心状态不跟着改名。
Checkpoint 应落在稳定边界
每收到一个流式 token 就持久化状态,写放大会很大,恢复价值却不高。只在会话结束时保存,又可能丢掉已经发生的副作用。实用 checkpoint 往往落在几个稳定边界:模型完整消息组装完成、工具策略决定完成、工具终态确认、任务节点完成。
checkpoint 需要 schemaVersion 和事件游标。加载旧版本时先迁移结构,再从最后一个已持久事件之后重放;不要因为解析失败就静默新建会话。恢复失败是用户需要知道的状态,不是应该藏起来的实现细节。
写 checkpoint 也可能失败。若工具还未执行,可以阻止副作用并提示存储不可用;若工具已经成功,则必须先保留执行证据,再报告“动作完成但状态保存失败”。两种顺序的风险完全不同。
表驱动测试只覆盖合法路径还不够
常规测试会列出 idle 到 requesting、requesting 到 executing 等正确箭头,但事故更多来自非法事件。可以自动枚举状态和事件的笛卡尔积,明确每个组合是允许、忽略还是拒绝。
“忽略”也要谨慎。重复的 tool-progress 可以忽略,completed 后收到同 callId 的成功结果可以幂等处理;completed 后收到另一个 callId 的结果则可能暴露串线,应该记录异常。把所有未知事件都忽略,会把协议 bug 变成安静的数据丢失。
测试还可以检查终态不变量:进入 completed 后没有 pending command;进入 cancelled 后所有前台 controller 已收到 abort;进入 unknown 后至少存在一种 inspection action。状态名称因此变成可执行承诺,而不是界面标签。
并发任务需要父子状态,而不是一个超级状态机
主 Agent、两个并行只读工具和一个后台测试,各自都有生命周期。把它们做成一个包含几十个组合值的状态机,很快会出现 parent-waiting-child-a-running-child-b-done 这类无法维护的枚举。
更好的方式是每个执行单元拥有自己的状态和 ID,父任务只维护依赖关系与聚合规则。例如两个只读检查都 completed 后,父节点才发出 children-settled;某个后台任务失败,可以按任务合同决定阻断或降级。局部状态机简单,组合逻辑留在任务图。
发生取消时也按树传播:父 controller 取消前台子任务,明确标记为 detached 的后台任务继续;每个子任务最终独立写状态,父任务等待需要等待的那部分。这样“取消主回答但让测试继续跑”才有清楚语义。
从一条脏 trace 恢复
假设事件文件最后三行是 effect-started、abort-requested、半截 JSON。读取时应保留前两条有效事件,报告尾行损坏,再根据 effect ID 查询真实状态。不能因为最后一行坏了就丢弃整个会话,也不能只看到 abort 就宣布已取消。
NDJSON 的优势正是在这里:逐行校验,损坏局部隔离。恢复器记录最后有效游标、损坏字节位置和下一步检查动作;修复完成后追加新事件,不回头篡改旧记录。事件历史既服务恢复,也服务事故追踪。
Event Sourcing 不是要求所有内存变化都写日志
只有会影响恢复、权限和副作用判断的领域事件值得持久化:计划批准、模型完整响应、工具开始/终态、任务状态、artifact 创建。光标闪烁、每个 token delta、spinner 帧属于视图事件,写入主事件流只会放大噪声。
持久事件先有 schema 和幂等 ID,再由 projector 生成当前状态。Projector 代码更新后,可以从旧事件重建新视图;领域语义变化则通过事件迁移或兼容 handler 处理,不篡改过去。
Snapshot 可加速长会话加载,但包含 lastEventId 与 hash。读取 snapshot 后只重放后续事件,校验不一致则回到较早 snapshot;snapshot 不是删原事件的理由。
用模型检查思路找状态图死角
状态不多时,可以枚举有限事件序列,检查不变量:completed 后不会产生 execute command;任何 effect-started 最终只能 completed/failed/cancelled/unknown 之一;unknown 必有 inspect action;cancelled 后无新 model request。
不需要引入重型形式化工具,属性测试随机生成 1000 条合法/非法事件就能发现很多遗漏。对最危险的 write timeout,再手动画所有交错:timeout 与成功结果同时到达、cancel 与 result 竞态、checkpoint 先后失败。
竞态处理使用 callId 和单调状态。unknown 后迟到的明确 success 可以转 completed并保留“曾超时”证据;completed 后迟到 timeout 不得倒退。规则写进 reducer,UI 和恢复器共享,不在各自 if 中发明。
状态名称要面向证据,不面向安慰
almost-done、probably-failed 不能指导恢复。使用 waiting-response、effect-unknown、cancellation-requested 等可观察名称,界面再翻译成友好文案。
用户看到“取消中,命令是否结束尚未确认”可能不如一个绿色“已取消”舒服,却避免他马上启动冲突任务。状态机的首要责任是保存事实,体验层在不篡改事实的前提下解释。