让模型”返回 JSON”只需要一句话,让下游系统稳定消费这份 JSON 却需要一整套契约工程。这篇教程讲清结构化输出的三层约束、Function Calling 的完整调用闭环,以及并行调用与错误处理的落地细节。

它解决什么问题

LLM 的默认输出是自然语言——给人看没问题,给程序用就是灾难。真实工程里,模型产出往往要被反序列化、进规则引擎、写数据库、触发下游调用,这些环节只认契约,不认”大概”:

  • 模型在 JSON 前面加一句”好的,以下是结果”,解析器直接报错。
  • 必填字段被悄悄省略,DTO 反序列化失败,或不完整数据被带进后续链路。
  • 数字字段变成字符串、布尔值变成 "false",类型错位被静默转换掩盖,排查时只能从下游异常回溯。
  • 信息不足时模型不拒答,而是编出一个业务上根本不存在的枚举值。

这类”JSON 漂移”的共同根源是:自然语言提示没有类型系统,也无法强制执行。结构化输出要解决的,就是把”希望模型这么输出”变成”模型只能这么输出,且服务端可校验”。

核心原理

三层约束:语法、契约、生成

讨论结构化输出时,三个概念常被混为一谈,但它们处在不同层级:

  1. JSON Mode:输出模式开关,只保证产出是语法合法的 JSON。字段对不对、枚举值业务认不认,它一概不管。
  2. JSON Schema:结构描述规范。用 properties 声明字段、required 声明必填、enum 圈定取值、additionalProperties 拒绝多余字段。它本身只是一份”合同文本”,不参与生成。
  3. Structured Outputs / 约束解码:供应商在生成阶段应用 Schema 的能力。实现上属于约束解码(Constrained Decoding):每生成一个 Token,解码器只保留”能让最终结果仍然符合 Schema”的候选,非法分支在采样前就被剪掉。约束由此从事后检查前移到了生成过程中。

需要注意:各家对 JSON Schema 关键字的子集支持并不一致,pattern、组合关键字、递归引用等能力落地前要核对目标模型的文档,别把完整规范照搬到请求里。

Function Calling:模型只产出意图

Function Calling 这个名字有误导性——模型从不执行你的函数。它做的是:根据用户意图和工具描述,输出”调用哪个工具、参数是什么”的结构化意图。真正的执行者是业务服务端、Agent Runtime 或托管环境。

一次完整的调用闭环:

  1. 定义:服务端向模型注册工具名、用途描述、参数 Schema。
  2. 选择:用户提问后,模型判断需要调工具,输出工具名加参数——参数本身受 Schema 约束,是结构化输出的特例。
  3. 校验与执行:业务侧先做结构校验(类型、必填、枚举),再做业务校验(订单归属、状态流转)与权限校验,全部通过才触碰真实系统。
  4. 回填:执行结果连同调用 ID 原样回传,模型基于事实组织最终回答。

闭环的关键在权责划分:模型负责”自然语言 → 结构化参数”的语义映射;权限、幂等、副作用,全部留在执行层。

深入机制

一个工具定义示例

{
"name": "query_order",
"description": "查询订单状态。用户询问订单进度、是否发货、物流情况时使用;退款、改地址等写操作不要用此工具。",
"parameters": {
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "订单号,以大写字母 O 开头后跟数字。"
},
"includeLogistics": {
"type": "boolean",
"description": "是否需要物流信息。用户问及发货、配送、签收时为 true。"
}
},
"required": ["orderId", "includeLogistics"],
"additionalProperties": false
}
}

注意 description 写清了”何时用、何时不用”——工具误选最常见的根因不是模型笨,而是描述太模糊。

服务端校验与回填

def handle_tool_call(call, user, schema, order_service):
errors = schema.validate(call.arguments) # 第一层:结构校验
if errors:
return tool_error(call.id, "INVALID_ARGUMENTS", errors)
args = call.arguments
if not order_service.can_read(user.id, args["orderId"]): # 第二层:权限校验
return tool_error(call.id, "FORBIDDEN", "无权查询该订单")
order = order_service.query(args["orderId"], args["includeLogistics"])
return ToolResult(call_id=call.id, content=order.to_dict())

回填时调用 ID 必须严格对应——主流 API 都为每次调用生成唯一标识,并行调用场景下少了它,结果和请求就会错配。

并行调用与错误处理

模型可能在一轮里同时发起多个调用:”查订单 123 的物流,顺便查一下当地天气”。并行调用要处理三件事:

  • 依赖关系:无依赖的调用并行执行压低延迟;有依赖的(先查用户 ID 再查其订单)必须串行,模型通常会分轮发起。
  • 结果配对:每个结果按调用 ID 分别回填,缺一个 ID,整轮对话都会错位。
  • 部分失败:某个工具超时时,回填明确的错误信息而非空结果,让模型向用户解释”当前查不了”,而不是基于空白编答案。

错误处理的通用策略:校验失败时,把具体错误(哪个字段、应该是什么)反馈给模型做 1~2 次定向重试,而不是把原始问题原样重跑;仍失败则降级——进人工队列、走规则兜底、或追问用户补信息。原则是可以降级,但绝不让模型编造业务事实。

工程实践

  • Schema 设计原子化:一个字段只表达一件事,后端才好校验、统计、路由和灰度。
  • 枚举优先于自由文本:能用 enum 就别让模型自由发挥,否则服务端的模糊映射会把模型的不确定性扩散进业务规则。
  • 未知要显式表达:信息不足时用 null 或 NEED_MORE_INFO 状态位,别让模型为了填满 Schema 编值。
  • Schema 带版本号:多方消费同一份输出时加 schemaVersion 字段,字段与枚举变更按接口治理,Prompt、Schema、解析代码共用同一版本边界。
  • 工具分级管控:只读工具宽进;写操作加权限校验与幂等键(由可信执行层生成,而不是接收模型给的键);高危操作(退款、删除)拆成”预备 + 确认”两步;全程留审计日志,记录模型意图、校验结论与实际执行结果。
  • 供应商差异要核实:不同模型对严格模式、必填约束、采样参数的建议并不一致,跨供应商接入时以各家文档为准。

对比与权衡

维度 JSON Mode JSON Schema Structured Outputs(约束解码)
角色 输出格式开关 契约描述规范 生成期约束能力
保证什么 语法合法 什么都不保证(只描述) 结构贴合 Schema
字段完整性 不保证 不参与生成 取决于供应商支持范围
服务端校验 仍需要 仍需要 仍需要

面试视角

为什么”请返回 JSON”不可靠? 自然语言提示没有强制力:模型可能加解释文本、漏字段、错类型、编枚举,复杂上下文里还可能直接放弃格式。生产环境需要生成期约束、服务端校验、失败重试与降级,三层缺一不可。

JSON Mode、JSON Schema、Structured Outputs 的区别? 三者分层:JSON Mode 管语法合法,Schema 描述字段、类型、枚举契约,Structured Outputs 在解码期应用契约。即使用了最强的一层,权限与业务状态仍需服务端兜底。

Function Calling 的完整链路? 注册工具定义 → 模型生成工具名与参数 → 业务侧做结构、业务、权限三层校验 → 执行 → 结果连同调用 ID 回填 → 模型生成最终回答。模型只产出意图,执行权永远在业务侧。

并行工具调用要注意什么? 区分有依赖(串行分轮)与无依赖(并行执行);结果严格按调用 ID 配对回填;单个工具失败要回填错误而非空结果,防止模型基于空白编造。

工具调用的安全边界在哪? 模型生成的参数一律按不可信输入处理:结构校验之外必须做业务与权限校验;写操作配幂等;高危操作二次确认;审计日志回答”模型想做什么、服务端允许了什么、实际执行了什么”。

小结

  1. 结构化输出是工程契约问题:自然语言提示守不住边界,必须靠生成期约束与服务端校验双侧落实。
  2. JSON Mode 管语法、Schema 管契约、约束解码把契约前移进生成过程,三者互补而不互替。
  3. Function Calling 的模型侧只产出调用意图;定义、选择、执行、回填四步闭环中,执行与安全的权责全在业务层。
  4. 并行调用处理好依赖、配对与部分失败三件事;错误处理坚持”可降级、禁编造”。
  5. 结构化输出与工具调用是 Agent 的地基——地基不实,上层再花哨的能力都会变成生产事故。