引言
假设你是一个刚开始学习 Agent 的开发者。你已经跟着教程写过几次 LLM 调用:给模型一段 prompt、定义一个天气或搜索工具、把工具结果再发回模型。示例能够跑通,但当你想继续追问时,问题很快出现:
- 多轮任务怎样结束?
- 工具报错后模型如何调整?
- 上下文越来越长怎么办?
- 任务中断后怎样恢复?
这时转去阅读成熟的 Coding Agent,体验通常又是另一种挫败。
最典型的代表便是Claude Code和Codex,它们作为生产级产品,还要考虑处理界面、权限、跨端会话、复杂错误处理、平台适配和生态集成等问题。对初学者来说,Agent 的核心循环往往被这些产品层代码包围,难以从中切入去学习。
简单教程不够深入,成熟项目又无从下手,学习路径恰好断在了这里。
PI 恰好位于两者之间:它不是只演示一次工具调用的玩具项目,也没有把 Agent 的关键控制流淹没在大量产品实现中,覆盖了理解Coding Agent核心运行机制所需的关键部分:上下文处理与压缩,工具调用,Skill注入,模型适配,会话持久化和Agent Loop等等。PI的价值不在于功能更少,而在于把Agent的核心控制流程都保留在了清晰的代码中
同时,Pi具有极大的拓展性,你可以在学习更高级的Agent知识的同时在Pi上实现相应的功能:
- 更成熟的上下文压缩策略
- 多Agent调用与子Agent派发
- 接入MCP服务
PI的设计也在鼓励使用者在其上持续扩展,设计出结合具体场景的生产级Agent
1. PI 总体介绍
1.1 PI 是什么?
PI 是一个用 TypeScript 编写、运行在 Node.js 上的开源 Coding Agent。它提供终端 CLI,也把模型调用、Agent Loop、会话和终端交互拆成独立模块。
| 模块 | 功能 |
|---|---|
| packages/ai | 统一调用不同模型与 Provider |
| packages/agent | 实现 Agent Loop、消息状态和工具调用 |
| packages/coding-agent | 提供 CLI、内置工具、会话与上下文管理 |
| packages/tui | 负责终端交互界面 |
1.2 核心循环与拓展接口
PI 的核心循环只负责推进任务:向模型发送当前上下文,执行模型返回的工具调用,再将工具结果写回上下文。
packages/agent/src/agent-loop.ts 直接实现了这条流程:
- 循环从 assistant message 中取出
toolCall,执行工具后追加toolResult - ReAc执行流程下,如果还有工具调用,就继续请求模型。
2. 完整Agent Loop的流程
Agent Loop 可以完成短任务。要让 Agent 在真实项目中连续工作,还要解决模型上下文、工具执行和任务恢复的问题。PI 将这些问题拆为四条链路:
| 机制 | 要解决的问题 | PI 中的主要实现路径 |
|---|---|---|
| 会话存储 | 对话如何持久化,以及回退后如何形成分支? | pi/packages/coding-agent/src/core/session-manager.ts、pi/packages/coding-agent/src/core/agent-session.ts |
| 上下文 | 本次请求需要给模型哪些规则、历史和外部信息? | pi/packages/coding-agent/src/core/resource-loader.ts、pi/packages/coding-agent/src/core/system-prompt.ts、pi/packages/coding-agent/src/core/agent-session.ts |
| 压缩策略 | 历史超过模型窗口后,怎样保留可继续工作的信息? | pi/packages/coding-agent/src/core/compaction/compaction.ts |
| 工具调用 | 如何根据当前任务选择、执行并返回工具结果? | pi/packages/agent/src/agent-loop.ts、pi/packages/coding-agent/src/core/tools/ |
下面先看 PI 提供的工具,再按这四条链路分析 Agent Loop。
2.1 Tools
PI 默认启用四个工具,足以完成基本的代码阅读、修改和验证:
| 工具 | 作用 |
|---|---|
read | 读取文件内容 |
bash | 执行命令,例如搜索代码或运行测试 |
edit | 修改已有文件中的指定内容 |
write | 写入文件 |
仓库还内置
grep:搜索文件内容find:查找文件ls:列出目录powershell:执行 PowerShell 命令。- 这些工具有现成实现,但不属于默认启用的四个工具;需要时可以在工具配置中选用。
源码入口:全部内置工具、默认工具组合。
2.2 上下文
每次API调用都是无状态的,单次API调用的不会自动知道当前Agent对话的上下文,所以Agent Harness需要在每次请求前组装上下文。而且相应的上下文组装策略也需要斟酌,不能一股脑地将不同来源的上下文杂糅到一起,这样会稀释上下文,需要将不同来源的信息放在不同层次。
| 信息来源 | PI 的处理方式 |
|---|---|
| 项目规则 | 加载AGENTS.md、CLAUDE.md项目约束提示词 |
| Skill | 先提供名称和描述;任务匹配时再读取 Skill 正文 |
| 会话历史 | 只投影当前会话分支,而不是整棵会话树 |
| 工具结果 | 作为toolResult留在消息历史中,供下一次模型请求使用 |
请求发送前,PI 会先按需调用transformContext。它接收当前的应用层消息列表,可用于筛选历史消息、调整顺序或注入额外信息;未配置时,消息保持原样。随后,convertToLlm 将其中的自定义消息转换为通用的 LLM 消息,并过滤不应进入模型上下文的消息。具体 Provider 的请求格式由后续模型调用层处理。
源码入口:资源加载、系统提示词、消息转换。
2.3 会话存储
PI 默认用一个 JSONL 文件保存一次会话。首行是会话信息,后续每行是一条 entry;节点共有的id、parentId和timestamp字段定义在 SessionEntryBase。
model_change记录模型切换:
{ "type": "model_change", "id": "b2c3d4e5", "parentId": "a1b2c3d4", "timestamp": "2024-12-03T14:05:00.000Z", "provider": "anthropic", "modelId": "claude-sonnet-4-5" }compaction记录压缩摘要及从哪个节点开始保留原始内容:
{ "type": "compaction", "id": "c3d4e5f6", "parentId": "b2c3d4e5", "timestamp": "2024-12-03T14:10:00.000Z", "summary": "已完成项目结构分析,下一步检查工具调用。", "firstKeptEntryId": "a1b2c3d4", "tokensBefore": 50000 }branch_summary把离开旧分支时生成的摘要接到新分支上:
{ "type": "branch_summary", "id": "d4e5f6a7", "parentId": "a1b2c3d4", "timestamp": "2024-12-03T14:15:00.000Z", "fromId": "c3d4e5f6", "summary": "原分支尝试了方案 A,但尚未完成验证。" }此外还有记录推理级别的 thinking_level_change、记录用量的 usage,以及标记节点的 label 等节点分类
PI 按节点增量记录会话,而不是等任务结束后一次性保存。AgentSession 的 message_end 事件处理会把完成的用户、助手和工具结果消息交给 SessionManager.appendMessage。
新建会话时,PI 先把会话信息和用户消息暂存在内存里,此时还没有创建 JSONL 文件。模型的第一条回复生成完毕后,PI 才创建文件,把内存中已有的记录一次写入。此后每产生一条新记录,就直接追加到文件末尾。
PS:PI 进程在第一条助手消息写入会话前就退出,这次会话可能无法恢复。
回退
回退时,PI 不删除旧节点,而是通过 navigateTree 移动当前节点。
- 如果选中一条旧的用户消息,PI 会把当前节点移到它的父节点,并把原消息放回输入框供修改
- 再次发送后,新消息以该父节点为
parentId,形成另一条分支。
回退后,getBranch 只沿当前节点的parentId找到这条分支。模型收到的消息再由 buildSessionProjection 生成。会话树中选中一条旧消息,只是把 PI 当前的位置移过去。这一步不会修改会话文件。你从这里发送新消息后,PI 才会把新消息接在所选消息后面,形成一条新分支,并写入同一个 JSONL 文件。原来的分支仍然保留。如果选择“总结旧分支”,PI 也会写入一条摘要消息。
PS:切换会话分支只影响聊天记录,不会把此前工具修改过的项目文件恢复原状。
2.4 压缩
长任务会不断积累用户消息、模型回复和工具结果。PI 不直接删除旧消息,而是在当前分支的上下文接近模型窗口上限时,将较早的内容概括成摘要。启用自动压缩时,判断条件由 shouldCompact 实现,有两个关键的变量:
reserveTokens:当上下文窗口的剩余token达到reserveTokens就会触发压缩keepRecentTokens:是压缩时尽量保留的近期消息量的Token数。
前者决定何时压缩,后者决定保留的消息token
确定边界。prepareCompaction 根据当前分支的路径,从最新消息向前累计 token,找到尽量保留约
keepRecentTokens的位置。firstKeptEntryId指向第一个原样保留的节点。切点不能直接落在toolResult上,否则可能只保留工具结果,却丢掉对应的工具调用;如果单次用户任务本身太长,也可以在助手消息处切开,并单独概括此前的部分。切点选择生成摘要。compact 将边界之前的消息交给模型总结,而不是机械截断文本。摘要重点保留任务目标、约束、进度、关键决策和下一步;如果之前压缩过,还会结合旧摘要更新。摘要请求
保存并重建上下文。PI 追加一个包含
summary、firstKeptEntryId等字段的 compaction 节点。下一次请求时,buildContextEntries 选出该压缩节点和从firstKeptEntryId起保留的节点;消息投影 再将压缩节点展开为系统提示词状态和摘要。旧节点仍在 JSONL 会话文件中;压缩改变的是模型看到的历史,不是原始记录。
触发方式:
- 自动触发:会在工具结果写入后、下一次助手回复前等时机执行
/compact手动触发
若模型请求发生上下文溢出时,PI 压缩完上下文后,会重试发送对话
3. 从 PI 开始扩展自己的 Agent
学习 PI 的下一步,是在不断的Agent学习中,以PI为基础,扩展出更多Agent能力
| 扩展方向 | 可以做什么 |
|---|---|
| 压缩策略 | 根据任务定制摘要,保留关键约束、进度和待办事项 |
| MCP | MCP Server 提供的工具注册成 PI 工具 |
| 多 Agent | 将派发子Agent注册为tool,要处理独立子任务时调用,结束后向主 Agent 返回结果 |
| Hook | 在特定的时间节点注入信息、拦截工具调用或处理结果 |
扩展时可以参考PI官方源码自带的示例,参考其实现,慢慢推进。