教程Claude Code系列第 4 / 8 篇

Claude Agent SDK 的会话、逐字输出和结构化结果:接着聊、边出边显示、直接拿数据

把一次性的问答变成能放进应用的东西,要补三样:记住前面聊过的、文字边生成边显示、返回程序能直接用的数据。本站用 Python 实测了多轮对话、换进程恢复、分支、逐字输出和按 JSON 结构返回,附各自的坑:不给 ID 接着聊可能接错会话,有工具调用时第一个字照样要等。

发布 · 约 12 分钟读完 · 经人工审核()· 本站的内容怎么来的

本站已实测,测试日期:2026-10-12。

本页目录(11 节)
  1. 先弄清“会话”是什么
  2. 同一个进程里接着聊:ClaudeSDKClient
  3. 换一个进程接着聊:resume
  4. 不记 ID 行不行:continue_conversation
  5. 从同一个起点分两路:fork
  6. 回答边生成边显示:include_partial_messages
  7. 让它返回数据,不是一段话:output_format
  8. 三样怎么搭配
  9. 这一趟花了多少
  10. 这次实测没有覆盖到的
  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 这一家中转站上测过。 别的中转站会不会把流式内容攒起来再发,要各自测。
  • 每种情况只跑了一两次。 第一段文字多久到,和当时的网络、模型负载都有关系,上面的秒数只是这一次的。

下一步

到这里,智能体能记事、能逐字显示、能返回数据,但还只是你终端里的一个脚本。系列的最后一篇讲把它放到服务器上要考虑的事:它其实是一个子进程,占多少内存,怎么限制花费和时间,多个用户之间怎么隔开,密钥放在哪。

来源

通过 API 中转站使用这些模型?到 Veridrop 免费检测中转站给你的是不是它声称的模型,或者看看中转站排行榜前 100 名。

相关文章