第 6 章:消息循环与对话管理

# 第 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)