DeepSeek Harness · 上下文工程

Compaction 双路径与 replaceGeneration

上下文快满了就主动收拾,真撑爆了就先收拾再重试。重试前先对一遍世代号,收拾没起效就不许重试。

课程目标读完你能说清两个思路:DSH 为什么把自动压缩拆成主动和被动两个触发器,各管一段、互不重叠;以及溢出后允许重试的凭证,为什么是 replaceGeneration 这个只增不减的世代号,插件自己的返回值为什么不算数。
交互演示 · 行李箱收纳模拟器

把上下文窗口想成一只行李箱:每条消息是一件衣物,虚线是八分满警戒线。左下角的收拾次数就是 replaceGeneration(世代号),只增不减。三个情景对应压缩的三种命运,每一步都有字幕解说。

八分满线(阈值 0.8)
请求被拒 · CONTEXT_WINDOW_EXCEEDED
收拾次数 replaceGeneration3
选一个情景点播放,或滚动到此处自动播放情景 A。
逻辑轨迹(行号对应 compaction-basic/src/index.ts,动画走到哪一步,哪一行亮)
PRESSURE 路径
on('agent/pre-step')L147
measure().totalTokens ≥ thresholdTokens ?L304
剪枝 → 摘要 → surface 替换L308-323
CONTEXT-OVERFLOW 路径
on('agent/request-error')L179
code ≠ CONTEXT_WINDOW_EXCEEDED → next()L183
generation = surface.replaceGenerationL191
compactIfNeeded('context-overflow')L194
replaceGeneration > generation ?L218-219
是 → return { kind: 'retry' }L222
否 → return next(),保留原始错误L219
演示是教学化模拟:容量条、衣物块与世代号都是课程化抽象。情景 C 里谎报成功的压缩后端是教学假设,真实的 compaction-basic 不会谎报,只是 compaction 是一个开放接缝,第三方后端接进来之后什么都可能发生,世代号对账防的就是它们。出处:packages/compaction/compaction-basic/src/index.ts 第 147 至 223 行。
设计思路一 · 收拾东西要分两个触发器

它解决什么问题

假设只做主动阈值这一条路:每次请求前量一下,超过八成就收拾。听起来够了,实际不够。token 数是估出来的,估算和 provider 的真实计数总有出入。一条超大的工具结果突然塞进来,测量还没到阈值,请求已经超限,provider 直接拒绝。这时候没有任何补救逻辑接手,turn 就地报错终止,用户看到的是一次莫名其妙的失败。

一次算错就直接撞墙,没有第二道防线。这就是单触发器的问题。

思路是什么

DSH 把这件事拆成两个独立的触发器。快满了主动收,撞墙了被动救,两条路挂不同的事件、用不同的条件、有不同的失败语义。

pressure 路径挂在每个 Step(一次模型请求)开始之前。先量总 token,超过容量的 0.8 就动手收拾,收拾时给最近的对话留 16% 的原文尾巴。它的失败语义很松:收拾中途出了错,日志里记一句就继续走,提前收拾失败了天塌不下来。

context-overflow 路径挂在请求报错之后,只认适配器规范化过的错误码 CONTEXT_WINDOW_EXCEEDED,其他错误一律放行。它不看阈值,保留预算直接清零,强制做一次真实的缩减。它的失败语义很严:必须给出决定,要么重试,要么保留原始错误上报。重试上限默认 1 次,每收到一条成功的模型回复就清零计数,正常干活的会话不会被卡住。

同一件事,两个触发器,各管一段 快满了(请求前测量) 总 token 超过容量的 80% 主动收拾 留 16% 最近对话原文 旅程继续 失败只记一句日志 撞墙了(请求被拒) CONTEXT_WINDOW_EXCEEDED 强制收拾 不看阈值,能压全压 对账后决定 重试,或上报原始错误
上排是预防,下排是兜底。两条路各自独立,一条失效了另一条照常工作。

出处:pressure 监听器在 packages/compaction/compaction-basic/src/index.ts 第 147 至 165 行,overflow 监听器在第 179 至 223 行;0.8 与 0.16 两个默认值在同包 config.ts 第 20 与 23 行,重试上限默认 1 次在第 93 行,都可按 provider 加 model 的组合逐一覆盖。

为什么长期成立

把预防和兜底分开,是可靠性工程的通则。备份和恢复是两套系统,限流和熔断是两道闸门,道理相同:预防路径追求便宜、常跑、失败无所谓;兜底路径追求可靠、少跑、失败必须有交代。这两种诉求塞进同一段逻辑里必然互相迁就。所以哪怕换个语言重写整个 harness,只要模型有上下文上限、token 靠估算,这两个触发器就都得在。

设计思路二 · 重试要出示证据

它解决什么问题

撞墙之后收拾了一次,接下来要重发请求。问题是:怎么确认收拾真的起效了?compaction 是一个开放接缝,第三方可以接自定义后端。假设某个后端每次都报告成功,但从来没真正改过模型可见的内容:如果只看返回值就重试,请求原样超限,再报错,再压缩,再重试,每一圈都是白花的 API 钱,死循环烧到天亮。

思路是什么

DSH 的答案是一个世代号。先说背景:surface 是会话日志里模型可见事件的实时投影,可以理解成模型眼里的那份对话。replaceGeneration 是它身上的一个只读计数器,记的是这份对话被替换过几次。整个代码库只有一处会让它加一:一段旧消息真的被摘要替换、真的落盘的那一刻。没有任何 API 能把它改小或重置。所以世代号前进了,在数学上等价于至少发生过一次真实的、已落盘的替换。

溢出恢复的用法就三步:动手收拾之前先拍快照记下当前值;收拾;收拾完拿新值和快照比。严格变大才允许重试,否则放行,原始错误原样上报。插件说什么不重要,账本上的数字变没变才重要。

重试前先看收拾次数有没有变 动手前拍快照 收拾次数 = 3 收拾一次 交给压缩后端执行 再看一眼计数 现在是几? 变成 4,箱子确实动过 允许重试 还是 3,收拾没起效 拒绝重试,上报原始错误
计数只增不减,全库只有替换真正落盘的那一处会加一,所以数字变大就是硬证据。

还有一个反方向的细节。就算收拾中途抛了异常,只要前面的免费剪枝已经落盘、世代号已经前进,这份进展照样够格授权重试。凭证据放行,凭证据拒绝,两边用的是同一条标准。

世代号没前进,一次重试都不放行。

出处:快照与比对在 packages/compaction/compaction-basic/src/index.ts 第 191 行与第 218 至 222 行,异常后凭已落盘进展重试在第 195 至 208 行;replaceGeneration 的定义在 packages/core/session/src/surface.ts 第 136 至 142 行,全库唯一的加一处在第 361 至 371 行。项目的 Agent Note(.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md)明确否决过只看返回值的写法,理由是自定义后端可能报告成功却没有改变模型可见状态。

为什么长期成立

用单调递增的版本号证明状态确实变了,这个套路数据库的乐观锁用了几十年,Git 的 commit 链、分布式系统里的 epoch 也都是它的变体。它的好处是把信任问题变成算术问题:执行者可以撒谎,账本不会。只要系统里存在不受信任的扩展点,重试之前核对一个改不了的计数器,永远是最便宜的防线。

横向对比 · 三家怎么防烧钱
Grok Build

主动阈值路径和 DSH 的 pressure 同构:默认 85% 触发,另有默认关闭的 two-pass 预摘要,细节见站内 Compaction:85% 阈值与可选 two-pass。请求报错后的被动恢复加世代号对账这条路,在已核对的 Grok Build 材料里没有见到等价机制。这一条基于已公开证据,保留未知项。

Claude Code

主动方向做得最厚,每次调 API 前要过裁剪、微压缩、折叠、全量摘要四道工序。防烧钱的答案是计数熔断:自动压缩连续失败 3 次就停手。这个 3 来自真实事故,源码注释记载曾有 1279 个 session 连续失败 50 次以上,全球每天浪费约 25 万次 API 调用。出处:claude-code-sourcemap-main/study/chapters/03-context-management.md 第 78 至 81、121 至 124 行。

对比焦点就一个:压缩失败会不会循环烧钱。Claude Code 数失败次数,数到 3 就熔断,止损线是拿事故数据校准出来的,属于计数器止损:先允许问题发生几次,再靠上限兜住。DSH 不数次数,要求每次重试都出示世代号前进的证据,一次无效重试都不放行,属于结构性证明:让无效重试从机制上发不出去。前者的 3 需要事故喂出来,后者的 0 是推导出来的。再加上双路径正交这一点,这两处是 DSH 在三家对比里独有的设计。

课堂练习
01

手推一个谎报成功的后端

设重试上限为 1,装一个自定义压缩后端:每次都报告收拾成功,但从不真正替换模型可见的内容。现在第一次溢出报错发生了。

问题一:DSH 会发起第二次压缩尝试吗?提示:对账失败后原始错误直接上报,turn 就结束了,重试计数根本没机会增加。

问题二:如果把判定标准改成只看后端的返回值,同一场景下每圈耗时 5 秒,第一分钟会发出多少次注定失败的请求?Claude Code 的 3 次熔断又会在第几次请求后止损?

Takeaway:快满了主动收,撞墙了被动救,预防和兜底各走各的触发器。溢出重试的凭证是 replaceGeneration 的单调前进,插件的返回值不算数。要证据,别信口头汇报。