从 Agent 循环到上下文压缩:结合源码读懂 Pi 编程 Agent 的内部架构
我是安徽最忧郁程序员无隅

很多人第一次接触编程 Agent,容易把它理解成"给大模型加几个读文件、执行命令的工具"。但真正决定 Agent 是否可扩展、可观察、可长期运行的,不是工具数量,而是模型、工具、上下文、会话和 UI 之间有没有清晰边界。
Pi 是一个很适合拿来拆解的案例:它把核心运行时做得很小,再把终端交互、会话、扩展、Skills 等能力放到上层。这篇文章不把 Pi 当作一个命令行产品来介绍,而是借它回答一个更通用的问题:一个生产级编程 Agent 到底由哪些机制组成?
文中架构结论基于 Pi 官方开源仓库与文档整理;具体默认值、配置项和目录可能随版本变化,落地时请以官方文档为准。
一、先抓住本质:Pi 不是一个"大 Prompt",而是一套可分层的运行时
从官方仓库的包划分看,Pi 至少可以被理解成四层能力:
| 层级 | 代表包 | 负责什么 |
|---|---|---|
| Provider 适配层 | pi-ai |
统一不同模型提供商的流式调用接口 |
| Agent 运行时 | pi-agent-core |
维护状态、协调模型调用与工具调用 |
| 终端渲染层 | pi-tui |
负责终端组件与差量渲染 |
| 编程 Agent 应用层 | pi-coding-agent |
负责会话、提示词、工具、扩展、Skills 和交互模式 |
最重要的边界是:TUI 不等于 Agent。
终端 UI 只是事件的一个消费者。只要核心运行时把"模型开始流式输出""工具开始执行""工具产生进度""当前 Turn 结束"等过程暴露为事件,同一套 Agent Core 就可以被终端、脚本、JSON 客户端,甚至其他应用复用。
这也是我们自己写 Agent 时值得保留的设计原则:
- 把 Provider 的差异压在最底层,业务层面对统一的消息和流。
- 把"推理 + 调用工具"的反馈循环放在核心层。
- 把会话、界面、项目约定、扩展放到外围,而不是塞进循环。
- 把每次状态变化做成可观察事件,避免核心逻辑直接操作 UI。
Pi 官方仓库对这些包的职责有明确说明;SDK 文档也展示了 Agent 的状态中包含消息、模型、工具、系统提示词和流式中的消息等信息。Pi Monorepo Pi SDK 文档
二、核心链路:模型不是直接"做事",而是在反馈循环中决定下一步
工具型 Agent 的核心,不是一次 LLM 调用,而是一个不断接收反馈的循环。它的抽象可以写成下面这样:
ts
while (true) {
const assistantMessage = await streamModel(context)
const toolCalls = findToolCalls(assistantMessage)
if (toolCalls.length === 0) {
return assistantMessage
}
const results = await executeTools(toolCalls)
context.messages.push(...results)
}

这段伪代码只有几行,但里面有四个不能混淆的角色:
- Context Builder:决定这一次模型能看到什么,例如系统指令、当前问题、历史消息和工具 Schema。
- Model Stream:接收模型流式返回的 Assistant 消息,并从中解析是否存在工具调用。
- Tool Scheduler:校验参数、执行 Hook、调度工具,并把工具输出标准化。
- Result Writer:把工具结果以模型能理解的形式追加进上下文,推动下一轮推理。
这里最容易忽略的是"工具结果"不是给用户看的日志,而是下一次模型推理的输入。比如模型先调用 read 读取文件,拿到内容后才可能决定调用 edit;执行测试获得报错后,才可能形成新的修复假设。工具把外部世界变成模型可消费的证据。
生产实现还会补充几个工程约束:
- 相互独立的工具调用可以并行,以降低总等待时间;
- 即使并行完成,写回消息时仍按模型原始调用顺序排列,避免上下文语义混乱;
- 模型流、工具执行与会话操作共享取消信号,用户中断时能一致停止;
- 事件流只描述"发生了什么",不决定"终端怎么画出来"。
因此,真正可复用的不是这段 while,而是它的 Contract:模型产出结构化意图,运行时执行意图,再把环境反馈交还给模型。
从源码看:Pi 的循环比伪代码多了哪些保护
前面的伪代码刻意忽略了生产运行时必须处理的边界。当前 agent-loop.ts 中,真正的 runLoop 有一个内层循环和一个外层循环:内层继续处理工具调用与已经进入安全边界的 Steering 输入;外层在 Agent 原本准备结束时,检查是否有排队的 Follow-up 输入。
ts
// 根据 runLoop 的控制流进行教学化改写
while (true) {
let hasMoreToolCalls = true
while (hasMoreToolCalls || pendingMessages.length > 0) {
appendPendingMessagesToContext()
const assistant = await streamAssistantResponse(context)
const calls = assistant.content.filter((item) => item.type === "toolCall")
const batch = calls.length > 0
? await executeToolCalls(context, assistant, signal)
: { messages: [], terminate: false }
appendResultsToContext(batch.messages)
hasMoreToolCalls = !batch.terminate
}
const followUps = await getFollowUpMessages()
if (followUps.length === 0) break
pendingMessages = followUps
}
这里要分清两种用户输入:
- Steering:在下一个安全边界插入,意图是调整当前工作方向。
- Follow-up:当前 Agent 稳定结束后再处理,意图是开启下一段追问。
如果不做队列区分,用户在工具运行中追加的请求要么会被无序插入,要么只能等到所有工作完全结束才被看到。源码中 getSteeringMessages 和 getFollowUpMessages 位于不同的循环阶段,正是为了保证这种交互顺序。Agent Loop 源码
源码关键点:模型上下文只在 Provider 边界转换
Pi 内部可以保存 Bash 记录、扩展消息、分支摘要、压缩摘要等应用专用类型;但调用模型前只投影出 Provider 能接收的消息。下面是对 streamAssistantResponse 关键逻辑的教学化改写:
ts
let visibleMessages = context.messages
if (config.transformContext) {
visibleMessages = await config.transformContext(visibleMessages, signal)
}
const llmMessages = await config.convertToLlm(visibleMessages)
const llmContext = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
}
这一步不是格式细节,而是架构边界。内部状态可以为应用服务,外部请求只服从 Provider Contract。这样以后替换模型厂商、追加自定义消息或调整压缩策略时,不会污染 Session 和工具层。Agent Loop 源码
工具调度源码:并行之前先判断是否有顺序约束
当前实现会先查看全局配置和工具自身的 executionMode,再在串行与并行之间选择。重点是:并发是对独立操作的优化,不是默认正确答案。
ts
// 基于 executeToolCalls 的教学化改写
const mustRunSequentially = toolCalls.some((call) => {
const tool = context.tools?.find((item) => item.name === call.name)
return tool?.executionMode === "sequential"
})
if (config.toolExecution === "sequential" || mustRunSequentially) {
return executeSequentially(toolCalls)
}
return executeInParallel(toolCalls)
可以并行的典型任务是同时读取多个不相关文件;必须串行的典型任务是先写入再读取、先迁移再启动服务。除此之外,Pi 还会在模型因输出长度被截断时拒绝执行这一批工具调用------因为此时看似能解析的 JSON 参数也可能语义不完整。工具执行要以正确性为先,吞吐量为后。
事件与取消:循环如何从"能跑"变成"可观测"
核心循环持续发出 Agent、Turn、Message 和 Tool Execution 事件:
text
agent_start
turn_start
message_start → message_update* → message_end
tool_execution_start → tool_execution_update* → tool_execution_end
turn_end
agent_end
终端用它来更新画面,会话层用它来持久化,测试用它断言顺序,扩展用它加策略。同时,AbortSignal 会向模型流、工具执行和上下文转换传播取消请求。这样用户中断时,停止的不只是显示,而是整条执行链。
三、最关键的状态设计:Session 保存历史,Context 服务下一步决策
长任务一定会遇到一个矛盾:我们既希望保留完整历史,方便回溯和分支探索;又不能把全部记录无差别塞给模型,否则上下文窗口很快耗尽。
Pi 的解决思路是把两个概念刻意分开:
- Session 回答"此前发生过什么"。
- Model Context 回答"模型下一步需要知道什么"。

会话为什么适合做成树,而不是线性聊天记录
一次编程任务经常会出现这样的过程:先采用方案 A,后来发现不合适,再回到早期决策点尝试方案 B。如果会话只有线性消息列表,回退往往意味着丢弃后续内容。
用 id 和 parentId 把记录组织为树后,可以从旧节点继续新增子分支,原分支仍然保留:
text
用户:实现登录
└── Assistant:方案 A
├── 用户:继续方案 A
└── 用户:改用方案 B
这份树是"完整事实记录"。它适合追加、检查和回放,也能让用户比较不同路径。Pi 的 SDK 文档允许直接替换 Agent 的消息数组,用于恢复或分支类场景;这也说明对话状态是核心运行时里一个独立、可管理的对象。Pi SDK 文档
压缩为什么不是"删聊天记录"
压缩解决的是模型上下文有限,而不是历史记录无用。
当活跃路径过长时,运行时可以把较早消息总结成结构化 Checkpoint,并保留最近、最具体的消息尾部。一个合格的 Checkpoint 至少应包含:
- 当前目标与约束;
- 已完成、进行中、阻塞中的工作;
- 关键技术决策;
- 重要文件、函数名和报错;
- 下一步行动。
之后模型看到的是"Checkpoint + 最近上下文",而不是从第一条消息开始的全部记录。压缩改变的是模型的视野,不是已经发生过的历史。
这条经验很适合迁移到自己的 Agent 项目中:持久化层优先保证完整性;上下文层优先保证相关性。把两者混成同一个数组,后面会很难同时做好回放、分支和 Token 控制。
JSONL 会话记录:为什么对话树天然适合探索式开发
官方 Session 格式中,除头信息外的每个条目都有 id、parentId 和时间戳。下面是根据文档缩写后的结构:
text
{"type":"session","version":3,"id":"session-001"}
{"type":"message","id":"m1","parentId":null,"message":{"role":"user","content":"实现登录"}}
{"type":"message","id":"m2","parentId":"m1","message":{"role":"assistant","content":[{"type":"text","text":"先检查结构"}]}}
{"type":"message","id":"m3","parentId":"m2","message":{"role":"toolResult","toolName":"read","isError":false}}
当用户回到 m2 后继续,新增消息仍以 m2 为父节点,原来的 m3 不会消失。这个设计让 Session 负责保存"发生过什么",而不是强迫模型只能沿一条线性聊天记录工作。Session File Format
压缩的真正实现目标:保留最近细节,替换旧上下文
Pi 当前文档给出的自动压缩阈值是:
text
contextTokens > contextWindow - reserveTokens
触发后,运行时会保留最近消息,将较旧消息总结为 Checkpoint,再用"摘要 + 最近消息"重建下一轮 Context。当前官方文档中的 reserveTokens 默认是 16,384,keepRecentTokens 默认是 20,000;二者均可配置,实际使用请以安装版本的文档为准。
json
{
"compaction": {
"enabled": true,
"reserveTokens": 16384,
"keepRecentTokens": 20000
}
}
压缩时最重要的约束是:工具调用与它对应的工具结果不能被拆开。否则模型会看见"调用过命令"却看不到输出,下一轮推理会断裂。官方文档还把自动 Compaction 与 /tree 下的分支摘要分开:前者释放上下文空间,后者帮助安全切换对话分支。Compaction 文档
四、扩展、Skills 与终端 UI:把变化放在稳定边界之外
如果把所有能力都写进 Agent Core,核心会很快变成一个难以维护的"全能框架"。Pi 更偏向于提供稳定边界,让变化从边界进入。
Extension:改变 Agent "能做什么"
扩展更适合放可执行能力和运行时 Hook,例如:
- 注册新的工具、命令和快捷键;
- 在工具执行前做审批、审计或路径保护;
- 注入模型 Provider;
- 增加消息 Renderer 或终端组件;
- 改写压缩、会话或工作流策略。
以自定义工具为例,核心循环并不需要知道"发布预览环境"是什么;它只需要面对统一的工具 Contract:
ts
pi.registerTool({
name: "deploy_preview",
description: "部署当前分支的预览环境",
parameters: schema,
async execute(toolCallId, params, signal, onUpdate) {
// 执行任务,并通过 onUpdate 报告进度
return { content: [{ type: "text", text: "Preview ready" }] }
},
})
安全策略也应该放在这个边界:例如 Shell 命令执行前审批、敏感路径写入前拦截、工具输出进入模型前脱敏。这样核心循环保持通用,环境策略则可以按项目替换。扩展相关 API 和示例可从官方仓库的 coding-agent 文档与示例目录继续追踪。Pi Monorepo
Skill:改变 Agent "何时、如何做"
Skill 更像一份可复用的工作说明书。它通常以 SKILL.md 为入口,告诉 Agent:
- 这个任务适用于什么场景;
- 需要先读哪些文件;
- 应执行哪些命令;
- 哪些操作需要批准;
- 最终如何验证和交付。
它和 Extension 的分工可以一句话记住:
| 机制 | 本质 | 典型用途 |
|---|---|---|
| Extension | 新增能力或拦截生命周期 | 新工具、Hook、UI、Provider 集成 |
| Skill | 复用操作方法 | 发布流程、CI 排错、代码审查规范 |
Skill 的价值还在于渐进加载:启动时只向模型暴露名称、描述和位置这类"目录信息";真正匹配任务后,再读取完整说明。这样技能库变大时,不会把所有细节永久占满系统提示词。官方 SDK 示例展示了 Skill 的发现、筛选和自定义注入方式。Skills SDK 示例
TUI:它展示运行时,但不拥有运行时
终端 UI 的难点是模型文本、工具进度和用户输入会同时变化。Pi 的 pi-tui 使用差量渲染:比较新旧帧后,尽量只追加或刷新变化的尾部,从而减少整屏重绘和闪烁。
这个设计再次强调了边界:UI 订阅事件并呈现状态,而不是直接接管工具循环。于是交互式终端、一次性命令、JSON 事件输出或 SDK 嵌入,都能共享同一个 Agent Harness。
从工具 Contract 看 Extension:内容给模型,详情给状态恢复
Extension 注册工具时,content 与 details 不应混为一谈。前者是下一次模型推理要阅读的内容,后者适合保存扩展的结构化状态,以便沿 Session 分支重建。
ts
export default function registerPreviewTool(pi: ExtensionAPI) {
pi.registerTool({
name: "deploy_preview",
description: "部署当前分支到预览环境",
parameters: previewSchema,
async execute(callId, params, signal, onUpdate) {
onUpdate?.({ content: [{ type: "text", text: "正在部署" }] })
const url = await deploy(params.branch, signal)
return {
content: [{ type: "text", text: "预览已就绪:" + url }],
details: { callId, branch: params.branch, url },
}
},
})
}
这段工具的输入是模型产生的参数和取消信号;输出既包含模型可见的文本,也包含运行时可追踪的数据。官方扩展文档要求工具返回结果形状保持一致,因为 UI 和 Session 都依赖这些形状做渲染和状态跟踪。Extensions 文档
Skills 的渐进加载:避免把整个流程库塞进 System Prompt
Skill 可以用一个目录封装操作说明、脚本、参考资料和资源:
text
my-skill/
├── SKILL.md
├── scripts/
│ └── verify.sh
├── references/
│ └── api-guide.md
└── assets/
└── template.json
Pi 启动时只扫描 Skill 名称和描述,并放入系统提示词的可用能力目录;任务命中后才读取完整 SKILL.md。这种渐进披露既保留了可路由性,又不会让不相关的长流程永久占用上下文。
需要区分四种机制:始终生效的项目约定放在 AGENTS.md;用户显式触发的固定模板适合 Slash Command;需要新增可调用能力或审批交互时使用 Extension;像 PDF 处理、发布流程、CI 排错这种可选但复杂的工作流,才是 Skill 的好场景。Skills 文档
五、从 Pi 可以复用的 Agent 构建顺序
如果从零实现一个编程 Agent,建议按下面的顺序推进,而不是一开始就堆 Prompt、子 Agent 和复杂 UI:
- 先统一模型流式接口,明确消息与工具调用 Schema。
- 实现最小而正确的"模型 → 工具 → 结果 → 模型"循环。
- 给消息、Turn、工具执行加事件,先让过程可观察。
- 分离 Session 与 Model Context,支持持久化和上下文投影。
- 再加入工具权限、扩展 Hook、压缩和 Skills。
- 最后构建终端或 Web UI,让 UI 成为 Harness 的 Adapter。
Pi 最值得学习的并不是某一个命令或某一个默认配置,而是这种架构取舍:每层都足够小,边界足够清楚,复杂能力通过扩展和组合获得。
对于正在做 Python Agent 应用的同学,这套思路可以直接映射到自己的项目:用状态对象管理对话,用工具协议隔离外部操作,用事件或回调隔离界面,用摘要控制长上下文,用 Skill 把重复流程沉淀成可执行规范。这样做出来的 Agent 才不只是"能调用模型",而是一个能持续工作、可维护、可扩展的应用运行时。