pi agent loop · 解剖手册
0 / 9 章 ← 首页
01

浅层:所谓 Agent Loop,就是六行伪代码

LLM 自己不会动手——它只能输出文字和「工具调用申请」。让申请落地、把结果喂回去、再问一次「还要不要继续」,这个转圈的动作就是 Agent Loop。

剥掉所有工程细节,pi 的 agent loop 核心就是一个双条件 while:

心智模型 · 6 行版
while (true) {
  msg = callLLM(messages)          // ① 问模型:下一步干嘛?
  if (msg.没有工具调用) break       // ② 模型交卷 → 收工
  results = runTools(msg.toolCalls) // ③ 替模型干活
  messages.push(msg, ...results)   // ④ 干活结果写回上下文
}                                  // ↻ 回到①,直到模型不再要工具

为什么必须有这个循环

  • LLM 是无状态的——每次调用都要把完整历史塞过去,「上下文」本质是客户端自己维护的一个数组。
  • 工具调用是一次性的——模型说「帮我读 a.js」,读文件这件事必须由 loop 所在的进程完成,再把内容作为新消息追加进去。
  • 停止权在模型手里——loop 从不替模型决定何时结束;回复里没有 toolCall 块,循环自然退出。
你的进程 维护 messages 数组 + 执行工具 LLM 无状态 · 只看这次发来的历史 messages[] 全量历史 回复:文字 或 文字+toolCall 申请 有 toolCall? 执行它 → 结果 push 回数组 → 再问一轮 toolResult 写回
fig.01 — 整个 Agent 的心跳:问 → 干 → 喂回 → 再问,直到模型不再要工具
✦ pi 的特别之处 这个循环在 pi 里是一个纯函数runAgentLoop(prompts, context, config, emit, signal)。不碰全局变量、不知道类为何物、所有副作用通过 emit 回调往外吐事件。理解了「纯函数 + 事件外吐」这八个字,后面全是细节。
自测:循环什么时候结束?是谁决定的?
正常情况下由模型决定:assistant 回复的 content 里没有 type:"toolCall" 块,hasMoreToolCalls=false 且队列里没有待注入消息,循环退出。但有三处例外可以提前终止:回复 stopReasonerror/abortedshouldStopAfterTurn 钩子返回 true;整批工具结果的 terminate 都为 true。
✦ 本章通关条件(点击打卡)
不看答案能默写出 6 行伪代码
能说出「停止权在模型手里」的含义和三个例外
02

架构:一个循环被拆成了三层

pi 把「循环」拆成纯函数、状态机、产品壳三层,每层职责单一。这也是你以后自己写 agent 时最值得抄的结构决策。

代码位置体量职责知道什么 / 不知道什么
L1 纯函数
runAgentLoop
@earendil-works/pi-agent-core
/dist/agent-loop.js
552 行 双环调度、流式消费、工具执行管线、事件发射 只知道传入的 context 和 config;
不知道会话持久化、UI、扩展的存在
L2 状态机
Agent
…/pi-agent-core/dist/agent.js 421 行 持有 transcript、订阅者分发、abort 生命周期、steering / followUp 双队列 知道「当前这轮谁在跑」;
不知道压缩、重试、系统提示词
L3 产品层
AgentSession
@earendil-works/pi-coding-agent
/dist/core/agent-session.js
2686 行 系统提示词组装、扩展事件、自动压缩、错误重试、skill 展开、模型鉴权 知道一切产品语义;
通过钩子函数遥控 L1/L2
用户 / TUI prompt · steer · abort L3 · AgentSession —— 产品壳 扩展事件 before_agent_start 等 压缩 / 重试 compact · retry-once 系统提示词 skills · AGENTS.md 鉴权 / 模型 API key 校验 L2 · Agent —— 状态机 transcript 状态 messages[] · isStreaming 双队列 steeringQueue · followUpQueue 订阅分发 subscribe(event ⇒ …) L1 · runAgentLoop —— 纯函数(真正的心脏) 入:prompts + context + config(钩子) 出:AgentEvent 流(agent/turn/message/tool_execution × start·update·end) 双环 while 见第 03 章 createContextSnapshot() events 回流给订阅者
fig.02 — 下行是「控制流」(上层指挥下层),上行是「事件流」(下层只管往外吐)

为什么要拆三层

  • 纯函数可测试——L1 不依赖任何全局态,测试时喂假 streamFn 就能把整个循环跑遍,不用 mock 文件系统或终端。
  • 状态机可复用——L2 不知道「coding agent」这回事。你想做一个浏览器里的聊天 agent,直接拿 L1+L2 换个 UI 就是了。
  • 脏活全在壳里——压缩、重试、扩展这些高频变更的产品逻辑被隔离在 L3,靠 beforeToolCall / transformContext / shouldStopAfterTurn 这些钩子注入,心脏保持稳定。
⚠ 读源码时的定位口诀 看到「turn 怎么转、工具怎么执行」去 agent-loop.js;看到「消息队列、abort 之后状态对不对」去 agent.js;看到「为什么会突然压缩、systemPrompt 为什么变了」去 agent-session.js
自测:想在「每次调 LLM 前」偷偷改消息列表,应该改哪一层?用什么机制?
机制在 L1 定义config.transformContext 钩子,每个 turn 调 LLM 前触发),但实际注册发生在 L3(AgentSession 把扩展系统的 session_before_compact/上下文整理逻辑接进来)。这正是分层设计的味道:机制下沉,策略上浮
✦ 本章通关条件
能对着 fig.02 复述三层的职责边界
理解「机制下沉、策略上浮」并能在自己项目里复用
03

核心:runLoop 的双环走读(配步进器)

真正的循环长这样:外环处理「follow-up 追加任务」,内环处理「工具调用 + steering 插话」。下面是可以一步步踩着走的真实代码骨架。

pi-agent-core/dist/agent-loop.js · runLoop(节选注释版)
async function runLoop(ctx, cfg, signal, emit) {
  let firstTurn = true;
  let pending = await cfg.getSteeringMessages();  // ⓪ 启动前先看有没有插队
  while (true) {                                   // ── 外环:follow-up ──
    let moreTools = true;
    while (moreTools || pending.length > 0) {      // ── 内环:工具 + steering ──
      emit({ type: "turn_start" });
      ctx.messages.push(...pending); pending = [];  // ① 注入插话(在 LLM 调用前!)
      const msg = await streamAssistantResponse(ctx, cfg, emit); // ② 调 LLM
      if (msg.stopReason === "error" || msg.stopReason === "aborted")
        return emit({ type: "agent_end" });        // ③ 失败即终止,不吞错
      const calls = msg.content.filter(c => c.type === "toolCall");
      moreTools = false;
      if (calls.length) {
        const batch = msg.stopReason === "length"     // ④ 截断的参数不可信
          ? await failAllToolCalls(calls, emit)
          : await executeToolCalls(ctx, msg, cfg, emit);
        ctx.messages.push(...batch.messages);        // ⑤ 结果写回上下文
        moreTools = !batch.terminate;                // ⑥ 整批 terminate 可提前收工
      }
      emit({ type: "turn_end", message: msg });
      await cfg.prepareNextTurn?.(…)                // ⑦ 每 turn 后可换模型/换上下文
      if (await cfg.shouldStopAfterTurn?(…)) return; // ⑧ 外部闸门
      pending = await cfg.getSteeringMessages();    // ⑨ 再偷看一眼有没有新插话
    }
    const followUps = await cfg.getFollowUpMessages();
    if (followUps.length) { pending = followUps; continue; } // ── 外环续命 ──
    break;                                          // 真没活了 → agent_end
  }
}
外环 OUTER · follow-up 续命环 内环 INNER · 工具循环 ① 注入 steering 插话 在下一次 LLM 调用之前 ② 调 LLM(流式) transformContext → convertToLlm → streamFn stopReason? error / aborted ? ③ 直接终止 turn_end → agent_end content 里有 toolCall? 没有 → 内环出口 ④ 执行工具批 默认并行 · 结果按源顺序写回 stopReason=length → 全部判失败 详见第 04 章 ⑤ turn_end + 收尾钩子 prepareNextTurn · shouldStopAfterTurn 还有工具要跑 / 有插话 ↻ 内环出口 没工具 + 没插话 getFollowUpMessages() agent 本来要停了,有人排队吗? ⑥ agent_end 本轮彻底结束 有 → 当作 pending 进内环(外环续命)
fig.03 — runLoop 完整控制流。注意两条回流边:内环回到「调 LLM」,外环把 follow-up 变成新一轮内环

交互步进器:亲手跑一圈

下面是一场真实缩排的对话:「帮我看下 repo 结构 → bash ls → 总结」。点击下一步,观察左侧高亮代码行、右侧 messages 数组的增长和当前阶段说明。

— 按「开始」起跑 —
这场戏共 12 步:一次 steering 插话、两轮 LLM、一次工具执行、最后自然收敛。
(上下文为空)
0 / 12
自测:为什么 steering 消息要在「下一次 LLM 调用之前」注入,而不是等这一轮工具跑完随便找个空档?
因为 LLM 是无状态的——一旦下一次请求发出,这次对话的「事实」就定格了。插话必须赶在下一次请求组装之前进入 ctx.messages,否则模型永远看不到。这也是为什么 getSteeringMessages() 在循环开头和每个 turn_end 后各被调用一次:抓住每一个「即将调 LLM」的窗口。
✦ 本章通关条件
用步进器完整走完 12 步,且能预测每一步的高亮位置
能画出 fig.03 的双环结构(两条回流边不能少)
04

解剖:一次工具调用的五段流水线

「执行工具」不是一句 tool.execute() 就完事。pi 给每一次调用铺了五段流水线,每一段都是一个可插拔的安全阀。

① find tool 找不到 → 立刻报错 prepareArguments 预加工 validateToolArguments ② beforeToolCall 外部闸门(扩展/权限) { block:true, reason } 可附带 terminate 提示 ③ execute tool.execute(id,args,signal, onPartialUpdate) → tool_execution_update 异常被捕获 ≠ 崩溃 ④ afterToolCall 可改写 content/details/ isError/usage/terminate 逐字段替换 · 无深合并 ⑤ toolResult 消息 role:"toolResult" push 进 ctx.messages 成为下一轮 LLM 输入 并发规则(executeToolCalls 的分叉逻辑) · 默认 parallel:准备阶段串行(①②),执行阶段并发(③),结果按 assistant 里的源顺序写回 · 任一工具声明 executionMode:"sequential",或配置整体为 sequential → 整批退化为串行 · terminate 语义:只有当整批每一个结果都是 terminate:true,才会跳过下一轮 LLM 调用提前收工——一票不足以否决
fig.04 — 五段流水线。①②④ 都是同步检查/改写点,只有 ③ 会花时间;异常在任何一段都会变成 isError 的 toolResult 而不是抛出

两个容易忽略的防御性设计

✗ length 截断 → 整批枪毙 流式输出的 toolCall 参数是用「尽力抢救的 JSON 解析器」拼出来的。stopReason === "length"(撞到输出 token 上限)意味着参数可能静默截断却仍然解析合法——比如 rm 少了个目录。所以 pi 不尝试抢救,而是把本批所有 toolCall 全部判失败,附上「请重新发起完整调用」的错误信息,让模型自己重来。宁可浪费一轮,不赌一次误删。
✓ 错误是数据,不是异常 StreamFn 的契约写着:绝不允许 throw。网络错、限流、模型拒答……一律编码成 stopReason:"error" 的 assistant 消息向上传。工具执行同理:任何异常都被包成 isError:true 的 toolResult 喂回给模型。这让整个 loop 在类型层面不可能因为一次失败而失控。
自测:模型一次回了 3 个 toolCall,其中第 2 个被 beforeToolCall block 掉。另外两个还执行吗?循环停吗?
执行——block 只影响那一个调用(它会得到一条以 reason 为内容的错误 toolResult)。循环是否提前停看 terminate 投票:仅当 3 个结果全部 terminate:true 才终止;只要有一个 false(包括没设置),下一轮 LLM 照常发生,模型会看到 block 原因并自行调整。
✦ 本章通关条件
能按序说出五段流水线及各自的「逃生口」
能给同事讲清楚「为什么 length 要整批枪毙」
05

边界:消息的两副面孔(AgentMessage vs Message)

pi 全程使用自由的 AgentMessage(可以有自定义类型),只在「按下发送键」的一瞬间收窄成 LLM 认识的三种消息。这条窄门是整个设计的粘合剂。

AgentMessage[](内部通用) user · assistant · toolResult + custom(任意扩展类型) 带 timestamp / display / details 持久化到 session.jsonl 的就是它 两道工序 transformContext(可选) convertToLlm(必需) Message[](LLM 方言) 只剩 user / assistant / toolResult custom 类型在这里被过滤掉 ⚠ 只在 LLM 调用边界转换 · 内部数组从不被污染 每轮 turn 重新转换 → 中途随便改内部历史,下一轮自动生效 为什么要这道窄门:session.jsonl 可以存任何 UI 需要的花哨消息(思考卡、图表、扩展自定义块), 而 Provider API 的 schema 永远只需要认识三种角色。两边各自演进,互不拖累。
fig.05 — convertToLlm 是单向阀门:宽类型进窄类型出,且每个 turn 都重新执行
streamAssistantResponse · 每个 turn 的固定开场(节选)
async function streamAssistantResponse(context, config, …) {
  let messages = context.messages;
  if (config.transformContext)                       // 可选:内部宽类型的最后一道加工
    messages = await config.transformContext(messages, signal);
  const llmMessages = await config.convertToLlm(messages);  // 必需:收窄成三种角色
  const response = await streamFunction(config.model, {
    systemPrompt: context.systemPrompt,
    messages: llmMessages,
    tools: context.tools,
  }, { apiKey: await config.getApiKey?.(config.model.provider), … });
  // 之后就是对流的 switch:start/text_delta/toolcall_delta/done …
  // 每个增量事件都会更新 partialMessage 并 emit message_update
}
✦ 流式消费的小机关 流一开始,partial assistant 消息就被 push 进 context.messages,之后每个 delta 事件都是「原地替换数组最后一个元素」。好处:任何时刻去看上下文,它都是完整的(正在生长的最后一条也算数);坏处:中断时要记得清理半成品——pi 用 done/error 事件的 finalMessage 统一回填来兜底。
自测:扩展往历史里塞了一条 custom 消息(比如一张渲染卡片),下一轮 LLM 会看到它吗?
取决于 convertToLlm 的实现。默认实现会把非 user/assistant/toolResult 的角色过滤掉,所以模型看不到——但它仍在 session 历史和 UI 里。想让模型看见,要么把内容并进一条 user 消息,要么提供自定义 convertToLlm 把 custom 映射成 user。
✦ 本章通关条件
能说出两道工序的名字、可选/必需、各自干什么
理解「转换发生在边界、每轮重新做」的两个后果
06

并发:打断一门正在跑的任务(steering vs followUp)

agent 正在埋头干活,用户又发来一句话怎么办?pi 给了两条不同性格的通道:steering 抢方向,followUp 排下一单

steer() 插话转向followUp() 排队续命
注入时机下一个 turn 开始时、LLM 调用之前(内环开头 + 每个 turn_end 后轮询)agent 本来要停的那一瞬间(内环出口 → 外环判断)
效果改变本次任务的走向:「等等,先别动那个文件」开启新的工作:「干完这个再帮我看看 X」
队列模式默认都是 "one-at-a-time"(每次排水口只放最老的一条);可切 "all" 一次放光
上层路由AgentSession.prompt 检测到正在 streaming 时,要求显式指定 streamingBehavior: "steer" | "followUp",否则抛错——逼调用方想清楚意图
t turn 工具 队列 LLM turn 1 LLM turn 2 LLM turn 3(终答) bash read 🡒 steer:「先别删」 🡒 followUp:「然后看X」 注入于 turn 2 之前 ← 改变走向 若没有 followUp, agent_end 发生在 turn 3 之后 排水口A:turn_end 后 排水口B:本来要停
fig.06 — 同一条时间轴上,两种队列在两个不同的「排水口」泄洪。steering 抢在下次请求前,followUp 卡在停止点上
AgentSession · 正在流式时收到新输入的路由(节选)
if (this.isStreaming) {
  if (!options?.streamingBehavior)
    throw new Error("Agent is already processing. Specify " +
      "streamingBehavior ('steer' or 'followUp') to queue the message.");
  if (options.streamingBehavior === "followUp")
    await this._queueFollowUp(expandedText, images);
  else
    await this_queueSteer(expandedText, images);
  return;
}
自测:steer 的消息会不会打断「正在执行中的工具」?
不会。注入点只有两个:内环开头和每个 turn_end 之后——都落在「工具批次已收尾、下一次 LLM 还没发车」的间隙里。正在跑的工具不受影响(要停它得用 abort(),那是另一条通路)。所以 steering 的粒度是「回合间」,不是「毫秒级抢占」。
✦ 本章通关条件
能用一句人话区分 steering 和 followUp(改道 vs 加单)
记住两个排水口的准确位置,以及 one-at-a-time 默认值
07

韧性:出错之后发生了什么(L1 停机 + L3 自愈)

L1 的原则是「错了就停,绝不硬撑」;L3 的职责是「停下来之后,看看能不能不动声色地救回来」。两层配合出一个打不死的 agent。

L1 的三种停法

stopReason含义L1 的动作
"stop"正常说完,没有(或不再需要)工具内环退出 → 检查 follow-up → agent_end
"error"API 错误 / 限流 / 内容拒绝立刻 turn_end + agent_end,错误挂在消息上上抛
"aborted"用户按了 Esc / abort()同上,但保留已生成的部分内容
"length"撞到输出 token 上限不算致命:本批 toolCall 全部判失败后照常继续(见第 04 章)

L3 的自愈决策树

_handlePostAgentRun() agent_end 之后、彻底歇菜之前 是可重试错误? isRetryableAssistantError(限流/5xx…) 指数退避自动重试 auto_retry_start/end 事件 是上下文溢出 / 可恢复截断? isContextOverflow ∨ recoverableLength 溢出自愈(仅一次机会) 删尾消息 → compact → continue 上下文超阈值? shouldCompact(tokens, window, settings) 阈值压缩(不重试) 压完等用户自己继续 yes no / 重试也救不了 yes yes 三条防抖护栏 ① 溢出自愈只试一次,   二次溢出直接报告失败 ② 换过模型后,旧模型的   溢出不触发新模型的压缩 ③ 压缩边界之前的老 usage   不会再次触发压缩 (防止 stale 数据死循环)
fig.07 — agent 停稳后的四连问。注意所有自愈最终都汇入 agent.continue()——复用同一个 loop,而不是另起炉灶
agent-session.js · 自愈驱动的外层再入(节选)
async _runAgentPrompt(messages) {
  await this.agent.prompt(messages);
  // prompt 返回 ≠ 真结束:可能刚做完一次自愈,需要 continue 再入
  while (await this._handlePostAgentRun()) {
    await this.agent.continue();   // 重试 / 溢出压缩 / agent_end 处理器塞的新消息
  }
}
自测:为什么溢出自愈要先「删掉最后一条 assistant 消息」再压缩?
那条消息正是撑爆上下文的元凶(或是带着错误状态的半成品)。它留在 session.jsonl 里供回溯,但必须从 agent 的活动上下文里摘除,否则:① 压缩摘要把垃圾也编进去;② continue() 要求末尾是 user/toolResult,挂着 assistant 尾巴根本无法续跑。删尾巴 + 压缩 + continue,三步才构成一次完整自愈。
✦ 本章通关条件
能背出四种 stopReason 及 L1 对应动作
能讲出三条防抖护栏中至少两条的动机
08

俯瞰:这套设计教会我们的五件事

读完源码,值得带走的不只是「pi 怎么写的」,而是这些可以直接迁移到自己项目里的结构性决策。

#设计决策代价收益
1循环写成纯函数,副作用全部走 emit 事件多一层事件 plumbing任意 UI(TUI/RPC/网页)都能挂上来;循环本身可用假 streamFn 全路径测试
2错误即消息:StreamFn 与工具都不许 throw调用方要多检查 stopReason/isError控制流永远线性可推理,不会出现「半个 turn 悬在空中」的状态
3机制下沉、策略上浮:钩子在 L1 定义,语义在 L3 注册钩子签名要提前设计好心脏 552 行几乎不变,产品逻辑随意迭代
4防御性悲观:length 整批枪毙、terminate 一票否决制反着用(全票才停)、溢出只自愈一次偶尔多做一轮无用功杜绝「静默截断的参数被执行」「stale 触发的无限压缩」这类最难查的事故
5队列即 API:steering/followUp 是一等公民而非补丁调用方必须显式选行为「打断正在跑的 agent」从玄学变成两个语义清晰的排水口
✦ 一句话总结 pi 的 agent loop = 一个纯函数双环 + 两道消息窄门 + 两条排队通道 + 一棵自愈决策树。所有复杂度都被推到了「边界」上:LLM 调用边界(convertToLlm)、工具边界(五段流水线)、停止边界(三问自愈)。边界清晰,内核才能小。
自测:如果让你给自己的 agent 加「每轮结束后上报 token 消耗」,你会挂在哪个点?
首选 turn_end 事件(L1 已带 message.usage)——订阅即可,零侵入。要聚合统计就在 L2 的 subscribe 监听里累加。千万别去改 L1 的循环体:那是「策略入侵机制」的反面教材,升级 pi 时会被冲掉。
✦ 本章通关条件
能挑出表格 5 条中最打动自己的一条,并举一个自己项目的对应场景
09

终极测验:5 题验收

覆盖全部章节。答完看总分——4 题以上算出师。

final exam · pi agent loop 0 / 5
QUESTION 1 / 5