DeepSeeker-Code源码导读04-上下文压缩

读懂上下文压缩:让 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,用的是 estimateTokenscontextCore.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.tsgroupUnits 先把消息按"对话单元"分组(: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 时。

六、推荐的源码阅读顺序

  1. 先读 contextCore.tsestimateTokens(估算口径)、groupUnits(配对分组)、splitUntils(切分)。这是压缩的三个底层原语,先建立"怎么算 token、怎么切消息"的认知。
  2. truncate.ts 的工具函数区microcompactTextContent(文本微压缩)、truncateToolResult(头尾截断)。这些是"单条消息"层面的压缩。
  3. 重点读 ensureFitsWindow:225):对照第二节的流程,把三重感知的交汇点(:230-241)逐行读通。
  4. compactToLine + compactBatch:150/166):看批次怎么封、怎么并行压缩。
  5. 读熔断段:319-358)和兜底段(:373):看压缩失败和超窗口两条极端路径怎么收场。
  6. 最后读 store.tsgetRollingState/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 是对我最大的鼓励。

总结

  1. 压缩不是满了就压 :触发判定是 estReal(校准估算) > modelWindow × effectiveRatio(动态阈值),三重感知交汇在这个式子里;
  2. 三重感知:口径校准(correctionRatio 修正本地低估,EMA + 跨 run 持久化)、缓存感知(cacheFactor:hit 率高推迟压、低早压,幅度夹 0.9,1.1 + 0.82 封顶)、批次切分(groupUnits 对话单元不跨批,防 400);
  3. 全流程:判定 → 循环(splitUntils 切 / compactToLine 压 / 写 message1 槽 / 落盘)→ hardLimitRatio 物理兜底;
  4. 三个设计决策:摘要固定进 message1(保前缀缓存)、摘要自收敛(防越压越大怪圈)、连续失败 3 次物理熔断(防天价账单死循环);
  5. 四个技术难点:码点迭代(emoji 代理对致估算偏高)、批次大小取舍(16K 保摘要质量 vs 速度)、压缩与缓存因果纠缠(带阻尼控制)、落盘原子写 + per-session 写锁(防截断/覆盖丢失)。
相关推荐
鲁邦通物联网41 分钟前
充电站柔性负荷架构演进:网络卡顿导致设备烧损,如何依托边缘计算网关重塑本地调功防线?
人工智能·边缘计算·边缘计算网关·物联网网关·5g数采·边缘计算盒子·工业级边缘计算网关
weixin_4713830344 分钟前
17 Self-RAG —— 幻觉检测 + 答案质量评估
python·agent
叠层归一研究院1 小时前
如何用程序搭建一个 AGI 种子系统(三):生长如何对接物理与数学宇宙
人工智能·python·算法·机器学习·transformer·agi
兴趣使然黄小黄1 小时前
【AI-agent】让 AI 输出可依赖:LLM 工程化的四道防线
大数据·人工智能
2501_926978331 小时前
AGI封锁的物理边界:发现模式决定封锁可行性
人工智能·经验分享·笔记·ai写作·agi
tech讯息1 小时前
企业 AI Agent 对接外部服务如何安全集成?—— 多租户业务场景优先选用 WebSocket 方案
人工智能·websocket·安全
秦先生在广东1 小时前
ai-memory:AI 编码 Agent 的跨会话持久记忆与跨厂商接力方案
人工智能
过去式的美好1 小时前
阿里云 2 核 2G 服务器搭建 AI 知识库:从 0 到可用(附踩坑实录)
服务器·人工智能·阿里云
阿弱1 小时前
graph-core 的边与命令模式设计
java·后端·agent