
Claude Code SDK #46:ClaudeSDKClient 全解:query、receive_response、interrupt,把 Agent 变成可控多轮会话
从 query 与 ClaudeSDKClient 的选型差异出发,拆解连续会话、消息终态、中断排空、动态权限与长上下文控制,帮助开发者把 Agent 接进 Chat、REPL 和可人工接管的应用。
先看一个反直觉点
你用
query() 写 Agent,单次任务很顺手:传入 prompt,遍历消息,拿到结果。但聊天界面、REPL、代码审查助手都会很快遇到三个问题:下一句要记住上一句;用户要在任务跑到一半时喊停;你还要在同一条会话里切换模型、权限或 MCP 连接。
这时继续堆
resume 和全局变量,代码会越来越像一个手写的会话管理器。Python Agent SDK 已经给了一个更直接的入口:ClaudeSDKClient。query() 和 ClaudeSDKClient 不是同一个抽象
先用一张表把选型说清楚:
| 维度 | query() | ClaudeSDKClient |
|---|---|---|
| 默认会话 | 每次调用新建 | 多次 client.query() 复用同一会话 |
| 连接管理 | SDK 自动管理 | 由你控制 connect() / disconnect(),也可以交给异步上下文管理器 |
| 对话形态 | 一次任务,一次结果 | 连续提问,下一次能接上前一轮上下文 |
| 中途打断 | 不支持 | 支持 interrupt() |
| 适合场景 | CI 任务、批处理、独立脚本 | Chat、REPL、需要人工接管的 Agent |
这里的「复用会话」不是把上一次的字符串拼到下一次 prompt 前面。Agent 会话里包含 prompt、工具调用、工具结果和回复,下一轮会继续使用这些上下文。文件本身是否被回滚,则是另一套 file checkpointing 能力,不能把会话分支当成文件分支。2
一个客户端,三轮对话
最小骨架只有两个动作:
client.query() 发请求,client.receive_response() 消费当前请求的消息流。import asyncio
from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
ResultMessage,
TextBlock,
)
async def print_response(message) -> None:
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(f"Claude: {block.text}")
elif isinstance(message, ResultMessage):
if message.subtype == "success":
print(f"[done] {message.result}")
else:
print(f"[stopped] {message.subtype}")
async def main() -> None:
options = ClaudeAgentOptions(
allowed_tools=["Read", "Glob", "Grep"],
max_turns=8,
)
async with ClaudeSDKClient(options=options) as client:
await client.query("分析 auth 模块的登录流程")
async for message in client.receive_response():
await print_response(message)
await client.query("刚才提到的 token 在哪些文件里被创建?")
async for message in client.receive_response():
await print_response(message)
await client.query("只读审查完成后,列出三个最值得先修的风险")
async for message in client.receive_response():
await print_response(message)
asyncio.run(main())ClaudeSDKClient 会在内部维护 session ID,所以第二个 prompt 不需要手动传 resume。async with 则负责连接建立和释放;如果你需要自己管理生命周期,也可以显式调用 connect() 与 disconnect()。2注意
receive_response() 的语义:它会持续产生消息,直到收到当前任务的 ResultMessage。中间可能有 AssistantMessage,里面既可能是文本,也可能是工具调用。ResultMessage 才是这一次 Agent loop 的终态,应该从它的 subtype 判断任务是成功、达到上限,还是执行中断。3Agent loop 仍然在客户端里面运行
ClaudeSDKClient 并没有把 Agent 变成普通聊天 API。每次 client.query() 仍然会启动一轮完整的 Agent loop:Claude 评估 prompt,调用工具,接收工具结果,再决定下一步,直到产生没有工具调用的最终回复。所以一次
receive_response() 里可能出现很多条消息,而不是一条模型回复。一个「修复测试」任务可能先调用 Bash,再读取文件,接着编辑代码,最后再次运行测试。SDK 将这些轮次都放在当前会话里,客户端代码只负责消费事件和决定何时发出下一条用户输入。3这也解释了为什么
client.query() 和 client.receive_response() 不应该混写成一个黑盒函数:前者是输入边界,后者是输出边界。做 Web UI 时,你可以在 AssistantMessage 到达时更新进度,在 ResultMessage 到达时把输入框重新打开;做 REPL 时,则可以在结果终态后等待下一条命令。中断:先停任务,再清空消息边界
interrupt() 最容易被误用的地方,是把它当成「立即清空当前任务」。官方实现并不是这样。它发送停止信号,但不会清空已经产生的消息。被中断的任务仍会把剩余消息推到缓冲区里,通常包括一个
subtype="error_during_execution" 的 ResultMessage。因此,正确顺序是:- 发起长任务。
- 调用
interrupt()。 - 继续消费
receive_response(),直到拿到被中断任务的终态。 - 再发起新 query。
import asyncio
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, ResultMessage
async def interruptible_task() -> None:
options = ClaudeAgentOptions(
allowed_tools=["Bash"],
permission_mode="acceptEdits",
)
async with ClaudeSDKClient(options=options) as client:
await client.query("慢慢执行一个长任务,逐步检查当前目录中的文件")
await asyncio.sleep(2)
await client.interrupt()
interrupted_result = None
async for message in client.receive_response():
if isinstance(message, ResultMessage):
interrupted_result = message
print(f"上一任务结束状态:{interrupted_result.subtype}")
await client.query("停止刚才的计划,只输出当前已经确认的文件列表")
async for message in client.receive_response():
if isinstance(message, ResultMessage):
print(f"新任务结束状态:{message.subtype}")
asyncio.run(interruptible_task())如果中断后马上发新请求,却只调用一次
receive_response(),你读到的可能还是上一任务的消息,而不是新任务的结果。这个坑在 UI 里尤其隐蔽,因为界面会把旧任务的尾部消息误画到新任务下面。1不要用 break 结束消息流
Python 参考文档特别提醒,遍历消息时不要用
break 提前退出,否则可能触发 asyncio 清理问题。Agent Loop 文档也说明,ResultMessage 之后仍可能有少量尾部系统事件,正确做法是让迭代自然结束。推荐把「我已经拿到结果」和「停止消费流」分开:
result = None
async for message in client.receive_response():
if isinstance(message, ResultMessage):
result = message
# 迭代器自然结束后,再读取 result
if result is not None and result.subtype == "success":
print(result.result)会话内还能动态改什么
ClaudeSDKClient 的价值不止是省掉一个 resume 参数。Python API 还提供了一组当前会话级别的控制方法:set_permission_mode(mode):改变当前会话的权限模式。set_model(model):切换当前会话使用的模型,传入None可恢复默认模型。get_mcp_status():读取 MCP 服务状态。reconnect_mcp_server(name):重新连接失效的 MCP 服务。toggle_mcp_server(name, enabled):在会话中启用或停用某个 MCP 服务。rewind_files(user_message_id):在启用enable_file_checkpointing=True后,把文件恢复到指定用户消息对应的状态。
这组方法适合放进应用的控制层,而不是让模型自己决定。比如,用户点击「只读审查」时由宿主程序切换权限;某个 MCP 服务断开时由宿主程序重连;用户选择轻量模型时由宿主程序调用
set_model()。这些方法的定义和限制见 Python SDK 参考。1长会话要管住上下文
客户端会持续复用上下文,但上下文也会持续变长。系统 prompt、工具定义、历史消息、工具输入和工具输出都会进入会话上下文;读取一个大文件或执行一条输出很多内容的命令,都会让后续请求更贵、更慢。
因此,
ClaudeSDKClient 不是「无限记忆」开关。生产应用至少要给 Agent loop 配置 max_turns 或 max_budget_usd,并在 ResultMessage 里记录 subtype、num_turns、成本和 session_id。达到上限时,结果不会有可直接使用的成功文本,应该把它当作可恢复状态处理。3还有一个边界:客户端对象只负责当前进程里的连续交互。进程重启后要恢复指定历史,仍然需要保存
session_id 并使用 resume;多用户服务也不能把一个 client 实例给所有用户共享。Sessions 页面把这几种情况分得很清楚:当前进程多轮对话用 ClaudeSDKClient,跨进程或指定历史用 resume,探索另一条路径用 fork。2最后的选型规则
- 一个 prompt 对应一个结果,选
query()。 - 下一条输入依赖上一轮上下文,选
ClaudeSDKClient。 - 用户可以点击停止、改权限、切换模型,选
ClaudeSDKClient。 - 进程会重启,先保存
session_id,再设计resume。 - 要保留原会话并尝试另一种方案,使用
fork,不要把文件复制一份就当成会话分支。
如果你正在做 Chat 或 REPL,先把「查询、消费、终态、打断、排空缓冲」这条链跑通,再接 UI 和数据库。最小闭环不是
await client.query(),而是:发出一轮输入,完整消费到 ResultMessage,确认状态,再决定下一轮动作。관련 콘텐츠
- 로그인하면 댓글을 작성할 수 있습니다.
More from this channel›
- Claude Code SDK #45:Model Configuration 全解——model × effort × fallback × budget,把质量和成本分开调
- Claude Code SDK #44:LLM Gateway 全解——ANTHROPIC_BASE_URL × apiKeyHelper × provider switching,把模型请求收口到团队网关
- Claude Code SDK #43:Corporate Proxy 全解——HTTPS_PROXY × CA store × mTLS,让 Claude Code 穿过企业内网
- Claude Code SDK #42:Hosting 全解——subprocess × SessionStore × tenant isolation,把 Agent 跑进生产环境