LLM输出JSON总失败?从Prompt到Function Calling的可靠性实践
2026/9/5 13:52:56 网站建设 项目流程

我到现在还记得线上第一次被 JSON 解析失败打爆的那天。告警群里贴了一串 traceback,错误位置在 json.loads(),点开原始返回内容一看,模型在 JSON 前面加了一行“好的,这是抽取结果:”,后面还跟了三个反引号。Prompt 里明明写了“只输出 JSON 对象,不要任何解释”,上线前我拿几百条数据跑过,成功率高得惊人,结果一到真实流量里就开始翻车。

后来我把这类问题整理成一句话:模型并不是在“执行格式规范”,它是在“模仿一段看起来合理的文本”。而 Prompt 本质上只是一种软约束,它会把 Json 输出概率推得很高,但永远无法把非法输出变成不可能事件。这也是这篇文章想讲的核心问题:为什么只靠 Prompt 让 LLM 输出 JSON 不可靠?如果你正在做 LLM 应用集成、写数据抽取脚本或者折腾 Agent,建议把这条链路从头看一遍,能帮你少踩不少坑。

1. 先看现象:同一份 Prompt,为什么今天行明天不行

1.1 一份看起来无懈可击的 Prompt,为什么也会翻车

我见过太多人写这样的 Prompt:

“请从用户输入中抽取姓名、年龄、地址,并以 JSON 格式返回。”

表面上看这段指令已经足够明确了。测试时模型也确实会返回类似{"name": "张三", "age": 18}的内容。但一旦换成更难的输入、更长的历史消息,或者换了模型版本,问题就陆续出来了。

最典型的场景是字段值本身比较复杂。比如摘要里包含英文双引号、换行符、反斜杠,又或者某个字段值非常长,模型输出的字符串没有正确转义,就会直接导致整个 JSON 语法非法。再比如输入内容充满歧义时,模型可能会忍不住在 JSON 前加一句“根据上下文判断,该用户不存在”,这也会让解析直接失败。

还有一个被很多人忽略的点:Prompt 越长,模型越容易“忘记”末尾的格式要求。尤其当你把格式要求放在用户消息的最后一段,模型可能因为前面上下文太长,把精力都放在内容推理上,反而忽略了必须输出 JSON 这件事。写 Prompt 像是跟一个能力很强但偶尔走神的实习生沟通,你说得再清楚,他也可能凭习惯自由发挥。

1.2 我见过的高频失败输出形态

下面这张表我几乎每次写结构化输出方案都会摆出来,因为大多数解析失败都能归到这几类里:

失败类别典型表现后果
自然语言包裹前面有“好的,以下是 JSON”,后面有一句总结json.loads 直接异常
Markdown 代码块输出被 ```json 包裹需要二次清洗
宽松语法单引号、尾逗号、不标准的转义不符合 JSON 规范
字段名被改写要求返回 page_content,结果返回 pageContent解析成功,业务却取不到值
类型失控字段要求 int,结果输出字符串“18”或“N/A”下游类型校验失败
内容截断输出被 max_tokens 截断,写到一半就断了JSON 必然不完整
嵌套结构错误该返回数组的地方返回对象,少一层或多一层数据错位

前两类还比较容易被发现,后面几类才是最麻烦的。因为程序不一定报解析错误,而是拿到了一份结构合法、但内容完全不符合预期的 JSON。这类问题用单纯的 json.loads 根本挡不住,必须额外加 JSON Schema 校验才能在入口处拦截。

2. 根因:语言模型本质上是在预测下一个词,不是在编写 JSON

2.1 大模型的底层机制决定了 Prompt 只是“概率性约束”

大部分 LLM 的核心任务是预测下一个 token。给定前面的文本,模型会根据训练得到的知识,计算出词表中每个 token 被选中的概率,然后从中挑一个。换句话说,生成 JSON 的过程并不是在执行一套严格的 JSON 语法规则,而是在做概率采样。

你可以把“返回 JSON”这个要求理解成一个很强的先验信号。模型在训练阶段见过太多“用户要求 JSON,AI 乖乖返回 JSON”的对话,因此当你的 Prompt 里出现 JSON 字样时,后面生成合法 JSON 的概率会变得很高。但概率再高也不是 100%。对于某些表达有歧义、或者内容本身容易触发自然语言回复的场景,模型仍然会选择一条不在格式规则内的路径。

我经常用一个类比来解释:人类写代码时,如果缺少编译器的类型检查,代码里有一百个隐藏的语法问题也可能一直没被发现。大模型写 JSON 时同样没有“编译器”,它对自己输出的正确性没有任何感知。它只是觉得“这样写挺顺的”,于是就这么写出来了。所以只靠 Prompt 无法像编译期约束那样从机制上屏蔽非法输出。

2.2 采样参数也在放大 JSON 输出的不确定性

有人会说,那把 temperature 调成 0 不就等于确定性输出吗?其实不是。即使 temperature 为 0,模型也只是每次都选择概率最高的 token,可这个概率分布本身就不是收敛的。换个输入顺序、加个上下文干扰、甚至模型服务端版本更新,都可能导致截然不同的输出。

如果 temperature 调得比较高,比如接近 1.0,模型就会选择一些概率稍低但更“有创意”的 token,自然语言解释、多余修饰、不闭合的引号等毛病更容易出现。所以做结构化输出时,我几乎都会把 temperature 调到 0.1 或 0,至少在采样层面减少随机性干扰。

但这里要明确一点:低 temperature 只是降低风险,不能根治问题。真正的根治办法是把语法约束放到解码层面去处理,这部分后面会详细讲。简单说,Prompt 负责给模型“指路”,但没法保证它一定不跑偏。

2.3 失败率并不是均匀分布的,单次验证通过说明不了什么

还有一个隐蔽的心理陷阱:你测了 20 次都成功,就以为这套 Prompt 没问题。但失败率可能是 5% 或者 1%,只是恰好没在测试集里命中。一旦接入真实业务,每天处理 10 万次调用,哪怕失败率只有 1%,也会有 1000 次解析异常需要你去面对。

不同输入内容引出的失败概率也完全不同。短文本、简单字段、无特殊字符的输入,模型几乎不会犯语法错误;但遇到包含换行和引号的长文本,字符串转义出错概率会急剧上升。如果你的测试数据全是从简单样本里抽的,那得到的“成功率”没有任何参考价值。想评估一套 Prompt 是否够可靠,至少要准备一批带“脏数据”的真实样例,批量跑几百次才算数。

3. 什么场景能用 Prompt 硬扛,什么场景必须换方案

3.1 适合“Prompt 一把梭”的典型场景

不可否认,Prompt 在很多场景下依然够用。比如内部脚本、临时分析、非关键任务,下游代码本身已经做好了解析异常处理,就算偶尔失败也不会造成什么严重后果。这类场景追求的是开发速度和成本低廉,不愿意为了一个简单的小功能引入复杂链路。

具体来说,下面几个特征可以作为判断标准:

  • JSON 结构非常扁平,字段不超过 5 个,类型基本是字符串和数字;
  • 输出内容较短,基本不会触发转义和截断问题;
  • 调用量不高,失败后重试一次的成本可以接受;
  • 解析失败时可以直接让用户重新发起,或者人工介入。

比如“从一句话里判断情感倾向并返回{"label": "positive"}”这种玩法,用 Prompt 完全能撑住。就算偶尔失败,重新请求一次也就回来了。但一旦出现字段嵌套深、值类型敏感、文本内容复杂,我就不会再用纯 Prompt 方案硬扛。

3.2 出现这几个信号,建议立刻换更可靠的方案

如果你的项目满足以下任何一条,我都建议不要继续在 Prompt 里死磕:

  • JSON 会被直接写入数据库,或流入后续业务逻辑,失败会影响数据质量;
  • 字段值可能包含引号、换行、反斜杠等需要转义的字符;
  • 字段类型敏感,比如 id 是数字类型,一旦模型把它输出成字符串,下游关联就会出问题;
  • 输出结构是深层嵌套或动态变化的数组;
  • 调用频率很高,单次失败率哪怕只有几个百分点都会形成大量错误;
  • 你无法在代码里写复杂的修复逻辑,只能依赖解析成功。

到这一步,你应该把你的精力从“调 Prompt”挪到“换工具链”上。因为 Prompt 再精细,它也只是一个软约束,解决不了概率问题。真正能解决概率问题的方案,是下面要说的 Function Calling 和 JSON Schema 约束。

3.3 一个实用的判断标准:简单和复杂字面的差距

我做过很多次对比实验,同样一套 Prompt,字段从三个变成八个,嵌套层数从一层变成三层,失败率会出现跳跃式上升。原因是模型对复杂结构的记忆其实很脆弱。它可以流畅地生成像{"name":"张三"}这种高频出现的模式,但面对自定义字段+复杂嵌套时,它更像一个看过一遍样例后凭记忆写代码的人,很容易漏一个括号或者把键名写串。

复杂输出的另一个隐患是自定义字段名与模型的先验知识冲突。比如要求返回company_id,模型可能觉得companyId更符合惯例,于是悄悄改掉。这种错误比语法错误更隐蔽,因为解析层完全正常,只有业务层取数据时才发现永远是空值。所以当你需要稳定获得一个底层结构复杂、字段定义特定的 JSON 时,一定要把 schema 放在代码层面去约束,不能指望模型一边猜测一边编码。

4. 更可靠的工程方案:Function Calling 与 JSON Schema

4.1 Function Calling:让模型先选工具,再按参数表输出

Function Calling,有的地方也叫 Tool Calling,是一个比纯粹文本输出要可靠得多的方案。它的思路是:不让模型直接生成“自然语言回复”,而是让模型自己判断该调用哪个工具,并按照工具的参数定义填充 JSON。

举个具体例子。假设我要从一段客服工单文本中抽取用户姓名、订单号和问题分类。可以定义一个叫extract_order_info的函数,参数结构为:

{ "type": "object", "properties": { "user_name": { "type": "string" }, "order_id": { "type": "string" }, "category": { "type": "string" } }, "required": ["user_name", "order_id", "category"] }

然后再把这段工单文本作为用户消息发过去。模型看到用户消息后,如果判断需要通过这个函数来处理,它就会在生成结果中把参数部分以 JSON 形式返回。主流的模型服务提供商会把参数部分从普通自然语言生成中分离出来,在处理这条路径时用更强的约束保证 JSON 结构正确。

实际开发里最大的感受是:一旦模型进入 Function Calling 模式,它就很难再说出“好的,以下是你要的 JSON”这种话。因为整个输出的目标已经从“生成一段好听的文本”变成了“这次要调用哪个函数、参数是什么”。这种目标切换本身就减少了自然语言前缀出现的概率。当然,代码层仍然要做校验,我的原则是绝不因为用了 Function Calling 就放弃 json.loads 和 schema 验证。

4.2 JSON Schema 结构化输出:从“语义规劝”变成“文法强制”

如果你用的模型服务商或者开源推理框架支持结构化输出模式,那就更直接了。它允许你在请求里传入一个 JSON Schema,并明确要求模型输出严格匹配这个 schema 的 JSON。

这类实现的核心原理是约束解码:模型在解码每一轮 token 时,系统会把当前已经生成的 token 与 JSON Schema 做匹配,动态算出一个“当前合法 token 集合”。凡是会让后续输出偏离 schema 的 token,都被直接置成零概率。换句话说,模型从头到尾就没有机会生成一个非法的 JSON 结构。

在这个模式下,你不再需要在 Prompt 里反复强调“不要解释、不要代码块、必须返回 JSON”,因为就算模型想输出解释,解码器也不会放行。系统层面已经帮模型堵死了这条路。

一份请求里常见的简化结构长这样:

{ "response_format": { "type": "json_schema", "json_schema": { "name": "order_info", "strict": true, "schema": { "type": "object", "properties": { "user_name": { "type": "string" }, "order_id": { "type": "string" }, "category": { "type": "string" } }, "required": ["user_name", "order_id", "category"] } } } }

如果你用的是本地模型,也能找到对应的方案,比如 llama.cpp 的 GBNF grammar,或者 vLLM 等推理框架里的 guided decoding,原理都是把 schema 转成解码约束。各家接口叫法不同,但思路一致。

4.3 Prompt 还要不要写?要写,但它的职责变了

很多开发者在切换到 Function Calling 或结构化输出后,很容易把 Prompt 写得特别懒,只丢一句“抽取工单信息”。这个思路也不对。

Prompt 和 schema 的职责是不同的。Schema 负责规定输出的形状,比如有几层、字段名是什么、每个字段的类型是什么;而 Prompt 负责传递任务语义,比如你要模型关注工单里的哪个部分、哪个字段的判定业务规则是什么、如果多个候选值冲突时优先选哪个。这些语义是 schema 没法表达的,必须靠自然语言传清楚。

所以我现在的默认写法是:Prompt 管语义,schema 管结构,代码管兜底。三者缺一不可。遇到复杂输出,我还会在 Prompt 里对关键字段做额外的业务解释,避免模型把null理解成“字段内容为文本 N/A”。

5. 如果只能靠 Prompt,怎么把失败率尽量压低

5.1 输出格式约束到底该怎么写才有效

虽然工程方案更可靠,但并不是每个项目都能立刻接入 Function Calling。比如某些模型没有工具调用能力,或者你只能调一个文本补全接口。这时候就只能靠 Prompt 技巧来尽量降低失败率。我还是积累了一些有效经验的。

首先,把格式要求放在 System Prompt 里,比放在用户消息最末尾更有效。System Prompt 通常会被模型视为更高优先级的全局指令,而用户消息里的要求可能被当成“本次任务内容”而不是“输出格式规范”。其次是表述要直接,比如“你的输出必须是合法 JSON 文本,不要使用 Markdown 代码块,不要包含任何解释性文字。”这里“必须”能起到很强的强调作用。

还要明确告诉模型:如果某个字段找不到,就用null填充,而不是编造一个默认值,也不是用自然语言写一句“没有找到”。很多 JSON 语义错误都来自模型想“把话说完整”,这时候你必须在 Prompt 里拦截它这种倾向。

5.2 一份可直接套用的“仅 JSON 输出”模板

我经常用下面这套模板作为初始版本。你可以根据自己的业务调整字段和说明:

你是一个数据抽取器。请从 USER_INPUT 中抽取字段,并输出唯一一段 JSON 文本。 硬性要求: 1. 输出必须是合法 JSON,不要使用 Markdown 代码块。 2. 不要输出任何解释、前缀或后缀文字。 3. 字段缺失时填 null,不要编造字段值。 4. 必须使用以下键名,不得修改字段名: {"name": string|null, "age": int|null, "city": string|null} USER_INPUT: 张三今年 18 岁,住在上海。

这个模板的优势有两点:第一,明确禁止了绝大多数自然语言包裹;第二,用“用户输入”和“提取目标”做了区分,让模型知道该从哪里提取数据。如果你要输出更复杂的嵌套结构,建议再给一两个 few-shot 示例,因为仅仅描述结构是不够的,让模型看一个输入输出对,它遵循格式的把握会大很多。

5.3 温度、预填充、二次约束这些细节能派上大用场

在只有纯 Prompt 的情况下,这些细节能提升成功率:

  • 把 temperature 调到 0 或尽量低,减少随机采样带来的格式漂移;
  • 如果模型服务商支持 assistant 消息预填充,可以先填入一个{字符,引导模型从 JSON 对象内部开始续写,从源头消除自然语言前缀;
  • max_tokens 不要设置得太小,给 JSON 输出留足余量,否则输出到一半被截断,任何技巧都救不回来;
  • 遇到解析失败,不要直接重试原 Prompt,把上一次输出的错误信息一起喂回去,要求模型“修正为合法 JSON”,二次约束效果往往比盲重重来要好。

在我实际操作过的一个项目中,仅仅加了“assistant 预填一个 {”这一步,就把纯 Prompt 方案的失败率降了不少。因为它相当于给模型戴了一个“开头必须匹配 JSON”的帽子,后面再跑偏的概率已经小很多。当然,这些都是补救措施,和真正的解码约束相比还是差了一个维度。

6. 兜底框架与排查实战

6.1 先把错误分类,再决定怎么修

很多人一看到 json.loads 报错,第一反应就是改 Prompt,反复重试。这是效率很低的排查路径。我建议先做一个错误分类表,把问题分成几个大族,再决定针对哪个环节做调整。

错误类型可能原因优先处理方向
输出前有自然语言前缀Prompt 约束不够强加约束、预填充、换 Function Calling
代码块包裹 JSON模型习惯了 Markdown 回复Prompt 明确禁止;正则清理
单引号/尾逗号等宽松语法模型对 JSON 规范理解不到位二次修正;schema 约束
内容被截断max_tokens 太小或输出太长增加 token 上限;优化输出长度
键名被改写模型先验知识与 schema 有冲突在 Prompt 里加重强调字段名
字段值类型不对Prompt 缺乏类型说明补全 schema 类型要求;下游类型校验
缺少字段模型认为某个字段不必填用 required 字段列表约束

分类之后,很多问题会变得清晰。比如如果错误日志里大量出现“output contains ```json”,那说明模型正在把 JSON 包装成 Markdown 代码块,这明显是 Prompt 的锅。如果错误主要是键名不一致,那就说明模型没有把 schema 当作权威标准,这时候你需要加大字段名的说明力度,或者直接在 schema 层面用枚举值校验。

6.2 代码兜底处理链路:能修则修,不能修就重试

即使有了 Function Calling 或者 JSON Schema 约束,我也一定会写一套兜底处理逻辑。这不算重复劳动,而是最后一道安全带。我常用的处理顺序是:

先把原始文本原样保存到日志;然后尝试直接 json.loads 解析,这是最理想的情况;如果失败,先做规则修复,比如剥离 Markdown 代码块、去掉明显的自然语言前后缀、修正首尾多余符号;修复后再次解析,如果成功再进入下一步 schema 校验;如果还是失败,就把完整的原始输出和新请求的 Prompt 拼在一起,要求模型自己修正格式;最后一次重试仍失败,则返回给上层一个可识别的错误,由业务决定降级或人工处理。

这里有个容易踩的坑:不要一开始就依赖自动修复库把所有毛病全兜住。自动修复虽然能救回不少脏数据,但它也可能悄悄改变字段内容。比如模型把金额输出成“1,234.56”,修复库可能把它处理成另一个字符串格式,反而掩盖了问题。自动修复之后一定要有记录和告警,让你能发现失败模式是不是频繁发生,而不是默默吞掉所有错误只留给下游一个“看似正常”的 JSON。

6.3 日志记录是排查的第一步,也是最重要的一步

很多线上问题查不明白,是因为日志里只记录了“解析失败”这几个字,没有把原始返回内容记录下来。解析失败的样本是无价的,如果不记录原始输出,你就会失去唯一的线索。

我会把每次模型返回的原文、使用的 Prompt 版本、当时的输入样本、解析失败原因都记录到一张宽表里。排查的时候先按失败类型归类,再看具体是哪一类输入导致的。比如你可能会发现只要输入里包含很长的英文 URL,模型输出的 JSON 就经常在 URL 中间的引号处断裂。这种问题不结合样本看,永远猜不到原因。

有了日志,你还能对 Prompt 版本做对比实验。每次调整 Prompt 或 schema,跑一批固定测试集,比较失败率变化,避免“改了之后感觉变好了但没数据”这种状态。这种小规模回归测试看起来不起眼,但能帮你拦住大多数回归风险。

6.4 一个简单的成功率量化实验

我会把三层指标分开统计:第一层是语法合法率,能用 json.loads 解析通过的占比;第二层是 schema 通过率,能通过 JSON Schema 校验的占比;第三层是字段语义正确率,人工或规则判定字段内容确实符合业务的占比。

这里给一个很简化的伪代码思路,方便你在自己项目里快速落地:

import json def try_parse(raw: str): try: return json.loads(raw) except Exception: return None # 对同一批测试样本,分别跑纯 Prompt 和结构化输出模式 for mode in ["prompt", "function"]: ok_count = 0 for sample in test_samples: raw_response = llm_chat(sample, mode=mode) parsed = try_parse(raw_response) if parsed is not None: ok_count += 1 print(mode, "语法合法率:", ok_count / len(test_samples))

不要只看最后的成功率,一定要把失败的样本打印出来看一眼。很多时候你会发现,模型虽然输出了合法 JSON,却把 JSON 放在一段解释性文字的末尾。对 json.loads 来说这是非法格式,但处理思路和“模型漏字段”完全不同。固定测试集和固定统计口径,是让 Prompt 工程从玄学走向科学的第一步。

7. 我给自己的几条默认规则

这套问题折腾过我几次之后,我现在养成了几个默认习惯:凡是给用户用的功能,绝不只靠 Prompt 去保证 JSON 格式;Prompt 描述“抽什么”,Schema 或 Function 描述“长什么样”,代码描述“出错后怎么办”;上线前一定准备一批带脏数据的真实样本来做批量测试,语法合法率、Schema 通过率、关键字段正确率都要看。还有一条最重要的经验:自动修复永远只是兜底,不能因为它存在,就不再追踪模型原始输出中反复出现的失败模式。真正可靠的系统,不是靠某一段 Prompt 写得漂亮,而是让每一层都承担自己该承担的责任。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询