模块三:Agent 核心循环 | 前置依赖:第 07 课 | 预计学习时间:65 分钟
学习目标
完成本课后,你将能够:
- 列举 Claude Code 的 7 种核心消息类型及其用途
- 理解每种消息类型的完整字段结构
- 说明消息创建函数的设计模式与命名规范
- 描述消息在 query() → API → UI 之间的流转与变换过程
- 解释
normalizeMessages和normalizeMessagesForAPI的区别与作用
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 不包含 TombstoneMessage 和 ToolUseSummaryMessage —— 它们是 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 种用途:
| 用途 | isMeta | content 类型 | 示例 |
|---|---|---|---|
| 真实用户输入 | 无 | string | "帮我看看这个文件" |
| 工具执行结果 | 无 | ContentBlockParam[] 含 tool_result | Read 工具返回的文件内容 |
| 元消息 | true | string | 恢复消息 "Output token limit hit..." |
| 中断消息 | 无 | 固定文本 | "[Request interrupted by user]" |
| 压缩摘要 | true + isCompactSummary | string | 上下文压缩后的摘要 |
创建函数
// 通用创建
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_continuation | Hook 阻止了继续 |
hook_permission_decision | Hook 的权限决策 |
max_turns_reached | 达到最大轮次 |
mcp_instructions_delta | MCP 指令增量 |
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.ts → callModel()
│
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...`
这个指导文本同时做了两件事:
- 允许合理绕行 —— 比如
cat被拒绝时可以用head - 禁止恶意绕行 —— 不能用测试框架来执行非测试操作
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:消息类型分类
不看本课内容,回答以下每种场景创建的是什么消息类型:
- 用户在终端输入 "写一个 hello world"
- Claude 回复 "好的,让我来创建这个文件"
- Claude 请求使用 FileWrite 工具
- FileWrite 执行成功返回结果
- REPL 显示 "已切换到 Sonnet 模型" 的通知
- 工具执行时的进度条更新
- 用户按 Ctrl+C 中断
练习 2:追踪 tool_result 的生命周期
一个 tool_result 消息从创建到被 Claude 处理,经过哪些变换?提示:
- 工具执行后在哪里创建?
- 它的
content字段包含什么? normalizeMessagesForAPI对它做了什么?prependUserContext对它做了什么?- Claude 如何知道它对应哪个
tool_use?
练习 3:设计新的消息类型
假设你要为 Claude Code 添加一个"实时协作"功能(多个用户同时使用),你需要设计一种新的消息类型来表示"其他用户的操作"。思考:
- 它应该属于
Message联合类型还是 query() 的额外 yield 类型? - 它需要发送给 API 吗?
- 它需要哪些字段?
- 现有哪个消息类型与它最相似?
练习 4:消息过滤链
normalizeMessagesForAPI 按顺序执行多个过滤/变换步骤。如果顺序改变会怎样?比如:
- 如果在"过滤 virtual 消息"之前就"合并相邻 user messages",可能出什么问题?
- 如果跳过
ensureToolResultPairing,API 会返回什么错误? - 为什么
reorderAttachmentsForAPI要在最前面执行?
本课小结
| 要点 | 内容 |
|---|---|
| 消息类型总数 | 7 种核心类型,其中 SystemMessage 有 10+ 子类型 |
| Message 联合 | UserMessage | AssistantMessage | SystemMessage | AttachmentMessage | ProgressMessage |
| UserMessage 多重身份 | 真实输入、工具结果、元消息、中断消息、压缩摘要 |
| AssistantMessage content | text / 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 循环协作。