第 41 章:运行中操控 Agent

追问不是“再来一条消息”这么简单。它跨过一条正在活动的并发边界,改变当前回答属于哪一轮,同时仍需遵守自己的交付契约。


快速通道(5 分钟掌握核心)

  1. 分清 acceptedcommittedcompleted——它们是三个不同时刻
  2. 在迭代边界取出追问,让模型在继续选工具前先看到它们
  3. 原子地关闭运行末尾的竞态:要么消费迟到追问,要么让它启动新运行
  4. 追踪每个答案属于哪条入站消息;合并后的一次运行可能产生多次回复
  5. 仅在回复成功送达后发送 delivery_ack,让交付失败的输入仍可重放

公开源码快照:实现细节取自 Kocoro 公开仓库 origin/main4ec6772 提交,复核日期为 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_ackclient.go L537
路由器入队与撤销排空共用一把所有权锁router.go L599

这些内容描述的是一个有日期的实现,不是普遍的消息传输契约。

41.9 常见陷阱

入队时就确认。 接受后、回复前一旦崩溃,消息就永久丢失。

工具执行期间追加追问。 下一个动作已经根据旧上下文选定。

返回前检查队列长度。 查看一眼无法关闭到达与撤销之间的竞态。

每次运行只追踪一个 ID。 合并后的轮次会把答案发给错误消息,或丢掉中间答案。

提交对话转录前就标记邮箱记录已消费。 崩溃后既没有排队消息,也没有对话记录。

用 Prompt 文本取消。 循环读到“停止”前可能又行动一次。

把重放当成传输错误。 重放正是未确认回复在交付失败后如何恢复。应按持久身份去重。

让追问悄悄改变 CWD。 当前运行已经拥有一个项目上下文。不同目标应开启新运行,而不是在一轮中途突变。

本章要点

  1. Accepted、Committed 与 Completed 是不同状态。 为三者命名,并各给一个所有者。
  2. 在下一次决策前取出追问。 新信息必须在另一个工具被选择前进入上下文。
  3. 原子地关闭路由。 迟到消息要么属于当前运行,要么属于新运行,不能两边都不属于。
  4. 为每个逻辑轮次寻址。 一个循环可以在多个入站 ID 下产生多个答案。
  5. 交付后再确认。 未确认重放是恢复机制,不是希望它消失的重复。

下一章:第 42 章提出对应的时间问题:运行中的一轮沉默时,究竟是哪个阶段拥有这段等待?

引用本文 / Cite
Zhang, Wayland (2026). 第 41 章:运行中操控 Agent. In AI Agent 架构:从单体到企业级多智能体. https://waylandz.com/ai-agent-book/%E7%AC%AC41%E7%AB%A0-%E8%BF%90%E8%A1%8C%E4%B8%AD%E6%93%8D%E6%8E%A7Agent/
@incollection{zhang2026aiagent_41_-_Agent,
  author = {Zhang, Wayland},
  title = {第 41 章:运行中操控 Agent},
  booktitle = {AI Agent 架构:从单体到企业级多智能体},
  year = {2026},
  url = {https://waylandz.com/ai-agent-book/%E7%AC%AC41%E7%AB%A0-%E8%BF%90%E8%A1%8C%E4%B8%AD%E6%93%8D%E6%8E%A7Agent/}
}