vbnet
title: "DeepSeek Harness 上下文压缩:长对话管理"
subtitle: "压缩不是删日志,是先剪枝再压缩"
description: "长对话的两种死法:静默挤掉(前面内容被挤走,模型还不知道)和直接报错(context length exceeded,任务中断)。DeepSeek Harness 的压缩不是删日志:先在调模型前称体重(超 80% 就压),先做无损剪枝(8192 剪工具结果中间,头尾保留),不够再做有损摘要(LLM 把旧对话压缩成 checkpoint 替换)。因为 messages 每次现算、日志追加式不可改,压缩靠的是给下一次重新生成改规则------surfaceOp replace。本文拆要压缩的是什么、在哪个阶段压、怎么压。"
summary: "上下文压缩:messages 内容、触发时机(pre-step 压力/溢出恢复/手动)、两种压缩方式(无损剪枝优先、LLM 摘要兜底)、replace 替换机制。"
keywords:
- DeepSeek Harness
- 上下文压缩
- compaction
- 长对话
- AI Agent 框架
tags:
- 源码解析
- AI Agent
- TypeScript
categories:
- 深入理解 DeepSeek Harness
slug: "deepseek-harness-compaction"
series:
- "深入理解 DeepSeek Harness"
series_weight: 6
toc: true
DeepSeek Harness 上下文压缩:长对话管理
「深入理解 DeepSeek Harness」系列第 6 篇。全系列地图见第 0 篇《5 分钟看懂 DeepSeek Harness》。
引言
长对话有两种死法,你都见过:
- 静默挤掉:对话到第 80 轮,模型开始"忘事"------前面读过的文件、改过的代码、定下的方案,像被橡皮擦擦掉一样。它自己不知道丢了什么,还在自信地往下聊;
- 直接报错 :
context length exceeded------上下文窗口爆了,任务中断,Agent 一脸无辜。
两种死法同一个根因:messages 只增不减,而上下文窗口是有限的。第 5 篇讲过:messages 是每次调模型前从日志现算的。对话越长,算出来的 messages 越大,迟早超过窗口。
DeepSeek Harness 的答案是:压缩不是删日志,是先剪掉超大工具结果的中间(头尾保留)、再把旧对话压缩成摘要------无损优先,有损兜底。而且压缩本身也被记录进日志,可审计(第 5 篇的日志哲学在这里继续)。
它做到这一点的原因,一句话:messages 是算出来的,压缩就是改"算 messages 的规则"------给下一次重新生成换一段更短的输入。
下面按"先认识要压缩的东西(messages)→ 在哪个阶段压(触发时机)→ 怎么压(两种方式)"拆开看。贯穿实例是一段 100 轮的长对话,看它第 80 轮怎么被压一次。
一图总览

图 1:messages 只增不减,触发后先无损剪枝、剪完仍超阈值再 LLM 摘要 ,结果用 surfaceOp replace 替换旧区间,下一次重新生成 messages 就变短了。图的中心是全文论点:压缩不是删日志,是先剪枝再压缩。
一、先认识 messages:要压缩的是什么
压缩的对象是 messages。先看它由什么组成(第 5 篇讲过消息怎么从日志生成,这里看内容本身):
| messages 的组成 | 来源事件 | token 大小 |
|---|---|---|
| 用户消息 | user/message |
瘦(一句话问题) |
| 助手消息(含 tool_calls) | assistant/message |
中(回复 + 工具调用声明) |
| 工具结果 | tool/result |
最大(一次 read 几万字符常见) |

图 2:messages 由三类事件组装而成,其中工具结果最大 ------一次 read 大文件的结果可能顶几十轮对话。所以压缩主要对付两样:历史累积 (旧对话越积越多)和超大工具结果(单个结果超大)。
一个具体的例子------一次"读文件"的往返,在 messages 里长这样(简化):
swift
[
{ "role": "user", "content": "读取 test.txt 并总结前三行" },
{
"role": "assistant",
"content": "",
"tool_calls": [{ "id": "call_001", "name": "read", "arguments": { "file_path": "test.txt" } }]
},
{
"role": "user",
"content": [{
"type": "tool-result",
"toolCallId": "call_001",
"content": "1: import fs\n2: const path = ...\n3: ...(共 120 行,约 50KB)",
"isError": false
}]
},
{ "role": "assistant", "content": "前三行是 import fs、定义 path......" }
]
一次完整的"读文件"往返 = 用户请求 → 助手声明 tool_calls → 工具结果回填(tool-result 块)→ 助手总结。注意最大的是 tool-result 那个块 ------50KB 文件内容整个塞在一条消息里。长对话就是这样的往返不断累积:读十次文件,十个 50KB 的 tool-result 就占掉大半窗口。
为什么不能"简单删"
对话长了,最直觉的想法是"把前面的消息删掉不就行了?"------不行,而且有两个层面的原因:
第一层(也是更重要的):删了,模型就"失忆"了 。旧对话里不只有"说过的话",还有任务背景 ------之前查过什么资料、改过哪个文件、为什么这么改、进行到哪一步、定下了什么方案。把这些直接删掉,模型下一轮就不知道"我在做什么、为什么做、做到哪了",它无法做出正确的判断------轻则答非所问,重则推翻之前的决定重来。所以对话变短不能靠"丢",要靠浓缩 :把背景压成摘要保留下来------模型拿到的是小体积但"记得"的上下文。
第二层(技术):想删也删不了。messages 是每次从日志重新生成的(第 5 篇),日志又是追加式、不可改的唯一真源------"删掉旧消息"无从删起,删了日志就没法重放、没法审计。
两条合起来:想让 messages 变短,只能让"下一次重新生成"输出不同的结果 ------在日志里追加 一条信息,声明"这一段对话以后用摘要替代"(这就是压缩)。下一次 deriveMessages 重新组装时,旧区间被摘要替代------messages 变短,但背景还在(浓缩版),日志一条没改。
你可能以为 :压缩就是把日志删掉一部分?不是。压缩动的是"算 messages 的输入"------追加 replace 影响下一次重新生成,不是删日志本身。
这带来一个代价:重新组装不再是无脑按序拼接 ------要处理 replace 区间替换、增量缓存失效重算(第 5 篇提过 derivedGeneration,replace 一落地缓存就失效)。这就是压缩比"简单截断"复杂的原因。
二、在哪个阶段压缩:触发时机
把压缩放进一张 Agent 执行任务的常见流程(从用户发任务到任务结束),压缩发生的地方用颜色标出来:

图 3:Agent 执行任务的常见流程------发任务 → 组装上下文 → 模型思考 → 需要工具就调用、拿回结果再思考,可以回答了才结束(源码对应:组装上下文 = preStep,思考 + 工具 = step)。黄框 = 压缩发生的地点 :① 组装上下文 (对话太长、超阈值就压,压完重新组装)、② 模型思考 (报"上下文超限"就压了重试); /compact 手动在用户发任务处。
- 预防(主力):每次调模型前称体重 。压缩挂在
agent/pre-step事件上(compaction-basic/index.ts 147 行)------每次调模型前,token 计量器测当前用量,超过thresholdRatio(默认 0.8,即上下文窗口的 80%)就压。这就是第 1 篇讲的"preStep 里的压缩"; - 补救:溢出后重试 。挂在
agent/request-error事件上(index.ts 183 行):模型报context length exceeded才压了重试,有maxOverflowRetries上限------不能无限补救; - 手动:/compact 命令(command-compact 包)------用户主动触发。
💡 源码细节(可跳过) :压力按模型/供应商分开配(
modelPolicies)------不同模型上下文窗口不同,阈值(thresholdRatio)、保留比(retainRatio)、保留 token(retainTokens)、摘要模型(summarizationProvider/model)都可以按目标路由单独设。
三、如何压缩:两种方式,先剪枝后摘要
先总览:有几种压缩方式、怎么分类
压缩方式有两种,分类看两个维度------压的对象 (对话消息 vs 工具结果)× 有损还是无损:
| 方式 | 压什么 | 有损/无损 | 调 LLM? | 优先级 |
|---|---|---|---|---|
| 模型无关剪枝 | 工具结果(超大结果) | 无损(只剪中间) | 否 | 优先 |
| LLM 摘要 | 对话历史(旧消息) | 有损(丢细节) | 是 | 兜底 |
触发时先做无损剪枝、再做有损摘要 (源码注释原话:"land the model-free pass before choosing a summary range"------先落无模型那一遍,再选摘要区间)------能无损解决就不动对话。为什么不能"简单删"、只能靠 replace 影响下一次重新生成------第一节讲过了。
方式一(优先):模型无关剪枝------不动对话,只剪超大结果
tool-result-pruner(第 4 篇讲过它的机制,这里是它在压缩里的角色):已落库的工具结果超过 8192 码点 时,把中间剪掉,保留头 4096 + 尾 1024,插入标记 [... tool result middle pruned ...]。8192 码点 ≈ 8192 个字符------码点(code point)可以粗略当成"字符数":一个汉字、一个字母、一个数字都算一个码点(少数特殊字符如 emoji 由多个码点组成),所以"超 8192 码点"就是"超约 8 千字符"。
💡 码点是什么? 码点(code point)是 Unicode 的字符编码单位,粗略可以当成"字符数":一个汉字、一个字母、一个数字都算一个码点(少数特殊字符如 emoji 由多个码点组成)。所以 8192 码点 ≈ 8192 个字符。源码按码点计数(
codePointLength)而不是按 UTF-16 单元------为了不拆散 emoji 这类由两个单元组成的代理对。

图 4:模型无关剪枝------只剪工具结果的中间,头尾保留,对话本身一个字不动。不调 LLM、纯启发式------无损、便宜,所以优先做。
pruneSession 在压缩流程里最先跑(index.ts 284 行):剪完重新测量,用量降到阈值以下就直接结束,根本不进摘要------大部分场景剪掉几个超大结果就够了。
它只动工具结果,不碰对话 ------源码里候选只挑 tool/result 事件(if (event?.type === 'tool/result'),index.ts 135 行),user/message、assistant/message 根本不会进剪枝流程。中间的用户/助手消息一条不删;对话本身的压缩只有 LLM 摘要(方式二)才做。
压缩前后对比------同一个工具结果,剪枝前后的 messages 长这样:
swift
// 压缩前:tool-result 块塞着 50KB 全文(超 8192 码点)
{ "role": "user", "content": [{ "type": "tool-result", "toolCallId": "call_001", "content": "1: import fs\n2: const path = ...\n...(共 50KB,2 万+ 码点)" }] }
// 压缩后:头尾保留 + 中间标记(约 5KB 码点)
{ "role": "user", "content": [{ "type": "tool-result", "toolCallId": "call_001", "content": "1: import fs\n...(前 4096 码点)\n\n[... tool result middle pruned ...]\n\n...(后 1024 码点)" }] }
同一个块、同一个 toolCallId,只是内容从 2 万码点缩到约 5 千------对话本身一个字没动。
💡 源码细节(可跳过) :每次剪枝用
compaction/prune事件记录"shadow price"------被剪内容的 token 估价,让纯消费方(token 统计)能精确减去被替换区间的代价,不用自己逐节点算。
方式二(兜底):LLM 摘要------旧对话压缩成 checkpoint
剪枝后用量仍超阈值,才动对话。两步:
第一步:选区间 。selectCompactableRange(region.ts 98 行)从头部(最老) 选一段可压缩区间,保留最近的 retainTokens verbatim------新对话原样保留,只有旧对话进摘要。选的时候不拆散工具调用和结果的配对(不能压掉 tool/call 却留下它的 result)。
第二步:生成摘要并替换 。摘要是一次独立的 LLM 调用,不是工具 ------不经过工具管道,直接走 ctx.llm.stream()(purpose: 'compaction')。它的"提示词"很特别:没有专门的摘要 system prompt ------复用对话自己的 system + tools + 被压区间的消息,末尾追加一条 user 消息(压缩指令 COMPACTION_INSTRUCTION) (summarizer.ts 注释原话:"delivered as the FINAL user message after the replayed conversation rather than as a distinct summarizer system prompt")------辅助调用是"最后一次请求的真正前缀",命中 provider 的 prefix cache,只有末尾那条指令是新的。
压缩指令要求输出固定结构的 checkpoint (8 个 section:Primary Request and Intent / Key Technical Concepts / Files and Code / Errors and Fixes / Pending Jobs / Current Work / Next Step / Critical Context),规则是"保留精确的文件路径、命令、错误串、标识符、数字"、"不要提这是压缩"、"只输出 checkpoint 文本";输出包在 <compacted-summary> 标签里作为落地节点。

图 5:LLM 摘要------复用 system + tools + 被压区间,末尾追加一条压缩指令 (没有独立摘要 system prompt),产出 checkpoint 摘要替换旧区间。有损(细节会被浓缩),所以只能当兜底,不能当主力。
💡 发给模型的提示词长什么样? 摘要请求的实际结构(源码原样):
system和tools直接复用对话自己的,messages= 被压区间的消息 + 末尾一条 user 消息(压缩指令):
json{ "system": "<对话自己的 system prompt,原样复用>", "tools": "<对话的工具列表,原样复用>", "messages": [ "...被压区间的消息(user / assistant / tool-result 往返)...", { "role": "user", "content": "<压缩指令全文>" } ] }那条压缩指令(
COMPACTION_INSTRUCTION,summarizer.ts 源码原样):
vbnetYou are now acting as a compaction engine for this AI coding assistant. Condense the conversation ABOVE into a structured checkpoint that lets another model resume the work with no loss of essential context. Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section. ## Primary Request and Intent / ## Key Technical Concepts / ## Files and Code ## Errors and Fixes / ## Pending Jobs / ## Current Work / ## Next Step / ## Critical Context Rules: - 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. - Output only the checkpoint text: do not call any tool or take any other action. - If the conversation already contains a <compacted-summary> block, it is a PRIOR checkpoint...整个提示词 = 对话前缀(system + tools + 被压区间,命中缓存)+ 这一条压缩指令 ;模型只需照结构输出 checkpoint 文本。这次调用走
ctx.llm.stream()(purpose: 'compaction',llmStreamCall: true标记),摘要是哪个模型写的、花了多少 token 都记在compaction/summary事件里。
压缩前后对比------一段旧对话,摘要前后的 messages 长这样:
swift
// 压缩前:一整段历史(很多轮 user / assistant / tool 往返)
[
{ "role": "user", "content": "帮我实现一个 CSV 解析器" },
{ "role": "assistant", "content": "好的,我先看下现有代码", "tool_calls": [{ "id": "call_001", "name": "read", "arguments": { "file_path": "parser.ts" } }] },
{ "role": "user", "content": [{ "type": "tool-result", "toolCallId": "call_001", "content": "1: ...\n2: ...(整个文件)" }] },
{ "role": "assistant", "content": "我准备加一个引号转义处理......" }
// ...后面还有几十轮
]
// 压缩后:整段压缩成一条带 <compacted-summary> 标签的 checkpoint
{ "role": "user", "content": "<compacted-summary>\n## Primary Request and Intent\n- 实现 CSV 解析器(支持引号转义)\n## Files and Code\n- parser.ts:已添加引号转义处理\n## Current Work\n- 正在处理转义引号\n## Next Step\n- 补充测试用例\n</compacted-summary>" }
几十轮往返压成一条 checkpoint------体积骤降,关键信息(目标、改过的文件、当前进度、下一步)按结构保留。
共同机制:replace 替换------压缩怎么影响下一次重新生成
无论剪枝还是摘要,落地都是同一个动作:追加一条 replace。压缩的完整生命周期事件(compaction/types.ts):

图 6:压缩生命周期------compaction/start 持锁 → compaction/summary 记录摘要和成本 → 紧随的 user/message(surfaceOp: replace)把摘要替换进对话 → compaction/end 释放锁。全部是 log-only 事件,不进 surface(第 5 篇的 non-surface 家族)------压缩的记录本身不污染对话。
三个细节:
compaction/start就是锁 (region.ts 152 行注释原话:"the durable opening marker is the compaction lock")------压缩期间引擎 maintenance 独占(第 1 篇讲过),压缩和新请求不交错;compaction/end释放,失败留下 unmatched start 可检测;- 原始事件一条不改------被压缩的旧消息、旧工具结果还在日志里,只是 surface 视图被替换(第 5 篇讲过:surfaceOp replace 不改历史);
- 可审计------压了哪段(shadowedSeqs)、减了多少 token(shadowedTokenCount)、摘要谁写的(provider/model)、用的什么模型------全在日志里。
四、这个设计换来了什么
回到开篇:长对话的两种死法,现在可以给出 DeepSeek Harness 的答案:
- 静默挤掉 → 不会。压缩是显式的:
compaction/start → summary → end全程记录,压了什么、丢了多少 token 可审计,模型不会"不知道自己丢了什么"------丢的部分换成了摘要; - 直接报错 → 有救。
context length exceeded触发溢出恢复:剪枝 + 摘要 + 重试(有上限)。
这套设计的得与失:
- 收益:长对话不断、溢出可恢复、先无损后有损(能省则省)、全程可审计;
- 代价:摘要丢细节(checkpoint 是浓缩不是原文)、摘要本身一次 LLM 调用有成本、每次 pre-step 称体重有开销、重新组装要处理 replace(增量缓存失效重算)。
回扣 :压缩是第 5 篇
surfaceOp replace的活例子------messages 是算出来的,压缩就是给下一次重新生成换一段更短的输入;日志一条没改,哲学不变:日志在,任务就在。
总结
回到开篇的问题:长对话怎么不爆窗? 现在可以给出 DeepSeek Harness 的答案:
- 要压缩的是什么 ------messages:用户消息、助手消息、工具结果,其中工具结果最大(第一节);
- 在哪个阶段压------pre-step 称体重(超 80% 预防)、溢出恢复(爆了补救)、/compact 手动(第二节);
- 怎么压 ------先无损剪枝(8192 剪工具结果中间),剪完仍超阈值再 LLM 摘要(旧对话压缩成 checkpoint) ;落地都是 surfaceOp replace,让下一次重新生成输出更短(第三节);
- 为什么复杂------messages 每次现算 + 日志不可改,压缩只能改"算 messages 的规则",不能删(第三节)。
一句话:压缩不是删日志,是先无损剪枝(剪超大结果中间)、再有损压缩(旧对话成 checkpoint)------messages 是算出来的,压缩就是改算法。
常见问题 FAQ
Q: 压缩会丢信息吗?
A: 会丢一部分,但分两种:模型无关剪枝只剪工具结果的中间 (头 4096 + 尾 1024 保留,对话本身不动,无损);LLM 摘要是有损 的------旧对话被浓缩成 checkpoint(Current Work / Next Step / Critical Context),细节会丢,但精确信息(文件路径、命令、错误串、数字)按规则保留。所以设计成先无损剪枝、不够再摘要,能无损解决就不动对话。
Q: 压缩什么时候触发?
A: 三个时机:① pre-step 压力检查 (主力)------每次调模型前 token 计量器测用量,超 thresholdRatio(默认 0.8,即 80% 上下文窗口)就压;② 溢出恢复 ------模型报 context length exceeded 才压了重试(maxOverflowRetries 上限);③ /compact 手动。
Q: 压缩后原文还在吗?
A: 在------日志是追加式、不可改,被压缩的旧消息和旧工具结果原始事件一条不少,只是 surface 对话视图被摘要替换(surfaceOp replace,第 5 篇讲过)。想恢复原文可以从日志重放。
Q: 会反复压缩同一段吗?
A: 不会无限反复:剪枝后重新测量,用量降到阈值以下就停;摘要区间的选择向前推进(保留最近 verbatim),旧 checkpoint 会被合并而不是重复压缩;溢出恢复有 maxOverflowRetries 上限。
Q: 摘要用什么模型?
A: 可配置:summarizationProvider / summarizationModel(也可以按模型/供应商在 modelPolicies 里分别设),默认跟随对话路由的模型。摘要请求复用对话自己的 system + tools 对齐 prefix cache。
Q: 压缩期间能发消息吗?
A: 不能------compaction/start 事件就是锁,压缩期间引擎进入 maintenance 独占(第 1 篇讲过),压缩和新请求不交错;compaction/end 释放锁。
Q: 8192 剪枝和 LLM 摘要是什么关系?
A: 同一压缩流程的两步:剪枝优先 (tool-result-pruner,8192 码点剪工具结果中间,无损、不调 LLM),剪完重测;剪完仍超阈值才摘要(LLM 生成 checkpoint 替换旧区间,有损)。源码注释原话:"land the model-free pass before choosing a summary range"。
Q: DH 怎么知道哪些历史已经被裁剪、被压缩摘要过?
A: 三套识别机制,都基于"历史都在 session 日志里"(第 5 篇):
- 压缩摘要带标签 ------checkpoint 落地时包在
<compacted-summary>标签里(summarizer.ts 的SUMMARY_OPEN_TAG/SUMMARY_CLOSE_TAG),模型和系统一眼认出"这是之前压缩的摘要"; - 压缩动作本身记日志 ------
compaction/start、compaction/summary(记被遮蔽的节点 seqs、减掉的 token 数、哪个模型写的)、compaction/end、compaction/prune都是 session 日志事件(log-only)------审计、重放能看到"压过哪些节点、减了多少 token"; - 再次压缩不叠摘要 ------压缩指令里有一条规则:"如果对话里已经有
<compacted-summary>块,它是 PRIOR checkpoint:不要原样复制,保留仍然成立的事实、丢弃过期的、把新信息合并进同一份结构"(summarizer.ts 原话)------旧摘要被合并进新摘要,不会摘要叠摘要。
剪枝也有标记 :被剪过的工具结果带 [... tool result middle pruned ...] 标记;而且剪一次后(头 4096 + 尾 1024,约 5KB 码点)远小于 8192 阈值,天然不会重复剪。
Q: LLM 压缩之后再压缩,是怎么做的?摘要会不会越叠越多?
A: 不会叠------再压缩时旧摘要被合并进新摘要,不是原样复制。机制:
- 识别旧摘要 :旧的 checkpoint 带
<compacted-summary>标签,系统知道"这是之前压的"(见上一问); - 合并规则 :压缩指令里明确要求------"如果对话里已经有
<compacted-summary>块,它是 PRIOR checkpoint:不要原样复制,保留仍然成立的事实、丢弃过期的、把新信息合并进同一份结构"(summarizer.ts 原话); - 一次再压缩 = 旧摘要 + 它之后的对话 → 一个新摘要 :再次触发时
selectCompactableRange从头部选区间(旧 checkpoint 在头部,且旧摘要很短、容易落在保留预算之外),把旧摘要连同后面的新对话一起喂给模型,产出合并后的单一 checkpoint------始终只有一份摘要,不会摘要叠摘要; - 可追溯 :每次压缩的
compaction/start → compaction/summary(记被压节点 seqs、token 数)→compaction/end都在日志里,能看出压过几次、每次压了什么。
每次压缩还有重试保护:单次压缩 compactionRetries(摘要收敛)、溢出恢复 maxOverflowRetries(重试上限)。
系列文章导航
| 编号 | 文章 | 对应模块 |
|---|---|---|
| 000 | 5 分钟看懂 DeepSeek Harness | 全景图 |
| 001 | 动力引擎:Agent Loop 是怎么转起来的 | AGENT LOOP |
| 002 | 上下文拼接:让 AI 准确行动 | SYSTEM PROMPT |
| 003 | 模型适配:换模型只改配置 | LLM |
| 004 | 工具实现:模型看到的不只是工具结果 | TOOLS |
| 005 | 事件日志:保证任务不丢失 | SESSION |
| 006 | 上下文压缩:长对话管理(本页) | COMPACTION |
| 007 | 能力接缝模式:定义/实现/使用分离 | SHELL + 全部 Seam |
| 008 | Cordis 插件体系 | CORDIS |
| 009 | Subagent:委托与隔离(规划中) | SUBAGENT |
| 010 | Workflow:模型驱动的多 Agent 协作(规划中) | WORKFLOW |
| 011 | Guard + Permission + Sandbox(规划中) | GUARD / INTERACTION / SANDBOX |
| 012 | Plan + Preset + Context + Todo(规划中) | PLAN / PRESET / CONTEXT / TODO |
| 013 | Hooks:Claude Code / Codex hook 桥(规划中) | HOOKS |
备注:系列文章链接为占位符,各篇发布后替换为真实 URL。
参考链接
- DeepSeek Harness 仓库:github.com/deepseek-ai...