第 41 章:运行中操控 Agent
追问不是“再来一条消息”这么简单。它跨过一条正在活动的并发边界,改变当前回答属于哪一轮,同时仍需遵守自己的交付契约。
快速通道(5 分钟掌握核心)
- 分清 accepted、committed 与 completed——它们是三个不同时刻
- 在迭代边界取出追问,让模型在继续选工具前先看到它们
- 原子地关闭运行末尾的竞态:要么消费迟到追问,要么让它启动新运行
- 追踪每个答案属于哪条入站消息;合并后的一次运行可能产生多次回复
- 仅在回复成功送达后发送
delivery_ack,让交付失败的输入仍可重放
公开源码快照:实现细节取自 Kocoro 公开仓库
origin/main的4ec6772提交,复核日期为 2026-07-27。章节讲的是不变量;具体常量应以当前源码为准。
41.1 从“完成”一刻到达的追问说起
一个 Agent 已经花十二分钟重构服务。就在它撰写最终答复时,用户发来:
另外更新数据库迁移,并重新运行集成测试。
有两个很直觉的实现,但都会输。
立刻开启第二次运行,会让两个循环并发修改同一工作区。把消息排到以后并允许第一次运行返回,用户会先收到“完成”,但要求的数据库迁移还完全没动。即使在返回前看一眼内存队列,竞态仍在:追问可能恰好在检查之后、路由撤销之前到达。API 报告接收成功,却再也没有任何运行拥有这条消息。
接着还有一个更隐蔽的错误。假设运行中的循环确实吸收了追问,渠道回复应该指向原始重构请求,还是“另外更新数据库迁移”?如果只追踪会话 ID,答案就会落到传输层碰巧记得的那条消息下面。
所以,运行中操控同时包含三份契约:并发、对话边界与回复寻址。
41.2 Accepted 不等于 Committed,也不等于 Completed
请使用三个动词,因为只用一个词正是消息消失的原因。
Accepted 表示路由器找到了活动运行,并把消息放进带缓冲的注入通道。InjectOK 说明所有权已经转移;它不表示模型已经看到文本。
Committed 表示循环在迭代边界取出了消息,构建出真正的用户轮次,把它追加到当前对话,并触发生命周期钩子。只有现在,排队的草稿才能变成普通用户气泡。如果消息来自持久邮箱记录,也只能在此时移除该记录——文本已经在对话转录里有了归宿,而不是刚刚进入内存的时候。
Completed 表示这条入站消息对应的答案已经送达。只有完成交付后,传输层才能删除它的重放记录。
accepted committed completed
router → inject queue → live user turn → model ... → reply delivered → ack
每条箭头都可能独立失败。让每种状态各有一个所有者,恢复才有可能;全都压成“received”,每次崩溃后大家只会争论这个词到底是什么意思。
41.3 在决策边界取出追问
当前运行拥有一个邮箱。每轮迭代开头,它以非阻塞方式取出可用追问,过滤已经撤回的消息,把剩余内容合成一个用户轮次,并在下一次主模型请求前追加进去。
这个边界是有意选择的。在工具执行期间取出追问,会在已经选定的动作下方修改上下文。等到下一次模型响应后才处理,Agent 又可能在看到用户纠正前执行一次昂贵或破坏性的工具。迭代边界正好位于前序效果已知、下一个动作尚未选择的时刻。
提交追问时还会更新 latestUserText,让 Skill 发现与延迟工具续接根据新指令,而不是原始指令推理。只修改对话转录,却让旁路系统继续读取旧用户文本,只完成了一半操控。
多条待处理追问可以合并成一个用户轮次。这是产品决策,不是传输层的偶然行为。如果希望每条消息各有一个答案,就不要批量合并;如果选择合并,就要定义答复归属哪个入站 ID,以及其他 ID 如何处理。
41.4 关闭最后一条消息的竞态
只在循环顶部取出追问还不够。最难处理的是 §41.1 的到达时机:模型正在组织终态答复。
简单检查 len(queue) > 0 无法关闭竞态。检查时队列可能为空,却在路由撤销前又收到消息。公开运行时使用由路由锁拥有的最终排空操作:
under one lock:
if a surviving follow-up exists:
drain it and keep the run open
else:
close the injection window
这样,竞态中的发送方只有两个合法结果。消息在原子排空前落地,由当前运行直接提交;或者发送方看到注入窗口已经关闭,开启一次新运行。它不可能先收到“accepted”,随后失去所有者。
撤回也必须位于同一边界内。用户取消的排队草稿不能穿过最终排空操作,重新打开一个本已结束的循环。“过滤后为空”意味着返回;“至少一条消息仍有效”意味着继续。
这是第 40 章更广泛的教训:持久性不只是写入状态,还要让所有权迁移足够原子,使恰好一个组件知道下一步该做什么。
41.5 每个答案都需要地址
一次物理 Run 现在可以包含多个逻辑用户轮次,所以“这次运行的消息 ID”已经不再够用。
循环从主入站 ID 开始。提交一条追问时,它把 replyCloudMessageID 推进到新消息的 ID,并将该 ID 记入待确认集合。如果一个已经完成的答案即将被另一条注入轮次覆盖,循环会在推进之前先捕获旧回复 ID,再通过 OnIntermediateAnswer 发出答案。
顺序是承重结构:
capture old reply target
→ commit superseding follow-up
→ deliver old turn's answer to old target
→ continue under new target
先推进,原答案就会显示在追问下面;不发送中间答案,它就会彻底消失;只按会话寻址,两个人快速发来的消息会塌成一条含糊的对话线索。
当多条消息被合并成一个组合轮次时,快照把组合答案分配给最后一个非空入站 ID,同时把更早吸收的 ID 保留在确认集合里。其他产品可以做不同选择,重要的是策略必须明确,并在公开传输边界上测试。
41.6 交付确认不是邮箱接收
入站传输采用 at-least-once 语义。Cloud 会把未确认消息保存在重放缓冲区,并在重连后再次交付。
delivery_ack 只有一个狭义含义:终态回复已成功交付给用户。Daemon 先发送回复,再确认所有被吸收的入站 ID。如果回复交付失败,它不会发送确认;这样重连时仍能重放输入,而不是默默丢掉用户从未看到的答案。
这正是前面措辞重要的原因。消息进入注入队列时不能发 delivery_ack,提交到上下文时也不能发;两个事件都发生在用户得到答案之前。
去重仍需要持久身份。重放可能再次交给 Daemon 同一个入站 ID;系统必须识别它是已有活动所有者、已有完成回复,还是确实需要另开一次运行。At-least-once 传输加幂等处理才能得到可靠交付;假装传输层是 exactly-once,只会得到重复。
41.7 追加、撤回与中断是三种操作
普通追问表示:保留当前运行,在下一次决策前加入这条信息。
撤回表示:删除一条尚未提交的排队消息。它需要客户端消息 ID,以及一枚能赢过延迟交付竞态的墓碑记录。
中断表示:停止当前运行。它通过取消信号传播,而不是放进 Prompt。把“停止”追加成文本,模型可能要到又选择一个工具后才读到——对真正的取消而言太晚了。
“中断并发送”又是另一种操作:取消旧运行,然后让替代请求拥有一次新运行。路由器把 cancelPending 状态的路由视为非活动,使替代请求不会撞上一个已经在退出过程中的循环。
应保持这些路径分离。带模式字符串的统一 steer API,往往会让取消操作等待邮箱排空,让撤回看起来像一条新指令,还让不同分支各自定义消息到底有没有被接受。
41.8 快照证据
| 观察 | 4ec6772 源码 |
|---|---|
| 追问被取出时才触发注入提交,而不是刚被接受时 | loop.go L670 |
| 注入消息分别携带邮箱、Cloud 与客户端身份 | loop.go L716 |
| 一个提交路径构建并追加真正的用户轮次 | loop.go L3106 |
| 普通排空操作发生在迭代顶部 | loop.go L3223 |
| 原子最终排空操作关闭运行末尾竞态 | loop.go L4213 |
| 回复目标推进到最后处理的入站消息 | loop.go L1498 |
| 待确认 ID 只在最终交付后确认 | loop.go L1520 |
仅当 SendReply 成功后才发送 delivery_ack | client.go L537 |
| 路由器入队与撤销排空共用一把所有权锁 | router.go L599 |
这些内容描述的是一个有日期的实现,不是普遍的消息传输契约。
41.9 常见陷阱
入队时就确认。 接受后、回复前一旦崩溃,消息就永久丢失。
工具执行期间追加追问。 下一个动作已经根据旧上下文选定。
返回前检查队列长度。 查看一眼无法关闭到达与撤销之间的竞态。
每次运行只追踪一个 ID。 合并后的轮次会把答案发给错误消息,或丢掉中间答案。
提交对话转录前就标记邮箱记录已消费。 崩溃后既没有排队消息,也没有对话记录。
用 Prompt 文本取消。 循环读到“停止”前可能又行动一次。
把重放当成传输错误。 重放正是未确认回复在交付失败后如何恢复。应按持久身份去重。
让追问悄悄改变 CWD。 当前运行已经拥有一个项目上下文。不同目标应开启新运行,而不是在一轮中途突变。
本章要点
- Accepted、Committed 与 Completed 是不同状态。 为三者命名,并各给一个所有者。
- 在下一次决策前取出追问。 新信息必须在另一个工具被选择前进入上下文。
- 原子地关闭路由。 迟到消息要么属于当前运行,要么属于新运行,不能两边都不属于。
- 为每个逻辑轮次寻址。 一个循环可以在多个入站 ID 下产生多个答案。
- 交付后再确认。 未确认重放是恢复机制,不是希望它消失的重复。
下一章:第 42 章提出对应的时间问题:运行中的一轮沉默时,究竟是哪个阶段拥有这段等待?