DeepSeek Harness · 上下文工程 Compaction 双路径与 replaceGeneration 上下文快满了就主动收拾,真撑爆了就先收拾再重试。重试前先对一遍世代号,收拾没起效就不许重试。 一句话速览 压力触发与溢出恢复两条正交路径,以及用世代号证明「压缩确实发生过」才允许重试
课程目标 读完你能说清两个思路:DSH 为什么把自动压缩拆成主动和被动两个触发器,各管一段、互不重叠;以及溢出后允许重试的凭证,为什么是 replaceGeneration 这个只增不减的世代号,插件自己的返回值为什么不算数。
交互演示 · 行李箱收纳模拟器
把上下文窗口想成一只行李箱:每条消息是一件衣物,虚线是八分满警戒线。左下角的收拾次数就是 replaceGeneration (世代号),只增不减。三个情景对应压缩的三种命运,每一步都有字幕解说。
情景 A · 快满了就收拾 情景 B · 爆仓后救回来 情景 C · 收拾没起效
播放 单步 重置
八分满线(阈值 0.8)
请求被拒 · CONTEXT_WINDOW_EXCEEDED
收拾次数 replaceGeneration 3
选一个情景点播放,或滚动到此处自动播放情景 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.replaceGeneration L191 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 的单调前进,插件的返回值不算数。要证据,别信口头汇报。