结构化输出与函数调用

让模型稳定输出可被程序消费的结构,是把 LLM 接入业务系统的前提。本文讲清 JSON Schema 约束与严格模式、grammar 与有限状态机的约束解码原理、工具定义与并行调用、参数校验与失败重试、流式结构化输出、多轮工具循环,以及写操作的幂等设计,附可运行的工具定义与校验代码和常见坑清单。

把 LLM 接入业务系统的第一步,不是让模型更聪明,而是让它「说话可被程序解析」。一段自然语言回复对人友好,对系统却是灾难:你要写多少正则才能可靠地从中抽出金额、日期、订单号?结构化输出与函数调用(Function Calling)解决的正是这个问题——把模型的输出约束成一个程序能直接消费的结构,或者让模型以「调用函数」的形式表达它的意图。

这件事的工程难点不在「怎么让模型吐 JSON」——提示里加一句「请输出 JSON」就能做到,但成功率可能只有 80%,剩下 20% 是多余的解释文字、尾随逗号、缺字段、或者干脆用中文标点。真正的难点是如何把成功率推到 99.9% 以上,因为下游系统的一次解析失败就是一次线上事故。

达成高可靠有两条路径:一是约束解码(constrained decoding),在模型生成时就把非法 token 屏蔽掉,从概率上保证输出合法;二是应用层校验与重试,把不可避免的失败兜住。成熟的系统两条都用:约束解码负责「绝大多数情况直接正确」,校验重试负责「剩下的兜底」。本文按这个框架展开,并覆盖工具定义、并行调用、流式输出、多轮循环与幂等设计。

目录

  1. 结构化输出解决什么问题
  2. JSON Schema 与严格模式
  3. 约束解码:grammar 与有限状态机
  4. 工具定义与并行调用
  5. 参数校验与失败重试
  6. 流式结构化输出
  7. 多轮工具调用循环
  8. 与业务系统集成的幂等设计
  9. 性能与成本
  10. 生产落地清单
  11. 权衡取舍
  12. 常见坑清单
  13. 小结

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语法编译成 FSMoutlines、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 token20 个工具约 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,00026 美元9,490 美元可忽略
2%20,000104 美元37,960 美元轻微
5%50,000260 美元94,900 美元明显
15%150,000780 美元284,700 美元严重

这张表说明:把重试率从 5% 压到 0.5%,一年省下约 8.5 万美元,且不需要任何架构改动,只需要更严的 schema 与更好的错误反馈。这是结构化输出里投入产出比最高的一项优化。

10. 生产落地清单

  1. 所有面向系统的输出都用 schema 定义,不用自然语言约定。
  2. 能开严格模式就开,不能开则用约束解码库。
  3. schema 里加 additionalProperties: false 与 required。
  4. 应用层用 pydantic 做二次校验,覆盖 schema 表达不了的业务规则。
  5. 校验失败把错误反馈给模型重试,上限 1 到 2 次。
  6. 工具描述写清楚「做什么、何时用、不做什么」。
  7. 工具数量超 20 个做分组或路由。
  8. 多轮循环设轮次上限,工具失败包装成结果回填。
  9. 写操作必须幂等,不可逆操作加确认。
  10. 流式输出用增量解析,字段顺序即展示顺序。

权衡取舍

决策点严格侧宽松侧判断依据
约束强度严格模式 / FSM提示 + 校验下游能否容忍失败
schema 复杂度只约束必需字段穷举所有规则生成速度 vs 可靠性
工具数量分组路由全量暴露模型选择准确率
重试策略反馈错误重试直接失败降级延迟预算与失败代价
写操作确认 + 幂等直接执行操作是否可逆
流式增量解析等完整再返回体验 vs 实现复杂度

原则:约束解码负责「绝大多数直接正确」,校验重试负责「兜住剩下的」,幂等与确认负责「失败也不造成损害」。三层缺一不可。

常见坑清单

  • 只在提示里写「请输出 JSON」:成功率停在 80% 到 90%。必须用严格模式或约束解码。
  • 开了严格模式就不做业务校验:严格模式保证结构合法,不保证语义正确。业务规则仍要应用层校验。
  • 重试不带错误信息:盲目重试同一提示,模型大概率重复犯错。要把校验错误反馈给模型。
  • 无限重试:模型持续失败时烧光预算。重试必须有上限并降级。
  • 工具描述含糊:模型不知道何时该用,误调用或漏调用。description 要写清用途与边界。
  • 工具过多不分组:20 个以上工具让模型选择准确率骤降,schema 还占用大量 token。做意图路由。
  • 并行调用有依赖的工具:第二个调用需要第一个的结果,却并行执行导致参数缺失。有依赖必须串行。
  • 工具失败直接抛异常:中断整个循环,模型无法换策略。失败要包装成结果回填。
  • 多轮循环无上限:模型反复调用直到失控。必须设轮次上限。
  • 写操作不幂等:多轮循环导致重复下单、重复扣款。写操作必须带幂等键,不可逆操作加确认。
  • 流式输出中途做最终校验:字段还没生成完就判失败。校验只在流结束后做。
  • schema 塞满可选字段:严格模式不支持可选字段,写错导致接口报错。用 nullable 表达可选。

小结

结构化输出的可靠性来自三层协作:约束解码在生成时屏蔽非法 token,把结构错误从概率上消除;应用层校验与带错误反馈的重试兜住语义与业务规则;幂等与确认机制保证即使前面都失败也不会造成损害。三层各司其职,任何一层的缺失都会让整体可靠性塌陷。

工程上最重要的判断是「约束的边界」:schema 只约束下游真正需要的字段,约束越紧越可靠但越慢、越可能损害语义质量。工具定义则是另一种约束——description 写得好,模型调用得准;工具数量控制得住,模型选错得少。

最后回到第一性原理:结构化输出的价值不是「输出好看」,而是让 LLM 成为可编程组件,进入类型系统、单元测试与自动化流水线。一旦做到这一点,LLM 应用才真正从「演示」变成「系统」。而当输出需要被可靠消费时,网关层的统一 schema 转换与多模型兼容(见 模型网关与多模型路由 )能省掉大量适配工作。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「LLMOps」更多文章

  1. 语义缓存与 Prompt 缓存
  2. 多智能体编排与工作流引擎
  3. LLM 护栏与提示注入防护