Appearance
从最小 Coding Agent 到 Pi:一个真实 Coding Agent 还需要什么
阅读提示
这篇文章内容比较长,包含多个章节。一次没有读完很正常,可以按照章节分几次阅读,每一节先理解当前讨论的问题即可。
为什么入门最后还要讲一次 Pi
到这里,「入门」栏目已经接近尾声。
前面我们从 Agent、Workflow、RAG 等基本概念开始,一直到自己实现一个最小 Agent,再进一步实现一个能够读取文件、修改代码、执行命令的最小 Coding Agent。
如果只是为了理解 Agent 最核心的运行方式,其实到这里已经足够了。
我们已经能够把一个 Agent 最基础的执行过程写出来:
text
todo
用户任务
↓
模型判断
↓
Tool Call
↓
执行工具
↓
Tool Result
↓
重新进入 Context
↓
模型继续判断甚至只需要一个很小的 while 循环,就能够让这套流程真正跑起来。
但上一篇做到最后,我们也留下了一串问题:
text
工具调用一直出错怎么办?
模型反复调用同一个工具怎么办?
Context 越来越长怎么办?
对话怎么持久化?
执行一半程序退出怎么办?
不同模型 Provider 怎么统一?
工具结果太大怎么办?
文件路径和命令权限怎么控制?
命令一直不结束怎么办?
如何支持流式输出?
如何让用户中途修改指令?
怎么做人工审批?
工具越来越多以后怎么管理?这些问题并不会改变最底层的 Agent Loop,却会直接决定一个 Agent 能不能从“可以运行”,继续走到“真正可以长期使用”。
所以在正式结束「入门」栏目之前,我还是想再多做一步:
不要只停留在我们自己写出来的最小实现,而是真正打开一个有人长期开发和使用的 Agent 项目看一遍。
我选择的是 Pi。
Pi 当前把自己定位成一个 minimal terminal coding harness。它并没有试图把所有 Agent 能力全部塞进核心,而是围绕 Coding Agent 最基础的执行链,继续补上模型接入、Agent Runtime、Session、Context、工具系统、流式事件和扩展能力。Pi 的官方仓库当前也明确拆成了三个主要层次:
text
pi-ai
→ 统一不同模型和 Provider
pi-agent-core
→ Agent Loop、工具调用和状态管理
pi-coding-agent
→ 真正面向用户的 Coding Agent它并不是把 Agent 最核心的思想全部推翻,然后换成一套完全不同的架构。
相反:
最下面的那层 Loop 依然存在。
真正增加的是围绕这层 Loop 的工程能力。
例如我们前面写 Tool 时,可能只是:
typescript
const result = await executeTool(toolCall);
messages.push(result);到了真实 Agent Runtime,就必须继续考虑:
text
参数是否合法?
一轮出现多个 Tool Call 怎么办?
这些 Tool 应该并行还是串行?
工具执行过程中能不能取消?
模型输出被截断以后,Tool 参数还能不能执行?
工具执行失败以后,下一轮 Context 应该怎么构造?Pi 当前的 agent-loop.ts 里,已经把这些问题真正放进了 Agent Runtime:模型响应、Tool Call、Tool Result、串行/并行执行、Abort、Steering、Follow-up 等都围绕同一条执行链组织起来。
再比如我们前面一直把:
typescript
messages.push(...)往后追加。
短任务没有问题。
但真实 Coding Agent 一次任务可能持续几十轮甚至更久,read 和 bash 又会不断产生大量内容。
Pi 对这个问题并不是简单“删掉旧消息”,而是引入了压缩 Compaction:
text
完整历史
↓
较老部分生成 Summary
+
最近一部分原始 Messages
↓
重新构建给模型的 Context完整 Session 历史仍然保留,只是下一轮不需要把所有内容重新发送给模型。
这篇文章,我们不会按照 Pi 的 README 从安装、命令、快捷键开始逐项介绍,也不是写一篇“Pi 使用教程”的文章。
接下来会一直拿前面实现的最小 Coding Agent 作为参照:
text
我们之前怎么实现
↓
为什么教学阶段这样已经够用
↓
到了真实项目以后会出现什么问题
↓
Pi 是怎么处理的
↓
为什么这样设计有些地方,我们会发现 Pi 做得远比前面的示例复杂。
但也有一些地方我们会发现:
Pi 并没有选择全部自己解决。
例如 Pi 本身并不提供一个完整的文件系统、网络和进程权限 Sandbox。默认情况下,它仍然使用启动 Pi 的用户和进程所拥有的系统权限;如果需要真正的隔离,官方建议使用容器或者 Sandbox。Pi 官方文档也把这类运行环境取舍放在了框架边界之外。
这其实同样值得我们学习。
因为一个真实 Agent 框架最重要的设计问题,不只是:
还能加入什么功能?
还有:
什么应该放在核心?什么应该做成扩展?什么应该交给运行环境?
Pi 的极简思想,很大一部分就体现在这些取舍上。
所以这一篇既是对前面几个最小 Agent 的一次补充,也是我们第一次真正从一个实际项目出发,看看:
一个能够运行的 Agent,距离一个真实 Coding Agent,到底还差些什么。
先认识 Pi:它到底是什么
Pi 官方描述为:
一个极简的终端 Coding Agent Harness。
这里有两个词比较重要:
text
Coding Agent
+
Harness前者很好理解。
Pi 和 Codex、Claude Code 一样,核心场景都是让模型进入真实代码环境:
text
读取文件
↓
执行命令
↓
修改代码
↓
根据结果继续工作真正值得注意的是后面的:
text
Harness准确地说,它提供的是一套让模型、工具、Context、Session 和用户交互能够持续运行起来的 Agent 执行底座。
Pi 官方也一直强调一个设计方向:
text
不要把所有可能的 Agent 功能
都默认塞进核心例如它并没有默认内置:
text
Plan Mode
Sub-Agent而是更倾向于提供一套相对小的核心,再通过 Extension、Skill、Prompt Template 等方式继续扩展。
这和我们前面实现最小 Coding Agent 时的思路其实非常接近。
Pi 为什么强调 Minimal Coding Harness
前一篇里,我们最终只给 Coding Agent 四个工具:
text
read
write
edit
bashPi 默认给模型的核心 Coding Tools,也是:
text
read
bash
edit
write也就是说,Pi 并不是依靠:
text
几十个内置工具
+
复杂 Planner
+
一堆专用 Agent才成为 Coding Agent。
它最底层仍然建立在一个非常简单的能力集合上:
text
读取
执行
修改
写入这四个能力已经足够让模型完成大量代码任务。
真正复杂的部分,并不是:
再增加多少个 Tool。
而是:
怎么让这几个 Tool 在真实项目里长期、稳定、可恢复、可扩展地运行。
这也是 Pi 最值得我们研究的地方。
Pi 的三个核心层:pi-ai、pi-agent-core、pi-coding-agent
Pi 当前最核心的结构,可以先压缩成三层:
text
┌───────────────────────────────────────┐
│ pi-coding-agent │
│ │
│ 真正面向 Coding Agent 用户的产品层 │
│ │
│ CLI / TUI │
│ Coding Tools │
│ Session │
│ Compaction │
│ Skills │
│ Extensions │
│ Context Files │
└───────────────────┬───────────────────┘
│
▼
┌───────────────────────────────────────┐
│ pi-agent-core │
│ │
│ 通用 Agent Runtime │
│ │
│ Agent Loop │
│ Tool Calling │
│ Tool Execution │
│ State │
│ Events │
│ Steering / Follow-up │
│ Abort │
└───────────────────┬───────────────────┘
│
▼
┌───────────────────────────────────────┐
│ pi-ai │
│ │
│ 模型与 Provider 层 │
│ │
│ Model │
│ Provider │
│ Auth │
│ Streaming │
│ Tool Calling │
│ Token / Cost │
└───────────────────────────────────────┘Pi 仓库 README 当前也明确把这三部分分别描述为:
text
pi-ai
→ Unified multi-provider LLM API
pi-agent-core
→ Agent runtime with tool calling and state management
pi-coding-agent
→ Interactive coding agent CLI这三个层次建议大家记住。
因为我们之前实现的最小 Coding Agent,其实已经隐约包含了这三层,只不过全部挤在了几个文件里。
先看最下面的 pi-ai。
我们之前直接写:
typescript
const client = new OpenAI({
apiKey,
});
const response =
await client.chat.completions.create({
model,
messages,
tools,
});这意味着我们的 Agent 和:
text
OpenAI SDK直接绑在了一起。
Pi 则把这一层单独拆了出去。
pi-ai 负责统一:
text
OpenAI
Anthropic
Google
OpenRouter
Bedrock
……这些不同 Provider。
而且它处理的不只是:
text
model.generate()还包括:
text
模型列表
认证
Streaming
Tool Calling
Thinking / Reasoning
Token Usage
Cost
不同 API 协议Pi 当前的 pi-ai 就是一个统一多 Provider LLM API。
所以:
text
pi-agent-core并不需要关心:
现在调用的是 OpenAI 还是 Anthropic。
它只需要面对一个相对统一的模型接口。
再往上一层是:
text
pi-agent-core这部分和我们前面写的:
typescript
while (true) {
const response = await callModel();
const toolCalls =
response.tool_calls ?? [];
for (const toolCall of toolCalls) {
const result =
await executeTool(toolCall);
messages.push(result);
}
}最相关。
它负责的就是 Agent 真正的执行循环。
只不过实际项目里,还要继续处理:
text
Streaming
Tool 参数校验
Tool 串行 / 并行
Tool Error
Abort
Steering
Follow-up
Turn 生命周期
Agent 生命周期这些我们后面会专门拆解。
最上面才是:
text
pi-coding-agent这一层真正知道:
我现在做的是 Coding Agent。
因此它才会提供:
text
read
write
edit
bash这些 Coding Tools。
同时这一层也会处理:
text
项目工作目录
Session
Compaction
AGENTS.md
Skills
Extensions
TUI
命令换句话说:
text
pi-ai并不知道“写代码”是什么。
text
pi-agent-core也不应该只为 Coding Agent 服务。
真正把:
text
模型
+
Agent Runtime
+
代码工具
+
终端交互组合起来的,是:
text
pi-coding-agent从我们的 Mini Agent 到 Pi
现在重新看前一篇写出来的项目:
text
mini-coding-agent/
├── index.ts
├── agent.ts
├── tools.ts
└── workspace/当时我们大概是这样分工:
text
index.ts
→ 接收用户输入
agent.ts
→ 模型调用
→ System Prompt
→ Messages
→ Agent Loop
tools.ts
→ read / write / edit / bash把它和 Pi 对照一下,会发现:
text
我们的实现 Pi
OpenAI SDK
│
└──────────────────────→ pi-ai
agent.ts
│
├─ Messages
├─ Agent Loop
└─ Tool Calling
→ pi-agent-core
tools.ts
index.ts
workspace
→ pi-coding-agent也就是说:
Pi 并没有把我们前面写出来的最小 Agent 推翻。
它做的第一件事,其实是把不同职责拆开。
我们的版本:
text
┌─────────────────────────────┐
│ agent.ts │
│ │
│ OpenAI SDK │
│ System Prompt │
│ Messages │
│ Agent Loop │
│ Tool Calling │
└──────────────┬──────────────┘
│
▼
read / write
edit / bashPi 则变成:
text
┌─────────────────────────────┐
│ pi-coding-agent │
│ │
│ Coding Tools │
│ Session / Context │
│ TUI / Extensions / Skills │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ pi-agent-core │
│ │
│ Agent Loop │
│ Tool Execution │
│ State / Events │
└──────────────┬──────────────┘
│
▼
┌─────────────────────────────┐
│ pi-ai │
│ │
│ Provider / Model │
│ Auth / Streaming │
└─────────────────────────────┘我们的 Demo Agent 可以把所有东西写在一起。
但真实项目随着功能增加,必须逐渐拆开:
text
模型层
Agent Runtime
业务 Agent否则以后每增加一个 Provider、一个 Tool、一个 Session 功能,都可能直接修改 Agent Loop。
而且 Pi 的这种分层还有一个很实际的好处。
比如以后我们想做的不是 Coding Agent,而是:
text
数据分析 Agent
运维 Agent
客服 Agent
研究 Agent理论上:
text
pi-ai
+
pi-agent-core这两层的大量能力仍然可以复用。
真正变化的主要是最上面的:
text
Tools
System Prompt
Context
产品交互这其实也是一个比较通用的 Agent 架构思想:
text
业务 Agent
↓
Agent Runtime
↓
Model Layer后续我们在其他框架仍然会再次见到这个思想。
不过看到这里还不能认为:
Pi 只是把我们的三个文件拆成了三个 Package。
真正的区别远不止目录结构。
例如我们的 Agent Loop 只有:
text
模型
↓
Tool
↓
Result
↓
模型但真实运行时会立刻出现一堆我们之前没有处理的问题:
text
模型一次返回多个 Tool Call 怎么办?
read 和 bash 能不能并行?
edit 和 write 还能并行吗?
Tool 参数生成到一半,模型输出被截断怎么办?
用户按下 Escape 时怎么终止模型和正在执行的命令?
Tool 执行过程中如何实时显示输出?
用户在 Agent 工作时又输入了一条新指令怎么办?这些问题,才真正让:
text
几十行 Agent Loop逐渐变成:
text
Agent RuntimeAgent Loop:真实项目里的循环没有那么简单
前面我们已经看过 Pi 的整体分层。
接下来直接进入最熟悉的一部分:
text
Agent Loop因为不管 Pi 外面再增加多少 Session、TUI、Skills 和 Extensions,Agent 真正开始执行任务以后,最底层仍然要解决同一个问题:
模型做完这一轮以后,下一步到底发生什么?
我们之前只有一个 while(true)
前一篇实现最小 Coding Agent 时,我们的核心代码基本可以压缩成:
typescript
while (true) {
const response = await callModel();
const toolCalls =
response.tool_calls ?? [];
if (toolCalls.length === 0) {
return response.content;
}
for (const toolCall of toolCalls) {
const result =
await executeTool(toolCall);
messages.push(result);
}
}这段代码已经能够跑通:
text
Model
↓
Tool Call
↓
执行 Tool
↓
Tool Result
↓
Messages
↓
Model对于演示完全够用了。
但如果继续追问几个问题,那么上面那个演示 Agent 就完全处理不了了。
比如:
text
模型请求了两个 Tool,应该一起执行吗?
Tool 参数不合法怎么办?
Tool 执行失败怎么办?
模型输出到一半被截断怎么办?
用户想中途停止怎么办?
模型正在工作时,用户又发来了一条指令怎么办?
这一轮结束以后,是否一定要继续下一轮?
什么时候才算整个 Agent Run 真正结束?所以到了真实 Agent Runtime,原本的一条循环需要承担更多责任。
Pi 的 Agent Loop 在处理什么
Pi 当前源码中真正的 Agent Loop 位于:
text
[packages/agent/src/agent-loop.ts](https://github.com/earendil-works/pi/blob/main/packages/agent/src/agent-loop.ts)如果只看最外层,它其实仍然没有脱离我们之前那套结构流程。
Pi 会先创建当前 Context,把新的 Prompt 加进去,然后开始整个 Agent Run。
源码中会先发出:
text
agent_start
turn_start随后进入真正的 runLoop()。
可以先把它简化成:
text
Agent Run 开始
↓
Turn 开始
↓
调用模型
↓
处理 Tool Call
↓
写入 Tool Result
↓
Turn 结束
↓
判断是否继续
↓
……
↓
Agent Run 结束这里出现了两个我们前面实现 Agent 时没有认真区分的概念:
text
Agent Run
Turn简单理解:
text
Agent Run
→ 用户这一次任务从开始到结束的完整执行过程
Turn
→ Agent Run 中的一轮模型调用以及随后发生的工具执行例如前面修复计算器 Bug:
text
用户:
修复测试失败的问题
↓
Turn 1
模型 → bash("npm test")
↓
测试失败
↓
Turn 2
模型 → read(...)
↓
文件内容
↓
Turn 3
模型 → edit(...)
↓
修改成功
↓
Turn 4
模型 → bash("npm test")
↓
测试通过
↓
Turn 5
模型 → 最终回答
↓
Agent Run 结束我们的最小实现其实也有这些 Turn。
只不过当时:
typescript
while (true)就全部包过去了,没有把生命周期显式表示出来。
Pi 则把这些生命周期真正变成了 Runtime 中的事件:
text
agent_start
turn_start
message_start
message_update
message_end
tool_execution_start
tool_execution_update
tool_execution_end
turn_end
agent_end这样上层 TUI、Session、Extension 等模块,就可以监听整个 Agent 执行过程中发生了什么。
这其实是从:
text
一个函数走向:
text
一个 Runtime非常重要的一步。
我们之前的实现只关心:
text
最后返回了什么而一个真实框架还需要让其他模块知道:
text
模型什么时候开始回答?
现在是不是正在调用 Tool?
Tool 执行到哪里了?
这一轮结束了吗?
整个 Run 被取消了吗?所以 Pi 的 Agent Loop 不只是:
“循环调用 LLM。”
它还承担了一部分:
整个 Agent Run 的生命周期调度。
Tool Call 不只是执行函数
我们之前执行 Tool 非常直接:
typescript
const args =
JSON.parse(
toolCall.function.arguments,
);
const result =
await executeTool(
toolCall.function.name,
args,
);可以理解成:
text
拿到参数
↓
找到函数
↓
执行但实际上 Runtime 不能默认:
模型既然生成了 Tool Call,就一定可以执行。
至少要先回答:
text
这个 Tool 存不存在?
参数是否合法?
参数是否符合 Tool Schema?
这一次 Tool Call 是否已经被取消?
这个 Tool 是否允许执行?
模型生成的 Tool Call 是否完整?Pi 的 Agent Loop 在工具执行阶段,会先做 Tool 查找和参数校验等准备,再真正进入执行。当前源码也直接使用 validateToolArguments 对模型产生的参数进行验证。
所以 Pi 源码中的流程其实是:
text
Tool Call
↓
查找 Tool
↓
校验 Arguments
↓
执行前处理
↓
真正执行
↓
整理 Tool Result
↓
写回 Context而不是简单:
text
toolName
↓
switch
↓
function()这里还有一个很典型的问题。
假设模型正在生成:
json
{
"path": "src/index.ts",
"oldText": "..."结果刚生成一半,就因为:
text
达到模型最大输出 Token响应被截断了。
某些情况下,流式 JSON 的恢复逻辑甚至可能仍然得到一个“看起来能解析”的 Tool Call。
如果只是我们之前的代码:
typescript
JSON.parse(...)能够成功,就可能直接执行。
但这里存在一个风险:
参数能解析,不代表参数生成完整。
Pi 当前专门处理了这个情况。
如果 Assistant Message 的 stopReason 是:
text
length也就是输出因为长度限制被截断,Pi 不会执行这一批 Tool Call。
而是给这些调用生成错误结果,大概是:
text
这次 Tool Call 没有执行,
因为模型响应达到 Token 上限,
参数可能已经被截断,
请重新生成完整 Tool Call。然后让模型下一轮重新决定。
这是一个非常现实的:
text
在我们的 Demo 中几乎不会考虑但:
text
真实 Runtime 必须考虑的问题。
一轮多个 Tool Call:串行还是并行
前一篇我们已经允许:
typescript
for (const toolCall of toolCalls) {
await executeTool(toolCall);
}也就是一轮模型可以返回多个 Tool Call。
但我们的处理方式非常简单:
text
全部按照顺序执行例如:
text
read("a.ts")
↓
完成
read("b.ts")
↓
完成
read("c.ts")
↓
完成这样当然没有错。
但假如三个文件完全没有依赖关系:
text
read("a.ts")
read("b.ts")
read("c.ts")理论上完全可以:
text
┌→ read("a.ts")
Model ──┼→ read("b.ts")
└→ read("c.ts")并行执行。
如果每个 Tool 都需要 500ms:
text
串行:
500 + 500 + 500
≈ 1500ms
并行:
max(500, 500, 500)
≈ 500ms差距很明显。
但问题又来了。
如果模型这一轮产生的是:
text
edit("a.ts")
write("a.ts")还能直接并行吗?
很危险。
因为两个 Tool 都在修改同一个文件。
执行顺序可能直接影响最终结果。
再比如:
text
bash("npm install")
bash("npm test")第二个命令可能依赖第一个命令完成。
所以不能简单得出:
多个 Tool Call 全部并行就一定好。
Pi 当前的 Agent Loop 同时支持:
text
Sequential
Parallel两种 Tool 执行方式。
源码中会先检查当前 Tool Batch:
text
这一批里是否存在要求 sequential 的 Tool如果全局配置要求顺序执行,或者其中某个 Tool 声明了:
text
executionMode === "sequential"就进入串行执行;否则可以进入并行执行。
逻辑大致可以压缩成:
typescript
if (
config.toolExecution === "sequential" ||
toolCalls.some(tool => tool.executionMode === "sequential")
) {
await executeSequentially();
} else {
await executeInParallel();
}这个设计背后有一个很重要的细节:
Tool 的执行策略,本身也是 Tool 能力定义的一部分。
有些工具天然适合并行:
text
read
grep
find
独立网络查询有些工具通常更需要保证顺序:
text
修改文件
执行有依赖的命令
改变外部状态所以:
text
Tool Calling并不只是:
“模型选择了哪个函数。”
还包括:
Runtime 应该如何安全、有效地执行这些函数。
Tool 出错以后为什么不能让整个 Run 崩掉
我们前面的 bash 已经特意做过一件事情。
命令执行失败以后,不是:
typescript
throw error;直接结束整个 Agent。
而是返回:
json
{
"ok": false,
"error": "..."
}然后继续写回 Messages。
这个思路其实非常重要。
因为从 Agent 的角度看:
text
Tool 失败很多时候只是:
环境给了模型一个新的 Observation。
例如:
text
bash("npm test")
↓
测试失败这不是 Agent 自己崩了。
恰恰是 Agent 获取到了很重要的外部信息。
再比如:
text
read("src/a.ts")
↓
文件不存在模型下一轮完全可以判断:
text
是不是路径写错了?
要不要先 ls?
要不要 find?
文件是不是还没创建?如果 Tool 一失败整个 Runtime 就退出:
text
Agent
↓
第一次行动失败
↓
整个任务结束那就失去了 Agent 根据环境反馈继续修正的能力。
Pi 也是按照这个方向设计 Tool Result。
它的 Tool Result 本身就包含:
text
isError错误结果仍然是一种正式的 ToolResultMessage,可以进入后续 Context,而不是必须以异常结束整个 Agent Run。Pi 的 Session Message 定义中也明确区分正常和错误 Tool Result。
可以理解成:
text
Tool 成功
↓
ToolResultMessage
isError = false
Tool 失败
↓
ToolResultMessage
isError = true然后:
text
都可以交回模型这其实和我们做普通业务程序的思路稍微有一点不太一样。
普通程序代码经常是:
text
Error
↓
异常流程而 Agent Runtime 中,大量 Tool Error 实际上应该是:
text
Error
↓
Observation
↓
模型重新判断当然,不是所有错误都应该继续。
例如:
text
模型 Provider 完全不可用
整个 Run 被用户 Abort
Runtime 自身内部状态损坏这些就属于另外一层错误了。
所以真实 Agent Runtime 还必须区分:
text
Tool Error
Model Error
Runtime Error
Abort而不是全部:
text
catch (error)以后直接返回一句“执行失败”。
Abort、Stop 和一次 Run 的生命周期
我们之前的最小 Coding Agent 还有一个很明显的问题:
typescript
while (true) {
await callModel();
await executeTool();
}一旦开始运行:
基本只能等它自己结束。
如果模型正在生成很长的内容怎么办?
如果 bash 正在运行一个很久的命令怎么办?
如果用户突然发现:
text
任务提错了想立刻停止怎么办?
真正的 Coding Agent 必须能:
text
Abort整个正在执行的 Run。
Pi 当前在 Agent Loop 中一直向下传递:
typescript
AbortSignal它会进入:
text
模型 Streaming
Tool Execution等真正需要取消的地方。
所以取消不是 UI 上显示“停止了”这么简单。
真正需要传播:
text
用户按下取消
↓
AbortSignal
↓
停止模型响应
↓
停止 Tool
↓
结束当前 Turn
↓
结束 Agent RunPi 在模型返回:
text
stopReason === "error"或者:
text
stopReason === "aborted"时,会结束当前 Turn,然后发出 agent_end,终止这一次 Run。
但这里还有另外一个概念:
text
StopAbort 是:
外部强制终止当前运行。
而正常 Stop 则是:
Agent 自己已经没有更多工作需要继续。
比如:
text
最后一轮模型:
“问题已经修复,测试通过。”
没有新的 Tool CallAgent 可以自然结束。
Pi 的 Loop 里还有:
typescript
shouldStopAfterTurn(...)这样的扩展停止点。
也就是说,一轮结束以后,Runtime 还可以根据外部策略判断:
text
即使模型还可以继续,
这一轮之后是否应该强制停止?因此一个更完整的 Run 生命周期,大概是:
text
Agent Start
↓
Turn Start
↓
Model Call
↓
Tool Calls?
/ \
有 无
↓ ↓
Execute Tools 正常结束?
↓ ↓
Tool Results │
↓ │
Turn End ←────────┘
↓
是否还有工作需要继续?
/ \
是 否
↓ ↓
下一次 Turn Agent End与此同时,任何执行阶段又可能收到:
text
Abort直接终止当前 Run。
看到这里,其实我们最开始那段:
typescript
while (true)并没有错。
Pi 最下面依然存在循环。
真正的变化是,我们原来隐含在几行代码里的东西:
text
什么时候开始
什么时候结束
工具怎么执行
失败怎么办
用户怎么取消
下一轮什么时候开始到了真实 Runtime 里,都必须变成明确的状态和规则。
所以可以把两边放在一起:
text
我们的 Mini Agent
while (true) {
Model
Tool
Result
}到了 Pi:
text
Agent Lifecycle
+
Turn Lifecycle
+
Streaming
+
Tool Validation
+
Sequential / Parallel Execution
+
Tool Result
+
Error Handling
+
Abort
+
Steering / Follow-up
+
Stop Policy但最底层依然是:
text
Model
↓
Action
↓
Environment
↓
Observation
↓
Model这也是为什么前面我们一定要自己实现一次最小 Coding Agent。
如果没有前面的 while (true),现在直接打开 Pi 的 agent-loop.ts,看到的可能只是一大堆:
text
Event
Config
AbortSignal
ToolResultMessage
Steering
Follow-up
PrepareNextTurn很容易迷失在工程细节里。
但现在再看,就可以知道:
这些代码没有改变 Agent 的核心,它们是在保护和完善那条最简单的 Loop 链路。
接下来我们再往下看 Tool。
Tools:四个工具背后其实还有很多工程问题
上一篇实现最小 Coding Agent 时,我们花了不少篇幅写四个 Tool:
text
read
write
edit
bashPi 默认提供给模型的核心 Coding Tools,同样也是这四个。
当前源码中的:
typescript
createCodingTools()直接返回:
typescript
return [
createReadTool(cwd, options?.read),
createBashTool(cwd, options?.bash),
createEditTool(cwd, options?.edit),
createWriteTool(cwd, options?.write),
];所以从表面上看,我们之前写出来的东西似乎已经和 Pi 很接近了:
但是继续打开具体实现以后,我们就会发现:
Tool 的名字可以很简单,但真正麻烦的是如何让它在真实环境里稳定工作。
我们之前重点解决的是:
text
模型能不能调用这个工具?Pi 还要继续解决:
text
文件太大怎么办?
命令输出几十万行怎么办?
工具执行过程中被取消怎么办?
两个 Tool 同时修改一个文件怎么办?
图片能不能读?
不同操作系统怎么办?
Tool 能不能换成远程执行?
Tool 结果如何展示给 TUI?
模型传来的参数不标准怎么办?这些才是 Tool 从 Demo 走向工程实现以后逐渐出现的问题。
我们和 Pi 都从 read、write、edit、bash 开始
我们先想一个问题:
为什么 Coding Agent 最小工具集经常就是这四个?
因为它们刚好覆盖了 Coding Agent 最基本的几个动作:
text
read
→ 观察已有代码
bash
→ 观察和改变外部环境
edit
→ 精确修改已有代码
write
→ 创建文件或整体重写组合起来就是:
text
Observe
↓
Act
↓
Observe
↓
Act例如:
text
bash("npm test")
↓
发现测试失败
↓
read("src/calculator.ts")
↓
找到问题
↓
edit(...)
↓
bash("npm test")所以真正决定 Coding Agent 能力上限的,并不只是:
text
工具数量而是:
text
这些基础工具设计得是否足够可靠Pi Agent 除了这四个默认 Coding Tools,目前还实现了:
text
grep
find
ls
powershell等工具,不过默认的 createCodingTools() 仍然保持 read / bash / edit / write 这套非常小的组合。
Tool Schema 和 Tool Implementation 依然分离
前一篇里,我们专门讲过:
text
Tool Schema
≠
Tool Implementation例如:
typescript
{
name: "read",
description: "读取文件",
parameters: {
path: ...
}
}是给模型看的。
而:
typescript
async function readTool() {
return await readFile(...);
}才是程序真正执行的。
Pi 依然保持这层区分,只不过封装得更完整。
以 read 为例,首先定义参数:
typescript
const readSchema = Type.Object({
path: Type.String(...),
offset: Type.Optional(Type.Number(...)),
limit: Type.Optional(Type.Number(...)),
});然后 createReadToolDefinition() 里同时提供:
text
name
description
parameters
execute()最后再通过:
typescript
createReadTool()包装成 Agent Runtime 真正使用的 Tool。
可以把它理解成:
text
Tool Definition
│
├─ 模型看到什么
│ ├─ name
│ ├─ description
│ └─ parameters
│
└─ Runtime 怎么执行
└─ execute()比我们之前:
text
一个 tools 数组
+
一个 executeTool() switch更容易继续扩展。
Pi 的 Tool Definition 还有一个我们之前完全没有考虑的东西:
text
promptSnippet
promptGuidelines例如 read 会额外告诉 System Prompt:
text
Read file contents以及:
text
Use read to examine files instead of cat or sed.write 也会贡献:
text
Use write only for new files or complete rewrites.这意味着:
Tool 不只是向模型提供一个 JSON Schema,它还可以影响 Agent 的行为规则。
这个问题我们下一节讲 System Prompt 时还会强调。
read 为什么不能无限读取文件
先看我们之前的 read。
核心只有:
typescript
const content =
await readFile(
filePath,
"utf8",
);
return JSON.stringify({
ok: true,
path: inputPath,
content,
});对于:
text
calculator.js这种十几行的小文件完全没有问题。
但如果 Agent 执行:
text
read("package-lock.json")文件有 2MB 呢?
再极端一点:
text
read("logs/app.log")里面有几十万行呢?
如果直接全部返回:
text
文件
↓
Tool Result
↓
Messages
↓
下一轮 Context很可能一次 Tool Call 就塞进去大量 Token。
所以真实 Coding Agent 必须考虑:
Tool Output 本身也是 Context 成本的一部分。
Pi 当前对文本读取默认设置了两个上限:
text
最多 2000 行
或
最多 50KB谁先达到,就按照谁截断。
read 使用的是:
typescript
truncateHead(...)也就是优先保留文件前面的内容。
为什么?
因为读取代码时,一般更自然的过程是:
text
先读前面
↓
如果还不够
↓
继续往后读因此 Pi 的 read Schema 不是只有:
typescript
path还有:
typescript
offset
limit例如:
text
read({
path: "src/app.ts",
offset: 1,
limit: 500
})如果文件没有读完,返回结果里还会主动告诉模型类似:
text
Showing lines 1-500 of 3200.
Use offset=501 to continue.于是模型可以继续:
text
read({
path: "src/app.ts",
offset: 501
})这比简单地:
text
“输出太长,所以截断了”要好很多。
因为 Tool Result 不只是告诉模型:
text
发生了截断还应该尽量告诉它:
text
下一步怎么继续这里其实可以总结出一个非常有用的 Tool 设计原则:
Tool Result 不只是返回数据,还应该帮助模型理解结果以及下一步可采取的动作。
例如:
不好:
text
[Output truncated]更好:
text
当前显示第 1-2000 行,共 5831 行。
继续读取请使用 offset=2001。后者明显更适合 Agent 进行下一步的预测性行为。
Pi 的 read 甚至不只支持文本。
它还会检测:
text
jpg
png
gif
webp
bmp等图片文件。
如果当前模型支持图片输入,就可以把图片作为 ImageContent 返回给模型;如果模型不支持视觉输入,则会给出对应提示。
所以真实的:
text
read已经不是简单:
typescript
fs.readFile()而是逐渐变成:
text
路径解析
↓
文件访问检查
↓
文件类型判断
↓
文本 / 图片处理
↓
分页
↓
截断
↓
构建适合模型消费的 Tool Resultbash 输出太大怎么办
bash 的问题甚至比 read 更明显。
前一篇我们直接:
typescript
const {
stdout,
stderr,
} = await execAsync(command);然后整个返回模型。
但真实命令的输出可能非常夸张。
比如:
bash
npm installbash
npm testbash
git diff或者某个错误配置下疯狂打印日志的程序。
都可能产生大量输出。
所以 Pi 的 bash 也会对结果做截断。
但是这里和 read 有一个非常值得注意的区别。
read:
text
保留头部bash:
text
保留尾部Pi 的共享截断逻辑分别提供:
typescript
truncateHead()
truncateTail()而 bash 的描述明确说明,它保留最后最多:
text
2000 行
或
50KB超出部分则会被截断。
为什么 Bash 更适合看尾部?
因为很多命令真正重要的信息都在最后:
text
前面:
Building...
Building...
Building...
最后:
ERROR
Test failed
exit code 1尤其是:
text
测试
编译
构建最后几十行通常比最开始几十行更重要。
所以:
text
同样叫“截断”实际策略也应该根据 Tool 的语义不同而不同。
这也是阅读真实框架实现的价值。
而且 Pi 并不是把被截掉的内容直接丢掉。
当前 bash Tool 会使用 OutputAccumulator 保存输出;如果发生截断,可以把完整输出写入临时文件,并通过 fullOutputPath 保留下来。
于是可以形成:
text
Bash 输出非常大
↓
只把最重要的一部分
送进 Context
↓
完整结果另外保存
↓
必要时再继续查看这是另一个非常重要的思想:
运行时保存的数据,不一定都应该进入模型 Context。
我们后面讲 Session 和 Compaction 时还会不断遇到这个区别。
edit 为什么比简单字符串替换复杂
前一篇实现 edit 时,我们采用的是:
text
oldText
↓
精确找到
↓
替换成 newText代码大概是:
typescript
const firstIndex =
content.indexOf(oldText);
const updated =
content.slice(0, firstIndex) +
newText +
content.slice(
firstIndex + oldText.length,
);这个方向其实和 Pi 很接近。
Pi 当前也仍然采用:
Exact Text Replacement
当前 edit 的 Schema 大致是:
typescript
{
path,
edits: [
{
oldText,
newText
}
]
}但和我们的版本相比,Pi 已经继续处理了很多细节。
首先,Pi 允许一次修改同一个文件中的多个独立位置:
typescript
edits: [
{
oldText: "...",
newText: "..."
},
{
oldText: "...",
newText: "..."
}
]而不是:
text
edit
↓
edit
↓
edit连续发三次 Tool Call。
Pi 给模型的 Prompt Guideline 也明确建议:
text
如果同一个文件中有多个分离修改,
尽量在一次 edit 中提供多个 edits。这样可以:
text
减少 Tool Round Trip
+
减少模型调用轮数
+
一次完成同一文件的相关修改但这里马上会产生新的问题:
text
两个 edit 会不会互相覆盖?例如:
text
Edit A 修改第 10-20 行
Edit B 又修改第 15-25 行这两个范围发生了重叠。
所以 Pi 要求:
text
每个 oldText
都针对原始文件匹配
不同 edits 之间
不能重叠或嵌套如果多个修改位置太接近,应该合并成一个较大的 Edit。
于是一次 Edit 的语义变得更加明确:
text
原始文件
↓
同时确定多个独立替换
↓
一次性产生新文件而不是:
text
Edit 1 改完
↓
再基于修改后的文件执行 Edit 2
↓
再执行 Edit 3这能够减少:
text
前面的修改影响后面的匹配这种问题。
Pi 的 Edit 还会处理一些我们写 Demo 时同样没有考虑的细节。
例如:
text
BOM代码文件开头可能存在不可见的 Byte Order Mark。
模型生成的:
text
oldText通常不会包含这个不可见字符。
所以 Pi 在匹配前会先:
typescript
splitBom(...)把 BOM 拆出来。
修改完成以后再恢复。
再比如不同操作系统的换行符:
text
Linux / macOS
LF
Windows
CRLF如果模型生成的是:
text
\n但真实文件是:
text
\r\n直接做字符串匹配就可能失败。
因此 Pi 在 Edit 过程中还会:
text
检测原始换行符
↓
统一成 LF 做匹配和修改
↓
修改完成
↓
恢复原始换行风格这就是非常典型的工程细节。
Pi 在修改完成以后也不只是返回:
text
文件修改成功还会生成:
text
diff
patch
firstChangedLine这些 Details。
这些信息不一定全部要交给模型。
但对于:
text
TUI 展示
Extension
编辑器跳转
变更审查却非常有用。
再次体现了:
Tool Result 不只是“给模型看的字符串”,还可以携带给 Runtime 和 UI 使用的结构化元数据。
多个 Tool 同时修改一个文件怎么办
上一节我们刚刚讲过:
text
Pi 支持并行 Tool Call这时候又会出现一个很现实的问题。
假设模型一轮产生:
text
write("src/a.ts")
edit("src/a.ts")或者两个独立操作恰好同时修改:
text
src/a.ts如果真的并行:
text
Task A Task B
读旧文件
↓ ↓
修改 A 修改 B
↓ ↓
写文件 写文件最后谁覆盖谁?
很容易发生竞态条件。
Pi 当前为文件修改专门实现了:
typescript
withFileMutationQueue()write 和 edit 都会通过它执行文件写操作。
它的核心思想是:
针对同一个文件的修改串行执行,不同文件仍然可以并行。
源码注释写得非常直接:
text
Serialize file mutation operations targeting the same file.
Operations for different files still run in parallel.所以:
text
edit("a.ts") ───┐
├→ 串行
write("a.ts") ──┘但:
text
edit("a.ts") ───→
edit("b.ts") ───→可以并行。
非常合理的一个折中方案:
text
完全串行
→ 安全,但浪费并发
完全并行
→ 快,但容易产生竞态
按资源加锁
→ 同资源串行,不同资源并行write 看起来最简单,也仍然有边界问题
四个 Tool 里面,write 看起来最简单:
text
path
+
content然后:
typescript
writeFile(...)Pi 的核心逻辑确实也不复杂。
它会:
text
解析目标路径
↓
创建父目录
↓
写入文件如果文件不存在就创建,存在则覆盖。
但即使这个最简单的 Tool,Pi 仍然加上了:
text
Abort 检查
File Mutation Queue
Pluggable Operations特别是:
typescript
operations?: WriteOperations意味着真正的写文件实现不是完全写死的。
默认可以:
text
写本地文件系统但调用方也可以替换这些底层 Operations。
read、edit、bash 同样采用了类似思想。
例如 read 有:
typescript
ReadOperationsedit 有:
typescript
EditOperationsbash 有:
typescript
BashOperations这就产生了一层很重要的解耦:
text
Agent Tool
↓
Tool Operations
↓
具体执行环境于是同一个:
text
read理论上可以:
text
读取本地机器也可以把底层 Operations 换掉以后:
text
读取远程服务器而模型看到的 Tool Interface 可以保持不变。
Tool 越来越多以后怎么管理
我们的 Mini Agent 只有四个 Tool,所以直接:
typescript
switch (name) {
case "read":
case "write":
case "edit":
case "bash":
}完全没有问题。
但真实项目以后可能出现:
text
read
write
edit
bash
grep
find
ls
powershell
browser
database
deploy
issue
search
……如果全部继续塞进:
typescript
executeTool()这个 switch 会越来越大。
Pi 当前内置 Tool 已经抽象成一组:
text
ToolDefinition并提供:
typescript
createToolDefinition(...)
createTool(...)
createCodingTools(...)
createReadOnlyTools(...)
createAllTools(...)等统一创建方式。
例如:
text
Coding Tools
read
bash
edit
write而:
text
Read Only Tools
read
grep
find
ls也可以组成另一套能力集合。
于是工具不再是:
text
“程序里写死的四个函数”而逐渐变成:
text
可以组合的一组能力这个变化非常重要。
因为真实 Agent 很少应该永远拥有:
text
所有工具不同场景可能只需要:
text
只读 Agent
→ read / grep / find / ls
Coding Agent
→ read / bash / edit / write
扩展后的 Agent
→ Coding Tools + Custom Tools所以 Tool System 最后要解决的不只是:
Tool 怎么执行?
还要解决:
当前 Agent 到底应该拥有哪一组 Tool?
Pi 后面的 Extension 系统还可以继续注册 Custom Tool。
不过这一部分我们后面单独讲 Extensions 时再展开。
现在再回头看我们最开始那四个 Tool。
当时我们可能觉得:
text
read
write
edit
bash
已经写完了但真实实现继续往下走,会逐渐变成:
text
Tool
│
┌────────────┼────────────┐
↓ ↓ ↓
Schema Runtime Prompt
│ │ │
│ │ └→ Guidelines
│ │
│ ├→ Abort
│ ├→ Error
│ ├→ Concurrency
│ ├→ Streaming
│ └→ Result Metadata
│
└→ 参数约束
│
↓
Operations Layer
│
┌──────────┴──────────┐
↓ ↓
Local FS Remote Env而且这里还有一个细节已经出现很多次了。
我们刚刚看到:
text
read
write
edit
bash每个 Tool 除了:
text
Schema
execute()还会带上:
text
promptSnippet
promptGuidelines也就是说,Pi 并没有把 System Prompt 写成一段永远固定的字符串。
当前有哪些 Tool、加载了哪些项目规则、有哪些 Skills,都会进一步影响最终 Prompt。
System Prompt:真实 Prompt 不是一段写死的字符串
前一篇实现最小 Coding Agent 时,我们直接定义了一段:
typescript
export const SYSTEM_PROMPT = `
你是一个运行在本地项目工作区中的 Coding Agent。
工作目录:
${WORKSPACE_DIR}
使用 read、write、edit 和 bash 工具检查、修改并验证项目。
规则:
- 修改前先读取相关文件。
- 修改已有文件中的局部内容时,优先使用 edit。
- 创建新文件或替换整个文件时,使用 write。
- 修改代码后,如果条件允许,应运行相关测试。
- 任务完成后停止调用工具,并简要总结。
`.trim();对于我们的 Mini Agent,这样完全够用。
因为当时整个运行环境几乎是固定的:
text
固定的 4 个 Tool
+
固定的 Workspace
+
固定的行为规则所以:
text
SYSTEM_PROMPT = 一段固定字符串没有什么问题。
但真实 Coding Agent 继续发展以后,很快就会出现新的情况:
text
这一次只有只读工具怎么办?
用户安装了新的 Tool 怎么办?
加载了新的 Skill 怎么办?
不同项目有自己的 AGENTS.md 怎么办?
用户希望额外追加一段 System Prompt 怎么办?如果这些东西全部继续手写进一个巨大的字符串:
text
SYSTEM_PROMPTPrompt 很快就会和真实运行环境脱节。
所以 Pi 没有把最终 System Prompt 写死,而是选择:
在 Agent 启动时,根据当前运行环境动态构建。
我们之前直接写了 SYSTEM_PROMPT
先回头看我们的 Mini Agent。
当时 Prompt 和 Tool 实际上是两套独立配置:
text
SYSTEM_PROMPT
“使用 read、write、edit 和 bash”另一边:
typescript
const tools = [
read,
write,
edit,
bash,
];这其实存在一个潜在问题。
假设以后删掉:
text
bash但是忘了改 Prompt:
text
System Prompt:
“使用 read、write、edit 和 bash”模型就会被告诉:
text
你可以使用 bash但 Runtime 实际上:
text
根本没有 bash反过来也一样。
如果新增加:
text
grep但 Prompt 不知道,那么这个工具虽然出现在 Tool Schema 里,却没有在系统提示词中引导模型调用它。
对于四个固定 Tool 的 Demo,这个问题不明显。
但真实系统里的能力是会变化的:
text
不同运行模式
不同项目
不同 Extension
不同配置
↓
可用 Tool 不一定相同所以 Prompt 最好也跟着这些能力变化。
Pi 怎么动态构建 System Prompt
Pi 当前专门有:
text
packages/coding-agent/src/core/system-prompt.ts其中的:
typescript
buildSystemPrompt()负责构建最终 Prompt。
它接收的不是一段字符串,而是一组输入:
typescript
interface BuildSystemPromptOptions {
customPrompt?: string;
selectedTools?: string[];
toolSnippets?: Record<string, string>;
promptGuidelines?: string[];
appendSystemPrompt?: string;
cwd: string;
contextFiles?: Array<{
path: string;
content: string;
}>;
skills?: Skill[];
}也就是说,最终 System Prompt 实际上受到很多因素影响:
text
基础 Prompt
+
当前可用 Tools
+
Tool Guidelines
+
项目 Context Files
+
Skills
+
用户追加的 Prompt
+
当前工作目录可以把它理解成:
text
buildSystemPrompt()
│
┌──────────────────┼──────────────────┐
↓ ↓ ↓
当前 Tools 项目 Context Skills
│ │ │
└──────────────────┼──────────────────┘
↓
Final Prompt所以真实 Agent 里的 System Prompt 更像:
当前运行环境的一次快照。
而不是一段永远不变的角色设定。
Pi 默认 Prompt 的主体其实也没有想象中那么神秘。
核心仍然是在告诉模型:
text
你是 Coding Assistant
你有哪些 Tool
应该遵守哪些基本工作规则
当前工作目录是什么但关键区别在于:
这些内容不是全部手工拼死,而是根据 Runtime 当前真正拥有的能力生成。
Tool 会反过来影响 Prompt
上一节我们已经看到一个细节。
Pi 的 Tool 除了:
text
name
description
parameters
execute还可以提供:
text
promptSnippet
promptGuidelines例如 read 会贡献类似:
text
Read file contents以及:
text
Use read to examine files instead of cat or sed.write 则会告诉模型:
text
Use write only for new files or complete rewrites.edit 的指导更加具体,例如:
text
精确修改使用 edit
oldText 应尽量小,但必须能够唯一匹配
同一个文件多个独立修改,
尽量放在一次 edit 调用里
不要产生互相重叠的 edits也就是说:
text
Tool不只是:
给模型一个 Function Schema。
它还可以同时告诉模型:
这个能力应该怎么使用。
于是 Tool 和 Prompt 之间形成了对应关系:
text
Tool 存在
↓
Prompt 中出现相应能力和 Guidelines
Tool 不存在
↓
相关 Guideline 也不应该继续出现这比我们之前:
text
代码里维护 tools
+
另一个地方手写 Prompt更不容易失配。
Pi 甚至会根据当前 Tool 组合动态改变一些规则。
例如当前没有:
text
grep
find
ls这些专用文件探索工具,但存在:
text
bashPi 会增加类似这样的 Guideline:
text
Use bash for file operations like ls, rg, find如果对应的专用工具已经存在,就没必要再给出同样的指导。
所以:
text
System Prompt不是独立于 Tool System 的。
更准确地说:
Agent 有什么能力,会直接影响应该怎样指导模型。
AGENTS.md 等项目上下文怎么进入 Context
还有一类信息,我们的 Mini Agent 完全没有处理:
text
项目自己的开发规范例如某个真实项目可能要求:
text
修改前必须运行 npm test
不要修改 generated 目录
数据库迁移必须放在 migrations/
前端统一使用 pnpm
提交前必须执行 npm run check这些规则当然不能全部写进 Pi 自己的默认 Prompt。
因为:
text
Pi 的规则
≠
具体项目的规则所以 Coding Agent 还需要加载:
text
Project ContextPi 当前的 Resource Loader 会查找项目上下文文件,包括:
text
AGENTS.override.md
AGENTS.md
AGENTS.MD
CLAUDE.md
CLAUDE.MD并且不只是查看当前目录。
它会从当前工作目录向父目录继续查找相关 Context File,同时还可以加载 Agent 全局目录中的上下文。
所以一个项目可能形成:
text
全局规则
↓
仓库级 AGENTS.md
↓
子目录级 AGENTS.md
↓
当前任务这些内容最终会被作为:
text
contextFiles交给 buildSystemPrompt()。
Pi 会把它们组织到类似:
xml
<project_context>
Project-specific instructions and guidelines:
<project_instructions path=".../AGENTS.md">
...
</project_instructions>
</project_context>这样的区域里。
这样模型看到的不只是:
text
“你是一个 Coding Agent”还会知道:
text
“在当前这个项目里,应该怎么工作。”这其实解决了一个非常关键的问题。
Coding Agent 一般同时存在两类规则:
text
Agent 通用规则
例如:
使用 read 检查文件
修改后进行验证
保持回答简洁以及:
text
Project Rules
例如:
必须用 pnpm
禁止修改某目录
遵守当前项目架构两者不应该混成一个永远写死的 Prompt。
合理的方案是:
text
Agent Base Prompt
+
Project Context
↓
当前 Agent 的实际 Prompt这样同一个 Coding Agent 换一个仓库以后:
text
Agent Runtime 不变
Tools 不变但:
text
Project Context 改变模型的工作方式也可以跟着改变。
Skills 又是怎么加入 Prompt 的
还有最后一块:
text
Skills假设我们有:
text
frontend-design
database-migration
release
security-review这样的 Skill。
最直接的做法似乎是:
text
把所有 SKILL.md
全部拼进 System Prompt但这样很快就会出现问题。
假设:
text
20 个 Skill
每个几千 Token即使当前任务只是:
text
修改一个 Button 样式也要把所有 Skill 全部发给模型。
明显浪费 Context。
Pi 当前并不是直接把所有 Skill 正文全部塞进 Prompt。
formatSkillsForPrompt() 主要放进去的是:
text
name
description
location例如可以理解成:
xml
<available_skills>
<skill>
<name>frontend-design</name>
<description>
用于前端 UI 设计相关任务
</description>
<location>
.../frontend-design/SKILL.md
</location>
</skill>
</available_skills>同时告诉模型:
text
如果当前任务与某个 Skill 的描述匹配,
再使用 read 去读取那个 Skill 文件。于是过程变成:
text
System Prompt
先告诉模型:
“现在有哪些 Skill”
↓
模型判断任务需要 frontend-design
↓
read(frontend-design/SKILL.md)
↓
真正加载详细说明而不是:
text
启动 Agent
↓
把所有 Skill 全文全部塞进去这实际上是一种:
按需加载 Context。
而且 Pi 还允许 Skill 设置:
text
disable-model-invocation: true这种 Skill 不会出现在模型可自主选择的 Skill 列表里,只能通过显式方式调用。
所以 Skills 也不是简单:
text
很多 Prompt 文件而是:
text
可以按需发现和加载的专项知识 / 指令模块现在再回头看我们的 Mini Agent。
当时:
typescript
const SYSTEM_PROMPT = `...`;没有任何问题。
因为我们只是为了看清:
text
Agent Loop没有必要一开始就实现动态 Prompt 系统。
但 Pi 让我们看到,当 Agent 真正走向可扩展以后:
text
System Prompt逐渐会从:
text
一段字符串变成:
text
Final System Prompt
↑
┌───────────────┼───────────────┐
│ │ │
Base Prompt Tools Project Context
│ │ │
│ │ │
└───────────────┼───────────────┘
│
Skills
│
Append Prompt
│
cwd所以这一节真正值得记住的并不是:
Pi 的 Prompt 到底写了哪几句话。
因为这些文字以后完全可能继续变化,另一个框架可能也完全不同。
真正值得借鉴的是:
System Prompt 应该和 Agent 当前真实拥有的能力、运行环境以及项目上下文保持同步。
不过到这里,我们仍然有一个问题没有解决。
无论 Prompt 构建得多完善,我们前面的 Mini Agent 运行时仍然只有:
typescript
const messages = [];程序一退出:
text
整个任务历史就没有了。而真实 Coding Agent 显然不能每次启动都从零开始。
所以接下来就要从:
text
Messages继续往上一层,看看为什么真实 Agent 还需要:
text
SessionMessages 不够了:为什么还需要 Session
前面的 Mini Agent 运行时,我们一直维护这样一个数组:
typescript
const messages: ChatCompletionMessageParam[] = [
{
role: "developer",
content: SYSTEM_PROMPT,
},
{
role: "user",
content: task,
},
];后面每发生一次模型调用或者 Tool Call,就继续:
typescript
messages.push(...)所以整个 Agent 的运行过程,其实都暂时保存在:
text
messages[]里面。
这种方式非常适合理解 Agent Loop。
但只要程序退出:
text
进程结束
↓
内存释放
↓
Messages 消失下一次重新启动以后,Agent 根本不知道之前发生过什么。
真正的 Coding Agent 显然不能一直这样工作。
我们之前的 Messages 只存在内存里
假设我们正在完成一个稍微复杂一点的任务:
text
重构用户认证模块,并保证测试通过Agent 已经工作了 20 分钟:
text
User
→ 重构认证模块
Assistant
→ bash("npm test")
Tool
→ 3 个测试失败
Assistant
→ read("src/auth/service.ts")
Tool
→ 文件内容
Assistant
→ edit(...)
Tool
→ 修改成功
Assistant
→ bash("npm test")
Tool
→ 还剩 1 个测试失败
……结果这时候:
text
终端被关闭如果只有内存里的:
typescript
messages那么下一次启动 Agent:
text
之前做了什么?
修改了哪些文件?
测试失败在哪里?
模型已经尝试过什么?全部不知道。
最简单的补救办法当然是:
typescript
await writeFile(
"messages.json",
JSON.stringify(messages),
);重新启动的时候:
typescript
messages =
JSON.parse(
await readFile("messages.json"),
);这至少已经能够做到:
text
持久化对话历史但继续往下走会发现:
真实 Session 保存的东西,并不只是 Messages。
Pi 为什么把 Session 保存成 JSONL
Pi 当前会把 Session 持久化为:
text
JSONL也就是:
text
JSON Lines默认 Session 文件大致保存在:
text
~/.pi/agent/sessions/--项目路径--/
└── 时间戳_session-id.jsonl其中每一行都是一个独立 JSON 对象。
例如可以把它想象成:
json
{"type":"session","version":3,"id":"...","cwd":"/project"}
{"type":"message","id":"a1","parentId":null,"message":{...}}
{"type":"message","id":"a2","parentId":"a1","message":{...}}
{"type":"model_change","id":"a3","parentId":"a2","provider":"openai","modelId":"..."}
{"type":"message","id":"a4","parentId":"a3","message":{...}}为什么不用一个巨大的:
json
{
"messages": [...]
}然后每次整体覆盖?
JSONL 有一个很直接的好处:
text
新事件发生
↓
在文件末尾追加一条 Entry而不是:
text
读取整个 Session
↓
修改大 JSON
↓
重新写完整文件对于长期运行、不断产生新记录的 Agent 来说,这种结构比较自然。
更重要的是,Pi 保存的单位已经不只是:
text
Message而是:
text
Session EntryMessage、Tool Result 和其他状态怎么保存
我们的 Mini Agent 基本只有:
text
User Message
Assistant Message
Tool ResultPi 的 Session 当然也会保存这些内容。
例如当前 Session Format 中,基础消息大致包括:
text
UserMessage
AssistantMessage
ToolResultMessage其中 Assistant Message 还会记录:
text
provider
model
usage
stopReason
timestampTool Result 则不仅有:
text
content还会记录:
text
toolCallId
toolName
isError
details
usage等信息。
这就比我们之前简单的:
typescript
messages.push({
role: "tool",
content: result,
});丰富很多。
但 Session 里还不止 Message。
比如用户执行过程中切换了模型:
text
Claude
↓
OpenAIPi 可以记录:
text
model_change用户调整 Thinking Level,也可以记录:
text
thinking_level_change后面我们会讲到的 Context 压缩,会产生:
text
compaction用户切换分支时可能产生:
text
branch_summaryExtension 还可以保存自己的:
text
custom
custom_message等 Entry。
所以真实 Session 接近:
text
Session
│
┌────────────┼─────────────┐
↓ ↓ ↓
Messages Runtime State Metadata
│ │ │
User Model Change Session Name
Assistant Thinking Labels
Tool Result Compaction Custom State
Branch这就是:
text
Messages和:
text
Session之间最大的区别之一。
可以先简单理解成:
Messages 是模型对话的一部分,而 Session 是整个 Agent 工作过程的持久化记录。
这个区别后面会非常重要。
因为:
text
Session 里保存了不代表:
text
下一轮都要发送给模型例如 Pi 的:
text
CustomEntry可以保存在 Session 里,但是明确:
text
不进入 LLM Context而:
text
CustomMessageEntry则可以进入 Context。
所以真正的 Agent 里至少要区分三件事情:
text
程序曾经发生过什么
↓
Session
模型下一轮需要看到什么
↓
Context
模型之间实际交换的对话数据
↓
Messages这三者有关联,但并不完全相等。
为什么 Session 是一棵树而不是一个数组
我们最容易想到的对话历史是:
text
Message 1
↓
Message 2
↓
Message 3
↓
Message 4也就是:
typescript
messages[0]
messages[1]
messages[2]
messages[3]一条线。
但 Pi 当前 Session Entry 会带:
typescript
id
parentId例如:
text
A
↓
B
↓
C其实表示:
text
B.parentId = A
C.parentId = B于是如果从:
text
B重新开始一条路线:
text
┌→ C → D
A → B ──┤
└→ E → F完全不需要把旧历史删除。
为什么 Coding Agent 特别适合这种结构?
因为写代码本身就经常存在:
text
尝试
↓
失败
↓
回退
↓
换一种方案例如 Agent 一开始决定:
text
方案 A:
直接修改 AuthenticationService跑了一段以后发现:
text
架构方向不对我们可能希望回到之前:
text
读取完代码
但还没有开始修改那个位置,然后告诉 Agent:
text
不要继续方案 A,
换成增加 Adapter 的方式。如果历史只有一条线:
text
A → B → C → D通常只能:
text
删除 C、D或者:
text
复制一份历史Pi 的树结构则可以保留:
text
┌→ 方案 A
原始上下文 ────┤
└→ 方案 B两条路线都存在。
这背后其实还有一个很重要的思想:
Agent History 不一定是一条不可修改的时间线。
尤其对于 Coding Agent:
text
探索
试错
回退
重新规划本身就是正常工作方式。
所以 Session Tree 比简单的:
text
Message[]能够表达更多东西。
/tree、/fork 和 /clone 解决了什么
Pi 在这个 Session Tree 上进一步提供了几个很有代表性的能力:
text
/tree
/fork
/clone虽然它们表面上是 CLI 功能,但背后其实都建立在:
text
Session History不是简单数组,而是带有父子关系的历史树。
先看:
text
/tree它允许在当前 Session 历史树中跳回之前某个节点,然后从那里继续。
例如原来:
text
A → B → C → D现在回到:
text
B重新继续:
text
┌→ C → D
A → B ──┤
└→ E → F这里需要注意:
text
C → D不会消失,也不会被删除。
Pi 只是把当前的:
text
leaf移动到了新的分支上。
之后仍然可以再次使用 /tree,从 E、D 或其他历史节点继续。
所以 /tree 做的是:
text
切换当前对话上下文而不是:
text
删除后续历史它也不会自动恢复代码文件。C、D 执行过的代码修改,仍然可能保留在当前工作区中。
/fork 则更进一步。
它会从当前活动分支中的某个历史用户消息创建新的 Session 文件。
可以理解成:
text
原 Session
A → B → C → D
↓
/fork
↓
新 Session
A → B然后从这里修改 Prompt,继续新的任务方向。
原 Session 和新 Session 会分别保存自己的后续历史。
Pi 当前也会在新 Session Header 中记录:
text
parentSession用于表示它来源于哪个 Session。
/clone 则更像:
把当前正在使用的完整活动分支复制成一个新的 Session。
例如:
text
A → B → C → D当前就在:
text
D执行 Clone 后:
text
Session 1
A → B → C → D
Session 2
A → B → C → D两边接下来可以各自继续。
三者可以简单区分成:
| 操作 | 核心目的 |
|---|---|
/tree | 在同一个 Session 中切换历史分支 |
/fork | 从历史用户消息创建新的 Session |
/clone | 把当前活动分支整体复制成新的 Session |
真正需要理解的不是三个命令怎么用。
而是:
Pi 把“试错、回退、分支探索”当成 Coding Agent 正常工作过程的一部分。
现在再回头看我们之前的:
typescript
const messages = [];可以发现从 Mini Agent 走到真实 Coding Agent 以后,状态逐渐变成:
text
Mini Agent
Messages
↓
只负责当前进程里的对话状态到了 Pi:
text
Session
├── Messages
├── Tool Results
├── Model Changes
├── Thinking Changes
├── Compaction
├── Branch Information
├── Extension State
└── Metadata而且这些内容:
text
持久化到 JSONL
+
通过 id / parentId 组成树这样 Agent 才真正拥有:
text
恢复
继续
回退
分支这些长期运行所需要的能力。
不过这里马上会出现下一个问题。
即使 Session 可以无限保存:
text
100 条
1000 条
10000 条模型的 Context Window 也不是无限的。
所以:
text
Session 可以保存完整历史并不意味着:
text
每一轮都可以把完整历史发给模型Context 越来越长怎么办:Compaction
上一节我们刚刚把:
typescript
const messages = [];升级成了可以长期保存的:
text
Session这样程序退出以后,历史不会丢失。
但紧接着就会出现另一个问题:
Session 可以一直增长,模型的 Context Window 却不可以。
也就是我们常说的模型上下文是有限制的。
我们之前把所有 Messages 一直往后追加
前面的 Mini Agent 每完成一轮,基本都在做:
typescript
messages.push(assistantMessage);
messages.push(toolResult);于是整个执行过程就是:
text
User
↓
Assistant
↓
Tool Result
↓
Assistant
↓
Tool Result
↓
Assistant
↓
Tool Result
↓
……下一次调用模型时,再把:
typescript
messages整个交回去。
对于十几轮的小任务,这样非常自然。
因为模型下一轮能够直接看到:
text
用户最开始要求什么
之前读过哪些文件
执行过哪些命令
哪些方案失败了
刚刚修改了什么这也是 Agent 能够连续工作的基础。
但 Coding Agent 的 Message 往往增长得非常快。
一次:
text
read可能返回几千行代码。
一次:
text
bash可能返回大量测试日志。
再加上:
text
Assistant Response
Tool Call
Tool Result
Thinking
用户继续补充要求几十轮以后,Context 很容易变成:
text
User Message
Assistant
Tool Result 20KB
Assistant
Tool Result 35KB
Assistant
Tool Result 10KB
……前面我们已经通过 Tool 截断减少了一部分问题。
但即使每个 Tool Result 都有限制:
只要 Session 足够长,Context 还是会不断增长。
Context Window 为什么迟早会耗尽
这里我们需要再次区分两个概念:
text
Session和:
text
ContextSession 可以保存在磁盘里,理论上可以很长:
text
几百条 Entry
几千条 Entry
甚至更长但每次调用模型时,真正能够送进去的内容受到:
text
Context Window限制。
可以简单理解成:
text
模型 Context Window
┌─────────────────────────────┐
│ System Prompt │
│ Project Context │
│ User Messages │
│ Assistant Messages │
│ Tool Calls │
│ Tool Results │
│ Compaction Summary │
│ …… │
│ │
│ 还要给模型输出留空间 │
└─────────────────────────────┘所以不能真的等到:
text
输入 Token
=
Context Window才处理。
因为模型还需要继续输出。
真实 Runtime 通常必须提前预留一部分空间。
Pi 当前自动 Compaction 的核心判断可以概括成:
text
contextTokens > contextWindow - reserveTokens其中默认:
text
reserveTokens = 16384也就是在真正把 Context Window 用满之前,就提前为下一次模型输出预留空间。
例如假设模型 Context Window 是:
text
128K并且预留:
text
16K那么接近:
text
112K时,就应该开始考虑压缩,而不是继续无脑追加。
Pi 什么情况下触发 Compaction
Pi 同时支持:
text
自动 Compaction
+
手动 Compaction手动可以直接:
text
/compact也可以:
text
/compact <额外要求>告诉模型这次总结应该重点保留什么。
自动 Compaction 则主要发生在:
text
Context 接近上限或者已经出现:
text
Context Overflow时。
Pi 当前还会在一个 Agent Run 的多轮执行过程中检查 Context。
例如:
text
Model
↓
Tool Calls
↓
Tool Results 写回 Context
↓
发现 Context 已经太大
↓
Compaction
↓
再开始下一轮 Model也就是说,它不是一定要等:
text
整个 Agent Run 结束以后才压缩。
长任务可以在执行过程中完成 Compaction,然后继续当前任务。
这点非常重要。
否则一个一次运行几十轮的 Agent:
text
还没结束
↓
Context 已经满了
↓
只能失败就无法真正处理长任务。
Summary + Recent Messages 怎么重新构建 Context
如果让我们自己设计最简单的压缩方案,可能会直接想到:
text
Context 太长
↓
删除最早的 Messages例如原来是:
text
M1 M2 M3 M4 M5 M6 M7 M8 M9 M10直接删除前面的内容,变成:
text
M6 M7 M8 M9 M10这样确实可以缩短 Context。
但模型也可能突然不知道:
text
用户最初的目标是什么?
之前已经做过什么?
为什么选择当前方案?
哪些方案已经失败?
哪些文件曾经被修改?所以 Pi 并不是简单地删除旧消息,而是:
text
总结较老的历史
+
保留最近的原始消息大致过程可以这样理解:
text
Compaction 之前
Context 中的历史消息:
M1 M2 M3 M4 M5 M6 M7 M8 M9 M10
从最新消息开始反向估算 token 数
↑
尽量保留约 20K tokens
↓
较老的消息:
M1 M2 M3 M4 M5 M6
↓
Summary
最近的消息:
M7 M8 M9 M10
↓
保留原始内容这里的 M1、M2... 表示 Context 中的一条条消息。
它们可能包括:
text
用户消息
Assistant 消息
Tool Call
Tool Result因此,这里的“最近消息”不等同于“最近几条用户消息”,也不严格等同于“最近几个完整 Agent turn”。
下一轮模型实际看到:
text
System Prompt
+
Compaction Summary
+
M7
M8
M9
M10而不是重新发送完整:
text
M1 ~ M10Pi 当前默认使用:
text
keepRecentTokens = 20000它会从最新的 Session Entry 开始向前估算消息大小,直到接近这个范围。
这个估算并不是调用一个精确的 tokenizer,而是根据消息内容的字符数量进行近似计算。因此,20K tokens 更准确地说是“尽量保留约 20K tokens 的最近消息”,并不是一个绝对精确的硬边界。
至于工具调用之间的边界,以及一个 Agent turn 本身过大的情况,Pi 还有专门的处理方式,后面会单独说明。
下一轮 Context 的构建可以概括成:
text
Session
↓
找到最新的 Compaction 边界
↓
旧消息 → Summary
最近消息 → 保留原始内容
↓
重新组合 Context
↓
发送给 Model这里特别值得注意:
Summary 不是对 Session 的替代,而是下一轮 Context 中对旧历史的压缩表示。
完整历史仍然保存在 Session 中。
所以这三个概念需要分开理解:
text
Session
│
│ 保存完整历史
│
├───────────────────────────────┐
│ │
↓ │
Compaction │
│ │
├─ Older History → Summary │
│ │
└─ Recent History → 原始保留 │
│
↓ │
Context │
↓ │
Model │text
Session
→ 保存完整历史
Compaction
→ 压缩较老的历史
Context
→ 根据当前分支和 Compaction 结果,重新组织下一轮发送给模型的内容也就是说:
text
Session History
→ 我们真正发生过什么
Context View
→ 下一轮模型需要看到什么Pi 并没有破坏原来的 Session History,只是为模型构建了一个更短的 Context View。
Summary 里到底应该保留什么
这里还有一个很重要的问题。
假如只是让模型:
text
“总结一下上面的聊天”很容易得到这种 Summary:
text
用户正在修改一个项目,
期间读取了多个文件并进行了若干修改,
目前还需要继续解决问题。这对 Coding Agent 基本没什么用。
因为真正需要保留下来的往往是:
text
当前 Goal
用户提出的 Constraints
已经完成什么
当前做到哪里
遇到了什么 Blocker
为什么做出某个设计决定
下一步应该做什么
哪些文件已经读过
哪些文件已经修改Pi 当前使用的 Summary 本身就是结构化的,大致包括:
markdown
## Goal
## Constraints & Preferences
## Progress
### Done
### In Progress
### Blocked
## Key Decisions
## Next Steps
## Critical Context
<read-files>
...
</read-files>
<modified-files>
...
</modified-files>这其实非常值得借鉴。
对于 Agent 来说,一个好的 Compaction Summary 不应该追求:
把对话总结得尽可能详细。
它真正应该解决的是:
即使忘掉旧的原始 Messages,Agent 是否还能继续工作?
例如原始历史可能非常长:
text
用户要求修复认证模块
↓
读了 8 个文件
↓
尝试方案 A
↓
测试失败
↓
放弃方案 A
↓
改成方案 B
↓
修改 service.ts
↓
修改 adapter.ts
↓
目前还剩一个 refresh token 测试失败一个有价值的 Summary 应该类似这种:
text
Goal:
修复并重构认证模块,保证测试通过。
Progress:
- 已确认问题不在 Controller。
- 已放弃直接修改 AuthenticationService 的方案。
- 当前采用 Adapter 方案。
- service.ts 和 adapter.ts 已修改。
- 目前只剩 refresh token 相关测试失败。
Key Decision:
认证 Provider 通过 Adapter 隔离,不继续向 Service 增加 Provider 分支。
Next Step:
检查 refresh-token.test.ts 与 TokenAdapter 的过期时间处理。
Modified Files:
- src/auth/service.ts
- src/auth/adapter.ts这样即使旧的几十条 Tool Result 不再进入 Context,模型依然能够继续工作。
Pi 还会累计跟踪:
text
readFiles
modifiedFiles即使发生多次 Compaction,也会尽量把这些文件操作信息继续带入后续 Summary。
这也是 Coding Agent 相比业务场景比较特殊的一点:
它的历史不仅是“聊过什么”,还有“对真实环境做过什么”。
为什么完整历史不能直接删除
既然:
text
旧 Messages已经被 Summary 替代了,那为什么还要继续在 Session 中保存?
直接删除不是更省空间吗?
其实不应该。
因为:
text
Compaction本质上是一种:
有损压缩。
Pi 官方文档也明确指出:
text
Compaction is lossy.Summary 不可能百分之百保留旧历史的所有细节。
例如:
text
某一次完整测试错误日志
某段曾经读取过的代码
模型具体尝试过的一条命令
一个后来才发现重要的用户要求都有可能在 Summary 中被弱化甚至遗漏。
所以 Pi 的设计是:
text
完整历史
→ 继续留在 Session JSONL
模型 Context
→ 使用 Summary + Recent Messages而不是:
text
Compaction
↓
永久删除历史这也是为什么上一节介绍的:
text
/tree依然有意义。
即使某段内容已经不在当前 Context 中:
text
Session 里仍然存在用户依旧可以回到之前的历史位置。Pi 的 Session 构建 Context 时,会根据当前分支和最新的 Compaction Entry,决定哪些 Entry 转换成下一轮模型真正看到的 Messages。
所以:
text
存储历史和:
text
给模型提供历史必须分开设计。
我们已经强调了很多遍。
Compaction 以后,Session 发生了什么
Pi 并不会把旧 Entry 重写掉。
它会在 Session 中继续追加一个:
text
CompactionEntry里面记录类似:
typescript
{
type: "compaction",
summary: "...",
firstKeptEntryId: "...",
tokensBefore: 50000
}其中最重要的是:
text
summary以及:
text
firstKeptEntryId后者告诉系统:
哪些旧消息已经被 Summary 覆盖,从哪里开始继续保留原始消息。
于是 Session 可以是:
text
M1
↓
M2
↓
M3
↓
M4
↓
M5
↓
M6
↓
M7
↓
Compaction但下一次构建 Context 时变成:
text
Compaction Summary
↓
M5
↓
M6
↓
M7具体保留边界由:
text
firstKeptEntryId确定。
这比直接修改:
text
messages[]要清晰很多。
因为:
text
Session History没有被破坏。
只是:
text
Context View发生了变化。
如果单独一轮就特别长怎么办
这里还有一个比较真实的边界情况。
通常我们希望 Compaction:
text
按完整 Turn 切因为 Tool Call 和 Tool Result 最好不要被硬生生拆开。
例如:
text
User
↓
Assistant Tool Call
↓
Tool Result
↓
Assistant最好作为一个完整工作过程理解。
但如果:
text
单独一个 Turn本身就已经非常大呢?
例如 Agent 一次读了大量文件、运行了超长测试,又进行了多次 Tool Call。
这时候甚至:
text
一个 Turn
>
keepRecentTokensPi 也处理了这种:
text
Split Turn场景。
它会对这一轮较早的部分单独做 Summary,同时保留更靠后的内容,而不是因为“不能在 Turn 中间切”就彻底无法 Compaction。
这个细节我们不需要继续深入源码。
但它说明了一件事情:
Context 管理不能假设每一轮都很小。
对于 Coding Agent,单轮 Tool Output 本身就可能非常大。
Branch Summary 又解决了什么
Pi 还有一个和 Compaction 很像,但目的不同的机制:
text
Branch Summarization前面我们讲 /tree 时有这样一个场景:
text
┌→ B → C → D
A ──────┤
└→ E → F假设之前一直工作在:
text
B → C → D现在决定回到:
text
A然后换一条路线:
text
E → F问题来了。
旧分支:
text
B → C → D里面可能已经获得很多有价值的信息。
例如:
text
哪些方案不可行
测试暴露了什么问题
读取过哪些文件
发现了哪些架构约束如果切到新分支以后全部丢掉:
text
又有点可惜。所以 Pi 在 /tree 分支导航时,还可以对正在离开的那条 Branch 生成:
text
Branch Summary然后把它带到新的路线中。
可以理解成:
text
旧分支
A → B → C → D
↑
└─ 这些探索结果
↓
Branch Summary
新分支
A → E → F
+
“刚才另一条路线已经发现了什么”这样新的 Agent 不需要重新经历全部旧分支,也不会完全失去刚才探索得到的信息。
虽然:
text
Compaction和:
text
Branch Summary都会调用模型生成 Summary,但它们解决的是两个不同问题:
| 机制 | 主要解决什么 |
|---|---|
| Compaction | 当前 Context 太长 |
| Branch Summary | 切换分支时保留旧路线中的有价值信息 |
这两个机制最终都说明:
真实 Agent 的 Context 并不是简单等于“完整聊天历史”。
现在我们可以把最开始的 Mini Agent 再升级一层。
最开始是:
text
Messages
↓
全部发送给模型后来有了 Session:
text
Session
↓
保存完整工作历史再到 Pi 的 Context 管理:
text
Session
│
┌───────────┴───────────┐
│ │
完整历史保留 当前 Active Branch
│
Compaction 判断
│
┌────────────┴────────────┐
↓ ↓
Older History Recent History
↓ ↓
Summary 原始 Messages
└────────────┬────────────┘
↓
Context
↓
Model不过即使 Context 问题解决了,我们的 Mini Agent 还有一个非常明显的限制。
前面代码一直直接依赖:
typescript
new OpenAI()如果以后想切换:
text
Anthropic
Google
OpenRouter
Bedrock
甚至本地模型难道每换一个 Provider,都要重新修改 Agent Loop?
Pi 显然没有这么做。
只支持一个 OpenAI 模型肯定不够
前面的 Mini Agent 从一开始就直接依赖:
typescript
import OpenAI from "openai";
const client = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
const response =
await client.chat.completions.create({
model: process.env.OPENAI_MODEL!,
messages,
tools,
});为了学习 Agent Loop,这样简化写还是挺合理的。
我们当时真正关心的是:
text
Model
↓
Tool Call
↓
Tool Result
↓
Model至于底层到底是谁提供模型,并不是重点。
但真实 Coding Agent 显然不能永远假设:
text
模型 = OpenAIPi 当前支持 OpenAI、Anthropic、Google、OpenRouter、Amazon Bedrock、Groq、Mistral、DeepSeek 等多种 Provider,也支持 Ollama、vLLM、LM Studio 这类 OpenAI-compatible API。pi-ai 的定位就是把这些差异尽量挡在 Agent Runtime 下面。
我们之前把 OpenAI SDK 写死了
先看看当前 Mini Agent 的依赖关系:
text
Agent Loop
↓
OpenAI SDK
↓
OpenAI API这意味着:
typescript
client.chat.completions.create(...)实际上已经进入了我们的 Agent Runtime。
如果以后改成 Anthropic,可能会变成另一套调用:
text
Agent Loop
↓
Anthropic SDK
↓
Anthropic Messages API再接 Google,又可能出现第三种实现。
很容易就写成了:
typescript
if (provider === "openai") {
// OpenAI
}
if (provider === "anthropic") {
// Anthropic
}
if (provider === "google") {
// Google
}然后这些 Provider 差异继续蔓延到:
text
Messages 转换
Tool Calling
Streaming
Thinking
Token Usage
错误处理
认证其实麻烦的并不是代码多。
而是
模型 Provider 的变化开始污染 Agent Loop。
但从 Agent Runtime 的角度看,它其实只想做:
text
给模型 Context
↓
得到模型响应
↓
发现 Tool Call
↓
执行 Tool
↓
继续下一轮至于下面到底调用:
text
OpenAI
Anthropic
Google理论上不应该改变这条 Loop。
pi-ai 如何把 Provider 和 Agent 解耦
这也是 Pi 为什么单独拆出:
text
@earendil-works/pi-ai这一层。
整体关系从我们的:
text
Agent Runtime
↓
OpenAI SDK变成:
text
Agent Runtime
↓
pi-ai
↓
Provider
↓
真实模型 APIpi-ai 当前提供的是一套统一的 LLM API,负责 Provider Collection、模型查询、认证解析、Streaming、Tool Calling、Token 与 Cost 等能力。
例如它可以先建立一组 Models:
typescript
const models = builtinModels();然后:
typescript
const model =
models.getModel(
"openai",
"gpt-4o-mini",
);调用时则统一:
typescript
const response =
await models.complete(
model,
context,
);如果换成 Anthropic,核心调用方式并不需要跟着完全重写:
typescript
const model =
models.getModel(
"anthropic",
"claude-sonnet-4-5",
);
const response =
await models.complete(
model,
context,
);Pi 当前的 Models Collection 会根据这个 Model 属于哪个 Provider,把请求路由到对应 Provider。
于是上面的 Agent Runtime 看到的仍然可以是:
text
Context
↓
Model
↓
Response而不是:
text
OpenAI Context
Anthropic Context
Google Context
……Model、Provider 和 API Protocol 不是一回事
这里有一个比较容易混淆的问题。
平时我们经常把:
text
Model
Provider
API混在一起说。
但 Pi 在这里把它们拆得比较清楚。
可以先看三个概念。
Model
Model 是具体要调用的模型。
例如:
text
某个 GPT 模型
某个 Claude 模型
某个 Gemini 模型它会包含:
text
model id
context window
是否支持 image
是否支持 reasoning
价格信息等模型级信息。
Provider
Provider 是真正的运行单元。
Pi 当前对 Provider 的定义很明确:
Provider 拥有自己的模型目录、认证方式以及 Stream 行为。
例如可以理解成:
text
Anthropic Provider
├─ 有哪些 Model
├─ API Key / OAuth 怎么获取
├─ 请求如何发送
└─ Stream 怎么处理Models Collection 再负责:
text
Model
↓
找到拥有它的 Provider
↓
Provider 执行API Protocol
这又是另外一层。
不同 Provider 并不意味着:
text
每个 Provider
=
一种完全不同的协议例如 Pi 当前文档明确区分了:
text
anthropic-messages
openai-responses
openai-completions这样的 API Implementation。
Anthropic Provider 可以走:
text
anthropic-messagesOpenAI 可以走:
text
openai-responses而 xAI、Groq、Cerebras、OpenRouter 等多个 Provider,则可以共享:
text
openai-completions这一类协议实现。
所以实际结构更像:
text
Model
↓
Provider
↓
API Implementation
↓
Remote API例如:
text
某个 OpenRouter Model
↓
OpenRouter Provider
↓
openai-completions
↓
OpenRouter API这个区分非常重要。
如果新加一个 Provider,本身就兼容现有协议:
text
Provider 配置
+
复用现有 API Implementation往往就已经能解决大量工作。
这也是为什么 Pi 可以同时支持:
text
很多 Provider但不意味着底层存在同样数量完全独立的 LLM 调用实现。
为什么切换模型时 Agent Loop 不应该重写
现在回到最重要的问题。
假设当前 Agent 正在执行:
text
修复登录测试失败的问题它的 Loop 是:
text
Model
↓
bash("npm test")
↓
Tool Result
↓
Model
↓
read(...)
↓
Tool Result
↓
Model现在把模型从:
text
GPT换成:
text
Claude这条执行链理论上有什么变化?
其实没有。
仍然是:
text
Model
↓
Tool Call
↓
Tool Result
↓
Model再换 Gemini:
text
Model
↓
Tool Call
↓
Tool Result
↓
Model也还是一样。
所以真正合理的边界应该是:
text
Agent Runtime
Agent Loop
│
│
▼
Unified Model API
│
┌────────────┼────────────┐
↓ ↓ ↓
OpenAI Anthropic Google而不是:
text
OpenAI Agent Loop
Anthropic Agent Loop
Google Agent Loop这其实和前面的 Tool 分层是同一个思想:
稳定的核心逻辑,不应该因为底层实现变化而不断重写。
Pi 的 Context 也专门设计成可以在模型之间传递。
它统一维护:
text
systemPrompt
messages
tools而不同 Provider 负责把这套通用 Context 转换成自己真正需要的请求格式。pi-ai README 也把 Context 描述为可以序列化并在模型之间转移的结构。
所以理论上可以出现:
text
前 10 轮
Claude
↓
同一个 Session / Context
↓
第 11 轮
GPTAgent 不需要因为模型变化就把任务从头开始。
Pi 的 Session 里甚至会专门保存:
text
model_change来记录中途切换过哪个 Provider 和 Model。
不同模型的能力其实也不完全一样
当然,统一接口并不意味着:
text
所有模型都完全一样这也是抽象模型层最容易走向另一个极端的地方。
不同模型可能有不同:
text
Context Window
是否支持图片
是否支持 Reasoning
Reasoning 参数
Tool Calling 行为
Streaming 行为所以 Pi 的 Model 本身会保存这些能力信息。
例如可以查询:
typescript
model.contextWindow
model.input
model.reasoning
model.api这意味着正确的统一不是:
假设所有模型都完全一样。
而是:
把共同部分统一,把真正不同的能力明确暴露出来。
这一点很重要。
Pi 在统一 stream()、complete() 等入口的同时,仍然允许对特定 API 使用对应的 Provider-specific Options。
所以结构其实是:
text
统一接口
+
必要时暴露 Provider 特有能力而不是:
text
强行把所有 Provider 做成完全一样Token、Cost 和 Auth 为什么也属于模型层
我们前面的 Mini Agent 对 Token 基本没有处理。
最多只是:
text
调用模型
↓
拿到 Response但真实 Coding Agent 长期运行以后,尤其是进入商业化阶段,我们就会关心:
text
这一轮用了多少 Token?
整个 Session 花了多少钱?
这个模型 Context Window 多大?
缓存命中了多少?
当前 Provider 怎么认证?这些信息其实都和:
text
Model / Provider紧密相关。
Pi 当前的 Assistant Message 会记录 Usage:
typescript
usage: {
input,
output,
cacheRead,
cacheWrite,
reasoning,
totalTokens,
cost: {
input,
output,
cacheRead,
cacheWrite,
total
}
}Session 因此可以把模型使用情况继续保存下来。
pi-ai 也直接负责 Token 和 Cost Tracking,而不是把价格计算塞进 Agent Loop。
这才是合理的。
因为:
text
Agent Loop根本不应该知道:
text
某个 Provider
输入 Token 单价是多少这种事情。
Auth 也是一样。
我们的 Mini Agent 直接:
typescript
process.env.OPENAI_API_KEY但多 Provider 以后可能出现:
text
API Key
OAuth
AWS Credential
Google ADC
Stored Credential
环境变量Pi 当前把认证解析交给 Provider。
当执行:
typescript
models.complete(model, context)时,Models Collection 会通过拥有该 Model 的 Provider 解析认证信息。
也就是说:
text
Agent Runtime只需要说:
text
“我要调用这个 Model。”不用自己负责:
text
“Anthropic 的 Key 在哪?”
“OpenAI 怎么认证?”
“Bedrock 用什么 Credential?”
“OAuth Token 过期了吗?”这些属于:
text
Provider Layer的职责。
所以现在回头看 Pi 最开始那三层:
text
pi-coding-agent
↓
pi-agent-core
↓
pi-ai其中最下面这个:
text
pi-ai已经不只是:
“帮我们换几个模型。”
它真正隔离的是一整组模型基础设施:
text
Model Catalog
Provider
API Protocol
Auth
Streaming
Tool Calling
Reasoning
Token
Cost这样上面的:
text
Agent Loop才可以尽量保持稳定。
不过前面还有一个细节我们一直没有展开。
我们的 Mini Agent 每次调用模型都是:
typescript
const response =
await client.chat.completions.create(...);然后一直:
text
等
↓
等
↓
等
↓
完整 Response 返回真实 Coding Agent 如果也这样做,模型思考几十秒、输出大量内容、开始生成 Tool Call 时,用户界面几乎什么都不知道。
所以 Pi 的模型层和 Agent Runtime 都大量建立在:
text
Streaming Event之上。
下一节继续看:
从一次性 Response 到 Streaming Event
前面的 Mini Agent 每次调用模型时,采用的都是:
typescript
const response =
await client.chat.completions.create({
model,
messages,
tools,
});程序执行到这里以后,就开始等待。
等模型把这一轮 Response 全部生成完成,我们才能知道:
text
模型说了什么
有没有 Tool Call
Tool 参数是什么
这一轮为什么停止对于最小 Agent,这样最简单。
但放到真实 Coding Agent 里,用户体验就会变成:
text
用户提交任务
↓
界面没有变化
↓
等待 5 秒
↓
等待 10 秒
↓
突然出现完整回答如果模型这一轮还包含 Thinking、Tool Call,甚至需要更长时间。
所以 Pi 并没有把一次模型调用设计成:
text
Request
↓
等待
↓
Response而是:
text
Request
↓
Event
↓
Event
↓
Event
↓
……
↓
完成这就是:
text
Streaming我们之前 await 一次模型响应
先看 Mini Agent 的处理方式。
大概是:
typescript
const response = await callModel();
const message =
response.choices[0].message;
messages.push(message);
if (message.tool_calls) {
// 执行 Tool
}整个过程只有两个明显状态:
text
调用前
调用后但模型真正生成 Response 时,中间其实经历了很多阶段:
text
开始响应
开始输出文本
文本持续增加
开始 Thinking
Thinking 持续增加
开始生成 Tool Call
Tool 参数持续生成
Tool Call 完成
整个 Response 结束这些信息如果全部等到最后才拿到:
text
Runtime
UI
用户都无法知道中间发生了什么。
Pi 的 pi-ai 则把这些变化统一成 Streaming Event。
当前事件大致包括:
text
start
text_start
text_delta
text_end
thinking_start
thinking_delta
thinking_end
toolcall_start
toolcall_delta
toolcall_end
done
error所以一次模型调用可能变成:
text
start
↓
thinking_start
↓
thinking_delta
↓
thinking_delta
↓
thinking_end
↓
text_start
↓
text_delta
↓
text_delta
↓
text_end
↓
toolcall_start
↓
toolcall_delta
↓
toolcall_delta
↓
toolcall_end
↓
done注意,这并不意味着每次响应一定会出现所有事件。
如果模型直接输出文本:
text
text就可能没有 Thinking。
如果没有调用 Tool:
text
toolcall_*也不会出现。
Runtime 只是拥有了观察这些过程的统一方式。
Pi 为什么把响应拆成 Event Stream
Streaming 最直观的好处当然是:
text
模型生成一点
↓
用户看到一点不需要等整个 Response 完成。
但对于 Agent 来说,它的意义不只是:
“打字机效果更流畅。”
更重要的是:
Runtime 能够观察一次模型调用内部正在发生什么。
Pi 的 Agent Loop 中,streamAssistantResponse() 会真正遍历模型返回的 Stream:
typescript
for await (const event of response) {
switch (event.type) {
// ...
}
}当模型刚开始响应:
text
startPi 会建立当前的:
text
partialMessage并发出:
text
message_start后面如果收到:
text
text_delta
thinking_delta
toolcall_delta就不断更新这条 Partial Message,并向上层发出:
text
message_update最后:
text
done或者:
text
error出现以后,再把 Partial Message 替换成真正完整的:
text
finalMessage并发出:
text
message_end所以内部关系大概是:
text
pi-ai
start
text_delta
thinking_delta
toolcall_delta
done
↓
pi-agent-core
message_start
message_update
message_update
message_update
message_end这又体现了 Pi 前面讲过的分层。
pi-ai 关心的是:
text
模型到底流出了什么而 pi-agent-core 更关心:
text
当前 Agent Message
发生了什么变化text、thinking、toolCall 是怎么流出来的
我们先看最容易理解的文本。
模型最终想输出:
text
我先运行测试,确认当前失败原因。Streaming 时不会一定一次性得到整句话。
而是类似:
text
text_start
text_delta:
"我先"
text_delta:
"运行测试"
text_delta:
",确认当前"
text_delta:
"失败原因。"
text_end上层只需要不断把:
text
delta追加到当前内容。
用户就能看到模型边生成边显示。
Thinking 也是类似的:
text
thinking_start
↓
thinking_delta
↓
thinking_delta
↓
thinking_end至于是否真正存在 Thinking、具体能够得到什么,则取决于当前模型和 Provider。
Pi 的模型层负责尽量把不同 Provider 的响应统一成同类 Event,而不是让上层 TUI 分别理解每家 Provider 的 Streaming 格式。
真正和 Agent 更相关的是:
text
toolcall假设模型准备调用:
json
{
"name": "read",
"arguments": {
"path": "src/auth/service.ts"
}
}这些 Arguments 同样不是一定一次性出现。
可能经历:
text
toolcall_start
↓
toolcall_delta
↓
toolcall_delta
↓
toolcall_end在 toolcall_delta 阶段:
text
Arguments 可能还只是 Partial JSONPi 的 pi-ai README 甚至专门把:
text
Streaming Tool Calls with Partial JSON作为 Tool Calling 的一部分。
这也解释了我们前面 Agent Loop 里讲过的那个细节:
“JSON 能解析”不代表这个 Tool Call 一定已经完整。
真正是否可以执行,必须等待模型这一轮结束,并结合:
text
stopReason等状态确认。
如果 Response 因:
text
length被截断,即使 Tool 参数看起来能够解析,Pi 仍然不会执行。
Streaming 和 Tool Safety 在这里其实是连在一起的。
Streaming 不等于直接把 Delta 塞进 Session
这里还有一个容易产生误解的地方。
既然模型每次:
text
text_delta都会产生新内容,是不是应该:
text
来一个 Delta
↓
Session 保存一次并不是。
Pi 在 Streaming 过程中维护的是:
text
Partial Assistant Message例如:
text
刚开始:
"我"
后来:
"我先运行"
后来:
"我先运行测试"
最后:
"我先运行测试,确认失败原因。"Agent Loop 会不断用最新 Partial Message 更新当前 Context 中的那条 Assistant Message。等最终:
text
done / error出现以后,再得到正式的:
text
finalMessage所以:
text
Streaming Event和:
text
Session Message仍然不是一回事。
我们可以理解成:
text
Event Stream
→ 描述“现在正在发生什么”
Final Message
→ 描述“这一轮最终产生了什么”这和前面区分:
text
Session
Context
Messages其实是同一种工程思想:
不要因为几个概念都和“模型输出”有关,就把它们设计成同一个东西。
为什么 Tool 也需要流式更新
这也是一个很有意思的问题。
模型自己的 Response 可以 Streaming。
那 Tool 呢?
假设模型执行:
bash
npm test测试可能需要 30 秒。
如果 Runtime 只是:
typescript
const result =
await bash.execute(...);那么用户体验又回到了:
text
开始执行 npm test
↓
等 30 秒
↓
突然出现全部结果所以真实 Coding Agent 里的 Streaming 还不应该只停留在:
text
Model StreamingTool 本身也可能需要:
text
Progress UpdatePi 的 Tool 执行接口就预留了:
text
onUpdate这样的能力。
bash 会在命令运行过程中持续收集 stdout / stderr,并能够向上层发送当前输出,而不是一定等进程结束才返回最终 Tool Result。
于是一次命令可以变成:
text
tool_execution_start
$ npm test
PASS auth.test.ts
PASS user.test.ts
FAIL token.test.ts
tool_execution_end用户可以实时知道:
text
命令还在执行
目前执行到哪
已经输出了什么这在:
text
安装依赖
执行测试
项目构建
运行脚本
长时间命令这些场景中非常重要。
所以完整一点看,一个 Coding Agent 实际上可能同时存在两类 Stream:
text
Agent Run
│
┌─────────────┴─────────────┐
↓ ↓
Model Stream Tool Stream
text_delta stdout / stderr
thinking_delta progress update
toolcall_delta最终再统一交给:
text
Agent Runtime上层 UI 不需要自己去监听:
text
OpenAI Stream
Anthropic Stream
Node Child Process stdout
Tool-specific callbacks这些完全不同的数据源。
它更适合面对:
text
Agent Events这样一套统一生命周期。
TUI 为什么建立在事件系统之上
现在我们就能理解,为什么前面 Agent Loop 中会出现这么多:
text
agent_start
turn_start
message_start
message_update
message_end
tool_execution_start
tool_execution_end
turn_end
agent_end这些 Event。
它们不是为了让 Agent Loop 显得复杂。
而是在给上层提供一个稳定的接口。
例如 TUI 可以监听:
text
message_start然后创建一个新的 Assistant 区域。
收到:
text
message_update就更新正在生成的文字。
收到:
text
tool_execution_start就显示:
text
Running npm test...收到 Tool Update,就实时刷新命令输出。
收到:
text
tool_execution_end再显示执行完成。
最终:
text
agent_end整个 Run 才真正结束。
于是 UI 可以只依赖:
text
Agent Events而不需要知道 Agent Loop 内部到底:
text
调用了哪个 Provider
模型用了什么 Streaming Protocol
Tool 内部用了 child_process 还是远程 SSH可以把这个结构画成:
text
Provider Stream
│
↓
pi-ai
│
Model Events
│
↓
pi-agent-core
│
Agent Events
│
┌──────────────┼──────────────┐
↓ ↓ ↓
TUI Session Extension也就是说:
Event System 实际上也是模块之间的解耦层。
如果以后不用终端,而是做:
text
Web UI
IDE Plugin
Desktop App
Remote Client底层 Agent Runtime 仍然可以继续发相同类型的生命周期 Event。
上层只需要换自己的表现方式。
不过,Streaming 解决的是:
text
用户能不能看到 Agent 正在做什么但它还没有解决另一个问题:
text
Agent 执行过程中,
用户能不能修改任务要求?Agent 执行过程中,用户还能发消息吗
前面的 Mini Agent 还有一个很明显的限制。
一旦执行:
typescript
await runAgent(task);后面基本就是:
text
Model
↓
Tool
↓
Model
↓
Tool
↓
……
↓
任务结束用户只能等。
但真实 Coding Agent 的任务可能持续几分钟甚至几十分钟。
这个过程中,用户很可能突然发现:
text
“先不要改数据库。”
“刚才那个方案不对,改接口层就行。”
“这个问题修完以后,再把测试补上。”这些话看起来都是“新消息”,但实际上有两种完全不同的意图:
text
现在就影响正在执行的任务和:
text
当前任务做完以后再处理Pi 分别把它们设计成:
text
Steering Message
Follow-up Message我们的 Mini Agent 运行以后只能等它结束
在我们之前的实现里:
typescript
while (true) {
const response = await callModel();
if (response.toolCalls) {
await executeTools();
continue;
}
return response.content;
}整个 Agent Loop 内部没有:
text
用户消息队列所以用户提交任务以后:
text
User
↓
Agent Run 开始
↓
……
↓
Agent Run 结束
↓
User 才能再次输入如果发现 Agent 正在做错事,最直接的办法只能是:
text
Abort
↓
重新发任务但很多时候没有必要彻底终止。
例如 Agent 已经:
text
读完代码
定位问题
开始修改这时只想补一句:
text
“不要修改 public API。”更合理的方式应该是:
text
让当前正在执行的工作停在一个合适的边界
↓
把这句话加入 Context
↓
让模型重新判断下一步这就是 Steering。
Steering Message
Pi 的交互模式允许 Agent 正在工作时继续输入消息。
默认情况下:
text
Enter会把这条消息加入:
text
Steering Queue官方文档给出的语义是:
Steering Message 会在当前 Assistant Turn 及其 Tool Calls 执行完成以后送入 Agent。
这里的“Steering”可以理解成:
text
给正在进行的任务纠偏例如原本:
text
User:
重构用户模块并修复测试
↓
Agent:
read(...)
↓
edit(...)
↓
bash("npm test")运行过程中用户又输入:
text
不要修改数据库 Schema,
尽量保持现有表结构。这条消息不会粗暴地插进:
text
正在执行到一半的 Tool而是等待当前 Turn 进入合适的结束位置。
然后:
text
当前 Tool Calls 完成
↓
Steering Message
进入 Context
↓
下一次 Model Call
↓
模型根据新要求重新判断Pi 的 Agent Loop 确实会在下一轮模型调用之前获取:
typescript
getSteeringMessages()如果存在 Pending Message,就先把它们加入当前 Context,再继续 Assistant Response。
这里为什么不直接打断正在执行的 Tool?
因为如果一个:
text
edit
write
bash已经执行到一半,随意插入新的模型决策会让状态更加混乱。
例如:
text
write 正在写文件
↓
用户发 Steering
↓
立刻启动下一轮 Model
↓
模型又开始 read 同一个文件这会引入不必要的并发和状态一致性问题。
所以 Steering 的含义不是:
text
任何毫秒级时刻立即抢占而是:
在当前执行单元结束后,尽快让新指令影响下一步。
Follow-up Message
但有些消息并不是想纠正当前任务。
例如 Agent 正在:
text
修复登录接口用户想到:
text
“这个修完以后,顺便把 README 里的示例更新一下。”如果把它作为 Steering:
text
当前任务执行到一半
↓
模型突然看到 README 要求模型可能提前改变当前计划。
这并不是用户想要的。
所以 Pi 还有第二个队列:
text
Follow-up在交互模式中,默认使用:
text
Alt + Enter提交 Follow-up Message。
它不会在当前 Agent 工作过程中插入,而是:
等 Agent 当前工作自然结束以后,再把这条消息交给它。
例如:
text
当前任务:
修复登录 Bug
↓
read
↓
edit
↓
test
↓
测试通过
↓
本来应该 Agent End此时 Runtime 发现还有:
text
Follow-up Queue于是继续:
text
“再把 README 示例更新一下。”
↓
新的 Turn
↓
Agent 继续工作Pi 的 Agent Loop 正好有一个外层循环。
当内部 Tool Loop 本来准备结束时,会调用:
typescript
getFollowUpMessages()如果拿到了 Follow-up,就不会真正 agent_end,而是把这些消息作为新的 Pending Messages,继续下一轮。
所以可以把两者放在一起理解:
| 类型 | 什么时候生效 | 适合什么场景 |
|---|---|---|
| Steering | 当前 Turn 的 Tool Calls 完成后 | 纠偏、补充约束、改变当前方向 |
| Follow-up | 当前工作全部完成后 | 追加下一项任务 |
例如:
text
Steering:
“别改数据库。”
“只修这个 Bug,不要重构。”
“刚才说错了,目标文件是 service.ts。”而:
text
Follow-up:
“修完以后补测试。”
“做完以后更新 README。”
“最后再跑一下 lint。”虽然它们最后都会变成:
text
User Message但:
消息什么时候进入 Agent Loop,会直接改变 Agent 的行为。
Abort 以后发生什么
当然,有时候不是纠偏,而是:
text
现在就不要做了。这时候才需要:
text
Abort前面 Agent Loop 一节已经讲过,Pi 会把 AbortSignal 向模型调用和 Tool Execution 继续传递。
这里再补一个和交互相关的细节。
Pi 当前交互模式中:
text
Escape不仅会 Abort 当前运行,还会把已经排队的 Steering / Follow-up Messages 恢复到编辑器中。
为什么要恢复?
假设用户已经输入:
text
Steering:
不要修改数据库
Follow-up:
最后补充测试然后突然发现:
text
整个任务方向都错了按下 Escape。
如果 Runtime 只是:
text
Abort
↓
把 Queue 全部清空用户刚才输入的内容也跟着丢了。
Pi 则选择:
text
Abort 当前 Run
↓
取回 Queued Messages
↓
放回 Editor
↓
用户可以继续修改后重新提交另外还要区分:
text
Steering和:
text
AbortSteering 是:
text
当前工作可以继续到合理边界
↓
下一轮换方向Abort 是:
text
当前正在发生的事情也应该停止所以如果 Agent 正在:
text
npm test而用户只是说:
text
下一步不要修改数据库没有必要杀掉 Test Process。
但如果用户发现:
text
执行的是错误脚本那就应该 Abort。
这两个操作解决的是不同问题。
为什么这其实也是 Agent Runtime 的一部分
Steering 和 Follow-up 看起来很像:
text
TUI 的两个快捷键功能但真正值得我们关注的是,它们为什么必须进入 Agent Runtime。
因为用户并不是永远只存在于:
text
Agent Run 开始之前实际上的人机协作更像:
text
User
↓
Agent 开始工作
↓
观察环境
↓
执行 Tool
↓
User 补充要求
↓
Agent 调整
↓
继续执行
↓
User 再追加任务
↓
继续也就是说:
Agent Run 本身可能是一个持续接受外部输入的过程。
这会直接影响 Agent Loop 的设计。
我们的 Mini Agent 是:
text
一个输入
↓
while(true)
↓
一个最终输出而 Pi 是这样的:
text
Agent Run
│
┌────────────┼────────────┐
↓ ↓ ↓
Model Tools User
│ │ │
│ │ Steering
│ │ Follow-up
│ │ Abort
└────────────┼────────────┘
↓
Runtime所以 Agent Runtime 不只是在调度:
text
Model ↔ Tool它实际上还要协调:
text
Model
Tool
User三者之间的状态变化。
这也解释了 Pi agent-loop.ts 为什么不是简单:
typescript
while (hasToolCall) {
// ...
}而是同时存在:
text
Inner Loop
→ Tool Calls + Steering
Outer Loop
→ Follow-up原本看起来只是:
text
多套了一层 while但现在我们就能理解它背后的语义:
text
Inner Loop
=
当前任务还在推进
Outer Loop
=
当前任务看似结束后,
检查用户是否还追加了工作从我们的 Mini Agent 到这里,Agent Loop 又多了一层能力:
text
最初:
Model
↓
Tool
↓
Result
↓
Model后来加入:
text
Model
↓
Tool
↓
Result
↓
Steering?
↓
Model整个任务准备结束时又要检查:
text
Follow-up?同时任何阶段还可能:
text
Abort最终就变成:
text
User
┌────────┼────────┐
↓ ↓ ↓
Steering Follow-up Abort
│ │ │
└────────┼────────┘
↓
Runtime
│
Model ↔ Tool一个真正面向用户的 Agent,不能假设用户只在任务开始前说一次话,然后一直消失到任务结束。
下一节我们继续回到一个前面已经出现很多次、但还没有真正展开的 Tool:
text
bash我们的 Mini Agent 里它只是:
typescript
exec(command)但到了真实 Coding Agent 中,Shell Command 还涉及进程生命周期、超时、流式 stdout、取消以及整个进程树的清理。
Bash:真实命令执行比 exec() 麻烦得多
前面的 Mini Agent 里,bash 几乎是四个 Tool 中最好理解的一个。
核心代码可以压缩成:
typescript
const { stdout, stderr } =
await execAsync(command, {
cwd: WORKSPACE_DIR,
});然后把结果返回给模型:
text
Command
↓
exec()
↓
stdout / stderr
↓
Tool Result作为教学实现,这已经足够了。
但到了真实 Coding Agent 中,Shell Tool 有一个特殊之处:
它启动的不是一个普通函数,而是一个真实操作系统进程。
一旦涉及进程,就会立刻出现:
text
进程什么时候结束?
运行几十分钟怎么办?
用户 Abort 怎么办?
子进程又启动了子进程怎么办?
stdout / stderr 怎么实时返回?
进程输出几十 MB 怎么办?所以 Pi 的 bash 已经不只是:
text
exec(command)而是一套小型的进程生命周期管理。
我们之前直接 exec(command)
Node.js 中:
typescript
exec(command)非常适合快速执行一条命令。
它帮我们隐藏了很多细节:
text
启动 Shell
收集 stdout
收集 stderr
等待进程退出
返回最终结果所以前面的 Mini Agent 只需要:
typescript
const {
stdout,
stderr,
} = await execAsync(command);就能完成:
text
npm test
git status
node app.js之类的任务。
但它带来的思维模型也非常简单:
text
执行命令
↓
等待
↓
拿结果而 Pi 当前实现 Bash Tool 时使用的核心能力是:
typescript
spawn(...)并且直接管理:
text
Child Process
stdout
stderr
AbortSignal
Timeout
Exit Code
Process Tree于是执行模型变成:
text
Command
↓
Spawn Process
↓
持续读取 stdout / stderr
↓
等待进程生命周期变化
↓
Exit / Abort / Timeout
↓
整理 Tool Result这才是 Coding Agent 真正需要的 Bash Tool。
Pi 怎么启动和终止进程
Pi 当前会先解析实际使用的 Shell,然后通过:
typescript
spawn(...)启动子进程。
执行时还会指定:
text
cwd
env
stdin
stdout
stderr等运行环境。
其中:
text
cwd尤其重要。
Coding Agent 执行:
bash
npm test和在另一个目录执行完全可能是两个不同的项目。
所以 Pi 在真正执行命令之前还会检查:
text
当前 Working Directory 是否存在如果目录已经不存在,就直接返回错误,而不是启动一个行为不确定的 Shell。
另外 Pi 并没有把 Bash Tool 的底层实现完全锁死在本地 spawn() 上。
它抽象了一层:
typescript
BashOperations核心接口大致是:
typescript
exec(
command,
cwd,
{
onData,
signal,
timeout,
env,
}
)默认实现当然是在本机启动 Shell。
但这层抽象意味着以后也可以把执行后端替换掉,例如交给远程环境,而上面的 Agent 仍然使用:
text
bash这个 Tool。
又一次体现了前面的原则:
text
Tool Interface
≠
底层执行环境命令一直不结束怎么办
这里是 Bash Tool 一个非常现实的问题。
假设模型调用:
bash
npm run dev这个命令本来就是:
text
启动开发服务器
↓
一直运行或者程序本身出现死循环:
bash
node broken-script.js如果 Runtime 什么都不做:
text
await commandAgent 就可能永远卡在这一轮 Tool Execution。
所以 Pi 的 Bash Schema 除了:
typescript
command还提供:
typescript
timeout?: number单位是秒。
例如可以执行:
json
{
"command": "npm test",
"timeout": 120
}Runtime 会把它转换成定时器。
如果时间到了:
text
Timeout
↓
终止进程
↓
结束 Tool Call而不是永远等下去。
Pi 还会校验 Timeout 必须:
text
是有限数字
大于 0
不超过底层 Timer 能处理的最大值这些虽然只是实现细节,但说明真实 Tool 参数不能只考虑:
text
模型会不会正常传值还要考虑:
text
模型传了异常值怎么办为什么 Pi 没有默认给所有命令强制超时
这里有一个值得注意的地方。
Pi 当前的 timeout 是:
text
Optional而且 Schema 明确写着:
text
no default timeout也就是说,如果模型没有指定:
typescript
timeoutPi 不会默认规定:
text
所有命令 30 秒后必须杀掉为什么这种设计合理呢?
因为 Coding Agent 执行的命令差异太大了:
text
git status
→ 可能不到 1 秒
npm test
→ 可能几十秒
npm install
→ 可能几分钟
大型项目 build
→ 可能更久如果统一设置一个很短的默认 Timeout:
text
可能安全了一点但也很容易把:
text
正常的长时间任务误判成:
text
卡死所以 Pi 选择:
text
默认不强制 Timeout
+
需要时显式提供 Timeout
+
用户仍然可以 Abort这里其实没有一个对所有 Agent 都正确的答案。
如果是受控的企业任务执行系统,也完全可以在 Pi 外层增加:
text
最大执行时长策略Pi Core 只是没有替所有使用场景做这个决定。
这也符合它一直强调的:
text
Minimal Harness思路。
stdout 太大以后怎么保存
这一部分前面 Tools 章节已经讲过,所以这里只补充它和进程执行的关系。
Bash 命令执行过程中:
text
stdout
stderr会不断产生数据。
Pi 当前不是等命令结束以后一次性读取,而是在:
typescript
child.stdout.on("data", ...)
child.stderr.on("data", ...)这一类数据到达时持续交给:
text
OutputAccumulator处理。
同时可以通过:
text
onUpdate把当前输出不断更新给上层。
所以:
bash
npm test执行一分钟时,TUI 不需要等一分钟以后才看到结果。
最后真正送进模型 Context 的结果,仍然遵循前面讲过的限制:
text
最多保留尾部 2000 行
或 50KB如果发生截断:
text
完整输出会另存到临时文件,并通过:
text
fullOutputPath保留下来。
所以这里实际上同时维护了两种需求:
text
Runtime:
我要完整处理进程输出
Model:
我只需要足够做下一步判断的信息再次印证前面的结论:
Tool Result 不应该机械等于底层程序产生的全部原始数据。
Abort 为什么要杀掉整个进程树
最后一个问题更容易踩坑。
假设 Agent 执行:
bash
npm test看起来 Runtime 只启动了一个:
text
npm进程。
但 npm 后面可能继续启动:
text
node
↓
test runner
↓
worker
↓
其他子进程最后实际结构比如:
text
Shell
↓
npm
↓
node
├─ Worker A
├─ Worker B
└─ Worker C如果用户 Abort 时只结束最上面的进程:
text
npm 被杀掉下面某些子进程可能仍然存在。
结果就是:
text
Agent 显示已经停止但后台:
text
服务器还在跑
Worker 还在跑
端口仍然被占用
CPU 还在消耗这显然不是一个完整的 Abort。
Pi 当前本地 Shell 执行会跟踪启动的 Child Process。
在非 Windows 平台上启动进程时还会设置:
typescript
detached: true并在:
text
Abort或者:
text
Timeout发生时调用:
typescript
killProcessTree(child.pid)而不是简单只结束当前 Child Process。
整个思路是:
text
Agent Abort
↓
AbortSignal
↓
Bash Tool
↓
Process Tree
↓
全部结束这就是为什么上一节提到:
text
Abort 是 Agent Runtime 能力还不够。
它必须真正传播到底层资源。
如果:
text
Model Stream 停了但:
text
Shell Process 还在后台运行那么这个 Abort 其实没有完成。
我们前面的 Mini Agent 还尝试通过:
text
Workspace Path做一些范围限制。
但 Pi 默认到底有没有类似的安全边界?
Project Trust 又是不是 Tool Permission?
权限和安全:Pi 也没有替我们解决所有问题
到这里,我们已经让 Coding Agent 拥有了非常强的能力:
text
read
write
edit
bash这意味着模型不只是“回答问题”,而是真的可以:
text
读取本地文件
修改代码
创建文件
执行 Shell Command
启动其他程序能力越强,安全问题就越不能绕开:
Agent 到底被允许做什么?
实际上,Pi 很明确地区分了:
text
Project Trust
Tool Permission
Sandbox但是它们并不是一回事。
我们之前尝试限制 workspace 路径
前面的 Mini Agent 里,我们专门定义了:
typescript
const WORKSPACE_DIR = path.resolve("workspace");然后文件工具基本都会围绕这个目录解析路径:
typescript
function resolveWorkspacePath(inputPath: string) {
return path.resolve(
WORKSPACE_DIR,
inputPath,
);
}这至少建立了一个概念:
text
Agent 工作的项目
=
workspace/但是:
工作目录不等于安全边界。
我们前面的教学实现并没有真正构建一个 Sandbox。
例如仅仅:
typescript
path.resolve(WORKSPACE_DIR, inputPath)并不能自动保证结果一定仍然位于 WORKSPACE_DIR 中。
像:
text
../../outside.txt这样的路径仍然可能解析到 Workspace 外部。
即使我们再补充严格的路径检查:
text
resolvedPath
必须位于
WORKSPACE_DIR问题也没有彻底解决。
因为还有:
text
Symbolic Link
bash
子进程
网络访问
环境变量
本机凭据这些能力。
尤其是 bash。
即使:
text
read / write / edit全部限制在 Workspace 内,模型仍然可能执行:
bash
cat ~/.ssh/config或者:
bash
cd .. && ls所以:
text
限制文件 Tool 的 Path最多解决:
text
文件 Tool 的访问范围不能自动变成:
text
整个 Agent 的安全边界Pi 默认到底拥有什么权限
Pi 在当前的文档中直接说明:
Pi 以启动它的用户账户权限运行。
也就是说,如果当前用户本来能够:
text
读取某个文件
修改某个目录
运行某条命令
访问某个凭据那么 Pi 的本地 Tool 通常也处在同一个权限边界中。
Pi 当前并没有内置 Sandbox。
它的:
text
read
write
edit
bash会以 Pi 进程本身拥有的系统权限工作。
Extension 作为 TypeScript Module,同样运行在这个权限环境中。
所以默认结构大概是这样:
text
当前用户账户权限
│
┌──────────┼──────────┐
↓ ↓ ↓
Pi Built-in Extension
Tools
│
↓
本地操作系统而不是:
text
Pi
↓
自动进入一个受限 Sandbox
↓
只能访问当前项目官方文档明确说明这是有意的设计选择。
Pi 本身就是一个:
text
Local Coding Agent它需要:
text
访问源码
运行项目工具链
执行测试
调用包管理器
和本机开发环境集成如果在进程内部实现一个“不完整的 Sandbox”,很容易误以为:
text
“这个工作区已经安全隔离了。”但实际上底层仍然依赖:
text
Host Shell
Filesystem
Package Manager
Credentials
Extension Code这样的环境。
所以 Pi 的选择是:
核心 Harness 不伪装成安全边界,真正的隔离交给操作系统或虚拟化环境。
Project Trust 不等于 Tool Permission
这里最容易混淆的就是:
text
Project TrustPi 启动某个项目时,如果里面包含项目级资源,例如:
text
.pi/settings.json
.pi/extensions/
.pi/skills/
.pi/prompts/
.pi/SYSTEM.md等内容,可能会要求用户先决定:
text
是否信任这个 Project这个机制解决的是:
一个刚打开的仓库,能不能自动改变 Pi 自己的配置和加载可执行扩展。
例如下载了一个陌生项目:
text
some-project/
└── .pi/
└── extensions/
└── dangerous.ts如果 Pi 什么都不问就直接执行:
text
project-local extension显然风险很大。
所以 Project Trust 会控制这些项目级资源能不能被加载。
但它并不意味着:
text
“不信任 Project”
=
“read/write/bash 被沙箱限制住了”Pi 的安全文档对此说得非常清楚:
Project Trust 是输入加载保护,不是 Sandbox。
也就是说:
text
Project Trust主要控制:
text
要不要加载项目自己的 Settings
要不要加载项目 Extensions
要不要加载项目 Packages
要不要加载项目资源而:
text
Tool Permission回答的是另一个问题:
text
模型调用 bash 时能不能执行这条命令?
read 能不能访问这个文件?
write 能不能修改这个路径?两者完全不是一个层级。
甚至像:
text
AGENTS.md
CLAUDE.md这样的 Context File,在当前 Pi 中也不属于 Project Trust 所保护的可执行资源;只要没有关闭 Context Loading,它们仍然可以被加载。
所以 Project Trust 也不能解决:
text
Prompt Injection之类的问题。
仓库中的:
text
README
注释
文档
Context File
Build Output都可能包含模型会读取的内容。
Pi 官方安全文档也明确把这类本地内容导致的 Prompt Injection 视为 Local Agent 本身需要面对的风险,而不是 Project Trust 可以彻底消除的问题。
如何通过 Extension 加 Permission Gate
不过 Pi 并不是说:
text
“权限完全没法管。”它只是没有在 Core 里强制提供一套固定权限模型。
如果希望实现:
text
危险命令执行前询问用户可以通过 Extension 拦截:
text
tool_callPi 仓库本身就提供了一个:
text
permission-gate.ts示例。
它的核心思路非常简单。
先监听 Tool Call:
typescript
pi.on("tool_call", async (event, ctx) => {
// ...
});如果发现:
text
bash正在准备执行危险命令,比如:
text
rm -rf
sudo
chmod 777
chown 777就先暂停真正执行。
然后询问用户:
text
Allow?
Yes
No如果拒绝:
typescript
return {
block: true,
reason: "Blocked by user",
};于是流程从:
text
Model
↓
Tool Call
↓
Execute变成:
text
Model
↓
Tool Call
↓
Permission Gate
↓
允许?
├─ Yes → Execute
└─ No → Block这种 Extension 还能继续扩展。
例如可以实现:
text
禁止读取 .env
禁止修改 node_modules
删除文件必须确认
git push 必须确认
访问 Workspace 外路径必须确认
某些命令只允许在特定目录运行所以:
text
Permission Policy完全可以建立在 Pi 的 Extension Hook 上。
但这里仍然要注意:
Permission Gate 也不是 Sandbox。
Permission Gate 依赖的是:
text
Runtime 正确识别 Tool Call
+
Policy 正确覆盖危险行为如果规则只写了:
text
禁止 rm -rf并不代表所有具有删除效果的命令都被禁止。
更重要的是,Extension 自己也是在 Pi 进程中运行的,它拥有和 Pi 相同的系统权限。
真正的隔离为什么还是需要 Sandbox
如果场景只是:
text
自己的代码
自己的电脑
自己一直看着 Agent 工作这种本地开发方式可能已经符合使用需求。
但如果变成:
text
不可信代码仓库
自动执行生成代码
无人值守 Agent
服务器上的自动任务
允许 Agent 长时间自主运行风险模型就完全不同了。
这时候真正需要的是:
text
Sandbox而不是再写几十条 Prompt:
text
不要删除文件
不要读取敏感信息
不要运行危险命令因为 Prompt 是:
text
模型行为指导不是:
text
权限控制机制Pi 官方推荐真正需要隔离时使用:
text
Container
VM
Micro-VM
Remote Sandbox
Policy-controlled Sandbox并且只把任务真正需要的资源暴露进去。
例如:
text
Host
│
↓
┌─────────────────┐
│ Sandbox │
│ │
│ Pi │
│ ↓ │
│ Tools │
│ │
│ /workspace │
│ limited env │
│ limited net │
└─────────────────┘这样即使模型真的执行:
bash
cat ~/.ssh/id_rsaSandbox 里面根本没有这个文件。
即使运行:
bash
rm -rf /能够破坏的也只是隔离环境允许它访问的范围。
这才是真正意义上的:
text
Capability Boundary实际部署时,还可以继续收紧:
text
只挂载需要的 Workspace
不挂载 Host 的 ~/.pi/agent
只提供任务需要的 API Key
优先使用短期 Credential
没有联网需求就限制 Network
只读任务使用 Read-only MountPi 的安全文档也特别提醒:
如果把 Host Workspace 以:
text
read/write bind mount挂进 Container,那么 Sandbox 里的写操作仍然可以修改 Host 文件。
所以:
text
用了 Container也不自动等于:
text
宿主文件绝对不会被修改真正的权限取决于你给这个 Sandbox 暴露了什么。
现在可以把这几个安全机制放到一张图里:
text
Agent Security
│
┌──────────────┼──────────────┐
↓ ↓ ↓
Project Trust Permission Gate Sandbox
│ │ │
↓ ↓ ↓
是否加载项目资源 是否允许 Tool Call 系统实际允许
和可执行 Extension 执行 访问什么三者解决的是完全不同的问题。
其中:
text
Project Trust保护的是 Pi 启动时加载什么;
text
Permission Gate控制的是应用层允许 Agent 做什么;
而:
text
Sandbox才负责最终的:
即使 Agent 想做某件事,它到底有没有能力做到。
安全边界不能只存在于 Prompt 或 Agent 代码里,真正需要隔离时,边界必须下沉到运行环境。
Pi 没有试图把所有安全策略塞进 Core。
而这其实正好引出了它整个架构中另一个很明显的特点。
我们已经见过:
text
Permission Gate → Extension
Skills → 外部资源
Project Context → Context FilesPi 还有很多常见 Coding Agent 功能,也没有默认塞进核心。
这并不是因为它做不到,而是它刻意选择:
text
保持 Core 小
+
把工作流差异留给扩展层当功能越来越多:为什么 Pi 没有把所有东西塞进核心
看到这里,其实已经能发现 Pi 一个非常稳定的设计倾向:
text
核心只保留通用能力
+
具体工作流尽量往外扩展前面已经出现过很多例子。
比如:
text
权限确认
→ Extension
专项工作流程
→ Skill
项目规则
→ AGENTS.md
可复用任务指令
→ Prompt TemplatePi 当前文档甚至直接写明了:
Pi 会保持 Core 尽量小,把工作流相关行为放到 Extensions、Skills、Prompt Templates 和 Packages 中。
我们后续做自己的 Agent 的时候,也要考虑:
当用户不断提出新需求时,哪些应该进入 Core,哪些不应该?
Extensions
Extensions 是 Pi 最强的一层扩展机制。
当前 Pi 的 Extension 本质上是:
text
TypeScript Module它可以:
text
监听 Agent 生命周期事件
拦截或修改 Tool Call
注册新的 Tool
添加命令
注入 Context
修改 Compaction 行为
增加 UI 交互前面讲 Permission Gate 时已经见过:
typescript
pi.on("tool_call", ...)这个 Hook。
同样的机制还可以实现很多并不适合所有用户的功能。
例如:
text
危险命令确认
Git Checkpoint
自动保护某些路径
自定义 Tool
自定义状态
特殊工作流所以 Extension 是:
给 Runtime 增加新的行为。
它改的不只是 Prompt,而是真的可以改变 Agent 怎么运行。
这和直接不断修改 Pi Core 有很大区别。
假设有用户需要:
text
每次 edit 之前自动 git commit这显然不是每个 Coding Agent 用户都需要。
如果直接写进 Core:
text
所有人都要承担
这套功能的代码
配置
状态
测试
边界情况但做成 Extension:
text
需要的人加载
不需要的人完全不用管核心 Agent Loop 仍然可以保持稳定。
Skills
Skills 解决的又不是 Runtime 行为,而是:
text
模型如何完成某一类专项任务Pi 当前把 Skill 描述为:
可以按需加载的、自包含的能力包,其中可以包含专项工作流、配置说明、辅助脚本和参考文档。
例如:
text
database-migration
frontend-design
release
security-review这些能力不一定需要增加新的 Runtime 机制。
很多时候只是:
text
遇到这一类任务时
应该按照什么步骤做
应该读取哪些资料
应该调用哪些已有 Tool所以更适合放进:
text
SKILL.md而不是写进 Pi Core。
前面的 System Prompt 一节也讲过:
Pi 不会默认把所有 Skill 正文全部塞进 Context。
它主要先把:
text
name
description
location告诉模型,真正任务匹配时再读取完整 Skill。
所以 Skill 的核心价值可以概括成:
text
不修改 Agent Runtime
↓
给 Agent 增加专项做事方法Prompt Templates
Prompt Template 更轻。
Pi 当前的 Prompt Template 就是:
可以展开成完整 Prompt 的 Markdown 片段。
例如有一个:
text
review.md里面写:
markdown
Review this code for bugs, security issues,
and performance problems.之后可以通过:
text
/review直接展开。
它解决的是:
text
经常重复输入同一类任务而不是:
text
增加新的 Agent 能力这几个概念可以简单区分:
| 机制 | 主要改变什么 |
|---|---|
| Extension | Agent Runtime 的行为 |
| Skill | Agent 完成专项任务的方法 |
| Prompt Template | 用户重复输入的任务 Prompt |
| AGENTS.md | 当前项目长期规则 |
比如:
text
“每次危险 Bash 都必须确认”更适合:
text
Extension而:
text
“做数据库迁移时按照这套流程检查”更适合:
text
Skill如果只是:
text
“每次 Code Review 都使用相同的要求”则更适合:
text
Prompt Template它们解决的问题本来就不是同一种。
Pi Packages
当 Extension、Skill、Prompt Template 越来越多以后,又会出现一个新问题:
text
怎么分享?
怎么安装?
怎么复用?所以 Pi 又提供了:
text
Pi PackagesPackage 本身不是一种新的 Agent 能力。
而是:
把前面的扩展资源打包起来。
Pi 当前 Package 可以组合:
text
Extensions
Skills
Prompt Templates
Themes然后通过:
text
npm
或
git进行分发。
例如某个团队可以做一个:
text
company-agent-package里面同时包含:
text
company-security-extension
database-migration-skill
release-skill
review-prompt
内部 Theme其他成员安装这个 Package,就能得到同一套 Agent 工作环境。
所以可以把这个关系理解成:
text
Pi Core
│
├─ Extension
│ └─ 改 Runtime
│
├─ Skill
│ └─ 加专项工作流
│
├─ Prompt Template
│ └─ 加复用 Prompt
│
└─ Pi Package
└─ 把这些资源打包分发这种结构更容易长期维护和分发。
为什么 Pi 默认没有 Plan Mode 和 Sub-Agent
这里最能体现 Pi 的取舍。
现在很多 Coding Agent 都会强调:
text
Plan Mode
Sub-Agent
Todo
Background Bash
MCP
Permission Popup但 Pi 当前明确表示:
text
这些并不是默认 Core 功能官方设计原则中甚至直接列出:
text
MCP
Sub-Agents
Permission Popups
Plan Mode
To-dos
Background Bash都不会默认塞进 Pi Core,需要时可以通过 Extension、Package,或者外部工具实现。
这并不代表这些能力:
text
没有价值恰恰相反,它们很多都非常有用。
问题在于:
它们是不是所有 Agent 都必须拥有的基础能力?
例如 Plan Mode。
一种 Coding Agent 可能希望:
text
先完整规划
↓
用户批准
↓
再执行另一种可能更适合:
text
读一点
做一点
验证一点
不断调整如果 Pi Core 强制规定:
text
所有任务必须先 Plan那其实已经替所有用户选择了一种工作流。
Sub-Agent 也是一样。
一种系统可能设计成:
text
Main Agent
├─ Research Agent
├─ Coding Agent
└─ Review Agent另一种系统则可能坚持:
text
一个 Agent
+
更好的 Tools
+
更好的 Context到底哪一种更适合,并不存在统一答案。
所以 Pi 更倾向:
text
Agent Core 不替你决定如果确实需要 Plan Mode,Pi 仓库甚至已经有 Extension 示例来实现这一类工作流,而不是必须修改 Core。
这背后其实是一条很重要的工程原则:
text
通用机制
放 Core
个性化选择
放 Extension例如:
text
Agent Loop
Tool Execution
Session
Compaction
Streaming
Abort这些能力几乎所有真实 Agent 都会需要,所以适合成为基础设施。
而:
text
是否必须 Plan
是否使用多个 Agent
什么命令需要确认
是否自动 Git Checkpoint这些就没有必要全部写死在 Core。
现在再看 Pi 整体,可以把它的设计压缩成:
text
Stable Core
Agent Loop
Tool Runtime
Session
Context
Model Layer
Events
│
↓
Extension Points
┌────────┼────────┐
↓ ↓ ↓
Extension Skills Templates
│
↓
Packages所以:
真实 Agent 工程,并不是功能越多越完整。
更重要的是:
核心应该提供稳定机制,同时给变化频繁的工作流留下扩展空间。
这也是 Pi 的 Minimal Philosophy 真正值得学习的地方。
总结
从最小 Coding Agent 一直看到 Pi,我们会发现发生变化的,并不是那条核心的 Agent Loop。
最开始我们写的是:
text
Model
↓
Tool Call
↓
Tool Result
↓
Model到了 Pi,这条主线依然存在。
真正增加的,是围绕这条 Loop 建立起来的一整套工程基础设施:
text
Coding Agent
│
┌──────────────┼──────────────┐
↓ ↓ ↓
Model Runtime Tools
│ │ │
Provider Agent Loop read/edit
Streaming Events write/bash
Auth / Cost Abort
│
┌──────────────┼──────────────┐
↓ ↓ ↓
Session Context Extension
│
Compaction模型层需要处理不同 Provider、认证、Streaming、Token 和 Cost。
Runtime 不只是一个 while(true),还要处理 Tool 生命周期、错误、Abort、用户 Steering 和 Follow-up。
Tool 也不只是几段函数,还要面对输出截断、并发修改、进程管理和执行环境。
任务变长以后,还需要 Session 保存完整历史,再通过 Compaction 控制真正进入模型的 Context。
而当需求继续增加时,Pi 又没有选择把所有功能都塞进 Core,而是把更多变化留给 Extensions、Skills、Prompt Templates 和 Packages。
所以从我们做的最小 Coding Agent 到 Pi,真正增加的不是:
一个越来越复杂的 Agent Loop。
而是:
一整套让这条 Loop 能够长期、稳定、可扩展地运行下去的工程能力。
这也是 Pi 的 Minimal 思路最值得我们学习的地方。
Minimal 并不意味着功能少,也不意味着代码越少越好。
而是核心功能流程应该足够稳定,同时给用户留出扩展空间。
我们后续再去看新的 Agent Framework,会发现其实很多看起来完全不同的实现,底层依然绕不开这些问题:
text
模型怎么调用
工具怎么执行
状态怎么保存
Context 怎么控制
执行过程怎么观察
用户怎么介入
权限边界在哪里
功能应该放进核心还是扩展前面我们从零写最小 Agent,是为了看清 Agent 最核心的 Loop。
这一篇再看 Pi,则是为了理解:
一条简单的 Loop,真正走向一个完整 Coding Agent 时,还需要补上哪些工程能力。
到这里,「入门」栏目也就完成了它最重要的任务。
后面再进入具体框架、Context Engineering、Memory、MCP、Skills、Multi-Agent、Evaluation 等内容时,我们讨论的也不再是一堆孤立的名词,而是在不断完善同一个 Agent 系统。