# 第 6 章:消息循环与对话管理
> 对话不是一列火车,而是一棵树——每一次压缩都是有意识的遗忘。
---
## 6.1 核心问题
第 5 章解决了"如何和 Claude 说一句话"。但 Agent 的价值在于**多轮对话**:理解上下文、记住之前说过什么、在工具调用的来回中保持连贯。
Anthropic API 是无状态的——每次请求都要把**完整的历史消息**带过去。这就带来两个关键问题:
1. **消息如何组织?** `user` / `assistant` / `tool_use` / `tool_result` 如何正确配对和排列?
2. **历史无限增长怎么办?** 上下文窗口有上限(几十万 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]`
```typescript
// 只压缩特定工具的结果,避免误删
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 基础上添加了"不中断对话"的能力——主对话继续等待,后台触发压缩,完成后无缝替换历史
```typescript
// 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 个最近操作的文件内容,注入到压缩后的消息中:
```typescript
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 推断):
```typescript
// 用户消息(含工具结果)
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 核心流程
```typescript
// 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 轮次"分组,从最老的轮次开始丢弃,直到能装入上下文。
```typescript
const MAX_PTL_RETRIES = 3 // 最多重试 3 次
// 每次重试:丢掉最老的若干轮次 → 继续压缩
```
### 6.3.4 `getAutoCompactThreshold`:自动压缩触发点
```typescript
// 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` 中完成对话管理模块。
**接口规范**(已提供,不要修改):
```typescript
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>
}
```
**你需要实现**:
1. `estimateTokens()`:遍历消息,提取文本内容,总字符数 / 3
2. `callLLM()`:复用第 5 章的 `streamQuery`(`../../05/src/llm.js`),不重复实现流式逻辑
3. `compactHistory()`:将历史转为文本 → 调用 LLM 生成摘要 → 返回单条摘要消息
4. `ConversationManager` 类:管理 messages 数组,`chat()` 方法处理自动压缩 + 调用 LLM
**关键约束**:
- `callLLM()` 必须 import 并复用第 5 章的 `streamQuery`,不能重新实现流式逻辑
- `chat()` 方法在调用 LLM 前检查 `needsAutoCompact()`,超过阈值时自动压缩
### 验收
```bash
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 本章小结
本章构建了多轮对话的管理骨架:
1. **消息数组是 API 的"外部记忆"**:Anthropic API 无状态,每次请求必须携带完整历史。消息的 `role` 必须严格交替(`user` → `assistant` → `user`)。
2. **tool_use / tool_result 配对规则**:工具调用在 `assistant` 消息中,工具结果在下一条 `user` 消息中,`id` 严格对应——这是第 7 章 Agentic Loop 的基础。
3. **三层压缩策略**:
- **微压缩**(零成本,每轮前):替换过时的工具输出为占位符
- **全量压缩**(一次 LLM 调用):用摘要替换完整历史
- **自动压缩**(接近上限时):不中断对话的后台触发
4. **压缩的副作用**:压缩必然破坏 Prompt Cache(历史变了,缓存 hash 改变)。Claude Code 通过 `notifyCompaction()` 主动重置缓存基线,避免错误地认为缓存仍然有效。
5. **压缩后的记忆恢复**:全量压缩后,Claude Code 自动将最近操作的文件内容重新注入,确保 Agent 不会因为压缩而"忘记"正在编辑的代码。
6. **`conversation.ts` 独立导出**:`ConversationManager`、`callLLM`、`compactHistory`、`estimateTokens` 均从 `conversation.ts` 导出,供第 7 章直接 import 复用。`callLLM` 内部复用第 5 章的 `streamQuery`,不重复实现流式逻辑。
**下一章**将把消息管理与工具执行连接起来,实现完整的 **Agentic Loop**。
---
## 下一章
→ [第 7 章:工具调用循环(Agentic Loop)](07-agentic-loop)