2737 字
约 9 分钟
22
S20-X14 · 结构化输出与函数调用

把模型输出直接 json.loads 是这条链路上最常见的崩溃点。上游只要多输出一句"好的,以下是结果:",解析就整体失败;而失败发生在业务代码里,日志里只有一句 JSONDecodeError,看不出模型到底给了什么。

结构化输出要解决的不是"让模型输出 JSON",而是在不可靠的生成之上,构造一条可靠的解析与校验流水线。


1. 为什么需要结构化输出

下游动作 需要什么 自由文本的问题
入库 固定字段、类型 字段名漂移,类型不确定
路由/分支 枚举值 "大概属于退款类"无法 switch
调接口 参数齐全、类型正确 缺字段只能靠正则捞
展示 稳定 schema 结构随生成变化,前端无法渲染
审计 可校验、可比对 无法做 schema diff

判据:下游是不是代码。只要下游是代码而不是人,就必须有 schema。


2. 三条实现路线

路线 做法 可靠性 成本 适用
提示词约束 + 解析 在 prompt 里写"只输出 JSON" 低 最低 原型、内部工具
JSON mode 请求参数强制合法 JSON 中(合法≠符合 schema) 低 简单扁平结构
工具/函数调用 + 严格 schema 由 schema 约束生成 高 中 生产、复杂嵌套

JSON mode 只保证"语法合法",不保证字段对得上;工具调用才把 schema 变成生成约束。两者差别在字段级,不在语法级。

# 提示词约束路线:必须自己兜底,模型随时可能加前后缀
PROMPT = """只输出 JSON,不要任何解释文字。字段:intent(枚举: refund|logistics|other), order_id(string)"""

3. schema 设计原则

原则 反例 正例
字段少而明确 15 个字段一次填 拆成两次调用
枚举优于自由文本 category: string enum: ["退款","物流","其他"]
必填只留真必需 所有字段 required 只 required 关键字段
嵌套层数受控 4 层嵌套对象 ≤2 层,深层拆引用
命名无歧义 date(哪个日期?) refund_apply_date
{
  "type": "object",
  "properties": {
    "intent": {"type": "string", "enum": ["refund", "logistics", "other"]},
    "order_id": {"type": "string", "pattern": "^SO[0-9]{8,}$"},
    "urgency": {"type": "string", "enum": ["low", "normal", "high"], "default": "normal"}
  },
  "required": ["intent"],
  "additionalProperties": false
}

additionalProperties: false 很关键:不写它,模型会热情地补充它认为有用的字段,你的反序列化器要么报错要么静默丢弃。

嵌套是最容易低估的成本。层数一多,模型漏填深层字段的概率显著上升;把深层对象改成"id 引用 + 第二次调用填充",可靠性通常更高。


4. 解析失败的四种原因与容错顺序

原因 表现 处理
多余文本 Here is the JSON: {...} 剥离前后缀,提取首个 { 到末个 }
语法错误 尾逗号、单引号、未闭合 尝试修复,失败则重试
字段缺失 必填字段不存在 回灌缺失字段名重生成
类型不符 "3" 而非 3 宽松转换,转换失败再重试
import json, re

def parse_robust(text: str):
    # 1) 剥离:取第一个 { 到最后一个 },容忍 markdown 围栏
    s = re.sub(r"^```(?:json)?|```$", "", text.strip(), flags=re.M).strip()
    i, j = s.find("{"), s.rfind("}")
    if i >= 0 and j > i:
        s = s[i:j + 1]
    # 2) 解析
    try:
        return json.loads(s), None
    except json.JSONDecodeError as e:
        return None, {"stage": "syntax", "error": str(e), "raw": text[:500]}
    # 3) 修复/重试/降级由上层编排

容错顺序固定为:剥离 → 解析 → 修复 → 带错误信息重试 → 降级。顺序颠倒(先重试再剥离)会白烧一次调用,因为模型多半还是会加那句话。


5. 校验的两层与回灌

层 校验什么 失败后
语法层 JSON 合法、schema 匹配、类型正确 修复或重试
语义层 业务规则(金额>0、日期在未来、枚举组合合法) 回灌具体违反项,让模型修正
from pydantic import BaseModel, Field, ValidationError

class Refund(BaseModel):
    order_id: str = Field(pattern=r"^SO[0-9]{8,}$")
    amount: float = Field(gt=0, le=100000)
    reason: str = Field(min_length=4, max_length=200)

def validate_two_layers(raw: dict):
    try:
        obj = Refund(**raw)          # 语法层:类型/范围/正则
    except ValidationError as e:
        return None, {"layer": "schema", "errors": e.errors()}
    if obj.amount > order_total(obj.order_id):
        return None, {"layer": "business", "error": "退款金额超过订单金额"}
    return obj, None

校验必须在解析成功之后、入库之前,这个顺序没有争议。真正容易搞反的是另一处:把 schema 校验放在"调用下游"之后——那时脏数据已经写进库了。

回灌要带"可执行的信息":不要只说"校验失败",要说"amount 必须大于 0,你给了 -5"。模型能修正的前提是知道错在哪一字段、错在什么规则。


6. 函数调用的多轮与并行

user: 帮我查 SO20261109001 的退款状态
assistant(tool_calls): refund_query({"order_id": "SO20261109001"})
tool: {"status": "处理中", "eta_days": 3}
assistant: 你的退款正在处理中,预计 3 天内到账。

四步固定:模型要求调用 → 你执行 → 把结果作为 tool 角色消息回传 → 模型继续。最容易错的是回传格式:结果必须是模型当初给的那个 tool_call_id 对应的消息,ID 对不上,模型会当成新信息重新规划。

# 并行调用:一次返回多个 tool_calls,逐个执行后全部回传
for call in resp.tool_calls:
    result = dispatch(call.function.name, json.loads(call.function.arguments))
    messages.append({"role": "tool", "tool_call_id": call.id,
                     "content": json.dumps(result, ensure_ascii=False)})

并行调用要注意两点:有依赖的调用不能并行(第二个的参数依赖第一个的结果);回传顺序不必与调用顺序一致,但 ID 必须一一对应。


7. 流式输出下的结构化解析

流式返回的是增量 token,JSON 在传输过程中始终是不完整的。直接 json.loads 半截字符串必然抛异常。

方案 做法 代价
全缓冲 收齐再解析 失去流式体验
增量解析 边收边尝试补全括号 实现复杂,需容错
分块协议 每块是一个完整小 JSON(NDJSON) 需约定协议
双通道 文本流给人看,结构化结果最后单独给 需两次输出或工具调用
buf = ""
for chunk in stream:
    buf += chunk
    # 只在括号配平后才尝试解析,避免对半截字符串报错
    if buf.count("{") == buf.count("}") and buf.strip().endswith("}"):
        try:
            obj = json.loads(buf)
            yield obj          # 完整对象,可用于增量渲染
            buf = ""
        except json.JSONDecodeError:
            continue           # 配平但语法仍错,等更多数据或走修复

对 UI 更友好的做法是 NDJSON:模型每行输出一个完整对象,前端逐行解析。代价是模型得遵守"一行一个 JSON",可靠性不如工具调用。


8. 版本与向后兼容

变更 兼容性 做法
加可选字段 兼容 直接加,消费端忽略未知字段
加必填字段 不兼容 先加可选并给默认值,再收紧
改字段名 不兼容 新旧双写一段时间
改字段类型 不兼容 新增字段,旧字段只读
改枚举值 视情况 加值兼容,删值不兼容
class RefundV2(BaseModel):
    model_config = {"extra": "ignore"}   # 消费端忽略未知字段,是兼容的前提
    order_id: str
    amount: float
    currency: str = "CNY"                # 新增带默认值 = 兼容

兼容的主动权在消费端:只有消费端忽略未知字段、对缺失字段有默认值,生产端才敢加字段。反过来,生产端单方面加必填字段,等于让所有旧消费端同时崩。


9. 常见坑

坑 后果 改法
schema 太复杂 模型漏填深层字段 拆调用、减嵌套、缩字段数
校验放在解析之后、入库之后 脏数据已落库 校验前移到入库前
重试不带错误信息 模型重复同样的错 回灌字段名 + 违反的规则
流式里直接 json.loads 半截字符串必崩 配平检测或 NDJSON
忽略 tool_call_id 模型把结果当新输入 严格按 ID 回传
靠正则抽字段 格式一变全崩 用 schema 约束生成

10. 排错

现象 先查 处理
偶发解析失败 原始响应里有没有前后缀 加剥离层,日志存原文
字段总是缺一个 该字段是否 required 改 required 或给默认值
类型来回错 提示里有没有给类型示例 用 schema 约束,别靠描述
重试三次仍失败 错误信息是否回灌 带上具体字段与规则
流式首包就崩 是否在增量上解析 改配平检测
换模型后格式全乱 新模型是否支持该 tool 协议 降级到 JSON mode + 校验

11. 边界

场景 更合适 理由
只要一段自然语言 不做结构化 增加约束反而降低表达质量
输出极长(数千字段) 分批调用 + 合并 单次生成填不满
需要严格数值计算 让模型出表达式,代码算 模型算术不可靠
极低延迟场景 预定义模板 + 填空 结构化生成有固定开销
一次性离线任务 提示词约束 + 人工抽检 不值得建全套流水线

12. 自测

  1. JSON mode 与工具调用在可靠性上的差别,具体差在哪一层?
  2. 为什么 additionalProperties: false 对反序列化很重要?
  3. 解析失败的容错顺序为什么必须"先剥离再重试"?
  4. 语法层与语义层校验,哪一层该放在入库之前,两层都放行不行?
  5. 流式输出下为什么不能直接对增量做 json.loads?
  6. schema 加字段与改字段名,哪种兼容、哪种不兼容,消费端要配合什么?
S20-X14 · 结构化输出与函数调用
https://www.clxhxhhr.top/posts/5892/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。