前两篇拿回的都是自然语言:第 1 篇的第一次调用返回一段解释,第 2 篇让这段话逐字上屏。可真实工程里,模型的输出多数时候不是给人读的,而是给程序用的——分类结果要落进布尔分支,抽取的字段要写进数据库,Agent 的决策要能被调度代码分发。这些场景需要的不是"一段话",而是一个字段名固定、类型可预期的对象,这就是结构化输出要解决的问题。
本文是《Anthropic SDK 从入门到实战》第 3 篇,把 Anthropic 当前的结构化输出方案讲全:先对照 OpenAI 把三条路线定位清楚,再逐条落地——原生的 parse 方法、工具加 strict 模式、纯文本加 json.loads 兜底,最后用一个发票字段提取器把校验循环串起来。全文按"路线定位 → 原生姿势 → 工具姿势 → 文本兜底 → 实战 → 总结"组织。
1、三条路线与 OpenAI 对照
先说结论:Anthropic 现在有原生结构化输出,不再是"只能靠工具曲线救国"的旧格局。2025 年 11 月,Anthropic 为 Messages API 上线了结构化输出(structured outputs),先公开测试后正式发布;到 2026 年 10 月,官方文档把它与严格工具(strict tool use)并列为两个特性。两家厂商的官方答案对比如下:
| 对比项 | OpenAI | Anthropic |
|---|---|---|
| 原生 JSON 模式 | response_format 传 JSON Schema |
output_config.format 传 JSON Schema |
| SDK 便捷方法 | parse 辅助方法 |
client.messages.parse 传 Pydantic 模型 |
| 工具参数强校验 | 工具定义加 strict |
工具定义加 strict |
| 强制指定工具 | tool_choice 可指定 |
当前代模型已移除,改由提示词点名 |
历史脉络值得知道。在原生模式出现之前,Anthropic 没有独立的 JSON 模式,社区通行做法是"工具强制"——把想要的 JSON 写成某个工具的参数 schema,再用 tool_choice 逼模型调用它,从 tool_use 块里取参数当结果。这条路曾经是唯一解,旧教程至今仍在教,但它有一半已经失效:
注意:
tool_choice={"type": "tool", "name": ...}与{"type": "any"}在 Fable 5.1、Opus 5.5、Sonnet 5.5 上会直接返回 400,报错为tool_choice: type "tool" and "any" are not supported for this model.。这与第 1 篇提醒过的采样参数移除同属一类:照抄 2026 年之前的教程,必错。仍在维护旧模型(Opus 5、Sonnet 5 及更早)的代码可以继续用,但新代码不要依赖它。
所以现在的决策树简单了:单纯要一段 JSON,走原生结构化输出;任务本身要挂一堆工具、JSON 只是其中一环,走工具加 strict;快速原型对格式要求松,才考虑纯文本。流程如下:
解读:前两条路由服务端兜底,字段与类型有保证,解析不会翻车;第三条路全靠提示词与容错代码,能用但不保险。下面三节按这个顺序逐条落地。
2、主姿势:原生结构化输出
原生模式的核心是把 schema 随请求发给服务端,由服务端约束生成。SDK 在这上面封了一层 parse 方法,直接吃 Pydantic 模型。最小示例如下:
from typing import List
import anthropic
from dotenv import load_dotenv
from pydantic import BaseModel
load_dotenv()
client = anthropic.Anthropic()
class LeadInfo(BaseModel):
name: str
email: str
plan: str
interests: List[str]
response = client.messages.parse(
model="claude-sonnet-5-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": "提取:王雷(lei@co.com)想要企业版,关注 API 与 SDK。",
}],
output_format=LeadInfo, # 目标结构直接传模型类
)
lead = response.parsed_output # 已通过校验的 LeadInfo 实例
print(lead.name, lead.interests)
解读:parse() 的参数与非流式 create() 完全一致(max_tokens 依旧必填),多出的 output_format 接一个 Pydantic 模型。SDK 在幕后做三件事:把模型类转成 JSON Schema,作为 output_config.format 发给服务端;服务端保证输出严格符合 schema;响应回来后校验并反序列化,挂到 parsed_output 上。你拿到的直接就是一个类型完备的对象,json.loads 与手工校验全省了。
API 层的原始形态也值得看一眼,create() 直接传 output_config 的写法如下:
response = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": "……同上一条……"}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"plan": {"type": "string"}
},
"required": ["name", "email", "plan"],
"additionalProperties": False
}
}
},
)
import json
text = next(b.text for b in response.content if b.type == "text")
data = json.loads(text) # 首个 text 块保证是合法 JSON
解读:output_config.format 是 API 层的正式参数,parse() 的 output_format 只是它的便捷包装;这条路径下保证的是"第一个 text 块是合法 JSON",解析仍要自己做。因此日常优先 parse(),output_config 的价值在于不想引入 Pydantic、或用其他语言对接时手写 schema 直接发。两个参数的完整定义见官方 Messages API 参考,特性说明见官方结构化输出文档。
三个使用细节要有数。一是 schema 子集:只支持基础类型、enum、anyOf、$ref 与常见字符串格式(date、email 等),不支持递归 schema 与数值区间、字符串长度约束;Python SDK 会自动剥掉不支持的约束、改在客户端校验,Pydantic 模型里写 Field(gt=0) 不会报错。二是延迟:每个新 schema 有一次编译开销,相同 schema 24 小时内走缓存,高频任务不受影响。三是它并不排斥工具——output_config 与 tools 可以同用,工具负责查数据,output_config 负责约束最终文本,一次请求两头都拿到保证。
三是照旧检查 stop_reason——refusal 时输出可能不符合 schema,max_tokens 时 JSON 可能被截断,第 1 篇养成的"先看停止原因再消费内容"在这里同样适用。该特性与引用(citations)互斥,与流式、批处理、思考模式兼容——第 2 篇的打字机与它不冲突。
3、工具路线与 strict 模式
第二条路线服务于"本来就要用工具"的任务:管线里已经挂着查库存、发邮件等真实工具,结构化抽取只是其中一环。做法是把想要的输出结构定义成一个工具,schema 即答案的形状:
tools = [{
"name": "record_contact",
"description": "把用户提供的联系方式登记为一条记录",
"strict": True, # 参数必须严格符合 schema
"input_schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"plan": {"type": "string", "enum": ["Pro", "Max", "企业版"]}
},
"required": ["name", "email", "plan"],
"additionalProperties": False
}
}]
response = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=1024,
system="收到联系方式时,必须调用 record_contact 工具完成登记。",
tools=tools,
messages=[{"role": "user", "content": "李娜(na@co.com)要企业版"}],
)
解读:工具定义四件套——name、description、input_schema,再加 strict: True。strict 有两个前置条件:schema 必须声明 additionalProperties: False,且 required 覆盖全部字段;满足后服务端保证工具参数严格校验通过,这与原生模式是同一套保证机制。提示词里点名工具,替代的正是被移除的强制调用。tool_choice 现存取值如下:
| 取值 | 行为 | 当前代模型 |
|---|---|---|
auto |
模型自行决定是否调用,默认值 | 可用 |
any |
必须调用至少一个工具 | 返回 400 |
tool |
必须调用指定名称的工具 | 返回 400 |
none |
禁止调用任何工具 | 可用 |
auto 意味着"不保证调用",代码里必须检查并兜底。若要把每轮调用压到最多一次,可在 tool_choice 里附上 disable_parallel_tool_use: True——它与 auto 搭配依然有效,结构化抽取场景建议开着,省去处理并行调用的分支。结果从 content 块数组里取,第 1 篇的遍历判型在这里正式派上用场:
from pydantic import BaseModel
class Contact(BaseModel):
name: str
email: str
plan: str
tool_blocks = [b for b in response.content if b.type == "tool_use"]
if not tool_blocks: # auto 没触发调用,重试或改写提示词
raise RuntimeError("模型没有调用工具")
block = tool_blocks[0]
contact = Contact.model_validate(block.input) # input 已是字典,直接校验
print(contact.email, block.id)
解读:tool_use 块携带 name、id 与 input 三个字段,input 已被 SDK 解析成 Python 字典,不需要再 json.loads;把它交给 Pydantic 的 model_validate 做二次校验,等于在服务端保证之上再加一层业务规则。工具定义与选型的完整说明见官方工具使用文档。
4、次选:预格式 JSON 文本
第三条路线最朴素:不传任何特殊参数,只在 system 里要求"只输出 JSON",然后自己解析。示例如下:
import json
response = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=1024,
system="只输出一个 JSON 对象,不要代码块围栏,不要任何解释文字。",
messages=[{
"role": "user",
"content": "把'北京明天小雨,12 到 19 度'整理为 city、weather、low、high 字段",
}],
)
FENCE = "`" * 3 # 围栏标记:三个反引号
raw = next(b.text for b in response.content if b.type == "text").strip()
if raw.startswith(FENCE): # 容错一:剥掉围栏的首尾行
raw = "\n".join(raw.splitlines()[1:-1])
start, end = raw.find("{"), raw.rfind("}")
data = json.loads(raw[start:end + 1]) # 容错二:只保留大括号内
print(data)
解读:两处容错对应两类高发翻车——模型手滑加了 markdown 围栏,或在 JSON 前后缀了一句寒暄。这套代码能跑,但没有任何类型与字段保证:多一个字段、少一个字段、数字变字符串,都要自己写判断兜底。还有一类更隐蔽的风险是字段漂移——换个模型或改两句提示词,字段名悄悄变了,下游不会报错,只会静默写错数据;提示词给不了 schema 那样的硬边界。它够用的场景很有限:一次性脚本、快速原型、对格式宽容的展示用途。只要结果要进生产链路,就回到前两条路线——省下的不是几行容错代码,而是整类故障。
5、实战:发票字段提取器
把前面的零件拼成完整工具:从一段发票文字里抽出结构化账目,要求字段合法、数量为正、失败自动重试。先定义业务模型:
from typing import List
from pydantic import BaseModel, Field, ValidationError
class InvoiceItem(BaseModel):
name: str
qty: int = Field(gt=0) # 业务规则:数量必须为正
unit_price: float = Field(ge=0)
class Invoice(BaseModel):
invoice_no: str
date: str
currency: str
total: float
items: List[InvoiceItem]
解读:qty 与 unit_price 的约束写在 Pydantic 里而不是工具 schema 里——第 2 节说过,数值区间不在服务端支持范围内,这类业务规则正适合放在客户端校验层。工具定义沿用第 3 节的格式,name 取 record_invoice,schema 换成发票字段并保持 strict: True,items 的元素结构同样写全 required 与 additionalProperties。主循环如下:
def extract_invoice(text: str) -> Invoice:
messages = [{"role": "user", "content": f"提取发票字段:\n{text}"}]
for _ in range(3): # 校验循环:最多三轮
resp = client.messages.create(
model="claude-haiku-5-5", # 抽取类任务用轻量档
max_tokens=1024,
system="必须调用 record_invoice 工具完成提取。",
tools=[INVOICE_TOOL],
messages=messages,
)
blocks = [b for b in resp.content if b.type == "tool_use"]
if not blocks: # 没触发调用:点名重试
messages += [
{"role": "assistant", "content": resp.content},
{"role": "user", "content": "请调用 record_invoice 工具。"},
]
continue
try:
return Invoice.model_validate(blocks[0].input)
except ValidationError as err: # 校验失败:带错误重试
messages += [
{"role": "assistant", "content": resp.content},
{"role": "user", "content": f"校验失败:{err},请修正后重新调用。"},
]
raise RuntimeError("三轮未得到合法发票数据")
解读:循环处理两类失败——auto 没触发调用时点名重试;strict 保证了 schema 形状,但 gt=0 这类业务规则仍可能不满足,此时把 ValidationError 原文回传,模型能看到具体错在哪一字段。抽取任务选 claude-haiku-5-5:第 1 篇的档位表里它的定位就是分类与抽取,输入单价只有均衡档的二十分之一。进循环前照例先看 stop_reason,max_tokens 截断的半截参数不值得送去校验;跑批时批处理接口与工具调用兼容,量大可以排队异步消化。三轮上限防止死循环,超限抛错交由上层处理。这个"调用、校验、反馈、重试"的骨架,把第 3 节的所有零件都串了起来。
6、总结
本篇把 Anthropic 结构化输出的 2026 年 10 月口径讲清了:原生模式已经上线,client.messages.parse() 传 Pydantic 模型、拿 parsed_output,是取 JSON 的第一选择;工具路线用 strict: True 保证参数形状,配合提示词点名与"检查后重试",替代已被当前代模型移除的强制 tool_choice;纯文本加 json.loads 只配做原型。三句话带走:要 JSON 用 parse,工具混用开 strict,生产别裸解析。
留三个练习:给第 2 节的 LeadInfo 加一个嵌套的 company 对象字段,观察 Pydantic 嵌套模型如何自动转成 schema;把第 5 节的发票提取改成 parse() 版本,去掉工具与重试循环,对同一批样例对比两条路线的耗时与字段错误率;再给 Invoice 加一条模型级校验——total 必须等于各行 qty 与 unit_price 乘积之和,体验比 schema 更进一步的业务规则怎么进重试循环。对于正在把模型输出接进数据库或分支逻辑的开发者,建议今天就把手头的 json.loads 换成 parse()——那一层"也许能解析"的不确定性,本就不该由你的代码承担。工具的另一面——真正执行函数并把结果回传给模型——是后续篇章的主题,本篇的 tool_use 解析已经把门票买好了。

