第 08 课:消息类型与对话流转

2 阅读11分钟

模块三:Agent 核心循环 | 前置依赖:第 07 课 | 预计学习时间:65 分钟


学习目标

完成本课后,你将能够:

  1. 列举 Claude Code 的 7 种核心消息类型及其用途
  2. 理解每种消息类型的完整字段结构
  3. 说明消息创建函数的设计模式与命名规范
  4. 描述消息在 query() → API → UI 之间的流转与变换过程
  5. 解释 normalizeMessagesnormalizeMessagesForAPI 的区别与作用

8.1 消息是系统的血液

在 Agent 系统中,"消息"不是一个简单概念。在 Claude Code 里,消息承担了多重角色:

消息在系统中的多重身份:
├── 对话历史的载体(发给 API 的上下文)
├── UI 渲染的数据源(显示给用户看)
├── 工具执行的输入和输出
├── Agent 循环的控制信号(tool_use 触发循环、无 tool_use 退出循环)
├── 状态恢复的依据(会话恢复时重放消息)
└── 分析与日志的素材

所有消息类型统一通过 types/message.ts(编译产物)导出,工具函数集中在 utils/messages.ts(约 5000+ 行)。


8.2 七种消息类型全景

┌─────────────────────────────────────────────────────────────┐
│                   Claude Code 消息类型族谱                    │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  核心对话类型(发送给 API)                                   │
│  ├── UserMessage        — 用户输入 / 工具结果                │
│  └── AssistantMessage   — Claude 的回复                     │
│                                                             │
│  系统类型(仅本地使用)                                       │
│  ├── SystemMessage      — 系统通知(10+ 子类型)             │
│  ├── AttachmentMessage  — 上下文附件(memory、file change)  │
│  └── ProgressMessage    — 工具执行进度                       │
│                                                             │
│  特殊类型                                                    │
│  ├── ToolUseSummaryMessage — 工具使用摘要(SDK 用)           │
│  └── TombstoneMessage      — 消息墓碑(标记删除)            │
│                                                             │
│  联合类型                                                    │
│  └── Message = UserMessage | AssistantMessage |              │
│                SystemMessage | AttachmentMessage |           │
│                ProgressMessage                               │
│                                                             │
│  流事件类型                                                  │
│  └── StreamEvent = Message | RequestStartEvent               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

联合类型 Message 不包含 TombstoneMessageToolUseSummaryMessage —— 它们是 query() 的额外 yield 类型,不存储在消息列表中。


8.3 UserMessage — 用户消息

结构

type UserMessage = {
  type: 'user'
  message: {
    role: 'user'
    content: string | ContentBlockParam[]  // 文本或内容块数组
  }
  uuid: UUID
  timestamp: string
  // 元数据标记
  isMeta?: true            // 系统生成的元消息,不是真正的用户输入
  isVisibleInTranscriptOnly?: true
  isVirtual?: true         // 虚拟消息,不发送给 API
  isCompactSummary?: true  // 压缩摘要
  // 工具相关
  toolUseResult?: unknown  // 工具返回值(原始类型)
  sourceToolAssistantUUID?: UUID  // 对应的 tool_use 所在的 assistant 消息 UUID
  // MCP 协议元数据
  mcpMeta?: {
    _meta?: Record<string, unknown>
    structuredContent?: Record<string, unknown>
  }
  imagePasteIds?: number[]
  permissionMode?: PermissionMode
  summarizeMetadata?: { messagesSummarized: number; ... }
  origin?: MessageOrigin   // 消息来源:human / agent / system
}

UserMessage 的多重身份

UserMessage 不仅仅代表"用户说了什么"。在 Claude Code 中,它有至少 5 种用途:

用途isMetacontent 类型示例
真实用户输入string"帮我看看这个文件"
工具执行结果ContentBlockParam[]tool_resultRead 工具返回的文件内容
元消息truestring恢复消息 "Output token limit hit..."
中断消息固定文本"[Request interrupted by user]"
压缩摘要true + isCompactSummarystring上下文压缩后的摘要

创建函数

// 通用创建
export function createUserMessage({
  content,           // 必需
  isMeta,            // 标记为系统生成
  isVirtual,         // 虚拟消息
  toolUseResult,     // 工具原始结果
  sourceToolAssistantUUID,  // 关联的 assistant 消息
  // ... 更多可选参数
}): UserMessage {
  const m: UserMessage = {
    type: 'user',
    message: {
      role: 'user',
      content: content || NO_CONTENT_MESSAGE,  // 永远不发空消息
    },
    uuid: (uuid as UUID) || randomUUID(),
    timestamp: timestamp ?? new Date().toISOString(),
    // ... 其他字段
  }
  return m
}

// 中断消息
export function createUserInterruptionMessage({ toolUse = false }): UserMessage

// 合成的用户告诫消息
export function createSyntheticUserCaveatMessage(): UserMessage

设计原则:content 永远不为空

content: content || NO_CONTENT_MESSAGE  // NO_CONTENT_MESSAGE = "[no content]"

这是因为 Claude API 不接受空的 content 字段。


8.4 AssistantMessage — 助手消息

结构

type AssistantMessage = {
  type: 'assistant'
  uuid: UUID
  timestamp: string
  message: {
    id: string
    container: null
    model: string          // 模型 ID(合成消息用 '<synthetic>')
    role: 'assistant'
    stop_reason: string
    stop_sequence: string
    type: 'message'
    usage: Usage           // token 用量统计
    content: BetaContentBlock[]  // 内容块数组
    context_management: null | object
  }
  requestId?: string
  // 错误相关
  isApiErrorMessage?: boolean
  apiError?: 'max_output_tokens' | 'invalid_request' | string
  error?: SDKAssistantMessageError
  errorDetails?: string
  // 展示相关
  isVirtual?: true
  isMeta?: true
  advisorModel?: string
}

content 块类型

AssistantMessage 的 content 是一个数组,可以包含多种块类型:

// BetaContentBlock 的可能类型
type ContentBlock =
  | { type: 'text'; text: string }           // 文本回复
  | { type: 'tool_use'; id: string; name: string; input: object }  // 工具调用
  | { type: 'thinking'; thinking: string }   // 思考过程
  | { type: 'redacted_thinking' }            // 已编辑的思考

一个 AssistantMessage 可以同时包含多种块:

{
  "content": [
    { "type": "thinking", "thinking": "用户想看文件..." },
    { "type": "text", "text": "让我来读取这个文件。" },
    { "type": "tool_use", "id": "tu_01", "name": "Read", "input": { "file_path": "/src/index.ts" } }
  ]
}

创建函数

// 普通助手消息
export function createAssistantMessage({
  content,    // string 或 BetaContentBlock[]
  usage?,
  isVirtual?,
}): AssistantMessage

// API 错误消息(看起来像助手消息,但标记为错误)
export function createAssistantAPIErrorMessage({
  content,
  apiError?,     // 'max_output_tokens' | 'invalid_request' | ...
  error?,
  errorDetails?,
}): AssistantMessage

合成消息 vs 真实消息:

// 合成消息(本地生成的占位符)
const SYNTHETIC_MODEL = '<synthetic>'

function baseCreateAssistantMessage({ content, ... }): AssistantMessage {
  return {
    type: 'assistant',
    message: {
      model: SYNTHETIC_MODEL,  // 标记为合成
      usage: { input_tokens: 0, output_tokens: 0, ... },
      // ...
    },
  }
}

合成消息用于错误展示、中断提示等场景 —— 它们不是 Claude 真正的回复。


8.5 SystemMessage — 系统消息

SystemMessage 是一个复杂的联合类型,包含 10+ 个子类型:

type SystemMessage =
  | SystemInformationalMessage    // 通用信息(info/warning/error 级别)
  | SystemLocalCommandMessage     // 本地命令输出
  | SystemPermissionRetryMessage  // 权限重试通知
  | SystemBridgeStatusMessage     // Bridge 连接状态
  | SystemScheduledTaskFireMessage // 定时任务触发
  | SystemStopHookSummaryMessage  // Stop Hook 执行摘要
  | SystemTurnDurationMessage     // 轮次耗时统计
  | SystemAwaySummaryMessage      // 离开摘要
  | SystemMemorySavedMessage      // 记忆保存通知
  | SystemAgentsKilledMessage     // Agent 终止通知
  | SystemApiMetricsMessage       // API 指标
  | SystemCompactBoundaryMessage  // 压缩边界
  | SystemMicrocompactBoundaryMessage  // 微压缩边界

关键特性:SystemMessage 不发送给 API

normalizeMessagesForAPI 中,大部分系统消息被过滤掉:

.filter((_): _ is ... => {
  if (
    _.type === 'progress' ||
    (_.type === 'system' && !isSystemLocalCommandMessage(_)) ||
    isSyntheticApiErrorMessage(_)
  ) {
    return false  // 过滤掉
  }
  return true
})

唯一例外是 SystemLocalCommandMessage —— 它被转换为 UserMessage 发送给 API,因为模型需要知道用户在本地执行了什么命令。

创建函数示例

// 通用信息消息
export function createSystemMessage(
  content: string,
  level: SystemMessageLevel,  // 'info' | 'warning' | 'error'
  toolUseID?: string,
  preventContinuation?: boolean,
): SystemInformationalMessage

// 权限重试
export function createPermissionRetryMessage(
  commands: string[],
): SystemPermissionRetryMessage

// 轮次耗时
export function createTurnDurationMessage(
  durationMs: number,
  budget?: { tokens: number; limit: number; nudges: number },
): SystemTurnDurationMessage

8.6 AttachmentMessage — 附件消息

AttachmentMessage 携带"附加上下文":

type AttachmentMessage = {
  type: 'attachment'
  attachment: Attachment  // 各种附件类型的联合
  uuid: UUID
  timestamp: string
}

Attachment 是一个大的联合类型,包括:

附件类型用途
edited_text_file文件被修改的通知
memory记忆内容注入
nested_memory嵌套记忆(来自子目录的 CLAUDE.md)
hook_stopped_continuationHook 阻止了继续
hook_permission_decisionHook 的权限决策
max_turns_reached达到最大轮次
mcp_instructions_deltaMCP 指令增量
skill_discovery技能发现结果
queued_command排队的命令

附件消息在发送给 API 前会被转换为 UserMessage:

export function normalizeAttachmentForAPI(
  message: AttachmentMessage,
): UserMessage | null {
  // 将附件内容格式化为文本,包装成 UserMessage
  // 某些附件类型返回 null(不发送给 API)
}

8.7 ProgressMessage — 进度消息

type ProgressMessage<P extends Progress = Progress> = {
  type: 'progress'
  data: P              // 进度数据(泛型)
  toolUseID: string    // 关联的 tool_use ID
  parentToolUseID: string
  uuid: UUID
  timestamp: string
}

进度消息用于 UI 展示工具执行过程中的实时状态。不同工具有不同的进度数据类型:

type ToolProgressData =
  | BashProgress           // Bash 命令输出进度
  | AgentToolProgress      // 子 Agent 执行进度
  | MCPProgress            // MCP 工具调用进度
  | REPLToolProgress       // REPL 工具进度
  | SkillToolProgress      // 技能工具进度
  | TaskOutputProgress     // 任务输出进度
  | WebSearchProgress      // Web 搜索进度

ProgressMessage 纯粹是 UI 层面的 —— 它不会被发送给 API,也不会被持久化到会话历史中。

export function createProgressMessage<P extends Progress>({
  toolUseID,
  parentToolUseID,
  data,
}): ProgressMessage<P> {
  return {
    type: 'progress',
    data,
    toolUseID,
    parentToolUseID,
    uuid: randomUUID(),
    timestamp: new Date().toISOString(),
  }
}

8.8 ToolUseSummaryMessage — 工具使用摘要

type ToolUseSummaryMessage = {
  type: 'tool_use_summary'
  summary: string              // 人类可读的摘要文本
  precedingToolUseIds: string[] // 关联的 tool_use ID 列表
  uuid: UUID
  timestamp: string
}

这个消息类型为 SDK 消费者提供人类可读的工具使用摘要。在 query.ts 中异步生成:

// 启动摘要生成(非阻塞,Haiku 模型约 1 秒)
nextPendingToolUseSummary = generateToolUseSummary({
  tools: toolInfoForSummary,
  signal: toolUseContext.abortController.signal,
  lastAssistantText,
})
  .then(summary => summary ? createToolUseSummaryMessage(summary, toolUseIds) : null)
  .catch(() => null)

// 下一轮迭代开始时 yield
if (pendingToolUseSummary) {
  const summary = await pendingToolUseSummary
  if (summary) {
    yield summary
  }
}

时序优化:摘要用 Haiku(快速模型)生成,在模型流式响应期间(5-30 秒)并行执行。


8.9 TombstoneMessage — 墓碑消息

type TombstoneMessage = {
  type: 'tombstone'
  message: AssistantMessage  // 被标记删除的原始消息
}

TombstoneMessage 的作用是通知 UI 层移除某个消息。最典型的使用场景是模型降级

// query.ts — 流式降级发生时
if (streamingFallbackOccured) {
  // 为已经 yield 的 assistantMessages 生成墓碑
  for (const msg of assistantMessages) {
    yield { type: 'tombstone' as const, message: msg }
  }
  // 清空重来
  assistantMessages.length = 0
}

当主模型的流式传输中途切换到降级模型时,之前已经 yield 给 UI 的部分消息需要被"撤回"。Tombstone 就是这个撤回信号。


8.10 消息流转全景

消息在系统中经历多次变换:

用户输入 (string)
    │
    ▼ createUserMessage()
UserMessage { type: 'user', content: string }
    │
    ▼ query.tscallModel()
    │
API 返回流式响应
    │
    ▼ callModel 内部包装
AssistantMessage { type: 'assistant', content: [...] }
    │
    ▼ 工具执行
    │
    ├── 工具结果 → createUserMessage({ content: [{ type: 'tool_result', ... }] })
    ├── 进度 → createProgressMessage({ data: BashProgress, ... })
    └── 附件 → createAttachmentMessage({ type: 'edited_text_file', ... })
    │
    ▼ 追加到 messages[]
    │
    ▼ 下一轮 API 调用前
    │
    ▼ normalizeMessagesForAPI()
    │   ├── 过滤 progress(不发给 API)
    │   ├── 过滤 system(大部分不发给 API)
    │   ├── 转换 attachment → user(部分附件转为用户消息)
    │   ├── 合并相邻 user messages
    │   ├── 去除 virtual 消息
    │   └── 确保 tool_use/tool_result 配对
    │
    ▼ prependUserContext()
    │   └── 在首条用户消息前注入 CLAUDE.md 等上下文
    │
    ▼ 发送给 Claude API

normalizeMessages vs normalizeMessagesForAPI

这两个函数容易混淆,但用途完全不同:

函数用途输入输出
normalizeMessages()UI 渲染Message[]NormalizedMessage[]
normalizeMessagesForAPI()API 发送Message[](UserMessage | AssistantMessage)[]

normalizeMessages 将多块消息拆分为单块消息(每个 content block 一条消息),方便 UI 逐块渲染:

// 输入:一条包含 3 个块的 AssistantMessage
{ content: [thinking, text, tool_use] }

// 输出:3 条 NormalizedAssistantMessage
{ content: [thinking] }
{ content: [text] }
{ content: [tool_use] }

normalizeMessagesForAPI 做更重的工作:

export function normalizeMessagesForAPI(
  messages: Message[],
  tools: Tools = [],
): (UserMessage | AssistantMessage)[] {
  // 1. 重排附件位置(bubble up)
  const reorderedMessages = reorderAttachmentsForAPI(messages)
    .filter(m => !m.isVirtual)  // 2. 去除虚拟消息

  // 3. 过滤非 API 类型
  .filter((_) => {
    if (_.type === 'progress' || _.type === 'system' || ...) {
      return false
    }
    return true
  })

  // 4. 合并相邻同类型消息
  // 5. 转换附件为用户消息
  // 6. 确保 tool_use/tool_result 配对
  return ensureToolResultPairing(result)
}

8.11 合成消息与消息常量

系统中有一组预定义的"合成消息"文本:

export const INTERRUPT_MESSAGE = '[Request interrupted by user]'
export const INTERRUPT_MESSAGE_FOR_TOOL_USE =
  '[Request interrupted by user for tool use]'
export const CANCEL_MESSAGE =
  "The user doesn't want to take this action right now. STOP..."
export const REJECT_MESSAGE =
  "The user doesn't want to proceed with this tool use..."
export const NO_RESPONSE_REQUESTED = 'No response requested.'

这些常量被注册到一个 Set 中,用于识别:

export const SYNTHETIC_MESSAGES = new Set([
  INTERRUPT_MESSAGE,
  INTERRUPT_MESSAGE_FOR_TOOL_USE,
  CANCEL_MESSAGE,
  REJECT_MESSAGE,
  NO_RESPONSE_REQUESTED,
])

export function isSyntheticMessage(message: Message): boolean {
  return (
    message.type !== 'progress' &&
    message.type !== 'attachment' &&
    message.type !== 'system' &&
    Array.isArray(message.message.content) &&
    message.message.content[0]?.type === 'text' &&
    SYNTHETIC_MESSAGES.has(message.message.content[0].text)
  )
}

拒绝消息的措辞设计

权限被拒绝时的消息措辞经过精心设计:

export const DENIAL_WORKAROUND_GUIDANCE =
  `IMPORTANT: You *may* attempt to accomplish this action using other tools...` +
  `But you *should not* attempt to work around this denial in malicious ways...` +
  `If you believe this capability is essential, STOP and explain to the user...`

这个指导文本同时做了两件事:

  1. 允许合理绕行 —— 比如 cat 被拒绝时可以用 head
  2. 禁止恶意绕行 —— 不能用测试框架来执行非测试操作

8.12 消息 UUID 与确定性派生

每条消息都有一个 UUID。当 normalizeMessages 拆分多块消息时,需要为子消息生成新的 UUID:

export function deriveUUID(parentUUID: UUID, index: number): UUID {
  const hex = index.toString(16).padStart(12, '0')
  return `${parentUUID.slice(0, 24)}${hex}` as UUID
}

这是一个确定性派生 —— 同一个父 UUID + index 永远产生同一个子 UUID。这保证了:

  • 相同的消息拆分后 key 不变
  • React 渲染时不会因 key 变化导致不必要的重渲染

还有一个用于搜索索引的短 ID:

export function deriveShortMessageId(uuid: string): string {
  const hex = uuid.replace(/-/g, '').slice(0, 10)
  return parseInt(hex, 16).toString(36).slice(0, 6)
}

6 字符的 base36 短 ID,用于 snip 工具的消息引用标签 [id:...]


课后练习

练习 1:消息类型分类

不看本课内容,回答以下每种场景创建的是什么消息类型:

  1. 用户在终端输入 "写一个 hello world"
  2. Claude 回复 "好的,让我来创建这个文件"
  3. Claude 请求使用 FileWrite 工具
  4. FileWrite 执行成功返回结果
  5. REPL 显示 "已切换到 Sonnet 模型" 的通知
  6. 工具执行时的进度条更新
  7. 用户按 Ctrl+C 中断

练习 2:追踪 tool_result 的生命周期

一个 tool_result 消息从创建到被 Claude 处理,经过哪些变换?提示:

  1. 工具执行后在哪里创建?
  2. 它的 content 字段包含什么?
  3. normalizeMessagesForAPI 对它做了什么?
  4. prependUserContext 对它做了什么?
  5. Claude 如何知道它对应哪个 tool_use

练习 3:设计新的消息类型

假设你要为 Claude Code 添加一个"实时协作"功能(多个用户同时使用),你需要设计一种新的消息类型来表示"其他用户的操作"。思考:

  1. 它应该属于 Message 联合类型还是 query() 的额外 yield 类型?
  2. 它需要发送给 API 吗?
  3. 它需要哪些字段?
  4. 现有哪个消息类型与它最相似?

练习 4:消息过滤链

normalizeMessagesForAPI 按顺序执行多个过滤/变换步骤。如果顺序改变会怎样?比如:

  1. 如果在"过滤 virtual 消息"之前就"合并相邻 user messages",可能出什么问题?
  2. 如果跳过 ensureToolResultPairing,API 会返回什么错误?
  3. 为什么 reorderAttachmentsForAPI 要在最前面执行?

本课小结

要点内容
消息类型总数7 种核心类型,其中 SystemMessage 有 10+ 子类型
Message 联合UserMessage | AssistantMessage | SystemMessage | AttachmentMessage | ProgressMessage
UserMessage 多重身份真实输入、工具结果、元消息、中断消息、压缩摘要
AssistantMessage contenttext / tool_use / thinking / redacted_thinking 块
TombstoneMessage流式降级时撤回已 yield 的消息
normalizeMessages拆分多块消息 → 单块消息(UI 渲染用)
normalizeMessagesForAPI过滤/变换/配对 → 只留 user+assistant(API 发送用)
UUID 派生deriveUUID() 确定性生成子消息 ID
合成消息SYNTHETIC_MODEL = '<synthetic>',标记非 Claude 生成的消息

下一课预告

第 09 课:上下文构建 — 消息只是对话内容的载体,但 Claude 看到的不只是消息。我们将深入 context.ts,看看 Git 状态、CLAUDE.md 用户配置、环境信息如何被收集并注入到每次 API 调用中。还会探索 context/ 目录下的 React Context 集合 —— 通知系统、邮箱、语音状态等 UI 层上下文如何与 Agent 循环协作。