ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

大模型稳定输出JSON的实战指南:从Prompt约束到Function Calling与Schema校验

大模型稳定输出JSON的实战指南:从Prompt约束到Function Calling与Schema校验 1. 从野路子到正规军先搞清楚我们到底在解决什么问题这两年做大模型应用你会发现一个特别魔幻的现象模型对话能力越来越强但一旦你让它返回结构化数据它就开始“自由发挥”。要么给你一段前后带引号的JSON字符串要么字段名突然从下划线变成驼峰要么一个中文字符编码乱掉导致整个解析失败。如果你接的是本地部署的开源模型比如Ollama拉下来的那几个这种情况更是家常便饭。先说清楚一个概念JSONJavaScript Object Notation本质上是数据的字符串表示法它只有对象、数组、字符串、数字、布尔值和null六种数据类型。模型要做的不是“把JSON写出来”而是在给定文本约束下逐token地生成一段符合JSON语法的字符流。问题就出在这里——语言模型的目标函数是“预测下一个最可能的token”而不是“生成一段合法JSON”。这两个目标在大多数时候是一致的但在边界情况下会产生肉眼可见的偏差。这篇文章我想完整梳理一下让大模型稳定输出JSON的几条路径从最基础的提示词约束到函数调用、JSON Schema约束再到借助框架做结果校验和重试。我会结合Ollama本地部署、OpenAI兼容接口、Pydantic这类实际工具来拆解也会把我踩过的一些坑放进来。适合正在做大模型应用开发、或者想在本地跑一个可靠的智能体后端的朋友参考。2. 为什么大模型会输出“非法JSON”根因拆解在动手解决之前得先明白问题出在哪一层。你别一上来就责怪模型蠢很多时候是我们用错了工具或者压根没理解它内部的生成机制。2.1 解码策略带来的不确定性模型在生成token时本质上是在一个概率分布上做采样。你设置了temperature0.7它就敢在低概率的词上稍微冒险你设置了top_p0.95它就可能为了追求语义连贯而忽略json语法规则。这就导致一个非常现实的问题同一个prompt同一条输入模型这次给你的JSON是合法的下次就可能在数组末尾多了一个逗号。我在本地用Ollama跑Qwen系列的时候遇到过最离谱的情况是把值为null的字段直接给省略了。结果下游程序拿着schema去校验报了个missing field错误。这类问题在开放域对话模型上特别常见因为模型的训练数据里充塞着大量“非严格JSON”的文本——比如博客里的markdown代码块、聊天记录中的JSON片段、配置文件里的注释等等模型对“近似JSON”的容忍度远高于你的解析器。2.2 上下文长度与注意力衰减问题另一个容易被忽视的坑是当你把一长串示例JSON塞进prompt里期望模型严格模仿它的格式时模型的注意力会随着序列长度增加而衰减。它在生成早期字段时可能还规规矩矩但到了后面十几个字段时格式逐渐“放飞自我”。这就是为什么很多人在短示例上测试一切正常一旦换成真实业务的长schema就开始翻车。我的经验是大模型的JSON输出稳定性和目标JSON的长度成反比。这不是玄学而是模型注意力机制的内在特性。所以当你发现加长prompt反而导致出错率上升时不要总觉得是prompt写得不够好压缩约束长度往往更有效。2.3 微调阶段的数据偏差这个更隐蔽。如果你用的是经过微调的垂直模型比如基于Llama做过的中文对话微调它在微调阶段使用的数据里如果JSON示例不够规范模型学到的“JSON分布”就是歪的。我遇到过某个模型它特别喜欢在JSON里加注解性质的冗余字段像“description”这种东西导致输出体积膨胀解析没报错但内容严重偏离预期。所以做模型选型时如果你明确知道自己要大量使用结构化输出最好选择那些在指令微调阶段就强化过JSON能力的模型或者在部署时打开后端自带的结构化输出约束开关而不是单纯依赖模型“自觉”。3. 方案一纯Prompt约束的极限操作这是最基础的手段也是很多人的第一反应。我一说你就会觉得太简单在系统提示词里写“请严格输出JSON不要输出多余内容”然后期待模型乖乖听话。但实际效果嘛我只能说时灵时不灵。3.1 提示词的写法确实有讲究我在实践里摸索出几个还比较有效的写法原则分享出来给模型一个明确的“出发点”不要笼统说“返回JSON”而是给一个完整的JSON开头比如让模型接着这个开头继续写。这样能大幅降低模型另起炉灶的概率。用示例把schema的形状说清楚与其用自然语言描述“你要返回一个包含用户姓名、年龄和邮箱的对象”不如直接给一段包含真实值的示例JSON。模型对具体例子的模仿能力远强于对抽象描述的理解能力。明确禁止markdown标记很多模型习惯把JSON包在json ...代码块里这对人阅读友好但对程序解析是个噩梦。你要在prompt里明确说“不要使用markdown代码块标记”。实际操作中我用过一个相对好用的“模板式”prompt结构大概长这样你需要抽取用户信息并返回JSON。要求 1. 仅输出JSON对象本身不要输出任何其他解释文字。 2. 不要使用markdown代码块。 3. 输出格式必须严格匹配以下示例 {name: 张三, age: 28, email: zhangsanexample.com} 4. 如果某字段无法确定用null填充不要省略字段。这套写法比单纯说“请用JSON回答”要稳得多。至少在我用Qwen2.5 7B、Llama 3.1 8B本地部署测试时格式正确率能从不到50%提到80%以上。但这远远不够——你还有五分之一的概率拿到一串带前缀解释的文本。3.2 止损方案用正则和解析兜底纯Prompt约束做不到100%所以必须有一个后置的兜底逻辑。我的做法是先做一个“宽松提取阶段”用正则匹配出最像JSON的部分再去尝试解析。这里有几个常见的正则思路找到第一个{和最后一个}截取中间内容尝试json.loads。如果内容是{key: value}形式但value一侧有换行或多余符号先做一次清洗比如去掉不可见控制字符。如果JSON是嵌在markdown代码块里的优先提取代码块内容再解析。这个思路能救回不少本来要失败的输出。但它只是“亡羊补牢”治标不治本。遇到嵌套层级深的JSON或者数组里面套对象的复杂结构正则就力不从心了。4. 方案二让模型自己知道要调用工具——Function Calling / Tools如果你觉得Prompt约束像“靠嘴说服”那Function Calling就是“给人发了一张标准操作手册”。通过工具调用机制模型不再自由生成JSON字段而是在预设好的函数签名里填入参数。这一步的效果提升是质的飞跃。4.1 Function Calling的基本原理简单说你在请求里向模型声明了一批工具函数每个函数列出参数名、类型、是否需要必填和描述。模型如果判断需要调用工具就会在它的回复中输出一个结构化的调用意图比如{name: get_weather, arguments: {city: 北京}}这里的关键点在于模型输出的不是“一段符合JSON语法的自由文本”而是“在一个已经定死的框架里填槽位”。生成难度从“凭空创造结构”降级成“按提示填词”正确率自然高很多。在OpenAI兼容接口上请求格式大致是from openai import OpenAI client OpenAI( api_keyollama, base_urlhttp://localhost:11434/v1 ) response client.chat.completions.create( modelqwen2.5:7b, messages[ {role: system, content: 你是一个信息抽取助手。}, {role: user, content: 张三的邮箱是zhangsanexample.com} ], tools[ { type: function, function: { name: extract_user, description: 抽取用户姓名、年龄和邮箱, parameters: { type: object, properties: { name: {type: string, description: 用户姓名}, age: {type: integer, description: 用户年龄}, email: {type: string, description: 用户邮箱} }, required: [name, age, email] } } } ], tool_choicerequired )注意这里tool_choicerequired或者tool_choice{type: function, function: {name: extract_user}}这两个参数非常关键。它强制模型必须调用函数而不是“可选地”决定是否调用。如果你不设置模型可能自然回复一段文本根本不走工具调用通道。4.2 本地模型能用吗哪些模型支持得比较死这是大家最关心的问题。因为Function Calling的能力是在模型训练阶段注入的不是靠提示词就能“启发”出来的。你拿一个纯对话模型去调tools接口它可能直接无视你的工具声明给你一段普通的markdown文本。就我在Ollama上的实测经验来说Qwen 2.5系列7B及以上对Function Calling的支持还算能打基本能输出结构合理的工具调用。Llama 3.1 8B在简单场景下能跑通但复杂工具定义下偶尔会漏参数。一些小参数模型或者旧版模型比如Llama 2基本指望不上该用Prompt还是得用Prompt。这个版本依赖问题其实是目前本地大模型落地的主要堵点。有些号称支持Function Calling的模型只是在Prompt里假装“理解”了工具实际返回的内容根本不走结构化通道。所以当你选型本地模型时不要只看跑分和对话效果必须在你自己的任务上实测一遍工具调用流程。4.3 工具选择与控制强度如果你希望模型输出完全匹配你的JSON SchemaOpenAI兼容接口还支持response_format参数很多本地推理服务包括Ollama也已经兼容了。这个参数的逻辑是模型在大脑里先草拟一个符合格式约束的输出计划再调整生成时的概率分布让合法JSON的token概率更高。这里有一个细节值得注意response_format和tools同时使用的时候有些后端会有优先级差异。Ollama的formatjson虽然能保证输出是合法JSON但它的约束条件是“任何合法JSON”不会强制跟你给的schema匹配。也就是说模型自己决定了一个结构可能是 {“a”: 1, “b”: 2}而你本来期望的是 {“name”: “...”, “age”: ...}。字段不匹配同样会导致下游解析失败。所以如果你对字段结构有强要求更可靠的组合是toolstool_choice强制指定某一个函数让模型必须往预设的parameters里填。这时候你拿到的arguments基本就是你schema里的那些字段了。5. 方案三JSON Schema约束——让后端去“卡脖子”如果你嫌Function Calling在本地模型的稳定性不够或者说你的项目压根不依赖OpenAI兼容层那么你可以试试后端直接做JSON Schema约束。这一步的思路已经超越了提示词工程而是从生成源头就限制模型只能输出合法且匹配schema的token序列。5.1 结构化输出与采样约束的实现思路Ollama在比较早的版本就支持了format参数。简单说你可以传formatjson让它只输出JSON甚至可以传一个完整的JSON Schema后端会用基于上下文无关文法的受限采样逻辑把生成过程约束在合法JSON结构内。此时模型每一步只能从语法允许的token里采样非法逗号、缺失引号这类基础问题天然消失。用起来大概是这样的curl http://localhost:11434/api/generate -d { model: qwen2.5:7b, prompt: 张三今年28岁邮箱是zhangsanexample.com请提取用户信息, format: { type: object, properties: { name: {type: string}, age: {type: integer}, email: {type: string} }, required: [name, age, email] }, stream: false }Ollama拿到这个schema之后生成的JSON就是严格匹配的字段不会多不会少类型不会错。5.2 结构约束对生成质量的影响但这里有个tradeoff值得讲清楚约束越强模型的生成多样性越低有时还会影响它对语义的理解。道理也简单你把模型的搜索空间限制在一个非常窄的token集合里它每一步的选择变少了生成与用户query强相关的内容的能力也会下降。在极端情况下模型可能输出完全正确但语义完全不符合输入的内容比如把年龄填成null或者把email截断到一个奇怪的字符串。一个真实的坑有一次我用JSON Schema约束模型抽取用户的“所在城市”schema里限定了只能是字符串。结果模型为了满足语法需求把一个无法识别的城市名直接换成了一个随机城市而不是输出null。这种“为了合规而乱填”的幻觉行为在强约束下反而更容易出现因为模型在做语法约束和语义理解的多目标优化时很容易牺牲语义保真度。所以我在生产环境里的用法是双层结构第一层用强约束确保JSON语法绝对合法第二层在后处理逻辑里对关键字段做人工校验和规则审查。永远不要假设“格式正确”等于“内容正确”。5.3 处理API层格式校验失败的常见错误如果你接触过.NET或者FastAPI这类框架应该见过类似报错failed to deserialize the json body into the target type: input: missing fields这个问题看起来像是JSON反序列化失败但根因往往在模型输出端模型返回的JSON缺了一个必填字段。我在排查这类问题时总结了一个通用的处理顺序先把模型的原始输出打印出来确认是不是被截断了很多本地模型输出长度到了max_tokens就一刀切JSON只剩半个。检查字段命名。模型可能把created_at输出成了createdAt这种大小写和命名风格不一致在本地模型里非常普遍。检查字段类型。模型可能把数字输出成了字符串“28”而不是整数28这在JSON反序列化时会触发类型转换异常。如果以上都对再考虑是不是schema本身有问题比如漏了数组类型定义导致模型不知道该怎么套结构。6. 方案四框架层面的“封装对抗”——Pydantic与with_response_model当你把上述方法都掌握之后你会发现生产环境还有个更现实的问题你不能在代码里到处写try-catch来处理模型返回的垃圾JSON。你需要一个统一的抽象层让模型输出自动变成类型安全的Python对象。于是Pydantic这套方案就成了很多人的首选。6.1 为什么Pydantic适合做这层“防腐层”你在热词里能看到很多关于Java Bean转JSON时大写字母变下划线的讨论这本质上是语言层面对序列化/反序列化规则的默认约定不同。Python世界里Pydantic做的事情类似它定义数据模型然后负责从JSON字符串或字典里校验、清洗、转换成Python对象。放在大模型输出后面它就变成了一道“防线”模型输出的JSON先进Pydantic合法就过不合法就把错误信息返回去让你决定是重试还是走旁路。举一个实际例子。我现在写大模型抽取任务通常是这样设计的from pydantic import BaseModel, Field from openai import OpenAI class UserProfile(BaseModel): name: str Field(description用户姓名) age: int Field(description用户年龄必须为整数) email: str Field(description用户邮箱) is_member: bool Field(defaultFalse, description是否会员) client OpenAI( api_keyollama, base_urlhttp://localhost:11434/v1 ) resp client.chat.completions.create( modelqwen2.5:7b, messages[ {role: system, content: 从用户描述中抽取结构化信息}, {role: user, content: 张三28岁邮箱zhangsanexample.com是高级会员} ], response_format{type: json_object}, ) try: result UserProfile.model_validate_json(resp.choices[0].message.content) print(result) except Exception as e: print(解析失败原始输出, resp.choices[0].message.content) print(错误信息, e)如果模型返回的JSON里缺了name字段Pydantic会直接抛ValidationError提示你Field required。这比你自己手工判断字段缺失要稳健得多。6.2 with_response_model的便利与适配问题前面铺垫了逻辑现在说一个更偷懒的上层封装。如果你用的是openai的Python SDK并且想要更简洁的写法可以考虑用with_response_model这类语法糖在Instructor、LangChain等框架里都有类似实现。以Instructor为例它会自动完成三件事将Pydantic模型的字段描述转成tools参数格式发给模型。强制模型走工具调用通道直接返回arguments。在拿到结果后用Pydantic做一步反序列化校验失败会执行重试策略。示例近似这样import instructor from openai import OpenAI from pydantic import BaseModel client instructor.from_openai(OpenAI(base_urlhttp://localhost:11434/v1, api_keyollama)) class UserProfile(BaseModel): name: str age: int email: str user client.chat.completions.create( modelqwen2.5:7b, response_modelUserProfile, messages[ {role: user, content: 张三 28岁 zhangsanexample.com} ], )这层封装的好处是你不再关心模型输出的是不是“纯JSON”、要不要剥离markdown标记、字段命名对不对——框架全都帮你处理了。不好的一面是它引入了一层抽象一旦出了问题比如模型不支持工具调用、后端返回格式不符合instructor预期排查起来会绕一个圈子。7. Ollama本地部署中的格式与兼容性细节既然热词里反复出现Ollama、本地部署、私有大模型这些关键词这块我得多说几句因为本地部署模型做结构化输出时环境因素比模型本身更影响结果。7.1 模型量化级别对输出稳定性的影响我用Ollama跑过Qwen2.5 7B的Q4_K_M和Q8_0两个量化版本明显的感受是Q8_0在JSON输出上的语法正确率更高字段遗漏概率更低。这不是心理作用而是量化会引入微小但真实的信息损失尤其是在处理那些低概率的高维语义特征时。如果你的业务对结构化输出质量要求很高我的建议是能跑Q8_0或FP16就不要用Q4_K_M。如果显存只够跑低量化级别尽量配合Function Calling或JSON Schema这类强约束手段把“格式正确”这条底线交给机制而不是模型智力。不要迷信“参数越大越强”有时候7B模型在JSON语法稳定性上反而不如一个专门微调过的3B模型。7.2 混用OpenAI SDK与Ollama时的兼容问题很多教程会让你把base_url指向http://localhost:11434/v1这样就能用OpenAI SDK来调本地模型这在结构化输出场景下确实方便。但这里有几个坑你得提前知道第一Ollama的/v1/chat/completions不是100%兼容OpenAI的协议。比如response_format参数OpenAI官方支持json_object和json_schema两种Ollama只实现了部分json_object语义而且不保证json_schema能强制执行。第二tools参数在Ollama上是可选的但它的实现基于模型本身是否支持工具调用。你在Ollama上声明tools后端会把工具的JSON定义拼进提示词里模型如果本身没有工具调用能力它依然会按照普通对话的方式回复。第三你还得注意reasoning_effort、thinking这类附加参数在不同后端上可能完全不存在。不要以为OpenAI SDK的每个参数都是通用的换到Ollama后最好先跑一个最小验证确认关键参数真的生效了再放到业务流里。7.3 模型上下文长度对JSON完整性的影响这是个非常隐蔽的问题。当模型的上下文窗口较短而你输入了一段很长的用户文本再让模型输出一个长JSON它可能没有足够的“余量”把整个JSON结构完整生成出来。我遇到过模型输出到一半就停止JSON尾部缺失下游解析直接失败。解决办法有几个调高num_ctx参数但要注意这会加大显存占用。把输入文本先做截断或精简给输出留足空间。在prompt里明确要求“只输出JSON不要重复用户输入内容”避免模型在输出里复述原文导致长度耗尽。8. 常见问题与排查技巧速查为了让你以后遇到问题时能快速定位我把实际项目中使用结构化输出时的高频问题整理成了一个速查表。这些问题里有些是模型问题有些是参数配置问题有些是框架兼容问题别看它们各不相同但排错思路有规律可循。现象可能原因优先排查方向输出内容虽然看起来像JSON但解析失败有markdown代码块包裹用正则提取代码块内容或者检查prompt是否已明确禁止代码块JSON解析报missing field模型漏了必填字段改用Function Calling强制补全或后置校验时用默认值兜底类型错误字符串和数字混用模型对类型约束不敏感在JSON Schema里明确type并在后端做一次强制类型转换字段命名与预期不一致snake_case / camelCase模型的训练数据风格导致在prompt或schema中加入命名风格示例让模型照着写输出被截断JSON不完整max_tokens不足或上下文窗口过短调大输出长度上限压缩输入内容为输出留空间模型在特定输入下反复编造内容数据中实体信息确实缺失关闭无法确认的数据返回null并做规则校验别全信模型强约束下语义质量下降约束过严导致生成空间受限降低约束强度比如只锁定JSON骨架字段内容留给模型自由生成还有几个我个人觉得价值比较高的实操心得一并写出来一是永远保留一份原始输出日志。很多排查类问题的起点都是“我猜模型输出了什么”为此我很早就把一个中间环节固定下来raw_output永远先落日志再丢给解析器。这样即使解析失败也能在日志里看到原汁原味的模型输出而不是被包装错的异常堆栈。没有原始输出你就是盲人摸象。二是重试机制的粒度要细代价要限住。一次失败就整体重跑整个流程既慢又贵。我的习惯是先做“轻量修复”剥离代码块、纠正常见标点错误、去除多余字符修复失败再做“二次请求”带着报错信息回到模型让它重新生成再失败才触发“降级策略”用默认值填充关键字段或者把这个样本标记为人工处理。重试最多不要超过两次不然延迟不可控。三是区分服务端校验和客户端校验。如果你用的是API网关或后端服务建议在服务端统一做一次严格校验客户端只做展示。因为模型输出格式的问题常常是概率性的同一个prompt这次成功下次失败你必须在统一的入口把所有可能出错的报文拦下来而不是让业务方各自处理。四是不要忽略输入侧的数据质量。很多时候结构化输出的错误根本不在“格式”而在“内容”。例如你要模型从用户消息里抽取地址用户输入了一串无法识别的乱码模型为了强行凑出结构可能会填一个不存在的城市。这种问题靠约束JSON是解决不了的必须在前端把输入数据清洗好或者在模型判断不出时宁可返回null也不要返回猜测值。9. 结合一下实际一个信息抽取的端到端小案例前面写了太多理论我拿一个近期的实际任务把整个流程串起来。这个任务是从一段包含用户信息的聊天记录里自动抽取姓名、年龄、所在城市、工作年限和一句话职业简介并返回符合后端的UserProfile模型。我用qwen2.5:7b配合Ollama来完成要求实现三个目标JSON语法必须合法、字段必须齐全、内容尽量准确。我设计的处理流水线拆成五步第一步是预处理。把原文去掉表情符、多余空格、语气词整理成干净的文本。原文如果特别长我用一个截断函数保留下可能包含实体的部分比如包含“我今年”“我的邮箱”“工作”等关键词附近的段落。第二步是构建prompt。系统提示词里我会放一段示例JSON并声明“仅返回JSON不要包含额外解释”。用户消息里附带经过预处理的文本。示例里要故意包含null场景让模型学会缺失字段时返回null而不是编造值。第三步是请求配置。模型用qwen2.5:7bformat参数放一个完整的JSON Schema把num_ctx调到8192max_tokens调到1024temperature调整到0.3。这里温度不要设太高结构化输出任务对随机性几乎没有需求。第四步是后处理。用Pydantic的model_validate_json做解析校验如果失败进入修复逻辑用正则尝试剥离多余字符再校验一次还不行就调用模型做第二轮生成并附带上第一次的错误信息。第五步是输出。解析成功的对象转成Python字典传给下游入库。这一整套流程跑下来最直观的感受是严谨的数据链路本身比更强的模型更能提升稳定性。在我的一次对照测试中相同模型、相同参考数据纯prompt方案的正确率大约在61%加了JSON Schema约束后提升到83%再加上函数调用与校验填充可以到91%以上。剩下的个别失败样本基本都是模型语义幻觉问题不是格式问题。格式问题能被机器机制彻底挡掉内容问题才是后续真正需要打磨的部分。10. 绕不开的下一个话题从“格式合法”到“内容可信”写到这里我不太想做一个“总结陈词”因为这条路还远没有走完。结构化输出解决的只是最外面的那层壳——JSON合法了字段齐了类型对了但模型返回的内容是不是真的符合事实依然没有机制能保证。我自己的体会是做工程和做研究最大的区别就在这里工程上你追求的是一个可预期的结果哪怕这个结果不是最优的但必须是稳定的。所以我更愿意把大模型当成一个“语法上可以被约束、语义上仍需要校验”的组件而不是一个“说什么都对”的答案机器。后续如果你在这个方向继续深入有几个延伸点值得留意多模态模型的结构化输出图像输入配合JSON输出比如从图片里抽取票据信息返回结构化字段这在传统OCR领域已经有成熟方案但大模型的加入让这事的门槛和成本都降了很多。热词里的“农业大模型”就是一个很有意思的场景——实时监测土壤、气象数据通过多模态输入生成结构化的灌溉建议模型输出端和传感器数据端差不多可以无缝对接。微调与结构化能力绑定如果你有足够多的垂直领域数据可以在微调阶段就在模型的输出格式上做文章。数据准备阶段把大量JSON样本放进训练集模型会在权重层面记住“这类任务应该输出什么形状的结果”。这比任何后置校验都更本质。数据结构从JSON到更复杂的Graph或嵌套Schema有些业务并不适合扁平JSON需要输出带层级关系、多实体关联的结构。这时JSON Schema的嵌套定义能力很重要但模型的稳定性挑战也会成倍上升。最后分享一个小技巧让模型先输出一个“思考计划”再让它输出JSON时反而容易扰乱JSON结构。如果你真的需要做复杂任务建议让思考过程放在前一次调用里第二次调用只让它基于上一步结论输出JSON。把一个复杂任务拆成“对话”和“抽取”两个阶段比让模型一次性从思考跳到结构化输出稳定得多。好了这篇就写到这。你自己动手跑一遍比我写再多都有用。有什么你在本地部署或JSON输出上踩过的坑欢迎在评论区一起聊聊。
返回列表