第 10 章
把循环交给 Harness:query、client 与 SDK 的架构
你不是在写循环,你是在配置一台已经在跑的循环。
装完 SDK 的第一天,我找了三遍 agent.run()。
没有。又找 while True,没有。再找 tool_result 回填,也没有。
我甚至怀疑自己装错了包。后来把 query() 跑起来,看到它启动了一个 Claude Code CLI 子进程,才明白:
循环不在 SDK 里,在 CLI 里。SDK 是客户端,不是引擎。
这一章讲这条边界。它决定了你后面怎么调试、怎么多轮、怎么中断,也决定了你为什么不能“改循环”。
10.1 第一次调用:query() 到底做了什么
先看最小的一版:
async def main():
options = ClaudeAgentOptions(
cwd=str(ROOT),
allowed_tools=["Read", "Edit", "Bash"],
permission_mode="acceptEdits",
max_turns=20,
max_budget_usd=1.0,
)
async for msg in query(prompt=TASK, options=options):
if isinstance(msg, AssistantMessage):
...
elif isinstance(msg, ResultMessage):
print(msg.result)query() 背后大概发生这些事:
1. 解析 ClaudeAgentOptions
2. 启动 Claude Code CLI 子进程(argv、cwd、env)
3. initialize 握手:协议版本、能力、工具
4. 把 prompt 通过 stdin 发过去(NDJSON)
5. CLI 跑循环:模型 → 工具 → 权限 → 上下文
6. CLI 把消息通过 stdout 吐回来(NDJSON)
7. Python 解析成 AssistantMessage / ResultMessage
8. 任务结束,进程退出所以 query() 不是一次模型调用,是“启动一个 Harness,跑一个任务,再关掉”。
这个边界带来三个后果:
- 状态在 CLI 里。 你的 Python 进程只是客户端;会话、上下文、工具执行都在另一边。
- 调试要看另一侧。
stderr、ResultError、进程退出码,都是线索。 - 不是所有东西都能在运行期改。 启动参数要重开进程;运行期只能改少数几个。
10.2 消息流:你收到的不是一段字符串
query() / receive_response() 给你的是消息流,不是一段字符串。常见五种:
| 类型 | 是什么 | 你通常拿它干什么 |
|---|---|---|
AssistantMessage | 模型的一轮输出,含 text / thinking / tool_use 块 | 打印文本、看工具调用 |
UserMessage | 工具结果或用户输入 | 调试、回放 |
SystemMessage | 初始化、状态变化 | 看 session、工具列表、状态 |
ResultMessage | 最终结果 | 取 result、subtype、usage、cost |
StreamEvent | 增量文本/工具事件 | 流式 UI(需 include_partial_messages) |
ResultMessage 里有几个字段,第一次用很容易看错:
subtype:为什么结束,success/error_max_turns/error_max_budget_usd等;is_error:和subtype一起看,别只看一个;result:最终文本;num_turns:CLI 的轮次计数,不要拿它推算模型调用次数;total_cost_usd:CLI 的估算值,接兼容端点时不是真实账单;session_id:后续resume要用。
第一次跑,我建议先把整条消息流打出来,别急着只取 result。你会看到 SystemMessage(init) 里的工具列表、UserMessage 里的工具结果、AssistantMessage 里的 tool_use 块。这些是你后面调试的全部线索。
一条典型的流大概是这样(示意,实际顺序和数量会变):
SystemMessage(init) tools=[Read, Edit, Bash] session_id=...
AssistantMessage text("I'll read README first.")
tool_use(Read, {"file_path": "README.md"})
UserMessage tool_result("...")
AssistantMessage text("The test command is ...")
ResultMessage subtype=success num_turns=3 ...注意 AssistantMessage 里可以同时有 text 和 tool_use;UserMessage 不一定是用户说的,也可能是工具结果。分析消息流时,别用“谁说的”来判断,用 type 和内容块判断。
10.3 ResultError:进程退出不等于任务失败
这是我最开始踩的坑。
有一次任务跑完,ResultMessage 已经是 success,但 CLI 进程退出码非零,SDK 抛了 ResultError。我第一反应是“任务失败了”,差点把结果丢掉。
实际上:
ResultMessage是任务结果;ResultError是进程退出通知。
两者不是一回事。正确写法是:
async def main():
try:
async for msg in query(...):
...
except ResultError as e:
# 结果可能已经拿到了,这里只是进程退出通知
print(e.subtype, e.terminal_reason)先看已经拿到的结果,再看异常。别让一个退出码把整次任务否掉。
10.4 query vs client:一次性 vs 长连接
query() 适合一次性任务:CI、批处理、跑完就走。
多轮对话不行。你每轮新建一个 query(),就是每轮启动一个 CLI 进程:启动成本、初始化握手、上下文重建,全都要付。
多轮用 ClaudeSDKClient:
async def main():
client = ClaudeSDKClient(options=options)
await client.connect()
try:
await client.query("Read README.md and tell me how to run the tests.")
async for msg in client.receive_response():
...
await client.query("Fix the failing test in tests/test_core.py.")
async for msg in client.receive_response():
...
finally:
await client.disconnect()区别:
query() | ClaudeSDKClient | |
|---|---|---|
| 生命周期 | 一次运行,进程退出 | 长连接,多次 query |
| 适合 | 批处理、CI、一次性任务 | 聊天、多轮、需要中断 |
| 多轮 | 靠 resume 或新进程 | 原生 |
| 运行期控制 | 基本没有 | interrupt() / set_model() / set_permission_mode() |
| 状态 | 每次重新开始 | 进程内保留 |
一句话:一次性任务用 query(),交互/多轮用 ClaudeSDKClient。
10.5 启动参数 vs 运行期控制
这是 SDK 新手最容易混的一点。
只能在启动时给(改了要重开进程):
cwd:工作目录;tools/allowed_tools/disallowed_tools:工具面;system_prompt/setting_sources:上下文与项目设置;mcp_servers/agents:扩展能力;max_turns/max_budget_usd:循环刹车;permission_mode:权限模式(部分可运行期改)。
可以在运行期改:
interrupt():打断当前轮;set_model():换模型;set_permission_mode():换权限模式;get_context_usage():查上下文;get_mcp_status():查 MCP;rewind_files():回滚文件(需开启 checkpointing)。
所以别指望“运行中换工具集”——那要重开进程。运行期能改的,只有那几个明确的控制请求。
一个具体的例子:你在聊天里发现它用了错的模型,可以 set_model();但你发现它多了一个不该有的工具,只能断开、改 options、重连。
10.6 和 tiny 比:少写的代码去哪了
| tiny(第 9 章) | SDK(本章) | |
|---|---|---|
| 循环 | 你的 while True | CLI |
| 工具 | 六个手写 | 内置 + MCP |
| 权限 | Policy | can_use_tool + 模式 |
| 上下文 | Context | system_prompt / setting_sources |
| 会话 | SessionLog | resume / session_store |
| 代码量 | 约 550 行 | 几十行 |
| 调试 | 你的日志 | stderr / 消息流 / 进程边界 |
| 可控性 | 全改 | 只能配/扩 |
少写的代码不是消失了,是搬到了 CLI 里。
你换来了更成熟的循环、工具和权限,也换来了一个进程边界和一套闭源实现。这个交换值不值,取决于你要做什么:
- 如果你要的是“快速做一个能用的 Agent”,值;
- 如果你要的是“完全控制循环的每一步”,不值,回第 3 章自己写。
第 2 章说的“产品 vs 库”,现在你能亲手感受到它了。
10.7 第一次用 SDK 的三个坑
第一,以为 query() 是模型调用。
不是,它启动一个 Harness。启动有成本,进程有生命周期。你在循环里反复 query(),就是在反复启动 CLI。
第二,没设 cwd / setting_sources。
setting_sources 的默认值是 None,意思是“全部加载”,不是“什么都不加载”。它可能让 Agent 看不到项目文件,也可能意外加载项目/用户设置和技能;行为随机器变。要可复现,显式设 setting_sources=[] 或 ["project"]。
第三,把 ResultError 当“没结果”。
ResultMessage 可能已经到了;先看结果,再看异常。这个坑我在 10.3 讲过,但值得再说一遍,因为它会让你误判一整次任务。
10.8 实测:2026-10-21
环境:claude-agent-sdk==0.2.168(源码安装)、Claude Code CLI 2.1.296、模型 deepseek-flash、Anthropic 兼容端点。
ch10_query.py 的实际输出(节选):
[stderr] [claude-code:unrecognized_model] {"model":"deepseek-flash","query_source":"sdk"}
[tool] Skill {'skill': 'fix-failing-test'}
[tool] Read {'file_path': '.../README.md'}
[tool] Read {'file_path': '.../tests/test_core.py'}
[tool] Bash {'command': 'python3 -m unittest discover -s tests -v 2>&1 | tail -60'}
[tool] Bash {'command': "find .../taskflow -type f -name '*.py'"}
[tool] Read {'file_path': '.../taskflow/core.py'}
[tool] Edit {'file_path': '.../taskflow/core.py', ...}
[tool] Bash {'command': 'python3 -m unittest discover -s tests -v 2>&1'}
[result] subtype=success turns=10 cost=0.170644结果:测试 2/2 通过,没改测试;它把 days_left 改成直接返回 (task.due - today).days。
两个真实观察:
- 它先调了
Skill。 第 10 章的代码没有配置skills,但setting_sources默认是None,CLI 加载了项目的.claude/skills/。这就是 10.7 第二个坑的实例:默认值会让 Agent 看到你没显式配置的东西。 - 10 轮、$0.1706。 比脚本预期的 6 轮多;多出来的轮次花在“找实现文件”和“跑测试”上。
cost是 CLI 的估算值,接兼容端点时不是真实账单。
ch10_client.py 是两次 query:
- 第一轮“读 README 说测试命令”:2 轮,$0.038862;
- 第二轮“修失败测试”:7 轮,$0.120393;改成
return delta if delta > 0 else 0,测试通过。
两轮都没有改测试。第一轮它还说了一句:“运行命令来自项目 CLAUDE.md”;说明默认设置确实被加载了。
10.9 下一章:工具面
现在你知道 SDK 怎么把循环接起来了。下一步是:循环里给模型哪些工具?
第 11 章讲工具面:内置工具、tools/allowed_tools/disallowed_tools 的区别、进程内 @tool、MCP 命名,以及“工具不是越多越好”。
本章代码 单次运行:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/sdkagent/ch10_query.py 多轮长连接:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/sdkagent/ch10_client.py 第 1 章的最小版本:https://github.com/kevinkelin/claude-agent-sdk-book/blob/master/code/sdkagent/v0_query.py 实测环境:SDK 0.2.168 + CLI 2.1.296 +
deepseek-flash(兼容端点);结果见 10.8。成本是 CLI 估算,不是账单。