读懂上下文压缩:让 agent 记得久又不烧钱的三重感知
这篇讲什么
agent 跑起来,每一轮都在往上下文里塞东西------工具结果、对话、代码。跑个几十轮,上下文必然超出模型窗口。这时候就得压缩:把旧的历史压成一段摘要,腾出空间继续干活。
听起来简单,实际是整个 agent 里最难调的一块。因为压缩自带三个矛盾:压早了,浪费缓存(压缩会击穿前缀缓存);压晚了,撑爆窗口(API 报 400);压错了边界,工具调用被拦腰截断(又报 400)。 而且它还可能失败------失败的压缩如果无限重试,就是天价账单死循环。
DeepSeeker-Code 的解法是"三重感知":让压缩时机同时感知真实 token 口径 、缓存健康度 、对话单元边界 。这篇就拆 truncate.ts 这个文件,看这三重感知怎么交汇成一条压缩决策。底层原语在 contextCore.ts,持久化在 store.ts。
一、先看全貌:压缩不是"满了就压"
很多人对压缩的想象是:上下文满了 → 压一刀。但这样会出大问题。看 truncate.ts 里真正的触发判定(:241):
ts
if (estReal(event.messageArr) <= event.modelWindow * effectiveRatio) return;
注意它不是拿"原始估算"和"窗口"比,而是拿 estReal(校准后的真实口径)和 modelWindow * effectiveRatio(动态阈值)比。这两个值各自都不简单:
ts
const correctionRatio = event.correctionRatio ?? 1.4; // 口径校准
const estReal = (arr) => estimateTokens(arr) * correctionRatio;
// ...
const hitRate = lastCachedTokens / lastRealPromptTokens; // 缓存命中率
const cacheFactor = Math.min(1.1, Math.max(0.9, 0.9 + hitRate * 0.2)); // 缓存感知
const effectiveRatio = Math.min(event.compactRatio * cacheFactor, 0.82); // 动态阈值
这就是"三重感知"的交汇点。下面这张表是读这篇的钥匙------三重感知各管什么、怎么影响压缩:
| 感知维度 | 管什么 | 怎么影响 | 不感知会怎样 |
|---|---|---|---|
| 口径校准(correctionRatio) | 本地估算系统性低估约 31% | estReal = 估算 × 校准系数 |
以为安全其实快满了,靠 API 400 兜底 |
| 缓存感知(cacheFactor) | 压缩会击穿缓存 | hit 率高→cacheFactor 高→阈值高→推迟压 | 命中率高时还压,白白击穿缓存 |
| 批次切分(groupUnits) | 工具调用不可拦腰截断 | 按"对话单元"封批 | 批次落在 tool_calls/tool 间,报 400 |
拿几个场景盘一下,你会看到这三重感知实际怎么起作用:
-
场景 A:长任务跑了很多轮,缓存命中率高(94%)。 口径校准告诉它"真实 token 已经不低了",但缓存感知说"现在压掉,那 94% 的缓存红利就废了"。于是
cacheFactor把阈值顶高,推迟压缩,接着吃缓存红利。等缓存命中率掉下来了,才动手压。 -
场景 B:本地估算说"还安全,不用压"。 但口径校准系数是 1.4,
estReal把估算放大------"其实已经逼近窗口了"。于是提前触发压缩,不等 API 报 400 才动手(每次 400 都是一次完整失败的付费请求)。 -
场景 C:压缩时,旧历史里有"模型调工具 + 工具返回结果"这样一对。 批次切分保证这一对永远在同一个压缩批次里,绝不被拆开。要是拆开了------assistant 说"我要调 read_file",但后面的 tool 结果被切到另一批------API 直接报"tool_calls must be followed by tool messages"。
这三重感知是第 4 篇的灵魂。下面先看压缩的全流程,再逐个深挖。
二、压缩全流程:ensureFitsWindow
ensureFitsWindow 是压缩的入口,每轮推理前由主循环调用(上一篇讲的阶段表第 2 步)。它的流程:
第一步,判定要不要压 (:241)。estReal(全量) > modelWindow × effectiveRatio 才继续,否则直接 return------大多数轮次根本不压。
第二步,进入压缩循环 (:253):
ts
while (lastSize > event.modelWindow * effectiveRatio) {
const active = event.messageArr.slice(2); // 跳过 [0]系统提示词 [1]摘要槽
const { toCompact, keepRecent } = splitUntils(active, keep); // 切出待压区 / 保留区
const line = await compactToLine(toCompact, ...); // 压成摘要
summaryMsg.content = old ? `${old}\n${line}` : line; // 追加进摘要槽
// ... 摘要自收敛 ...
event.messageArr.length = 0;
event.messageArr.push(systemMsg, summaryMsg, ...keepRecent); // 重构上下文
// ... 落盘快照 ...
}
循环逻辑:切出"待压缩区"(旧消息)和"保留区"(最近 N 个对话单元),把待压缩区压成摘要塞进 message[1] 摘要槽,保留区原样留着。压完重算 token,没降到阈值以下就再来一轮。
第三步,兜底物理上限 (:373)。循环结束还有个 hardLimitRatio(compactRatio + 0.13,封顶 0.95)的检查------如果全量压缩完仍然超,说明任务本身太大,直接抛错让用户拆分任务,而不是硬塞给 API。
注意整个流程都绕着 message[0] 和 message[1] 转------系统提示词纹丝不动,摘要固定进第二个槽。这是上一章 P0-4 前缀缓存契约在压缩里的延续:压缩会改 message1 之后的内容,但 message0 系统提示词段保住,缓存损失最小化。
三、三重感知深挖
这是这篇的重头戏。
感知一:口径校准------本地估算为什么不准
本地算 token,用的是 estimateTokens(contextCore.ts)。它的口径是分类型的:
ts
const isStructured = m.role === 'tool' || (有 tool_calls);
const tokens = estimateTextTokens(pureText, isStructured ? 4 : 4.8) + 4;
中文按 1:1(一个字一个 token),英文散文按 ÷4.8,结构化内容(工具返回、工具调用参数)按 ÷4------因为代码、JSON 的 BPE token 密度比散文高。这个区分很关键,但还是低估。
为什么低估?因为本地估算再精巧,也只是经验公式,永远不可能和模型真实的 tokenizer 完全一致 。实测下来,对代码/CJK 场景系统性低估约 31%。这个低估的后果很严重:本地以为"还安全",其实真实 token 已经逼近窗口,于是漏压缩,最后靠 API 报 400 兜底------每次漏判都是一次完整失败的付费请求。
解法是 correctionRatio(:230):
ts
const correctionRatio = event.correctionRatio ?? 1.4;
const estReal = (arr) => estimateTokens(arr) * correctionRatio;
这个系数哪来?主循环每轮拿 API 返回的真实 prompt_tokens,和本地估算比,用 EMA(历史 0.6 / 新观测 0.4)维护(上一篇 runAgent 第 5 步讲过)。然后用 estReal 代替裸估算做判定,让压缩决策落在"真实 token 口径"上。
还有个更妙的点------这个校准系数跨 run 持久化 。一个会话往往跑多个 run,如果每个 run 都从 1.4 重新收敛,前几个 run 的压缩判定就会滞后。于是它被存进 store(store.ts 的 updateCalibration),下一个 run 接着用。注释写得很实在(runAgent.ts :98):"避免每 run 从 1.4 重零(实测致实现 run 压缩判定滞后、更晚压缩)"。
读源码启示:本地估算永远不准,关键是"知道不准多少、并动态修正"。EMA 平滑单轮抖动 + 跨 run 持久化让收敛不被打断,是这套校准能用的两个支点。
感知二:缓存感知------压缩本身是缓存的大敌
这是三重感知里最巧妙的一环。压缩明明是为了省钱(腾空间),但压缩动作本身会击穿缓存------因为它改写了 message1 之后的全部内容,从改动点往后,前缀缓存全废。
于是出现一个反直觉的权衡:缓存命中率越高的时候,压缩的"机会成本"越高 (一压,那 94% 的红利没了);命中率越低的时候(本来就在 miss 区),压缩越接近"纯赚"(反正要重新算,不如顺手压了)。cacheFactor 就是把这个权衡量化(:237):
ts
const hitRate = lastCachedTokens / lastRealPromptTokens;
const cacheFactor = Math.min(1.1, Math.max(0.9, 0.9 + hitRate * 0.2));
const effectiveRatio = Math.min(event.compactRatio * cacheFactor, 0.82);
hit 率高 → cacheFactor 高(最高 1.1)→ effectiveRatio 高 → 阈值高 → 推迟压缩 。hit 率低 → cacheFactor 低(最低 0.9)→ 阈值低 → 提前压缩。
两个克制设计很值得注意。一是幅度克制:cacheFactor 被夹在 [0.9, 1.1],不让缓存感知喧宾夺主------它只是微调,不是主导。二是 0.82 硬封顶:effectiveRatio 最高 0.82,绝不贴着窗口边缘,始终留安全余量。这两个克制,防止"为了吃缓存结果撑爆窗口"的极端情况。
读源码启示:一个优化动作本身可能有副作用(压缩击穿缓存)。成熟的工程不是消除副作用,而是"感知它的代价,动态决定要不要做"。
感知三:批次切分------工具调用不能拦腰截断
压缩要把旧历史发给摘要模型,但旧历史不是随便切的。contextCore.ts 的 groupUnits 先把消息按"对话单元"分组(:105):
ts
// assistant(tool_calls) + 紧跟的 tool 结果 = 不可分割单元
if (m.role === 'assistant' && m.tool_calls?.length > 0) {
const unit = [m];
while (messagesArr[i].role === 'tool') { unit.push(messagesArr[i]); i++; }
units.push(unit);
}
一次"模型决定调工具 + 工具返回结果"是一个整体,绝不能拆。为什么这么严格?看 truncate.ts 的血泪注释(:170):
原逐条按 token 封批,批次边界会落在 tool_calls 与 tool 结果之间,产生两类 400------"tool_calls must be followed by tool messages" / "tool must follow a tool_calls"------多批同时失败 → Promise.all reject → 连续失败计数 → 物理熔断,agent 直接死。
一个边界切错,就是两个 400;多批同时切错,整个压缩失败、触发熔断。所以封批严格按对话单元走,一个工具调用回合永不跨批 (:180):
ts
for (const unit of units) {
const size = estimateTokens(unit);
if (batchTokens + size > MAX_BATCH_TOKENS && batch.length > 0) {
batches.push(batch); // 当前批放不下,先封批
batch = []; batchTokens = 0;
}
batch.push(...unit); // 单元整体进批,绝不拆
batchTokens += size;
}
还有个细节:如果某个单元自己就超了 MAX_BATCH_TOKENS(16K),它只能独占一批------不能拆,拆了就破坏配对。宁可一批只有这一个超大的单元,也不能拦腰截断。
批次切好后,多个批次并行压缩 (:195):Promise.all(batches.map(b => compactBatch(b, signal)))。批次间无依赖(每次都是独立请求),并行能显著加速。这个并行安全的前提,正是前面的配对感知分组------只要批次边界不破坏工具配对,并行就安全。
读源码启示:流式协议里"工具调用 + 结果"是不可分割的配对。任何涉及切分消息数组的操作(压缩、分批、截断),都必须配对感知,否则就是隐蔽的 400 温床。
四、三个设计决策
决策一:摘要固定进 message1 槽,而非随意追加
压缩产出的摘要,固定写进 message[1] 这个"滚动摘要槽"(:273):
ts
event.messageArr.push(systemMsg, summaryMsg, ...keepRecent);
为什么不把摘要追加到末尾、或插到系统提示词后面?因为 message[0] 是系统提示词、message[1] 固定是摘要槽,这两个位置是全项目的硬约定(前几篇反复提过)。摘要进固定槽,意味着即便压缩了,message0 之后的前缀也尽可能稳定------再次保住前缀缓存。这是 P0-4 契约在压缩里的具体落地。
决策二:摘要自收敛,防"越压越大"怪圈
摘要只追加不收敛,会越长越大。看注释(:264):
摘要只追加不自收敛会越长越大,最终侵蚀窗口、形成"摘要越大→越早触发压缩→又追加新摘要"的怪圈。
解法是每轮压缩后检查摘要自身长度,超了 SUMMARY_SELF_COMPACT_THRESHOLD(2000 token)就就地再压一次 (:267):
ts
if (estimateTokens([summaryMsg]) > SUMMARY_SELF_COMPACT_THRESHOLD) {
summaryMsg.content = await compactToLine([summaryMsg], ...); // 摘要压摘要
}
这是个很容易被忽略的设计。摘要区不是无上限的黑洞,它本身也是个需要治理的"小上下文"。不自收敛,长会话迟早被摘要区吃满。
决策三:物理熔断,防天价账单死循环
压缩依赖摘要模型(一次 API 调用)。如果摘要模型挂了、网络崩了,压缩会失败。失败的压缩如果不加限制地重试,就是"每轮都发起一个注定失败的付费请求"------天价账单。
于是有连续失败熔断(:356):
ts
const nextFailures = (store.consecutiveFailures || 0) + 1;
await setRollingState(sessionId, { ..., consecutiveFailures: nextFailures });
if (nextFailures >= 3) {
throw new Error(`❌ [物理熔断] 上下文压缩已连续遭遇 ${nextFailures} 次失败。为防止天价账单死循环,系统已强行拦截。`);
}
连续失败计数持久化(写进 store),跨轮累积。到 3 次就硬熔断------宁可 agent 停下来报错让用户排查,也不无限烧钱。注释里的"保护钱包"四个字,是踩过坑的语气。
五、四个技术难点
难点一:token 估算的码点迭代陷阱
estimateTextTokens 有个很容易踩的坑------按什么遍历字符串。看注释(contextCore.ts :39):
用 codePointAt 按 Unicode 码点迭代:原 charCodeAt 把增补平面字符(emoji 等,占 2 个 UTF-16 code unit)的代理对算作 2 个 rest,导致 token 估算偏高、过早触发压缩。
JavaScript 字符串是 UTF-16,emoji 这类字符占两个 code unit。用 charCodeAt 逐个遍历,会把一个 emoji 算成两个字符,估算偏高、过早压缩。改用 codePointAt 按码点迭代,并跳过低位代理:
ts
for (let i = 0; i < text.length;) {
const c = text.codePointAt(i)!;
if (isCjkCodePoint(c)) cjk++; else rest++;
i += c > 0xffff ? 2 : 1; // 增补平面字符占 2 个 code unit,跳过低位代理
}
这种"看不见的字符"导致的估算偏差,是最难查的一类 bug------它不报错、不崩溃,只是悄悄让你的压缩时机偏了。
难点二:批次边界与两类 400
难点三(批次切分)已经讲过。补充一个为什么 MAX_BATCH_TOKENS 固定 16K 而不是按窗口比例(:167):
原 modelWindow×0.25≈62.5K 一批过大,摘要模型在超长输入下注意力稀释、丢细节------而摘要恰是用来保细节的。
摘要模型一次性吃 62K,注意力被稀释,会漏掉关键细节。而压缩的目的恰恰是保住细节(压成摘要就是怕丢上下文)。所以宁可多分几批(通常 1-3 批)、每批小一点,保证摘要质量。这是"批次大小"在"压缩速度"和"摘要质量"间的取舍,选了质量。
难点三:压缩与缓存的因果纠缠
感知二讲了缓存感知,这里讲它难在哪。压缩和缓存是互为因果的:压缩击穿缓存 → 下一轮缓存命中率掉 → 触发"低命中率早压缩" → 又压缩 → 又击穿......
如果任它发展,就是"压一次、缓存崩一次、又触发压缩"的恶性循环。克制设计(幅度夹 0.9,1.1 + 0.82 封顶)就是防这个------不让缓存感知反应过激,避免正反馈失控。本质上,缓存感知是个带阻尼的控制,不是无脑追极值。
难点四:落盘的原子性与并发锁
压缩每轮都落盘快照(rollingSummary、archivedMessageCount)。落盘本身有两个坑(store.ts)。
一是原子写 (:117):先写 .tmp 再 rename,避免写一半崩溃留下截断的 state.json------截断文件会被当空对象,archivedMessageCount 归零,已归档的旧消息重新进上下文,破坏压缩语义。
二是per-session 写锁 (:127):state.json 的 read-modify-write 在 await 间可能交错(压缩写摘要 vs SessionEnd hook 写 store),后写覆盖先写,滚动摘要静默丢失。于是用 per-session 互斥锁串行化所有写操作。
ts
const withStoreLock = (sessionId, fn) => {
const prev = storeLocks.get(sessionId) ?? Promise.resolve();
const run = prev.then(fn, fn);
storeLocks.set(sessionId, run.then(() => {}, () => {}));
return run;
};
读不锁(读到稍旧值可接受),写串行。这是本地文件存储处理并发的标配------尤其一个会话里压缩、hook、todo 多处都在写同一个 state.json 时。
六、推荐的源码阅读顺序
- 先读 contextCore.ts :
estimateTokens(估算口径)、groupUnits(配对分组)、splitUntils(切分)。这是压缩的三个底层原语,先建立"怎么算 token、怎么切消息"的认知。 - 读 truncate.ts 的工具函数区 :
microcompactTextContent(文本微压缩)、truncateToolResult(头尾截断)。这些是"单条消息"层面的压缩。 - 重点读
ensureFitsWindow(:225):对照第二节的流程,把三重感知的交汇点(:230-241)逐行读通。 - 读
compactToLine+compactBatch(:150/166):看批次怎么封、怎么并行压缩。 - 读熔断段 (
:319-358)和兜底段(:373):看压缩失败和超窗口两条极端路径怎么收场。 - 最后读 store.ts 的
getRollingState/setRollingState/updateCalibration+ 写锁:看压缩状态怎么持久化、怎么防并发。
七、关联:压缩的上下游
压缩不是孤岛,它和很多东西咬合:
- 上游 :主循环每轮推理前调
ensureFitsWindow(第 2 篇阶段表第 2 步);推理时若 API 报 context_length_exceeded,streamInference还会强制更激进地压一次重试(第 3 篇三道重试)。 - 校准来源 :
correctionRatio和缓存数据,来自streamInference回传的真实 usage,由 runAgent 用 EMA 维护、跨 run 持久化。 - 下游 :压缩产出写进
message[1]摘要槽,保 P0-4 前缀缓存(第 3 篇);落盘快照让会话恢复后能重建带摘要的上下文。 - 熔断:连续失败计数与 token-cost 诊断(本系列后续)相关------压缩失败是 token 失控的一大来源。
你会看到,"三重感知"不是压缩模块自己闭门造的车,它的每个输入(真实 token、缓存命中)都来自推理层,每个输出(摘要、落盘)都服务于会话层。压缩是连接推理、缓存、持久化的枢纽。
最后
上下文压缩这块,表面是"满了就压",实际是在三个互相矛盾的约束之间反复拿捏:压准(口径校准)、压省(缓存感知)、压稳(批次切分 + 熔断)。DeepSeeker-Code 用"三重感知"把这三个约束统一进一个 effectiveRatio,让压缩时机既不浪费缓存、又不撑爆窗口、还不切错边界。
读这段源码,最值得带走的是那个 effectiveRatio = min(compactRatio × cacheFactor, 0.82) 的式子------它把三个维度的感知浓缩成一行代码,是整个压缩模块设计含量的集中体现。
下一篇,我们读计划模式与子 Agent------看 agent 怎么把"想清楚再动手"和"派分身去干活"做成两套机制。
项目源码开源在 github.com/xnk/deepSee... ,文章里提到的文件都在 src/core/src/agent/ 和 src/core/src/session/ 下,欢迎对着源码读。觉得这个导读系列有点意思,点个 star 是对我最大的鼓励。
总结
- 压缩不是满了就压 :触发判定是
estReal(校准估算) > modelWindow × effectiveRatio(动态阈值),三重感知交汇在这个式子里; - 三重感知:口径校准(correctionRatio 修正本地低估,EMA + 跨 run 持久化)、缓存感知(cacheFactor:hit 率高推迟压、低早压,幅度夹 0.9,1.1 + 0.82 封顶)、批次切分(groupUnits 对话单元不跨批,防 400);
- 全流程:判定 → 循环(splitUntils 切 / compactToLine 压 / 写 message1 槽 / 落盘)→ hardLimitRatio 物理兜底;
- 三个设计决策:摘要固定进 message1(保前缀缓存)、摘要自收敛(防越压越大怪圈)、连续失败 3 次物理熔断(防天价账单死循环);
- 四个技术难点:码点迭代(emoji 代理对致估算偏高)、批次大小取舍(16K 保摘要质量 vs 速度)、压缩与缓存因果纠缠(带阻尼控制)、落盘原子写 + per-session 写锁(防截断/覆盖丢失)。