pi — 本课题摘录
读了哪几篇: 02-agent-loop(循环与工具调用)、03-harness-context(会话树、系统提示与压缩)。
其余三篇(统一模型层、编码 agent 工具集、终端界面库)本轮没读。
这一家在"最小实现"这一组里是最完整的一份:TypeScript,循环干净,而且把"循环"和"记住/装配/瘦身"分成了两层。
它对本课题回答了什么
决定四:双层循环 —— 内层管工具与插话,外层管追问
外层 while(true) ← 处理「本该停下,但有人排队追问」
内层 while(还有工具要跑 或 有插话消息待注入)
… 一个回合 …
内层退出后:
有追问? → 设为待注入,回到内层
没有 → 跳出外层 → 结束
(依据:Agent 库 · Pi · pi-agent-core:agent 循环与工具调用 —— runLoop 是双层 while——内层续跑条件是 hasMoreToolCalls || pendingMessages.length > 0(还有工具要跑或有 steering 消息待插),外层处理「本该停下但有 follow-up 排队」)
内层的续跑条件写得极清楚:"上一轮模型是否点了工具" 或 "是否有插话消息要插",任一为真就再转一圈。
它区分了两种"插话"的时机:
| 时机 | 什么时候 |
|---|---|
| 插话 | 干活途中插 |
| 追问 | 本要停下时追问 |
这个区分很实用。 前面 codex 是"排到下一回合开头"、cherry-studio 是"排队+让步+续接"—— pi 把它拆成了两种不同的队列,对应循环的两层。这是最清楚的一种建模。
决定一:转换边界只在"调模型那一瞬"
关键设计:循环内部一路用自己的消息类型,只有在调模型的前一刻才转成厂商认识的格式。 (依据:Agent 库 · Pi · pi-agent-core:agent 循环与工具调用 —— 循环内部一路用 AgentMessage,只在调模型前一刻才 convertToLlm 转成 provider 的 Message;这让 app 能往对话里塞自定义消息(UI 通知、状态卡)而不污染发给模型的内容)
好处很具体:应用能往对话里塞自定义消息(界面通知、状态卡),它们进历史,但发给模型前会被过滤掉。
这条跟 nanobot 的"发给模型的是副本"、deepagents 的"不改历史只做投影"是同一族, 但 pi 的实现最轻:不是复制一份再修,而是在类型层面就分开—— 历史里放的是"我们的消息",发出去的是"厂商的消息",中间有一次显式转换。
它还用类型系统的声明合并让应用能往消息类型里加自己的种类。
决定一(架构):循环之上再分一层
它把"这一回合怎么跑完"和"跨几十上百回合怎么活下来"明确分成两层。
上面那一层要管循环不管的四件事:
| 循环不管的事 | 说的是什么 |
|---|---|
| 关掉再打开 | 对话得存下来、能恢复 |
| 回到半小时前的岔路口试另一种改法 | 历史得能分叉、能回溯 |
| 第 80 回合把窗口塞满 | 老历史得自动压缩,否则模型直接拒绝请求 |
| 中途换模型、临时启用某个能力 | 这些变更得被记住、下回合生效 |
(依据:Agent 库 · Pi · Harness:会话树、系统提示、Skills 与上下文压缩 —— AgentHarness 是循环之上的一层,管循环不管的四件事——存下来能恢复、历史能分叉回溯、上下文撑爆前自动压缩、中途换模型或启用技能要被记住并下回合生效)
那一层的职责被概括成三个词:记住、装配、瘦身。
会话被存成一棵可分叉的、只追加的树。 于是"回到岔路口试另一种改法"是天然支持的。
每回合重新拼系统提示与消息——不是拼一次就固定。
对比 hermes-agent 的"拼一次就冻住整场会话"和 cowagent 的"每轮从磁盘重建": pi 是"每回合重新装配",但装配的输入是内存里的会话树,不是磁盘。 三家的取舍轴是同一根:改动即时生效 vs 前缀缓存命中。
决定三:工具失败要抛,不要把错误编码进结果
一条明确的接口约定:工具执行失败要抛异常,不要把错误编码进返回内容里。 (依据:Agent 库 · Pi · pi-agent-core:agent 循环与工具调用 —— AgentTool 的 execute 接口约定「失败要 throw,不要把错误编码进 content」)
这条跟 cline / haystack 的"错误即消息"不矛盾——它说的是分工: 工具只管抛,把"错误怎么变成给模型的消息"这件事收在循环里做。 这样错误的格式是统一的,不用每个工具各写一遍。
工具批次可以串行或并行,而且可以按工具粒度覆盖。
决定一补充:真正调模型的那一步是可替换的
循环与厂商解耦:调模型那一步是一个可替换的函数。 默认走本地实现,也能换成走自己的服务器。
这跟 rig 的"决策与 IO 分离"是同一目标的轻量版:rig 把所有 IO 都拿出去,pi 只把"调模型"这一处做成可替换。 对最小原型来说,pi 这个粒度更实际。
全程发事件流(各级生命周期),界面可实时渲染。
它的做法(可以抄的部分)
它对问题的判断值得原样记下来:
"这段循环 90% 的项目都在重复造轮子,而且容易写错——并发、中止、错误处理、插话。"
(依据:Agent 库 · Pi · pi-agent-core:agent 循环与工具调用 —— 文档明说这段循环 90% 的项目都在重复造轮子,而且容易写错:并发、中止、错误处理、插话)
这四个词就是"循环真正难在哪"的答案。 跟 kun 说的"全部工程量不在转,而在四类打断和一串跑偏纠正"是同一句话的两种说法。
它没回答什么
- 不用原生工具调用怎么办——它假设模型支持。
- 停止条件的细节——只有"模型不再要工具",没有轮数上限、连错熔断这些护栏的讨论。
- 压缩的具体预算——第三章提到压成"结构化摘要",但本轮没读到数字。
坑与代价
- 双层循环的代价是"哪一层该退出"要想清楚。 内层退出不等于结束,外层还要看有没有追问。写错就会出现"该停的时候没停"或"追问被吞掉"。
- 自定义消息类型进历史,意味着历史里有模型永远看不到的东西。 好处是界面能用同一份数据渲染,代价是"历史"这个词有了两个含义——回放和调试时要分清哪一份。
判断(无锚): 这个代价值得付。我们的循环里迟早要记"这一步花了多少 token""这个工具跑了多久",这些不该发给模型但该存。 如果错,会错在: 如果只有命令行、不做界面,那自定义消息没有消费者,徒增一层转换。那就先用纯厂商消息,等要做界面时再分。