合集

Claude Agent SDK 实战

已更新 27 章
阅读进度 41%

第 10 章

把循环交给 Harness:query、client 与 SDK 的架构

你不是在写循环,你是在配置一台已经在跑的循环。

装完 SDK 的第一天,我找了三遍 agent.run()。

没有。又找 while True,没有。再找 tool_result 回填,也没有。

我甚至怀疑自己装错了包。后来把 query() 跑起来,看到它启动了一个 Claude Code CLI 子进程,才明白:

循环不在 SDK 里,在 CLI 里。SDK 是客户端,不是引擎。

这一章讲这条边界。它决定了你后面怎么调试、怎么多轮、怎么中断,也决定了你为什么不能“改循环”。


10.1 第一次调用:query() 到底做了什么

先看最小的一版:

python
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() 背后大概发生这些事:

text
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 块。这些是你后面调试的全部线索。

一条典型的流大概是这样(示意,实际顺序和数量会变):

text
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 是进程退出通知。

两者不是一回事。正确写法是:

python
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:

python
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 TrueCLI
工具六个手写内置 + MCP
权限Policycan_use_tool + 模式
上下文Contextsystem_prompt / setting_sources
会话SessionLogresume / 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 的实际输出(节选):

text
[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。

两个真实观察:

  1. 它先调了 Skill。 第 10 章的代码没有配置 skills,但 setting_sources 默认是 None,CLI 加载了项目的 .claude/skills/。这就是 10.7 第二个坑的实例:默认值会让 Agent 看到你没显式配置的东西。
  2. 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 估算,不是账单。