dsh 源码解析
第三段 · 能力与治理 · 第 10 章

上下文压缩:测量、剪枝、摘要、溢出恢复

四层由便宜到贵的缩减机制;摘要调用重放前缀以复用缓存

本章基准:dsh 0.1.1-rc.2,commit b150a551b8d465e31e418e1b2eaf5e79bbb7d28e

主要源码:packages/compaction/(4 包)、packages/llm/token-meter/packages/spill/(3 包)

上下文窗口会满。绝大多数 harness 对这件事的处理是"历史太长了就叫模型总结一下"。dsh 把它拆成了四种独立的机制,各自有独立的触发条件、独立的代价和独立的失败模式:

机制 什么时候 花几次模型调用 谁的责任
spill 工具刚返回、结果太大 0 tools/post-execute(第 7 章的 waterfall)
prune 压力已经超阈值,摘要之前 0 ctx.toolResultPruner
summarize 剪枝之后仍超阈值 1(可能重试) ctx.compaction
overflow recovery provider 已经明确报了超窗 1 agent/request-error

四层由便宜到贵,前一层能解决就不进下一层。 而且它们全部通过第 5 章那个 append-only 日志的 surfaceOp: replace 实现——原始事件永远留在日志里。

全章图示:assets/ch10-compaction-layers.svg

四层缩减机制与压缩事务


1. 第零层:spill —— 大结果根本不该进上下文

在压缩机制之前,先看一个更早的拦截点。spill-policy 是一个 tools/post-execute 监听器(第 7 章的第三个 waterfall):

When a final result exceeds maxInlineBytes, it saves the FULL text through ctx.spillStore and replaces the model-facing result with a bounded head/tail preview plus the backend's locator and retrieval hint.

模型看到的是:

<retained head/tail preview>

(Omitted N bytes. Full formatted result stored at: /…/session-…/…-web_fetch.txt. Use read with offset/limit, or grep this path to search within it.)

不是丢弃,是换成一个可检索的定位符。 模型想看细节就 readgrep 那个路径。

这个插件的自我限定值得注意:

This plugin registers no service and owns no storage or preview mechanics: preview is dsh-output-retention (TextRetainer), storage is ctx.spillStore. It only decides WHEN to spill and composes the notice.

一个只做决策的插件。预览怎么切是别人的,存哪里是 seam 的,它只回答"该不该 spill"。

1.1 预算里要预留通知的成本

sized so the whole replacement (preview + blank line + notice) stays within maxInlineBytes — the notice's byte cost is reserved out of the budget, so the preview shrinks to fit and the model-facing result never exceeds the cap

而且有个边界:通知本身就填满预算时预览为空;连通知都超预算时保留原结果——it never emits a replacement over the cap。所以有一条不变式:spill 永远不会让结果变大。

1.2 跳过 read

行为列表第 2 条:

Skip nested executions, accepted value replacements, read (avoids a read → spill → read again loop), and any non-accept decision

read 必须跳过,否则形成循环:读一个大文件 → spill 成一个"用 read 读这个路径"的通知 → 模型 read 那个路径 → 又 spill。

run_code 子调用的日志副本read 是要 spill 的,理由写得很清楚:a log copy is not model context, so the read-again loop cannot occur, and read is precisely the tool that produces huge logs同一个规则在不同上下文里的判断相反,因为循环的前提不同。

1.3 best-effort,且不改变成败

no session owner, no ctx.spillStore backend, or a saveText rejection ⇒ the policy logs a warning and returns the original result. A spill failure never turns a successful call into an isError or hides the inline result.

对比第 9 章的沙箱:那里是 fail-closed(不确定就拒绝),这里是 fail-open(存不下就保留原样)。因为威胁不同:沙箱失败意味着可能越权,spill 失败只意味着上下文更挤。

存储侧(spill-local)的路径设计倒是很硬:

<root>/session-<hash>/<random>-<safeName>
  • root 默认是私有 0700 的进程级临时目录——A predictable, world-readable root would let other local users read spilled tool output or plant symlinks
  • 文件名前缀是不可预测的 hex——defeats symlink planting in a shared root
  • 写入是 open(path, 'wx', 0o600)——排他 + 仅所有者,任何已存在路径(无论是否符号链接)都失败,所以预埋的目标无法重定向写入。

一个"临时文件"也可以是攻击面。 工具输出可能包含凭据。


2. 测量:一个共享的、replay-aware 的计量器

ctx.tokenMeter 是单例服务,和压缩没有依赖关系

It advances one isolated fold per session from the durable log, so compaction and other pressure-sensitive plugins can share accounting without depending on CompactionEngine.

两个操作:

measure(session, requestHeader?)   // 请求压力 + 当前 surface 定价(同一个 consumed-log revision)
estimateMessage(message)           // 用固定启发式给一条消息定价

2.1 固定启发式,明确拒绝配置

The estimator has no settings. It intentionally uses one fixed heuristic: four characters per token plus structural overhead for roles, blocks, and request-envelope fields. Any key is rejected;model capacity belongs to the adapter that owns an exact provider/model route.

故意不可配置。 一个可调的估算器会诱导每个部署去调它,而真正的答案(模型容量)在拥有那条路由的 adapter 手里。这是第 8 章"配置在拥有它的边界解析"的另一面:不该我拥有的东西,我连旋钮都不提供。

2.2 provider usage 只在信封完全一致时复用

Provider usage is reused only when the latest successful call's canonical request envelope matches the measured envelope and its total is no lower than that call's full heuristic anchor; a later success replaces the earlier anchor. Otherwise the complete current envelope and surface are estimated. Surface changes remain signed relative to a matching anchor, including negative deltas after shrinking replacements.

策略是:能用 provider 报的真实数字就用,一旦信封变了就退回全量估算。而 surface 的变化是带符号的增量——压缩造成的收缩是负值。

已知限制里承认了代价:

Provider usage is only reusable for an identical canonical envelope — prompt, prefix, tools, provider, model, or call-config changes deliberately fall back to full heuristic estimation.

以及性能上的坦白:

Every measurement clones the current surface — coherent immutable snapshots make reads O(surface), including below-threshold pressure checks.

每一次压力检查(包括没超阈值那些)都是 O(surface)。 换来的是一致的不可变快照。这个取舍被写了下来,不是被藏起来。

2.3 projectedTokens:为什么必须有这个字段

session projection 里三个单元中最有意思的是 contextPressureprojectedTokens

projectedTokens is what the NEXT request's prompt would cost: the sample plus the heuristic repricing of everything the surface gained or lost since it was taken … That last case is why the field exists: compaction summarizes through a direct ctx.llm.stream() call and appends no usage of its own, so pressureTokens alone reports the pre-compaction prompt until an entire further turn completes.

压缩不产生 usage 记录,所以 provider 报的数字在压缩后是过时的。 用户刚 /compact,占用率却一动不动——直到又跑完一整轮。projectedTokens 用启发式给"自采样以来 surface 的增减"重新定价,把 provider 的锚点搬到当前。

contextBreakdown 那段的诚实程度更值得抄:

All three figures use the measurement service's fixed heuristic and are estimates: they will not sum to projectedTokens, whose provider anchor carries exactly the error — CJK text and JSON schemas underprice badly at four characters per token — that the composition rows still contain. Present them as an approximate composition, never as a total.

四字符一 token 对中文和 JSON schema 严重低估。文档不但承认,还直接指示 UI 怎么呈现。

2.4 占用率是参考值,不是判据

The occupancy fields are independent last-wins records and are not one atomic observation of a single request. … This is deliberate. An occupancy percentage is a user-facing reference figure, not a billing record or a gating input — nothing in the harness makes decisions from it, and compaction reads measure() instead.

被拒绝的方案(原子配对比较)记在 Agent Note 里。给人看的近似值和给机器做决策的精确值是两条路,不要为了让 UI 好看去污染决策路径。


3. 第一层缩减:model-free 剪枝

ctx.toolResultPruner 把超预算的 tool/result 改写成"有界头部 + 固定省略标记 + 有界尾部":

\n\n[... tool result middle pruned ...]\n\n

默认预算:thresholdChars: 8192headChars: 4096tailChars: 1024(单位是 Unicode 码点)。

3.1 一个可判定的收敛性质

Every emitted result has exactly the configured head budget, fixed marker, and tail budget in text code points, is no larger than thresholdChars, and is strictly smaller than the triggering input. A second pass therefore emits no replacement.

而配置校验保证了这个性质:headChars + marker + tailChars must fit within thresholdChars

幂等性是配置校验的产物,不是运行时检查的产物。 加载时挡住不可能收敛的配置,运行时就不需要防重复改写。

3.2 替换保留原事件的一切

它 append 一个新的 tool/result

{ surfaceOp: { op: 'replace', start: originalSeq, end: originalSeq }, sourceEventSeqs: [originalSeq] }

The replacement spreads the complete original data and changes only content, preserving turn, step, callId, error fields, meta, and later data additions.

只改 content,其余整体铺开。 callId 必须保留,否则第 7 章那个工具配对就断了;meta 必须保留,否则 UI 渲染信息丢失。

3.3 三条承认的限制

  • 字符预算不是 token 预算——ctx.tokenMeter remains the authority for deciding whether pruning relieved request pressure;
  • 剪枝是语法性的——it retains the beginning and end without interpreting which middle lines are semantically important
  • grapheme cluster 可能被切开——码点切分保护了代理对,但不做 locale-aware 分割。

第一条是关键的分工:剪枝用便宜的字符数决定"改哪些",压力是否解除由 token 计量器说。


4. 第二层:压缩 seam

4.1 一个刻意违反指南的 Service Definition

CompactionEnginepackages/compaction/compaction/src/index.ts:96)三个抽象方法。而 README 里承认了一个例外:

Unlike the bash seam, this Service Definition depends on dsh-session and dsh-llm — the contract's verbs are defined over a Session and its output is the ContentBlock vocabulary, so they cannot be expressed without naming those packages. That deviation from the "Service Definition depends only on cordis" guidance is intentional and recorded in the capability-seam Agent Note.

对比第 9 章:那里 sandbox 包用结构化类型 EscalationApprover<A, C> 避开了 import。这里做不到,因为契约的动词就是定义在 Session 上的——compactRegion(start, end, agent) 里的 seq 是会话 seq,输出是 ContentBlock[]

用结构化类型规避依赖有个前提:契约不真正依赖那个词汇。 这里前提不成立,于是记录例外,而不是硬扭。

4.2 范围是 surface 位置,不是 seq 区间

compactRegion(start, end) 的文档里这句最容易被误读:

The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order.

第 5 章的 replace 是"append 一个新事件 + 声明它遮蔽一段旧范围"。所以一次压缩之后,surface 的最前面坐着一个 seq 很大的摘要节点。"最老的一段"在 surface 顺序里是头部,在 seq 顺序里可能是最大值。 把范围当数值区间处理就会选错。

4.3 五步事务:为什么锁的释放在变更之后

1. append compaction/start   (log-only)— 获取锁
2. 摘要这段范围
3. append compaction/summary (log-only)— 摘要、范围、被遮蔽 seq、token 数、provider/model 调用信封
4. append 一个 user/message  — source 是 compactCheckpointSource(compactionId)
                              surfaceOp: { op: 'replace', start, end }
                              ← 整个操作里唯一的 surface 变更
5. append compaction/end     (log-only)— 释放锁

关键在第 4 步的位置:

The surface mutation (step 4) sits inside the lock bracket: compaction/end is the last event, so the lock is never released before the mutation lands. A crash between compaction/start and compaction/end therefore leaves a detectable orphaned lock rather than a compaction/end that falsely claims compaction finished while the surface was never shadowed.

崩溃后留下一个可检测的孤立锁,比留下一个说谎的完成标记好。 前者是可以诊断的异常状态,后者是无声的数据错误。

compaction/* 事件不可能出现在 surface 上——SurfaceEventType 是闭合联合,只有 user/messageassistant/messagetool/result 能带 surfaceOp(第 5 章)。所以摘要必须通过一个 user/message 落地。这不是设计选择,是类型系统的后果。

4.4 标记对命名的是锁,不是容器

The marker pair names lock acquisition and release, not an exclusive event container. An idle inject() may append unrelated context between a manual start and end while summarization is pending. Manual stability therefore revalidates the selected span rather than demanding whole-surface equality; the positional replacement leaves that injected context visible after the checkpoint. Automatic compaction keeps whole-surface equality inside its active turn.

两种稳定性规则:

  • 自动压缩在自己的 turn 内,要求整个 surface 不变;
  • 手动 /compact 可能等好几秒,期间第 4 章的 inject() 可能塞进新的上下文——所以只重新校验被选中的那一段

同一个操作的两个入口有不同的并发假设,就该有不同的稳定性规则。 强行统一成"整体不变"会让手动压缩在正常使用下随机失败。

4.5 锁的活性判断跨越进程生命周期

Tail inspection independently finds the latest unmatched compaction/start and the newest session/end-seed. An unmatched start after that boundary is live and reports busy; an older unmatched start is stale evidence from a prior process lifecycle and does not block.

session/end-seed(第 5 章那个 seed 结束标记)在这里当作生命周期分界。一个上次进程崩溃留下的孤立锁不能永久堵住这个会话。

而且锁的实现方式被明确对比过:

The lock is the durable bracket, not a WeakSet, wrapper mutex, or client-side anchor. compaction/start is appended synchronously before summarization yields.

内存互斥量在进程崩溃后消失,日志括号不会。

4.6 每个失败恰好一次关闭尝试

Every later failure makes exactly one compaction/end { error } attempt; if that close append itself fails, the unmatched start remains the intentional busy signal and no flush is attempted.

以及:

A successfully closed manual attempt is flushed even when it reports changed or summary, preserving the recorded attempt before turn admission is released.

失败的尝试也要持久化。 因为"我试过压缩但摘要没能缩小"是一个诊断事实。

ManualCompactionError.code 是闭合集合 busy | changed | summary | commit | persistencecommand-compact 把每一个映射成一句稳定的人类文本。最值得看的是 commit

Compaction did not finish cleanly; some session history may have changed. Inspect the current session state before retrying.

commit is deliberately neutral about partial mutation

不确定改了多少就别声称。 一句"失败了,没有影响"在部分变更的情况下就是谎话。


5. 后端:压缩策略住在 provider 里

BasicCompactionEngine 拥有全部策略。

5.1 两个触发路径,两个扩展点

ctx.on('agent/pre-step', async ({ agent, signal }, next): Promise<PreStepDecision> => {
  if (!signal.aborted) {
    try {
      const result = await this.compactIfNeeded(agent, 'pressure', signal)
      if (result !== null) logResult(result, 'step pressure')
    } catch (error: unknown) {
      if (error instanceof TargetPressureConfigError) {
        if (this.warnedPressureConfigTargets.has(error.targetKey)) return next()
        this.warnedPressureConfigTargets.add(error.targetKey)
      }
      ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`)
    }
  }
  return next()
})

compaction-basic/src/index.ts:147

这是第 4 章那个 agent/pre-step waterfall 的一个 serial 监听器。主动压缩在请求派生之前发生——所以这一步的请求已经用上了压缩后的历史。

而失败处理是"警告后继续":

Summarization failure preserves the latest durable surface — before any replacement, the auto path logs a warning and proceeds with full over-budget history.

压缩失败不能让这一轮死掉。 超预算的请求可能仍然成功(阈值是 0.8,不是 1.0);就算不成功,下面那条溢出恢复路径会接住。

注意 TargetPressureConfigError 的去重:同一个 provider/model 组合的配置错误只警告一次。一个每步都刷一遍的警告等于没有警告。

5.2 溢出恢复:provider 说超了才算超

ctx.on('agent/request-error', async ({ agent, failure, signal }, next) => {
  if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next()

  const generation = agent.session.surface.replaceGeneration
  let result: CompactionResult | null
  try {
    result = await this.compactIfNeeded(agent, 'context-overflow', signal)
  } catch (recoveryError: unknown) {
    if (!signal.aborted && agent.session.surface.replaceGeneration > generation) {
      ctx.logger.warn(`context-overflow compaction failed after durable surface progress: …; retrying from the replacement surface`)
      this.overflowRetries.set(agent, retries + 1)
      return { kind: 'retry' }
    }
    ctx.logger.warn(`context-overflow compaction failed: …; ${signal.aborted ? 'cancellation prevents retry' : 'preserving the original request error'}`)
    return next()
  }
  if (signal.aborted || agent.session.surface.replaceGeneration <= generation) return next()
  this.overflowRetries.set(agent, retries + 1)
  return { kind: 'retry' }
})

compaction-basic/src/index.ts:179

这是第 4 章那个 agent/request-error waterfall。四个细节:

重试的授权凭据是 surface.replaceGeneration 前进了,不是"压缩函数返回了非 null"。 注释解释得很直白:

// A model-free prune can land before later summary work fails. That
// durable reduction is sufficient retry proof; do not discard it just
// because the optional second phase threw. Cancellation still wins.

剪枝已经落地了,摘要才抛错——这次重试是值得的。 用"surface 真的变小了"作为判据,比用"哪个阶段成功了"更准确,因为它直接表达了"下一次请求会不一样"。

溢出恢复绕过阈值和保留策略:

if (trigger === 'context-overflow') {
  if (prune !== undefined) { prune.pruneSession(agent.session); measurement = meter.measure(agent.session) }
  const range = selectCompactableRange(agent.session, measurement, 0)   // retainTokens = 0
  if (range === null) return null
  return this.compactRegion(range.start, range.end, agent, signal)
}

retainTokens0——保留尾部的策略在这里作废,因为 provider 已经证明了必须缩。README 说得很干脆:provider-confirmed overflow needs no capacity metadata。也就是说:即使这个模型的 adapter 没配 contextWindow(主动压缩会因此报配置错误),溢出恢复照样能干活。

取消永远优先。 三处 signal.aborted 检查,而且注释标注了为什么这些看起来"不必要"的条件是必要的:the signal can abort while recovery is awaited

重试计数在成功响应后清零:

ctx.on('session/event', (session, event) => {
  if (event.type !== 'assistant/message') return
  const agent = this.overflowAgents.get(session)
  if (agent !== undefined) this.overflowRetries.delete(agent)
})

注释:A successful response starts a fresh overflow-recovery sequence even when tool calls continue the same turn into another request. 一个长 turn 里的多次请求各自有完整的恢复预算,因为它们之间有真实的进展。

5.3 收敛:拒绝不缩小的摘要

if (framedSummaryTokenCount >= prepared.shadowedTokenCount) {
  throw new SummaryError(
    `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedTokenCount})`,
  )
}

compaction-basic/src/region.ts:374

比较的是加了框架标签之后的摘要(<compacted-summary> 包裹 + 前言),不是裸摘要。否则一段"节省了 5 个 token 但框架花了 50 个"的摘要会通过校验。

外层是有界重试:

for (let attempt = 0; attempt <= spec.compactionRetries; attempt += 1) {
  const range = selectCompactableRange(agent.session, measurement, spec.retainTokens)
  if (range === null) { if (result === null) return null; break }
  result = await this.compactRegion(range.start, range.end, agent, signal)
  measurement = meter.measure(agent.session)
  if (measurement.totalTokens < spec.thresholdTokens) return result
}
throw new Error(`compaction still above threshold after ${spec.compactionRetries + 1} compaction attempts (…)`)

compaction-basic/src/index.ts:315

每次重试后重新测量,而不是假设减了多少。压不下去就抛错——由 agent/pre-step 的 catch 变成一条警告。

5.4 范围选择:从尾部倒着攒预算,再对齐到安全切口

let accumulated = 0
let keepFromIdx = pricedNodes.length
for (let index = pricedNodes.length - 1; index >= 0; index -= 1) {
  accumulated += pricedNodes[index]!.tokens
  keepFromIdx = index
  if (accumulated >= retainTokens) break
}
if (keepFromIdx === 0) return null

while (keepFromIdx > 0) {
  if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break
  keepFromIdx -= 1
}
if (keepFromIdx === 0) return null

compaction-basic/src/region.ts:98

两个阶段:倒着攒够保留预算,然后往前挪到一个工具配对平衡的切口。两次 keepFromIdx === 0 返回 null——没有安全范围就什么都不做。

选择之前还有一道一致性检查:

if (surfaceNodes.length !== pricedNodes.length
  || surfaceNodes.some((seq, index) => seq !== pricedNodes[index]?.seq)) {
  throw new Error('compaction: token-meter surface does not match the current session surface')
}

计量快照和当前 surface 不一致就抛错,不猜。

5.5 工具配对边界:为什么不能用 step 标记

tool-pairing.ts 的模块注释一句话说清:

Compaction changes surface positions, so safe cuts are derived from tool-call/result content in current surface order rather than step markers.

而增量算法是:

function eventDelta(event: SessionEvent): number {
  switch (event.type) {
    case 'assistant/message':
      return event.data.message.content.filter(block => block.type === 'tool-call').length
    case 'tool/result':
      return -1
    default:
      return 0
  }
}

一个 surface 有 N 个节点就有 N+1 个切口,缓存每个切口的平衡状态。缓存键是 session.surface.replaceGeneration + 已处理的条目数:

An unchanged generation extends the fold with unseen tail entries only; a log-only append with no new surface entry does no event reads, while a replacement generation rebuilds current membership and balances.

log-only 追加(比如审批审计事件)不触发任何事件读取。 每个 step 都要查平衡,这个短路很值。

而错误处理是 fail-loud:Missing event seqs and a tool/result without a preceding open call reject as corrupt surface state.

保留策略里那句最反直觉的话:

Turn boundaries do not protect old steps inside a runaway turn.

一个跑飞了的 turn 里的旧 step 不受保护。 如果 turn 边界是硬保护,那一个连续调用 50 次工具的 turn 就完全没法压缩——而这恰好是最需要压缩的情况。


6. 摘要调用:为了缓存重放整段对话

这是整章最精巧的一手。摘要调用不是"把历史塞进一个新 prompt",而是:

The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim, including image references, and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it.

KV cache 的效果:

The replayed system prompt, tools, and shadowed-region messages match the conversation's last routed request byte-for-byte, so the provider's warm prefix cache is reused up to the trailing instruction; only that instruction, and the summary output, is uncached.

摘要调用几乎是免费的输入。 一个"另起一个干净 prompt"的实现要为整段历史付全价输入 token;这里只有最后那条指令是冷的。

代价也写明了:Routing the summarizer to a different provider/model, or compacting a non-head range, forgoes this reuse.——换模型或压非头部范围就没这个便宜了。

6.1 只有返回的文本进入检查点

Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call; image output fails with UNSUPPORTED_CONTENT rather than disappearing.

三种输出三种处理:

  • 文本 → 进检查点;
  • reasoning / tool-call → 丢弃,因为一个进入历史的 tool-call 会变成永远得不到 result 的孤儿调用(第 7 章的配对不变式),reasoning 则是私有的;
  • 图片 → 报错,不是静默丢弃。

"静默丢弃"和"报错"的区别在于调用方能不能发现。 丢 reasoning 是安全的(它不影响任何不变式),丢图片会让摘要缺内容而没人知道。

6.2 归因头不进模型可见的 body

It sets GenerateOptions.purpose to compaction, which adapters may forward as request attribution (the DeepSeek adapter sends x-deepseek-harness-compact: 1) without touching the model-visible body.

用 header 做归因,不动请求体。所以"这是一次压缩调用"这个事实对成本统计可见,对模型不可见。

6.3 那条压缩指令本身

八个固定小节:Primary Request and IntentKey Technical ConceptsFiles and CodeErrors and FixesPending JobsCurrent WorkNext StepCritical Context

几条规则值得单独看:

Write "(none)" for an empty section — never drop a section.

空小节写 "(none)",不许删。 结构固定,下游(人或下一次压缩)才能预期它。

Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.

这些东西不能改写。 一个被"总结"过的文件路径就是错的路径。

Do NOT mention this summarization request or that the context was compacted.

If the conversation already contains a <compacted-summary> block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.

多次压缩必须合并,不能叠加。 否则第 N 次压缩的输出里套着 N-1 个历史检查点,token 只增不减。这也是为什么摘要必须"丢掉过时事实"——保留一切的合并等于不合并。

而模型侧看到的前言:

This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.

"不要确认这个检查点"。 否则模型每次压缩后都会先说一句"好的,我看到了之前的总结",白花 token 也打断任务。


7. 检查点的可识别性:一个绕过 TypeScript 限制的子路径

compactCheckpointSource(compactionId, sourceCommandId?) 是所有后端必须用来构造替换消息 source 的构造器。它声明在 @deepseek-ai/dsh-compaction/checkpoint 子路径上,README 解释了原因:

The leaf imports no cordis and declares no module augmentation …, which is what lets a client or wire program name the checkpoint source: the package root cannot enter such a program at all, because it reaches dsh-session's root and that Context merge declares the host sessions service against the client's own (TS2717 — one program per side).

一个 Web 客户端需要认出"这条消息是压缩检查点",但它不能 import 主包——因为主包的 Context 声明合并会和客户端自己的冲突(TS2717:同一标识符的重复声明合并)。

解法是把不含 cordis、不含模块增强的纯类型和纯函数拆到一个叶子子路径。而且:

The web client's transcript adapter pins its plugin literal to the leaf's source type, so renaming the plugin id there is a compile error here.

跨程序的一致性靠类型钉住,不靠约定。 改了名字,另一边编译失败。

还有一个防御:The constructor requires the owning CompactionId, preventing backends from writing an uncorrelated marker that the package invariant must reject. 必填参数让"写一个无法关联的标记"这件事在类型层面做不到。


8. 设计代价

四层机制意味着四套配置和四种失败模式。 maxInlineBytesthresholdChars/headChars/tailCharsthresholdRatio/retainRatio/compactionRetries/maxOverflowRetries、加上每个模型的 modelPolicies 覆盖。理解"为什么这次没压缩"要看四个地方。

测量是固定启发式,四字符一 token 对中文和 JSON schema 严重低估。 文档明说了,但这意味着实际压缩阈值对中文会话偏晚。

每次压力检查都是 O(surface) 克隆。 包括那些远低于阈值的检查。

摘要要花一次真实的模型调用,收敛重试会花多次。 输入靠缓存重放几乎免费,但输出 token 和延迟是真的。

没有面向模型的压缩工具。 /compact 是人的命令。模型不能自己决定"我该整理一下上下文了"。

部分溢出治不了。 三种情况在契约之外:单个不可分割的非工具节点过大、剪不动的工具残余过大、信封(system prompt + tools + prefix)本身就接近窗口。压缩只缩派生历史。

摘要质量取决于模型对那八小节指令的遵守。 结构是要求的,不是校验的——唯一的硬校验是"框架后必须比原内容小"。

compactRegion 需要一个打开的 turn。 完全关闭的会话上手动压缩会抛 "no open turn"。

overflow 分类由 adapter 维护。 provider 的错误措辞变了,CONTEXT_WINDOW_EXCEEDED 就认不出来,恢复路径就不触发。

spill 的本地文件没有生命周期清理。 理由是持久化、恢复、fork 出来的会话可能还引用那个路径——但结果是文件一直留着。


9. 可迁移的经验

1. 按代价分层,前一层能解决就别进下一层。 spill(0 次调用)→ prune(0 次)→ summarize(1 次)→ overflow recovery(1 次)。绝大多数压力在前两层就解决了,而它们完全不花模型调用。

2. 缩减操作要保留原件。 全部通过 append + surfaceOp: replace,原始事件永远在日志里。replay 确定,审计完整,而"模型看到什么"只是一个投影。

3. 测量服务不要可配置的估算器。 一个可调的启发式会诱导每个部署去调它;真正的答案(模型容量)在别的所有者手里。不该你拥有的东西,连旋钮都不要提供。

4. 用便宜的度量决定"改哪些",用权威的度量决定"够了吗"。 剪枝按字符数选目标,压力解除与否由 token 计量器说。

5. 幂等性最好是配置校验的产物。 head + marker + tail ≤ threshold 在加载时挡住,运行时就不需要防重复改写。

6. 崩溃后留下一个可检测的异常状态,胜过留下一个说谎的完成标记。 锁的释放放在数据变更之后:孤立的 compaction/start 可诊断,一个 surface 从没被遮蔽的 compaction/end 是无声的错误。

7. 锁要放在持久日志里,不要放在内存互斥量里。 而且要能区分"活着的锁"和"上个进程留下的僵尸锁"(用一个生命周期分界事件),否则一次崩溃永久堵死一个会话。

8. 失败的尝试也要持久化。 "试过压缩但摘要没能缩小"是诊断事实。

9. 不确定改了多少就别声称。 commit 错误刻意对"部分变更"保持中立,并让用户先检查状态再重试。

10. 同一个操作的不同入口可以有不同的并发假设。 自动压缩在自己 turn 内要求整体不变;手动压缩会等人,只重新校验被选中的那一段。强行统一会让手动路径随机失败。

11. 重试的授权凭据要用"状态真的变了",不要用"哪个阶段成功了"。 surface.replaceGeneration 前进就允许重试——剪枝落地而摘要抛错,这次重试是值得的。

12. 摘要调用重放原对话前缀,把新指令追加在最后。 于是 provider 的热前缀缓存全部复用,只有最后那条指令是冷的。一个"另起干净 prompt"的实现要为整段历史付全价。

13. 从模型输出里只取你能安全落地的部分,并区分"静默丢弃"和"报错"。 丢 reasoning 安全(不影响不变式),丢一个 tool-call 会造成孤儿调用,丢图片会让人发现不了内容缺失——所以后者报错。

14. 多次压缩必须合并,不能叠加。 指令里明确要求"上一个检查点不要原样搬过来:保留仍然成立的事实,丢掉过时的"。保留一切的合并等于不压缩。

15. 告诉模型不要确认元操作。 "不要提到这次压缩,直接从后面的消息继续"——省 token,也不打断任务。

16. 给人看的近似值和给机器做决策的值走两条路。 占用率是 last-wins 的独立记录、明确不是原子观测;压缩自己调 measure()。为了 UI 好看去污染决策路径是常见错误。

17. 跨程序共享的常量拆成不含框架依赖的叶子子路径,并用类型钉住两边。 客户端要认出一个标记,但 import 主包会撞 TS2717;叶子子路径解决它,而客户端把字面量钉到叶子的类型上,让改名变成编译错误。

18. 临时文件也是攻击面。 私有 0700 根目录、不可预测的文件名前缀、open(path, 'wx', 0o600) 排他写入——工具输出可能包含凭据。

19. 一个 Definition 确实需要某个词汇时,就承认依赖并记录例外。 用结构化类型规避依赖的前提是"契约不真正依赖那个词汇";前提不成立时硬扭比诚实的例外更糟。


10. 本章源码位置

位置 内容
packages/compaction/compaction/src/index.ts:96 CompactionEngine:Service Definition 与三个抽象操作
packages/compaction/compaction/src/index.ts:113 compactIfNeeded()pressure / context-overflow 两个触发
packages/compaction/compaction/src/index.ts:139 compactNow():同步预留 idle 准入、turn: null、等待持久化检查点
packages/compaction/compaction/src/index.ts:164 compactRegion():surface 位置范围(不是 seq 区间)
packages/compaction/compaction/src/types.ts:23 compaction/start:log-only,持锁
packages/compaction/compaction/src/types.ts:33 compaction/summary:摘要、被遮蔽范围、token 数、provider/model 信封
packages/compaction/compaction/src/types.ts:71 compaction/end:释放锁;error 记录失败尝试
packages/compaction/compaction/src/types.ts:81 compaction/prune:共享的 shadow-price 协议
packages/compaction/compaction/src/tool-pairing.ts:1 模块注释:为什么安全切口来自内容而非 step 标记
packages/compaction/compaction/src/tool-pairing.ts:117 toolPairingBalancedBefore()
packages/compaction/compaction/src/tool-pairing.ts:129 toolPairingBalancedAfter()
packages/compaction/compaction/src/checkpoint.ts:33 compactCheckpointSource():必填 CompactionId
packages/compaction/compaction/README.md 五步事务、锁语义、Definition 的刻意依赖例外、叶子子路径与 TS2717
packages/compaction/compaction-basic/src/index.ts:147 agent/pre-step 主动压力监听器;配置错误去重警告
packages/compaction/compaction-basic/src/index.ts:179 agent/request-error 溢出恢复;以 replaceGeneration 为重试凭据
packages/compaction/compaction-basic/src/index.ts:258 compactIfNeeded():剪枝 → 重测 → 选范围 → 摘要
packages/compaction/compaction-basic/src/index.ts:315 收敛重试循环:每次重测,压不下去则抛错
packages/compaction/compaction-basic/src/region.ts:98 selectCompactableRange():倒攒尾部预算 + 对齐安全切口
packages/compaction/compaction-basic/src/region.ts:374 拒绝"加框架后不比原内容小"的摘要
packages/compaction/compaction-basic/README.md 完整策略清单、压缩指令原文、KV cache 分析、限制
packages/compaction/compaction-tool-result-pruner/README.md 无模型剪枝:预算、幂等性质、只改 content
packages/compaction/command-compact/README.md /compact 与五个错误码的稳定人类文本
packages/llm/token-meter/README.md 固定启发式、usage 复用条件、projectedTokens 存在的理由
packages/spill/spill-policy/README.md tools/post-execute spill:预算预留、跳过 read、best-effort
packages/spill/spill-local/README.md 私有 0700 根、不可预测前缀、open(path, 'wx', 0o600)