让 Claude 按固定结构输出 JSON、给回答标出处:结构化输出、引用和搜索结果块
程序要解析 Claude 的回答,就用结构化输出;要让用户核对回答的依据,就用引用。本文通过中转站实测两者的用法,并记下两个坑:可选字段写在必填字段前面会被漏掉,中文的纯文本文档不会被自动分句。
本站已实测,测试日期:2026-10-13。
本页目录(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 在中间做了三件事:
- 把接口不支持的约束从 schema 里拿掉,改写进字段的说明文字里;
- 给每个对象补上
additionalProperties: false; - 响应回来以后,用你原来的模型再校验一遍。
所以约束没有丢,只是从“接口保证”变成了“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 数 |
| 自动分出的句子 | 页码,从 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 的关系
- Claude Code 的无界面模式有一个
--json-schema参数,做的是同一件事,见《在脚本和 CI 里跑 Claude Code》。 - Agent SDK 里对应的是
output_format选项,见《会话、流式输出和结构化结果》。 - 自己直接调接口时用本文的
output_config.format。三处的 schema 限制是同一套。
花费
这一篇一共发了约 50 次请求,都是几百到一两千 token 的小请求。按官方标价粗算约 0.1 美元。
这次实测没有覆盖到的
- 官方接口没有测。 第五节“官方文档”一栏是文档的说法。
- 可选字段那一条只在一个例子、一个模型上测了 16 次,原因是本站的推测。别的模型、嵌套的对象、数组里的对象没有试。
- 中文不分句那一条只用了一份短文档,每种写法问了一次。
- 只测了 Python。 其他语言的 SDK 怎么转换 schema,是按文档整理的。
- 枚举值大小写、拒答、复杂度上限这几种情况没有复现。
- 引用的流式增量、配缓存、通过 Files API 给文档没有试。
- 语言那一节没有做实验,是按文档整理的。
- 只测了一家中转站、一天。