结构化输出是不可信的契约
合法 JSON 不等于合法的业务决策。解析字节、校验结构、核实含义,然后才允许执行动作。
类型: Build 语言: Python 前置要求: 验证主张,而非置信度、Messages API 是一台状态机预计时间: 约 95 分钟
学习目标
- 区分 JSON 语法、schema 有效性、语义有效性和授权
- 设计窄 schema,让无效状态难以表达
- 在不做不安全清理或乐观强制转换的前提下解析 Claude 输出
- 通过次数受限、证据充分的重试修复无效响应
- 演进输出契约时不悄悄破坏消费者
- 在对抗性与流式边界测试结构化输出
本应失败的 JSON
你的客服应用要求优先级介于 1 到 5,返回结果如下:
{
"category": "billing",
"priority": 9,
"summary": "Customer reports a duplicate charge",
"needs_human": false
}JSON 解析器成功了。对象拥有全部预期键。应用把它路由为最高紧急优先级,跳过人工审核,并呼叫值班工程师。
模型没有违反 JSON。是你的应用没有执行契约。
结构化输出有四道关卡:
- 语法: 是否恰好有一个可解析的 JSON 值?
- 结构: 该值是否符合类型、必填字段、枚举、边界及额外属性规则?
- 语义: 字段是否与领域事实以及彼此一致?
- 权限: 所请求的下游动作是否被允许?
通过前一道关卡,绝不意味着通过后一道。
flowchart LR
Raw[Raw model output] --> Parse[Strict JSON parse]
Parse --> Schema[Schema validation]
Schema --> Meaning[Semantic checks]
Meaning --> Policy[Authorization and policy]
Policy --> Consume[Typed application object]
Parse --> Repair[Bounded repair]
Schema --> Repair
Meaning --> Escalate[Human review or safe fallback]
Policy --> Deny[Deterministic denial]
Repair --> Raw要求 JSON 输出不等于建立契约
“只返回 JSON”是一条指令。它能提高概率,却不能让无效输出绝无可能,也不能防止 schema 漂移或校验业务含义。
如果当前模型和 API 支持结构化输出,你可以提供 JSON Schema,并要求平台约束生成。这会减少语法和结构失败,但仍无法证明被引用的订单存在、退款已获授权,或分类是正确的。
产品说明,已于 2026-08-09 核实:结构化输出的可用性、支持的 schema 关键字、与其他功能的不兼容项以及模型支持情况都可能变化。上线前请检查结构化输出。即使启用了受约束解码,也要保留应用侧校验。
应用拥有 schema。像管理 API 一样为它做版本管理。
{
"$id": "support-triage-v1",
"type": "object",
"required": ["category", "priority", "summary", "needs_human"],
"additionalProperties": false,
"properties": {
"category": {
"type": "string",
"enum": ["billing", "bug", "account", "other"]
},
"priority": {
"type": "integer",
"minimum": 1,
"maximum": 5
},
"summary": {
"type": "string",
"minLength": 1,
"maxLength": 240
},
"needs_human": {
"type": "boolean"
}
}
}这个 schema 的严格性有其理由。消费者预期恰好四个字段。意外的 debug_context 字段可能把私密文本带入日志;整数边界能阻止 9;枚举能防止分类拼写分裂分析数据。
从消费者决策出发设计 Schema
设计 schema 时,先看下一个确定性组件必须决定什么。
若消费者需要选择队列,就给它枚举。若它要排序优先级,就给它有边界的整数。若不确定性会改变路由,就把它明确写进字段。
对比这两份契约:
{"answer": "Probably a billing issue. It seems urgent."}{
"category": "billing",
"priority": 4,
"evidence_ids": ["invoice-483", "message-12"],
"uncertainty": "medium",
"needs_human": true
}第二个对象让路由和核验成为可能。它仍可能出错,但可以检查。
采用以下设计规则:
- 自由文本标签改用枚举。
- 仅当每个合法响应都能提供字段时,才将它设为必填。
- 有意使用
null表达“已知不存在”,不要把它当作通用逃生口。 - 除非消费者有意支持扩展,否则拒绝额外属性。
- 限制字符串和数组长度,以控制成本和存储。
- 事实必须可追溯时,包含证据标识符。
- 动作字段只表示提案,不能作为授权证明。
- 为 schema 提供稳定的名称和版本。
不要用一个巨型 schema,通过几十个可选字段来表示无关模式。应使用带标签的联合类型或拆分端点契约。每个字段都可选时,无效状态会成倍增加。
严格解析
乐观清理会掩盖失败。看下面的模式:
raw = raw.replace("```json", "").replace("```", "")
payload = json.loads(raw)它看似友好,却在生成后改变了契约。含有说明文字、两个 JSON 对象或用户可控围栏文本的响应,可能被改造成模型从未作为单一值返回过的内容。
应优先使用严格解析:
payload = json.loads(raw)
validate_against_schema(payload)若契约要求一个 JSON 对象,就应拒绝 Markdown 围栏和尾随散文。记录失败类别,这样修复尝试才能收到精确错误。
不要悄悄强制转换:
"4"不是整数。1不是布尔值。"false"不是 false。- 逗号分隔的字符串不是数组。
- 除非 schema 声明了安全默认值且应用有意应用它,否则缺失字段不等同于安全默认值。
Python 有一种情况尤其微妙:bool 是 int 的子类。天真的 isinstance(True, int) 检查会在需要整数的位置接受布尔值。可运行的校验器会明确拒绝它。
在结构之后校验含义
schema 可以证明 invoice_id 是字符串,无法证明该发票存在或属于已认证用户。
语义校验使用可信的应用数据:
if payload["invoice_id"] not in invoices_for(authenticated_user):
raise SemanticError("invoice is not visible to this user")
if payload["refund_amount"] > verified_charge_amount:
raise SemanticError("refund exceeds verified charge")跨字段规则同样重要。当 uncertainty: high 时,needs_human: false 可能无效。提议的 action: close_account 可能需要审批 token。引用 ID 必须能解析为确实支持该主张的来源。
模型可以协助产出提案。确定性代码则校验身份、所有权、金额边界、权限与状态迁移。
带预算地修复
无效输出不总是必须失败。若任务风险低,且修正不会编造缺失证据,语法或 schema 错误可以修复。
修复循环应包含:
- 原始任务和未变更的可信上下文。
- schema 或精确的契约摘要。
- 带字段路径的机器生成校验错误。
- 严格的最大尝试次数。
- 终止回退或升级处理。
Repair the previous output.
Return one JSON object and no surrounding text.
Validation errors:
- $.priority: expected integer from 1 through 5
- $.needs_human: required field is missing
Do not invent evidence that was not present in the source.不要把原始异常转储、密钥、数据库记录或任意不可信字符串粘贴到更高信任级别的指令区。校验反馈也是数据,应对它划定边界,让可信修复指令保持独立。
两次尝试通常就能看出失败是随机格式问题,还是更深层的契约不匹配。无限重试会消耗预算,还可能放大 prompt-injection 载荷。应统计尝试次数、token、延迟和重复的错误指纹。
若来源缺少必需证据,修复 JSON 就是错误操作。应返回明确的不完整状态或升级处理。
工具输入与最终输出是不同的契约
Claude 的工具使用也会提供结构化输入,但它服务于不同边界。
- 工具输入 schema 帮助模型构造调用。
- 工具处理程序仍会校验值,并对调用者授权。
- 来自远程服务的工具结果是不可信的外部数据。
- 最终应用输出拥有面向消费者的独立 schema。
不要把宽泛的内部工具 schema 复用于公开响应契约。内部字段可能暴露实现细节或密钥。应把已核验的工具结果映射为最小化的最终对象。
同样,绝不要因为最终 JSON 含有 "approved": true 就执行动作。批准必须来自经过认证的应用状态;模型输出无权批准。
当工具使用作为结构化输出机制时,请了解 CCAR-F 指南使用的三种公开 tool_choice 决策:
| 选择 | 模型行为 | 适用场景 |
|---|---|---|
auto | 模型可以调用工具,或返回对话文本 | 两条路径都有效 |
any | 模型必须调用所提供工具之一 | 需要类型化工具结果,但有多个有效 schema |
{"type":"tool","name":"extract_metadata"} | 必须选择指定工具 | 后续工作前必须执行一次已知提取 |
对于最终的机器可读响应,如果当前原生结构化输出能力支持所需 schema 与功能组合,应优先使用它。只有当工作流确实在选择或调用工具时,才使用工具 schema。无论哪一种,语义校验和授权仍是应用的职责。
Pydantic 是校验器实现,不是契约
公开 CCAR-F 指南将 Pydantic 与 JSON Schema 校验、校验重试循环并列提及。在 Python 中,Pydantic 模型可以生成 schema,根据其配置强制转换或拒绝输入,并表达跨字段校验。它不能让模型主张变为事实,也不能授予下游权限。
本仓库保持 stdlib-first,因此可运行实验直接实现相关检查。如果生产应用已经使用 Pydantic,请显式映射同样的四道关卡:
JSON parse -> Pydantic shape validation -> domain validation -> authorization检查强制转换行为。将 "4" 静默变成 4 的校验器,可能适合某个外部边界,却在另一个边界不可接受。把有边界、字段级的校验错误反馈给修复流程;来源缺少必需证据时则升级处理。
流式传输产生不完整语法
通过流接收的 JSON,直到相关内容块结束之前都不完整。前缀 {"category":"bill 还不是无效内容,它尚未完成。
缓冲结构化块。不要反复解析每个字符,除非使用专为增量 JSON 设计、且确实理解其部分状态语义的解析器。不要在某个必填字段恰好较早出现时触发下游动作。
内容块完成后:
- 确认流到达有效的终止事件。
- 只解析一次。
- 校验 schema。
- 校验语义和策略。
- 原子地提交下游状态迁移。
若流断开,丢弃或隔离部分对象。UI 可以显示暂定文本,但应用契约尚未完成。
Schema 演进就是 API 迁移
假设版本 1 将 priority 作为整数返回,版本 2 用 severity: "low" | "medium" | "high" 替换它。先部署 prompt 会破坏旧消费者;先部署消费者则可能拒绝旧输出。
使用以下策略之一:
- 添加契约版本字段,在迁移期间同时支持两个版本。
- 为严格规划的兼容窗口部署宽容读取器。
- 在切换前并行生成并比较结果。
- 在适配器边界把新输出转换为旧内部类型。
绝不要悄悄修改 schema。在 trace 中记录 schema 版本、prompt 版本、模型版本和校验器版本。回归评估必须覆盖新旧示例、边界值、遗漏字段、意外字段、恶意字符串与大输入。
构建校验器与修复循环
code/main.py 在无外部依赖的情况下,实现了 JSON Schema 的实用子集。它校验对象、必填字段、额外属性、原始类型、枚举、数值边界、字符串边界、数组和嵌套路径;随后将校验器包装进一个有次数上限的提取器。
运行它:
cd certifications/claude/lessons/09-structured-output-and-defensive-parsing/code
python3 main.py
python3 -m unittest discover tests -v第一个脚本响应在需要整数的位置使用 "high",第二个响应修复该字段。测试证明:Markdown 围栏、缺失字段、布尔值充当整数、意外字段和重试耗尽都会显式失败。
在生产中,应优先使用应用技术栈支持的成熟校验器。手写子集用于展示库会执行哪些检查,不能替代完整 JSON Schema 实现。
交互实验
使用恢复图将候选输出依次送过语法、schema、语义和授权关卡。把修复预算花在一个结构错误上,再将结果与必须升级处理的缺失证据失败进行对比。
09-structured-output-recovery实践实验
运行有次数上限的提取器,然后提交围栏包裹的 JSON、布尔整数、意外字段和两次无效尝试。确定每个失败归属语法、结构、含义还是权限。
交付产物
outputs/validated-triage.json 是无 provider 修复演示产出的已填充契约。运行 python3 main.py 重现它,然后运行单元测试套件。一个测试会比较已提交产物与 demo();其余测试覆盖围栏、缺失字段、布尔整数、额外属性、次数受限的修复和耗尽的重试。
验证
cd certifications/claude/lessons/09-structured-output-and-defensive-parsing/code
python3 main.py
python3 -m unittest discover tests -v综合项目关联
测验会检查每个失败归属哪道关卡。在 Developer 综合项目 30 与 Architect 综合项目 31、32 中使用已校验对象及修复证据。
考试决策规则
- 输出能解析但违反范围或枚举时,选择 schema 校验,而不是 prompt 清理。
- 输出符合 schema 却与可信记录冲突时,选择语义核验。
- 对象提议特权动作时,应根据应用身份和策略授权。
- 格式短暂失败时,使用带精确校验反馈的次数受限修复。
- 证据缺失时,升级处理或返回明确的不完整状态,不要修复事实。
- 流未完成时,不要解析,也不要当作契约已结束来执行动作。
- schema 变化时,应像任何公开 API 一样做版本化和迁移。
- 如果可使用受约束生成,可借它减少错误,但仍要保留下游校验。
练习
- 将
evidence_ids添加为有边界字符串组成的数组。为合法列表、整数项和超过你选择上限的列表编写测试。 - 添加跨字段规则:
uncertainty: high要求needs_human: true。 - 创建一个语义校验器,确认发票属于已认证用户,同时不向模型暴露完整发票记录。
- 添加
contract_version字段,并实现从版本 1 到版本 2 的适配器。 - 向校验器输入十个对抗性字符串:围栏、重复对象、意外字段、转义控制文本、超长摘要、布尔整数和嵌套的 prompt-injection 语言。
- 在单独的生产沙箱中将分诊契约重建为 Pydantic 模型。对比严格模式和强制转换行为,但不要将 Pydantic 添加为本课依赖。