ARTICLE · 1062185
DeepSeek Harness 源码-Compaction 与 Spill 内存管理
DeepSeek Harness 源码解析系列
第 40 讲:Compaction 与 Spill 内存管理
基于 DeepSeek Harness 源码 · 2026-09-23
💡 本讲一句话:Agent 跑得越久,上下文越满。DeepSeek Harness 用两套机制解决:Compaction 把旧历史"摘要成一个检查点"(带持久化锁、稳定性校验、KV cache 复用),Spill 把超大工具输出"搬到磁盘只留预览"——读完你能看懂一次压缩事务从触发到落盘的完整生命周期。
一、两种内存压力,两套机制
前几讲我们跟着 Agent Loop 走完了"请求 → 工具调用 → 结果回填"的完整循环。但有一个问题被刻意搁置了:这个循环每转一圈,session 日志就变长一点,发给模型的请求包也越来越大——直到某天 provider 直接回一句 "context window exceeded"。
DeepSeek Harness 把内存压力拆成两种性质完全不同的问题,分别交给两个能力域:
🔹 历史太长(compaction):对话本身合法、每个节点都有价值,但总量超了窗口。解法是"有损压缩"——把旧的一段历史摘要成一个检查点节点,替换掉原区间。这是模型参与的操作,贵、慢、可能失败。
🔹 单条结果太大(spill / prune):某一次 web_fetch 或 read 吐了 2MB 文本,一条消息就吃掉半个窗口。解法是"无损外置"——全文原样写到磁盘,上下文里只留头尾预览 + 一个可回读的定位符。这是纯机械操作,便宜、确定、永不丢数据。
本讲按"seam(抽象服务定义)→ basic backend(默认实现)→ policy(策略插件)"的顺序,把 packages/compaction、packages/spill 两个能力域的核心源码全部过一遍。
二、Compaction 服务定义:三个入口,一把锁
@deepseek-ai/dsh-compaction 是能力缝(capability seam):它只定义"压缩是什么",不规定"怎么压"。抽象类 CompactionEngine 暴露三个入口,分别对应三种触发场景:
📄 packages/compaction/compaction/src/index.ts (第 96-170 行)
export abstract class CompactionEngine extends Service { // 抽象压缩服务:子类实现触发策略、保留策略与摘要方式 constructor(ctx: Context) { // 注册为 ctx.compaction 服务——每个 context 只允许一个实现 super(ctx, 'compaction') } abstract compactIfNeeded( // 自动入口:步骤边界或溢出恢复时调用,"需要才压" agent: CompactionAgentContext, // 持有 session 与路由选项(provider/model)的 Agent 上下文 trigger: CompactionTrigger, // 'pressure' 常规压力 | 'context-overflow' provider 确认的窗口溢出 signal: AbortSignal, // 取消信号;基于模型的实现必须把它转发给摘要调用 ): Promise<CompactionResult | null> // 返回压缩结果,或 null(没有可安全压缩的区间) abstract compactNow( // 手动入口:/compact 命令走这里,低于阈值也强制压一次 agent: ManualCompactAgentContext, // 空闲 Agent 上下文,额外要求 runMaintenance 能力 signal: AbortSignal, // 只作用于本次请求的取消信号 sourceCommandId?: CommandId, // 发起这次压缩的手动命令身份(用于日志关联) ): Promise<CompactionResult | null> // 返回结果;没有安全区间时返回 null abstract compactRegion( // 强制入口:把显式指定的 surface 区间压成一个摘要节点 start: number, // 起始 surface seq(含),按"表面位置"而非数值大小解释 end: number, // 结束 surface seq(含);替换发生后可见 seq 可能非单调 agent: CompactionAgentContext, // 被修改的 session 属主,其路由选项指导摘要 signal?: AbortSignal, // 可选取消信号,转发给摘要器 ): Promise<CompactionResult> // 返回事件 seq、摘要内容、被替换区间与 token 账目}为什么这样设计:三个入口的差别不在"压多少",而在并发语义。compactIfNeeded 发生在 turn 内部(有 owner turn),compactNow 发生在 turn 之间(owner 为 null,必须独占空闲 Agent),compactRegion 是纯原语。把"何时压"的策略留在子类、把"怎么安全地替换一段历史"的事务逻辑固化在共享代码里——这是整个能力域最重要的分层决策:任何后端(模板摘要器、远程摘要服务)都逃不开同一套锁与校验。
三、自动压缩:步骤边界 + 溢出恢复双钩子
BasicCompactionEngine(packages/compaction/compaction-basic/src/index.ts)是默认后端。当 auto: true(默认值)时,它在构造期注册两组钩子——一组挂在"每次模型请求之前"做压力检查,另一组挂在"请求失败之后"做溢出抢救:
📄 packages/compaction/compaction-basic/src/index.ts (第 137-224 行)
private _registerAutomaticCompaction(): void { // auto=true 时注册两个触发源的自动压缩 const { ctx } = this // 解构插件上下文,供下面所有钩子闭包使用 const logResult = (result: CompactionResult, trigger: string): void => { // 日志助手:记录每次落地的持久化缩减 ctx.logger.info( // info 级别——运维可以据此审计每一次压缩省了多少 token `compaction (${trigger}): shadowed ${result.shadowedSeqs.length} surface nodes ...`, // 一行摘要:替换了几个节点、影子区间与估算 token 数 ) } ctx.on('agent/pre-step', async ({ agent, signal }, next): Promise<PreStepDecision> => { // 钩子一:每个模型请求之前的步骤边界 if (!signal.aborted) { // 本轮已被取消就跳过测量,省一次无谓的 meter 计算 try { const result = await this.compactIfNeeded(agent, 'pressure', signal) // 压力检查 + 超阈值则压缩(动态分派,子类可覆写) if (result !== null) logResult(result, 'step pressure') // 落地了持久化缩减就记一条审计日志 } catch (error: unknown) { if (error instanceof TargetPressureConfigError) { // 配置缺口(没配 contextWindow):按目标去重告警 if (this.warnedPressureConfigTargets.has(error.targetKey)) return next() // 这个 provider/model 已经警告过——保持安静,避免每步刷屏 this.warnedPressureConfigTargets.add(error.targetKey) // 记下警告键,后续同目标的同类错误不再重复输出 } const message = error instanceof Error ? error.message : String(error) // 把任意抛出值归一成可读字符串 ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`) // 压缩失败绝不能阻塞本轮对话——只告警不中断 } } return next() // 无论成败都放行步骤:这里的压缩是尽力而为(best-effort) }) ctx.on('agent/status', ({ agent, status }) => { // Agent 回到空闲时清理它的溢出重试计数 if (status === 'idle') this.overflowRetries.delete(agent) // 新一轮对话重新获得完整的恢复预算 }) ctx.on('session/event', (session, event) => { // 一次成功的响应会开启全新的溢出恢复序列(即使同一 turn 继续调工具) if (event.type !== 'assistant/message') return // 只有助手完成消息才算"有进展" const agent = this.overflowAgents.get(session) // 找到在这个 session 上做恢复的 Agent if (agent !== undefined) this.overflowRetries.delete(agent) // 请求成功了——重试计数清零,防止旧预算卡死新轮次 }) ctx.on('agent/request-error', async ({ agent, failure, signal }, next) => { // 钩子二:模型请求失败后的恢复通道 if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next() // 只处理 provider 确认的窗口溢出;其他错误原样放行 this.overflowAgents.set(agent.session, agent) // 记住 session→agent 映射,供成功事件钩子清零计数用 const target = routedTarget(agent.session) // 解析最近一次持久化路由的确切 provider/model if (target === undefined) return next() // 没有持久化路由就选不出策略——放弃恢复,保留原错误 const policy = resolveTargetPolicy(this.config, target) // 把该模型的精确覆盖项合并到默认策略上 const retries = this.overflowRetries.get(agent) ?? 0 // 这个 Agent 已经消耗了几次溢出恢复机会 if (retries >= policy.maxOverflowRetries) return next() // 预算耗尽——不再抢救,把原始错误交还给调用方 const generation = agent.session.surface.replaceGeneration // 恢复前对 surface 状态做快照(每次持久化替换都会 +1) let result: CompactionResult | null // 恢复尝试的结果占位 try { result = await this.compactIfNeeded(agent, 'context-overflow', signal) // 强制一次"有用的平衡缩减",绕过常规阈值与保留尾策略 } catch (recoveryError: unknown) { const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError) // 归一化失败信息用于日志 if (!signal.aborted && agent.session.surface.replaceGeneration > generation) { // 摘要阶段挂了,但无模型的 prune 可能已经先落地了 ctx.logger.warn(`context-overflow compaction failed after durable surface progress: ${message}; retrying from the replacement surface`) // 部分进展也是进展——基于更小的 surface 重试值得做 this.overflowRetries.set(agent, retries + 1) // 把这次"半成功"计入已消耗的重试预算 return { kind: 'retry' } // 重新发起同一个请求,让它落在缩减后的表面上 } ctx.logger.warn(`context-overflow compaction failed: ${message}; ...`) // 没有任何持久化进展——记录后走原错误路径 return next() // 保留原始请求错误交给上层处理 } if (signal.aborted || agent.session.surface.replaceGeneration <= generation) return next() // surface 没有变化(或被取消)——没有可重试的新状态 if (result !== null) logResult(result, 'context overflow recovery') // 记录这次让重试成为可能的持久化缩减 this.overflowRetries.set(agent, retries + 1) // 消耗一格溢出恢复预算,防止无限循环 return { kind: 'retry' } // 重试同一请求:更小的 surface 现在应该装得下了 })}为什么这样设计:注意 replaceGeneration 这个细节——它是 surface 的"版本号",每次持久化替换都会递增。恢复逻辑在摘要调用前后各读一次:如果版本没变,说明这次抢救什么都没留下,重试毫无意义;如果变了(哪怕只是无模型的 prune 先落地了),就值得基于新状态再试一次。maxOverflowRetries(默认 1)给整个抢救过程上了预算上限,避免"溢出→压缩→还溢出"的无限循环烧钱。而 pre-step 钩子里那句 continuing the turn 点明了自动路径的铁律:压缩是优化,不是正确性依赖——它失败时对话必须继续。
🔹 重试预算的三条清零路径:① Agent 回到 idle(agent/status);② 任意一次助手消息成功落地(session/event);③ 预算耗尽后自然停止。三者共同保证:恢复计数只描述"当前这次卡住",绝不跨轮次累积。
四、决策核心 compactIfNeeded:先量、再剪、后摘要
compactIfNeeded 是两种触发共用的决策函数。它的执行顺序非常讲究——测量 → (可选)无模型修剪 → 再测量 → 选区间 → 摘要,且可以重试:
📄 packages/compaction/compaction-basic/src/index.ts (第 258-332 行)
override async compactIfNeeded( // 针对一个显式触发源做自动压缩决策 agent: Agent, // 要测量其最近一次持久化路由请求的 Agent trigger: CompactionTrigger, // 'pressure' 步骤边界压力 | 'context-overflow' 溢出恢复 signal: AbortSignal, // 本轮取消信号,转发给摘要调用): Promise<CompactionResult | null> { // 返回摘要结果;什么都没做时返回 null const target = routedTarget(agent.session) // 解析最近一次请求持久化路由的确切 provider/model if (target === undefined) return null // 没有持久化路由——选不出策略,静默跳过 const policy = resolveTargetPolicy(this.config, target) // 把该模型的精确覆盖项合并到已校验的默认策略上 const meter = this.ctx.tokenMeter // 单例会话计量器:所有定价决策共用同一个估算源 let measurement = meter.measure(agent.session) // 对当前 surface 逐节点计价 switch (trigger) { // 封闭联合类型的穷尽性守卫(两个分支都在下方按语义处理) case 'context-overflow': break // 溢出路径:绕过阈值强制缩减 case 'pressure': break // 压力路径:先解析模型容量再算阈值 default: assertNever(trigger, 'compaction trigger') // 未来新增触发源却忘了处理——编译期/运行期双重兜底 } const prune = this.ctx.get('toolResultPruner') // 可选的兄弟服务——用 ctx.get 而非注入,保证 compaction-basic 可独立组合 if (trigger === 'context-overflow') { // provider 已经亲口说"装不下了"——不需要再算阈值 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) // 从头到尾摘要成一个检查点节点 const context = (await this.ctx.llm.resolveModelInfo(target.provider, target.model, signal)).context // 向适配器询问该模型的真实上下文容量 assertNoActiveCompaction(agent.session, 'automatic pressure compaction') // 异步策略决策之后复查持久化锁——防止并发压缩 const targetKey = `${target.provider}/${target.model}` // 稳定的警告去重键 if (context === undefined) { // 适配器没有声明 contextWindow throw new TargetPressureConfigError( // 配置缺口:没有容量就算不出阈值,必须大声失败 targetKey, `compaction-basic: no context capacity for ${targetKey}; configure contextWindow on that adapter model` // 可操作的诊断信息,直接点名要修哪条路由 ) } const spec = resolveCompactSpec(policy, context.contextWindow) // 把比例(0.8/0.16)换算成该窗口的具体 token 预算 if (measurement.totalTokens < spec.thresholdTokens) return null // 还没到阈值——本轮无需压缩 if (prune !== undefined) { // 压力达标后,先落地无模型路径再选摘要区间 prune.pruneSession(agent.session) // 修剪可能直接把总量压回阈值以下 measurement = meter.measure(agent.session) // 通过单例 replay fold 重新测量 } if (measurement.totalTokens < spec.thresholdTokens) return null // 光靠剪枝就够了——省掉一次昂贵的摘要调用 let result: CompactionResult | null = null // 记录本轮重试循环中最近一次成功的压缩 for (let attempt = 0; attempt <= spec.compactionRetries; attempt += 1) { // 有界尝试:首次 + 配置的额外重试次数(默认共 2 次) const range = selectCompactableRange(agent.session, measurement, spec.retainTokens) // 选一个头锚定区间,保留最近 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 (${measurement.totalTokens} estimated tokens >= threshold ${spec.thresholdTokens})` // 带上精确数字,方便诊断是窗口太小还是保留尾巴太大 )}为什么这样设计:"先剪后摘"的顺序是成本优化——prune 是纯字符串操作,零模型调用;如果它就能把总量压回阈值以下,昂贵的摘要根本不用发生。而重试循环解决的是另一个现实:一次摘要未必够(比如保留尾巴太大、或摘要本身偏长),所以允许再选一个区间再压一次,但次数有界(compactionRetries 默认 1)。最后那个 throw 值得注意:自动压缩"尽力而为"指的是失败不阻塞对话,而不是静默放弃目标——用尽手段仍超阈值属于配置错误,必须让运维看见。
五、区间选择:保留尾巴 + 平衡切割点
selectCompactableRange(region.ts)回答"压哪一段"。规则是:从头开始压,保留最近 retainTokens 的原文尾巴,且切割点必须落在工具调用配对的平衡边界上——绝不能把一个 assistant 的 tool-call 和它的 result 劈到摘要两侧:
📄 packages/compaction/compaction-basic/src/region.ts (第 98-134 行)
export function selectCompactableRange( // 选出要压缩的头锚定区间,同时保留一段计过价的最近尾巴 session: Session, // 提供权威当前 surface 位置的会话 measurement: TokenMeasurement, // 来自会话计量器的统一压力与表面测量结果 retainTokens: number, // 必须逐字保留的最近尾巴预算(溢出恢复时传 0)): { start: number; end: number } | null { // 返回含端点的 surface seq 区间;没有可压内容时返回 null const pricedNodes = measurement.nodes // 按 surface 顺序排列的逐节点 token 价格 if (pricedNodes.length === 0) return null // 空表面——无物可压 const surfaceNodes = session.surface.nodes // 权威的当前 surface 位置序列 if (surfaceNodes.length !== pricedNodes.length || surfaceNodes.some((seq, index) => seq !== pricedNodes[index]?.seq)) { // 计量器快照必须与活 surface 完全一致 throw new Error('compaction: token-meter surface does not match the current session surface') // 不一致意味着并发修改——宁可大声失败也不能切错节点 } let accumulated = 0 // 已保留尾巴的累计 token 数 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 // 该切割点之前没有未闭合的 tool-call——可以安全切在这里 keepFromIdx -= 1 // 这个位置会把某一步的 call/result 劈成两半——再往前挪一格 } if (keepFromIdx === 0) return null // 尾巴之前找不到任何平衡边界(比如整段都是未闭合调用) const first = surfaceNodes[0]! // 压缩永远从表面头部开始 const cutoff = surfaceNodes[keepFromIdx - 1]! // 将被摘要影子覆盖的最后一个节点 return { start: first, end: cutoff } // 含端点的"位置区间"——注意是位置,不是数值连续区间}"平衡切割点"由 tool-pairing.ts 提供。它维护一个按 surface 顺序的增量折叠状态:assistant 消息里每个 tool-call 块 +1,每条 tool/result -1;计数恰好归零的位置就是安全切割点:
📄 packages/compaction/compaction/src/tool-pairing.ts (第 29-38、77-97 行)
function eventDelta(event: SessionEvent): number { // 一个 surface 事件如何改变"进行中 tool-call 计数" switch (event.type) { // 只有助手消息和工具结果影响配对平衡 case 'assistant/message': return event.data.message.content.filter(block => block.type === 'tool-call').length // 每个 tool-call 块打开一个待闭合的配对(一条消息可开多个) case 'tool/result': return -1 // 每条结果恰好闭合一个进行中的调用 default: return 0 // 其余事件类型对配对计数是中性的 }}function balanceCache(session: Session): BalanceCache { // 返回与当前 session surface 同步的平衡状态 const surface = session.surface // 可见节点的活投影 const seqs = surface.nodes // 当前 surface 位置序列(顺序即折叠顺序) const generation = surface.replaceGeneration // 每次持久化替换都会递增——用它使过期缓存自动失效 const cached = balanceCacheBySession.get(session) // 按 session 维度的增量状态(WeakMap:会话消亡时自动回收,不泄漏) if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > seqs.length) { // 首次使用、surface 被重写、或表面缩短——三种情况都重建 const rebuilt = extendCache(session, { // 重建 = 从空表面的初始状态重新跑一遍同样的折叠 generation, // 记录本次描述的是哪一代 surface cutBalanced: [true], // 空表面只有一个"头部切割点",它天然平衡 indexBySeq: new Map(), // seq→位置 的索引表(重建时为空) inProgressToolCalls: 0, // 进行中的 tool-call 计数从零开始 }, seqs) // 把当前所有节点逐个折叠进新的平衡状态 balanceCacheBySession.set(session, rebuilt) // 存回 WeakMap,供后续 O(1) 查询 return rebuilt } if (cached.cutBalanced.length - 1 < seqs.length) return extendCache(session, cached, seqs) // 只是追加了新节点——只增量折叠未见过的尾巴,不重算全量 return cached // surface 自上次检查以来没变——直接复用缓存的切割点平衡表}为什么这样设计:模型协议要求 tool-call 与 result 严格配对,摘要如果劈开一对,replay 出来的对话就是非法的——provider 会直接拒绝。所以"能不能切在这里"不是启发式,而是从事件内容推导出的硬约束。缓存用 replaceGeneration 做失效键而不是时间戳:只要 surface 没被替换过,增量折叠的结果就永远有效;一旦被压缩改写过,整张表推倒重来。这是典型的"版本号驱动的缓存一致性"。
六、持久化事务:锁、稳定性校验与提交
真正"动刀"的是 compactSurfaceRegion——整个能力域里最讲究的函数。它把一次压缩建模成带锁的事务:先写一个持久的 compaction/start 标记当锁,摘要(唯一异步让出点)完成后校验表面没变,再一次性提交替换体并写 compaction/end 释放锁:
📄 packages/compaction/compaction-basic/src/region.ts (第 152-254 行)
export async function compactSurfaceRegion( // 对一个选定的位置区间执行唯一的压缩事务 dependencies: RegionDependencies, // 会话计量器 + 动态分派的摘要钩子(子类可替换) session: Session, // 将被本事务修改表面的会话 start: number, // 目标区间的起始 surface seq(含) end: number, // 目标区间的结束 surface seq(含) agent: Agent, // 供摘要器使用路由与历史的 Agent options: CompactionTransactionOptions, // 括号属主、稳定性规则、可选的持久化检查点 signal?: AbortSignal, // 可选的摘要取消信号): Promise<CompactionResult> { // 返回成功的持久化压缩结果(失败则抛出) if (options.owner === null) signal?.throwIfAborted() // 手动路径:任何工作开始前先尊重调用方取消 const selection = validateSurfaceRegion(session, start, end) // 只读校验区间存在、方向正确、两端都是平衡切割点 const entryState = inspectCompactionEntryState(session.events) // 从日志尾部倒扫:开放 turn / 未闭合压缩标记 / 最新 seed 边界 assertCompactionInactive( // 若已有持久化压缩锁被持有,直接拒绝 entryState.unmatchedCompactionStart, entryState.latestEndSeedSeq, 'compaction' // 有 start 无 end、且其后没有 session/end-seed 边界 = 另一个事务正持锁 ) let owner: number | null // 本压缩所属的 turn(null 表示 turn 之间的独立手动事务) if (options.owner === null) { // 手动空闲路径:要求会话当前没有开放 turn if (entryState.openTurn !== null) throw new ManualCompactionError('busy', 'manual compaction: the session already has an open turn') // 活着的 turn 会与替换竞争——拒绝执行 owner = null // 独立事务:start/end 标记的 turn 字段写 null } else { // 自动当前轮路径:必须被某个编号 turn 严格包含 if (entryState.openTurn === null) throw new Error('compactRegion: no open turn — automatic compaction events must be enclosed in a turn') // 不变量:自动压缩事件必须包在所属 turn 内 owner = entryState.openTurn // 把事务绑定到那个编号 turn const compactionId = CompactionId(randomUUID()) // 本次压缩全生命周期的稳定身份(start/summary/end 共用) const lifecycle = { // start / summary / end 三个标记事件共用的载荷 compactionId, // 事务身份 ...options.sourceCommandId === undefined ? {} : { sourceCommandId: options.sourceCommandId }, // 手动命令关联(存在才写入,保持日志紧凑) turn: owner, // 属主 turn(自动路径为编号,手动路径为 null) } const startEvent = session.append('compaction/start', lifecycle) // 持久化开标记——从这一刻起它就是压缩锁本身 const assertStable: StabilityCheck = options.stability === 'whole-surface' ? assertWholeSurfaceUnchanged : assertSelectedSpanStable // 选择摘要后以多严格的标准复查表面 let failure: TransactionFailure | undefined // 捕获失败:错误 + 阶段(summary 还是 commit) let flushFailure: unknown // 单独捕获提交后的持久化检查点失败 let result: CompactionResult | undefined // 完整成功时才被赋值的最终结果 let closed = false // 是否已追加配对的 compaction/end let closing = false // 守卫:确保"关闭括号"动作最多执行一次 let stage: TransactionFailure['stage'] = 'summary' // 当前阶段——提交体落地后翻转为 commit try { const prepared = prepareCompaction(dependencies, session, selection) // 快照计价 + 为这个精确区间构建 replay 输入(只读) const summarized = await summarizeCompaction( // 事务中唯一的异步让出点:摘要模型调用 dependencies, prepared, agent, compactionId, options.sourceCommandId, signal // 把身份与取消信号转发进摘要器 ) if (options.owner === null) signal?.throwIfAborted() // 手动路径:长 await 之后再次检查取消 assertStable(dependencies, session, summarized) // 拒绝基于旧表面代生成的摘要(详见下方稳定性说明) stage = 'commit' // 从这里起,任何失败都归类为提交阶段失败 const pending = commitCompactionBody(session, startEvent, summarized) // 追加 summary 记录 + 替换体——同步完成,不再让出 closing = true // 标记关闭阶段已开始(catch 里据此判断是否还需补 end) const endEvent = session.append('compaction/end', lifecycle) // 持久化闭标记——释放压缩锁 closed = true result = completeCompaction(pending, endEvent) // 把 end seq 挂上,组装出对外的完整结果 } catch (error: unknown) { failure = { error, stage: closing ? 'commit' : stage } // 记录失败内容与所处阶段(供手动路径分类上报) if (!closing) { // 摘要阶段就失败了——仍然必须把括号闭合,让锁可被检测为"已尝试过" closing = true try { session.append('compaction/end', { ...lifecycle, error: errorChain(error) }) // 带错误链的失败关闭;若这次追加也抛错,未匹配的 start 就留在日志里供诊断 closed = true // 括号重新配对(载荷里带着失败原因) } catch (closeError: unknown) { failure = { error: closeError, stage: 'commit' } // 关闭本身失败——上报这个更严重的错误 } } } if (closed && options.flush !== undefined) { // 手动路径专属:括号闭合后做持久化检查点 try { await options.flush() // 把闭合的标记对强制刷到持久存储,再向调用方报告成功 } catch (error: unknown) { flushFailure = error // 保存失败不能回滚已提交的日志状态——单独上报为 persistence 错误 } } if (options.owner === null) signal?.throwIfAborted() // 取消永远优先:即使提交已完成也如实报告 if (failure !== undefined) { // 有失败——按路径分类抛出 if (options.owner === null) throwManualFailure(failure) // 手动调用方拿到稳定错误码(busy/cancelled/changed/summary/commit/persistence) throw failure.error // 自动路径原样重抛,交给 pre-step / request-error 钩子处理 } if (flushFailure !== undefined) { // 提交成功但没存住——手动路径的独立失败类别 throw new ManualCompactionError('persistence', 'manual compaction durability checkpoint failed', { cause: flushFailure }) // 调用方可以区分"做完了"和"存住了" } if (result === undefined) throw new Error('compaction committed without a result') // 不可达兜底——上面每条路径要么返回要么已抛出 return result}为什么这样设计:三个关键决策。其一,锁是日志本身——compaction/start 是一个持久化事件,进程崩溃后重启,倒扫日志就能发现"有 start 无 end"的悬挂事务并拒绝并发压缩;不需要任何外部锁服务。其二,摘要前后各校验一次表面稳定性:自动路径要求整个 surface 逐节点深相等(assertWholeSurfaceUnchanged),手动路径只要求被选区间本身没变(assertSelectedSpanStable)——因为手动压缩期间允许新消息落在区间之外。其三,失败也要闭合括号:catch 里补写带 error 的 compaction/end,保证"每次尝试在日志里都有完整记录",这是可审计性的底线。
🔹 影子价格协议(shadow price):每次替换前,先追加一个计量事件(compaction/summary 或 compaction/prune),记录"被影子覆盖区间的启发式 token 价格";紧接着同步追加替换体。这样纯消费者做计费时只需"减去紧邻在前的计量事件",无需维护每个节点的历史价格——把状态压进了日志顺序本身。
七、提交体与 KV cache 复用的摘要调用
commitCompactionBody 在同一个同步块里写两件事:先落 compaction/summary(计量事件,带 provider/model/rawOutput 全量溯源),再落真正的替换体——一条合成的 user message,其 surfaceOp: replace 让 replay 投影把影子区间换成这一个节点:
📄 packages/compaction/compaction-basic/src/region.ts (第 427-478 行)
function commitCompactionBody( // 追加一条完成的 summary 记录与替换体——全程不让出 session: Session, // 被修改的会话日志 startEvent: SessionEvent<'compaction/start'>, // 开标记事件——它的 seq 是结果身份的起点 summarized: SummarizedCompaction, // 已校验的摘要 + 框架化检查点消息 + 计价快照): Omit<CompactionResult, 'endSeq'> { // 返回除 endSeq 外的完整结果(end 由调用方随后追加) const { start, end, shadowedSeqs, shadowedTokenCount, summary, provider, model, maxTokens, usage, checkpointMessage } = summarized // 一次性解构事务载荷,后续字段引用更清晰 const callProvenance = summarized.llmStreamCall === true // 如果摘要确实经由本上下文的 LLM 缝发出…… ? { rawOutput: summarized.rawOutput, llmStreamCall: true as const } // …记录完整 provider 输出 + 显式标记(可重建性要求) : summarized.rawOutput === undefined ? {} : { rawOutput: summarized.rawOutput } // 未标记的摘要器(模板/远程)可选附带原始输出 const summaryEvent = session.append('compaction/summary', { // 持久化计量事件——本次替换的影子价格记录 compactionId: startEvent.data.compactionId, // 与开标记同一事务身份 ...startEvent.data.sourceCommandId === undefined ? {} : { sourceCommandId: startEvent.data.sourceCommandId }, // 手动命令关联(存在才写入) summary, // 安全投影后的纯文本摘要块 ...callProvenance, // "这条摘要是谁写的、完整输出是什么"——日志 + 代码即可重建 shadowedRange: { start, end }, // 被替换的表面位置区间(注意:先前替换后 start 可能大于 end) shadowedSeqs: [...shadowedSeqs], // 被影子覆盖节点的权威集合,按表面顺序 shadowedTokenCount, // 影子内容在计量器固定估算器下的启发式价格 provider, model, // 摘要调用的信封——"哪个模型写了这条摘要"的持久化答案 ...maxTokens === undefined ? {} : { maxTokens }, // 生成上限(若当时生效) ...usage === undefined ? {} : { usage }, // provider 报告的本次摘要请求 token 用量 }) session.append('user/message', checkpointMessage, { // 真正的表面替换:一条合成 user message 影子掉整个区间 surfaceOp: { op: 'replace', start, end }, // replay 投影丢弃影子节点、只展示这一条——上下文从此变短 sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs], // 完整溯源链:开标记 + 计量事件 + 所有被替换节点 }) return { // 用持久化事件的 seq 组装对外结果 compactionId: startEvent.data.compactionId, // 事务身份(与三个标记一致) ...startEvent.data.sourceCommandId === undefined ? {} : { sourceCommandId: startEvent.data.sourceCommandId }, // 手动命令关联透传 startSeq: startEvent.seq, // 开标记的日志位置 summarySeq: summaryEvent.seq, // 计量事件的位置(/compact 用它让 UI 跳转到新检查点) summary, // 摘要内容块本身 shadowedRange: { start, end }, // 被替换区间 shadowedSeqs: [...shadowedSeqs], // 被影子节点集合(拷贝,防外部修改) shadowedTokenCount, // 省下的估算 token 数 }}摘要本身由 summarizer.ts 完成,这里藏着整个能力域最精妙的一笔:KV cache 复用。摘要指令不是独立的 system prompt,而是作为最后一条 user message 追加在"原对话的 system + tools + 前缀消息"之后——于是这次辅助调用恰好是上一次路由请求的真前缀扩展,provider 的前缀缓存(KV cache)直接命中,不用重新编码整段历史:
📄 packages/compaction/compaction-basic/src/summarizer.ts (第 21-66、189-195 行)
const SUMMARY_OPEN_TAG = '<compacted-summary>' // 包裹结构化摘要的开标签——落在检查点节点内部const SUMMARY_CLOSE_TAG = '</compacted-summary>' // 闭标签——让后续压缩能识别"这是前一次检查点"/** The summarization directive, delivered as the FINAL user message after the replayed conversation. */const COMPACTION_INSTRUCTION = [ // 一次性提示词:把上方对话浓缩成可续作的结构化检查点 'You 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 — never drop a section.', // 严格输出契约——下游解析与后续合并都依赖结构稳定 '', '## Primary Request and Intent', // 第 1 节:用户到底要什么(措辞重要处逐字引用) "- [the user's original and evolving goals; quote verbatim where the exact wording matters]", '', '## Key Technical Concepts', // 第 2 节:在用的技术栈、框架与约定 '- [technologies, frameworks, patterns, and conventions in play]', '', '## Files and Code', // 第 3 节:精确路径 + 为什么重要(续作最关键的细节) '- [exact path: why it matters, key changes or snippets]', '', '## Errors and Fixes', // 第 4 节:踩过什么坑、怎么解的——避免接手模型重蹈覆辙 '- [error: how it was resolved, plus any related user feedback]', '', // …其余四节:Pending Jobs / Current Work / Next Step / Critical Context(完整文本见源码) 'Rules:', // 对摘要风格与保真度的硬约束 '- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.', // 精确优先于流畅——这些 token 是接手模型必须逐字拿到的 '- Capture user feedback and explicit instructions faithfully, especially corrections.', // 用户的纠正编码了真实偏好,丢了就前功尽弃 '- 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 ${SUMMARY_OPEN_TAG} 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.`, // 压缩的压缩:合并旧检查点而不是层层堆叠].join('\n') // 数组拍平成单个提示词字符串export function frameSummary(summary: readonly ContentBlock[]): ContentBlock[] { // 把原始摘要块包进持久化检查点框架 return [ { type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` }, // 前言 + 开标签合成一个引导块(前言告诉接手模型"这是背景,别复述") ...summary, // 模型的结构化章节原样落在两个标签之间 { type: 'text', text: SUMMARY_CLOSE_TAG }, // 闭标签——标记检查点正文结束 ]}调用侧 summarizeWithLlm 把"复用前缀"落实成具体的请求构造,并对失败做 fail-closed 处理:
📄 packages/compaction/compaction-basic/src/summarizer.ts (第 121-182 行)
export async function summarizeWithLlm( // 执行默认的"复用缓存"一次性摘要调用 ctx: Context, // 提供 LLM 服务缝的上下文 config: SummaryConfig, // 本次摘要解析好的 provider/model/maxTokens input: SummarizationInput, // replay 的对话前缀(system、tools、头部消息)——要浓缩的对象 agent: Agent, // 提供路由模型历史、回退模型与会话 id signal?: AbortSignal, // 可选取消信号,转发给适配器): Promise<SummaryResult> { // 返回安全纯文本摘要块 + 精确调用信封 + 原始输出 const latest = agent.session.requestHeader()?.config // 本对话最近一次持久化路由的 provider/model const configured = config.summarizationProvider.length === 0 ? undefined : { provider: config.summarizationProvider, model: config.summarizationModel } // 插件配置里的显式摘要器覆盖(空字符串视为未设置) const agentTarget = agent.options.provider !== undefined && agent.options.provider.length > 0 && agent.options.model !== undefined && agent.options.model.length > 0 ? { provider: agent.options.provider, model: agent.options.model } : undefined // Agent 级回退目标(前两者都缺时才用) const target = configured ?? latest ?? agentTarget // 优先级:显式配置 > 对话路由 > Agent 选项 if (target === undefined) throw new Error('no provider/model available for summarization: ...') // 没有模型就无法摘要——大声失败而不是静默降级 const assembler = new BlockAssembler() // 增量装配流式 chunk 为内容块(与对话路径同一套原语) const messages: Message[] = [ // 辅助请求的消息体 ...input.messages, // replay 的对话前缀——与上次路由请求的前缀逐字节一致,这是缓存命中的关键 createUserMessage({ // 压缩指令作为最后一条 user message(而不是独立 system prompt) content: [{ type: 'text', text: COMPACTION_INSTRUCTION }], // 本次调用中唯一的新颖输入 source: { kind: 'plugin', plugin: 'dsh-compaction-basic' }, // 溯源标记——日志里能认出这条指令来自压缩插件 }), ] const options: GenerateOptions = { // 完整的一次性生成信封 provider: target.provider, model: target.model, messages, // 路由 + 消息体 ...input.system === undefined ? {} : { system: input.system }, // 复用对话自己的 system prompt——前缀对齐的第一块 ...input.tools === undefined ? {} : { tools: [...input.tools] }, // 连工具 schema 也原样带上——前缀完全一致,KV cache 不失效 maxTokens: config.maxTokens, // 检查点文本的生成上限(默认 8192) sessionId: agent.session.id, // 把辅助调用与属主会话关联起来 purpose: 'compaction', // 请求打标——provider/遥测可把它和对话轮次区分开 ...signal === undefined ? {} : { signal }, // 取消信号转发进适配器 } for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk) // 流式消费一次性调用,逐块累积 const error = finishError(assembler.finish) // 把终止原因映射为 fail-closed 错误(error/aborted/max-tokens) if (error !== undefined) throw error // max-tokens 截断也拒绝——不完整的检查点比没有更糟 const rawOutput = assembler.blocks() // 投影前的完整 provider 输出(留档用) const summary = summaryText(rawOutput) // 拒绝图片输出、只保留文本块(检查点必须是纯文本) if (!summary.some(block => block.text.trim().length > 0)) throw new Error('summarization produced no text summary content') // 空摘要算失败,不算成功 return { // 与 compaction/summary 事件一起持久化的完整信封 summary, // 安全投影后的纯文本块 rawOutput, // 原始输出全量留档(可重建性) llmStreamCall: true, // 显式标记:这次调用确实走了本上下文的 LLM 缝 provider: options.provider, model: options.model, // 实际使用的路由 maxTokens: config.maxTokens, // 生效的生成上限 ...(assembler.usage === undefined ? {} : { usage: assembler.usage }), // provider 报告的用量(若发出) }}为什么这样设计:摘要调用本身要花一次完整的模型推理,如果它的请求前缀和对话不一致,provider 就得把整段历史重新编码一遍——等于为"省 token"额外烧掉一大笔 prefill。把指令做成尾部 user message、system/tools/消息全部原样复用,让这次调用成为上次路由请求的真前缀扩展:缓存命中时,摘要的边际成本只剩那条指令本身。另外 finishError 对 max-tokens 也 fail-closed——一个被截断的检查点会让接手模型基于残缺信息续作,比"这次没压成"危险得多。
八、Spill:超大输出的无损外置
换一边看 Spill。它的抽象缝 @deepseek-ai/dsh-spill 刻意做到只有一个方法——saveText:存全文、返回定位符 + 字节数 + 回读指引。保留策略归 output-retention,替换决策归 spill-policy,存储缝自己什么都不管:
📄 packages/spill/spill/src/index.ts (第 45-56 行)
export abstract class SpillStore extends Service { // 抽象 spill 存储服务——只定义"做什么",不规定"怎么做" constructor(ctx: Context) { // 注册为 ctx.spillStore;加载第二个实现会抛错(cordis 标准重复服务行为) super(ctx, 'spillStore') } /** Persist input.content to a session-scoped spill artifact. */ abstract saveText(input: SaveTextSpill): Promise<SpillRef> // 唯一方法:逐字持久化全文,返回定位符 + 精确字节数 + 面向模型的取回指引}本地后端 dsh-spill-local 的存储机制(store.ts)把安全细节做满了:私有根目录、路径穿越免疫的段编码、独占创建 + 仅属主可读:
📄 packages/spill/spill-local/src/store.ts (第 27-30、48-63、107-120 行)
export function privateRoot(): string { // 默认 spill 根目录:OS tmpdir 下的私有进程级目录 defaultRoot ??= mkdtempSync(join(tmpdir(), 'dsh-spill-')) // 惰性创建一次;mkdtemp 给不可预测后缀 + 0700 语义 return defaultRoot // 世界可读的路径会让其他本地用户读到工具输出、或预植符号链接export function encodeSegment(raw: string): string { // 把任意不可信字符串编码成单个安全路径段(对所有 UTF-16 串单射) if (raw.length === 0) return '~' // 空名编码为单个波浪号——绝不产生空文件系统段 if (raw === '.') return '~002E' // 整段 token '.' 被转义,永远无法表示"当前目录" if (raw === '..') return '~002E~002E' // 同理处理 '..'——路径穿越在进文件系统之前就被中和 let out = '' // 编码结果累加器 for (let i = 0; i < raw.length; i++) { // 遍历不可信输入的每个 UTF-16 码元 const code = raw.charCodeAt(i) // 该码元的数值身份(转义用) const ch = String.fromCharCode(code) // 字符本身,用于安全集判定 if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) { out += ch } // 安全字符原样通过;'~' 保留为转义前缀所以必须排除 else { out += '~' + code.toString(16).toUpperCase().padStart(4, '0') } // 其余一律转成 ~XXXX——可逆且不同输入绝不碰撞 } return out // 单个文件系统安全段:由调用方名字派生而来,但永不等于它export async function saveTextFile(options: SaveTextOptions): Promise<SavedText> { // 把内容写入会话作用域目录下的全新文件 const dir = sessionDir(options.root, options.sessionId) // <root>/session-<sha256(sessionId)前12位>——按产生它的会话分组存储 await mkdir(dir, { recursive: true, mode: 0o700 }) // 即使调用方选了共享根,目录也保持仅属主可访问 const safeName = encodeSegment(options.suggestedName) // 调用方建议名先消毒成单个安全段再使用(它只是提示,不是路径) const path = join(dir, `${randomBytes(6).toString('hex')}-${safeName}`) // 随机 hex 前缀:不可预测(防符号链接预植)且保持可读 const bytes = Buffer.byteLength(options.content, 'utf8') // 精确 UTF-8 字节数,回报给策略层做预算核算 const handle = await open(path, 'wx', 0o600) // 独占创建 + 仅属主读写:路径已存在(哪怕是符号链接)直接失败,预植目标无法劫持写入 try { await handle.writeFile(options.content) // 全文逐字落盘——存储层不做任何截断 } finally { await handle.close() // 无论写成功与否都释放文件描述符 } return { path, bytes } // 定位符就是这个路径;远程后端则会返回 URI/key,消费方永远不解析它}为什么这样设计:spill 文件里装的是工具吐出的原始数据——可能包含凭据、内网地址、用户隐私。所以默认根目录是 mkdtemp 出来的 0700 私有目录而不是固定路径;文件名带 6 字节随机前缀,让攻击者无法预测目标来预植符号链接;'wx' 独占创建保证"已存在即失败"。三层防御对应三类威胁:其他本地用户读、共享根里的符号链接劫持、名字注入穿越目录。
九、spill-policy:何时外置,以及"永不失败"的降级
dsh-spill-policy 是策略插件:挂在 tools/post-execute 瀑布上,当纯文本结果超过 maxInlineBytes 时触发外置。它最讲究的是预算核算——替换体(预览 + 通知行)的总字节数必须仍小于上限:
📄 packages/spill/spill-policy/src/index.ts (第 95-108、130-188 行)
function preview(text: string, budget: number): { text: string; omitted: Omitted } { // 为一条超大结果构建有界的头尾预览 const headBytes = Math.ceil(budget / 2) // 预算的一半给开头(向上取整)…… const tailBytes = Math.floor(budget / 2) // ……另一半给结尾——两端各留一半上下文 const retainer = new TextRetainer({ kind: 'headTail', headBytes, tailBytes }) // 复用共享保留原语——spill-policy 自己不实现任何预览机制 retainer.push(text) // 全文喂进按字节计量的保留器 const kept = retainer.finish() // 收尾:得到保留文本 + 精确的省略量 return { text: kept.text, omitted: kept.omittedBytes } // 策略层把两者组合成面向模型的替换内容function spillNotice(omitted: Omitted, ref: SpillRef): string { // 追加在每个预览后面的单行通知 const omission = describeOmitted(omitted, 'bytes') // 人类可读的省略量描述(如 "123456 bytes omitted") return `(${omission} Full formatted result stored at: ${ref.locator}. ${ref.retrievalHint})` // 定位符 + 后端专属取回指引——模型知道怎么把剩下的读回来 async function spillReplacement( // 外置文本并构建有界替换体;策略必须保留原文时返回 undefined text: string, totalBytes: number, sessionId: SessionId | undefined, toolName: string, callId: CallId, label: 'result' | 'dispatch', // 全文、总字节数、属主会话、来源工具/调用与标签(模型面 or 日志面) ): Promise<string | undefined> { if (sessionId === undefined) { ctx.logger.warn(...); return undefined } // 没有会话属主(直接/测试调用)——无法作用域化存储,保留内联内容 const spillStore = ctx.get('spillStore') // 动态查找后端服务…… if (!spillStore) { ctx.logger.warn(...); return undefined } // ……没加载后端就降级:告警 + 保留原文 const save: SaveTextSpill = { // 组装存储请求 owner: { sessionId }, source: { toolName, callId, label }, suggestedName: `${toolName}.txt`, content: text, // 属主、来源描述、建议名(仅提示)与全文 } let ref: SpillRef try { ref = await spillStore.saveText(save) // 全文落盘——唯一可能真正失败的步骤 } catch (error: unknown) { ctx.logger.warn(`spill-policy: saveText failed for ${toolName}: ...; keeping the inline content`) // 存储失败(权限/ENOSPC/后端宕机)绝不能让成功的工具调用变 isError return undefined // 降级:保留原始内联结果——best-effort 的底线 } const reserve = Buffer.byteLength(spillNotice({ kind: 'exact', count: totalBytes }, ref), 'utf8') + 2 // 把通知行的字节成本从预算里预留出来(+2 是 "\n\n" 连接符) const previewBudget = Math.max(0, cap - reserve) // 预览只能用剩下的预算——否则替换体可能比上限还大 const { text: previewText, omitted } = preview(text, previewBudget) // 在扣减后的预算内构建头尾预览 const notice = spillNotice(omitted, ref) // 用真实省略量生成最终通知行 const replacedText = previewText.length > 0 ? `${previewText}\n\n${notice}` : notice // 拼出完整替换体(预算耗尽时只剩通知行) if (Buffer.byteLength(replacedText, 'utf8') > cap) { ctx.logger.warn(...); return undefined } // 不变量:绝不发出超过上限的替换——通知行本身超限时宁可保留原文 return replacedText // 通过所有检查的有界替换体 }挂载点代码展示了它与工具生态的协作方式——先委托后约束,并且刻意跳过 read 工具:
📄 packages/spill/spill-policy/src/index.ts (第 190-209 行)
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => { // 挂在工具执行后的瀑布上(prepend:先于其他监听器) const decision = await next() // 先委托下游监听器(如 hook)敲定结果——我们只约束它接受下来的内容 if (decision.kind !== 'accept' || Object.hasOwn(decision, 'value') || exec.parent !== undefined || exec.name === 'read') return decision // block/值替换/嵌套调用/read 一律放行:spill 只整形被接受的纯文本顶层结果 const content = decision.content ?? result.content // 取最终生效的内容块 const text = flattenPlainText(content) // 拍平成单个 UTF-8 串;含任何非文本块则返回 undefined(策略不碰富内容) if (text === undefined) return decision // 非纯文本结果——原样放行 const totalBytes = Buffer.byteLength(text, 'utf8') // 按字节计量(与上限单位一致,避免多字节字符误判) if (totalBytes <= maxInlineBytes) return decision // 没超预算——零开销直通 const replacedText = await spillReplacement(text, totalBytes, ownerSessionId(exec), exec.name, exec.callId, 'result') // 触发外置 + 构建有界替换体(任何失败都返回 undefined) if (replacedText === undefined) return decision // 降级路径:保留原始内联结果,工具调用照常成功 const replaced: ContentBlock[] = [{ type: 'text', text: replacedText }] // 用预览 + 通知行替换原内容 return { kind: 'accept', content: replaced, ...decision.additionalContexts ? { additionalContexts: decision.additionalContexts } : {} } // 保持 accept 语义并透传附加上下文——对模型而言只是"结果变短了,且知道去哪取全文"}, { prepend: true })为什么这样设计:"跳过 read"是最容易被忽略的细节——如果 read 的结果被外置成"预览 + 路径",模型下一步很可能再 read 一次那个 spill 文件,形成 read → spill → read again 的循环。所以模型面这条臂对 read 放行(反正它本来就该读文件);而另一条挂在 tools/code-dispatch-log 上的日志面手臂则连 read 子调用也约束——因为日志副本不是模型上下文,不存在循环问题,read 恰恰是产生巨量日志的工具。两条臂共用同一个 spillReplacement,保证"模型看到的"和"日志里存的"字节级一致。
十、工具结果修剪:无模型的"头-中-尾"压缩
dsh-compaction-tool-result-pruner 是 compaction 的"廉价前哨":不调模型,直接把超预算工具结果的中间段裁掉(默认阈值 8192 字符,留头 4096 + 尾 1024)。它按 Unicode code point 切片而不是 UTF-16 码元,保证不会劈开代理对:
📄 packages/compaction/compaction-tool-result-pruner/src/index.ts (第 68-122 行)
measureContent(blocks: readonly ContentBlock[]): number { // 按 Unicode code point 计量文本内容;非文本块计零 let chars = 0 // 累计码点数 for (const block of blocks) { // 遍历工具结果的内容块 if (block.type === 'text') chars += codePointLength(block.text) // 只统计文本块的码点长度(Array.from 语义,不劈代理对) } return chars // 该结果的总"字符预算占用"pruneContent(blocks: readonly ContentBlock[]): ContentBlock[] | null { // 替换超预算文本的中间段,同时保留富块顺序;未超预算返回 null const totalChars = this.measureContent(blocks) // 先计量总量 if (totalChars <= this.config.thresholdChars) return null // 在预算内——零改动直通 const removedStart = this.config.headChars // 删除区间的起点(保留头之后的位置) const removedEnd = totalChars - this.config.tailChars // 删除区间的终点(保留尾之前的位置) const pruned: ContentBlock[] = [] // 修剪后的内容块序列 let consumed = 0 // 已消费的码点偏移(跨文本块的连续坐标系) let markerInserted = false // 剪枝标记是否已插入(全文只插一次) for (const block of blocks) { // 逐块处理,保持非文本块原序 if (block.type !== 'text') { pruned.push(block); continue } // 富块(图片等)不参与字符预算,原样保留 const points = Array.from(block.text) // 按 code point 展开——切片边界不会劈开代理对 const blockStart = consumed // 该文本块在全局坐标系中的起点 const blockEnd = blockStart + points.length // ……以及终点 const headEnd = Math.min(points.length, Math.max(0, removedStart - blockStart)) // 本块内保留头的结束位置(越界钳制) const tailStart = Math.min(points.length, Math.max(0, removedEnd - blockStart)) // 本块内保留尾的起始位置(越界钳制) const intersectsRemoved = blockStart < removedEnd && blockEnd > removedStart // 该块是否与删除区间相交 const marker = intersectsRemoved && !markerInserted ? PRUNE_MARKER : '' // 第一个相交的块插入 "[... tool result middle pruned ...]" 标记 if (marker.length > 0) markerInserted = true // 标记只出现一次——后续相交块不再重复 const text = points.slice(0, headEnd).join('') + marker + points.slice(tailStart).join('') // 头 + 标记 + 尾拼成本块的替换文本 if (text.length > 0) pruned.push({ ...block, text }) // 空块丢弃,非空块保留原元数据只换文本 consumed = blockEnd // 推进全局偏移 if (!markerInserted) throw new Error('tool-result prune: failed to locate the removed text span') // 理论上不可达——超阈值必然存在被删区间;防御性兜底 const charsAfter = this.measureContent(pruned) // 修剪后的总量 if (charsAfter > this.config.thresholdChars || charsAfter >= totalChars) throw new Error('tool-result prune: replacement must be smaller and within threshold') // 双不变量:必须更小、必须在阈值内——否则宁可失败也不产出"越修越大"的结果 return pruned // 通过校验的修剪内容}pruneSession 把修剪落到会话表面时,同样遵守影子价格协议——先写计量事件、再同步追加替换体:
📄 packages/compaction/compaction-tool-result-pruner/src/index.ts (第 159-173 行)
session.append('compaction/prune', { // 持久化计量事件:为即将被替换的节点记录影子价格 shadowedRange: { start: seq, end: seq }, // 单节点区间(表面位置对) shadowedSeqs: [seq], // 被影子覆盖的那一个节点,按表面顺序 shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(event.data.message), // 计量器固定估算器下的启发式价格——纯消费者直接减去即可,无需逐节点状态})const replacement = session.append('tool/result', { ...event.data, message }, { // 有界替换体紧随其价格记录同步落地(协议要求紧邻) surfaceOp: { op: 'replace', start: seq, end: seq }, // replay 投影在同一位置用新节点换掉旧节点 sourceEventSeqs: [seq], // 溯源:这条替换顶替的是哪个原始事件})为什么这样设计:prune 与 spill 的分工是"上下文内 vs 上下文外":prune 直接改写会话表面(replay 时模型看到的就是修剪版,原文在日志里仍可恢复——替换体引用了源事件),适合"中间一大段废话、头尾有用"的工具输出;spill 则把全文搬出上下文、只留指针,适合"每一部分都可能被问到"的大文件。两者都遵守同一个影子价格协议,所以计费逻辑对两种缩减一视同仁。
十一、手动入口:/compact 命令与空闲串行化
/compact 命令(dsh-command-compact)是用户主动触发压缩的入口。它要求 Agent 空闲——通过 runMaintenance 把整个操作串行化在驱动轮次之外,期间新输入按 FIFO 排队等待:
📄 packages/compaction/compaction-basic/src/index.ts (第 368-420 行)
override compactNow( // 强制一次低于压力阈值的手动压缩,且只在标记对持久化检查点后才 resolve agent: Agent, signal: AbortSignal, sourceCommandId?: CommandId, // 空闲 Agent、本次请求的取消信号、发起命令身份): Promise<CompactionResult | null> { // 返回提交结果;没有安全区间时返回 null signal.throwIfAborted() // 入口即检查——已取消的请求不做任何工作 try { return agent.runMaintenance(async (agentSignal) => { // 把操作注册为"仅空闲时执行"的维护任务:Agent 活跃则同步抛错(→ busy) const operationSignal = AbortSignal.any([agentSignal, signal]) // 合并两个取消源:Agent 侧取消 + 调用方取消,任一生效即中止 try { operationSignal.throwIfAborted() // 拿到维护槽位后再查一次——排队期间可能已被取消 const range = selectCompactableRange(agent.session, this.ctx.tokenMeter.measure(agent.session), 0) // retainTokens=0:手动压缩"能压尽压",不保留尾巴 if (range === null) return null // 没有安全区间——干净的 no-op return await compactSurfaceRegion( // 执行完整事务(owner=null → 独立括号 + selected-span 稳定性) this.regionDependencies(), agent.session, range.start, range.end, agent, { // 绑定单例计量器与动态分派的摘要钩子 owner: null, stability: 'selected-span', // 手动路径:允许区间外新增消息,只要求被选区间本身稳定 ...sourceCommandId === undefined ? {} : { sourceCommandId }, // 命令身份写入三个标记事件(存在才写) flush: async () => { await this.ctx.sessions.flush(agent.session) } // 持久化检查点:闭合括号对必须落盘才算成功 }, operationSignal, ) } catch (error: unknown) { if (agentSignal.aborted && operationSignal.reason === agentSignal.reason) throw new ManualCompactionError('cancelled', 'manual compaction was cancelled', { cause: error }) // 取消源于 Agent 侧——归类为 cancelled(而非普通错误) operationSignal.throwIfAborted() // 调用方取消——原样抛出其精确原因 throw error // 其他失败继续向上传播,由外层分类 } }) } catch (error: unknown) { throw new ManualCompactionError('busy', 'manual compaction requires an idle agent with no waking queued work', { cause: error }) // runMaintenance 同步抛错 = Agent 正忙——统一归类为 busy }}命令层把六种稳定错误码翻译成人类可读的短消息,并管理并发调用的生命周期:
📄 packages/compaction/command-compact/src/index.ts (第 58-78、96-105 行)
async function executeCompact(ctx: Context, invocation: CommandInvocation): Promise<CommandResult> { // 执行一次无参数的手动压缩请求 if (invocation.rawInput.trim().length > 0) return { kind: 'error', text: USAGE } // /compact 不接受任何参数——带参即用法错误 try { const result = await ctx.compaction.compactNow(invocation.agent, invocation.signal, invocation.commandId) // 通过稳定缝委托给后端(空闲串行化由 runMaintenance 保证) if (result === null) return { kind: 'success', text: 'No compactable history yet.' } // 没有可压历史——干净的 no-op,不是错误 return { // 报告省了多少、检查点落在哪 kind: 'success', text: `Compacted ${result.shadowedSeqs.length} history items (~${result.shadowedTokenCount} tokens).`, sourceEventSeq: result.summarySeq, // summary 事件 seq 让 UI 能直接跳转到新检查点 } } catch (error: unknown) { if (invocation.signal.aborted) return { kind: 'error', text: 'Compaction cancelled.' } // 调用方主动中止——报告为取消而非失败 if (error instanceof ManualCompactionError) return expectedFailure(error) // 六种稳定错误码各自映射成简洁的人类可读结果(busy/cancelled/changed/summary/commit/persistence) throw error // 非预期错误继续上抛——那是 bug,不是可预期的运行状态 }}ctx.effect(function* () { // 插件生命周期:注册命令 + 卸载时排空在途调用 yield async () => { await Promise.allSettled(active) } // 先挂"排空钩子"——复合拆除是 LIFO,保证已启动的 handler promise 安静结束期间没有新调用进入 yield ctx.commands.register({ name: 'compact', description: 'Compact older conversation history', handler }) // 再注册 /compact(handler 内部把每次调用记入 active 集合并在结束时移除)}, 'command-compact lifecycle')为什么这样设计:expectedFailure 的六条文案值得细读——每条都明确告诉用户"对话是否被改动、该不该重试"(比如 changed 说 "The conversation is unchanged; the attempt is recorded in the session log")。这是面向模型的契约与面向人的契约分离:日志里是精确的错误码和事件链,人看到的是可行动的一句话。而 LIFO 排空钩子解决的是插件卸载竞态:先挂的清理后执行,确保拆除期间不会有新的 /compact 调用溜进来。
十二、默认配置速查
compaction-basic 的出厂默认值(config.ts 第 19-23、74-96 行):
pruner 的默认值(compaction-tool-result-pruner/src/config.ts):阈值 8192 字符、保留头 4096 + 尾 1024,剪枝标记为 [... tool result middle pruned ...]。spill-policy 的 maxInlineBytes 无默认值——不配置就是完全 no-op(插件什么都不注册),这是"opt-in 才生效"的典型设计。
十三、三种机制横向对比
把本讲拆过的三条缩减路径放在一起看(每行一张卡片):
摘要压缩(compaction-basic)
触发条件 请求包 ≥ 窗口×0.8(压力);或 provider 确认 context window exceeded(溢出恢复,绕过阈值);或 /compact 手动
成本 一次完整模型推理(KV cache 命中时只剩指令的边际成本);有界重试
替换内容 头部一段历史 → 一个带 <compacted-summary> 框架的检查点 user message(surfaceOp: replace)
失败行为 自动路径:告警不阻塞对话;手动路径:六类稳定错误码 + 日志留痕;摘要必须严格小于被影子内容,否则拒绝提交
工具结果修剪(tool-result-pruner)
触发条件 单条工具结果文本 > 8192 字符(默认);在压力/溢出压缩之前先行执行
成本 零模型调用,纯字符串操作(按 code point 切片)
替换内容 结果中间段 → "[... tool result middle pruned ...]",保留头 4096 + 尾 1024;原文经 sourceEventSeqs 可从日志恢复
失败行为 替换体必须更小且在阈值内,否则抛错;先写 compaction/prune 影子价格事件再同步落地替换
输出外置(spill-policy + spill-local)
触发条件 纯文本工具结果 > maxInlineBytes(未配置则完全不生效);模型面跳过 read,日志面对 run_code 子调用同样约束
成本 一次本地文件写入(wx + 0600);无模型调用
替换内容 全文 → 头尾预览 + "(N bytes omitted, stored at: <path>. Use read with offset/limit...)";通知行字节成本从预算内预留,替换体永不超上限
失败行为 best-effort:无会话/无后端/存储失败一律保留原文——spill 失败绝不把成功的工具调用变成 isError
十四、数据流:一次自动压缩的完整生命周期
把前面各节的碎片串起来,压力触发的自动压缩走的是这条路径:
自动压缩路径(pressure / context-overflow)
① 触发:agent/pre-step 或 agent/request-error
步骤边界做压力检查;provider 报 context window exceeded 时走溢出恢复(带重试预算)。
▼
② 测量:tokenMeter.measure(session)
单例计量器逐节点计价;压力路径要求 totalTokens ≥ contextWindow × thresholdRatio(默认 0.8)。
▼
③ 无模型修剪:toolResultPruner.pruneSession
超大工具结果先被裁成头尾 + 剪枝标记;每处替换前写 compaction/prune 影子价格事件。
▼
④ 再测量 + 选区间:selectCompactableRange
修剪后仍超阈值才继续;保留最近 retainTokens 尾巴,切割点必须落在工具配对平衡边界。
▼
⑤ 事务:start 锁 → KV cache 摘要 → 稳定性校验
compaction/start 落盘即持锁;replay system+tools+前缀消息,指令作尾部 user message 命中缓存;摘要必须严格小于影子内容。
▼
⑥ 提交:summary → user/message(replace) → end
三个事件同步落地不再让出;compaction/end 释放锁。失败则补写带 error 的 end,尝试全程可审计。
🔹 溢出恢复分支:compactIfNeeded 抛错但 replaceGeneration 已递增 → 视为部分成功,消耗一格预算后 { kind: 'retry' }🔹 手动 /compact 分支:runMaintenance 独占空闲 Agent + flush 持久化检查点,失败映射为六类稳定错误码
超大工具结果的外置路径则短得多:
Spill 路径(tools/post-execute)
① 委托:next() 先让下游监听器敲定结果
prepend 挂载 + 先委托后约束——hook 替换过的内容同样会被有界化。
▼
② 判定:纯文本 + 字节数 > maxInlineBytes
含非文本块、嵌套调用、read 工具一律放行;未超预算零开销直通。
▼
③ 落盘:spillStore.saveText → session-
随机前缀文件名 + wx 独占创建 + 0600;全文逐字保存,返回定位符与字节数。
▼
④ 有界替换:预留通知字节 → 头尾预览 + 定位符
替换体总字节数保证 ≤ maxInlineBytes;任何环节失败都保留原文,工具调用照常成功。
📚 系列导航
← 第 39 讲:Hook 桥接(Claude Code/Codex)
→ 第 41 讲:Host 层与 Runtime Diagnostics
关注公众号「AI技术推荐官」获取更多源码解析内容