Claude Code 深度拆解:上下文里有什么——消息上下文管理

2 阅读12分钟

Hi,大家好,欢迎来到维元码簿。

本文属于 《Claude Code 源码 Deep Dive》 系列,专注于上下文工程中的 Messages 板块。如果你想了解整个系列,可以先看系列开篇 | Claude Code 源码架构概览:51万行代码的模块地图

本文聚焦一件事:你打了一句话,模型实际收到了多少信息?——Messages 背后的类型映射、隐藏注入和缓存策略。

读完全文,你将能回答这几个问题:

  • 为什么内部用 5 种消息类型,API 只认 2 种? 中间的清洗管道在做什么?
  • 除了你主动输入的文字,还有哪些信息悄悄混进了 Messages? CLAUDE.md、日期、IDE 选中代码、工具结果……它们是怎么塞进去的?
  • 为什么旧的工具结果能被"删除"但不破坏缓存? cache_edits 用了什么精妙的设计?

前情提要:模型收到了什么

在姊妹篇[身份设定与环境感知](./02-Claude Code深度拆解-上下文里有什么-System Prompt工程.md)中,我们拆解了发给模型的第一大数据块——System Prompt(模型是谁、怎么做事)。但模型收到的不只是指令,还有对话历史、工具结果、隐藏注入……这些全部塞在一个叫 messages 的数组里。

paramsFromContext() 返回的完整参数中,真正变成 token 喂给模型的有三个字段:

字段作用预估占比
system告诉模型"你是谁、怎么做事"的指令集~30%
messages对话历史:用户输入、模型回复、工具调用结果~60%
tools工具 Schema:告诉模型可以调用哪些工具~10%

在这里插入图片描述

本文拆解第二个板块 Messages——你打了一句话,模型实际收到了多少信息。 System Prompt 和 Tools 分别在姊妹篇中展开。Messages 是上下文中占比最大的板块(约 60%),也是变化最频繁的部分——每个 turn 都会追加新消息。但 Messages 里不只有用户的键盘输入,还有工具结果、附件展开、系统提醒等大量隐藏注入。

Messages——承载对话与隐藏注入的"消息管道"

System Prompt 设定了"你是谁"和"你在什么环境里",Messages 承载"发生了什么"。理解 Messages 的关键在于三个问题:

  • 为什么内部用 5 种类型,API 只认 2 种? 中间需要一个清洗管道做 5→2 的映射。
  • 为什么每条消息内部还有更小的单元? 每条消息的 content 是一个 Content Part 数组——文本、图片、工具调用、工具结果,每个都是独立的 Part。
  • 除了用户主动输入,还有哪些信息悄悄注入了 Messages? UserContext 和 Attachments——它们不来自用户的键盘,但模型每次调用都能看到。

所以这一章的结构是:先看类型映射,再看 Content Part 和清洗管道,最后看两个隐藏注入点。

内部 5 种类型 vs API 2 种类型

在这里插入图片描述

Claude Code 内部维护 5 种消息类型,经过清洗管道后只保留 API 认识的 2 种。其中 UserMessage 和 AssistantMessage 直接映射,AttachmentMessage 中转后映射,SystemMessage 和 ProgressMessage 在管道中被过滤。

内部类型API 映射是否持久化核心职责
UserMessagerole: 'user'最忙的类型——用户输入、工具结果、附件、系统提醒
AssistantMessagerole: 'assistant'模型回复——唯一由模型"创造"的类型
AttachmentMessage先转为 UserMessage → 再映射上下文补充信封——不直接发 API
SystemMessage不发给 API内部状态标记(压缩边界、系统通知)
ProgressMessage不发给 API工具执行进度——仅 UI 展示

UserMessage 是"最忙"的类型,它承载的信息远不止用户的键盘输入:

  • 工具执行结果——每个 tool_result Content Part 都以 UserMessage 形式存在
  • 附件转换后的内容——AttachmentMessage 展开后注入为 UserMessage
  • 系统提醒——包裹在 <system-reminder> 标签中,通过 isMeta 标记对用户隐藏但模型可见
  • 压缩摘要——上下文压缩后的关键信息以 UserMessage 形式恢复到对话中

isMeta 标记是这里的关键设计:它让系统可以向模型传递额外信息(如 CLAUDE.md 内容),而不干扰用户的终端阅读体验——用户不会在终端里看到这些"隐藏消息"。

AssistantMessage 是 Agent 循环的"发动机"。每次模型返回 stop_reason: 'tool_use',系统就执行对应工具、把结果作为 UserMessage 追加、再调用模型——这个循环持续到 end_turn 或达到停止条件。

为什么需要 5 种类型,而不是直接用 API 的 2 种? 因为 Claude Code 内部需要区分信息的来源和用途:SystemMessage 记录压缩边界("从这里开始是压缩后的摘要"),ProgressMessage 驱动终端进度条,AttachmentMessage 封装系统自动收集的信息。如果直接用 user/assistant 两种,这些内部状态就无处安放了——要么丢失功能,要么用额外字段模拟类型系统。5 种类型是"内部灵活性"和"API 简洁性"之间的桥梁。

Content Parts 与清洗管道

上面讲了消息的"类型"层面(5→2 映射),现在看消息的"内容"层面——每条消息的 content 是一个 Content Part 数组,每个 Part 是 API 层面最小的信息单元:

在这里插入图片描述

类型方向说明
text双向文本内容,最基础的类型
image / documentUser → API图片和 PDF,base64 编码
tool_useAssistant → API模型决策调用工具,包含 idnameinput
tool_resultUser → API工具执行结果,通过 tool_use_idtool_use 配对
thinking / redacted_thinkingAssistant → APIExtended Thinking 的思维链
tool_referenceUser → API延迟工具发现——按需加载 MCP 工具

tool_usetool_result 的配对是 Agent 循环的核心机制——配对通过 tool_use_id 保证,如果压缩导致配对断开,ensureToolResultPairing() 会自动修复。

这些内部结构最终都要通过 normalizeMessagesForAPI() 清洗为 API 能理解的格式。这个约 380 行的管道处理以下步骤:

  1. 过滤:移除 SystemMessage 和 ProgressMessage——它们对 API 没有意义
  2. 展开:AttachmentMessage 通过 attachmentToMessages() 转为 UserMessage
  3. 配对修复ensureToolResultPairing() 确保每个 tool_use 后面都有 tool_result
  4. 字段清理:移除 tool_reference(延迟加载模式)、advisor 相关字段等内部标记
  5. 不完整消息处理:如果最后一条 AssistantMessage 的 stop_reasonnull,这条消息不能发给 API
  6. 缓存断点标记addCacheBreakpoints() 在每条消息的最后一个 Content Block 上附加 cache_control

理解这个管道的意义在于:当你遇到"模型为什么看不到某个信息"或"为什么某个工具结果没传给模型"时,可以逐层排查——是过滤了?是配对修复了?还是缓存断点标记有问题?

举一个具体的例子:一条消息的完整旅程。 你在终端输入"帮我读一下 main.ts"。这条输入先变成一个 UserMessage。模型回复一个 AssistantMessage,里面包含一个 tool_use(调用 Read 工具)。系统执行 Read,把文件内容包装成 tool_result Content Part,再包在一个新的 UserMessage 里追加到对话。下一轮,模型看到文件内容后开始分析,回复文本和可能的修改建议(又一个 AssistantMessage)。

这只是明面上的消息流。与此同时,prependUserContext() 已经在 messages[0] 塞入了 CLAUDE.md 的内容和今天的日期。Attachments 系统可能收集了 IDE 诊断信息或之前压缩恢复的上下文。这些"隐藏消息"你不会在终端里看到(因为 isMeta 标记),但模型每次调用都能读到。

UserContext:注入到 messages[0] 的隐藏信息

除了用户主动输入的消息,还有一类"隐藏"的 UserMessage——UserContext。prependUserContext() 在消息数组最前面注入一条包裹在 <system-reminder> 中的 UserMessage:

// 简化
export function prependUserContext(messages, context) {
  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>`,
      isMeta: true,  // 对用户隐藏,不显示在终端
    }),
    ...messages,
  ]
}

UserContext 包含两个关键信息:

claudeMdCLAUDE.md 文件的内容——用户自定义的项目级指令。Claude Code 自动发现并加载所有 CLAUDE.md(当前目录及上级目录递归搜索)。模型在每个新会话中都能"看到"这些约定。

currentDate:当前日期,如 "Today's date is 2025-04-18."。模型需要知道"今天是哪天"来判断 Git 日志时效性、时间相关的 Bug 等。

注意那句精心设计的提示词:IMPORTANT: this context may or may not be relevant to your tasks. 它防止模型过度依赖 CLAUDE.md——用户问"今天天气怎么样"时,模型不应该引用 CLAUDE.md 里的 TypeScript 约定。

与之对应的是 SystemContext,它追加在 System Prompt 尾部。两者都叫"Context",但设计意图完全不同:SystemContext 是环境状态(身份层面),UserContext 是项目知识(对话层面)——详见姊妹篇[Claude Code 的身份设定与环境感知](./02-Claude Code深度拆解-上下文里有什么-System Prompt工程.md)。

Attachments:补充信封的展开策略

Attachments 是 Claude Code 最独特的设计之一。它不是直接发给 API 的消息,而是一种"信封"——系统在每个 turn 自动收集各种补充信息,包装为 Attachment,然后通过 attachmentToMessages() 转换为 UserMessage 注入对话。

src/utils/attachments.ts(~4000 行)定义了 40+ 种 Attachment 子类型,分为 5 大类:

在这里插入图片描述

大类典型子类型触发时机
用户输入触发file(@-mention)、selected_lines_in_idemcp_resource用户操作时
线程级relevant_memoriesdate_changediagnostics每 turn 自动收集
Hook 相关async_hook_responsehook_blocking_errorHook 回调时
压缩恢复compact_file_referenceplan_file_referencetask_status压缩后自动恢复
系统/状态token_usagedeferred_tools_deltamcp_instructions_delta状态变化时

在这里插入图片描述

为什么需要 Attachment 机制而不直接放 System Prompt?

答案在于"变化频率"。System Prompt 相对稳定(每会话只组装一次),但有一类信息是"系统自动收集、每 turn 可能变化"——比如 IDE 选中的代码、Hook 结果。这些信息变化频率太高,放在 System Prompt 里会破坏 prompt cache。作为附件注入到消息流中,可以保持 System Prompt 的稳定性。

这也解释了 mcp_instructions 的设计演进:原来是每 turn 重算(破坏缓存),改为 mcp_instructions_delta 附件后,只在 MCP Server 连接/断开时注入增量,System Prompt 保持稳定。同样的信息,用不同的传递方式,可以带来巨大的成本差异。

Attachments 的信息量有多大? 一个典型场景:你在 IDE 中选中了一段代码并开始对话。此时系统自动收集的附件可能包括:选中的代码文本(selected_lines_in_ide)、当前文件的语言和路径(ide_context)、Git 状态变化(git_status)、之前的压缩恢复数据(compact_file_reference)。你的输入可能只有一句话,但模型实际收到的上下文可能包含几千字的补充信息。这就是 Attachments 的威力——它把"系统知道的"和"用户说的"无缝融合在一起。

Messages 的缓存断点

Messages 的缓存策略相对 System Prompt 更简单:在每条消息上标记一个 ephemeral 断点。 这样当新消息追加时,之前的消息可以从缓存读取——每轮只处理新增的那一条。

但"每条消息加一个标记"这件简单的事,在复杂场景下有不少工程挑战:消息可能连续出现相同 role(API 要求 user/assistant 交替)、单条消息可能过大(需要拆分以优化缓存粒度)、工具搜索结果需要在正确位置插入。

其中最精妙的设计是 cache_edits——不在本地修改消息,而是通过 API 的 Cache Editing 功能直接在服务端删除旧工具结果。本地消息数组不变,prompt 前缀完全一致,缓存完美命中。"删除"操作在服务端透明完成。cache_edits 的完整生命周期(注册、决策、构建、插入、pin/replay)在姊妹篇[缓存工程深度拆解](./02-Claude Code深度拆解-上下文里有什么-Prompt Cache机制.md)中展开。

本章小结

Messages 看起来只是"用户说一句、模型回一句"的简单结构,但内部的工程体系远比表面复杂:

在这里插入图片描述

从消息源到最终发送给 API 的消息,经过了类型映射、Content Part 组装、清洗管道三个阶段。整个过程中有两个关键设计:

  • 变化隔离:附件注入 Messages 而非 System Prompt,高变化频率的内容不影响低频内容的缓存
  • 隐藏注入:UserContext 和系统提醒通过 isMeta 对用户隐藏,但模型每次都能看到

你看到的是一句话,模型收到的是一封被层层塞过信件的"信封"。CLAUDE.md 的指令藏在 messages[0],IDE 选中代码作为附件展开,工具结果经过 6 步清洗管道——最终模型看到一个干净但信息丰富的对话流。


系列导航

本文属于 《Claude Code 源码 Deep Dive》 系列中「上下文组成与缓存」命题的子篇章,专注于 Messages 的清洗、注入与缓存。

本文是完整版《Claude Code 发给模型的是什么:拆解上下文的组成与缓存》的子命题之一。如果你想了解上下文编排的全景(System Prompt + Messages + Tools + 缓存工程),推荐阅读完整版。

姊妹篇(可独立阅读):

如果这篇文章对你有帮助,欢迎点赞收藏支持一下。有任何想法或疑问,欢迎评论区留言讨论 👋