对话不是一列火车,而是一棵树——每一次压缩都是有意识的遗忘。
6.1 核心问题
第 5 章解决了"如何和 Claude 说一句话"。但 Agent 的价值在于多轮对话:理解上下文、记住之前说过什么、在工具调用的来回中保持连贯。
Anthropic API 是无状态的——每次请求都要把完整的历史消息带过去。这就带来两个关键问题:
- 消息如何组织?
user/assistant/tool_use/tool_result如何正确配对和排列? - 历史无限增长怎么办? 上下文窗口有上限(几十万 token),长会话必然触及天花板。
第二个问题尤其棘手。最简单的解法是"滑动窗口截断"——丢掉最旧的消息。但这样 LLM 会突然"忘记"之前写的代码、同意的方案。
Claude Code 的答案是三层压缩策略:用 LLM 自己来总结历史,而不是盲目截断。
6.2 原理讲解
6.2.1 消息数组:Anthropic API 的"记忆"
Anthropic 的 Messages API 的消息格式非常严格:role 必须严格交替(user → assistant → user → ...),且内容块有固定类型。
一次工具调用的完整往返:
messages[0]: role=user, content=[TextBlock("帮我写一个排序函数")]
messages[1]: role=assistant, content=[TextBlock("好的,让我先看看..."),
ToolUseBlock(id="tu_01", name="Read", input={...})]
messages[2]: role=user, content=[ToolResultBlock(id="tu_01", content="...文件内容...")]
messages[3]: role=assistant, content=[TextBlock("我看完了,这是排序函数:\n```ts...")]
关键约束:
tool_use块只能在assistant消息里tool_result块只能在user消息里,且每个tool_result的id必须对应前一个assistant消息中某个tool_use的id- 每次 API 调用,整个
messages数组(包括历史)都要发送——服务端不存储任何状态
6.2.2 Claude Code 的消息类型系统
Claude Code 在 API 的基本类型上叠加了内部类型系统(src/types/message.ts),区分了消息的来源与用途:
Message(联合类型)
├── UserMessage ← 用户输入 / tool_result
├── AssistantMessage ← LLM 回复(含 text、tool_use、thinking 块)
├── ProgressMessage ← 工具执行进度(仅 UI 展示,不发送给 API)
├── AttachmentMessage ← 系统注入的附件(技能、工具增量等,发送给 API)
├── SystemMessage ← UI 状态信息(错误、会话边界等,不发送给 API)
└── ... (TombstoneMessage、ToolUseSummaryMessage 等)
关键区分:normalizeMessagesForAPI() 函数负责在发送前过滤掉所有不应发给 API 的内部类型(SystemMessage、ProgressMessage 等),确保 API 收到的是纯净的对话历史。
6.2.3 tool_use / tool_result 配对
工具调用涉及至少两轮 API 往返,消息配对规则是:
轮 N(助手):
assistant.content = [
ToolUseBlock(id="tu_abc", name="Bash", input={command:"ls"}),
ToolUseBlock(id="tu_def", name="Read", input={file_path:"./a.ts"}),
// 可以同时有多个 tool_use(并行调用)
]
轮 N+1(用户,包含工具结果):
user.content = [
ToolResultBlock(tool_use_id="tu_abc", content="total 8\n..."),
ToolResultBlock(tool_use_id="tu_def", content="1 │ import...\n..."),
// 每个 tool_use 必须有对应的 tool_result,顺序不影响
]
Claude Code 中有 ensureToolResultPairing() 函数负责检测孤立的 tool_use(没有对应 tool_result),为其插入占位符——防止 API 拒绝请求。
6.2.4 上下文窗口与 Token 预算
每个模型都有上下文窗口上限(如 claude-sonnet-4-5 为 200K token)。一次请求消耗的 token = 输入 token(所有历史消息)+ 预留输出 token(max_tokens)。
可用 token 上限 = 上下文窗口 - max_tokens
当 token_count(messages) 接近「可用 token 上限」时,有三种应对策略:
1. 报错(简单但用户体验差)
2. 截断(快速但丢失语义)
3. 压缩(调用 LLM 生成摘要,保留语义精华)← Claude Code 的做法
6.2.5 三层压缩策略
Claude Code 实现了三层互补的压缩机制:
层一:Micro Compaction(微压缩)
触发:每次 API 调用前自动运行
成本:零(纯内存操作,不调用 LLM)
原理:检测历史中的 FILE_UNCHANGED_STUB(文件未变化占位符)和过时的大型工具结果(bash 输出、文件读取等),就地替换为 [Old tool result content cleared]
// 只压缩特定工具的结果,避免误删
const COMPACTABLE_TOOLS = new Set([
FILE_READ_TOOL_NAME, // "Read"
BASH_TOOL_NAME, // "Bash"
GREP_TOOL_NAME, // "Grep"
GLOB_TOOL_NAME, // "Glob"
WEB_SEARCH_TOOL_NAME, // "WebSearch"
WEB_FETCH_TOOL_NAME, // "WebFetch"
FILE_EDIT_TOOL_NAME, // "Edit"
FILE_WRITE_TOOL_NAME, // "Write"
])
效果:对于长时间运行的 Agent 会话,工具输出通常是最大的 token 消耗来源,微压缩可将 token 使用量减少 30-60%。
层二:Full Compaction(全量压缩)
触发:用户手动 /compact,或 Auto Compaction 触发
成本:一次独立的 LLM API 调用
原理:启动一个"forked sub-agent"(独立的压缩 Agent),把整段对话历史作为输入,生成一份结构化摘要,替换掉全部旧历史:
压缩前:
messages = [msg1, msg2, ..., msg_N] // 几十个消息
压缩后:
messages = [
CompactBoundaryMarker, // 系统边界标记
UserMessage("以下是压缩前的对话摘要:\n[详细摘要...]"),
// 可选:最近几个读取过的文件内容(post-compact 恢复)
]
关键副作用:压缩后 Prompt Cache 失效——新的消息历史与之前不同,需通过 notifyCompaction() 通知缓存模块重置 hash 基线,下一次请求重新建立缓存。
Pre/Post Compact Hooks:压缩前后会触发可扩展的钩子,允许外部工具(如 CI 系统)在压缩节点做额外处理。
层三:Auto Compaction(自动压缩)
触发:当前 token 使用量超过 getAutoCompactThreshold(model) 时自动触发
实现:在 Full Compaction 基础上添加了"不中断对话"的能力——主对话继续等待,后台触发压缩,完成后无缝替换历史
// src/services/compact/autoCompact.ts
export const AUTOCOMPACT_BUFFER_TOKENS = 13_000
export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000
export function getAutoCompactThreshold(model: string): number {
const effectiveContextWindow = getEffectiveContextWindowSize(model)
return effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS
// 例:200K 窗口 - 20K 输出预留 - 13K 缓冲 ≈ 167K token 时触发
}
三层策略的触发顺序:微压缩在每轮前运行 → Auto Compaction 在接近上限时自动触发 → 用户可随时手动 /compact 全量压缩。
6.2.6 压缩后的"记忆恢复"
全量压缩后,Claude 会"忘记"之前读过的文件内容。Claude Code 通过 createPostCompactFileAttachments() 自动恢复最多 5 个最近操作的文件内容,注入到压缩后的消息中:
export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5
export const POST_COMPACT_TOKEN_BUDGET = 50_000 // 总 token 预算
export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000 // 单文件上限
这确保即使压缩后,Claude 仍能记住当前正在编辑的文件。
6.3 Claude Code 源码细节
6.3.1 消息类型的层次结构
src/types/message.ts 中定义的核心类型(从 src/utils/messages.ts 的 import 推断):
// 用户消息(含工具结果)
type UserMessage = {
type: 'user'
message: { role: 'user'; content: ContentBlock[] }
uuid: string
isMeta?: boolean // true → 隐藏在 UI 中(内部系统消息)
}
// 助手消息(LLM 回复)
type AssistantMessage = {
type: 'assistant'
message: {
role: 'assistant'
content: ContentBlock[] // TextBlock | ToolUseBlock | ThinkingBlock
model: string
usage: Usage
stop_reason: string
}
uuid: string
isApiErrorMessage?: boolean // true → 内容是错误描述
}
// 内容块联合类型
type ContentBlock =
| { type: 'text'; text: string }
| { type: 'tool_use'; id: string; name: string; input: object }
| { type: 'tool_result'; tool_use_id: string; content: string | ContentBlock[] }
| { type: 'thinking'; thinking: string; signature: string }
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
6.3.2 compactConversation():Full Compaction 核心流程
// src/services/compact/compact.ts(简化)
export async function compactConversation(
messages: Message[],
context: ToolUseContext,
// ...
): Promise<CompactionResult> {
// 1. 执行 Pre-compact hooks
const hookResult = await executePreCompactHooks({ trigger: 'manual' }, signal)
// 2. 启动 forked sub-agent 生成摘要
const summaryResponse = await streamCompactSummary({
messages,
summaryRequest: createUserMessage({ content: getCompactPrompt(customInstructions) }),
// ...
})
// 3. 清理文件读取缓存(准备恢复)
context.readFileState.clear()
// 4. 恢复最近操作的文件内容
const fileAttachments = await createPostCompactFileAttachments(
preCompactReadFileState, context, POST_COMPACT_MAX_FILES_TO_RESTORE
)
// 5. 通知 Prompt Cache 模块重置(缓存失效)
notifyCompaction()
// 6. 返回新的消息列表:[边界标记, 摘要消息, 文件恢复附件...]
return {
newMessages: [boundaryMarker, ...summaryMessages, ...fileAttachments],
summary,
}
}
6.3.3 truncateHeadForPTLRetry:压缩本身撞上 token 上限
极端情况:待压缩的历史本身就太长,导致"压缩请求"触发 prompt_too_long 错误。Claude Code 有专门的 truncateHeadForPTLRetry() 处理这个递归问题——按"API 轮次"分组,从最老的轮次开始丢弃,直到能装入上下文。
const MAX_PTL_RETRIES = 3 // 最多重试 3 次
// 每次重试:丢掉最老的若干轮次 → 继续压缩
6.3.4 getAutoCompactThreshold:自动压缩触发点
// src/services/compact/autoCompact.ts
export function getEffectiveContextWindowSize(model: string): number {
const reservedForSummary = Math.min(
getMaxOutputTokensForModel(model),
MAX_OUTPUT_TOKENS_FOR_SUMMARY, // 20_000 token 上限
)
return getContextWindowForModel(model) - reservedForSummary
}
export function getAutoCompactThreshold(model: string): number {
return getEffectiveContextWindowSize(model) - AUTOCOMPACT_BUFFER_TOKENS // 13_000
}
// claude-sonnet-4-5: 200K - 20K - 13K = 167K token 触发自动压缩
电路熔断器:consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES (3) 时停止自动压缩重试,避免无限循环消耗 API 资源。
6.4 最小化产出物
代码骨架位于
../chapters/06/src/,参考实现位于../chapters/06/solution/。 前置条件:需要ANTHROPIC_API_KEY环境变量(/compact命令和npm start需要)。
本章要实现什么
在 ../chapters/06/src/conversation.ts 中完成对话管理模块。
接口规范(已提供,不要修改):
export type ConversationMessage = Anthropic.MessageParam & { _tokenEstimate?: number; _timestamp: number }
export type CompactionResult = { summary: string; originalCount: number; compressedCount: number; tokensSaved: number }
export function estimateTokens(messages: ConversationMessage[]): number
export const SYSTEM_PROMPT: string
export async function callLLM(messages, system?, onText?): Promise<{ text: string; inputTokens: number; outputTokens: number }>
export async function compactHistory(messages): Promise<{ newMessages: ConversationMessage[]; result: CompactionResult }>
export class ConversationManager {
getMessages(): ConversationMessage[]
addUserMessage(text: string): void
addAssistantMessage(text: string): void
estimatedTokens(): number
needsAutoCompact(): boolean
async compact(): Promise<CompactionResult>
clear(): void
showHistory(): void
async chat(userInput: string): Promise<void>
}
你需要实现:
estimateTokens():遍历消息,提取文本内容,总字符数 / 3callLLM():复用第 5 章的streamQuery(../../05/src/llm.js),不重复实现流式逻辑compactHistory():将历史转为文本 → 调用 LLM 生成摘要 → 返回单条摘要消息ConversationManager类:管理 messages 数组,chat()方法处理自动压缩 + 调用 LLM
关键约束:
callLLM()必须 import 并复用第 5 章的streamQuery,不能重新实现流式逻辑chat()方法在调用 LLM 前检查needsAutoCompact(),超过阈值时自动压缩
验收
cd docs/chapters/06
npm install
npm test
卡住时查看 ../chapters/06/solution/conversation.ts。
6.5 消息历史的生命周期图
用户输入
│
▼
addUserMessage()
│ messages = [...旧历史, {role:'user', content:'...'}]
│
▼
needsAutoCompact()?
│ 是:estimatedTokens() > AUTO_COMPACT_THRESHOLD
├─ 是 ──→ compact()
│ │ 1. 将历史转为文本
│ │ 2. 调用 LLM 生成摘要(独立调用,不入历史)
│ │ 3. messages = [{role:'user', content:'[对话历史已压缩]\n\n摘要...'}]
│ │ 4. notifyCompaction() → 重置 Prompt Cache 基线
│ ▼
└─ 否 ──→ callLLM(messages) ← 发送完整历史
│
▼(流式响应)
addAssistantMessage()
│ messages = [...历史, {role:'assistant', content:'...'}]
▼
下一轮等待用户输入
6.6 本章小结
本章构建了多轮对话的管理骨架:
-
消息数组是 API 的"外部记忆":Anthropic API 无状态,每次请求必须携带完整历史。消息的
role必须严格交替(user→assistant→user)。 -
tool_use / tool_result 配对规则:工具调用在
assistant消息中,工具结果在下一条user消息中,id严格对应——这是第 7 章 Agentic Loop 的基础。 -
三层压缩策略:
- 微压缩(零成本,每轮前):替换过时的工具输出为占位符
- 全量压缩(一次 LLM 调用):用摘要替换完整历史
- 自动压缩(接近上限时):不中断对话的后台触发
-
压缩的副作用:压缩必然破坏 Prompt Cache(历史变了,缓存 hash 改变)。Claude Code 通过
notifyCompaction()主动重置缓存基线,避免错误地认为缓存仍然有效。 -
压缩后的记忆恢复:全量压缩后,Claude Code 自动将最近操作的文件内容重新注入,确保 Agent 不会因为压缩而"忘记"正在编辑的代码。
-
conversation.ts独立导出:ConversationManager、callLLM、compactHistory、estimateTokens均从conversation.ts导出,供第 7 章直接 import 复用。callLLM内部复用第 5 章的streamQuery,不重复实现流式逻辑。
下一章将把消息管理与工具执行连接起来,实现完整的 Agentic Loop。