跳到主要内容

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""这个工具跑了多久",这些不该发给模型但该存。 如果错,会错在: 如果只有命令行、不做界面,那自定义消息没有消费者,徒增一层转换。那就先用纯厂商消息,等要做界面时再分。