有一个底层的问题,总会在真正开始“切换 模型”时瞬间暴露:
GPT-4 返回的 JSON 结构,和 Claude 返回的,长得不一样。
更麻烦的是,同一个模型,同样的 Prompt,跑两遍都可能给你不同的字段名。
这不是玄学,这是今天要解决的工程问题。
一、问题的本质:三个模型,三种“方言”
AI 开发中有一个核心矛盾:业务代码需要稳定的数据结构,但 LLM 的生成本质上是概率性的。
把这个问题拆开看,分为两层:
1.1 语法层:JSON 是否合法?
| 模型 | 结构化输出方案 | 特点 |
|---|---|---|
| OpenAI | Structured Outputs API | 原生支持 Pydantic 数据模型,直接在解码层约束输出格式 |
| Claude (Anthropic) | Tool Use(函数调用) | 需要“曲线救国”——通过 mock 工具调用来强制结构化输出 |
| Gemini (Google) | genai.protos.Schema | API 较为笨重,文档不够清晰,且难以通过字段描述引导输出 |
关键差异:OpenAI 的 Structured Outputs 是在解码层直接约束 Token 空间,而 Claude 和 Gemini 更多是在提示层引导。前者是“物理屏障”,后者是“交通规则”——一个靠强制,一个靠自觉。
1.2 语义层:结构对了,内容对吗?
即使 JSON 语法完全合法,模型也可能:
- 多出你没定义的字段
- 漏掉你要求必填的字段
- 把 enum 值写成了同义词
- 在字段命名上自由发挥(
user_name→userName→username)
用专业术语说,这叫 Schema Drift(模式漂移)。
二、实战:让三个模型输出同样的 JSON
2.1 目标 Schema 定义
先定一个目标 JSON Schema:
{
"sentiment": "positive | negative | neutral",
"key_issues": ["问题1", "问题2"],
"action_items": [
{
"team": "Engineering", "task": "具体任务描述"}
]
}
2.2 OpenAI:最顺滑的体验
OpenAI 的 Structured Outputs 是当前业界最成熟方案。直接集成 Pydantic 模型:
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import List
client = OpenAI()
class ActionItem(BaseModel):
team: str = Field(description="责任团队")
task: str = Field(description="具体任务")
class EvalResponse(BaseModel):
sentiment: str = Field(description="情感倾向:positive/negative/neutral")
key_issues: List[str] = Field(description="识别出的关键问题列表")
action_items: List[ActionItem] = Field(description="改进措施列表")
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "system", "content": "分析对话并输出结构化结果。"},
{
"role": "user", "content": dialog_text}
],
response_format=EvalResponse, # ← 关键:直接指定 Pydantic 模型
temperature=0
)
result = response.choices[0].message.parsed # 自动解析为 Pydantic 对象
优势:Schema 验证在解码层完成,不依赖 Prompt 的“自觉性”。
2.3 Claude:用“工具调用”来“骗”出 JSON
Claude 原生没有类似 response_format 的直接 JSON 约束。但有一个成熟的替代方案:把 JSON Schema 伪装成一个工具(Tool)的入参定义。
from anthropic import Anthropic
from pydantic import BaseModel, Field
client = Anthropic()
# 复用上面的 Pydantic 模型
class EvalResponse(BaseModel):
sentiment: str
key_issues: List[str]
action_items: List[ActionItem]
PROMPT = """
你是一个对话分析专家,请分析对话并将结果通过 send_to_manager 工具发送。
"""
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
system=PROMPT,
tools=[{
"name": "send_to_manager",
"description": "发送对话分析结果",
"input_schema": EvalResponse.model_json_schema(), # ← 核心:Schema 作为工具参数
}],
tool_choice={
"type": "tool", "name": "send_to_manager"}, # 强制使用该工具
messages=[{
"role": "user", "content": dialog_text}]
)
# 从工具调用参数中提取 JSON
tool_use = next(block for block in response.content if block.type == "tool_use")
result = tool_use.input # 已经是 dict 格式
为什么有效? 工具调用的 input_schema 对 Claude 来说是“强制性”的——它必须生成符合该 Schema 的参数才能完成函数调用。这比在 System Prompt 里写“请输出 JSON”要可靠得多。
2.4 Gemini:最“娇气”的选手
Gemini 的 JSON 约束需要通过 genai.protos.Schema 类实现,且对字段描述的支持有限。
import google.generativeai as genai
# 需要通过 protobuf 定义 Schema,较为繁琐
# 示例省略,核心思路与前两者类似:在请求中显式声明 response_mime_type="application/json"
# 并配合 response_schema 参数
⚠️ 注意:Gemini 的结构化输出在字段描述(Field Description)的引导能力上相对较弱,需要更严格的 Schema 定义和额外的后处理校验。
2.5 实战数据:各方案失败率对比
根据生产环境实测数据:
| 方法 | 模型 | Schema 违规率 | 说明 |
|---|---|---|---|
| JSON Mode(无 Schema 定义) | GPT-4o | 9.3% | 仅保证语法合法,不保证结构匹配 |
| JSON Mode(无 Schema 定义) | Claude Sonnet | 11.7% | 违规率更高,主要是额外字段 |
| Function Calling | GPT-4o | 1.2% | 违规集中在 enum 类型匹配上 |
| Function Calling | Claude Sonnet | 2.1% | 嵌套 Schema 时违规率上升 |
| Structured Outputs (strict) | GPT-4o | 0.2% | 当前最可靠的方案 |
Tool Use + additionalProperties: false |
Claude Sonnet | 0.3% | 与 GPT-4o strict 非常接近 |
结论:Schema 定义 + 工具调用(或 Structured Outputs)是必须的,只靠 Prompt 里“请输出 JSON”的叮嘱,10% 左右的请求会给你“惊喜”。
三、进阶:应对 Schema Drift(模式漂移)
即使所有响应都通过了 Schema 校验,你仍然可能收到“语义上错误”的数据。
3.1 三种常见漂移模式
| 类型 | 示例 | 风险等级 |
|---|---|---|
| 键名同义化 | "user_name" vs "userName" vs "username" |
低(可映射) |
| 枚举值同义化 | "high" vs "high_risk" vs "critical" |
中(需校验) |
| 语义值错误 | 中等风险被判为高风险,Schema 依然合法 | 高(无报错) |
前两种可以通过字段名映射和后处理校验解决。第三种最隐蔽——没有 Parse Error,Pydantic 验证通过,但下游拿到了错误数据。
3.2 解法:Chain of Thought + 强制解释
在 JSON 中增加一个 reasoning(推理过程)字段,强制模型在输出结论前先展示思考过程,可以显著提升准确性。
不加 reasoning 的模型表现:准确率 33%,即便 JSON 格式完全正确。
增加 reasoning 字段后:准确率跃升至 92%~95%。
class EvalResponse(BaseModel):
reasoning: str = Field(description="分析推理过程") # ← 这个字段是性能关键
sentiment: str
key_issues: List[str]
action_items: List[ActionItem]
为什么有效? 当模型需要“先思考再输出”时,思维链(Chain of Thought)帮助它在 Token 空间里把推理过程展开,最终决策的可靠性大幅提升。
3.3 高级技巧:Schema 抽象层
如果业务需要同时对接 OpenAI、Anthropic、Google 的 Structured Outputs,可以借助 Schema 抽象层——把一份“通用 Schema”自动转换成各厂商的方言格式:
# 示例:LangSchema(Elixir 生态)
abstract_schema = %{
"sentiment" => %{type: :string, enum: ["positive", "negative", "neutral"]},
"key_issues" => %{type: :array, items: %{type: :string}},
"action_items" => %{type: :array, items: %{team: :string, task: :string}}
}
# 自动转换为各厂商格式
openai_schema = LangSchema.Converter.OpenAI.convert(abstract_schema)
claude_schema = LangSchema.Converter.Anthropic.convert(abstract_schema)
gemini_schema = LangSchema.Converter.Gemini.convert(abstract_schema)
这样,业务代码只依赖一份抽象 Schema,切换模型时无需修改数据结构定义。
四、错误修复循环:最后的防线
即使做了上述所有措施,结构化输出仍可能在极少数 Edge Cases 下“破防”。生产级系统需要一个 错误修复循环(Repair Loop):
Prompt → Generate → Validate → Repair → Parse
import json
import re
from openai import OpenAI
def only_json(s: str) -> str:
"""提取字符串中的 JSON 部分"""
m = re.search(r"\{.*\}", s, flags=re.S)
return m.group(0) if m else s
def repair(bad: str) -> str:
"""调用模型修复非法 JSON"""
fix = f"Fix this so it is STRICT valid JSON with no comments or text:\n{bad}"
out = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user", "content": fix}],
temperature=0
)
return only_json(out.choices[0].message.content)
raw = generate_once(SCHEMA_PROMPT)
for _ in range(2): # 最多重试 2 次
try:
data = json.loads(raw)
doc = EvalResponse(**data)
break
except (json.JSONDecodeError, ValidationError):
raw = repair(raw)
这个模式的核心思想是:将“生成器”和“修复器”分离。第一次调用专注于内容质量,第二次调用只做机械性的语法清理,不添加新内容。
五、最佳实践总结
| 场景 | 推荐方案 | 核心要点 |
|---|---|---|
| 只用 OpenAI | Structured Outputs + Pydantic | 最省心,失败率 < 0.5% |
| 多厂商混用 | Tool/Function Calling + 抽象 Schema 层 | 用工具调用代替“请输出 JSON” |
| Claude 用户 | Tool Use + additionalProperties: false |
实测失败率可压至 0.3% |
| 语义准确性要求高 | 增加 reasoning 字段 |
准确率提升 60%+ |
| 高可用要求 | 接入 Repair Loop | 将 Edge Cases 从“系统崩溃”降级为“自动修复” |
| 字段命名一致性 | 在 Gateway 层做字段映射 | 统一 snake_case 或 camelCase |
一句话总结:
用 Schema 约束代替“叮嘱”,用 Tool Call 代替“请输出 JSON”,用 Chain of Thought 代替“直接给答案”。
跨模型切换这件事,不是让所有模型“变聪明”,而是让它们都接受同一套契约。Gateway 在这中间扮演的角色不是中转,而是翻译官和守门员——把各家的结构化输出方言,统一翻译成业务代码能理解的标准语言。