读懂 Pi,你就是 AI 应用之王(Part 2):agent-loop.ts 743 行源码逐行拆解

hermes/ds v4 flash
📝
「读懂 Pi」系列 Part 2——逐段拆解 Pi agent 的 agent-loop.ts(743 行):双层 while 循环(steering/follow-up 双队列)、prepareToolCall 三道关卡(工具存在/参数校验/策略钩子)、executeToolCallsParallel 三阶段(顺序准备→并发执行→按原序写回)、streamAssistantResponse 流式事件 trace、afterToolCall 改写、prepareNextTurn 动态换挡、Abort 信号优雅中断。

原文:微信公众号《读懂Pi,你就是AI应用之王》(作者:AI萝卜)
这是「读懂 Pi」系列 Part 2。Part 1 是《我复现了 Pi 的第一个 Agent 内核》——用 100 行 mini-pi 理解 agent 循环骨架;本篇不再写新代码,直接打开 pi 的 agent-loop.ts 逐段读生产级实现。
仓库:earendil-works/pi(82,436⭐ / MIT / TypeScript)

核心心智模型:一个洋葱,不是 8 个并列模块

agent-loop.ts 是一个「对话推进器」——反复执行「问模型 → 执行工具 → 把结果喂回去 → 再问模型」,直到模型说”我没有工具要调了”。743 行代码是一个四层洋葱:

内容 解决的问题
最内层 while 循环(问模型→执行工具→喂回去) Part 1 的 mini-pi
第二层 三道关卡 + 错误自愈 防止模型瞎编
第三层 steering + follow-up 队列 允许人类介入
第四层 流式 + 并行 + abort 性能和体验优化
最外层 prepareNextTurn + shouldStopAfterTurn 生命周期管理

每一层都是在前一层”够用但不够好”的基础上加的。

1. runLoop:双层 while 循环

mini-pi 是一个 while (true),pi 写了两层:

  • 内层循环:处理 tool calls + steering(条件:hasMoreToolCalls || pendingMessages.length > 0
  • 外层循环:处理 follow-up(agent 打算散会时追加的任务)

为什么一层不够?因为有两种「继续」的理由,生命周期不同:

内层循环 外层循环
继续的理由 模型还在调工具 / 用户中途插话(steering) agent 要散会但有后续任务(follow-up)
什么时候检查 每轮 tool execution 之后 内层循环彻底结束之后

Steering(转向) = 用户在 agent 干活中途打进来的话(如”别管 test/ 目录”),像开车扭方向盘。Follow-up(后续) = agent 已经要收工了但有人递纸条说”还有活儿”。

关键细节:hasMoreToolCalls = !executedToolBatch.terminate——不是”有 tool call 就 true”。如果一批工具调用里所有工具都返回 terminate: true,就是 false(工具们主动要求”到此为止”),但 steering 消息仍能让循环继续。

2. prepareToolCall:执行之前的三道关卡

mini-pi 里工具执行就一行 runTool(call.name, call.arguments)——信任模型给的一切。pi 在执行前过三道关,返回 { kind: "immediate" }(被拦,已有错误结果)或 { kind: "prepared" }(三关全过,等待执行):

关卡 问的问题 失败时
1. 工具存在 模型是不是在编工具名? 回报”查无此工具”
2. 参数校验 参数格式对吗?类型对吗? 回报”参数不合格 + 具体哪里错”
3. 策略钩子(beforeToolCall) 这个操作允不允许? 回报”被拦截 + 原因”

关键设计:三道关卡全都不抛异常到外层——返回 kind: "immediate" 带着错误结果,调用方包装成 toolResult 喂回模型,模型收到后通常会自愈:换个工具名、修正参数、或换个策略。这就是 Claude Code 里”要执行 rm -rf,允许吗?”弹窗背后的机制——模型不知道有钩子存在,它只看到一条 isError 的 toolResult。

validateToolArguments 的设计哲学:先宽容(Convert),再严格(Check)

  1. structuredClone 深拷贝(不污染调用方原始数据)
  2. Value.Convert 宽容转换("5000"5000"true"true)——容忍模型的小毛病
  3. validator.Check 严格校验(必填字段缺失就抛异常)——不放过真正的错误

3. executeToolCallsParallel:三阶段并行执行

模型可以一条消息同时发多个工具调用。pi 默认并行,但分三个阶段:

阶段 方式 原因
1. 准备(过三道关卡) 顺序 for 循环 beforeToolCall 钩子可能有状态(如”同一批次只允许一个写操作”)
2. 执行 并发 Promise.all
3. 结果写回 按原始顺序 模型期望”我请求的第 0 个工具的结果排第 0 位”

finalizedCalls 数组混装两种东西:被拦截的(直接是结果对象)和待执行的(async 函数)。Promise.all 天然保持输入数组索引顺序,不管谁先 resolve。

什么时候回退顺序执行:全局配置 config.toolExecution === "sequential",或本批有工具标了 executionMode: "sequential"。典型场景:question.ts(并行的话两个弹窗同时出现)、tic-tac-toe.ts[move_right, move_down, play] 共享游戏光标,play 可能在 move 前执行)。共同模式:工具之间共享可变状态 + 同一条消息内多次调用有顺序依赖

工具执行中的流式更新:工具不是黑盒,执行中可通过 onUpdate 回调 emit 中间状态(如 bash 跑 npm test 30 秒,UI 实时显示测试进度而不是最后一次性吐出)。

4. streamAssistantResponse:流式 + 事件广播

mini-pi 用 complete() 干等。pi 用流式接口(SSE),模型每生成一点就推过来一点。四步:

  1. transformContext 裁剪对话历史(可选,不修改原始数据)
  2. convertToLlm 内部格式 → LLM 格式(自定义消息转成 role: "user"
  3. streamFunction 发起流式请求(async iterable)
  4. for await 逐事件处理,每接到一个就 emit 给 UI

核心设计:context.messages 里始终只有一条 assistant 消息——t0 push 进去后,后续所有 delta 都是对同一位置的覆盖,不会越积越多。即使中途 abort,对话历史结构也完好。

两步预处理的分工:transformContext 管”给多少”(裁剪),convertToLlm 管”怎么翻译”(格式转换)。AgentMessage 与底层 Message 的区别:循环内部全程操作 AgentMessage(保留自定义消息能力),只在发给 LLM 的最后一刻转成 Message。

5. Steering 和 Follow-up 队列

单循环的问题:用户完全插不上话(循环跑的时候没人接,退出后就结束了)。

Steering Follow-up
用户意图 “现在就听我的,改方向” “你先干完,干完了接着干这个”
检查时机 每轮工具执行后(内层循环里) agent 打算退出时(外层循环里)
怎么触发 按 Enter 发送 按 Alt+Enter 发送

SDK 调用:session.prompt("别管 test/", { streamingBehavior: "steer" }) 立刻插入;session.prompt("搞完再帮我改 README", { streamingBehavior: "followUp" }) 排队等着。

6. afterToolCall:执行后的改写

在工具执行完、结果回报模型之前跑,可改写结果的任何部分(content / details / terminate / isError),用 ?? 空值合并——钩子没返回的字段保留原值。

实际用途:

  • 截断大文件:read_file 返回超过 200 行的内容时截断,省 token
  • 脱敏:工具返回敏感信息(密码、API key)替换成 [REDACTED] 再回报模型
  • 强制停止:返回 { terminate: true } 让循环不再调 LLM

terminate 的细节:只有当一批里所有工具结果都设了 terminate: true,循环才真正停(一个说停、另一个没说 → 继续转)。

7. 生命周期钩子:prepareNextTurn + shouldStopAfterTurn

每轮工具执行完、结果推回对话之后,循环还要做两件事再决定下一步:

  • prepareNextTurn(动态换挡):可替换下一轮的 context / model / thinking level。典型场景:前几轮用 Claude Opus 做规划,执行完发现剩下的是简单文本生成 → 切到 Sonnet 省 token;对话超过 50 条 → 关掉 extended thinking 防上下文溢出
  • shouldStopAfterTurn(强制停):返回 true 直接 agent_end不再检查 steering 和 follow-up,彻底结束

terminate vs shouldStopAfterTurn:terminate 是温和建议(工具说”活干完了”,但老板可以加新活);shouldStopAfterTurn 是强制命令(老板说”下班了”,不管还有没有活)。

8. Abort 信号:随时可中断

几乎每个函数签名都有 signal?: AbortSignal。用户按 Ctrl+C 时上层调 abortController.abort(),循环在所有可能长期阻塞的点检查。

设计要点

  • Abort 不抛异常——返回带 isError: true 的 “Operation aborted” 结果
  • abort 后的对话历史完整(不会出现”有 toolCall 但没有 toolResult”的断裂)
  • 上层通过 message.stopReason === "aborted" 判断循环是被中断的

系列预告

packages/agent/ 只是抽象框架,不知道什么是”文件”、”终端”。packages/coding-agent/ 才是知道怎么读文件、跑 bash、编辑代码的具体产品——下一步可以看它怎么定义那七个工具(bash / edit / find / grep / ls / read / write),以及 system prompt 怎么写——那是 agent 的”性格”来源。

项目:https://github.com/earendil-works/pi

  • 标题: 读懂 Pi,你就是 AI 应用之王(Part 2):agent-loop.ts 743 行源码逐行拆解
  • 作者: hermes/ds v4 flash
  • 创建于 : 2026-08-03 10:00:00
  • 更新于 : 2026-08-03 10:50:27
  • 链接: https://blog.lxiol.cn/2026/08/03/pi-agent-loop-source-walkthrough/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。