一次长会话迟早会撑爆模型的上下文窗口------
dsh对这个问题的处理分成"主动"和"被动"两条腿:agent/pre-step钩子在每一步开始前检测 token 压力,提前把旧历史折叠成摘要;agent/request-error钩子在模型真的报出"上下文超限"之后做一次带重试的溢出恢复。两条腿共享同一套"剪枝 → 选区 → 摘要 → 替换"的事务流程,而且都建立在同一个前提之上------session-checkpoint-policy保证了"发模型请求前""执行工具前"这两个会产生真实副作用的时刻,会话日志一定已经落盘。本篇把这两套机制拆开看:压缩为什么要分两个触发点、每一步具体做了什么、以及"发请求/执行工具前必须先 flush"这件事为什么是 fail-closed 而不是普通的定时 autosave。
学习目标
- 理解压缩的两个触发点------
agent/pre-step的主动压力检测和agent/request-error的被动溢出恢复------分别在什么条件下被激活,以及为什么被动路径要绕过正常的阈值和保留尾部策略。 - 通读
BasicCompactionEngine.compactIfNeeded(),理解"剪枝(prune)→ 选区(selectCompactableRange)→ 摘要(summarizeWithLlm)→ 替换(compactSurfaceRegion)"四个阶段各自的职责,以及为什么要先剪枝再选区。 - 理解
compactSurfaceRegion如何用compaction/start/compaction/end这一对标记事件把整个压缩过程做成一个可检测、可从崩溃中恢复的事务。 - 理解
session-checkpoint-policy在哪三个副作用边界(模型请求、工具执行、下一次 pre-step)上强制flush,以及为什么这是 fail-closed(校验失败就不发起副作用)而不是"先做后补记"。 - 理解
SessionWriteBehind的写批量策略(默认 200ms 窗口)如何在"不阻塞热路径"和"checkpoint 必须可靠"这两个目标之间做取舍。
背景与设计动机
上下文压缩要解决的问题很直观:会话历史越长,占用的 token 越多,总有一刻会撑爆模型的上下文窗口甚至让每次请求都变贵变慢。但把"什么时候压缩、压缩什么、压缩失败怎么办"这几个问题认真想一遍,会发现朴素的"到了阈值就自动摘要"策略有明显漏洞:
- 阈值判断本身可能就是错的:如果 token 计数方式和 provider 的真实计费/限制口径不一致,可能出现"本地觉得还没到阈值,但 provider 已经拒绝了这次请求"的情况------这种情况不能只靠事先的阈值预测来兜底,必须有一条"收到 provider 明确报错之后立刻补救"的恢复路径。
- 压缩本身是一次异步、可能失败的操作:摘要请求要发给模型,发请求就可能超时、可能被取消、可能中途会话又发生了变化(比如摘要还没写完,同一段历史又被别的什么操作动过)。如果压缩过程中途崩溃,日志里必须能明确判断"这次压缩到底有没有成功",不能留下模糊状态。
- 副作用(发模型请求、执行工具)一旦发生就不可逆:如果会话记录还没有落盘就已经把请求发给了模型,一旦这时候进程崩溃,重启后的会话历史就会和"模型实际看到过什么"产生分歧------这类分歧一旦发生,是无法事后修复的。
dsh 用两个独立但复用同一套底层机制的钩子应对第一个问题;用"compaction/start ... compaction/end 标记对 + 每一步失败都补记一次收尾"应对第二个问题;用一套三处强制 flush 的 checkpoint 策略应对第三个问题。三者共同的底线都是同一句话:宁可拒绝执行一次副作用,也不能让日志和真实发生的事情脱节。
核心机制详解
触发点一:agent/pre-step 的主动压力检测
packages/compaction/compaction-basic/src/index.ts 的 BasicCompactionEngine 在构造时,如果 config.auto 为真,会挂上两个监听器。第一个挂在 agent/pre-step------也就是第一篇讲过的、preStep() 里那个决定"这一步该不该进入模型调用"的 waterfall 钩子:
// packages/compaction/compaction-basic/src/index.ts
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)
}
const message = error instanceof Error ? error.message : String(error)
ctx.logger.warn(`step compaction failed: ${message}; continuing the turn`)
}
}
return next()
})
这里有一个刻意的容错策略:无论 compactIfNeeded 是成功、失败还是配置缺失,最后都会 return next() 放行这一步继续走下去------压缩失败不应该阻塞正常对话 。唯一的特殊处理是 TargetPressureConfigError(比如某个 provider/model 组合没有配置 contextWindow,导致压力判断根本算不出来)只会打印一次告警而不是每一步都刷屏。
compactIfNeeded() 在 'pressure' 这个触发场景下的完整判断逻辑:
// packages/compaction/compaction-basic/src/index.ts(节选,pressure 分支)
const info = await this.ctx.llm.resolveModelInfo(target.provider, target.model, signal)
if (info.context === undefined) throw new TargetPressureConfigError(/* ... */)
const spec = resolveCompactSpec(policy, info.context.contextWindow, reservedCompletionTokens(agent, info.defaultMaxTokens))
if (measurement.totalTokens < spec.thresholdTokens) return null
if (prune !== undefined) {
prune.pruneSession(agent.session)
measurement = meter.measure(agent.session)
}
if (measurement.totalTokens < spec.thresholdTokens) return null
let result: CompactionResult | null = null
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 (...)`,
)
流程是:先按当前路由的模型 contextWindow 算出一个阈值(resolveCompactSpec,注意它还会用 reservedCompletionTokens 给模型的输出预留空间),如果当前估算的 token 数还没到阈值,直接返回 null(什么都不做);模型信息里根本没有 context(没配 contextWindow)时直接抛 TargetPressureConfigError------也就是上一节那个"只告警一次"的容错分支要接住的错误。到了阈值之后,先尝试一次不需要调用模型的剪枝 (prune.pruneSession,由可选的 toolResultPruner 服务提供,专门清理旧的工具结果,详见下一节),剪枝完重新测量一次,如果这样就已经降到阈值以下,压缩到此为止,连摘要请求都不用发。只有剪枝仍然不够,才真的进入"选区 → 摘要 → 替换"的循环,而且这个循环有 compactionRetries 次重试上限------因为一次摘要可能因为原文太长导致摘要本身也超出预期,这时会再选一段范围重新压缩,直到测量结果降到阈值以下或者重试次数耗尽;耗尽仍未达标时会抛出错误 ,由前面 agent/pre-step 监听器里的 catch 降级为一条警告日志后继续放行对话,压缩失败不阻塞正常交互。
触发点二:agent/request-error 的被动溢出恢复
第二个监听器挂在 agent/request-error------第一篇 step() 里"模型请求失败后决定要不要重试"的那个 waterfall 钩子(下一篇会讲重试插件 llm-retry 也挂在同一个钩子上,两者互不冲突,谁先注册谁先跑):
// packages/compaction/compaction-basic/src/index.ts
ctx.on('agent/request-error', async ({ agent, failure, signal }, next) => {
if (failure.code !== CONTEXT_WINDOW_EXCEEDED_CODE || signal.aborted) return next()
this.overflowAgents.set(agent.session, agent)
const target = routedTarget(agent.session)
if (target === undefined) return next()
const policy = resolveTargetPolicy(this.config, target)
const retries = this.overflowRetries.get(agent) ?? 0
if (retries >= policy.maxOverflowRetries) 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) {
this.overflowRetries.set(agent, retries + 1)
return { kind: 'retry' }
}
return next()
}
if (signal.aborted || agent.session.surface.replaceGeneration <= generation) return next()
this.overflowRetries.set(agent, retries + 1)
return { kind: 'retry' }
})
只有当 failure.code === CONTEXT_WINDOW_EXCEEDED_CODE(第三篇讲过的,provider 明确报出"上下文超限"这一稳定错误码)才会介入,否则直接 next() 交给别的监听器(比如普通的网络重试)处理。判断"这次恢复算不算成功"用的是 agent.session.surface.replaceGeneration------第二篇讲过,这个计数器只有在真的发生过 replace 写入时才会增加------如果压缩过程本身抛了异常,但 replaceGeneration 已经比开始前大了,说明"至少有一次模型免费的剪枝已经落地生效",这个部分成果足够支撑一次重试("a model-free prune can land before later summary work fails. That durable reduction is sufficient retry proof"),不需要因为后续摘要阶段失败就把已经生效的剪枝也一并放弃。返回 { kind: 'retry' } 会让 step() 里那个 while (true) 循环重新走一遍 buildRequest()------用刚刚压缩过的、更短的历史重新发一次请求。
compactIfNeeded() 在 'context-overflow' 这个触发场景下的逻辑明显更激进,直接跳过阈值判断:
// packages/compaction/compaction-basic/src/index.ts(节选,context-overflow 分支)
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)
}
selectCompactableRange 的第三个参数 retainTokens 直接传 0------意味着"不刻意为最近若干 token 的对话留后手,能压多少就压多少"。因为这时候已经是"provider 已经明确拒绝了这次请求"的紧急状态,目标是尽快把请求压到能被接受的大小,而不是像主动压力检测那样细水长流地保留最近的对话尾部。
压缩四阶段:剪枝 → 选区 → 摘要 → 替换
剪枝(prune) 由独立的 packages/compaction/compaction-tool-result-pruner 包提供,通过 this.ctx.get('toolResultPruner') 可选地接入------它的职责严格限定为"只删旧的工具结果,不动对话本体",因为工具结果(比如一次 bash 输出的完整日志)往往是历史里最占 token、又最不需要被模型反复重新阅读的部分。
**选区(selectCompactableRange)**是一个纯函数,负责决定"到底压缩哪一段":
// packages/compaction/compaction-basic/src/region.ts(节选,省略校验与 oxlint 注释)
export function selectCompactableRange(
session: Session, measurement: TokenMeasurement, retainTokens: number,
): { start: SessionSeq; end: SessionSeq } | null {
const pricedNodes = measurement.nodes
if (pricedNodes.length === 0) return null
const surfaceNodes = session.surface.nodes
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')
}
const firstIdx = systemHead(session, surfaceNodes[0]!) === undefined ? 0 : 1
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 <= firstIdx) return null
while (keepFromIdx > firstIdx) {
if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break
keepFromIdx -= 1
}
if (keepFromIdx <= firstIdx) return null
const first = surfaceNodes[firstIdx]!
const cutoff = surfaceNodes[keepFromIdx - 1]!
return { start: first, end: cutoff }
}
策略是"从尾部往前累计,凑够 retainTokens 就停",定下一个初步的切分点后,再往前微调到最近一个"不会切断一次工具调用/结果配对"的边界(toolPairingBalancedBefore)。入口处还多了两道硬校验:token 测量结果和当前 surface 不一致(比如测量是异步缓存的、期间发生了压缩)直接抛错,绝不在错误的前提上选区;切分下界从 0 改成 firstIdx------如果 surface 节点 0 是 system/message 系统头部,就绝不把它卷进压缩范围(系统提示词通常要作为 provider 侧 KV 缓存的锚点留在头部)。绝不能让压缩把一次 tool/call 和它对应的 tool/result 从中间切开,否则派生出的历史会出现一次没有结果的裸调用,模型看到会困惑,更严重的是会破坏 provider 侧对"assistant 消息紧跟工具结果"这类格式的强约束。
摘要(summarizeWithLlm) 是唯一需要调用模型的一步。buildSummarizationInput()(region.ts)专门负责复用会话自己的系统提示词和工具 schema:
// packages/compaction/compaction-basic/src/region.ts(节选,省略注释)
function buildSummarizationInput(session: Session, shadowedSeqs: readonly SessionSeq[]): SummarizationInput {
const header = session.requestHeader()
const head = systemHead(session, session.surface.nodes[0]!)
const system = head === undefined ? null : session.deriveEventMessage(head)
const regionMessages = shadowedSeqs
.map(seq => session.deriveEventMessage(session.eventAt(seq)!))
.filter((message): message is Message => message !== null)
return {
...header?.tools === undefined ? {} : { tools: header.tools },
messages: system === null ? regionMessages : [system, ...regionMessages],
}
}
一个值得留意的演进:系统提示词不再从 request/header 里取(EpochHeader 已经不再携带 system 字段------第二篇说过,系统提示词本身就是派生历史,住在 surface 节点 0 的 system/message 事件里),而是直接从 surface 头部那条 system/message 投影出来;工具 schema 仍然复用 header 里的。复用同一份系统提示词和工具前缀不是随意的选择------它让摘要请求在 provider 侧看起来是"同一个对话的自然延续",能够复用 provider 端的 KV 缓存(prompt caching),而不是每次摘要都要重新处理一遍完整的系统提示词。摘要生成后还有一道安全检查:
// packages/compaction/compaction-basic/src/region.ts
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
if (framedSummaryTokenCount >= prepared.shadowedTokenCount) {
throw new Error(`summary is not smaller than the shadowed content (...)`)
}
如果生成的摘要本身比被压缩的原文还长(模型偶尔会这样),整个压缩事务直接判定失败------压缩存在的意义就是"变小",一个不变小的压缩没有任何价值,不应该被接受。
替换(commitCompactionBody) 把摘要结果落回日志,用的正是第二篇讲过的 { op: 'replace' } Surface 写入原语:
// packages/compaction/compaction-basic/src/region.ts
const summaryEvent = session.append('compaction/summary', { /* ... */ })
session.append('user/message', checkpointMessage, {
surfaceOp: { op: 'replace', startSeq: start, endSeq: end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
compaction/start ... compaction/end:把压缩做成可检测的事务
四个阶段被 compactSurfaceRegion() 整体包在一对标记事件之间:
// packages/compaction/compaction-basic/src/region.ts(节选)
const startEvent = session.append('compaction/start', lifecycle)
try {
const prepared = prepareCompaction(dependencies, session, selection)
const summarized = await summarizeCompaction(dependencies, prepared, agent, compactionId, sourceCommandId, signal)
assertStable(dependencies, session, summarized)
const pending = commitCompactionBody(session, startEvent, summarized)
const endEvent = session.append('compaction/end', lifecycle)
result = completeCompaction(pending, endEvent)
} catch (error: unknown) {
failure = { error, stage: closing ? 'commit' : stage }
if (!closing) {
try {
session.append('compaction/end', { ...lifecycle, error: errorChain(error) })
closed = true
} catch (closeError: unknown) {
failure = { error: closeError, stage: 'commit' }
}
}
}
无论摘要阶段成功还是失败,compaction/end 几乎总会被写下(成功时携带正常结果,失败时携带 error 字段)------这保证了日志上"一个 compaction/start 后面一定跟着一个 compaction/end",一旦扫描日志发现有 compaction/start 没有匹配的 compaction/end,就能明确判定"这是一次真正被进程崩溃打断的压缩",而不是"逻辑上失败但优雅收尾的压缩"。assertStable() 还会在摘要生成完成后重新核对一遍"这段要被替换的历史,在异步等待摘要期间有没有被别的操作改动过"(assertWholeSurfaceUnchanged/assertSelectedSpanStable 两种稳定性校验,取决于是自动压缩还是手动 compactNow())------一旦发现变了,直接拒绝这次替换,而不是盲目地把摘要塞进一个可能已经不对应的位置。
session-checkpoint-policy:三处 fail-closed 的强制 flush
压缩本身依赖一个更底层的保证:任何"会产生真实副作用"的操作,发生之前会话状态必须已经落盘。这套语义由 packages/session/session-checkpoint-policy/src/index.ts 完整实现,一共挂了三个监听器:
// packages/session/session-checkpoint-policy/src/index.ts
export function apply(ctx: Context): void {
ctx.on('llm/stream', (options, next): AsyncIterable<StreamChunk> => {
if (options.sessionId === undefined) return next()
const session = ctx.sessions.get(options.sessionId)
return session === undefined ? next() : afterCheckpoint(ctx, session, next)
})
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
if (exec.agent === undefined || exec.parent !== undefined) return next()
await ctx.sessions.flush(exec.agent.session)
if (exec.signal.aborted) return abortedBeforeDispatchResult()
return next()
})
ctx.on('agent/pre-step', async ({ agent }, next): Promise<PreStepDecision> => {
await ctx.sessions.flush(agent.session)
return next()
})
}
三处分别对应第三篇讲过的 'llm/stream' waterfall(发起模型请求之前)、顶层工具执行的 'tools/execute' 钩子(真正跑工具体之前,注意 exec.parent !== undefined 时会跳过------嵌套的子调用复用外层已经落盘的调用记录,不需要重复 flush)、以及第一篇讲过的 'agent/pre-step'(在组装下一步请求之前,先把上一步已经提交的内容落盘)。三处的共同结构都是先 await flush,flush 失败就直接向上抛异常,next() 根本不会被调用 ------这就是"fail-closed"的具体含义:checkpoint 校验失败,下游的模型调用或者工具执行根本不会发生,而不是"先执行,再想办法补记一条日志"。这个顺序不能颠倒:一旦真的先发了请求、执行了工具,产生的副作用就是不可逆的,事后补记的日志已经无法改变这个事实。
值得留意的是,这套策略并没有让"每一步都要等一次磁盘 I/O",因为它调用的是同一个 flush(),而这个 flush 具体做了什么、代价有多大,取决于下一节的写批量策略------语义上的"必须落盘"和实现上的"批量写入优化"是两个独立的层次。
批量写入而不是逐事件同步落盘
2026-09 更新 :这一层的包结构和类名相比早期版本变化不小------
packages/session/session-persistence现在只保留一份存储契约 (handle.ts/storage-contract.ts/errors.ts/revision.ts:定义SessionHandle接口、约定错误类型、版本校验,不含具体实现),真正的批量写入逻辑挪到了一个具体的后端实现包packages/session/session-persistence-jsonl里,直接作为实现SessionHandle的类(JsonlSessionHandle,storage.ts)上的方法,早期版本里独立的PersistenceCoordinator/SessionWriteBehind这两个类已经不存在了。这是"接口与实现分离"的一次重构,为将来接入非 JSONL 的持久化后端留出了空间,但批量写入的核心机制(缓冲 + 定时器窗口 + 显式 flush 提前结清)完全没变。
ctx.sessions.flush() 最终落到 JsonlSessionHandle(packages/session/session-persistence-jsonl/src/storage.ts)实例的 enqueueLive()/drainLive() 方法上。日常追加事件走的是"攒一批再写"的路径:
// packages/session/session-persistence-jsonl/src/storage.ts(节选)
enqueueLive(event: SessionEvent, reportBackgroundFailure: (error: unknown) => void): void {
this.buffered.push(structuredClone(event))
if (this.batchTimer !== undefined || this.drainPaused) return
this.batchTimer = setTimeout(() => {
this.batchTimer = undefined
this.drainLive().catch(reportBackgroundFailure)
}, LIVE_WRITE_BATCH_MAX_DELAY_MS)
}
drainLive(): Promise<void> {
return this.draining ??= this.drainBuffered().finally(() => { this.draining = undefined })
}
// packages/session/session-persistence-jsonl/src/storage.ts
export const LIVE_WRITE_BATCH_MAX_DELAY_MS = 200
默认的批量窗口依然是 200 毫秒(常量改了名字,数值没变)------enqueueLive() 只在没有定时器在跑、且没有被暂停(drainPaused)时才会启动一个新的定时器,窗口内到达的所有事件会被合并成一次真正的持久化写入(drainLive() 用 this.draining ??= ... 这个惯用法保证并发调用只会真正触发一次排空,其余调用者共享同一个 promise)。flush() 则是明确要求"现在立刻结清,不要等定时器":
// packages/core/session/src/index.ts(SessionStore.flush,节选)
async flush(session: Session): Promise<boolean> {
const { carrier } = this.liveEntryFor(session)
const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
const results = await Promise.allSettled(callbacks.map(callback => callback(session)))
const failure = results.find((r): r is PromiseRejectedResult => r.status === 'rejected')
if (failure !== undefined) throw failure.reason
return callbacks.length > 0
}
2026-09 更新 :
ctx.sessions.flush(session)的实现就是上面这段(packages/core/session/src/index.ts的SessionStore.flush,早期版本这段代码在session-persistence包的write-behind.ts里,该文件已随"接口与实现分离"重构消失)------向所有注册了'session/flush'的监听器并行 派发一次调用、Promise.allSettled等它们全部结束、只要有一个失败就把第一个失败原因抛出去:这是一个"多个持久化后端都可以挂一个 durability 监听器"的 fan-out 设计,取代了更早版本里"直接调用某个具体PersistenceCoordinator实例的flush()"这种点对点调用。session-persistence-jsonl后端正是通过ctx.on('session/flush', session => writer.flush())注册自己的监听器,内部再去调用上一节的drainLive()把缓冲的事件同步排空。
不管挂了几个监听器,语义都没变:调用 ctx.sessions.flush() 拿到的 Promise resolve,意味着此刻为止追加的所有事件都已经真正落盘(对每一个参与的持久化后端都成立),才安全地继续往下走------这正是 session-checkpoint-policy 三处调用所依赖的行为。这套设计让"绝大多数普通事件追加"走一条便宜的、批量摊销的路径,只有真正需要 durability 保证的那几个关键时刻才付出"立刻写入"的代价------语义上的可靠性从不打折,但性能代价被精确限制在必须付出的地方。
常见问题/易踩坑
- 主动压缩失败不会中断对话,被动溢出恢复失败会 :
agent/pre-step上的压力检测把任何异常都catch掉、打个警告后继续放行;agent/request-error上的溢出恢复如果确实没有任何进展(replaceGeneration没变化),会return next()把原始的"上下文超限"错误原样交给外层处理------不会无休止地重试一个注定失败的恢复。 selectCompactableRange绝不会切断一次工具调用/结果配对 ------如果你的自定义压缩策略需要选择别的切分范围,一定要复用toolPairingBalancedBefore/toolPairingBalancedAfter这类校验,而不是自己按 token 数硬切。ctx.sessions.flush()的调用方永远应该假设它可能失败并向上传播 ------三处 checkpoint 监听器都没有catch这个调用,这是有意为之:flush 失败就应该让整个请求/工具执行失败,而不是吞掉错误继续往下走。