Claude Agent SDK 的会话、逐字输出和结构化结果:接着聊、边出边显示、直接拿数据
把一次性的问答变成能放进应用的东西,要补三样:记住前面聊过的、文字边生成边显示、返回程序能直接用的数据。本站用 Python 实测了多轮对话、换进程恢复、分支、逐字输出和按 JSON 结构返回,附各自的坑:不给 ID 接着聊可能接错会话,有工具调用时第一个字照样要等。
本站已实测,测试日期:2026-10-12。
本页目录(11 节)
这篇教程解决一个问题:前几篇的智能体问一句、答一句就结束了。要把它放进一个真的应用,还差三样:它得记住前面聊过什么,回答得边生成边显示,结果得是程序能直接用的数据。
三样对应 SDK 的三组选项。本站接着用前两篇的订单助手,各跑了一遍。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-12 |
| 版本 | claude-agent-sdk 0.2.165,自带的 Claude Code 2.1.294 |
| 系统 | Ubuntu 24.04,Python 3.12.3,pydantic 2.14.1 |
| 模型 | Opus 5.5 |
| 连接方式 | 通过酷站AI 中转站,不是直连官方 |
| 示例 | 前两篇的订单库,只开放查订单、看详情两个只读工具 |
下面的代码都用到同一个 options(),先放在这里:
from claude_agent_sdk import ClaudeAgentOptions
from shop_tools import shop, PROMPT # 上一篇的工具服务器和系统提示
def options(**extra):
return ClaudeAgentOptions(
mcp_servers={"shop": shop}, tools=[], system_prompt=PROMPT,
allowed_tools=["mcp__shop__find_orders", "mcp__shop__get_order"],
permission_mode="dontAsk", **extra,
)
先弄清“会话”是什么
每调用一次 query(),SDK 默认开一个新的会话。会话就是这次对话的完整记录:你的问题、每次工具调用、工具的返回、Claude 的回答。它是磁盘上的一个文件:
~/.claude/projects/<把工作目录里的非字母数字字符都换成 - >/<会话 ID>.jsonl
每次运行结束时的 ResultMessage 上都有 session_id,成功和出错都有。“接着聊”,就是下一次请求把这个文件里的内容带上。
会话里存的是对话,不是文件。智能体改过的文件该在哪还在哪,和会话无关。
同一个进程里接着聊:ClaudeSDKClient
query() 每次都从头开始。要在一个进程里连着问,用 ClaudeSDKClient:它把底下的 Claude Code 进程一直开着,每次 client.query() 都接在同一个会话上,不用你管 ID。
import asyncio
from claude_agent_sdk import ClaudeSDKClient
QUESTIONS = [
"张伟有哪些订单?",
"金额大的那张,物流单号是多少?",
"这位客户两张订单加起来多少钱?不用再查了。",
]
async def main():
async with ClaudeSDKClient(options=options()) as client:
for q in QUESTIONS:
await client.query(q)
async for message in client.receive_response():
show(message) # 打印工具调用、回答和结束信息
asyncio.run(main())
receive_response() 读到这一问的 ResultMessage 为止,然后就可以发下一问。
实测的输出(表格做了省略):
问:张伟有哪些订单?
[调用] mcp__shop__find_orders {"customer": "张伟"}
[回答] 张伟有 2 张订单:A1002 已发货 ¥459.00;A1001 已签收 ¥129.00
[结束] success 轮数=2 累计花费估算=0.0110 会话=a9813109
问:金额大的那张,物流单号是多少?
[调用] mcp__shop__get_order {"order_id": "A1002"}
[回答] 金额大的是 A1002(¥459.00),物流单号是 YT9876543210。
[结束] success 轮数=2 累计花费估算=0.0248 会话=a9813109
问:这位客户两张订单加起来多少钱?不用再查了。
[回答] 两张订单一共 ¥588.00(¥459.00 + ¥129.00)。
[结束] success 轮数=1 累计花费估算=0.0266 会话=a9813109
三处可以看出它记得:
- 第二问没说是谁、哪张订单,它直接去查了 A1002,没有重新搜索。
- 第三问一次工具都没调,用前面查到的数字算的。
- 三次的会话 ID 是同一个。
花费是累计的。 每个 ResultMessage 上的 total_cost_usd 是这个会话到目前为止的总数,三次分别是 0.011、0.025、0.027 美元。要算总花费,读最后一个,不要把三个加起来。
TypeScript 没有这样一个保持会话的对象,官方文档的做法是后面每次 query() 带上 continue: true。本站没有实测。
换一个进程接着聊:resume
网页应用里,用户的两句话之间程序可能已经重启,或者第二句落到了另一个进程。这时要自己记住会话 ID,下次带上:
# 第一次运行结束时
session_id = result_message.session_id # 存进你的数据库,和用户或工单对应起来
# 之后的任何一次运行
async for message in query(prompt=question, options=options(resume=session_id)):
...
实测:上面那段对话结束后,另起一个 Python 进程,用同一个 ID 问一句;再不带 ID 问同一句做对照。
方式:resume 问:刚才那位客户叫什么?他有几张订单?不要再查。
[回答] 客户叫张伟,有 2 张订单(A1002 和 A1001)。
[结束] success 轮数=1 累计花费估算=0.0282 会话=a9813109
方式:不带会话 问:刚才那位客户叫什么?他有几张订单?不要再查。
[回答] 我这边看不到之前的对话记录,所以不知道刚才那位客户叫什么……
[结束] success 轮数=1 累计花费估算=0.0086 会话=c41be7f9
带 ID 的那次接上了,会话 ID 没变。不带的那次是一个全新的会话,什么都不知道。
顺带试了三种情况:
| 情况 | 实测结果 |
|---|---|
| 换一个工作目录再恢复 | 能恢复。官方文档说 Claude Code 2.1.223 起会到当前项目目录以外去找这个 ID |
| ID 不存在 | 抛出 ResultError,信息是 No conversation found with session ID: … |
| 换一台机器 | 没有实测。按文档,会话文件得在这台机器上;跨机器要用会话存储适配器,或者自己把文件搬过去 |
恢复的那次,total_cost_usd 是 0.0282,比恢复前的 0.0266 只多了一点。它把这个会话以前的花费也算在里面了,不是这一问就花了这么多。记账时怎么处理,下一篇讲。
不记 ID 行不行:continue_conversation
有一个更省事的选项:continue_conversation=True。不用给 ID,它接上“当前目录里最近的一次会话”。
实测时它接错了。
方式:continue 问:我上一句问的是什么?一句话回答,不要查。
[回答] 您上一句问的是:刚才那位客户叫什么、他有几张订单,并且不要再查。
[结束] success 轮数=1 累计花费估算=0.0119 会话=c41be7f9
回答看上去没毛病,但看会话 ID:c41be7f9。它接上的是刚才那次“不带会话”的对照,不是聊了三轮的 a9813109。原因很简单,对照那次是这个目录里最后运行的。
所以这个选项只适合一种情况:一个目录只有一个人、一条对话在用,比如你自己的命令行小工具。只要同一个目录里可能有两条对话,就老老实实存 ID。
从同一个起点分两路:fork
分支是在恢复的基础上加一个选项:
options(resume=session_id, fork_session=True)
它新建一个会话,开头带着原会话的全部历史,之后各走各的。实测:
方式:fork(原来的会话 a9813109) 问:把刚才查到的两张订单换成英文一句话概括。不要再查。
[回答] Zhang Wei has two orders: A1002 (¥459.00, shipped, …) and A1001 (¥129.00, delivered, …).
[结束] success 会话=3097b7d0
方式:resume(原来的会话 a9813109) 问:这次对话里我有没有让你用英文概括过?只回答有或没有。
[回答] 没有。
分支拿到了一个新的 ID,并且知道原会话里查到的订单。回头再问原会话,它不知道有过英文概括这回事。目录里也多了一个大小差不多的文件:
29956 a9813109-….jsonl 原会话
29423 3097b7d0-….jsonl 分支
15192 c41be7f9-….jsonl 对照
适合用在:想让它换一种做法试试,又不想把原来的对话弄乱。
文档提醒了一点:分支的只是对话历史。如果分出去的智能体改了文件,改动是真的,原会话那边看到的也是改过的文件。
回答边生成边显示:include_partial_messages
默认情况下,Claude 把一段话全部生成完,SDK 才给你一条完整的 AssistantMessage。回答长的时候,用户要对着空白等好几秒。
加上 include_partial_messages=True,消息流里会多出一种 StreamEvent,里面是模型接口原样的流式事件。要显示的文字在 content_block_delta 事件里:
from claude_agent_sdk import query
from claude_agent_sdk.types import StreamEvent
async for message in query(prompt=question, options=options(include_partial_messages=True)):
if isinstance(message, StreamEvent):
event = message.event
if event.get("type") == "content_block_delta":
delta = event.get("delta", {})
if delta.get("type") == "text_delta":
print(delta.get("text", ""), end="", flush=True)
StreamEvent 要从 claude_agent_sdk.types 导入。TypeScript 里对应的选项是 includePartialMessages。
文字真的是一段一段到的
先问一个不用查订单的问题,让它写 300 字左右,记下每段文字到达的时刻:
第一段 4.3s,最后一段 9.6s,结束 9.7s;共 105 段 487 字;相邻两段最长间隔 0.76s
从 4.3 秒到 9.6 秒,105 段文字陆续到达。不开这个选项,用户要等到将近 10 秒、整段话生成完才看到第一个字;开了之后是 4.3 秒。这是通过中转站测的,流式输出没有被攒成一整块再发。
有工具调用时,第一个字照样要等
再问一个要查订单的问题:
[12.5s 第一段文字到达]
张伟最近一张订单是 A1002,2026-10-08 下单,金额 459.00 元,现在是已发货……
[12.7s 结束] 文字分成 16 段到达
总共 12.7 秒,第一段文字在 12.5 秒才来。前面的时间花在了两次工具调用上,那段时间里没有文字可以显示。
逐字输出解决不了这段等待,但事件流里有别的信号可以用。这次运行里各种事件的先后是:
开始一块:tool_use → 完整的 AssistantMessage
→ 开始一块:thinking → 完整的 AssistantMessage
→ 开始一块:tool_use → 完整的 AssistantMessage
→ 开始一块:text → 完整的 AssistantMessage
每次工具调用开始时,都有一个 content_block_start 事件,里面写着块的类型是 tool_use 和工具的名字。看到它就在界面上显示“正在查订单…”,用户就知道程序没卡住。
两点要注意
- 开了逐字输出,完整的
AssistantMessage照样会来。 同一段文字你会收到两遍:先是零碎的,后是完整的。只显示其中一种。 - 结构化结果不适合逐字显示。 文档说明:同时开着的话,JSON 是作为一次工具调用的参数、一小段一小段流出来的,没有经过校验;校验过的结果只出现在最后的
ResultMessage上。
让它返回数据,不是一段话:output_format
回答是给人看的时候,一段话正好。回答要交给下一段程序的时候,一段话就得再解析一遍,还不一定解析得对。
output_format 让你用 JSON Schema 规定结果的形状。Python 里用 Pydantic 来写最省事:定义一个模型,让它生成 Schema,拿到结果后再用同一个模型校验。
from pydantic import BaseModel
from claude_agent_sdk import query, ResultMessage
class OrderLine(BaseModel):
id: str
status: str
amount_cents: int
class CustomerSummary(BaseModel):
customer: str
order_count: int
total_cents: int
orders: list[OrderLine]
needs_attention: bool
note: str
async for message in query(
prompt="汇总客户“张伟”的订单情况。total_cents 是所有订单金额之和;"
"有已发货但还没签收的订单时 needs_attention 为 true;note 用一句中文说明。",
options=options(output_format={
"type": "json_schema",
"schema": CustomerSummary.model_json_schema(),
}),
):
if isinstance(message, ResultMessage) and message.structured_output:
summary = CustomerSummary.model_validate(message.structured_output)
字段各自是什么意思,在提示里说清楚。Schema 只规定了形状,needs_attention 什么时候为真,它管不了。
实测的输出:
[调用] mcp__shop__find_orders {"customer": "张伟"}
[调用] StructuredOutput {"customer": "张伟", "order_count": 2, "total_cents": 58800, "orders": [...
[结束] success 轮数=3 花费估算=0.0268
structured_output: {"customer": "张伟", "order_count": 2, "total_cents": 58800,
"orders": [{"id": "A1002", "status": "shipped", "amount_cents": 45900},
{"id": "A1001", "status": "delivered", "amount_cents": 12900}],
"needs_attention": true,
"note": "张伟共有2笔订单,合计588.00元;其中A1002已发货但还没签收,需要跟进。"}
校验通过:张伟 共 2 张,合计 588.00 元,需要关注=True
几点观察:
- 它照常用工具。 先查了订单,再给结果。规定输出的形状不影响中间怎么干活。
- 结果是通过一次名叫
StructuredOutput的工具调用交上来的。 它出现在消息流里,所以轮数是 3 而不是 2。这个助手只预先批准了两个查询工具,权限模式是dontAsk,这次调用没有被拦。 - 数据在
structured_output上,已经是字典。result字段里同时有一份一样内容的 JSON 字符串。读前者。 - SDK 按 JSON Schema draft-07 校验你给的 Schema。 Pydantic 生成的能直接用。文档提到 TypeScript 用 Zod 时要指定
target: "draft-7"。
查不到的时候它填什么
把客户换成库里没有的“赵六”:
structured_output: {"customer": "赵六", "order_count": 0, "total_cents": 0, "orders": [],
"needs_attention": false, "note": "系统里没查到客户“赵六”的订单,请核对客户姓名是否正确。"}
形状照样完整,数量是 0,说明写在了 note 里。这次它处理得合理,但那是因为这个模型里正好有地方放“没查到”。
设计 Schema 时给“没有结果”留位置:一个说明字段,或者一个明确的 found: bool。如果所有字段都必填,又没有地方表达“没有”,它只能硬填。
拿到之后还要做的两件事
校验。 model_validate 那一行不要省。文档说明了两种失败:
| 结果消息的样子 | 意思 |
|---|---|
subtype 是 error_max_structured_output_retries |
试了几次都没给出符合 Schema 的结果 |
subtype 是 success,但 structured_output 是空的 |
跑完了却没交结果。文档明确说这种也要当失败处理 |
所以判断成功要同时看两样:subtype 是 success,并且 structured_output 有值。这两种失败本站都没有触发出来。
涉及钱的数字,自己再算一遍。 上面的 total_cents 是模型加出来的,这次是对的(45900 + 12900 = 58800)。但它是算出来的,不是查出来的。程序里一行就能核对:
assert summary.total_cents == sum(o.amount_cents for o in summary.orders)
更稳妥的是不让模型算:Schema 里只要明细,合计由程序加。
三样怎么搭配
| 你在做的东西 | 用什么 |
|---|---|
| 聊天界面,一个进程守着一条对话 | ClaudeSDKClient,加逐字输出 |
| 网页或接口,每个请求可能落到不同的进程 | 存会话 ID,每次 resume,加逐字输出 |
| 后台任务,结果交给程序 | query() 加 output_format,不用逐字输出 |
| 想试另一种做法又不想弄乱原对话 | resume 加 fork_session=True |
| 自己用的命令行小工具 | continue_conversation=True 就够 |
这一趟花了多少
十几次运行,合计约 0.15 美元:三轮对话加后面几次恢复,整个会话累计 0.03 美元;两次结构化结果各 0.025 美元左右;其余每次 0.01 到 0.02 美元。这些是 SDK 按官方价格估算的数字,不是中转站实际扣的金额。
这次实测没有覆盖到的
- TypeScript 没有实测。
continue、forkSession、includePartialMessages、outputFormat这些名字来自文档。 - 跨机器恢复会话没有试,会话存储适配器也没有接。
ClaudeSDKClient的其他能力没有试:中途打断(interrupt())、运行中切换模型和权限模式。- 结构化结果的失败情况没有触发出来,上面两种是文档的说法。
- 思考内容和工具参数的逐段事件只数了数量,没有做显示。 带子代理时的事件归属也没有试。
- 逐字输出只在酷站AI 这一家中转站上测过。 别的中转站会不会把流式内容攒起来再发,要各自测。
- 每种情况只跑了一两次。 第一段文字多久到,和当时的网络、模型负载都有关系,上面的秒数只是这一次的。
下一步
到这里,智能体能记事、能逐字显示、能返回数据,但还只是你终端里的一个脚本。系列的最后一篇讲把它放到服务器上要考虑的事:它其实是一个子进程,占多少内存,怎么限制花费和时间,多个用户之间怎么隔开,密钥放在哪。