从工具循环到上下文压缩:读懂 Pi 编程 Agent 的内部架构

从 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)
}

这段伪代码只有几行,但里面有四个不能混淆的角色:

  1. Context Builder:决定这一次模型能看到什么,例如系统指令、当前问题、历史消息和工具 Schema。
  2. Model Stream:接收模型流式返回的 Assistant 消息,并从中解析是否存在工具调用。
  3. Tool Scheduler:校验参数、执行 Hook、调度工具,并把工具输出标准化。
  4. 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 稳定结束后再处理,意图是开启下一段追问。

如果不做队列区分,用户在工具运行中追加的请求要么会被无序插入,要么只能等到所有工作完全结束才被看到。源码中 getSteeringMessagesgetFollowUpMessages 位于不同的循环阶段,正是为了保证这种交互顺序。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。如果会话只有线性消息列表,回退往往意味着丢弃后续内容。

idparentId 把记录组织为树后,可以从旧节点继续新增子分支,原分支仍然保留:

text 复制代码
用户:实现登录
└── Assistant:方案 A
    ├── 用户:继续方案 A
    └── 用户:改用方案 B

这份树是"完整事实记录"。它适合追加、检查和回放,也能让用户比较不同路径。Pi 的 SDK 文档允许直接替换 Agent 的消息数组,用于恢复或分支类场景;这也说明对话状态是核心运行时里一个独立、可管理的对象。Pi SDK 文档

压缩为什么不是"删聊天记录"

压缩解决的是模型上下文有限,而不是历史记录无用。

当活跃路径过长时,运行时可以把较早消息总结成结构化 Checkpoint,并保留最近、最具体的消息尾部。一个合格的 Checkpoint 至少应包含:

  • 当前目标与约束;
  • 已完成、进行中、阻塞中的工作;
  • 关键技术决策;
  • 重要文件、函数名和报错;
  • 下一步行动。

之后模型看到的是"Checkpoint + 最近上下文",而不是从第一条消息开始的全部记录。压缩改变的是模型的视野,不是已经发生过的历史。

这条经验很适合迁移到自己的 Agent 项目中:持久化层优先保证完整性;上下文层优先保证相关性。把两者混成同一个数组,后面会很难同时做好回放、分支和 Token 控制。

JSONL 会话记录:为什么对话树天然适合探索式开发

官方 Session 格式中,除头信息外的每个条目都有 idparentId 和时间戳。下面是根据文档缩写后的结构:

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 注册工具时,contentdetails 不应混为一谈。前者是下一次模型推理要阅读的内容,后者适合保存扩展的结构化状态,以便沿 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:

  1. 先统一模型流式接口,明确消息与工具调用 Schema。
  2. 实现最小而正确的"模型 → 工具 → 结果 → 模型"循环。
  3. 给消息、Turn、工具执行加事件,先让过程可观察。
  4. 分离 Session 与 Model Context,支持持久化和上下文投影。
  5. 再加入工具权限、扩展 Hook、压缩和 Skills。
  6. 最后构建终端或 Web UI,让 UI 成为 Harness 的 Adapter。

Pi 最值得学习的并不是某一个命令或某一个默认配置,而是这种架构取舍:每层都足够小,边界足够清楚,复杂能力通过扩展和组合获得。

对于正在做 Python Agent 应用的同学,这套思路可以直接映射到自己的项目:用状态对象管理对话,用工具协议隔离外部操作,用事件或回调隔离界面,用摘要控制长上下文,用 Skill 把重复流程沉淀成可执行规范。这样做出来的 Agent 才不只是"能调用模型",而是一个能持续工作、可维护、可扩展的应用运行时。

参考资料

相关推荐
mldong27 分钟前
跨语言对齐方法论:参考实现先行 + 契约测试
java·架构
黑马程序员毕设32 分钟前
基于B/S架构的“指尖乡味”助农电商小程序系统设计与实现
spring boot·微信小程序·小程序·架构·课程设计·毕设
xiezhr32 分钟前
现在的豆包跟以前不一样了
人工智能·ai·agent·ai agent·豆包
笨蛋©2 小时前
2026年高效生成CAD图纸气泡图与自动化检验计划的技术路径
ai·数字化·cad·质量管理·制造业
Elastic 中国社区官方博客8 小时前
搜索倍增器:推动收入、生产力和 AI 实现规模化
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
xu_wenming8 小时前
嵌入式软件架构中的6种解耦艺术
c语言·驱动开发·嵌入式硬件·架构
ZGIAI9 小时前
ZGI Workflow 变量池:接住节点输出
人工智能·架构
ZGIAI9 小时前
ZGI 记忆隔离:多人共用不串号
人工智能·架构