结构化输出
大约 4 分钟
结构化输出
agent 的输出终归要被程序消费:前端要渲染、下游要入库、编排要路由。本篇讲让模型稳定吐出「合法数据」的完整工具箱。
一、三档方案
L1 提示词约定 + 手工解析 「请输出 JSON」——最弱,模型随时飘
L2 受限生成 / JSON mode API 层保证语法合法(是个 JSON)
L3 Schema 约束输出 API 按 JSON Schema 采样,字段/类型/枚举全保证
L3:结构化输出(首选)
from pydantic import BaseModel
class Order(BaseModel):
order_id: str
amount: float
status: str
tags: list[str]
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": "解析这条短信:您的订单 A1024 已支付 299.50 元,商品:机械键盘",
}],
tools=[{
"name": "save_order",
"description": "保存解析出的订单信息",
"input_schema": Order.model_json_schema(), # Pydantic 直接生成
}],
tool_choice={"type": "tool", "name": "save_order"}, # 强制调用此工具
)
tool_input = next(b.input for b in resp.content if b.type == "tool_use")
order = Order.model_validate(tool_input) # 校验 + 类型转换一步到位
print(order.amount) # 299.5 —— 类型安全,IDE 补全全有
原理:tool_choice 强制模型调用指定工具 → 模型的输出就是工具参数 → 参数必须符合 schema → 你拿到了保证合法的结构化数据。「用工具做输出」是当前最通用的结构化输出手法。
二、Pydantic:schema 即代码
模型定义与校验一体,是 Python 侧的标准答案:
from pydantic import BaseModel, Field, field_validator
class ExtractedTask(BaseModel):
"""从用户消息中提取的任务"""
title: str = Field(description="任务标题,20 字内")
priority: Literal["low", "medium", "high"] = Field(description="优先级")
deadline: date | None = Field(
default=None,
description="截止日期 YYYY-MM-DD;用户未提及则不传",
)
confidence: float = Field(ge=0, le=1, description="提取置信度 0-1")
@field_validator("title")
@classmethod
def title_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("标题不能为空")
return v.strip()
# 一行拿到 schema(喂给 API)
schema = ExtractedTask.model_json_schema()
# 输出一行校验(API 返回后)
task = ExtractedTask.model_validate(raw_dict)
TS 侧对应 zod:
import { z } from "zod";
const TaskSchema = z.object({
title: z.string().describe("任务标题,20 字内"),
priority: z.enum(["low", "medium", "high"]),
deadline: z.string().date().nullable(),
});
type Task = z.infer<typeof TaskSchema>;
// zod-to-json-schema 转 schema 喂给 API
Field 级 description 会进入 JSON Schema → 模型看得见 → schema 本身也是提示词。
三、校验失败:重试循环
即使有 schema 约束,语义层面的错误仍会发生(日期是过去、数字不合理)。标准姿势——校验错误回给模型让它自己修:
async def extract_with_retry(user_msg: str, attempts: int = 3) -> ExtractedTask:
messages = [{"role": "user", "content": user_msg}]
for i in range(attempts):
resp = await call_llm(messages=messages, schema=ExtractedTask)
raw = resp.parsed # dict
try:
return ExtractedTask.model_validate(raw)
except ValidationError as e:
# 关键:把校验错误作为反馈发回
messages.append({"role": "assistant", "content": json.dumps(raw)})
messages.append({
"role": "user",
"content": f"输出未通过校验:{e}\n请修正后重新输出,只输出 JSON。",
})
raise RuntimeError("结构化输出重试耗尽")
这和工具错误回注是同一个思想:错误信息是给模型的输入,不是日志。
四、流式场景下的结构化输出
矛盾:流式要边生成边渲染,JSON 要完整才能解析。解法——流式增量解析:
方案 1:UI 层流式(展示),数据层等完整
文本部分正常流式渲染;结构化数据等 message 完成后再解析入库
方案 2:增量 JSON parser
用 partial-json 这类库,边收 delta 边解析:
{"title": "写周报", "pri → {title: "写周报"} 已经可用
适合长表单的实时预览场景
五、什么时候不该用结构化输出
✗ 长文本创作(文章、邮件正文)
→ 结构化反而限制表达;正文用纯文本,元数据用结构化:
{"title": ..., "tags": [...]} + 正文分开生成
✗ 需要多轮工具调用的任务
→ 结构化输出适合「终态」;过程交给 agent loop,只约束最终输出
✓ 最适合:抽取、分类、路由判断、生成配置、表单填充
混合模式实战——路由器(agent 编排里的高频组件):
class Route(BaseModel):
intent: Literal["chat", "search_docs", "create_ticket", "refund"]
reason: str = Field(description="一句话路由理由")
# 一次廉价的小模型调用,决定后面走哪条 agent 流水线
# → 06 篇多 Agent 编排的地基
六、常见坑
1. schema 里 optional 字段被模型硬填
→ description 写明「未知时不要传这个字段」
2. 枚举值飘(输出了 schema 外的值)
→ L3 方案下 API 已保证;L1/L2 下必须在代码里枚举校验
3. 大 schema 一次塞 30 个字段
→ 拆成多次抽取(每次一个子 schema),准确率显著更高
4. 数字精度(金额输出了 "299.50 元")
→ 类型约束 float + description 给示例「纯数字,不含单位」
5. 用 JSON mode 就以为万事大吉
→ JSON mode 只保证「是合法 JSON」,不保证字段和类型
本篇小结
- 三档方案:优先 L3(schema 约束输出,经 tool_choice 实现)
- Pydantic/zod:schema 即代码、即文档、即提示词
- 校验失败回注重试:错误信息是给模型的输入
- 长文本不要结构化;抽取/分类/路由是结构化输出的主场
下一篇:记忆与状态。
