第 16 章 · 消息数组的构造与生命周期
第 15 章讲的是"对 LLM 说什么"(系统提示词的设计),本章讲的是"怎么把完整请求组织起来"——包括消息类型设计、上下文注入架构、工具调用循环、上下文窗口管理和缓存优化。这是 LLM 智能体工程中最复杂、最容易做错的部分。Claude Code 用 ~9,000 行代码(
messages.ts+attachments.ts+query.ts)来解决这个问题,其中蕴含了大量可迁移到任何 Agent 系统的工程模式。
16.1 概述:消息的完整旅程
内部消息 vs API 消息
LLM API(如 Anthropic Messages API)只接受严格的 user/assistant 交替消息。但一个真实的 Agent 系统内部需要跟踪远不止这两种消息——系统事件、附件注入、工具结果、进度状态、压缩边界等。
Claude Code 的解决方案是维护一个 异构的内部消息数组,包含 7+ 种类型,在发送给 API 前通过规范化流水线转换为严格的 user/assistant 格式:
内部消息数组(单一真实来源)
┌──────────────────────────────────────────────────────────┐
│ user | assistant | system | attachment | progress | │
│ tombstone | tool_use_summary │
└────────────────────────┬─────────────────────────────────┘
│ normalizeMessagesForAPI()
↓
API 消息数组(严格 user/assistant 交替)
┌──────────────────────────────────────────────────────────┐
│ { role: 'user', content: [...] } │
│ { role: 'assistant', content: [...] } │
│ { role: 'user', content: [...] } │
│ ... │
└──────────────────────────────────────────────────────────┘
各消息类型的职责:
| 类型 | 到达 API? | 职责 |
|---|---|---|
user | 是 | 用户输入、工具结果(tool_result)、合成系统消息 |
assistant | 是 | LLM 回复(含 text、tool_use、thinking 等内容块) |
system | 否(转换后可能) | 本地命令结果、压缩边界、权限重试等 10+ 个子类型 |
attachment | 否(转换后) | 40+ 种动态上下文:记忆文件、Git diff、任务状态、技能发现等 |
progress | 否 | 流式进度指示(纯 UI 用途) |
tombstone | 否 | 标记已失效的消息(如模型回退后的旧消息) |
tool_use_summary | 否 | 工具使用摘要(移动端 UI 用) |
内部消息数组是会话的唯一真实来源——UI 渲染、会话持久化、转录导出、压缩决策都从这个数组出发。
端到端消息管道
核心文件分布:
| 文件 | 行数 | 职责 |
|---|---|---|
src/query.ts | ~1,800 | 查询循环、状态机、错误恢复 |
src/utils/messages.ts | ~4,500 | 消息创建、规范化、合并、配对验证 |
src/utils/attachments.ts | ~3,900 | 40+ 附件类型、Delta 模式、上下文注入 |
src/utils/api.ts | ~700 | 上下文注入函数、系统提示词分块、工具 Schema 缓存 |
src/services/api/claude.ts | ~1,200 | API 消息转换、cache_control、流式处理 |
src/services/compact/ | 多个文件 | 全量/微量/选择性压缩策略 |
16.2 三通道上下文注入
Claude Code 通过三个独立通道将环境上下文注入到 LLM 请求中。每个通道有不同的缓存特征、刷新频率和在请求中的定位:
通道一:系统提示词追加
appendSystemContext() 将 Git 状态等信息追加到系统提示词末尾:
export function appendSystemContext(
systemPrompt: SystemPrompt,
context: { [k: string]: string },
): string[] {
return [
...systemPrompt,
Object.entries(context)
.map(([key, value]) => `${key}: ${value}`)
.join('\n'),
].filter(Boolean)
}
这个通道的内容来自 getSystemContext()(src/context.ts),包含:
- gitStatus:当前分支、主分支、未提交变更、最近提交等
- cacheBreaker:用于主动触发缓存失效的注入标记(内部用户功能)
getSystemContext() 通过 memoize 做会话级缓存——整个会话期间只计算一次,因为 Git 状态在会话开始时就是一个快照。
通道二:合成用户消息
prependUserContext() 将 CLAUDE.md 内容和当前日期包装成一条合成的用户消息,插入到消息数组的最前面:
export function prependUserContext(
messages: Message[],
context: { [k: string]: string },
): Message[] {
if (Object.entries(context).length === 0) {
return messages
}
return [
createUserMessage({
content: `<system-reminder>
As you answer the user's questions, you can use the following context:
${Object.entries(context)
.map(([key, value]) => `# ${key}\n${value}`)
.join('\n')}
IMPORTANT: this context may or may not be relevant to your tasks.
You should not respond to this context unless it is highly relevant to your task.
</system-reminder>\n`,
isMeta: true,
}),
...messages,
]
}
几个设计决策值得注意:
<system-reminder>标签:不是随意命名——系统提示词中明确告诉模型"system-reminder 标签包含系统信息",建立了一种模型可理解的协议isMeta: true:标记为系统生成的消息,不出现在转录/导出中- "may or may not be relevant" 限定语:防止模型每次都对上下文做回应,只在真正需要时使用
通道三:附件消息
getAttachmentMessages() 是一个 async generator,每轮注入 40+ 种动态上下文:
export async function* getAttachmentMessages(
input: string | null,
toolUseContext: ToolUseContext,
ideSelection: IDESelection | null,
queuedCommands: QueuedCommand[],
messages?: Message[],
querySource?: QuerySource,
): AsyncGenerator<AttachmentMessage, void> {
const attachments = await getAttachments(
input, toolUseContext, ideSelection, queuedCommands, messages, querySource,
)
for (const attachment of attachments) {
yield createAttachmentMessage(attachment)
}
}
为什么需要三个通道?
| 维度 | 系统提示词追加 | 合成用户消息 | 附件消息 |
|---|---|---|---|
| 位置 | system 参数末尾 | 消息数组最前面 | 消息数组中间/末尾 |
| 缓存 | 静态前缀可全局缓存 | 会话级稳定 | 每轮变化 |
| 刷新 | 一次(会话开始) | 一次(会话开始) | 每轮重新计算 |
| 内容 | Git 状态 | CLAUDE.md, 日期 | 记忆、任务、工具列表、MCP 指令… |
| 成本 | 低(缓存命中) | 低(稳定) | 中(每轮计算) |
核心设计原则:按变化频率分通道。不变的放系统提示词(享受全局缓存),会话级的放合成用户消息(享受会话缓存),每轮变化的放附件(不影响前两者的缓存)。
16.3 附件系统架构
getAttachments:40+ 种附件的编排
getAttachments() 是附件系统的入口(src/utils/attachments.ts:743),按三个阶段组织附件的收集:
export async function getAttachments(
input, toolUseContext, ideSelection, queuedCommands, messages, querySource
): Promise<Attachment[]> {
// 阶段一:用户输入触发的附件(必须先完成)
const userInputAttachments = input ? [
maybe('at_mentioned_files', () => processAtMentionedFiles(input, context)),
maybe('mcp_resources', () => processMcpResourceAttachments(input, context)),
maybe('agent_mentions', () => processAgentMentions(input, ...)),
maybe('skill_discovery', () => getTurnZeroSkillDiscovery(input, ...)),
] : []
const userAttachmentResults = await Promise.all(userInputAttachments)
// 阶段二:线程安全的附件(子智能体也可用)— 并发执行
const allThreadAttachments = [
maybe('queued_commands', () => getQueuedCommandAttachments(queuedCommands)),
maybe('date_change', () => getDateChangeAttachment(messages)),
maybe('deferred_tools_delta', () => getDeferredToolsDeltaAttachment(...)),
maybe('agent_listing_delta', () => getAgentListingDeltaAttachment(...)),
maybe('mcp_instructions_delta', () => getMcpInstructionsDeltaAttachment(...)),
maybe('nested_memory', () => getNestedMemoryAttachments(...)),
maybe('todo_reminders', () => getTodoReminders(...)),
maybe('task_reminders', () => getTaskReminders(...)),
// ... 更多
]
// 阶段三:仅主线程的附件(IDE 集成、诊断等)
const mainThreadOnly = isMainThread ? [
maybe('ide_selection', () => getIDESelectionAttachments(...)),
maybe('diagnostics', () => getDiagnosticsAttachments(...)),
maybe('token_usage', () => getTokenUsageAttachment(...)),
// ... 更多
] : []
const [threadResults, mainResults] = await Promise.all([
Promise.all(allThreadAttachments),
Promise.all(mainThreadOnly),
])
return [...userAttachmentResults, ...threadResults, ...mainResults].flat()
}
maybe() 包装器:优雅降级
每个附件都通过 maybe() 包装,实现"单个附件失败不影响整体"的容错模式。如果某个附件计算函数抛出异常或返回空数组,其他附件照常注入。
三阶段执行顺序的必要性
阶段一(用户输入附件)必须在阶段二之前完成,因为 @提及文件 的处理结果会填充 nestedMemoryAttachmentTriggers,而阶段二的 nested_memory 附件依赖这个数据。 这种"用户输入 → 环境推导"的依赖关系要求严格的执行顺序。
附件到 API 消息的转换
附件不能直接发送给 API——它们在 normalizeMessagesForAPI() 中被转换。大多数附件被包装成 <system-reminder> 标签内的文本;文件附件被渲染为合成的 tool_use / tool_result 消息对,模拟"读取文件"工具的调用结果,让模型以它熟悉的格式理解文件内容。
16.4 Delta 附件模式
问题:每轮重新通知的代价
在 Claude Code 早期,智能体列表直接嵌入在 AgentTool 的工具描述中。这意味着每次 MCP 服务器连接/断开或 /reload-plugins 都会改变工具描述 → 触发工具 Schema 缓存失效 → 重新创建整个 Prompt Cache 前缀。数据显示这占了全网 cache_creation token 的约 10.2%。
解决方案:无状态扫描 + 增量发射
三种 Delta 附件(deferred_tools_delta、agent_listing_delta、mcp_instructions_delta)使用相同的模式:
export function getAgentListingDeltaAttachment(
toolUseContext: ToolUseContext,
messages: Message[] | undefined,
): Attachment[] {
// 步骤一:扫描消息历史,重建"已通知"的状态
const announced = new Set<string>()
for (const msg of messages ?? []) {
if (msg.type !== 'attachment') continue
if (msg.attachment.type !== 'agent_listing_delta') continue
for (const t of msg.attachment.addedTypes) announced.add(t)
for (const t of msg.attachment.removedTypes) announced.delete(t)
}
// 步骤二:计算当前实际状态
const currentTypes = new Set(filtered.map(a => a.agentType))
// 步骤三:对比,只发射增量
const added = filtered.filter(a => !announced.has(a.agentType))
const removed: string[] = []
for (const t of announced) {
if (!currentTypes.has(t)) removed.push(t)
}
// 无变化 → 不注入任何附件
if (added.length === 0 && removed.length === 0) return []
return [{
type: 'agent_listing_delta',
addedTypes: added.map(a => a.agentType),
addedLines: added.map(formatAgentLine),
removedTypes: removed,
isInitial: announced.size === 0,
}]
}
为何这个模式如此巧妙
-
无需维护显式状态:不用在某个全局变量里跟踪"上次通知了什么"——直接从消息历史重建。这消除了状态同步的 bug 风险
-
天然适配压缩:当 full compact 删除旧消息后,扫描结果变成空集(
announced = {}),下次自动生成一个完整通知(isInitial: true)。不需要任何特殊的"压缩后重建"逻辑 -
缓存友好:工具描述保持静态(不再嵌入动态智能体列表),Prompt Cache 不会因为智能体池变化而失效
-
Token 高效