乐于分享
好东西不私藏

Claude Code 源码揭秘:消息结构里藏着的秘密

Claude Code 源码揭秘:消息结构里藏着的秘密

↑阅读之前记得关注+星标⭐️,😄,每天才能第一时间接收到更新

「Claude Code 源码揭秘」系列的第二篇,上一篇聊了 Agent 循环的细节,但那个循环操作的核心数据结构是什么?是消息数组。每一条用户输入、模型回复、工具请求、工具结果,全部通过这个数组流转。

今天拆解一下这个消息结构。有个设计让我第一次看到的时候愣了一下——工具结果居然是伪装成用户消息发的。


消息数组的基本结构

Claude Code 的对话状态就是一个消息对象数组,遵循 Anthropic Messages API 的格式。每条消息有两个关键字段:role(角色,只有 user 和 assistant 两种)和 content(内容,可以是字符串或内容块数组)。

messages = [  { role: "user", content: "..." },  { role: "assistant", content: [...] },  { role: "user", content: [...] },  { role: "assistant", content: [...] }]

有一条死规矩:角色必须交替。用户消息后面必须是助手消息,助手消息后面必须是用户消息。这个约束影响了整个工具结果的处理方式。


内容块的几种类型

content 字段可以包含不同类型的内容块。

文本块——最简单的,两种角色都能用:

{  "type": "text",  "text": "src 目录里有什么文件?"}

工具调用块——只有 assistant 角色能发。模型想用工具的时候,就发这个:

{  "type": "tool_use",  "id": "toolu_01ABC123",  "name": "Read",  "input": {    "file_path": "/project/package.json"  }}

三个关键字段:id 是这次工具调用的唯一标识,name 是工具名称,input 是工具参数。

工具结果块——这里有意思了。工具结果是作为 user 消息发的,不是什么特殊角色:

{  "type": "tool_result",  "tool_use_id": "toolu_01ABC123",  "content": " 1\t{\n 2\t \"name\": \"my-project\",\n 3\t \"version\": \"2.0.0\"\n 4\t}",  "is_error":false}

tool_use_id 必须跟对应的 tool_use 块的 id 匹配。is_error 标记工具是否执行失败——这个字段很关键,后面会说。

图片块——只有 user 角色能发,用来附加图片:

{  "type": "image",  "source": {    "type": "base64",    "media_type": "image/png",    "data": "iVBORw0KGgoAAAANSUhEUg..."  }}

工具结果伪装成用户消息——这个设计挺巧妙

这是最容易让人困惑的地方:没有 tool 角色。工具输出被格式化成包含 tool_result 块的用户消息。

为什么这么设计?

因为角色必须严格交替。模型发了 tool_use 请求,按规矩下一条必须是 user 消息。工具结果正好可以”借用”这个位置。

更深层的原因是,模型需要把工具输出当成普通输入来处理。用用户消息的形式发送,对话就能保持严格的交替模式,模型把工具输出当成新信息来推理。

user: "package.json 里版本号是多少?"assistant: [tool_use: Read package.json]  ← 模型请求工具user: [tool_result: 文件内容]              ← CLI 把结果当用户消息发回去assistant: "版本号是 2.1.0"                ← 模型回复

我第一次看到这个设计的时候觉得有点”取巧”,但仔细想想,确实是最简洁的方案。不用引入新角色,不用改 API 规范,利用现有的交替规则就把问题解决了。


一个完整的对话示例

来看一个真实的多轮对话长什么样。

第一轮:用户提问

{  "role": "user",  "content": "package.json 里的版本号是多少?"}

第二轮:助手调用 Read 工具

{  "role": "assistant",  "content": [    {      "type": "text",      "text": "我来读一下 package.json 文件。"    },    {      "type": "tool_use",      "id": "toolu_01Read",      "name": "Read",      "input": { "file_path": "package.json" }    }  ],  "stop_reason": "tool_use"}

第三轮:工具结果(作为用户消息)

注意 Read 工具的输出带行号:

{  "role": "user",  "content": [    {      "type": "tool_result",      "tool_use_id": "toolu_01Read",      "content": " 1\t{\n 2\t \"name\": \"my-project\",\n 3\t \"version\": \"2.1.0\",\n 4\t \"description\": \"A sample project\"\n 5\t}"    }  ]}

第四轮:助手最终回复

{  "role": "assistant",  "content": [    {      "type": "text",      "text": "package.json 里的版本号是 2.1.0。"    }  ],  "stop_reason": "end_turn"}

一个简单的问题,四条消息。这也是为什么工具调用多的任务会快速消耗上下文窗口。


一次调用多个工具

助手可以在一条消息里请求多个工具:

{  "role": "assistant",  "content": [    {      "type": "text",      "text": "我来搜索 TypeScript 文件并查找 TODO 注释。"    },    {      "type": "tool_use",      "id": "toolu_01Glob",      "name": "Glob",      "input": { "pattern": "**/*.ts" }    },    {      "type": "tool_use",      "id": "toolu_02Grep",      "name": "Grep",      "input": { "pattern": "TODO", "path": "src/" }    }  ],  "stop_reason": "tool_use"}

对应的工具结果也打包在一条用户消息里:

{  "role": "user",  "content": [    {      "type": "tool_result",      "tool_use_id": "toolu_01Glob",      "content": "src/index.ts\nsrc/utils.ts\nsrc/types.ts"    },    {      "type": "tool_result",      "tool_use_id": "toolu_02Grep",      "content": "src/index.ts:45: // TODO: refactor this"    }  ]}

多个 tool_use 对应多个 tool_result,通过 id 匹配。清晰明了。


工具失败的处理

工具执行失败怎么办?用 is_error 标记:

{  "role": "user",  "content": [    {      "type": "tool_result",      "tool_use_id": "toolu_01Edit",      "content": "Error: old_string not found in file. The content may have changed.",      "is_error":true    }  ]}

模型看到这个错误,可以决定重新读文件、换个方法试、或者把失败情况告诉用户。

这个设计比硬编码异常处理灵活多了。不是代码来决定”文件读取失败后该干嘛”,而是让模型自己判断。上一篇也提到过这个思路。


System Prompt 不在消息数组里

还有个容易搞混的点:系统提示词不是消息数组的一部分,而是单独的参数:

await anthropic.messages.create({  model: "claude-sonnet-4-20250514",  system: systemPrompt,    // 单独的参数  messages: messages,      // 对话历史  tools: toolDefinitions,  stream: true});

系统提示词包含身份指令、环境信息(工作目录、平台、日期)、CLAUDE.md 内容、工具使用指南、行为约束等。它保持不变,而消息数组随着交互不断增长。


消息验证规则

API 有严格的校验:

  • • 角色必须交替:user 和 assistant 必须轮流
  • • 第一条必须是 user:对话必须由用户发起
  • • tool_use_id 必须匹配:每个 tool_result 的 tool_use_id 必须对应前面某个 tool_use 的 id
  • • 内容不能为空:消息不能有空的 content 数组
  • • JSON 必须合法:工具输入必须是有效的 JSON

违反这些规则会直接报 API 错误。


Token 消耗

不同内容类型消耗 token 不一样:

  • • 文本块:按普通文本分词
  • • 工具调用块:输入 JSON 分词,加大约 20 token 开销
  • • 工具结果块:内容按文本分词,加大约 10 token 开销
  • • 图片块:根据图片尺寸计算

一次工具调用循环就增加两条消息(助手请求 + 用户结果),这就是为什么工具调用多的对话会快速吃掉上下文。后面专门聊上下文管理的时候会深入讲这个问题。


理解了消息结构,很多之前觉得奇怪的行为就能解释了。比如为什么有时候模型会”忘记”之前读过的文件——可能是老消息被压缩掉了。比如为什么多轮对话越来越慢——消息数组越来越大,每次 API 调用都要发完整历史。

下一篇聊工具执行流程——从权限检查到结果格式化,工具调用经过了哪些环节。

本文基于 Claude Code 2.0.76 版本源码分析。

最后记得⭐️我,每天都在更新:欢迎点赞转发推荐评论,别忘了关注我

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » Claude Code 源码揭秘:消息结构里藏着的秘密

评论 抢沙发

2 + 2 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮