跳至主要內容

十万个why:为什么让大模型输出 JSON 不难,但是稳定输出JSON这么难?

程序员小富大约 8 分钟

大家好,我是小富~

《十万个why》系列持续更新~

其实让大模型输出一个 JSON 很简单,Prompt 里写一句"请以 JSON 格式输出",大多数模型就能给你吐出来。

但你真正在项目里用过就知道,能输出和稳定输出,完全不是一个难度级别的事

我刚开始做 LLM 相关开发的时候,第一反应就是疯狂改 Prompt,各种花式叮嘱:

严格按照 JSON 输出。
不要输出任何解释。
不要使用 Markdown。

这些写法有没有用?有用。但本质上你只是在引导模型,并不是在约束模型。模型仍然随时可能给你整出花活:

  • 前面加一句"好的,下面是结果:"
  • ```json ``` 包一层 Markdown 代码块
  • 少字段、多字段
  • 字段类型不对,该给数字给了字符串
  • JSON 格式本身就有语法错误,直接解析失败

测试环境跑 50 条数据全部完美,上线后结果生产环境每隔几个小时就蹦一个 JSONParseException

Prompt 可以提高成功率,但永远不能保证稳定性!!!

要控制模型稳定输出,必须多管齐下~

Prompt 把输出结构描述清楚

太多人写 Prompt 的时候太随意了,就丢一句:

返回姓名、年龄、地址。

这种描述模糊得一批,模型看到以后全靠自由发挥。它可能返回 {"姓名": "张三"},也可能返回 {"name": "张三"},你根本无法预期。

更好的做法是直接在 Prompt 里给出目标 JSON 结构,例如:

{
  "name": "",
  "age": 0,
  "address": ""
}

或者用更清晰的字段描述:

name: string,用户姓名
age: integer,用户年龄
address: string,用户地址

模型有了明确的参考模板,输出通常会稳定不少。我一般还会补一句狠话:

只输出纯 JSON 文本,不要输出任何解释文字、Markdown 标记或代码块。

但是说实话,Prompt 写得再严格,该翻车还是会翻车。这就好比你跟同事交代工作,哪怕说了三遍"别加注释",他还是可能顺手写两行注释上去。毕竟大模型的底层是概率生成,不是指令执行。

优先使用模型原生的结构化输出能力

如果项目要投入生产,千万不要只依赖 Prompt

现在主流的大模型基本都提供了结构化输出的能力,常见的有三种:

  • Structured Output(结构化输出)
  • JSON Schema
  • Function Calling(也叫 Tool Calling)

这三种方案的共同点是:不是在语义层面上"劝"模型遵守格式,而是在生成阶段就按照你指定的 Schema 进行硬约束。

举个例子,你定义了这样一个 Schema:

{
  "score": {
    "type": "integer"
  }
}

在 Structured Output 模式下,模型一般不会返回:

{
  "score": "90"
}

因为字段类型在底层就被锁死了,它想输出字符串都输出不了。

Structured Output 和 Function Calling 怎么选?

其实很简单,看你的场景:

如果你只是想让模型返回固定的数据结构,比如从一段文本里提取几个字段,Structured Output 通常是首选。你给它一个 JSON Schema,它严格按照这个 Schema 生成,干净利落。

如果后续还要调用数据库、搜索接口、天气 API 等外部能力,那 Function Calling 更合适。因为它不仅规范了参数格式,还能直接驱动工具调用。模型在 Function Calling 模式下会意识到自己是在"填写参数表",而不是在跟人聊天,输出的规范性会好很多。

以 OpenAI 的接口为例,用 Function Calling 大概长这样:

tools = [{
    "type": "function",
    "function": {
        "name": "extract_user_info",
        "description": "从文本中提取用户信息",
        "parameters": {
            "type": "object",
            "properties": {
                "name": {"type": "string", "description": "用户姓名"},
                "age": {"type": "integer", "description": "用户年龄"},
                "address": {"type": "string", "description": "用户地址"}
            },
            "required": ["name", "age", "address"]
        }
    }
}]

response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "张三,28岁,住在北京市朝阳区"}],
    tools=tools,
    tool_choice={"type": "function", "function": {"name": "extract_user_info"}}
)

后端拿到的直接就是一个标准的函数调用对象,不是一段带有 Markdown 包裹的文本,用 Java 或者 Go 直接反序列化就完事了。

能用模型原生能力约束的,就别靠 Prompt 去求模型。,这条原则在 AI 工程化落地中怎么强调都不为过。

不要让模型一次干太多活

这是一个特别容易被忽略的坑。

很多人写 Prompt 的时候,恨不得把所有事情一股脑塞进去:

阅读下面这篇文章 → 总结核心观点 → 提取关键词 → 判断情绪倾向 → 翻译成英文 → 最后输出 JSON。

任务链越长,模型越容易在中间某个环节跑偏,到最后输出的 JSON 就开始变形。要么丢字段,要么格式错乱,甚至直接变成一段自然语言的"总结报告"。

模型不是流水线工人,它是一个概率生成器。你给它的任务越复杂,它需要同时兼顾的约束就越多,出错概率就越高。

实际开发中,我更倾向于拆成多个步骤

第一步:内容理解
 → 输入原始文章,让模型做摘要和关键词提取,输出自然语言即可

第二步:结构化输出
 → 把第一步的结果喂给模型,让它严格按照 JSON Schema 输出结构化数据

虽然多调用了一次模型,多花了几分钱 Token 费,但整体稳定性通常会显著提高,而且出了问题也更容易定位——是第一步理解错了,还是第二步格式化出了问题,一目了然。

这跟写代码是一个道理:一个函数只干一件事,永远比一个函数干五件事更可靠。

程序一定要做校验和兜底

永远不要假设模型一定会返回合法 JSON!!!

哪怕你已经用了 Structured Output,哪怕你已经用了 Function Calling,程序也必须做好兜底。原因很简单:线上环境什么妖蛾子都可能出——模型版本升级、接口超时返回了半截响应、网络抖动导致 JSON 被截断……

一个成熟的 AI 应用,对模型的输出至少要做以下校验:

import json

def validate_model_output(raw_output: str, required_fields: list) -> dict:
    # 1. JSON 是否能解析
    try:
        data = json.loads(raw_output)
    except json.JSONDecodeError:
        # 尝试修复常见问题:去掉 Markdown 代码块包裹
        cleaned = raw_output.strip()
        if cleaned.startswith("```"):
            cleaned = cleaned.split("\n", 1)[-1].rsplit("```", 1)[0]
        try:
            data = json.loads(cleaned)
        except json.JSONDecodeError:
            raise ValueError("模型输出无法解析为 JSON")

    # 2. 必填字段是否缺失
    for field in required_fields:
        if field not in data:
            raise ValueError(f"缺少必填字段: {field}")

    # 3. 字段类型是否正确(根据业务定义校验)
    # 4. 枚举值是否合法
    # 5. 是否需要填充默认值

    return data

除了校验,还有一个非常重要的机制:自动重试

模型输出有一定的随机性,这次格式不对,重新调一次可能就对了。给关键链路加一个 2~3 次的重试机制,配合指数退避,能覆盖掉绝大多数偶发性的格式异常。

import time

def call_with_retry(func, max_retries=3):
    for attempt in range(max_retries):
        try:
            result = func()
            return validate_model_output(result, ["name", "age"])
        except (ValueError, KeyError) as e:
            if attempt == max_retries - 1:
                raise
            time.sleep(2 ** attempt)  # 指数退避:1s, 2s, 4s

想做一个能跑在线上的 AI 应用,不能假想模型特别可靠,必须保证应用能处理各种异常情况。 这跟你调第三方接口是一个道理。你不会裸调支付宝的接口不加任何异常处理吧?对待模型的输出,也该有同样的防御心态。

说在最后

总结一下,目前比较成熟的一套做法其实就是四层防线:

  1. Prompt 明确描述输出结构,减少模型的理解偏差。

  2. 优先使用 Structured Output 或 Function Calling,在生成层面做硬约束,而不是只靠 Prompt 软约束。

  3. 拆分复杂任务,让模型一次只专注一件事,降低出错概率。

  4. 程序侧做校验和兜底,对解析失败、字段异常等情况做好重试和容错。

一般刚接触大模型开发的人,会把大量时间花在反复修改 Prompt 上,希望把成功率从 90% 提高到 99%。但真做过线上应用就会发现,Prompt 只是第一步

真正决定稳定性的,是模型原生的结构化输出能力,加上程序侧的校验和异常处理。只有这种配合起来,才能把 JSON 输出真正做到生产级别的稳定可靠。

做 AI 应用和做传统后端最大的区别就是:传统代码的输出是确定性的,而模型的输出永远是概率性的。

接受这个现实,用工程手段去兜住概率的不确定性,这是现在 AI 应用开发的现实与常态。

上次编辑于: