为什么要压缩
一场 agent 对话会无界增长:每一条持久化上下文消息(工作区指令、时间读数、工具结果、目标轮提示词)都会一直留在派生历史中,直到有什么东西移除它。但模型有有限的上下文窗口。据此,压缩(compaction)就是:用一段较老历史去换取一条浓缩摘要检查点,使近期尾部与工作表面能装进窗口内。
压缩家族位于 packages/compaction/ 下,并按 capability-seam 模式切分:一个 Service Definition、一个具体后端、一个可选的无模型剪枝器,以及一个人类命令。
| 包 | 角色 | ctx 键 |
|---|---|---|
@deepseek-ai/dsh-compaction | Service Definition:抽象 CompactionEngine + compaction/* 事件 + CompactionResult | ctx.compaction |
@deepseek-ai/dsh-compaction-basic | Service Provider:token 压力策略 + llm.stream() 摘要 | 注册 ctx.compaction |
@deepseek-ai/dsh-compaction-tool-result-pruner | 可选无模型工具结果剪枝 | ctx.toolResultPruner |
@deepseek-ai/dsh-command-compact | compactNow() 之上的人类 /compact 命令 | 注册于 ctx.commands |
token 计量刻意不属于该接缝——它是独立的 ctx.tokenMeter LLM 家族服务。
服务接缝:ctx.compaction
packages/compaction/compaction/src/index.ts 定义抽象 CompactionEngine,含三个抽象操作:
| 成员 | 语义 |
|---|---|
compactIfNeeded(agent, trigger, signal) | 为 trigger: 'pressure' | 'context-overflow' 考虑自动压缩;返回 CompactionResult 或无可安全范围时的 null。 |
compactNow(agent, signal) | 即便低于自动压力也显式压缩一段有用的平衡较老区间;无有用跨度时什么都不写。 |
compactRegion(start, end, agent, signal?) | 强制将表面节点 [start, end](表面位置区间)摘要成单个替换节点。若压缩已在进行中或区间非法则抛出。 |
一次多轮摘要请求是直接的 ctx.llm.stream() 调用(不是循环步骤),因此逐调用拦截发生在 llm/stream。compaction/* 事件扩展 SessionEventMap——它们是会话事件,不是 Cordis Events。
为什么该接缝依赖 sessions 与 llm
与本代码库中的大多数 Service Definition 不同,@deepseek-ai/dsh-compaction 刻意依赖 @deepseek-ai/dsh-session 与 @deepseek-ai/dsh-llm:契约动词定义在 Session 之上,其输出是 ContentBlock 词汇表。这一偏离“Service Definition 只依赖 cordis”的指引是有意为之,并记录在 compaction capability-seam Agent Note 中。
一次成功压缩会写什么
SurfaceEventType 是闭合联合——只有 user/message、assistant/message、tool/result 可携带 surfaceOp。因此 compaction/* 事件不能出现在表面上。一次成功压缩改为:
- 追加
compaction/start(仅日志)——获取锁; - 摘要该区间;
- 追加
compaction/summary(仅日志),含摘要、区间、被遮蔽 seq、token 数与 provider/model 调用封套; - 追加一条
user/message,其source为compactCheckpointSource(compactionId, sourceCommandId?),surfaceOp: { op: 'replace', start, end }—— 唯一一次表面变更; - 追加
compaction/end(仅日志)——释放锁。
该变更坐在锁括号之内:compaction/end 是最后一个事件,因此锁绝不在表面变更落地前释放。start 与 end 之间崩溃会留下可探测的孤儿锁,而不是一个宣称“已完成”却从未遮蔽表面的 end。被遮蔽的事件保留在原始日志中,因此重放是确定性的;但 deriveMessages() 只渲染摘要加保留的尾部。
压缩锁是一个持久化标记
压缩由一个被日志记录的锁串行化,所有入口共享。该锁是持久化括号(compaction/start 而无匹配的 compaction/end),不是 WeakSet 或包装互斥量。尾部检查寻找最新的未匹配 compaction/start 与最新的 session/end-seed:该边界之后的未匹配 start 是活跃的,报告 busy;更早的未匹配 start 是先前进程生命周期中的陈旧证据,不阻塞。
检查点标记
compactCheckpointSource(id) / isCompactCheckpointSource() / CompactionCheckpointSource 位于 @deepseek-ai/dsh-compaction/checkpoint 子路径(并从根路径再导出)。构造函数要求拥有方 CompactionId,因此后端无法写出无关标记。该叶子不导入 cordis,也不声明模块增广——这正是客户端/线协议程序能命名检查点来源、而包根却不能的原因(它会拉入 dsh-session 的 Context 合并,TS2717)。
basic 后端:token 压力策略
packages/compaction/compaction-basic/src/index.ts 中的 BasicCompactionEngine 是随附的 provider。它拥有压缩策略:
- 计量 ——
ctx.tokenMeter在单一已消费日志修订上为最新规范日志封套与当前表面定价,因此步骤边界压力包含真实系统提示词、工具、路由、缓冲上下文与导航。 - 路由化策略 —— 容量从拥有最新持久化 provider/model 路由的适配器解析;
modelPolicies为精确配对叠加覆盖。 - 无模型剪枝 —— 区间选择前,可选的
ctx.toolResultPruner重写超大工具结果;随后 basic 重新计量,当压力转安全时跳过摘要。 - 保留 —— 压缩最老的整体表面单元,同时保留近期尾部,并通过该接缝的 tool-pairing 边界辅助促成平衡的工具调用/结果切分。
- 收敛 —— 在
compactionRetries内重试头部检查点压缩;拒绝不缩小其来源的摘要。 - 摘要 —— 一次直接
llm/stream调用,逐字重放会话自身的系统提示词、工具与被遮蔽区间消息(复用 provider 的暖前缀缓存),并把压缩指令作为最终用户消息追加。它设置GenerateOptions.purpose = 'compaction',DeepSeek 适配器据此转发x-deepseek-harness-compact: 1而不触碰请求体。只有返回的文本进入检查点——推理与工具调用都被排除。 - 溢出恢复 —— provider 确认的
CONTEXT_WINDOW_EXCEEDED绕过正常压力:先剪枝,再尝试一次最大平衡头部缩减,并允许每当surface.replaceGeneration前进时重试。
策略配置(BasicCompactionConfig)
所有设置均可选;modelPolicies 向精确 { provider, model } 配对应用部分覆盖。
| 键 | 默认值 | 含义 |
|---|---|---|
thresholdRatio | 0.8 | 在 floor(routedContextWindow × ratio) 处压缩 |
retainRatio | 0.16 | 保留的近期表面,作为窗口比例(与 retainTokens 互斥) |
retainTokens | — | 保留的近期表面绝对预算(与 retainRatio 互斥) |
summarizationProvider | '' | 摘要器 provider;空配对解析为最新请求目标再 AgentOptions |
summarizationModel | '' | 摘要器模型 |
maxTokens | 8192 | 摘要调用的生成上限 |
compactionRetries | 1 | 压力仍高于阈值时的额外尝试 |
maxOverflowRetries | 1 | 规范溢出后的最大重试;0 仅禁用恢复 |
modelPolicies | [] | 精确 { provider, model, ...partialPolicy } 覆盖 |
auto | true | 注册步骤边界压力 + 溢出恢复监听器 |
@deepseek-ai/dsh-compaction-basic 的 config-catalog 条目位于 docs/config-catalog.md,源码钉在 packages/compaction/compaction-basic/src/types.ts。
压缩如何触发
两个触发器到达 compactIfNeeded:
- 压力 —— 一个串行
agent/pre-step监听器在请求派生前检查 token 压力。 - 溢出 —— 规范 provider 溢出通过
agent/request-error进入,并仅在持久化表面推进后才授权重试。
面向模型的结果是一条检查点消息,由前导 + <compacted-summary>…</compacted-summary> 标签框架起来:
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.无模型剪枝器
ctx.toolResultPruner(@deepseek-ai/dsh-compaction-tool-result-pruner)把超预算的 tool/result 表面节点重写为有界头部、固定省略标记与有界尾部——\n\n[... tool result middle pruned ...]\n\n——而完整原始内容保留在只追加日志中。pruneSession(session) 把每个超预算结果替换为新 tool/result,携带 { surfaceOp: { op: 'replace', start, end }, sourceEventSeqs }。文本切片永不会拆开 UTF-16 代理对。配置:
| 键 | 默认值 | 含义 |
|---|---|---|
thresholdChars | 8192 | 组合文本超过这么多 Unicode 码点即剪枝 |
headChars | 4096 | 保留的前导码点 |
tailChars | 1024 | 保留的尾部码点 |
measureContent() 统计码点;pruneContent() 返回有界替换或 null。因此第二轮不产生任何替换。basic 通过可选 ctx.get('toolResultPruner') 读取它,使每个包都保持独立可组合。
/compact 人类命令
@deepseek-ai/dsh-command-compact 通过 ctx.commands 注册一个全局命令,因此每个组合出的命令适配器都能无模型轮次地进行发现与执行。被调用的 agent 是精确目标,UI 的取消信号会转发给接缝。
| 输入 | 结果 |
|---|---|
/compact | 摘要一段有用的平衡较老区间,然后报告替换的历史条目数与估算 token |
/compact(无历史) | No compactable history yet. |
/compact <任意> | Usage: /compact (no arguments) |
每次回定的调用都会记录仅日志的 command/run / command/done 对;成功时 command/done.sourceEventSeq 命名 compaction/summary 事件。预期的 ManualCompactionError 码(busy · cancelled · changed · summary · commit · persistence)变成稳定的直接错误。/compact 仅限空闲:当一轮已有通行权时报告 busy。
CompactionResult 形态
一次成功操作返回 CompactionResult(来自 packages/compaction/compaction/src/types.ts),使调用方能精确重建发生了什么、发生在哪里:
| 字段 | 含义 |
|---|---|
compactionId | 本次压缩完整持久生命周期(compaction/start … compaction/end)共有的稳定身份 |
sourceCommandId? | 手动时发起它的那条人类命令 |
startSeq / summarySeq / endSeq | 三条追加 compaction/* 事件的 seq |
summary | 后端产生的摘要内容块 |
shadowedRange | 被遮蔽的表面边界对——表面位置区间,而非数值 seq 区间(先前替换把新高位 seq 摘要节点落到较老区间位置后,start 可能大于 end) |
shadowedSeqs | 按表面顺序的被遮蔽节点权威集合 |
shadowedTokenCount | 在 token-meter 固定估算器下的被遮蔽内容估算 token 数 |
调用方既得到原始摘要,也得到簿记 seq,外加被遮蔽区间与 token 账目——这正 Web UI 与 /compact 呈现用于把检查点折入会话而无需解析摘要文本的依据。
一个触发时序时间线
压缩到底何时运行?两个触发器共用一个串行化锁分派,但经不同接缝进入:
| 触发器 | 进入接缝 | 备注 |
|---|---|---|
pressure | 串行 agent/pre-step 监听器 | 在请求派生之前运行;仅当超出 floor(routedContextWindow × thresholdRatio) 才压缩 |
context-overflow | agent/request-error 监听器 | 规范 provider CONTEXT_WINDOW_EXCEEDED;绕过正常压力,先剪枝,再做一次最大平衡头部缩减 |
agent/pre-step ──► tokenMeter 为当前表面定价
│ 低于阈值?
▼ 否
┌─────────────┐
│ 压缩 │ 锁 = compaction/start
│ 串行化 │ 摘要 → compaction/summary
│ │ 替换表面(user/message)
│ │ compaction/end(释放)
└─────────────┘
▼
派发在更小的派生历史下进行后端通过直接 ctx.llm.stream() 调用做摘要,且必须把操作的 signal 转发进 GenerateOptions.signal,从而使中止或 fiber 处置能拆解运行中的摘要。在 compactIfNeeded 路径上,自动压缩在其活跃轮次内保持整表相等;而手动 compactNow 路径只重新校验其选定区间,因此 start/end 之间注入的空闲上下文在检查点后仍然可见。
压缩小结
接缝定义做什么;后端、剪枝器与命令提供怎么做与何时做。一次成功循环用很多保留历史 token 换取一条有框架的摘要检查点,代价是从第一个被遮蔽 token 起使 KV 缓存复用失效。这里刻意没有面向模型的压缩工具——只有人类 /compact 命令与程序化的 compactNow()/compactRegion() 调用。
进一步阅读
- 上下文来源 — 压缩最终遮蔽的持久化上下文消息。
- Token 计量 — 为压力定价的
ctx.tokenMeter服务。 - LLM 层 — 摘要
llm/stream调用运行之处,以及GenerateOptions.purpose归因。 packages/compaction/compaction/src/types.ts—compaction/*事件载荷与CompactionResult。docs/config-catalog.md—@deepseek-ai/dsh-compaction-basic与@deepseek-ai/dsh-compaction-tool-result-pruner的条目。.agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md与2026-07-30-queued-manual-compaction.md— 接缝与手动压缩背后的设计决策。