教程Claude Code系列第 14 / 21 篇

让 Claude 按固定结构输出 JSON、给回答标出处:结构化输出、引用和搜索结果块

程序要解析 Claude 的回答,就用结构化输出;要让用户核对回答的依据,就用引用。本文通过中转站实测两者的用法,并记下两个坑:可选字段写在必填字段前面会被漏掉,中文的纯文本文档不会被自动分句。

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

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

本页目录(8 节)
  1. 一、结构化输出:回答一定是合规的 JSON
  2. 二、引用:回答的每句话出自哪里
  3. 三、搜索结果块:给自己的检索系统用的引用
  4. 四、回答用什么语言
  5. 五、通过中转站实测到的
  6. 和 Claude Code、Agent SDK 的关系
  7. 花费
  8. 这次实测没有覆盖到的

这篇教程解决两个问题:

  • 程序要拿 Claude 的回答接着处理,怎么保证拿到的一定是能解析的 JSON?
  • 回答是根据你给的文档写的,怎么让它标出每句话出自哪里?

前一个用结构化输出,后一个用引用。两者不能同时用,后面会说。

实测环境
日期 2026-10-13
方式 Python:anthropic SDK 1.13.0,部分实验直接发 HTTP 请求
模型 Sonnet 5.5,个别用 Opus 5.5
连接方式 通过酷站AI 中转站,不是直连官方

先说实测的结论:

  • 结构化输出、文档引用、搜索结果块,通过这家中转站都能用。
  • schema 里把可选字段写在必填字段前面,这个可选字段会被漏掉,6 次里 6 次没有输出,其中 2 次还把别的字段写坏了。必填的写前面就没事。
  • 中文的纯文本文档不会被自动分句,每条引用引的都是整篇文档。要自己把文档切成块。
  • 旧参数 output_format 在这家中转站上被悄悄忽略,返回的是普通文字。

一、结构化输出:回答一定是合规的 JSON

不用它的时候,就算在提示词里写了“只输出 JSON”,模型也可能在外面包一层代码块标记,或者前后加一句话。实测同一个提取任务,只靠提示词要求,拿到的是:

```json
{
  "name": "林晓",
  ...
}
```

用了结构化输出,接口在生成的时候就把输出限制在你给的 JSON Schema 之内,回答的文字块里只有 JSON 本身。

它和上一组文章里讲过的 strict 是同一套机制的两个用途:strict 管工具调用的参数,这里管最后的回答。strict 见《工具调用入门》第九节。

用法一:让 SDK 替你转换和解析

Python 里把一个 Pydantic 模型交给 client.messages.parse()。下面这段在服务器上原样跑过:

from typing import Literal, Optional
import anthropic
from pydantic import BaseModel, Field

client = anthropic.Anthropic()


class SignupRequest(BaseModel):
    name: str
    email: str
    plan: Literal["free", "pro", "team"]
    seats: int = Field(ge=1, description="要开通的人数")
    deadline: Optional[str] = Field(default=None, description="希望的开通时间,原话")


reply = client.messages.parse(
    model="claude-sonnet-5-5",
    max_tokens=500,
    output_format=SignupRequest,
    messages=[{"role": "user", "content": "从这段话里提取信息:我叫林晓,邮箱 lin@example.com,"
                                          "想给我们 12 个人的小组开团队版,最好下周一前开通。"}],
)
print(reply.stop_reason)
print(reply.content[0].text)          # 原始的 JSON 文本
req = reply.parsed_output             # 已经是 SignupRequest 的实例
print(type(req).__name__, req.plan, req.seats + 1)

输出:

end_turn
{"name":"林晓","email":"lin@example.com","plan":"team","seats":12,"deadline":"下周一前"}
SignupRequest team 13

注意模型里的 ge=1(人数至少为 1)。接口的 schema 并不支持这种数值约束,直接发会报 400。SDK 在中间做了三件事:

  1. 把接口不支持的约束从 schema 里拿掉,改写进字段的说明文字里;
  2. 给每个对象补上 additionalProperties: false;
  3. 响应回来以后,用你原来的模型再校验一遍。

所以约束没有丢,只是从“接口保证”变成了“SDK 事后检查”。TypeScript 配 Zod 的写法(zodOutputFormat())是同一个思路。Java 的 SDK 不做转换,遇到不支持的约束直接在本地报错。

用法二:自己写 JSON Schema

schema 来自文件、OpenAPI 定义,或者是运行时拼出来的,就直接放进 output_config.format:

import json
import anthropic

client = anthropic.Anthropic()

schema = {
    "type": "object",
    "additionalProperties": False,            # 每个对象都必须写这一行
    "properties": {
        "severity": {"type": "string", "enum": ["low", "medium", "high"]},
        "component": {"type": "string"},
        "summary": {"type": "string", "description": "一句话概括,不超过 30 个字"},
        "rolled_back": {"type": "boolean", "description": "是否已经回滚"},
    },
    "required": ["severity", "component", "summary", "rolled_back"],
}
log = ("14:02 deploy api v2.31.0 完成\n14:05 支付回调 5xx 比例从 0.1% 升到 37%\n"
       "14:06 报错 KeyError: 'channel_id' at billing/callback.py:88\n14:09 值班回滚到 v2.30.4,5xx 恢复")

reply = client.messages.create(
    model="claude-sonnet-5-5",
    max_tokens=500,
    output_config={"format": {"type": "json_schema", "schema": schema}},
    messages=[{"role": "user", "content": "把这段事故日志整理成一条记录:\n" + log}],
)
if reply.stop_reason == "end_turn":           # 被截断或拒答时,内容不保证符合 schema
    record = json.loads(reply.content[0].text)
    print(record)
else:
    print("没有拿到完整的结果:", reply.stop_reason)

输出:

{'severity': 'high', 'component': '支付回调 billing/callback.py', 'summary': 'v2.31.0上线致支付回调5xx升至37%,已回滚恢复', 'rolled_back': True}

schema 能写什么

和 strict 完全一样,只支持 JSON Schema 的一个子集。实测到的两种报错:

写法 结果
对象上没写 additionalProperties: false 400:'additionalProperties' must be explicitly set to false
整数加 minimum 400,报错指出 minimum 是不支持的约束

不支持的还有:字符串的长度约束、数组里除 minItems 为 0 或 1 以外的约束、递归的 schema、外部的 $ref。数量上的限制(一个请求里最多 24 个可选参数、16 个多类型参数)和第一次使用一份 schema 时的编译延迟,也和 strict 相同。

它要多花一点输入 token。 接口会自动加一段说明输出格式的系统提示词。上面第一个例子,带 schema 的请求输入是 392 个 token,同一句话不带 schema 是 91 个。

可选字段要放在必填字段后面

这是本站实测里最意外的一条。同一句话“提取:王芳,32 岁,邮箱 wang@example.com,备注是老客户”,同样的四个字段,只改 schema 里的顺序和必填设置:

schema 结果
A:顺序是 notes、name、age、email,必填 name、email 6 次里 6 次都没有输出 notes,其中 2 次的 email 被写坏
B:顺序同 A,四个都必填 5 次全对,四个字段都有
C:顺序改成 name、email、age、notes,必填 name、email 5 次全对,四个字段都有

A 的正常输出是这样的,notes 不见了:

{"name":"王芳","age":32,"email":"wang@example.com"}

写坏的两次里,一次是下面这样:JSON 本身是合法的,但 email 的值里混进了别的东西。另一次是在邮箱后面不停地输出看不见的空白字符,直到撞上 max_tokens。

{"name":"王芳","age":32,"email":"wang@example.com','notes':'老客户'}"}

本站的理解是:输出里字段的先后被限制成和 schema 一致,可选字段可以跳过,但跳过了就回不去。模型从 name 写起,等于放弃了排在它前面的 notes;写到最后想补,语法上已经没有位置,只能要么不写,要么硬塞进当前的字符串里。这是根据现象的推测。

官方文档对顺序的说法是“必填字段在前,可选字段在后,各自按 schema 里的顺序”,和这里看到的不完全一样(A 的输出里 age 排在 email 前面)。不管哪种说法对,结论相同,文档也是这么建议的:

  • 顺序要紧的时候,把所有字段都设成必填。 没有值的字段让模型填空字符串或 null。
  • 一定要有可选字段的话,把它们放在 schema 的最后。
  • 合规不等于正确。 结构化输出保证的是形状,值对不对仍然要自己检查。

三种拿不到合规结果的情况

情况 表现 怎么办
被 max_tokens 截断 stop_reason 是 max_tokens,JSON 只有半截。实测把上限设成 12,拿到的是 {"name":"林晓","email":" 调大 max_tokens 重发
拒答 stop_reason 是 refusal,状态码仍是 200,内容可能不符合 schema 先判断 stop_reason 再解析
枚举值的大小写 文档说,模型返回的枚举值可能和 schema 里的只差大小写,而且不会报任何错 比较枚举值时不区分大小写

另外,schema 里不要设一个让模型写“推理过程”的字段,可能招来拒答。要解释就要一句简短的说明。

能和什么一起用

功能 能不能一起用
流式输出 能。实测 JSON 分 9 段到达,拼起来再解析
思考 能。语法只约束最后的回答,不约束思考。实测 Opus 5.5 开着思考,结果一样合规
工具调用、strict 能,各管各的
批量处理 能
引用 不能,见下一节
预填(替助手写开头) 文档说不能

旧的写法是顶层的 output_format 参数,现在已经挪到 output_config.format。文档说不带 beta 头用旧参数会报 400。

二、引用:回答的每句话出自哪里

把文档放进请求,打开引用,Claude 的回答就会被切成一段一段的文字块,凡是根据文档写的那几段,各自带着一个 citations 列表,里面是它依据的原文和位置。

和“在提示词里要求它引用原文”相比,官方列的好处是:位置是接口解析出来的,保证指向文档里真实存在的地方;cited_text(被引用的原文)不算输出 token,带回下一轮时也不算输入 token。

三种文档

类型 最小的可引用单位 引用里的位置
纯文本 自动分出的句子 字符的起止位置,从 0 数
PDF 自动分出的句子 页码,从 1 数
自定义内容 你给的每一个块,不再细分 块的起止序号,从 0 数

位置的结束值都是“不含”的:start_block_index 为 1、end_block_index 为 2,指的就是第 1 块。

下面的例子用的是第三种,自己把文档切成块。原样跑过:

import anthropic

client = anthropic.Anthropic()

policy = ["退款政策(2026 年 10 月版)。",
          "自签收之日起 7 天内,未拆封的商品可以无理由退货,运费由买家承担。",
          "已拆封的商品只有在存在质量问题时才能退货,需要提供照片。",
          "退款在仓库验收后 3 个工作日内原路退回。",
          "定制商品和虚拟商品不支持无理由退货。",
          "超过 30 天的订单不再受理任何退换。"]

reply = client.messages.create(
    model="claude-sonnet-5-5",
    max_tokens=800,
    messages=[{"role": "user", "content": [
        {"type": "document",
         "title": "退款政策",
         "citations": {"enabled": True},
         # 自己把文档切成块:一块就是一个最小的可引用单位
         "source": {"type": "content", "content": [{"type": "text", "text": t} for t in policy]}},
        {"type": "text", "text": "我买的定制水杯到货 5 天了,没拆封,能退吗?"},
    ]}],
)
sources = []
for block in reply.content:
    if block.type != "text":
        continue
    print(block.text, end="")
    for c in block.citations or []:
        sources.append(c)
        print(f"[{len(sources)}]", end="")
print("\n")
for i, c in enumerate(sources, 1):
    print(f"[{i}] 《{c.document_title}》第 {c.start_block_index} 块:{c.cited_text}")

输出(中间有删节):

根据退款政策,您的定制水杯**不能无理由退货**。

虽然自签收之日起 7 天内,未拆封的商品可以无理由退货,运费由买家承担[1],您的情况(到货 5 天、未拆封)本来符合这一条件,但政策同时规定:定制商品和虚拟商品不支持无理由退货[2]。因此,定制水杯属于例外情形。
……

[1] 《退款政策》第 1 块:自签收之日起 7 天内,未拆封的商品可以无理由退货,运费由买家承担。
[2] 《退款政策》第 4 块:定制商品和虚拟商品不支持无理由退货。
[3] 《退款政策》第 2 块:已拆封的商品只有在存在质量问题时才能退货,需要提供照片。
[4] 《退款政策》第 5 块:超过 30 天的订单不再受理任何退换。

中文的纯文本文档不会被自动分句

如果图省事,把整篇文档作为一个纯文本文档给进去(source 的 type 写 text),指望接口自动分句,中文会落空。同一份退款政策,四种给法各问一次:

文档 引用到的片段
英文译文 4 个不同的句子,各有各的字符范围
中文,句号后不换行 1 个,范围是整篇(0 到 139)
中文,每句一行 1 个,范围是整篇
中文,句号后加一个空格 1 个,范围是整篇

中文的三种写法下,每条引用引的都是全文,等于没有引用。中文内容要用自定义内容文档,自己按句子或段落切好。 这也是文档对条目、对话记录这类不适合自动分句的内容给的建议。

用法上的规则

  • 一次请求里的文档,要么都开引用,要么都不开。
  • title 和 context 会给模型看,但不能被引用。文档的元数据可以放在 context 里。
  • PDF 只能引用其中的文字,图片不能被引用;扫描件没有可提取的文字,也就不能引用。
  • 流式输出时,引用通过 citations_delta 这种增量到达,每个增量是一条引用。
  • 配缓存时,cache_control 加在文档块上。引用块本身不能缓存,被引用的文档可以。

代价:打开引用要多一些输入 token。同一份 139 个字的文档、同一个问题,开引用的请求输入是 1,109 个 token,不开是 288 个,多了约 800 个。一份两页的 PDF 上多了约 1,000 个。

引用和结构化输出不能同时用

引用要把回答切成带引用的小块,结构化输出要回答是一整段严格的 JSON,两者冲突。实测同时打开,返回 400:

Citations cannot be enabled when output format is set.
Please disable citations on uploaded document blocks.

既要出处又要结构,可以分两步:先带引用回答,再用另一次请求把答案整理成 JSON;或者在 schema 里自己设一个“依据原文”的字段,这时位置就不再有接口的保证了。

三、搜索结果块:给自己的检索系统用的引用

上一节的文档引用,引用里只有文档的序号和标题。做检索增强(RAG)的应用往往希望引用长得像联网搜索那样,带着来源地址。搜索结果块就是干这个的:你把自己检索到的内容包成 search_result 块,每块带一个你定的 source 和 title,Claude 引用时原样带回。

{
  "type": "search_result",
  "source": "kb://refund-policy",
  "title": "退款政策",
  "citations": {"enabled": true},
  "content": [
    {"type": "text", "text": "已拆封的商品只有在存在质量问题时才能退货,需要提供照片。"},
    {"type": "text", "text": "退款在仓库验收后 3 个工作日内原路退回。"}
  ]
}

source 可以是网址,也可以是任何稳定的内部标识。content 里只能放文字块,一块是一个最小的可引用单位,所以想让引用更细,就把内容切得更碎。引用默认是关的,要显式打开。

给的办法有两种:直接放在用户消息里(内容是事先检索好的),或者作为你的工具的返回结果(让模型自己决定查什么)。下面是第二种,原样跑过:

import anthropic

client = anthropic.Anthropic()

KB = [  # 假装这是你的检索系统:(关键词, 来源, 标题, 分好块的正文)
    ("退", "kb://refund-policy", "退款政策", ["已拆封的商品只有在存在质量问题时才能退货,需要提供照片。",
                                           "退款在仓库验收后 3 个工作日内原路退回。"]),
    ("运费", "kb://shipping", "配送说明", ["默认发顺丰,江浙沪次日达。", "偏远地区加收 15 元运费。"]),
]


def search_kb(query):
    hits = [(source, title, texts) for key, source, title, texts in KB if key in query]
    return [{"type": "search_result", "source": source, "title": title, "citations": {"enabled": True},
             "content": [{"type": "text", "text": t} for t in texts]} for source, title, texts in hits]


tools = [{"name": "search_kb", "description": "搜索内部知识库,返回相关的条目。回答政策类问题之前先查。",
          "input_schema": {"type": "object", "properties": {"query": {"type": "string", "description": "关键词"}},
                           "required": ["query"]}}]
messages = [{"role": "user", "content": "已拆封的商品能退吗?退款多久到账?先查知识库再回答。"}]
reply = client.messages.create(model="claude-sonnet-5-5", max_tokens=800, tools=tools, messages=messages)
while reply.stop_reason == "tool_use":
    messages.append({"role": "assistant", "content": reply.content})
    results = []
    for block in reply.content:
        if block.type == "tool_use":
            print("检索:", block.input)
            # 工具结果的内容直接就是一组 search_result 块
            results.append({"type": "tool_result", "tool_use_id": block.id, "content": search_kb(block.input["query"])})
    messages.append({"role": "user", "content": results})
    reply = client.messages.create(model="claude-sonnet-5-5", max_tokens=800, tools=tools, messages=messages)

for block in reply.content:
    if block.type == "text":
        print(block.text, end="")
        for c in block.citations or []:
            print(f"〔{c.title},{c.source}〕", end="")
print()

输出(末尾有删节):

检索: {'query': '已拆封商品 退货'}
检索: {'query': '退款 到账时间'}
**已拆封的商品能退吗?**
有条件地能退。已拆封的商品只有在存在质量问题时才能退货,需要提供照片。〔退款政策,kb://refund-policy〕如果只是不想要了,已拆封的商品不在可退范围内。

**退款多久到账?**
退款在仓库验收后 3 个工作日内原路退回。〔退款政策,kb://refund-policy〕3 个工作日从仓库验收退货后开始算,不是从你提交申请时算。……

每条引用除了 source 和 title,还有 cited_text、是第几个搜索结果(search_result_index)和块的起止序号。

两条容易踩的规则:

  • 一个工具结果里只要有一个 search_result 块,就必须全是 search_result 块,不能和普通文字混放。想附带一段说明,把它作为一个文字块放进某个搜索结果的 content 里。
  • 一次请求里所有搜索结果的引用设置要一致,都开或都不开。

四、回答用什么语言

Claude 会从对话里推断该用什么语言回答。官方文档的建议是,正式的应用不要靠它猜,在系统提示词里明确写出目标语言;用户可以在界面上选语言的话,把选的结果填进系统提示词。要翻译,就把两种语言都点名,例如“把用户的消息从德语翻译成韩语,只输出译文”。

另外两条:原文用它本来的文字写,不要用拼音或转写;翻译之外还要考虑文化和地区的习惯,可以要求它“像母语者那样用地道的说法”。

文档给了一张各语言相对英语的成绩表,用的是把一套英文测试题请人译成 14 种语言之后的得分。表里只有两个较早的模型:简体中文在 Sonnet 4.5 上是英语成绩的 96.9%,在 Haiku 4.5 上是 94.2%。当前这一代模型没有给数字。

五、通过中转站实测到的

项目 官方文档 通过中转站实测
output_config.format 可用 可用
SDK 的 messages.parse() 可用 可用
schema 的校验 400 400,一致
文档引用(纯文本、自定义内容、PDF) 可用 可用
搜索结果块(两种给法) 可用 可用
引用加结构化输出 400 400,一致
旧参数 output_format,不带 beta 头 400 被忽略:返回 200 和一段普通文字,不是 JSON
结构化输出加预填 不兼容 200,结果和不加预填一样

倒数第二条要留意:用旧参数的老代码在这里不会报错,只是悄悄拿不到 JSON。

和 Claude Code、Agent SDK 的关系

花费

这一篇一共发了约 50 次请求,都是几百到一两千 token 的小请求。按官方标价粗算约 0.1 美元。

这次实测没有覆盖到的

  • 官方接口没有测。 第五节“官方文档”一栏是文档的说法。
  • 可选字段那一条只在一个例子、一个模型上测了 16 次,原因是本站的推测。别的模型、嵌套的对象、数组里的对象没有试。
  • 中文不分句那一条只用了一份短文档,每种写法问了一次。
  • 只测了 Python。 其他语言的 SDK 怎么转换 schema,是按文档整理的。
  • 枚举值大小写、拒答、复杂度上限这几种情况没有复现。
  • 引用的流式增量、配缓存、通过 Files API 给文档没有试。
  • 语言那一节没有做实验,是按文档整理的。
  • 只测了一家中转站、一天。

来源

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

相关文章