读懂 Pi,你就是 AI 应用之王(Part 2):agent-loop.ts 743 行源码逐行拆解
原文:微信公众号《读懂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):
structuredClone深拷贝(不污染调用方原始数据)Value.Convert宽容转换("5000"→5000,"true"→true)——容忍模型的小毛病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),模型每生成一点就推过来一点。四步:
transformContext裁剪对话历史(可选,不修改原始数据)convertToLlm内部格式 → LLM 格式(自定义消息转成role: "user")streamFunction发起流式请求(async iterable)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 的”性格”来源。
- 标题: 读懂 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 进行许可。