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

两层循环为什么缺一不可

沿 runAgentLoop 与 runLoop 的控制流拆开 run、turn 和 provider request,解释内层工具推进与外层 follow-up 恢复为何不能合并成一个条件。

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

上一章看到 Agent 用事件更新状态。事件里同时有 agent_startturn_start,这两个名字已经暗示:一场 run 不等于一轮 turn,更不等于一次 provider request。只要模型要求调用工具,Pi 就会把工具结果送回模型;同一个 prompt() 尚未返回,第二次 provider request 已经开始。

AgentEvent 对 turn 的定义很窄:一次 assistant response,加上它触发的工具调用与结果。run 则从 agent_startagent_end,可以覆盖多个 turn。用户最初提交的 prompt 属于 run 的新消息,但它不单独构成一个 turn;第一次 turn_start 在发 prompt 的 message events 之前已经发出,随后那次 assistant response 才完成这一轮。

入口只负责把 run 架起来

runAgentLoop() 复制初始 context,把新 prompts 接到运行内消息列表,然后依次发 agent_start、第一次 turn_start 和 prompt 的 message start/end。它没有自己写工具循环,而是把 currentContext、本次新增消息数组和配置交给 runLoop()runAgentLoopContinue() 走同一个 runLoop(),区别是它不追加 prompt,也不重发已有末尾消息的 message events。

这层拆分决定了返回值的语义。新 prompt run 的 newMessages 从 prompts 开始;continuation 的数组从空开始,只返回这次新产生的 assistant 与 tool results。它们都在共享的 context 上继续推理,但“这次调用新增了什么”并不相同。后面读 session 持久化时,这个差别会再次出现。

内层循环回答:这一轮之后还必须继续吗

runLoop() 先读取一次 steering queue,随后进入外层 while (true)。每次外层迭代把 hasMoreToolCalls 设为 true,于是内层至少运行一次。内层每次迭代做这些事:

  1. 除第一次外发出新的 turn_start
  2. 把 pending messages 作为 message events 注入 context。
  3. 请求一次 assistant response。
  4. 若响应是 error/aborted,立刻以 turn_endagent_end 结束。
  5. 执行这一条 assistant message 中的工具批次,把 tool results 接入 context。
  6. turn_end,应用下一轮 snapshot,检查 graceful stop,再读取 steering。

内层条件是 hasMoreToolCalls || pendingMessages.length > 0。其中 hasMoreToolCalls 不是简单地看 assistant 有没有 tool call。执行完工具批次以后,Pi 读取批次的 terminate 结论:只要批次没有要求终止,就让工具结果推动下一轮模型请求。与此同时,即便工具批次选择终止,只要 steering 已经排入 pendingMessages,内层仍会继续,让用户的新方向进入下一次请求。

flowchart TD
  accTitle: runLoop 的两层继续条件
  accDescr: 内层由工具结果或 steering 驱动,内层耗尽后外层才读取 follow-up;没有 follow-up 时发 agent_end
  START["run start"] --> INIT["initial steering poll"]
  INIT --> TURN["inner: start one turn"]
  TURN --> INJECT["inject pending messages"]
  INJECT --> MODEL["one provider request"]
  MODEL --> TERMINAL{"error / aborted?"}
  TERMINAL -->|yes| END["turn_end + agent_end"]
  TERMINAL -->|no| TOOLS["execute tool batch"]
  TOOLS --> TE["turn_end + prepare/stop checks"]
  TE --> STEER["poll steering"]
  STEER --> INNER{"tools need model or steering exists?"}
  INNER -->|yes| TURN
  INNER -->|no| FOLLOW["poll follow-up"]
  FOLLOW --> HAS{"follow-up exists?"}
  HAS -->|yes| TURN
  HAS -->|no| END2["agent_end"]

外层循环回答:本来要停了,是否又收到任务

内层只有在“不再需要用工具结果追问模型,而且没有 steering”时退出。此刻 Agent 按当前上下文已经能正常停止,runLoop() 才调用 getFollowUpMessages()。有 follow-up,就把它放进 pendingMessages,回到外层顶部,再进入一轮内层循环;没有才真正 break,并发出 agent_end

如果把 follow-up 也塞进内层末尾,代码表面上可以少一个 while,语义会变得含糊:系统无法区分“用户正在纠正当前工作”与“当前工作已完成,接着做另一件事”。两者都能产生新的 user message,却位于不同的调度点。Pi 用循环结构把这个产品语义写进控制流,而不是只在队列名字里留一条注释。

反过来,steering 也不能等到外层才取。模型调用了工具,工具结果本来会立即触发下一次请求;如果此时用户已经输入纠正消息,steering 必须在那次请求前与工具结果一起进入 context。它仍不会掐断正在执行的工具批次,只是在完整 turn_end 之后抢在下一次 provider request 前注入。

三种计数不要混用

读日志或写 UI 指标时,可以用下面的关系校准:

  • 一次 prompt() / continue() 对应一场 run,正常情况下各有一个 agent_startagent_end
  • 一个 turn_start 对应后续一次 assistant response;该 turn 可以有零个或多个工具调用。
  • 一次 provider request 通常对应一个 assistant response,但 provider 适配器内部的 transport retry 不会额外产生 Agent turn。
  • steering 或 follow-up 会增加 turn 数量,不会新建外层 Agent.prompt() 调用。

这不是文档约定的推测。直接提取固定版本的循环骨架即可核对轮询位置:

repo="${PI_SOURCE_DIR:-/tmp/pi-handbook-qbQTcA}"
git -C "$repo" show v0.83.0:packages/agent/src/agent-loop.ts |
  nl -ba | sed -n '155,275p' |
  grep -E 'while \(|turn_start|streamAssistantResponse|getSteeringMessages|getFollowUpMessages|agent_end'
Pi Agent 两层循环、turn 结束与串并行工具执行入口的源码定位结果

同一份 agent-loop.ts 同时暴露新 run、continue、共享 runLoop() ,以及串行和并行工具批次入口。图中行号证明这些职责共处一条控制链;它没有执行 Provider 或工具,异常、abort 与队列时序仍要结合后续测试阅读。

预期顺序是:初始 steering poll,外层 while,内层 while,assistant request,turn 后 steering poll,内层结束后 follow-up poll,最后 agent_end。这个实验只能证明控制流位置;具体排队一次取几条,还要看 Agent 外壳里的 PendingMessageQueue。下一章就处理这两个队列为什么名字相似、落点却不同。