Claude Code SDK #46:ClaudeSDKClient 全解:query、receive_response、interrupt,把 Agent 变成可控多轮会话

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 更适合连续对话、交互应用和需要显式控制生命周期的场景。1

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 不需要手动传 resumeasync with 则负责连接建立和释放;如果你需要自己管理生命周期,也可以显式调用 connect()disconnect()2
注意 receive_response() 的语义:它会持续产生消息,直到收到当前任务的 ResultMessage。中间可能有 AssistantMessage,里面既可能是文本,也可能是工具调用。ResultMessage 才是这一次 Agent loop 的终态,应该从它的 subtype 判断任务是成功、达到上限,还是执行中断。3

Agent 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。因此,正确顺序是:
  1. 发起长任务。
  2. 调用 interrupt()
  3. 继续消费 receive_response(),直到拿到被中断任务的终态。
  4. 再发起新 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)
这段代码看起来比 break 多了一点,但它把两个生命周期分开了:ResultMessage 表示任务已结束,异步迭代器结束表示消息流已被正确收尾。13

会话内还能动态改什么

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_turnsmax_budget_usd,并在 ResultMessage 里记录 subtypenum_turns、成本和 session_id。达到上限时,结果不会有可直接使用的成功文本,应该把它当作可恢复状态处理。3
还有一个边界:客户端对象只负责当前进程里的连续交互。进程重启后要恢复指定历史,仍然需要保存 session_id 并使用 resume;多用户服务也不能把一个 client 实例给所有用户共享。Sessions 页面把这几种情况分得很清楚:当前进程多轮对话用 ClaudeSDKClient,跨进程或指定历史用 resume,探索另一条路径用 fork2

最后的选型规则

  • 一个 prompt 对应一个结果,选 query()
  • 下一条输入依赖上一轮上下文,选 ClaudeSDKClient
  • 用户可以点击停止、改权限、切换模型,选 ClaudeSDKClient
  • 进程会重启,先保存 session_id,再设计 resume
  • 要保留原会话并尝试另一种方案,使用 fork,不要把文件复制一份就当成会话分支。
如果你正在做 Chat 或 REPL,先把「查询、消费、终态、打断、排空缓冲」这条链跑通,再接 UI 和数据库。最小闭环不是 await client.query(),而是:发出一轮输入,完整消费到 ResultMessage,确认状态,再决定下一轮动作。

Contenido relacionado

  • Inicia sesión para comentar.
More from this channel