s06: Compaction — 给上下文做 GC
留工作集,归档其余。
问题
s01 的 agent 把”记忆”放在一个消息数组里:用户提问、模型回答、工具结果,全部按序追加。这个数组只增不减。
短任务没事。但一个长任务跑下来——读了一堆文件、跑了一堆命令、每条 tool_result 都把完整输出塞回数组——token 数很快逼近模型的上下文窗口。一旦超过,API 直接报错,循环崩溃。
你能想到的最朴素办法是丢掉最早的消息。但那样模型会失忆:它不记得自己改过哪个文件、做到哪一步了。上下文不是越长越好,但也不能无脑截断。
需要一个”既不崩溃、又不失忆”的办法。
解决方案
把上下文当成内存:有个工作集(最近几条消息,模型正在用),其余是历史。历史太满时,不是丢掉,而是压缩成一条摘要,再放回去。
三步:
| 步骤 | 动作 | 信号 |
|---|---|---|
| 检测 | 估算当前 token 数 | estimateTokens(messages) > maxContextTokens |
| 切分 | 留最近 N 条,其余交给 LLM 摘要 | splitIndex = length - keepRecentMessages |
| 替换 | [summary, ...recent] 原地替换原数组 | messages.length = 0; messages.push(...compacted) |
摘要存成一条 user 消息:"Previous conversation summary: ..."。模型下一轮看到的,就是”一段前情提要 + 最近的工作”,而不是 25 条原始消息。
本质是 GC for context:标记(哪些是工作集)、压缩(把老对象摘要成一个)、回收(替换数组)。代价是摘要必然丢细节,换来的是对话能无限续下去。
工作原理
打开 code.ts,重点看三块新东西,其余是 s01 原样。
第 1 块:token 估算。 一行启发式:
function estimateTokens(messages: ChatMessage[]): number {
return Math.ceil(messages.reduce((sum, m) => sum + flattenText(m).length, 0) / 4);
}
把每条消息拍平成纯文本,字符总数除以 4。为什么是 4?英文文本里平均一个 token 约 4 个字符,这是行业内最粗的一档估算。中文偏吃亏(一个汉字常占 1-2 token),但用来判断”要不要压缩”足够了——我们不需要精确,只需要一个阈值触发器。
第 2 块:压缩函数。 切一刀,老的摘要,新的原样保留:
async function compact(messages: ChatMessage[]): Promise<ChatMessage[]> {
const splitIndex = Math.max(0, messages.length - CONFIG.keepRecentMessages);
const conversation = messages.slice(0, splitIndex).map(flattenText).join("\n\n");
const response = await callLlm(
[{ role: "user", content: `${SUMMARIZATION_PROMPT}\n\n${conversation}` }],
[], // 关键:摘要请求禁用工具,模型只能输出文本
);
const summary = response.content.filter((b) => b.type === "text").map((b) => b.text).join("\n");
return [{ role: "user", content: `Previous conversation summary: ${summary}` }, ...messages.slice(splitIndex)];
}
注意 callLlm 的第二个参数传了 []:摘要请求不带工具,否则模型可能在摘要时反而去调 bash,那就乱套了。
第 3 块:在循环开头插一道闸。 这是 s01 循环唯一的结构变化:
while (true) {
if (messages.length > CONFIG.keepRecentMessages && estimateTokens(messages) > CONFIG.maxContextTokens) {
const compacted = await compact(messages);
messages.length = 0; // 原地清空
messages.push(...compacted); // 再填回压缩后的
}
const response = await callLlm(messages);
// ... s01 原样的工具执行与回填
}
messages.length = 0 这一手是 JS 原地替换数组的惯用法:外部引用(main 里的 history)还指向同一个数组对象,但内容已经换成压缩后的版本。不用返回新数组、不用重新赋值。
运行
export ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
export ANTHROPIC_API_KEY=sk-deepseek-...
export MODEL_ID=deepseek-chat # 可选,有默认值
node code.ts
想看到压缩实际触发,把 CONFIG.maxContextTokens 临时调小(比如 5000),然后给它一个会产出的长任务:
列出当前目录所有文件,逐个读取并总结内容(工具结果会迅速堆高 token)- 观察终端出现
[compaction] 上下文溢出,开始压缩...的紫色提示 - 压缩后追问
你刚才读了哪些文件——如果摘要保住了关键信息,模型还能答上来
调回 50000 跑正常任务,压缩基本不会触发,行为与 s01 一致。
前置概念清单
本章只引入五个新概念:
- 上下文窗口:模型单次能”看到”的 token 上限;超过就报错,agent 必须自己留在窗口内
- token 估算:用字符数 / 4 粗估 token,不精确但足以做阈值判断
- 工作集 / 历史:最近 N 条是工作集(原样保留),更早的是历史(摘要后归档)
- 摘要替换:把历史压成一条 summary 消息,
[summary, ...recent]替换原数组 - 原地替换:
arr.length = 0; arr.push(...new)保持数组引用不变,内容整体换新
源码锚点
mini-pi 的压缩是教学级简化。pi 的实现分布在 packages/agent/src/harness/compaction/,先读 compaction.ts,回答三个问题(答案就在代码里):
estimateTokens(第 202 行)对每种 role 分别数字符,而我们的flattenText把所有块一视同仁。pi 为什么要分 role?bashExecution和compactionSummary这两种 pi 独有的 role,教学版为什么可以忽略?(提示:我们的消息类型只有 user/assistant 两种)shouldCompact(第 196 行)的判断是contextTokens > contextWindow - reserveTokens,而我们是> maxContextTokens。reserveTokens这个”预留量”在防什么?(提示:摘要本身也要占 token,不预留会怎样)findCutPoint(第 328 行)倒着累加 token 找切点,且只在 user 消息边界切;我们是按固定条数keepRecentMessages切。如果在tool_use和tool_result之间切会发生什么?(提示:tool_result 必须紧跟在对应的 tool_use 后面,否则 API 报错)
动手任务(改 mini-pi):给 compact 加一个保护——如果 oldMessages 为空(历史全在工作集里),直接返回原数组不调 LLM。再思考:什么情况下 messages.length > keepRecentMessages 成立、但 splitIndex 算出来后 oldMessages 仍可能为空?(提示:Math.max(0, ...))
妥协清单
mini-pi 的压缩比pi 粗很多,以及为什么省略是安全:
| 省略项 | pi 的做法 | 为什么本章可以省 |
|---|---|---|
| 精确 token 计数 | calculateContextTokens 读 provider 返回的真实 usage | 字符数/4 的偏差对”要不要压缩”这个二元判断够用 |
| 切点合法性 | findCutPoint 只在 user 消息边界切,绝不拆散 turn | 固定按条数切,单工具场景下 tool_use/tool_result 成对出现,碰巧不破坏配对 |
| 拆分 turn 处理 | isSplitTurn 时单独摘要 turn 前缀再拼接 | 不切 turn 内部,自然没有拆分问题 |
| 迭代摘要更新 | previousSummary 增量更新已有摘要,而非每次重算 | 每次从零生成更简单,摘要质量略低但不影响教学 |
| 结构化摘要格式 | Goal/Progress/Decisions/NextSteps 分节 prompt | 一句扁平 prompt 已能演示”压缩 → 续接”的闭环 |
| 文件操作追踪 | extractFileOps 记录读写文件附在摘要后 | 不追踪;摘要靠 LLM 自行保留”关键文件路径” |
| 分支摘要 | branch-summarization.ts 摘要废弃的探索分支 | 单线性历史,无分支概念 |
| 溢出后兜底 | utils/overflow.ts 22 个正则识别 provider 溢出错误,触发紧急 compaction | 教学版只有预防性 compaction(token 超阈值);pi 还有 provider 返回溢出错误时的兜底,覆盖 Anthropic/OpenAI/Google 等 15+ provider |
| compaction 持久化 | 追加 CompactionEntry 到 jsonl,旧消息不删除,buildSessionContext 按 firstKeptEntryId 跳过 | 教学版原地替换内存数组(messages.length=0; push),旧消息丢失;pi 的 compaction 可回溯(fork 到压缩前状态),s14 会展开 |
这张表的每一行都是pi 多花的工程量。读完 compaction.ts 再回头看,你会更清楚每一项在防什么故障。
默写验收
合上 code.ts 和本 README,打开 practice.ts,凭记忆补全两个新函数:estimateTokens、compact,以及改写 agentLoop(在循环开头插入压缩闸门)。callLlm、runBash、main 是 s01 原样,写不出可以查。
通过标准:node practice.ts 跑起来,把 maxContextTokens 调到 5000,跑一个长任务,能看到紫色 [compaction] 提示且压缩后对话不崩溃。
写不出 compact 说明”切分 + 摘要 + 拼接”的闭环没进脑子,回到「工作原理」第 2 块重读。写不出 agentLoop 里的闸门插入位置,说明”原地替换”那段没理解,回到第 3 块。
下一章:s07 skills