compactConversation 逐行源码分析
文件: src/services/compact/compact.ts:387-763
概述
compactConversation 是 Claude Code 中传统对话压缩的核心函数。当对话上下文超过 token 阈值时,它调用 LLM(通常是 Haiku)生成摘要,用摘要 + 附件替换旧消息,释放上下文窗口空间。
函数签名(387-394)
export async function compactConversation(
messages: Message[],
context: ToolUseContext,
cacheSafeParams: CacheSafeParams,
suppressFollowUpQuestions: boolean,
customInstructions?: string,
isAutoCompact: boolean = false,
recompactionInfo?: RecompactionInfo,
): Promise<CompactionResult>
返回值 CompactionResult 包含:
boundaryMarker — 边界标记消息
summaryMessages — 摘要消息(对用户可见为 compact summary)
attachments — 附件(文件、代理信息、plan 等)
hookResults — session start hook 产出的消息
userDisplayMessage — 显示给用户的通知(结合 pre/post hooks)
preCompactTokenCount — 压缩前 token 数
postCompactTokenCount — 压缩 API 调用的 token 消耗
truePostCompactTokenCount — 压缩后上下文实际大小
compactionUsage — API usage 指标
逐行分析
前置校验(396-398)
try {
if (messages.length === 0) {
throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)
}
| 行号 | 说明 |
|---|
| 396 | 整段 try-catch-finally |
| 397-398 | 空消息直接抛出"消息不足"异常,避免后续流程处理空数组 |
预统计(401-404)
const preCompactTokenCount = tokenCountWithEstimation(messages)
const appState = context.getAppState()
void logPermissionContextForAnts(appState.toolPermissionContext, 'summary')
| 行号 | 说明 |
|---|
| 401 | 估算当前消息总 token 数,用于日志和边界标记 |
| 403-404 | 获取 appState 用于权限记录,void 前缀表示 fire-and-forget(ant 内部埋点) |
进度通知 - PreCompact(406-409)
context.onCompactProgress?.({ type: 'hooks_start', hookType: 'pre_compact' })
| 行号 | 说明 |
|---|
| 406-409 | 通知 UI 即将执行 pre_compact hooks(onCompactProgress 是可选的,所以 ?.) |
执行 PreCompact Hooks(411-429)
context.setSDKStatus?.('compacting')
const hookResult = await executePreCompactHooks(
{ trigger: isAutoCompact ? 'auto' : 'manual',
customInstructions: customInstructions ?? null },
context.abortController.signal,
)
customInstructions = mergeHookInstructions(customInstructions, hookResult.newCustomInstructions)
const userDisplayMessage = hookResult.userDisplayMessage
context.setStreamMode?.('requesting')
context.setResponseLength?.(() => 0)
context.onCompactProgress?.({ type: 'compact_start' })
| 行号 | 说明 |
|---|
| 412 | 通知 SDK 状态为 compacting |
| 413-419 | 执行 pre-compact hooks(用户配置的 pre_compact hook 脚本),传入触发方式(auto/manual)和自定义指令 |
| 420-423 | 合并 hook 产出的自定义指令,保留 userDisplayMessage |
| 427-429 | 切换 UI 状态为"请求中",并发送 compact_start 事件 |
Prompt 缓存共享检测(431-438)
const promptCacheSharingEnabled = getFeatureValue_CACHED_MAY_BE_STALE(
'tengu_compact_cache_prefix', true,
)
| 行号 | 说明 |
|---|
| 431-438 | GB feature flag tengu_compact_cache_prefix。用于 forked-agent 路径复用主会话的 prompt cache。注释说明了实验数据:关闭后 98% cache miss,每天增加 ~38B token 的 cache_creation |
构建压缩请求消息(440-443)
const compactPrompt = getCompactPrompt(customInstructions)
const summaryRequest = createUserMessage({ content: compactPrompt })
| 行号 | 说明 |
|---|
| 440 | 根据是否有自定义指令生成压缩 prompt(包含/不包含自定义内容) |
| 441-443 | 将压缩 prompt 包装为一条 user 消息,作为后续 LLM 调用的输入 |
LLM 摘要生成循环(445-491)
let messagesToSummarize = messages
let retryCacheSafeParams = cacheSafeParams
let summaryResponse: AssistantMessage
let summary: string | null
let ptlAttempts = 0
for (;;) {
summaryResponse = await streamCompactSummary({
messages: messagesToSummarize,
summaryRequest,
appState, context,
preCompactTokenCount,
cacheSafeParams: retryCacheSafeParams,
})
summary = getAssistantMessageText(summaryResponse)
if (!summary?.startsWith(PROMPT_TOO_LONG_ERROR_MESSAGE)) break
ptlAttempts++
const truncated = truncateHeadForPTLRetry()
if (!truncated) { throw new Error(ERROR_MESSAGE_PROMPT_TOO_LONG) }
messagesToSummarize = truncated
retryCacheSafeParams = { ...retryCacheSafeParams, forkContextMessages: truncated }
}
| 行号 | 说明 |
|---|
| 445-449 | messagesToSummarize 可变,因为 PTL 重试时需要截断;ptlAttempts 记录重试次数 |
| 450 | 无限循环,break 在摘要成功时退出 |
| 451-458 | streamCompactSummary() 调用 LLM(通常用 Haiku)生成摘要,使用流式方式读取 |
| 459 | 从 LLM 响应中提取文本 |
| 460 | 检查是否命中 prompt-too-long(消息总长超过模型上下文窗口) |
| 462-467 | CC-1180 修复: 如果压缩请求本身超出上下文,截断最早的消息组后重试,最多 MAX_PTL_RETRIES 次 |
| 469-478 | 超过重试上限 → 记录失败事件并抛出异常 |
| 484-490 | 更新截断后的消息数组和缓存参数,继续下一轮尝试 |
truncateHeadForPTLRetry 是压缩流程中的最后逃生口——当压缩请求本身(发给 LLM
总结的消息列表)都因为太长而返回 413 prompt-too-long 时调用。详细逻辑可查看文档:truncateHeadForPTLRetry 分析
摘要结果校验(493-515)
if (!summary) {
throw new Error('Failed to generate conversation summary...')
} else if (startsWithApiErrorPrefix(summary)) {
throw new Error(summary)
}
| 行号 | 说明 |
|---|
| 493-506 | LLM 返回了空内容 → 记录失败事件并抛出异常 |
| 507-515 | LLM 返回了 API 错误前缀 → 记录失败事件并将错误内容作为异常抛出 |
保存文件缓存状态(517-522)
const preCompactReadFileState = cacheToObject(context.readFileState)
context.readFileState.clear()
context.loadedNestedMemoryPaths?.clear()
| 行号 | 说明 |
|---|
| 518 | 将当前文件读取缓存(FileReadTool 缓存的文件路径 → 内容映射)快照为普通对象 |
| 521-522 | 清除文件缓存和嵌套 memory 路径缓存——压缩后这些引用不再有效,需要在 post-compact 附件中重建 |
关于 sentSkillNames 的说明(524-529)
| 行号 | 说明 |
|---|
| 524-529 | 特意不清除 sentSkillNames(已发送的 skill 列表)。理由是重新注入完整 skill_listing(~4K tokens)会浪费 cache_creation 资源,模型仍然有 SkillTool schema 和 invoked_skills attachment |
并行生成附件(531-544)
const [fileAttachments, asyncAgentAttachments] = await Promise.all([
createPostCompactFileAttachments(preCompactReadFileState, context, POST_COMPACT_MAX_FILES_TO_RESTORE),
createAsyncAgentAttachmentsIfNeeded(context),
])
| 行号 | 说明 |
|---|
| 532-539 | 并行生成两类附件(关键性能优化): |
| 533-537 | createPostCompactFileAttachments — 基于预压缩时的文件缓存快照,恢复压缩前模型引用过的文件内容。POST_COMPACT_MAX_FILES_TO_RESTORE 限制最大还原文件数 |
| 538 | createAsyncAgentAttachmentsIfNeeded — 检查是否有 in-progress 的异步代理,有则生成进度恢复附件 |
更多附件生成(545-585)
const planAttachment = createPlanAttachmentIfNeeded(context.agentId)
const planModeAttachment = await createPlanModeAttachmentIfNeeded(context)
const skillAttachment = createSkillAttachmentIfNeeded(context.agentId)
getDeferredToolsDeltaAttachment(...)
getAgentListingDeltaAttachment(...)
getMcpInstructionsDeltaAttachment(...)
| 行号 | 类型 | 说明 |
|---|
| 545-548 | Plan attachment | 当前会话在 plan mode 中?恢复 plan 状态 |
| 552-555 | Plan mode instructions | 提供 plan mode 指令,确保压缩后模型继续按 plan mode 工作 |
| 558-561 | Skill attachment | 如果会话中用到了 skill,恢复 skill 上下文 |
| 567-574 | Deferred tools delta | 重新声明延迟加载工具的 delta 信息。callSite: 'compact_full' 标记调用源 |
| 575-577 | Agent listing delta | 重新声明代理列表的 delta |
| 578-585 | MCP instructions delta | 重新声明 MCP 指令的 delta |
设计要点:压缩吞噬了原有的 delta attachment,所以需要用当前状态重新声明。空消息历史 diff 全量声明。
执行 SessionStart Hooks(587-594)
context.onCompactProgress?.({ type: 'hooks_start', hookType: 'session_start' })
const hookMessages = await processSessionStartHooks('compact', {
model: context.options.mainLoopModel,
})
| 行号 | 说明 |
|---|
| 587-590 | 通知 UI 开始 session_start hooks |
| 592-594 | 执行 session start hooks(CLAUDE.md 等) |
创建边界标记消息(596-611)
const boundaryMarker = createCompactBoundaryMessage(
isAutoCompact ? 'auto' : 'manual',
preCompactTokenCount ?? 0,
messages.at(-1)?.uuid,
)
const preCompactDiscovered = extractDiscoveredToolNames(messages)
if (preCompactDiscovered.size > 0) {
boundaryMarker.compactMetadata.preCompactDiscoveredTools = [...preCompactDiscovered].sort()
}
| 行号 | 说明 |
|---|
| 598-601 | 创建压缩边界标记消息,记录触发方式(auto/manual)、压缩前 token 数和最后消息 UUID |
| 603-611 | 提取压缩前所有已发现(discovered)的 deferred tool 名称,存入 boundary marker 元数据。压缩后的摘要不保留 tool_reference 块,但 API schema filter 需要这个信息来继续发送已加载的工具 schema |
构建摘要消息(613-624)
const transcriptPath = getTranscriptPath()
const summaryMessages: UserMessage[] = [
createUserMessage({
content: getCompactUserSummaryMessage(
summary, suppressFollowUpQuestions, transcriptPath,
),
isCompactSummary: true,
isVisibleInTranscriptOnly: true,
}),
]
| 行号 | 说明 |
|---|
| 613 | 获取会话转录文件路径 |
| 615-617 | getCompactUserSummaryMessage 包装 LLM 生成的摘要文本为 ContentBlockParam[],包含抑制追问标记和 transcript 链接 |
| 621-622 | isCompactSummary: true 用于 UI 渲染,isVisibleInTranscriptOnly: true 表示对用户不可见(仅转录中可见) |
Token 统计(626-645)
const compactionCallTotalTokens = tokenCountFromLastAPIResponse([summaryResponse])
const truePostCompactTokenCount = roughTokenCountEstimationForMessages([
boundaryMarker, ...summaryMessages, ...postCompactFileAttachments, ...hookMessages,
])
const compactionUsage = getTokenUsage(summaryResponse)
| 行号 | 说明 |
|---|
| 629-631 | compactionCallTotalTokens = 压缩 API 调用的总 token 数(近似压缩前 token 数)。注释强调语义上是 API 调用总消耗不是压缩后上下文大小 |
| 637-642 | truePostCompactTokenCount = 压缩后上下文真实 token 估算(边界 + 摘要 + 附件 + hooks)。注释说明下一轮 shouldAutoCompact 会在这个值上再加 ~20-40K 的 system prompt + tools + userContext,所以接近阈值时可能触发新一轮压缩 |
| 645 | 提取压缩 API 调用的 usage 详情(input_tokens, output_tokens 等) |
缓存断点检测重置(697-703)
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
notifyCompaction(context.options.querySource ?? 'compact', context.agentId)
}
markPostCompaction()
| 行号 | 说明 |
|---|
| 697-703 | 通知缓存断点检测系统发生了压缩,重置缓存读取基线,避免压缩后的 cache drop 被误报为 break。markPostCompaction() 更新全局状态标记 |
保留会话元数据(706-711)
reAppendSessionMetadata()
| 行号 | 说明 |
|---|
| 706-711 | 重新在消息末尾追加会话元数据(自定义标题、标签),确保其在 16KB tail 窗口内——否则 --resume 时读不到自定义标题,显示自动生成的名字 |
执行 PostCompact Hooks(719-728)
context.onCompactProgress?.({ type: 'hooks_start', hookType: 'post_compact' })
const postCompactHookResult = await executePostCompactHooks({
trigger: isAutoCompact ? 'auto' : 'manual',
compactSummary: summary,
}, context.abortController.signal)
| 行号 | 说明 |
|---|
| 719-722 | 通知 UI 开始 post_compact hooks |
| 723-729 | 执行 post-compact hooks,传入压缩摘要内容 |
构建最终结果(731-748)
const combinedUserDisplayMessage = [
userDisplayMessage,
postCompactHookResult.userDisplayMessage,
].filter(Boolean).join('\n')
return {
boundaryMarker,
summaryMessages,
attachments: postCompactFileAttachments,
hookResults: hookMessages,
userDisplayMessage: combinedUserDisplayMessage || undefined,
preCompactTokenCount,
postCompactTokenCount: compactionCallTotalTokens,
truePostCompactTokenCount,
compactionUsage,
}
| 行号 | 说明 |
|---|
| 731-736 | 合并 pre-compact hook 和 post-compact hook 的 userDisplayMessage(去空,换行连接) |
| 738-748 | 返回完整的 CompactionResult,包含所有附件、摘要、统计信息 |
错误处理与清理(749-762)
} catch (error) {
if (!isAutoCompact) {
addErrorNotificationIfNeeded(error, context)
}
throw error
} finally {
context.setStreamMode?.('requesting')
context.setResponseLength?.(() => 0)
context.onCompactProgress?.({ type: 'compact_end' })
context.setSDKStatus?.(null)
}
| 行号 | 说明 |
|---|
| 749-756 | 错误处理:手动压缩(/compact)→ 显示错误通知;自动压缩 → 静默失败(下一轮会重试),不显示弹窗 |
| 757-762 | finally 清理:无论成功/失败,恢复 UI 状态为 requesting、通知 compact_end、清除 SDK 状态 |
完整执行流程图
compactConversation(messages, context, ...)
│
├─ 空消息检查 → throw
│
├─ 保存 preCompactTokenCount
├─ 执行 PreCompact Hooks
│ └─ 合并自定义指令
│
├─ LLM 摘要循环(最多 MAX_PTL_RETRIES 次)
│ ├─ streamCompactSummary() ← 调用 LLM(Haiku)
│ ├─ 提取摘要文本
│ ├─ PTL 命中?→ 截断头部长消息 → 重试
│ └─ 成功退出
│
├─ 校验摘要 → 空/ApiError → throw
│
├─ 保存并清除文件读取缓存
│
├─ 并行生成附件:
│ ├─ createPostCompactFileAttachments()
│ ├─ createAsyncAgentAttachmentsIfNeeded()
│ ├─ createPlanAttachmentIfNeeded()
│ ├─ createPlanModeAttachmentIfNeeded()
│ ├─ createSkillAttachmentIfNeeded()
│ ├─ getDeferredToolsDeltaAttachment()
│ ├─ getAgentListingDeltaAttachment()
│ └─ getMcpInstructionsDeltaAttachment()
│
├─ 执行 SessionStart Hooks
│
├─ 创建边界标记 (createCompactBoundaryMessage)
│ └─ 携带 preCompactDiscoveredTools
│
├─ 构建摘要消息 (getCompactUserSummaryMessage)
│
├─ Token 统计 + 埋点 (tengu_compact)
├─ notifyCompaction() + markPostCompaction()
├─ reAppendSessionMetadata()
├─ (可选) KAIROS 转录写入
│
├─ 执行 PostCompact Hooks
│
└─ 返回 CompactionResult
├─ boundaryMarker
├─ summaryMessages
├─ attachments (文件/代理/plan/skill/MCP)
├─ hookResults
├─ userDisplayMessage
└─ token 统计 (pre / post / true)
关键设计要点
| 设计决策 | 目的 |
|---|
| LLM 生成摘要 | 保证摘要质量,但消耗 token |
| PTL 重试 | 解决极端大消息导致压缩本身超限的问题(CC-1180) |
| 文件缓存快照-清除-重建 | 避免 stale 文件引用,但保留下游需要的信息 |
| 附件并行生成 | 缩短压缩时间 |
| Delta attachment 重新声明 | 压缩吞噬了原有 delta,需用当前状态重建 |
| 自动压缩静默失败 | 避免用户困惑,下一轮会自动重试 |
| TruePostCompactTokenCount | 区分"压缩 API 消耗"和"压缩后实际上下文大小" |
| promptCacheSharingEnabled | 控制 forked-agent 是否复用主会话的 prompt cache(减少 cache miss) |