青雲的博客
深入浅出 Pi 第三部:Agent Loop 如何继续 第 15 章

Steering 与 Follow-up 不是同一种排队

从 PendingMessageQueue、continue 分支与 runLoop 的两个 drain point 出发,解释中途纠偏与任务完成后追加为什么需要独立队列。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

两层循环把继续的理由分开以后,steer()followUp() 的差别就不再只是 API 命名。Steering 的语义是“当前工作做完这一轮后,先按新方向走”;Follow-up 是“当前工作本来要结束,再接着处理这条消息”。它们都接收 AgentMessage,却在不同的控制点被取出。

先排除一个常见误解:steer() 不是抢占式 interrupt。模型正在流式输出时,它不会取消 request;两个工具正在跑时,它也不会跳过剩余工具。消息只是进入内存队列,等这一轮 assistant response 与整批工具结果都完成、turn_end 已发出,循环才轮询 steering。

QueueMode 控制的是一次 drain

PendingMessageQueue 只有一个数组和一个 mode。enqueue() 总是尾插;drain()all 模式复制并清空全部消息,在 one-at-a-time 模式取最老的一条,保留其余;clear() 则直接清空。两个队列分别持有自己的 mode,默认都是 one-at-a-time

“每个 drain point”这几个字不能省略。假设 steering queue 里有 A、B 两条,mode 是 one-at-a-time:循环第一次轮询取 A,发起下一轮模型请求;那轮结束后再次轮询,又能取到 B。因此同一场 run 可以连续处理 A 与 B。它不保证 prompt()continue() 每次只消费一条,只保证两条不会在同一次注入中一起交给模型。

all 模式则会把 A、B 都作为 pending messages 依次发出 message events、追加到 context,随后只发起一次 assistant request。选哪种不是吞吐量开关,而是在决定模型看到两条用户输入以后,是统一回答,还是回答一条后再接收下一条。

Steering 的 drain point 在 turn 之后

底层循环启动时先读一次 steering。这能接住 run 建立前已经入队的消息。此后每个 turn 完成工具批次、发出 turn_end、应用 prepareNextTurn 并通过停止检查后,才再次读 steering。读到的消息在下一次内层迭代开头进入 context。

这个顺序带来两个可验证的边界。第一,shouldStopAfterTurn() 返回 true 时,循环在读取 steering 之前结束,队列里的消息仍留在 Agent 外壳中。第二,一轮中若 assistant 发出多个工具调用,这些工具先按本批策略全部处理,steering 不会插在两个 tool result 中间。Pi 的测试专门把 steering 安排在工具已经开始之后,并断言两个工具都先完成。

sequenceDiagram
  accTitle: Steering 与 Follow-up 的不同落点
  accDescr: Steering 入队后等待当前完整 turn 结束,在下一次模型请求前注入;Follow-up 继续等待到 Agent 已无工具与 steering 才注入
  participant U as Caller
  participant A as Agent queues
  participant L as runLoop
  participant M as Model and tools
  U->>A: steer(S)
  Note over A: S 只在内存队列
  M-->>L: assistant + tool batch 完成
  L->>A: drain steering
  A-->>L: S
  L->>M: context + S
  U->>A: followUp(F)
  M-->>L: 当前工作自然结束
  L->>A: drain follow-up
  A-->>L: F
  L->>M: context + F

Follow-up 要多等一个条件

Follow-up 不在每个 turn 后检查。只有 hasMoreToolCalls 为 false,steering 也为空,内层循环退出,外层才调用 getFollowUpMessages()。这意味着持续调用工具的 Agent 不会因为 follow-up 已入队就提前转题;当前任务先走到自然停止点。

外壳把两个 getter 接到两只独立队列:steering getter 调 steeringQueue.drain(),follow-up getter 调 followUpQueue.drain()steer() / followUp() 本身只 enqueue,既不发事件,也不把消息加入 state.messages。只有消息在 loop 中被注入并发出 message_end,上一章的 reducer 才会把它写进公开 transcript。

这两只队列也不属于持久会话格式。它们是 Agent 实例里的普通数组;reset() 会一起清掉,进程退出也没有恢复游标。后续章节看到 session JSONL 时,不应从“消息已经排队”推出“消息已经耐久保存”。是否落盘由上层在何种事件上写入决定。

assistant 尾部的 continue 是一个补洞分支

低层 continuation 要求 context 最后一条可转换为 user 或 tool result,不能从 assistant 尾部直接请求模型。Agent.continue() 因此先检查最后消息:若是 assistant,它优先 drain steering,其次 drain follow-up;拿到队列消息后,改走一次新的 prompt run。两边都空才报 Cannot continue from message role: assistant

Steering 分支还传了 skipInitialSteeringPoll。原因是它已经在 continue() 里 drain 过一次;新 run 一进入 runLoop() 又有一次初始 steering poll,如果不跳过,就会在第一次模型请求前多 drain 一条,破坏 one-at-a-time 的注入粒度。跳过只发生一次,首个 turn 结束后的正常 steering poll 仍会继续消费后续消息。

可以用下面的只读实验核对三处 drain,不需要安装依赖:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/agent/src/agent.ts |
  nl -ba | sed -n '123,157p;350,377p;434,468p'
git -C "$repo" show v0.83.0:packages/agent/src/agent-loop.ts |
  nl -ba | sed -n '163,174p;247,268p'

看到队列什么时候交出消息以后,下一步才轮到工具。工具调用也不是模型给出 name 和 arguments 就立即执行。Pi 会先判断整条响应是否被截断,再查找工具、准备参数、做 schema 校验并运行 hook;这些检查失败时,仍要产出模型可见的 tool result。下一章进入这条预检链。