数据截至 (上游 commit a4eaba4a56f9)
深入 — 消息解析、跨后端任务、边界与横向对比
本章给要读源码/排障的人。三块:消息解析怎么保证前向兼容、任务管理为什么要自造轮子、这个库的边界在哪。
1. 消息解析:parse_message
1.1 职责
把 CLI 的原始 JSON dict 变成带类型的消息对象(AssistantMessage、ResultMessage 等),给你干净的 Python 类型(message_parser.py:50)。
1.2 精妙处:未知类型不崩,直接跳过
最外层 match message_type 的兜底分支(message_parser.py:392-395)对不认识的消息类型只 log debug 后返回 None,不抛错。理由写在注释:"Forward-compatible: 新版 CLI 不该让老版 SDK 崩"。InternalClient 收到 None 就跳过(_internal/client.py:188-192)。这让 CLI 能先加新消息类型,SDK 慢慢跟。
1.3 内层却很严格
对认识的类型,缺必填字段会抛 MessageParseError(如 message_parser.py:146-149)。松于未知、严于已知——这是刻意的取舍。
1.4 内容块的分派
assistant 消息的 content 逐块 match block["type"],分派成 TextBlock/ThinkingBlock/ToolUseBlock/ToolResultBlock/ServerToolUseBlock/ServerToolResultBlock(message_parser.py:168-208)。其中 server 工具块(web_search、web_fetch 等,types.py:967 ServerToolName)是 API 服务端替模型执行的,你无需返回结果。
2. 跨 async 后端的任务管理:_task_compat
2.1 它要解决的小问题
Query 要管一堆后台任务(读循环、stream_input、控制请求 handler),而且要能从任何任务上下文取消它们——包括 async 生成器的 finalizer(Python 可能在另一个任务里跑它)。
2.2 为什么不能用 anyio TaskGroup
注释讲得很清楚(_task_compat.py:1-16):anyio 的 TaskGroup cancel scope 有任务亲和性——从不同任务退出它,要么抛 RuntimeError: Attempted to exit cancel scope in a different task,要么在 asyncio 后端 busy-spin。所以不能用。
2.3 解法:spawn_detached
spawn_detached(_task_compat.py:147)用 sniffio 探测当前后端,分派到对应原语,返回统一的 TaskHandle:
| 后端 | 底层 | 说明 |
|---|---|---|
| asyncio | loop.create_task() | 直接,自动继承 contextvars |
| trio | trio.lowlevel.spawn_system_task | 包一层自己的 CancelScope,并显式传 context= 继承 contextvars(_task_compat.py:177-180) |
| 其它 | — | 关掉协程后抛 RuntimeError |
这是个典型的"库要同时支持 asyncio 和 trio"的抽象层。trio 那边还额外处理了"系统任务不能抛异常否则崩 trio"——把异常存到 handle 上,.wait() 时再抛(_task_compat.py:118-144)。
3. 错误类型
一棵浅继承树(_errors.py),都继承 ClaudeSDKError:
| 异常 | 何时抛 |
|---|---|
CLIConnectionError | 连不上 CLI |
CLINotFoundError | 找不到 claude 二进制(CLIConnectionError 的子类) |
ProcessError | 子进程非零退出,带 exit_code / stderr |
CLIJSONDecodeError | JSON 解不动或超缓冲上限 |
MessageParseError | 认识的消息类型缺必填字段 |
4. 边界与局限(诚实)
- 它不是 agent 本体。 真正的 agent 循环、工具执行、模型调用全在
claudeCLI 里。这个 SDK 只是驱动器。想懂"agent 怎么决策",得去看 CLI(不在本仓库)。 - 依赖捆绑 CLI 版本。 最低要求
2.0.0(subprocess_cli.py:36MINIMUM_CLAUDE_CODE_VERSION),低于此只 warn 不拦。SDK 和 CLI 的协议要对齐,版本错配可能出怪问题。 - 进程内 MCP 已补齐。
_handle_sdk_mcp_request曾是手写 JSONRPC 路由、只实现initialize/tools/list/tools/call等少数方法;现已重构为SdkMcpBridge(sdk_mcp_bridge.py)——每个 SDK 服务器跑一个真实的Server.run会话,tools/resources/prompts 等全由 mcp 库分派,query.py:645-674只做转发。 - ClaudeSDKClient 不能跨 async 上下文复用。 见 01 章第 4 节(
client.py:58-64)。 - 镜像是"至多一次"。
SessionStore.append失败重试有限、超时不重试,可能丢批次(报MirrorErrorMessage)。本地转录才是权威副本。 - 插件只支持本地。
plugins目前只认type="local",其它抛ValueError(subprocess_cli.py:723-728)。
5. 横向对比(ai-agent-reference 货架)
这个 SDK 在"agent 框架"里的取舍很独特:
| 维度 | claude-agent-sdk 的取舍 |
|---|---|
| agent 循环放哪 | 外包给独立 CLI 子进程,SDK 只做驱动+协议。多数框架(如 LangGraph、Letta)把循环实现在库内。 |
| 工具协议 | 押注 MCP;进程内工具也套 MCP 的 JSONRPC 语义 |
| 并发模型 | 同时兼容 asyncio 和 trio(自造 spawn_detached),多数库只绑 asyncio |
| 状态持久化 | 本地 JSONL + 可插拔 SessionStore 镜像,而非直接绑某数据库 |
"把重活外包给子进程"这个决定是理解整个代码库的钥匙:它解释了为什么核心是一条控制协议、为什么配置要翻译成命令行 flag、为什么进程内工具要靠反向请求。
6. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 消息解析 | src/claude_agent_sdk/_internal/message_parser.py | parse_message |
| 内容块类型 | src/claude_agent_sdk/types.py | TextBlock ToolUseBlock ServerToolUseBlock ContentBlock |
| 结果消息 | 同上 | ResultMessage DeferredToolUse |
| 跨后端任务 | src/claude_agent_sdk/_internal/_task_compat.py | spawn_detached TaskHandle |
| 错误类型 | src/claude_agent_sdk/_errors.py | ClaudeSDKError CLINotFoundError ProcessError CLIJSONDecodeError |
| 版本下限 | src/claude_agent_sdk/_internal/transport/subprocess_cli.py | MINIMUM_CLAUDE_CODE_VERSION |