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)的形状套上真实的量级算一遍:
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 轮还能继续的那个机制。
压缩做的三件事
图 3.7-1 一次压缩:长历史换成「摘要 + 近期消息」
阅读顺序:从左到右。要盯住两个地方。一是上下两条支路的**不对称**——旧消息被换成摘要,近期消息原样保留,因为「刚刚发生的事」精度最要紧。二是那条虚线:旧消息**没有被删掉**,它仍然完整躺在 Session 文件里,只是不再出现在发给模型的那一份里。
拆成三步就是:
- 选切点:从最新的消息往回数,累计 token 到某个额度(Pi 的默认值是两万)为止,这个位置就是切点。切点之后的保留原样,之前的进摘要。切点还必须落在一轮的边界上——一次 Tool Call 和它的工具结果不能被拆散,否则历史就不合法了(3.4 讲过这对配对关系)。
- 生成摘要:把切点之前的消息拼成一段文本,加上一段专门的摘要提示词,再调用一次模型。注意这是一次额外的、用户看不见的模型请求,它自己也要花钱、也要占时间。
- 替换并记录:把摘要作为一条特殊消息写进 Session,同时记下「从哪一条开始是保留的近期消息」;之后重建上下文,从摘要那条往后读即可。
代价与取舍
压缩不是免费的,它至少有四项成本,写自己的 Harness 时都要想清楚:
- 信息一定会丢。摘要是有损的,被压掉的细节(某个变量的确切名字、某次失败的确切报错)之后可能正好用得上。缓解办法是让摘要带结构——列出「读过哪些文件」「改过哪些文件」「当前目标是什么」——而不是写一段散文。
- 多一次模型请求。压缩本身要调用模型,用户会感到一次停顿。
- 触发时机很尴尬。太早浪费精度,太晚则可能来不及——余量不够,连摘要请求本身都发不出去。所以阈值不是「等于窗口」,而是「窗口减去一段预留」。
- 压缩之后模型可能变笨。它突然「记不清」十分钟前的细节,会重新去读已经读过的文件。一种看法是这属于可接受的退化,代价是用户偶尔觉得 Agent 有点健忘。
Extension:不改源码给框架加能力
第二堵墙:你想让 Agent 在执行 rm -rf 之前先弹窗确认。这个需求很合理,但它非常个人化——不可能指望框架作者替全世界每一种偏好都写一遍。
最贴切的类比是浏览器插件。浏览器本身不知道什么叫广告拦截,但它开放了「页面加载前」这个时机,插件在那里插一脚就实现了拦截。Extension 之于 Agent Harness 是同一回事:框架在自己的关键节点上发出事件并等待答复,扩展在这些节点上做事。
扩展能加的东西大致三类:
- 新工具:让模型多一件可调用的工具(比如查内部知识库)。对模型来说,它和内置工具没有区别(3.4)。
- 新命令:给用户加一条斜杠命令(如
/mycommand),由人来触发而不是模型。 - 事件钩子:在框架的生命周期节点上插入逻辑,并且可以改变结果——拦下一次工具调用、往上下文里注入内容、替换掉默认的压缩策略。
第三类是最要紧的,因为它决定了扩展是「只能旁观」还是「能真正改变行为」。看看这些钩子在循环里的位置:
图 3.7-2 扩展钩子挂在 Agent Loop 的哪些节点上
阅读顺序:从上往下,这就是 3.5 那个循环,只是在每个交接处插了一个钩子。要关注的是钩子的**方向性**:message end、turn end 这类是「通知型」,扩展只是被告知;input、context、tool call、tool result 这类是「可干预型」,返回值会改变后续流程——在 tool call 上返回一个「拦截」,那次工具执行就不会发生。图中每个钩子名都对应 Pi 里真实存在的事件名。
Skill:把「怎么做某类事」写成文档
第三堵墙不是能力问题,是知识问题。Agent 有读文件、执行命令、编辑文件这些工具,处理 PDF 所需的能力它都有——它缺的是「我们这儿处理 PDF 的流程是先跑哪个脚本、参数怎么填」。
一个笨办法是把流程全塞进系统提示词。问题立刻来了:你有二十套这样的流程,全塞进去就是几万 token,每一轮请求都要背着走,而其中十九套跟当前任务毫无关系。
SKILL.md),描述某一类任务该怎么做。系统提示词里只常驻它的**名字和一句话描述**;模型判断当前任务用得上时,再用读文件工具把全文读进上下文。 一个技能长这样——注意它没有一行需要框架执行的代码,全是给人和模型都读得懂的说明:
---
name: pdf-processing
description: 从 PDF 里抽取文本与表格、填写 PDF 表单、合并多个 PDF。处理 PDF 文档时使用。
---
# PDF 处理
## 首次使用前的准备
进入本技能目录执行 npm install。
## 用法
运行 scripts/process.sh,把 PDF 路径作为第一个参数传进去。
表格抽取的细节见 references/tables.md。这套安排叫渐进式披露(Progressive Disclosure):常驻上下文的只有目录,正文按需加载。它的效果是把「二十套流程 × 每一轮」的成本,降成「二十行描述 × 每一轮 + 一套流程 × 用到的那一次」。
其中最关键、也最容易写坏的字段是 description。因为模型是靠它决定要不要读全文的——写成「帮忙处理 PDF」,模型多半想不起来用;写清楚「什么时候该用我」,命中率才高。官方文档在技能一章专门强调了这一点,并且说明模型并不总会去读,因此还保留了让用户直接点名调用某个技能的入口。
Extension 与 Skill:代码 vs 知识
这两个概念最容易混,因为它们都叫「给 Agent 加东西」。一句话区分:Extension 加的是代码,Skill 加的是知识。
| Extension(扩展) | Skill(技能) | |
|---|---|---|
| 本体是什么 | 可执行的程序模块 | 一份 Markdown 文档 |
| 谁来「执行」 | 框架直接运行它的代码 | 模型阅读它,然后用已有工具照做 |
| 能做而对方做不到的事 | 拦截工具调用、改写参数、加新命令、访问框架内部状态 | 无 |
| 加载时机 | 启动时全部加载 | 描述常驻,正文按需读取 |
| 出错时的表现 | 抛异常,框架能捕获并报错 | 模型没读、或读了没照做,只能靠提示词纠正 |
| 写它需要会什么 | 会写 TypeScript | 会把流程讲清楚 |
判断口诀:需要「拦住」或「改变」框架行为的,做成 Extension;只是需要告诉模型「这类活儿怎么干」的,做成 Skill。 一个具体例子:「禁止写入 .env 文件」必须是 Extension——它要在工具真正执行前拦下来,靠嘱咐模型是拦不住的;而「本项目提交前跑哪几条检查命令」适合做 Skill——这是知识,模型知道了自己就会去做。
取消、中断与超时落在哪里
2.7 讲过 AbortController / AbortSignal 的用法,当时是孤立的语法练习。现在可以把它安回真实系统里了。用户按下 ESC,需要停下来的其实有三个地方:
- 正在进行的模型请求:
signal传给网络请求,连接直接断开。这是最要紧的一处——不断开,即使界面停了,token 还在继续计费。 - 正在执行的工具:
signal传给每个工具的执行函数。一个跑了三分钟的命令、一个正在下载的请求,都要在这里收到通知。工具能不能真的停下来,取决于它自己有没有认真处理这个信号。 - 下一轮开始之前:循环每转一圈,在发起新请求前检查一次
signal.aborted。这道检查最便宜也最容易漏——漏了的后果是「用户按了取消,Agent 还又跑了一整轮」。
超时不是另一套机制,而是取消的一种触发方式:起一个定时器,到点就调用同一个 abort()。它和用户按 ESC 走的是完全相同的路径,区别只在于谁按下了那个按钮。
取消之后还有收尾工作:把这一轮标记成「已中断」写进历史(3.6 的例子里就追加了一条「本轮被取消」),把状态复位成空闲,通知界面。取消不是让程序消失,而是让它以一种可记录、可恢复的方式停住。 完整链路在 5.6 取消与错误处理。
Pi 中哪里用到了它
三个概念在 Pi 里各有明确的落点(源码事实)。
压缩住在 packages/agent/src/harness/compaction/。它的配置对象把前面讲的两个额度写成了字段:reserveTokens(留给摘要提示词和回复的余量)与 keepRecentTokens(压缩后保留的近期上下文额度),默认分别是 16384 和 20000——本章开头估算里的 16384、以及「选切点」那一步说的两万,都出自这里。
CompactionSettings触发条件本身只有一行,正是本章的那个不等式:
shouldCompact扩展住在 packages/coding-agent/src/core/extensions/。一个加载完成的扩展在内存里就是下面这个结构:一张事件处理器表,加上工具、命令、快捷键、命令行开关几张注册表——和本章说的「三类能力」逐条对上。
技能的加载与格式化住在 packages/agent/src/harness/skills.ts,它的数据结构则把「代码 vs 知识」这句话摆得很直白——字段全是字符串,没有任何可执行的东西:
从源码结构看,这三样东西的分布也说明了它们的层次:压缩和技能在 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(技能的数据结构)。 - 自测问题:
- 压缩之后,如果用户想回看被压掉的那部分对话,还看得到吗?为什么?
- 为什么压缩阈值是「窗口减去一段预留」,而不是「等于窗口」?预留是留给谁的?
- 「禁止 Agent 写入
.env文件」这个需求,应该做成 Extension 还是 Skill?换成「本项目的代码风格约定」呢?分别说明理由。 - 用户按下 ESC 后,如果程序只在「下一轮开始前」检查了取消信号,会出现什么现象?
- 下一章:第三部分到此结束——你已经有了理解任何 Agent Harness 所需的全部词汇。从 4.1 Pi 是什么 开始,我们正式进入 Pi 本身:它是什么、仓库长什么样、有哪些 package、该从哪个文件开始读。