LLM输出JSON总失败?从Prompt到Function Calling的可靠性实践

LLM输出JSON总失败?从Prompt到Function Calling的可靠性实践 我到现在还记得线上第一次被 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. 根因语言模型本质上是在预测下一个词不是在编写 JSON2.1 大模型的底层机制决定了 Prompt 只是“概率性约束”大部分 LLM 的核心任务是预测下一个 token。给定前面的文本模型会根据训练得到的知识计算出词表中每个 token 被选中的概率然后从中挑一个。换句话说生成 JSON 的过程并不是在执行一套严格的 JSON 语法规则而是在做概率采样。你可以把“返回 JSON”这个要求理解成一个很强的先验信号。模型在训练阶段见过太多“用户要求 JSONAI 乖乖返回 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 Schema4.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, modemode) 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 写得漂亮而是让每一层都承担自己该承担的责任。