夜雨聆风学习资料网

ARTICLE · 1077195

DeepSeek-Harness 源码深读(13):砍掉一半对话,日志反而多了四条事件

DeepSeek-Harness 源码深读(13):砍掉一半对话,日志反而多了四条事件

摘要:上下文压缩要在飞行中砍掉模型正在用的历史,教科书写法是直接删消息。dsh 的 session 是只追加的事件日志,一条都删不得。compaction-basic 的答案:往日志里再写四条事件,start、summary、带 surfaceOp replace 的 user/message、end。压缩因此可回放、可崩溃恢复,摘要调用还复用了 KV 缓存前缀。

上一篇拆 token-meter,结尾留了三个问题:阈值到了怎么选保留区间,摘要调用怎么复用 KV 缓存前缀,提交时那对相邻事件怎么不被并发撕开。这篇拆 compaction-basic,三个问题逐个对账。

先把矛盾摆出来。会话跑久了,上下文逼近模型的窗口上限,常见的做法是把老历史总结成一段摘要,替换掉原文。问题在「替换」两个字。dsh 的会话是一本只追加的事件日志,《不保存任何会话状态,session 插件凭什么断点续跑》拆过这个地基:崩溃恢复、回放、审计,全部建立在「日志写进去就不动」之上。现在你要砍掉一半对话,等于是让这本只增不减的账本自己翻脸。

我们来看 dsh 的答案。它没有删任何东西,压缩这个动作本身就是四次追加:一条 compaction/start,一条 compaction/summary,一条带替换指令的 user/message,一条 compaction/end。模型看到的历史变短了,日志一个字节都没少,反而多了四条事件。这篇就沿着一条逼近上限的会话,看这四条事件是怎么被决定、被计算、被安全写进去的。

一、谁决定该压了:0.8 这条线

压缩引擎自己不养计数器。它的类注释写明定价全用别人(compaction-basic/src/index.ts:95-98):

Dependency-light compaction backend using ctx.tokenMeter for pressure, retention, cited source events, and summary-convergence pricing.

所以它是这样挂进运行时的(index.ts:103-104):

export class BasicCompactionEngine extends CompactionEngine {  static inject = ['llm', 'tokenMeter', 'sessions']

这段是干嘛的:声明三个注入依赖,llm 发摘要调用,tokenMeter 管所有定价,sessions 管落盘。注意看这里,没有剪枝器,没有 agent,依赖表短到可以背下来。

自动触发注册在构造函数里,监听的第一个事件是 agent/pre-step(index.ts:147-153):

ctx.on('agent/pre-step', async (  { agent, signal },  next,): Promise<PreStepDecision> => {  if (!signal.aborted) {    try {      const result = await this.compactIfNeeded(agent, 'pressure', signal)

这段是干嘛的:在 agent 每一步开始前,也就是两次模型请求之间的安全边界,做一次压力检查。注意看这里,检查点选在请求之间,不在请求中途,所以压缩永远不会打断一次正在飞行的调用。

压力怎么算成数字?0.8 是全局默认(config.ts:19-23):

/** Default request-pressure fraction for every routed model. */const DEFAULT_THRESHOLD_RATIO = 0.8/** Default verbatim-tail fraction for every routed model. */const DEFAULT_RETAIN_RATIO = 0.16

这两个比例最终被缩放成具体 token 数(config.ts:144-147):

  const thresholdTokens = Math.floor(contextWindow * policy.thresholdRatio)  const retainTokens = policy.retainTokens === undefined    ? Math.floor(contextWindow * policy.retainRatio)    : policy.retainTokens

这段是干嘛的:把 0.8 乘上模型窗口得到触发线,0.16 乘上窗口得到保留尾部。注意看这里,配置里写的是比例,代码里比的是 token 数,而窗口大小来自 llm 服务解析的模型信息,所以同一个配置迁到不同模型上会自动重新校准。

判定发生在 compactIfNeeded 里,就一行(index.ts:304):measurement.totalTokens < spec.thresholdTokens 就返回 null,什么都不做。计量器说没到线,引擎就闲着。

二、恰好超窗的那次请求:溢出恢复

但 0.8 的步间检查有个缝。压力是上一步结束时量的,这一步的请求可能正好跨过窗口上限,模型直接报 CONTEXT_WINDOW_EXCEEDED。这种失败不归压缩引擎管,可它监听着这个错误(index.ts:179-183):

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

这段是干嘛的:请求报错了,先看错误码,只有上下文超限这一种才进入恢复分支。注意看这里,其他错误原样放行,恢复逻辑不碰。

恢复路径有个狠地方,它绕过了 0.8 的阈值和 0.16 的保留尾部(index.ts:283-290):

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

这段是干嘛的:溢出触发时先跑一遍不花模型的工具结果剪枝,然后用 0 当保留预算选区。注意看这里,selectCompactableRange 的第三个参数是 retainTokens,传 0 意味着不留尾部、能压多少压多少。已经失败了,就没必要再守平时的绅士协议。

压完怎么证明有效?看替换代际(index.ts:191 与 218-222):进入恢复前记下 session.surface.replaceGeneration,压缩后这个数字没涨,说明表面根本没被替换过,原错误原样上抛;涨了,才返回 { kind: 'retry' } 让 agent 层原样重发请求。表面已经变小,新请求自然装得下。

还有一个体贴的边界(index.ts:197-207 的注释):恢复分两段,先免模型的剪枝,后花模型的摘要。如果剪枝已经产生了持久的表面缩减,摘要这段却抛错了,已落盘的缩减不算白干,照样计入重试证据。注释原话:不要因为可选的第二阶段失败而丢弃已落盘的缩减。

重试次数有账本,WeakMap 按 agent 记,超过 maxOverflowRetries(默认 1,config.ts:93)就放行原始错误。两个清零点:agent 转入 idle,或者任何一条 assistant/message 落盘。也就是说一次成功的模型响应就开启新的恢复序列,哪怕工具调用还在同一个回合里继续发下一条请求。

三、保留区间怎么选:尾部定价,配对不切

dd12 的第一问。选区核心是 selectCompactableRange(region.ts:98),策略一句话:压最老的连续区段,保留最近尾部,永不切断工具配对。

它进门先对账(region.ts:107-110):

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')}

这段是干嘛的:逐 seq 对比计量器手里的定价节点和会话当前的表面节点。注意看这里,对不上直接抛错,因为这时的定价描述的是旧的一代表面,拿旧报价去压缩新表面,等于照着过期的账本划款。

然后从尾部往回累加(region.ts:112-119):

let accumulated = 0let keepFromIdx = pricedNodes.lengthfor (let index = pricedNodes.length - 1; index >= 0; index -= 1) {  // oxlint-disable-next-line typescript/no-non-null-assertion  accumulated += pricedNodes[index]!.tokens  keepFromIdx = index  if (accumulated >= retainTokens) break}

这段是干嘛的:从最新一条往老的方向累加 token,攒够保留预算就停,切分点 keepFromIdx 之外的全进压缩区。注意看这里,保留是按 token 定价不是按条数,一条巨大的工具结果自己就能占满整个尾部预算。

切分点还要再退(region.ts:122-127):

while (keepFromIdx > 0) {  // oxlint-disable-next-line typescript/no-non-null-assertion  if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break  keepFromIdx -= 1}if (keepFromIdx === 0) return null

这段是干嘛的:如果切分点正好落在一个工具调用和它的结果中间,就往老的方向退,退到不撕裂配对的边界为止。注意看这里,一路退到 0 就返回 null,宁可不压缩,也不产生一条消息序列非法的表面。判定的实现在 dsh-compaction 基类包里,压缩边界永远从表面第一个节点开始,所以压的永远是最老的一整块,不在中间挖洞。

四、摘要调用为什么不是一笔巨款

dd12 的第二问,也是这个包最精的节省。压缩发生在上下文逼近上限的时刻,这时候补一次摘要调用,如果摘要走独立的提示词模板,provider 就要为一个全新的请求重算几乎全量的 KV 缓存,最贵的时刻再添一笔巨款。

dsh 的做法写在 SummarizationInput 的注释里(summarizer.ts:24-30):

The summarization directive, delivered as the FINAL user message after the replayed conversation rather than as a distinct summarizer system prompt. Keeping the conversation's own system prompt, tools, and message prefix in front of it makes the auxiliary call a genuine prefix of the last routed request, so the provider's KV cache is reused instead of invalidated.

压缩指令不放进独立的 system prompt,而是作为回放对话之后的最后一条 user 消息。被压缩区段的消息从哪来?用与真实请求完全相同的投影管线重建(region.ts:502-513):

const header = session.requestHeader()const events = session.eventsconst regionMessages = shadowedSeqs  // shadowedSeqs are current surface seqs, so each is a valid log index.  // oxlint-disable-next-line typescript/no-non-null-assertion  .map(seq => session.deriveEventMessage(events[seq]!))  .filter((message): message is Message => message !== null)return {  ...header?.system === undefined ? {} : { system: header.system },  ...header?.tools === undefined ? {} : { tools: header.tools },  messages: regionMessages,}

这段是干嘛的:取最近一次路由请求的 system 和 tools,再逐节点把被压缩区段的事件派生成消息。注意看这里,注释点破了一个事实,表面 seq 就是日志下标,所以 events[seq] 直接可查;派生用的是 deriveEventMessage,跟正常请求拼消息同一个函数,不是另写一套序列化。

指令本身长这样(summarizer.ts:146-152):

const messages: Message[] = [  ...input.messages,  createUserMessage({    content: [{ type: 'text', text: COMPACTION_INSTRUCTION }],    source: { kind: 'plugin', plugin: 'dsh-compaction-basic' },  }),]

这段是干嘛的:回放消息原样在前,压缩指令作为最后一条 user 消息追加。注意看这里,这次辅助调用的形状是[原 system + 原 tools + 被压缩区段消息 + 一条新指令],恰好是上一次路由请求的真前缀再加一段,provider 的前缀缓存直接命中,唯一的新输入就是那条指令。

指令要求输出固定的八段 Markdown(Primary Request and Intent / Key Technical Concepts / Files and Code / Errors and Fixes / Pending Jobs / Current Work / Next Step / Critical Context,summarizer.ts:36-58),必须保留精确路径、命令、错误串和数字。还有一条防膨胀的(summarizer.ts:65):对话里已有上一次压缩的  块时,不许原样照抄,要把仍然为真的事实合并进单个新摘要,嵌套压缩不滚雪球。

摘要回来先过三道安检。截断即失败(summarizer.ts:206-209 把 max-tokens 映射成错误码 MAX_TOKENS 的 incomplete checkpoint,宁可重试不要半截摘要);含图像即拒绝(summarizer.ts:220-221 抛 UNSUPPORTED_CONTENT);框架化后必须更小(region.ts:373-378):

const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)if (framedSummaryTokenCount >= prepared.shadowedTokenCount) {  throw new Error(    `summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedTokenCount})`,  )}

这段是干嘛的:给包好外壳的 checkpoint 消息估个价,跟它替换掉的那段原文比。注意看这里,大于等于就报错,压缩不减反增在这套代码里是硬故障,不是可接受的结果。

五、提交:四条事件和那把日志锁

dd12 的第三问,相邻事件怎么不被并发撕开。整个事务在 compactSurfaceRegion(region.ts:152),最关键的动作顺序是:先同步落一条 compaction/start(region.ts:189),然后才进入任何异步工作。

这条 start 就是锁。函数的 doc 注释(region.ts:137-142)写得很直白:空闲校验与 compaction/start 同步相邻,所以这个持久的开标记就是摘要让出事件循环之前最后一步落盘,任何并发的压缩都会在入口被它挡住。崩溃也一样,进程死在摘要中途,日志里留着一条没闭合的 start,下次进来扫一眼就知道上回有个没做完的压缩(region.ts:291-297 的 assertCompactionInactive 检查这个状态,抛 busy)。连关闭失败都是故意的:关不上就留着未闭合的 start,让这把锁保持可检测。

摘要完成、稳定性校验通过后,进入提交段(region.ts:447-465):

const summaryEvent = session.append('compaction/summary', {  compactionId: startEvent.data.compactionId,  // …summary 原文、shadowedRange、shadowedSeqs、provider、model、usage})session.append('user/message', checkpointMessage, {  surfaceOp: { op: 'replace', start, end },  sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],})

这段是干嘛的:一次让出事件循环的机会都不给,连着追加两条,先写摘要审计记录,再写那条带替换指令的 user/message。注意看这里,surfaceOp 声明「把 seq 在 start 到 end 之间的表面节点折叠成我这一条」,sourceEventSeqs 把开标记、摘要和全部被遮蔽事件的 seq 串成因果链。随后 compaction/end 落盘,这对括号闭合。

稳定性校验分两档。自动压缩用 whole-surface(region.ts:387-396,深比较整个度量节点列表),因为步间边界内表面本就不该动,动了就是有并发写人;手动压缩用 selected-span(region.ts:403-424),只要求被选区段还是那个等价替换目标,区段之外新长的节点不误伤摘要。手动路径的错误还会被分类成 busy / changed / summary / commit / persistence / cancelled 六种(region.ts:257-277 加 index.ts:413-419),/compact 命令报给用户的话因此能说清是哪种失败。

六、回放者得到了什么

四条事件落盘之后,我们来站到回放者那边看。一个从零折叠日志的进程,逐条执行事件,执行到那条带 surfaceOp replace 的 user/message 时,把 seq 在 [start, end] 的旧节点折叠替换成这一条 checkpoint 消息。它算出的表面,与在线进程压缩后的表面一致。被遮蔽的原始事件一条都没少,全在日志里,sourceEventSeqs 指得回去。

日志侧只追加四条事件,表面侧折叠替换

所以压缩在回放语义下不是特殊操作,只是又一次普通的追加。时间旅行调试跳到压缩前,看到完整原文;跳到压缩后,看到折叠结果;审计想知道「这段摘要凭什么替换那两百条消息」,顺着 sourceEventSeqs 一查便知,摘要原文、调用信封、token 用量全记在 compaction/summary 事件里。

挂载也佐证了这个设计的底气。base bundle 里它零配置上桌(bundle/base/cordis.patch.yml:284-285),/compact 命令插件跟着注释(cordis.patch.yml:287-289):后端无关,跟随任何当前挂载的压缩服务。

七、设计对账

开场那三个问题,现在逐个对账。

保留区间怎么选?从最新节点往回累加 token,攒够 0.16 乘窗口的预算就画线(region.ts:112-119),线压到工具配对中间就往老的方向退(region.ts:122-126),退到底退不动就放弃这次压缩(region.ts:127 返回 null)。

摘要调用怎么不算巨款?消息用 deriveEventMessage 从日志逐条重建(region.ts:507),指令作为最后一条 user 消息拼上去(summarizer.ts:146-152),整个调用是上次路由请求的真前缀加一段,KV 缓存命中,新的只有那条指令。

相邻事件怎么不被撕开?compaction/start 在任何 await 之前同步落盘当锁(region.ts:189),摘要与替换消息之间一次让出都不给(region.ts:447-465 连续两条 append),摘要让出期间表面有没有被动过,whole-surface 深比较说了算(region.ts:392-394)。

代价也摆在明处。选区只会从头部整块砍,想跳过某段重要历史没有开关,「什么值得留」全押给那八段提示词;压力检查在步间,恰好跨线的请求得先失败一次才轮到恢复兜底,重试次数用完就把原始错误端给用户。这两条不是疏忽,是把可预测性放在灵活性前面的取舍。

压缩管的是历史太长。可 agent 上下文的大头不止历史,还有每次循环都要过一遍的工具那套流程。下一篇拆 tools 插件:注册、钩子、权限校验在包内怎么分层,深读第 14 篇见。

你的 Agent 压缩历史之后,有没有遇到过摘要把关键细节弄丢的情况?评论区聊聊你怎么补的。


本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。

相关学习资料