Skip to content

3.7 Compaction、Extension 与 Skill

本章解决什么问题3.6 把 Agent Harness(Agent 运行框架)拆成了六个盒子,但那张图里有三处留白:历史无限增长怎么办、外人想给这套框架加功能怎么办、「某类活儿该怎么干」这种经验怎么交给模型。这三处留白分别由上下文压缩(Context Compaction)扩展(Extension)技能(Skill) 填上。本章只讲清这三样东西是什么、为什么需要、没有它会怎样,实现细节留给第六、七部分。

前置知识3.2 消息、上下文与 token(上下文窗口与 token 计量)、3.5 Agent 与 Agent Loop(一轮一轮怎么转)、3.6 Agent Harness、Session 与状态(六层分工)、2.7 事件、回调与取消(AbortSignal)。

学习目标:读完本章后你能

  • 用一个具体数字说明「为什么必须压缩」,并说出压缩的三个步骤和它必然付出的代价;
  • 解释「压缩只换掉发给模型的那一份,不删磁盘上的历史」这句话的含义;
  • 说出 Extension 能给 Harness 加的三类能力,以及它为什么必须以事件钩子的形式存在;
  • 用「代码 vs 知识」一句话区分 Extension 与 Skill,并判断一个新需求该做成哪一个;
  • 指出取消、中断与超时在 Agent Loop 里的三个落点。

建立直觉:三个还没回答的问题

上一章的整车已经能开了。但真拿去用一天,你会连着撞上三堵墙:

你会遇到的现象根本问题本章给的答案
聊到第八十几轮,模型突然报错,说请求超出上限历史只增不减,而窗口是固定的上下文压缩(Context Compaction)
你想让 Agent 在执行危险命令前先问一句,可你不想去改它的源码框架的行为写死在源码里,外人插不进手扩展(Extension)
你团队处理某类任务有一套固定流程,每次都要复述一遍给模型「怎么做」这种经验没有安放的地方技能(Skill)

三个问题彼此独立,答案也各管一段:压缩管上下文的容量,扩展管框架的行为,技能管模型的知识。先把这三条边界记住,后面就不容易混。

上下文压缩:让历史停止无限增长

先把问题量化

3.2 里给过因果链:模型无记忆 → 每轮重发历史 → 历史单调增长 → 窗口有限 → 必须压缩。当时的例子是纯聊天,增长得还算温和。但真实的编码 Agent 里,历史增长的主力不是模型说的话,而是工具结果——读一个文件动辄上千 token。

3.5 那个实验(labs/agent-concepts/04-agent-loop)的形状套上真实的量级算一遍:

ts
const USER_TOKENS = 60; // 用户那一句
const ASSISTANT_TOKENS = 200; // 模型的一段话 + 一次 Tool Call
const TOOL_RESULT_TOKENS = 2000; // 读一个文件返回的内容,很容易上千 token
const PER_TURN = ASSISTANT_TOKENS + TOOL_RESULT_TOKENS;

const CONTEXT_WINDOW = 200_000; // 模型的上下文窗口
const RESERVE_TOKENS = 16_384; // 留给摘要提示词和模型回复的余量

for (const turns of [1, 10, 50, 100]) {
  const messages = 1 + turns * 2;
  const tokens = USER_TOKENS + turns * PER_TURN;
  console.log(`${String(turns).padStart(3)} 轮 → ${String(messages).padStart(3)} 条消息,约 ${tokens} token`);
}

const threshold = CONTEXT_WINDOW - RESERVE_TOKENS;
console.log(`压缩阈值 = ${CONTEXT_WINDOW} - ${RESERVE_TOKENS} = ${threshold} token`);
console.log(`第 ${Math.ceil((threshold - USER_TOKENS) / PER_TURN)} 轮左右触发压缩`);
console.log(`若不压缩,第 ${Math.ceil((CONTEXT_WINDOW - USER_TOKENS) / PER_TURN)} 轮请求就会被服务端拒绝`);

把它存成 growth.ts,放进任意一个装过依赖的实验目录,用 npx tsx growth.ts 运行。真实输出

  1 轮 →   3 条消息,约 2260 token
 10 轮 →  21 条消息,约 22060 token
 50 轮 → 101 条消息,约 110060 token
100 轮 → 201 条消息,约 220060 token
压缩阈值 = 200000 - 16384 = 183616 token
第 84 轮左右触发压缩
若不压缩,第 91 轮请求就会被服务端拒绝

这几行数字是本节的全部动机:一个 20 万 token 的窗口,在真实工作负载下只够撑九十来轮。而九十轮对一次认真的重构任务来说并不算多。上下文压缩就是让第 91 轮还能继续的那个机制。

📘 概念上下文压缩(Context Compaction)
当上下文接近窗口上限时,把**较早的一段消息**交给模型,让它写成一段简短的结构化摘要,之后的请求用「系统提示词 + 摘要 + 近期消息」代替「系统提示词 + 全部历史」。它拿**信息精度**换**可用容量**。

压缩做的三件事

图加载中…

图 3.7-1 一次压缩:长历史换成「摘要 + 近期消息」
阅读顺序:从左到右。要盯住两个地方。一是上下两条支路的**不对称**——旧消息被换成摘要,近期消息原样保留,因为「刚刚发生的事」精度最要紧。二是那条虚线:旧消息**没有被删掉**,它仍然完整躺在 Session 文件里,只是不再出现在发给模型的那一份里。

拆成三步就是:

  1. 选切点:从最新的消息往回数,累计 token 到某个额度(Pi 的默认值是两万)为止,这个位置就是切点。切点之后的保留原样,之前的进摘要。切点还必须落在一轮的边界上——一次 Tool Call 和它的工具结果不能被拆散,否则历史就不合法了(3.4 讲过这对配对关系)。
  2. 生成摘要:把切点之前的消息拼成一段文本,加上一段专门的摘要提示词,再调用一次模型。注意这是一次额外的、用户看不见的模型请求,它自己也要花钱、也要占时间。
  3. 替换并记录:把摘要作为一条特殊消息写进 Session,同时记下「从哪一条开始是保留的近期消息」;之后重建上下文,从摘要那条往后读即可。
⚠️ 常见误解以为压缩会删掉聊天记录
不会。压缩改变的是下一次请求携带什么,不是磁盘上存了什么。Session 只追加不修改(3.6),压缩只是往里面追加了一条「摘要」记录,并标记出保留区的起点。你依然可以回看完整历史,也依然可以从压缩点之前分叉出一条新时间线。

代价与取舍

压缩不是免费的,它至少有四项成本,写自己的 Harness 时都要想清楚:

  • 信息一定会丢。摘要是有损的,被压掉的细节(某个变量的确切名字、某次失败的确切报错)之后可能正好用得上。缓解办法是让摘要带结构——列出「读过哪些文件」「改过哪些文件」「当前目标是什么」——而不是写一段散文。
  • 多一次模型请求。压缩本身要调用模型,用户会感到一次停顿。
  • 触发时机很尴尬。太早浪费精度,太晚则可能来不及——余量不够,连摘要请求本身都发不出去。所以阈值不是「等于窗口」,而是「窗口减去一段预留」。
  • 压缩之后模型可能变笨。它突然「记不清」十分钟前的细节,会重新去读已经读过的文件。一种看法是这属于可接受的退化,代价是用户偶尔觉得 Agent 有点健忘。

Extension:不改源码给框架加能力

第二堵墙:你想让 Agent 在执行 rm -rf 之前先弹窗确认。这个需求很合理,但它非常个人化——不可能指望框架作者替全世界每一种偏好都写一遍。

📘 概念扩展(Extension)
一段**外部代码**,由框架在启动时加载,用来给框架追加能力:注册新工具、注册新命令、订阅并干预生命周期事件。它不修改框架源码,卸载它框架就恢复原样。

最贴切的类比是浏览器插件。浏览器本身不知道什么叫广告拦截,但它开放了「页面加载前」这个时机,插件在那里插一脚就实现了拦截。Extension 之于 Agent Harness 是同一回事:框架在自己的关键节点上发出事件并等待答复,扩展在这些节点上做事。

扩展能加的东西大致三类:

  • 新工具:让模型多一件可调用的工具(比如查内部知识库)。对模型来说,它和内置工具没有区别(3.4)。
  • 新命令:给用户加一条斜杠命令(如 /mycommand),由人来触发而不是模型。
  • 事件钩子:在框架的生命周期节点上插入逻辑,并且可以改变结果——拦下一次工具调用、往上下文里注入内容、替换掉默认的压缩策略。

第三类是最要紧的,因为它决定了扩展是「只能旁观」还是「能真正改变行为」。看看这些钩子在循环里的位置:

图加载中…

图 3.7-2 扩展钩子挂在 Agent Loop 的哪些节点上
阅读顺序:从上往下,这就是 3.5 那个循环,只是在每个交接处插了一个钩子。要关注的是钩子的**方向性**:message endturn end 这类是「通知型」,扩展只是被告知;inputcontexttool calltool result 这类是「可干预型」,返回值会改变后续流程——在 tool call 上返回一个「拦截」,那次工具执行就不会发生。图中每个钩子名都对应 Pi 里真实存在的事件名。

🌱 初学者提示为什么不干脆改源码
改源码当然能实现同样的效果,代价是你从此维护着一个私有分支:上游每次更新你都要重新合并,冲突越积越多。扩展机制把「框架会稳定发出哪些事件」变成一份公开约定,双方各自演进,互不打扰。
⚠️ 常见误解以为扩展是安全的沙箱
不是。扩展就是普通代码,跑在你的机器上,权限和你自己完全一样:能读你的文件、能联网、能执行命令。Pi 的官方文档在扩展一章明确写了这一点,并提示只安装来自可信来源的扩展。判断一个扩展是否可用,靠的是「你信不信作者」,不是「框架会不会拦住它」。

Skill:把「怎么做某类事」写成文档

第三堵墙不是能力问题,是知识问题。Agent 有读文件、执行命令、编辑文件这些工具,处理 PDF 所需的能力它都有——它缺的是「我们这儿处理 PDF 的流程是先跑哪个脚本、参数怎么填」。

一个笨办法是把流程全塞进系统提示词。问题立刻来了:你有二十套这样的流程,全塞进去就是几万 token,每一轮请求都要背着走,而其中十九套跟当前任务毫无关系。

📘 概念技能(Skill)
一份写给模型看的 **Markdown 文档**(约定叫 SKILL.md),描述某一类任务该怎么做。系统提示词里只常驻它的**名字和一句话描述**;模型判断当前任务用得上时,再用读文件工具把全文读进上下文。

一个技能长这样——注意它没有一行需要框架执行的代码,全是给人和模型都读得懂的说明:

markdown
---
name: pdf-processing
description: 从 PDF 里抽取文本与表格、填写 PDF 表单、合并多个 PDF。处理 PDF 文档时使用。
---

# PDF 处理

## 首次使用前的准备
进入本技能目录执行 npm install。

## 用法
运行 scripts/process.sh,把 PDF 路径作为第一个参数传进去。
表格抽取的细节见 references/tables.md。

这套安排叫渐进式披露(Progressive Disclosure):常驻上下文的只有目录,正文按需加载。它的效果是把「二十套流程 × 每一轮」的成本,降成「二十行描述 × 每一轮 + 一套流程 × 用到的那一次」。

其中最关键、也最容易写坏的字段是 description。因为模型是靠它决定要不要读全文的——写成「帮忙处理 PDF」,模型多半想不起来用;写清楚「什么时候该用我」,命中率才高。官方文档在技能一章专门强调了这一点,并且说明模型并不总会去读,因此还保留了让用户直接点名调用某个技能的入口。

🌱 初学者提示这不就是让模型自己去读一个文件吗
是的,机制上就这么朴素:技能的加载动作,用的就是模型本来就有的读文件工具(3.4)。真正的设计巧思不在机制,而在**分层**:把「一句话摘要」和「完整正文」拆开,前者便宜到可以永远带着,后者贵到必须按需取。

Extension 与 Skill:代码 vs 知识

这两个概念最容易混,因为它们都叫「给 Agent 加东西」。一句话区分:Extension 加的是代码,Skill 加的是知识。

Extension(扩展)Skill(技能)
本体是什么可执行的程序模块一份 Markdown 文档
谁来「执行」框架直接运行它的代码模型阅读它,然后用已有工具照做
能做而对方做不到的事拦截工具调用、改写参数、加新命令、访问框架内部状态
加载时机启动时全部加载描述常驻,正文按需读取
出错时的表现抛异常,框架能捕获并报错模型没读、或读了没照做,只能靠提示词纠正
写它需要会什么会写 TypeScript会把流程讲清楚

判断口诀:需要「拦住」或「改变」框架行为的,做成 Extension;只是需要告诉模型「这类活儿怎么干」的,做成 Skill。 一个具体例子:「禁止写入 .env 文件」必须是 Extension——它要在工具真正执行前拦下来,靠嘱咐模型是拦不住的;而「本项目提交前跑哪几条检查命令」适合做 Skill——这是知识,模型知道了自己就会去做。

取消、中断与超时落在哪里

2.7 讲过 AbortController / AbortSignal 的用法,当时是孤立的语法练习。现在可以把它安回真实系统里了。用户按下 ESC,需要停下来的其实有三个地方:

  1. 正在进行的模型请求signal 传给网络请求,连接直接断开。这是最要紧的一处——不断开,即使界面停了,token 还在继续计费。
  2. 正在执行的工具signal 传给每个工具的执行函数。一个跑了三分钟的命令、一个正在下载的请求,都要在这里收到通知。工具能不能真的停下来,取决于它自己有没有认真处理这个信号。
  3. 下一轮开始之前:循环每转一圈,在发起新请求前检查一次 signal.aborted。这道检查最便宜也最容易漏——漏了的后果是「用户按了取消,Agent 还又跑了一整轮」。

超时不是另一套机制,而是取消的一种触发方式:起一个定时器,到点就调用同一个 abort()。它和用户按 ESC 走的是完全相同的路径,区别只在于谁按下了那个按钮。

取消之后还有收尾工作:把这一轮标记成「已中断」写进历史(3.6 的例子里就追加了一条「本轮被取消」),把状态复位成空闲,通知界面。取消不是让程序消失,而是让它以一种可记录、可恢复的方式停住。 完整链路在 5.6 取消与错误处理

Pi 中哪里用到了它

三个概念在 Pi 里各有明确的落点(源码事实)。

压缩住在 packages/agent/src/harness/compaction/。它的配置对象把前面讲的两个额度写成了字段:reserveTokens(留给摘要提示词和回复的余量)与 keepRecentTokens(压缩后保留的近期上下文额度),默认分别是 16384 和 20000——本章开头估算里的 16384、以及「选切点」那一步说的两万,都出自这里。

earendil-works/pi@c13ffe1第 163–178 行在 GitHub 查看 ↗
压缩的设置与默认值:enabled 控制是否自动压缩,reserveTokens 是「阈值离窗口留多远」,keepRecentTokens 是「保留多少近期消息」。

触发条件本身只有一行,正是本章的那个不等式:

earendil-works/pi@c13ffe1第 262–266 行在 GitHub 查看 ↗
上下文 token 超过「窗口减去预留」就该压缩了。这一行就是图 3.7-1 的触发闸门。

扩展住在 packages/coding-agent/src/core/extensions/。一个加载完成的扩展在内存里就是下面这个结构:一张事件处理器表,加上工具、命令、快捷键、命令行开关几张注册表——和本章说的「三类能力」逐条对上。

earendil-works/pi@c13ffe1第 1669–1682 行在 GitHub 查看 ↗
扩展的运行时形态:handlers 是订阅的事件钩子,tools 是注册的新工具,commands 是新增的斜杠命令,另外还能注册快捷键与命令行开关。

技能的加载与格式化住在 packages/agent/src/harness/skills.ts,它的数据结构则把「代码 vs 知识」这句话摆得很直白——字段全是字符串,没有任何可执行的东西:

earendil-works/pi@c13ffe1第 58–75 行在 GitHub 查看 ↗
技能的类型定义:name 与 description 进系统提示词,content 是按需加载的完整正文,filePath 用来解析技能目录里的相对引用。注释里写明前三者会以 XML 块的形式插入系统提示词。

从源码结构看,这三样东西的分布也说明了它们的层次:压缩和技能在 pi-agent-core(通用的 Harness 能力),扩展在 pi-coding-agent(和产品的界面、命令绑定得更紧)。继续深入:压缩看 6.6,扩展看 7.1,技能看 7.2,自定义工具与斜杠命令看 7.3,自己动手写一个钩子在 8.5

实践任务

🛠 实践任务数一数历史会涨到多大labs/agent-concepts/04-agent-loop

这是一道观察题,不需要写新代码,目的是把「压缩为什么必要」从道理变成你自己算出来的数字。

目标:回到 3.5 的实验目录 labs/agent-concepts/04-agent-loop,弄清它的消息数组在长时间运行下会变成什么样。

步骤

① 在该目录下运行 npm start,找到输出里第 3 部分(最大轮数保护)。它转了 3 轮,最后打印「历史里堆了 7 条消息」。请先自己解释这个 7 是怎么来的:1 条 user,加上每轮 2 条(一条 assistant、一条 toolResult)。

② 据此推算:如果把最大轮数改成 100 并让模型一直不收尾,结束时消息数组有多少条?(答案在下面,先自己算。)

③ 打开 src/loop.ts,找到往历史里 push 的那几行,确认你的算法和代码一致。

④ 现在做本章开头那道估算题:假设一条 assistant 消息约 200 token、一条工具结果约 2000 token,100 轮的上下文有多少 token?把它和 20 万的窗口比一比。

⑤ 最后是本题真正的问题:把这 201 条消息摆在面前,哪些能压掉、哪些绝对不能动? 请具体到消息类型,并给出理由。建议至少想清楚这四类:最开头那条 user 消息、第 5 轮读文件的工具结果、倒数第二轮的工具结果、模型每轮说的过渡性文字(「我再确认一下」)。

预期答案:100 轮 → 1 + 100 × 2 = 201 条消息;token 约 60 + 100 × 2200 = 220060,已经超出 20 万的窗口,请求会被服务端直接拒绝。

如何判断成功:你能不查资料说出「保留最近的、压缩中间的、始终保留最开头的用户目标」这条原则,并说明为什么近期的工具结果比早期的更不能动。

常见错误:① 只数 assistant 消息,忘了工具结果也在历史里,而且通常是最大的那部分;② 以为删掉一条 assistant 消息就行——它里面的 Tool Call 和对应的工具结果是一对,只删一半会让历史不合法(见 3.4);③ 以为「删」就是压缩——压缩是换成摘要,被换掉的内容在 Session 文件里仍然存在。

进阶思考:如果你要给这个实验加一个最朴素的压缩,你会把切点选在哪里?为什么切点不能落在「assistant 发出 Tool Call」和「toolResult 返回」之间?

更多实践入口见实践任务索引

本章小结

  • 上下文压缩在上下文接近窗口时,把较早的一段消息换成一段模型生成的摘要。触发条件是「上下文 token 超过 窗口减去预留」,三步是选切点、生成摘要、替换并记录。
  • 压缩不删历史:磁盘上的 Session 一条不少,被换掉的只是「下一次请求携带的那一份」。
  • 压缩的代价是信息有损、多一次模型请求、阈值不好定,以及压缩后模型可能重复做已经做过的事。
  • **Extension(扩展)**是外部代码,给框架加三类能力:新工具、新命令、事件钩子。钩子分「通知型」和「可干预型」,后者的返回值能改变框架的后续行为。扩展没有沙箱,权限等同于运行它的用户。
  • Skill(技能)是写给模型看的 Markdown 文档。系统提示词里只常驻名字和一句话描述,正文由模型按需读取——这叫渐进式披露description 写得准不准,直接决定技能会不会被用上。
  • 一句话区分:Extension 是代码,Skill 是知识;要拦住或改变行为就用扩展,只是要告诉模型怎么干就用技能。
  • 取消要落在三个地方:模型请求、工具执行、下一轮开始前的检查;超时只是由定时器按下的同一个取消按钮。
  • 关键术语:上下文压缩(Context Compaction)切点(cut point)预留 token(reserve tokens)扩展(Extension)事件钩子(hook)技能(Skill)渐进式披露(Progressive Disclosure)
  • 关键源码索引:packages/agent/src/harness/compaction/compaction.ts:163-178(压缩设置与默认值)、packages/agent/src/harness/compaction/compaction.ts:262-266(触发条件)、packages/coding-agent/src/core/extensions/types.ts:1669-1682(扩展的运行时形态)、packages/agent/src/harness/types.ts:58-75(技能的数据结构)。
  • 自测问题:
    1. 压缩之后,如果用户想回看被压掉的那部分对话,还看得到吗?为什么?
    2. 为什么压缩阈值是「窗口减去一段预留」,而不是「等于窗口」?预留是留给谁的?
    3. 「禁止 Agent 写入 .env 文件」这个需求,应该做成 Extension 还是 Skill?换成「本项目的代码风格约定」呢?分别说明理由。
    4. 用户按下 ESC 后,如果程序只在「下一轮开始前」检查了取消信号,会出现什么现象?
  • 下一章:第三部分到此结束——你已经有了理解任何 Agent Harness 所需的全部词汇。从 4.1 Pi 是什么 开始,我们正式进入 Pi 本身:它是什么、仓库长什么样、有哪些 package、该从哪个文件开始读。

本书分析的 Pi 版本:earendil-works/pi@c13ffe1(2026-07-30)