用 Claude Agent SDK 跑一次 query(),底层流回来的是结构化消息流,承载它的是 user、assistant、system、tool_result 与 result 五种类型;每种在循环里扮演不同角色——输入回声、模型回复、环境配置、工具回执、终止信号。把这五类认清,所有状态机分支、UI 进度展示、计费统计就有了落点。
一、消息流的整体形状
query() 返回一个 AsyncGenerator<SDKMessage>,调用方用 for await 不断 yield 消息。官方将 SDKMessage 定义为带 type 判别字段的联合类型,五种 type 构成一次完整循环的语义骨架。
| 消息类型 | 触发时机 | 携带核心字段 | 循环中的角色 |
|---|---|---|---|
system |
会话初始化、插件加载、配置变更 | session_id、tools、model、cwd |
描述当前运行环境的元信息 |
user |
应用层推入提示或多轮追问 | message.content(文本或多模态) |
把外部输入送进模型或回显到流 |
assistant |
模型生成的回复 | content[](text / tool_use / thinking) |
模型表达思考、文本与工具意图 |
tool_result |
工具执行完成后回传 | tool_use_id、content、is_error |
把工具输出反馈给模型继续推理 |
result |
整轮或整次调用结束 | subtype、total_cost_usd、usage |
终止信号 + 成本/统计/错误摘要 |
stream_event 是增量分片,不在主循环骨架里,与上面五种属于”半步”粒度消息。理解这五类是看懂状态机的前提。
二、五种类型的语义角色
把循环压扁成”谁说话、谁干活、谁收尾”,每个角色都能落到具体消息上。
2.1 system:环境契约
system 消息在 query() 启动、插件加载、权限模式切换时触发,常见 subtype 是 init。它的核心作用是让调用方知道”接下来跑在什么环境里”:当前 session_id、可用 tools 列表、model 名称、cwd、permissionMode。前端拿这条消息做就绪态展示,后端用它做审计锚点。
{
"type": "system",
"subtype": "init",
"session_id": "9c5b4c12-...",
"tools": ["Read", "Edit", "Bash", "Glob", "Grep"],
"model": "claude-sonnet-4-6",
"cwd": "/repo/code-review",
"permissionMode": "acceptEdits"
}
UI 接住它就显示”已连接,会话 id 9c5b4…”,这条信息不需要再发回模型,是纯环境描述。
2.2 user:输入回声
应用层把提示词以 prompt 字符串或 AsyncIterable<SDKUserMessage> 形式交给 query(),循环会原样回放一条 user 消息。它的语义是”这是用户说的”,不是工具结果,也不是模型自己想起的补充;调试时看到 user 就知道是入口。user 消息的 content 支持文本、图片等多模态块,多轮场景下还可以中途异步 yield 一条新 user 消息来打断长任务。
2.3 assistant:模型输出
assistant 是循环里最常出现的消息类型,content 是一个 ContentBlock[],三种块按需组合:
text:自然语言回复;tool_use:模型决定调用某个工具,附id、name、input;thinking:扩展思考开关打开后附带的内部推理。
循环里它承担两件事:把”我打算做什么”用文本说给用户听;用 tool_use 表达”请外部替我执行这一步”。后者会触发下一条 tool_result,循环就靠这个来回推进。
{
"type": "assistant",
"message": {
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "先把目录列出来再读关键文件" },
{ "type": "text", "text": "我先看一下仓库结构" },
{ "type": "tool_use", "id": "toolu_01", "name": "Bash", "input": { "command": "ls -la" } }
]
}
}
2.4 tool_result:执行回执
tool_result 不会主动产生——它在 tool_use 之后被 SDK 注入。核心字段是 tool_use_id,与上一条 assistant 里的 tool_use.id 严格对应,content 承载工具返回的字符串或结构化结果,is_error 标记失败。
语义上有两条要注意:
- 工具失败仍属于正常循环信号,模型会读到
is_error=true后决定重试、改方案或放弃; tool_result是循环”记忆”的一部分,模型下一轮决策要参考它,因此不要在 UI 层私自吞掉。
{
"type": "user",
"message": {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_01",
"content": "src README.md package.json",
"is_error": false
}
]
}
}
注意 tool_result 实际上挂在 user 通道下,模拟”用户把工具结果转交给模型”——这是 Anthropic Messages API 的设计,循环里把它单列成一种类型更直观。
2.5 result:终止与统计
循环到 max_turns、end_turn、错误或用户主动停止时,最后一条消息是 result,subtype 通常是 success,错误场景下是 error_max_turns、error_during_execution 之类。它带 total_cost_usd、usage(输入/输出 token、缓存读写)、turn_count,是 UI 显示”完成 + 花了多少 + 转了几次”的唯一来源。
{
"type": "result",
"subtype": "success",
"total_cost_usd": 0.0231,
"usage": {
"input_tokens": 412,
"output_tokens": 612,
"cache_read_input_tokens": 1024
},
"turn_count": 4
}
看到 result 就可以关掉进度条、扣减预算、写日志。
三、循环的状态机视角
把五种消息摆进时间轴,一次完整的 query() 大致长这样:
- 应用层
query({ prompt }); - SDK 立刻
yield system(init),给出会话配置; - SDK 注入
user,回放提示词; - 模型思考后
yield assistant(含 text 或 tool_use); - 工具执行,SDK 注入
tool_result; - 回到第 4 步,直到模型给出”无 tool_use 的 assistant”或达到
maxTurns; - 末条
result(success)收尾。
每一步都不是”SDK 在猜”,而是某一种消息被严格产生。循环天然支持 stream_event 增量分片,但它位于主流程之外,不打断状态机。
四、常见消费模式
4.1 抽取所有工具调用
import { query } from "@anthropic-ai/claude-agent-sdk";
function extractToolUses(messages: SDKMessage[]) {
const calls: Array<{ id: string; name: string; input: unknown }> = [];
for (const m of messages) {
if (m.type !== "assistant") continue;
for (const block of m.message.content) {
if (block.type === "tool_use") {
calls.push({ id: block.id, name: block.name, input: block.input });
}
}
}
return calls;
}
4.2 实时打印进度
for await (const m of query({ prompt: "重构支付模块" })) {
if (m.type === "system" && m.subtype === "init") {
console.log("会话", m.session_id, "工具数", m.tools.length);
} else if (m.type === "assistant") {
for (const b of m.message.content) {
if (b.type === "text") console.log("Claude:", b.text);
else if (b.type === "tool_use") console.log("→ 调用", b.name);
}
} else if (m.type === "result" && m.subtype === "success") {
console.log("完成,花费", m.total_cost_usd, "轮次", m.turn_count);
}
}
4.3 错误分流
result.subtype 是分流的关键:success 进入成功分支,error_max_turns 走”轮次耗尽”分支,error_during_execution 走”中途异常”分支,分别记录到不同告警通道。
五、循环之外的注意点
- 启用
includePartialMessages: true后会出现stream_event增量消息,用来驱动打字机式 UI;它不替代上面五类,只是把assistant内容拆细; - V2 API 通过
unstable_v2_createSession维持多轮上下文,会话恢复时 SDK 仍按这五类消息回放,但由会话对象托管而非query()一次性 yield; - 权限拦截在
permissionMode: "plan"或自定义canUseTool下会以tool_result(is_error=true)形式出现,循环不会断; - 计费只看
result.usage,不要拿tool_result的体积反推 token。
到这里,一次 query() 循环从初始化、用户回声、模型决策、工具回执到终止统计的语义就完整了;把这五类消息分别落到 UI、审计、计费三处,整套 SDK 编程就立得起来。
常见问题(FAQ)
Q1:为什么 tool_result 挂在 user 通道里?
沿用了 Anthropic Messages API 的角色设计:把”用户把工具输出转交模型”模拟为用户通道消息,循环里单独识别即可。
Q2:stream_event 算第六种消息类型吗?
不算主循环骨架,它是 assistant 内容的增量分片,启用 includePartialMessages 时才出现。
Q3:怎么判断循环是真的结束还是被中断?
只有 result 消息是终止信号,循环里见不到 result 就代表被 AbortController 打断,需要应用层补一次状态补偿。