为什么结构化输出如此重要
LLM的默认输出是自由文本,但实际工程中我们几乎总是需要结构化数据——API调用需要JSON参数、数据抽取需要表格、Agent决策需要指令。结构化输出的可靠性直接决定了系统能否自动化运行。
目前主流的三种结构化输出方案各有优劣,选对场景才能发挥最大价值。
方案一:JSON Mode
原理与用法
JSON Mode是OpenAI率先引入的功能,强制模型输出合法JSON。它的原理是在解码阶段约束token采样,确保输出符合JSON语法。
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": "你是数据抽取助手,输出JSON格式。"},
{"role": "user", "content": "抽取以下文本的人名和职位:张三是CEO,李四是CTO。"}
],
response_format={"type": "json_object"}
)
import json
result = json.loads(response.choices[0].message.content)
# {"people": [{"name": "张三", "title": "CEO"}, {"name": "李四", "title": "CTO"}]}
局限性
JSON Mode只保证语法合法,不保证结构符合预期。它无法约束字段名、类型和嵌套层级。你需要额外验证:
from pydantic import BaseModel, ValidationError
class PersonInfo(BasestModel):
name: str
title: str
class ExtractionResult(BaseModel):
people: list[PersonInfo]
def safe_parse(raw_json: str) -> dict | None:
try:
data = json.loads(raw_json)
return ExtractionResult(**data).model_dump()
except (json.JSONDecodeError, ValidationError) as e:
# 记录错误,触发修复流程
logger.warning(f"Schema validation failed: {e}")
return None
方案二:Function Calling
设计理念
Function Calling让模型"知道"有哪些可用函数及其参数schema,模型负责生成符合schema的调用参数。这是一种约束生成方式,比JSON Mode更严格。
tools = [
{
"type": "function",
"function": {
"name": "search_database",
"description": "在产品数据库中搜索",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索关键词"},
"category": {
"type": "string",
"enum": ["电子产品", "服装", "食品"],
"description": "限定品类"
},
"limit": {"type": "integer", "minimum": 1, "maximum": 50}
},
"required": ["query"]
}
}
}
]
response = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "帮我找3个电子产品类的手机"}],
tools=tools,
tool_choice={"type": "function", "function": {"name": "search_database"}}
)
# 模型生成结构化参数
tool_call = response.choices[0].message.tool_calls[0]
args = json.loads(tool_call.function.arguments)
# {"query": "手机", "category": "电子产品", "limit": 3}
多函数编排
Function Calling的真正威力在于多函数编排和模型自主决策调用链:
class FunctionOrchestrator:
def __init__(self, functions: dict):
self.functions = functions # name -> callable
async def run(self, query: str, max_rounds: int = 5):
messages = [{"role": "user", "content": query}]
tools = self._build_tool_schemas()
for round_idx in range(max_rounds):
resp = await client.chat.completions.create(
model="gpt-4o", messages=messages, tools=tools
)
msg = resp.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content # 模型决定直接回复
for tc in msg.tool_calls:
fn = self.functions.get(tc.function.name)
if fn:
result = await fn(**json.loads(tc.function.arguments))
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": str(result)
})
return "达到最大轮次限制"
方案三:Schema约束生成
约束解码
这是最严格的方法——在解码阶段直接约束token采样,确保输出100%符合JSON Schema。outlines和guidance是代表性框架:
from pydantic import BaseModel
from outlines import models, generate
class PersonExtraction(BaseModel):
name: str
age: int
occupation: str
skills: list[str]
model = models.transformers("Qwen/Qwen2.5-7B-Instruct")
generator = generate.json(model, PersonExtraction)
# 输出100%符合schema,无需重试
result = generator("张三,30岁,软件工程师,擅长Python和Go")
# PersonExtraction(name="张三", age=30, occupation="软件工程师", skills=["Python", "Go"])
约束解码原理
约束解码的核心是在每一步token采样时,计算当前允许的token集合:
# 简化示意:FSM状态机驱动的token mask
class JSONSchemaFSM:
def __init__(self, schema: dict):
self.states = self._build_states(schema)
self.current_state = 0
def allowed_next_tokens(self, tokenizer) -> set[int]:
"""返回当前状态下合法的token ID集合"""
legal = set()
for transition in self.states[self.current_state]:
for token_str in transition.accepts:
legal.update(tokenizer.encode(token_str))
return legal
def transition(self, token_str: str):
"""根据生成的token推进状态机"""
for transition in self.states[self.current_state]:
if token_str in transition.accepts:
self.current_state = transition.target
return
三方案对比
| 维度 | JSON Mode | Function Calling | Schema约束 |
|---|---|---|---|
| 保证级别 | 语法合法 | 参数结构合规 | 100% Schema合规 |
| 模型支持 | 广泛 | 主流闭源模型 | 开源模型为主 |
| 灵活性 | 高 | 中 | 低(需预定义schema) |
| 延迟开销 | 几乎无 | 轻微 | 有(约束解码) |
| 重试率 | 中等 | 低 | 零 |
| 适用场景 | 简单抽取 | Agent工具调用 | 严格数据生成 |
实践建议
- 简单抽取任务:用JSON Mode + Pydantic验证,失败重试最多3次
- Agent工具调用:用Function Calling,配合
tool_choice控制调用时机 - 离线批量处理:用Schema约束生成,零重试零浪费
- 混合策略:在线服务用Function Calling保证体验,离线验证用Schema约束兜底
# 混合策略示例
async def robust_extract(text: str) -> dict:
# 第一轮:快速JSON Mode尝试
result = await try_json_mode(text)
if result and validate(result):
return result
# 第二轮:降级到Function Calling
result = await try_function_call(text)
if result:
return result
# 第三轮:Schema约束兜底
return await try_constrained(text)
总结
结构化输出不是一个简单的"让模型输出JSON"的问题,而是约束强度与灵活性之间的工程权衡。理解三种方案的原理和边界,才能在不同场景下做出正确选择。核心原则:约束越强,可靠性越高,但灵活性越低。根据业务对可靠性的要求选择合适的约束级别。