把 LLM 接入业务系统的第一步,不是让模型更聪明,而是让它「说话可被程序解析」。一段自然语言回复对人友好,对系统却是灾难:你要写多少正则才能可靠地从中抽出金额、日期、订单号?结构化输出与函数调用(Function Calling)解决的正是这个问题——把模型的输出约束成一个程序能直接消费的结构,或者让模型以「调用函数」的形式表达它的意图。
这件事的工程难点不在「怎么让模型吐 JSON」——提示里加一句「请输出 JSON」就能做到,但成功率可能只有 80%,剩下 20% 是多余的解释文字、尾随逗号、缺字段、或者干脆用中文标点。真正的难点是如何把成功率推到 99.9% 以上,因为下游系统的一次解析失败就是一次线上事故。
达成高可靠有两条路径:一是约束解码(constrained decoding),在模型生成时就把非法 token 屏蔽掉,从概率上保证输出合法;二是应用层校验与重试,把不可避免的失败兜住。成熟的系统两条都用:约束解码负责「绝大多数情况直接正确」,校验重试负责「剩下的兜底」。本文按这个框架展开,并覆盖工具定义、并行调用、流式输出、多轮循环与幂等设计。
目录
- 结构化输出解决什么问题
- JSON Schema 与严格模式
- 约束解码:grammar 与有限状态机
- 工具定义与并行调用
- 参数校验与失败重试
- 流式结构化输出
- 多轮工具调用循环
- 与业务系统集成的幂等设计
- 性能与成本
- 生产落地清单
- 权衡取舍
- 常见坑清单
- 小结
1. 结构化输出解决什么问题
传统做法是「提示模型输出 JSON,再用正则或宽容解析提取」。这条路的问题在于失败模式不可控:
| 失败模式 | 表现 | 触发原因 |
|---|---|---|
| 包裹解释文字 | 好的,以下是结果:{...} | 模型习惯性加前言 |
| 语法错误 | 尾随逗号、单引号、缺引号 | 生成时的 token 级随机 |
| 字段缺失 | 少了必填字段 | 提示不够明确 |
| 类型错误 | 数字写成字符串 "123" | 模型不区分类型 |
| 枚举越界 | 输出 schema 未定义的取值 | 模型自由发挥 |
| 中文标点 | "name":"x" | 训练数据偏中文 |
每一种都要写对应的修补逻辑,而这些逻辑会随模型、提示、数据的变化不断失效。结构化输出把「解析」这件事从「事后修补」变成「生成时保证」,从根上消除前四类失败。
更重要的是,结构化输出让 LLM 从「文本生成器」变成「可编程组件」。一旦输出是可靠的 schema,就可以像调用普通 API 一样调用它,进入类型系统、进入单元测试、进入流水线。这是 LLM 应用工程化的分水岭。理解这一点,可以把模型调用与常规的 LLM API 基础 放在同一套工程约束下看待:一样的重试、一样的超时、一样的契约校验。
2. JSON Schema 与严格模式
JSON Schema 是描述结构的事实标准。给模型一个 schema,它就知道该产出什么形状的数据。
{
"type": "object",
"properties": {
"intent": {"type": "string", "enum": ["refund", "tech", "general"]},
"order_id": {"type": "string", "pattern": "^[A-Z]{2}[0-9]{6}$"},
"amount": {"type": "number", "minimum": 0},
"reason": {"type": "string", "maxLength": 200}
},
"required": ["intent", "order_id"],
"additionalProperties": false
}
关键约束的写法:
| 约束 | 关键字 | 作用 |
|---|---|---|
| 必填 | required | 防止字段缺失 |
| 枚举 | enum | 限定取值范围 |
| 正则 | pattern | 限定格式(订单号、邮箱) |
| 范围 | minimum / maximum | 数值边界 |
| 长度 | maxLength | 防止超长输出 |
| 封闭 | additionalProperties: false | 禁止多余字段 |
| 嵌套 | $ref / definitions | 复用与递归结构 |
严格模式(Structured Outputs)
OpenAI 在 2024 年推出的 Structured Outputs 是这条路线的里程碑:当开启 strict: true 时,模型被保证输出严格符合提供的 schema(通过约束解码实现)。它的限制也很明确:
- 所有字段必须列在
required里(不支持可选字段,用nullable表达)。 - 不支持部分 JSON Schema 关键字(如
minLength、pattern在早期版本不支持,需要自己校验)。 - 嵌套深度与 union 类型有限制。
- 需要模型与 API 版本支持。
严格模式把「解析成功率」从 95% 级推到接近 100%,但它不是银弹:schema 表达不了的业务规则(如「金额不能超过订单总额」)仍需应用层校验。严格模式保证的是「结构合法」,不是「语义正确」。
3. 约束解码:grammar 与有限状态机
约束解码是结构化输出的底层机制,理解它有助于判断「哪些约束是免费的、哪些是昂贵的」。
原理是:在每一步生成时,根据当前已生成的前缀,计算「哪些 token 能让最终输出仍然符合目标语法」,把这些合法 token 之外的所有 token 的 logit 置为负无穷,再做采样。
正常采样: p(token) = softmax(logits)
约束解码: mask = 合法 token 集合
logits[~mask] = -inf
p(token) = softmax(logits)
前缀 "{"name":" → 合法 token:任意字符串字符或 "
前缀 "{"name":"a" → 合法 token:"," 或更多字符或 "
合法的 token 集合由一台有限状态机(FSM) 或下推自动机(PDA) 维护。JSON 是上下文无关语法,严格来说需要 PDA 才能处理嵌套括号,但实践中用状态机加上对括号计数的近似,就能覆盖绝大多数情况。
| 实现 | 机制 | 代表 | 适用 |
|---|---|---|---|
| 正则约束 | 有限自动机 | outlines(regex 模式) | 简单格式(日期、电话) |
| JSON Schema | 语法编译成 FSM | outlines、XGrammar | 结构化输出主力 |
| CFG 语法 | 上下文无关文法 | llama.cpp GBNF | 复杂嵌套语法 |
| 后端原生 | 引擎内置 | vLLM guided decoding、TensorRT-LLM | 自建推理 |
import outlines
from pydantic import BaseModel, Field
class Refund(BaseModel):
order_id: str = Field(pattern=r"^[A-Z]{2}[0-9]{6}$")
amount: float = Field(ge=0)
reason: str = Field(max_length=200)
model = outlines.models.transformers("Qwen/Qwen2.5-7B-Instruct")
generator = outlines.generate.json(model, Refund) # schema 编译成 FSM 约束生成
result = generator("帮我退掉订单 AB123456,金额 199 元") # 必然是合法 Refund 实例
这段代码的价值在于:result 一定满足 schema,调用方不需要写任何解析兜底逻辑。
约束解码的代价是生成速度下降。每一步都要计算合法 token 的掩码,复杂 schema 会让这个计算变重;此外,被强制走合法路径的 token 可能降低语义质量(模型「想说的话」被语法挡住)。实测简单 schema 的吞吐损失在 5% 以内,复杂嵌套 schema 可能到 20%。
一个重要的判断:约束越松,质量越好、速度越快;约束越紧,越可靠但越慢。因此 schema 应该只约束「下游真的需要」的字段,而不是把能想到的规则都塞进去。
4. 工具定义与并行调用
函数调用(工具调用)是结构化输出的一个特例:模型输出的不是最终答案,而是一个「函数名 + 参数」的结构,由应用执行后把结果返回。
tools = [{
"type": "function",
"function": {
"name": "get_order_status",
"description": "查询订单的当前状态,用于回答发货、物流相关问题",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "订单号,格式如 AB123456"},
"include_logistics": {"type": "boolean", "description": "是否返回物流轨迹"}
},
"required": ["order_id"],
"additionalProperties": False
},
"strict": True
}
}]
工具定义的三个纪律:
- description 是给模型看的文档。它决定了模型何时调用这个工具。写得越清楚(做什么、什么时候用、不做什么),误调用越少。工具描述的质量比工具数量重要得多,工具设计的通用模式可参考 MCP 工具设计模式 。
- 参数 schema 尽量收紧。能用 enum 就别用 string,能加 pattern 就加,减少模型自由发挥的空间。
- 工具数量控制在 20 个以内。工具越多,模型选错的概率越高、schema 占用的 token 越多。超过 20 个应做分组或路由。
并行调用
现代模型支持一次返回多个工具调用(parallel tool calls)。这对「互不依赖」的查询收益巨大:
tool_calls = [ # 模型一次返回两个调用
{"id": "call_1", "function": {"name": "get_order_status",
"arguments": '{"order_id": "AB123456"}'}},
{"id": "call_2", "function": {"name": "get_user_profile",
"arguments": '{"user_id": "u_1021"}'}},
]
import asyncio, json
async def dispatch(tc):
fn = TOOL_REGISTRY[tc["function"]["name"]]
args = json.loads(tc["function"]["arguments"])
return {"role": "tool", "tool_call_id": tc["id"],
"content": json.dumps(await fn(**args), ensure_ascii=False)}
results = await asyncio.gather(*[dispatch(tc) for tc in tool_calls])
并行调用把两个串行往返压成一个,延迟直接减半。但要注意:只有无依赖的调用才能并行。如果第二个调用需要第一个的结果(比如先查订单拿到用户 ID 再查用户),就必须串行。
5. 参数校验与失败重试
即使有严格模式,参数也可能「结构合法但业务非法」:订单号格式对但不存在、金额超过上限、日期在过去。这些必须由应用层校验。
from pydantic import BaseModel, Field, ValidationError, field_validator
class RefundArgs(BaseModel):
order_id: str = Field(pattern=r"^[A-Z]{2}[0-9]{6}$")
amount: float = Field(gt=0, le=100000)
@field_validator("amount")
@classmethod
def check_amount(cls, v: float) -> float:
if v != round(v, 2):
raise ValueError("金额最多两位小数")
return v
def validate_and_repair(raw: str, retry_fn) -> RefundArgs:
"""先校验,失败则把错误信息反馈给模型重试一次"""
try:
return RefundArgs.model_validate_json(raw)
except ValidationError as e:
# 把校验错误作为观察结果反馈,让模型修正
repaired = retry_fn(
f"上一次输出校验失败:{e.errors()}。请修正后重新输出,只输出 JSON。"
)
return RefundArgs.model_validate_json(repaired)
重试的关键是把校验错误反馈给模型,而不是简单地重试同一个提示。模型看到「amount 必须是正数」的具体错误后,修正成功率远高于盲目重试。
重试要有上限(通常 1 到 2 次)。超过上限仍失败,应该降级:要么返回一个安全的默认值,要么转人工,要么抛出明确的业务错误。绝不能无限重试——那会在模型持续犯错时烧光预算。
| 失败类型 | 处理策略 | 重试次数 |
|---|---|---|
| 语法错误 | 约束解码已消除 | 0 |
| 结构缺失 | 反馈错误重试 | 1 |
| 业务规则 | 反馈错误重试 | 1 - 2 |
| 外部依赖失败 | 换工具或降级 | 按依赖重试策略 |
| 持续失败 | 降级 / 转人工 | 上限后停止 |
6. 流式结构化输出
流式输出对用户体验至关重要(首 token 时间),但结构化输出要求「完整合法」,两者存在张力:流式吐出的是不完整的 JSON 片段。
解决办法是增量解析:用一个能处理不完整 JSON 的解析器,每收到一段就尝试解析出已完整的字段。
import json
def incremental_parse(buffer: str) -> dict:
"""尝试解析可能不完整的 JSON,返回已完整的字段"""
# 逐步补全括号,尝试解析
for end in range(len(buffer), 0, -1):
candidate = buffer[:end].rstrip().rstrip(",")
opens = candidate.count("{") - candidate.count("}")
closes = "}" * max(opens, 0)
try:
return json.loads(candidate + closes)
except json.JSONDecodeError:
continue
return {}
partial = "" # 累积已收到的 JSON 片段
for chunk in stream:
partial += chunk
parsed = incremental_parse(partial)
if "summary" in parsed:
yield_frontend("summary", parsed["summary"]) # 字段一完整就展示
流式结构化输出的三个设计要点:
- 字段顺序即展示顺序。把用户最想先看到的字段(如摘要、标题)放在 schema 前面,模型会先生成它,前端能更早展示。
- 不要在流式过程中做最终校验。校验只在流结束后做,中途校验会因为「字段还没生成完」而误判。
- 前端要能处理「字段逐步出现」。UI 应该支持部分渲染,而不是等全部字段就绪。
一个常见的反模式是在流式输出上直接套严格模式并期待「逐 token 都合法」——严格模式保证的是最终结果的合法性,不是每个中间状态。
7. 多轮工具调用循环
工具调用不是一次性的,而是一个循环:模型请求调用工具 → 应用执行 → 结果回填 → 模型决定下一步。这个循环就是 Agent 的最小骨架,其工具调用机制与 Agent 工具调用 是同一套底层能力。
def run_tool_loop(messages: list, tools: list, max_turns: int = 8) -> str:
for turn in range(max_turns):
resp = client.chat.completions.create(
model="gpt-4o", messages=messages, tools=tools,
tool_choice="auto", temperature=0.2,
)
msg = resp.choices[0].message
messages.append(msg)
if not msg.tool_calls: # 没有工具调用,说明给出了答案
return msg.content
for tc in msg.tool_calls:
result = execute_tool(tc) # 可能失败,失败也要回填
messages.append({
"role": "tool", "tool_call_id": tc.id,
"content": json.dumps(result, ensure_ascii=False),
})
raise RuntimeError(f"工具循环超过 {max_turns} 轮,强制终止")
循环设计的三个纪律:
- 必须有轮次上限。没有上限的循环迟早会失控。
- 工具失败要作为结果回填,而不是抛异常。模型需要知道「这个工具失败了」,才能换策略。把失败包装成
{"error": "..."}返回给模型。 - 每轮都要重新评估上下文长度。多轮循环会让消息列表快速膨胀,超长会导致成本飙升或直接报错。必要时裁剪历史或对早期工具结果做摘要。
| 循环轮次 | 健康度 | 处置 |
|---|---|---|
| 1 - 3 | 正常 | 无需干预 |
| 4 - 6 | 偏多 | 检查工具描述是否清晰 |
| 7 - 8 | 异常 | 接近上限,排查循环倾向 |
| 超上限 | 失败 | 强制终止,转降级或人工 |
8. 与业务系统集成的幂等设计
这是最容易被忽视、后果却最严重的一环:多轮循环意味着同一个工具可能被调用多次。如果工具是「创建订单」「扣款」这类写操作,重复调用就是重复下单、重复扣款。
防御手段有三层:
幂等键
每次写操作带一个由模型提供的或应用生成的幂等键,服务端用这个键去重:
def create_order(idempotency_key: str, payload: dict) -> dict:
"""幂等创建订单:同一 key 重复调用返回首次结果"""
existing = store.get(f"idem:{idempotency_key}")
if existing:
return existing # 已处理过,直接返回
order = do_create_order(payload)
store.set(f"idem:{idempotency_key}", order, ttl=86400)
return order
幂等键的生成要稳定:同一次用户意图必须产生同一个键。常用做法是把 (会话 ID, 轮次, 工具名, 参数哈希) 拼起来做哈希。
确认机制
对不可逆操作(付款、删除、发信),在真正执行前插入确认:
模型:请求调用 create_refund(order_id="AB123456", amount=199)
应用:校验通过 → 返回 {"status": "pending_confirmation",
"confirm_token": "ct_9f3a", "summary": "退款 199 元"}
模型:向用户说明并请求确认
用户:确认
应用:带 confirm_token 执行,服务端校验 token 未被使用过
这套机制把「模型自主执行写操作」变成「模型提议、人确认、系统执行」,从根上避免模型被诱导执行危险操作。
参数再校验
写操作的参数必须在服务端做权限与业务校验,不信任模型给的任何值(包括模型「声称」的用户 ID)。模型的输出是提议,不是授权。
9. 性能与成本
结构化输出与函数调用带来的额外开销需要量化:
| 环节 | 延迟增量 | 说明 |
|---|---|---|
| 约束解码 | 5% - 20% 生成时间 | 每步计算合法 token 掩码 |
| schema 占用输入 token | 每个工具 100 - 300 token | 20 个工具约 3000 - 6000 token |
| 参数校验 | < 1 ms | 本地 pydantic 校验 |
| 失败重试 | 一次完整调用 | 重试率越低越好 |
成本优化的三个方向:
- 压缩 schema。精简 description,去掉用不到的字段,能省下可观的输入 token。工具多的场景,用意图路由只传相关工具(见编排篇的工具路由)。
- 把约束解码放在自建推理侧。vLLM 的 guided decoding 用 XGrammar 后端,性能损失比 Python 层的 outlines 小。
- 降低重试率。重试率每降 1 个百分点,成本与延迟都受益。降低重试的手段是更清晰的 schema 与更具体的错误反馈。
重试率的经济账
重试率看似只是「偶尔多调一次」,但在规模下影响可观。假设日调用 100 万次、单次成本 0.004 美元、重试需重发完整上下文(成本按 1.3 倍算):
| 重试率 | 日重试次数 | 日额外成本 | 年额外成本 | P99 延迟影响 |
|---|---|---|---|---|
| 0.5% | 5,000 | 26 美元 | 9,490 美元 | 可忽略 |
| 2% | 20,000 | 104 美元 | 37,960 美元 | 轻微 |
| 5% | 50,000 | 260 美元 | 94,900 美元 | 明显 |
| 15% | 150,000 | 780 美元 | 284,700 美元 | 严重 |
这张表说明:把重试率从 5% 压到 0.5%,一年省下约 8.5 万美元,且不需要任何架构改动,只需要更严的 schema 与更好的错误反馈。这是结构化输出里投入产出比最高的一项优化。
10. 生产落地清单
- 所有面向系统的输出都用 schema 定义,不用自然语言约定。
- 能开严格模式就开,不能开则用约束解码库。
- schema 里加
additionalProperties: false与required。 - 应用层用 pydantic 做二次校验,覆盖 schema 表达不了的业务规则。
- 校验失败把错误反馈给模型重试,上限 1 到 2 次。
- 工具描述写清楚「做什么、何时用、不做什么」。
- 工具数量超 20 个做分组或路由。
- 多轮循环设轮次上限,工具失败包装成结果回填。
- 写操作必须幂等,不可逆操作加确认。
- 流式输出用增量解析,字段顺序即展示顺序。
权衡取舍
| 决策点 | 严格侧 | 宽松侧 | 判断依据 |
|---|---|---|---|
| 约束强度 | 严格模式 / FSM | 提示 + 校验 | 下游能否容忍失败 |
| schema 复杂度 | 只约束必需字段 | 穷举所有规则 | 生成速度 vs 可靠性 |
| 工具数量 | 分组路由 | 全量暴露 | 模型选择准确率 |
| 重试策略 | 反馈错误重试 | 直接失败降级 | 延迟预算与失败代价 |
| 写操作 | 确认 + 幂等 | 直接执行 | 操作是否可逆 |
| 流式 | 增量解析 | 等完整再返回 | 体验 vs 实现复杂度 |
原则:约束解码负责「绝大多数直接正确」,校验重试负责「兜住剩下的」,幂等与确认负责「失败也不造成损害」。三层缺一不可。
常见坑清单
- 只在提示里写「请输出 JSON」:成功率停在 80% 到 90%。必须用严格模式或约束解码。
- 开了严格模式就不做业务校验:严格模式保证结构合法,不保证语义正确。业务规则仍要应用层校验。
- 重试不带错误信息:盲目重试同一提示,模型大概率重复犯错。要把校验错误反馈给模型。
- 无限重试:模型持续失败时烧光预算。重试必须有上限并降级。
- 工具描述含糊:模型不知道何时该用,误调用或漏调用。description 要写清用途与边界。
- 工具过多不分组:20 个以上工具让模型选择准确率骤降,schema 还占用大量 token。做意图路由。
- 并行调用有依赖的工具:第二个调用需要第一个的结果,却并行执行导致参数缺失。有依赖必须串行。
- 工具失败直接抛异常:中断整个循环,模型无法换策略。失败要包装成结果回填。
- 多轮循环无上限:模型反复调用直到失控。必须设轮次上限。
- 写操作不幂等:多轮循环导致重复下单、重复扣款。写操作必须带幂等键,不可逆操作加确认。
- 流式输出中途做最终校验:字段还没生成完就判失败。校验只在流结束后做。
- schema 塞满可选字段:严格模式不支持可选字段,写错导致接口报错。用 nullable 表达可选。
小结
结构化输出的可靠性来自三层协作:约束解码在生成时屏蔽非法 token,把结构错误从概率上消除;应用层校验与带错误反馈的重试兜住语义与业务规则;幂等与确认机制保证即使前面都失败也不会造成损害。三层各司其职,任何一层的缺失都会让整体可靠性塌陷。
工程上最重要的判断是「约束的边界」:schema 只约束下游真正需要的字段,约束越紧越可靠但越慢、越可能损害语义质量。工具定义则是另一种约束——description 写得好,模型调用得准;工具数量控制得住,模型选错得少。
最后回到第一性原理:结构化输出的价值不是「输出好看」,而是让 LLM 成为可编程组件,进入类型系统、单元测试与自动化流水线。一旦做到这一点,LLM 应用才真正从「演示」变成「系统」。而当输出需要被可靠消费时,网关层的统一 schema 转换与多模型兼容(见 模型网关与多模型路由 )能省掉大量适配工作。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。