LLM 结构化输出实战:JSON Mode、Function Calling 与约束解码(2026 版)
LLM 输出"不听话"——JSON 多一行解释、字段名拼错、该调工具时瞎编参数,是 AI 项目上线前最常撞的三堵墙。本文给一套直接落地的结构化输出方案:三种约束手段(JSON Mode / Function Calling / 约束解码)的精度-兼容性对比表、Schema 设计的 6 条铁律(扁平化、枚举优于开放字段、示例驱动)、解析与校验的防御链(pydantic 校验 + 自动重试 + 降级路径)、Agent 工具调用的防幻觉参数(schema 约束 + 参数白名单 + 调用日志审计)、多语言输出与嵌套结构的高频坑,最后给上线前结构化输出自检表。【LLM 工程实践】
LLM 不”听话”的三堵墙,都出在结构化输出上
AI 项目演示时惊艳、上线时翻车,十有八九死在同一处:模型输出”不听话”。客服工单系统要求模型输出 JSON 写回数据库,结果 3% 的请求多了一行”好的,以下是工单信息:“;Agent 调工具时把不存在的 order_id 编出来传过去,工具报错、流程卡死;内容管线让模型输出分类标签,一会儿”数码”、一会儿”数码产品”、一会儿”3C 数码”,下游报表全是脏数据。
结构化输出不是”提示词里说一句请输出 JSON”就完事的,它是一套工程问题:约束手段选型 → Schema 设计 → 解析校验 → 失败兜底 → 监控。 这篇文章给一套交付验证过的完整方案。
一、三种约束手段:精度、兼容性怎么选
1.1 约束强度对比
| 手段 | 保证什么 | 不保证什么 | 兼容性 | 适用 |
|---|---|---|---|---|
| 纯提示词(“请输出 JSON”) | 什么都不保证 | 合法性、schema 全不保 | 全兼容 | 已淘汰,仅作 fallback |
JSON Mode(response_format) | 输出是合法 JSON | 字段名/类型/枚举 | 大部分云端 API | 老模型兜底 |
| Structured Outputs / Function Calling | 输出 100% 符合 JSON Schema | 参数的”事实正确性” | OpenAI/Anthropic/Qwen/DeepSeek 等主流 API | 99% 场景默认选择 |
| 约束解码(guided decoding) | 逐 token 强制合规 | 同上 | 私有化 vLLM/Ollama/llama.cpp | 本地部署、离线环境 |
三条选型结论:
- 云端 API 一律用 structured outputs(OpenAI 叫 structured outputs、Anthropic 叫 tool use、Qwen/DeepSeek 叫 response_format 的 json_schema 变体)——这是 2026 年的基线能力,不用白不用。
- 私有化部署(vLLM 等)用约束解码——vLLM 原生支持
guided_json/guided_choice(基于 outlines 的 grammar 约束),Ollama 支持format: json(只保证合法 JSON)。私有化环境下模型看不到”结构化输出开关”,约束必须下沉到解码层。 - 小模型(7B 以下)降级处理——小模型对长 schema 遵从差,schema 必须极简(见下文铁律),且必须配解析重试兜底。
1.2 一个关键认知:约束的是”形状”,不是”事实”
Schema 能保证字段名拼对、类型对、枚举值在集合内,但保证不了参数在业务上真实存在。模型完全可能输出格式完美、语义捏造的 order_id: "ORD-2026-9981"。这不是约束手段的缺陷,是 LLM 的本质——它预测下一个 token,不查你的数据库。
所以结构化输出的完整定义是:约束(解码层保证形状)+ 校验(执行层保证语义),两层缺一不可。
二、Schema 设计六条铁律
2.1 六条铁律
| # | 铁律 | 反例 | 正例 |
|---|---|---|---|
| 1 | 嵌套 ≤2 层,每层字段 ≤8 个 | order.items[].attributes[].value | 扁平数组 + 外键(item_id 关联 attributes 表) |
| 2 | 枚举优于开放字段 | status: "已完成"(自由文本) | status: ["pending","paid","refunded"](enum) |
| 3 | 字段名自解释 + description | code(谁知道什么 code) | refund_reason + description 说明取值含义 |
| 4 | 提示词给 1-2 个完整示例 | 只给 schema 定义 | schema + few-shot 完整输出示例 |
| 5 | required 最小化 | 12 个字段全 required | 核心 3 个 required,其余 optional + 默认值 |
| 6 | 禁止二维数组 | tags: [["A","B"],["C"]] | tags: ["A","B","C"],分组用外键 |
2.2 一个生产级 Schema 示例
客服工单分类(真实交付项目的简化版):
{
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["billing", "technical", "account", "other"],
"description": "工单主分类,按用户诉求归类"
},
"urgency": {
"type": "string",
"enum": ["low", "medium", "high"],
"description": "high=影响资金或核心功能;medium=功能受损但有 workaround;low=咨询类"
},
"summary": { "type": "string", "maxLength": 100, "description": "一句话摘要,中文" },
"action": {
"type": "string",
"enum": ["auto_reply", "human_agent", "create_ticket"],
"description": "建议处理路径"
}
},
"required": ["category", "urgency", "action"],
"additionalProperties": false
}
注意三个细节:additionalProperties: false(禁止模型自由发挥加字段,structured outputs 下它才真正生效)、urgency 的 description 给了可执行的判定标准(不是”紧急程度”这种废话)、summary 是唯一开放文本字段且限了长。
三、解析与校验:四步防御链
3.1 防御链
模型输出 → ① pydantic 校验 → ② 失败:错误回喂重试(≤2 次)→ ③ 仍失败:业务降级
↓ 通过
④ 业务执行(工具调用前再校验参数真实性)
① 校验要完整:只 json.loads 等于没校验。pydantic(Python)/ zod(TS)把 schema 编译成校验器,字段缺失、类型错、枚举越界全部拦住。
② 重试要带反馈:裸重试(同样的 prompt 再发一次)成功率只有 ~60%。把校验错误信息回喂——“你上次的输出缺少 required 字段 urgency,请重新输出”——重试成功率提升到 95%+。重试上限 2 次,第 3 次基本是必败,不如降级。
③ 降级要预设:重试仍失败时,每个场景提前定好降级路径:
| 场景 | 降级路径 |
|---|---|
| 工单分类 | 默认 other + human_agent(转人工,宁可慢不可错) |
| 数据抽取 | 跳过该字段,标 null,人工补录队列 |
| Agent 工具参数 | 该步失败,回退上一步重新规划 |
| 内容生成 | 转人工模板 |
④ 截断要预防:max_tokens 按”schema 最大可能长度 ×1.5”估算。被 max_tokens 截断的 JSON 是不可修复的(缺的 token 没法补),只能整次重试——所以预算要给足,别为省 token 卡 max_tokens。
3.2 监控
解析失败率进监控大盘,三条规则:
- 失败率 >5%(基线通常 <1%)——提示词/模型/schemax 任一变更后的回归信号。
- 失败率突增 3 倍——头号原因:模型版本静默升级(供应商侧灰度),次因:提示词变更。
- 单场景失败率 >10%——该场景 schema 或 few-shot 需要返工。
四、Agent 工具调用:防幻觉参数的三层防御
4.1 三层防御
| 层 | 机制 | 挡什么 |
|---|---|---|
| 1. Schema 层 | 工具参数定义 JSON Schema + description | 类型错、缺参、枚举越界 |
| 2. 校验层 | 工具执行前查数据层验证参数有效性 | 幻觉参数(格式对但不存在) |
| 3. 粒度层 | 大工具拆小工具,参数 ≤4 个 | 模型”凑参数”行为(参数越多编造越多) |
第 2 层是关键,示例(工单系统的查单工具):
def get_order(order_id: str) -> dict:
order = db.orders.find(order_id)
if not order:
# 不抛异常——把"不存在"回喂给模型,让它换参数重试
return {
"error": f"order_id '{order_id}' 不存在",
"hint": "请从对话上下文中提取用户提供的订单号,或调用 search_orders 查询",
"suggestions": db.orders.recent(user_id, limit=3) # 给出候选
}
return order.data
工具”报错”的设计原则:错误信息是给模型看的,要包含”为什么错 + 下一步怎么办 + 候选值”,而不是给人看的堆栈。
4.2 工具描述比 schema 更重要
模型决定”调不调、传什么”,主要读工具 description。两行有效 description 的写法:
当用户询问订单状态或要求退款时使用(不要用于咨询类问题)。
参数 order_id:从用户消息中提取的订单号,格式 ORD-YYYY-NNNN;
用户未提供时先调用 search_orders,不要猜测。
“什么情况下调”(触发条件)+ “参数从哪来”(参数来源)+ “缺了怎么办”(兜底动作)——三句话齐了,误调率能降一个数量级。
五、高频坑:多语言与长文本
5.1 多语言混排
让 JSON 的”结构”说英文、“内容”说中文:
- 字段名、枚举值:固定英文(
status: "paid"),下游代码按英文匹配; - 内容字段(summary、reply 等):schema description 里写明”此字段输出中文”;
- 反例:不约束时模型输出
status: "已完成",数据库存了两种表示,统计报表全脏。
5.2 长文本结构化:分次调用
生成整篇报告塞一个 output 字段 = 必然截断 + 失败重试烧钱。正确姿势是骨架-填充两段式:
第 1 次调用:输出骨架
{ "sections": [ {"id": 1, "title": "...", "outline": "本章要点"}, ... ] }
第 2..N 次调用:逐章填充(可并行)
输入 = 全文要求 + 骨架 + 本章 outline
输出 = 本章 Markdown
组装:程序侧拼接(骨架里的 id 作为锚点)
额外收益:单章失败只重跑该章;章节间可并行调用,总耗时反而更短;骨架先审——人可以在填充前改章节结构,不用等全文生成完才发现方向错了。
六、上线前自检表
| 检查项 | 通过标准 |
|---|---|
| 约束手段 | 云端 API 用 structured outputs / 私有化用 guided decoding,不依赖纯提示词 |
| Schema 扁平度 | 嵌套 ≤2 层、每层 ≤8 字段、无二维数组 |
| 枚举覆盖 | 所有”有限取值”字段都是 enum,description 带可执行判定标准 |
| additionalProperties | 设为 false(structured outputs 下才真正生效) |
| Few-shot 示例 | 提示词含 1-2 个完整输出示例,且与 schema 完全一致 |
| max_tokens 预算 | 按 schema 最大长度 ×1.5 设定,有截断率监控 |
| 校验器 | pydantic/zod 完整 schema 校验,不是 json.loads |
| 重试机制 | 失败回喂错误信息重试 ≤2 次,重试成功率有埋点 |
| 降级路径 | 每个场景预设降级(转人工/跳过/重规划),无无限重试 |
| 工具参数校验 | 执行前查数据层验证参数存在性,错误信息含”原因+下一步+候选” |
| 工具粒度 | 单工具参数 ≤4 个,描述含触发条件+参数来源+兜底动作 |
| 监控告警 | 解析失败率大盘 + 三条规则(>5% / 突增 3× / 单场景 >10%) |
结构化输出是 LLM 应用的”数据契约”
延迟、成本、质量是 LLM 应用的三大工程指标,但结构化输出是它们的前提——输出解析不了,成本算不出来(不知道这次调用算成功还是失败)、质量评不了(没法自动打分)、Agent 跑不动(工具全在等合法参数)。
我们交付的 AI 客服、数据抽取、Agent 工作流项目,结构化输出的解析成功率都稳定在 99% 以上,靠的不是”挑了一个更听话的模型”,而是这套防御链:约束选型(structured outputs / guided decoding)→ 六条 Schema 铁律 → 四步解析防御 → 工具三层防幻觉。如果你正在做 AI 项目且被”输出不听话”卡住,欢迎带着你的 schema 和失败样本来聊——先帮你做一次结构化输出体检(失败归因 + 防御链缺口定位),再谈实施。
延伸阅读:
- LLM 成本管理实战 — 解析重试与降级路径的成本账
- AI Agent 工作流编排实战 — 工具调用在 Agent 编排中的位置
- LLM 模型选型与路由实战 — 不同模型的 structured outputs 能力差异
- AI 客服落地实战 — 工单分类 Schema 的完整生产案例
- LLM 应用评测实战 — 结构化输出后的自动化质量评测
需要结构化输出方案、Agent 工具链或解析成功率优化?联系我们 获取免费评估。
常见问题
JSON Mode、Function Calling、约束解码,三个有什么区别?怎么选?
约束强度递增、兼容性递减:① JSON Mode(response_format: json_object)——模型保证输出"合法 JSON",但不保证符合你的 schema,字段名拼错、多字段、漏字段都可能出现;② Function Calling / structured output(OpenAI structured_outputs、Anthropic tool use)——输出受 JSON Schema 约束,字段名/类型/枚举值强制符合,主流 API 均支持,99% 场景的默认选择;③ 约束解码(outlines / guidance / lm-format-enforcer 等,推理引擎层做 grammar 约束)——逐 token 屏蔽不合法 token,保证 100% 合规,用于私有化部署 vLLM/Ollama 等本地引擎(OpenAI 兼容但 structured outputs 不可用)的场景。选择路径:云端 API → 直接 structured outputs;私有化 vLLM → 约束解码(vLLM 已原生支持 guided decoding);老模型/小模型(7B 以下)→ 提示词 + JSON Mode + 解析重试兜底。
用了 Function Calling 为什么模型还会编造参数?
Schema 约束的是"形状",不是"事实"。模型会填一个类型合法但语义捏造的参数(比如 order_id 格式对但库里不存在)——schema 挡不住幻觉,只能挡格式错误。防御分两层:① 参数白名单校验——工具执行前,先查数据层验证参数有效性(order_id 必须存在),无效直接拒绝并回喂模型"该参数不存在,可用列表为…"让它重新生成;② 工具粒度收敛——一个大工具拆成小工具(不要 get_record(id, field) 这种万能工具,拆成 get_order(id) / get_customer(id)),参数少模型才不容易编。另外注意:工具描述写清"什么情况下调"和"参数从哪来",比 schema 本身更能减少误调。
解析 LLM 返回的 JSON 失败率大概多少?怎么工程化兜底?
主流模型 + structured outputs 的解析失败率 <0.5%(基本只剩超长截断);纯提示词 + JSON Mode 约 2-8%(小模型 15%+)。防御链四步:① 校验——pydantic/zod 校验完整 schema,别只 json.loads;② 自动重试——校验失败时把"错误信息 + 原输入"回喂模型重新生成,最多 2 次,重试时提示词里明确"上次输出缺 X 字段/类型错";③ 截断处理——max_tokens 给足(按 schema 最大长度 ×1.5 估算),截断的 JSON 不可救,只能重试;④ 降级路径——重试仍失败时,业务降级(跳过该环节/转人工/返回默认值),不能无限重试烧钱。监控:解析失败率进大盘,突增 3 倍告警(通常是模型版本升级或提示词变更引起)。
Schema 怎么设计才能让模型稳定输出?
六条铁律:① 扁平化——嵌套不超过 2 层,每层字段不超过 8 个(深层嵌套是小模型翻车重灾区);② 枚举优于开放字段——能用 enum 就不用 string("状态"给 ["pending","paid","refunded"],不给自由文本);③ 字段名自解释——用业务语义命名(refund_reason 而不是 code),加 description 字段说明每个字段含义;④ 示例驱动——提示词里给 1-2 个完整输出示例(few-shot),比纯 schema 描述有效得多;⑤ 必填最小化——只有业务必须的核心字段 required,其余 optional + 默认值;⑥ 禁止数组套数组——嵌套数组是结构化输出的可靠性悬崖,能用扁平数组 + 外键关联就不要二维数组。
多语言输出和长文本结构化(比如生成整篇报告的结构)怎么解决?
两个高频坑:① 多语言字段混排——让模型输出 JSON 时,枚举值和字段名固定用英文,只有"内容型"字段允许中文(schema 里 description 写清"此字段输出中文,其余字段值用英文"),否则下游匹配枚举时"已完成"和 "completed" 对不上;② 长文本截断——整篇报告塞一个 output 字段必然超 max_tokens,正确做法是分两次调用:第一次输出结构化骨架(章节数组 + 每章摘要),第二次按章节逐个填充(每章一次调用,输入骨架 + 该章要求),最后程序侧组装。分次调用的额外收益:单章失败只重跑该章,不用整篇重来。