大模型什么都会,就是不会好好说人话。这可能是做AI应用开发最让人头疼的一件事:你问它一个结构化的问题,它给你回一大段散文;你让它输出配置信息,它给你写一首诗;你辛辛苦苦部署好的服务,就因为解析失败当场崩溃。这个问题我踩了无数坑,今天把实战中验证过的方法完整梳理一遍——怎么用函数调用、响应格式限定、提示词约束、输出解析器给大模型套上缰绳,让它老老实实返回干净的JSON数据。这篇文章适合正在做AI应用开发、API对接、自动化流程的工程师,尤其是那些刚接触大模型、被不稳定输出折磨过的新手。
1. 为什么大模型总是"答非所问":结构化输出的痛点解剖
在动手写代码之前,先花点时间搞清楚问题的本质。大模型本质上是一个"预测下一个词"的语言模型,它并不知道自己正在和一段程序对话,也不知道对方的期待是什么。你让它"返回一个JSON",它脑子里想的是"好的,我来描述一个JSON长什么样",而不是"我来生成一段可以被json.loads直接解析的字符串"。
1.1 非结构化输出的三种典型表现
我在实际项目中总结了模型输出不稳定的几种常见形态,每一种都有自己的成因和对策。
第一种是散文式输出。模型收到请求后,会用自然语言回复,比如你问"提取这篇文章的关键词",它回答"好的,以下是这篇文章的关键词:人工智能、深度学习、自然语言处理……"。这种输出人类读起来很舒服,但程序要处理它就得写一大堆正则表达式去匹配,而且格式稍微一变化就全完蛋。最典型的是让模型输出一个代码片段,它会在代码块外面加上markdown的```标记,甚至附上"下面是代码"这样的说明文字。
第二种是格式漂移。模型可能上一轮返回的JSON带双引号,下一轮就用了单引号;上一轮字段名是驼峰式userName,下一轮就变成了下划线式user_name;更麻烦的是,它可能把布尔值从true/false变成True/False,这在Python的json.loads里直接报错,因为Python严格区分大小写。我曾经在一套自动化流程里让模型提取合同金额,同一份合同跑三次测试,三次返回的键名都不一样,调试到崩溃。
第三种是截断和幻觉。尤其在使用开源模型比如LLaMA、Qwen的本地部署版本时,模型生成到一半可能因为达到最大token长度而中断,留下的JSON字符串只有半个对象,花括号都不闭合。更常见的是模型在生成JSON时自己"创造"了一些不存在的字段,或者把NULL值写成了字符串"null"而不是null。这时候解析器能跑,但返回的数据结构完全不符合预期。
这三种形态的共同根源在于:默认情况下,模型的空间里没有"输出格式"这个约束条件。要让它稳定输出JSON,本质上是把这个约束从"语感揣测"变成"技术强制"。
1.2 一次真实的生产事故:解析失败的连锁反应
去年我在做一个人力资源系统的智能问答模块,大模型负责从简历库里筛选候选人信息。最初的实现方案非常天真——直接问模型"给出张三的电话号码",然后从响应的字符串里截取。结果上线第一天就出了事:一个候选人的电话号码是18开头,模型在电话前后各加了一段描述性文字,导致正则匹配到一串错误号码,系统把简历推荐给了完全不相关的岗位。
修复过程中尝试了提示词方案,在System Prompt里写"你必须只输出JSON",效果有改善但不稳定。后来换成函数调用(Function Calling),才最终解决了问题——因为函数的入参schema明确告诉模型"电话这里应该放一个符合手机号格式的字符串",模型的输出被约束在了这个结构内。
这次事故让我意识到一个核心问题:处理大模型输出,不能靠人品,不能靠概率,要在技术架构层面设计一套可靠的约束机制。下面从工具选型、实现方式、常见问题和调优技巧四个维度展开,讲清楚如何让大模型稳定返回JSON数据。
2. 主流方案全景对比:从提示词到结构化生成的路径选择
解决结构化输出问题,业界已经沉淀出几套主流方案,它们各有优劣和使用场景。我踩过不少坑,也对比过不少实现,下面把这几种路径掰开揉碎讲清楚。
2.1 从"软约束"到"硬约束"的技术谱系
先看三种主流方法的定位差异:
| 方案 | 实现难度 | 约束强度 | 适用场景 |
|---|---|---|---|
| 提示词强约束+解析器 | 最低 | 弱,依赖模型能力 | 快速原型、简单场景 |
| 函数调用(Function Calling) | 中 | 较强,结构固定 | 需要精确参数的API调用 |
| 结构化生成(受限解码) | 高 | 最强,强制执行 | 生产环境、对格式零容忍 |
这个表格只是我自己的工作,真实项目里通常不是只用一种,而是组合使用。比如用提示词做兜底,用函数调用做主路径,再用结构化生成处理最关键的数据接口。
2.2 方案一:提示词强约束 + 输出解析器
这可能是很多人第一个想到的办法,也是我在小项目里最常用的一种。核心思路是:在System Prompt里明文规定输出只能是JSON,不许带任何多余文字,然后借助一些开源解析库把模型输出"清洗"成可用的JSON。
实际写好一个强约束提示词,有几个细节容易被忽略:
- 给出目标JSON的完整示例,不要只给字段列表。模型对"实例"的理解远强于对"规则"的理解,你给它一个完美的JSON样例,它返回的格式就差不到哪去。
- 明确说明不能包含的字符,比如"不要使用花括号语言、不要带markdown代码块标记、不要解释你为什么这么输出"。
- 要求模型将不确定的字段置为null,而不是编造。这个对数据清洗非常重要,是防止幻觉的第一道防线。
只写提示词还是不够稳,因为模型无法100%遵守指令,尤其是一些参数量较小的开源模型。所以我一般会在提示词后面再接一层解析器兜底。
Python里常用的解析器有:
- LangChain的PydanticOutputParser:用Pydantic定义好数据结构,解析器先把LLM输出尝试json.loads,如果失败则自动尝试从文本中提取JSON部分,再按Pydantic模型校验。
- json-repair:专门修复不合法JSON的小库,能把"缺失引号的键名"、"单引号替代双引号"、"多余的尾逗号"等常见问题修好。
- OpenAI的JSON Mode:如果是OpenAI GPT-4o、GPT-4-turbo系列,在API里直接设置response_format={ "type": "json_object" },模型就只会生成合法的JSON文本,不夹带任何多余内容。
2.3 方案二:函数调用(Function Calling / Tool Use)
这是目前GPT-4、Claude、Qwen、GLM等主流大模型都支持的能力,也是我生产环境中首选的方案。函数调用的底层机制是:开发者给模型一组带JSON Schema描述的"工具函数"清单,模型根据用户提问自动决定要调用哪个函数,并生成符合该函数参数结构的JSON对象。
举个招聘筛选的例子:
用户问题:帮我找到张三的电话和最近的工作经历。 系统收到函数调用请求: 调用 get_candidate_info(candidate_name: "张三", fields: ["phone", "experience"])模型不是直接输出"张三的电话号码是138xxxx",而是生成一个结构化的调用请求。在OpenAI SDK里,这一步表现为返回的tool_calls数组。拿到这个数组后,你再决定是去查数据库、调外部API,还是直接把参数里的JSON转给下游使用。
函数调用的好处是:格式强约束是模型原生支持的,不需要提示词层级去碰运气。而且它天然适合"让大模型做决策、让代码做执行"的架构——模型负责理解意图、填参数,你的程序负责真正的逻辑和存储。我在做一个代码审查机器人时就用了这套机制,模型只负责"决定要不要触发规则",规则参数永远通过JSON Schema来传递,实现零格式错误。
2.4 方案三:结构化生成(受限解码 / Grammar-Constrained Decoding)
这是最硬核的手段。它的思路不是"劝"模型输出JSON,而是从解码层面直接限制模型只能生成符合某个语法格式的token序列。
开源模型生态里,这套方案的火烧得很旺。比如在使用vLLM部署模型时,可以传入一个guided_json参数,传入一份JSON Schema,那么模型在每一步生成时都会排除不符合该schema的token。在Llama.cpp里,也内置了json_schema语法约束功能。实践下来,条条大路通罗马,结构化生成的准确率基本是100%,因为不合法的token被直接屏蔽了。
但这套方案有个明显的取舍:需要占用额外的推理时间。因为每一步生成都要做一次语法校验,推理吞吐会下降15%到40%不等。如果不是对输出格式零容忍的生产级接口,下不了这个成本也没关系。
3. 实战拆解:从零构建一个稳定返回JSON的工具
思路理清了,是时候动手了。下面我从头演示一个完整的、可直接运行的解决方案,用的技术栈是:LangChain + Pydantic + OpenAI兼容API(也可以用Ollama本地部署的Qwen等开源模型代替)。最终效果是:输入一段自然语言,系统稳定返回一个符合预定义结构的JSON。
3.1 环境准备与相关库的选型理由
先装包:
pip install langchain langchain-openai pydantic如果你用的是本地部署的Ollama,那么可以搭配:
pip install langchain-ollama选这几个库的原因很简单:Pydantic负责定义数据结构和数据验证,LangChain负责把Pydantic结构和LLM输出粘合起来,LangChain-OpenAI或LangChain-Ollama则是模型接入的适配层。这套组合能让我从"一个点子"到"一个可运行的原型"只花十几分钟。
有一个细节很容易踩坑:如果要用OpenAI的JSON Mode功能,必须确保在消息末尾加上"JSON"这个词,否则接口会报错。LangChain的with_structured_output方法内部会帮你处理好这个逻辑,但如果直接裸调OpenAI SDK,就得自己拼提示词。
3.2 定义一个不可变的数据结构(Pydantic模型)
先想清楚你要的输出长什么样。比如我现在要做的是"从一段招聘JD里提取岗位信息",定义如下:
from typing import List, Optional from pydantic import BaseModel, Field class SkillRequirement(BaseModel): name: str = Field(description="技能名称") level: str = Field(description="技能要求等级:初级/中级/高级/专家") class JobPosting(BaseModel): title: str = Field(description="岗位名称") salary_range: Optional[str] = Field(default=None, description="薪资范围,如30k-50k") requirements: List[SkillRequirement] = Field(description="技能要求列表") years_of_experience: Optional[int] = Field(description="最低工作年限") remote_ok: bool = Field(description="是否支持远程办公")注意几点写作惯例:
- 每个字段都写description,这句描述会传给模型作为生成依据,越具体越好。不要写"职位信息"这种空话,要写"岗位名称,如:高级后端工程师"。
- Optional字段给默认值,避免模型因缺失字段而编造数据。如果给的是None,模型找不到就直接置null,不会卡住。
- 嵌套结构的设计尽量扁平。模型在生成深层嵌套的JSON时错误率会成倍上涨,能用两层的就不要搞五层。
3.3 核心调用代码:打通自然语言到JSON的管道
接下来是最核心的管道部分,代码不多,但每一步都决定了最终稳定性。
from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate # 1. 初始化模型 model = ChatOpenAI(model="gpt-4o", temperature=0) # 2. 绑定Pydantic结构 structured_model = model.with_structured_output(JobPosting) # 3. 构造提示词 prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个专业的招聘信息提取助手。只提取原文中明确提到的信息;未提到的字段一律置为null,不要猜测。"), ("human", "{text}") ]) # 4. 组装Runnable链 chain = prompt | structured_model # 5. 测试 result = chain.invoke({ "text": """ 我们正在招聘一名高级Python开发工程师,负责AI平台的后端开发。 薪资25k-40k,要求5年以上开发经验,精通Python、FastAPI、PostgreSQL, 熟悉大模型应用开发者优先。支持每周3天远程办公。 """ })执行完result就是一个JobPosting实例,直接用:
>>> result.model_dump() { "title": "高级Python开发工程师", "salary_range": "25k-40k", "requirements": [ {"name": "Python", "level": "专家"}, {"name": "FastAPI", "level": "高级"}, {"name": "PostgreSQL", "level": "高级"} ], "years_of_experience": 5, "remote_ok": true }漂不漂亮?模型没有再输出一大段心灵鸡汤,而是直接给出了干净的结果,model_dump()的字典可以直接被json.dumps序列化扔给前端或数据库。
3.4 解析失败的兜底策略与针对性修复
虽然with_structured_output内置了格式校验,但遇到模型输出没法匹配Pydantic结构的情况,它仍可能抛Error。这时我一般会写一个兜底函数:第一次解析失败,对文本做一次清洗——去掉模型顺手加的markdown标记、截取第一个"{"到最后一个"}"之间的子串,再喂给json.loads;如果还失败,就调用一次"修正模型",把原始输出和报错信息一块发回去,让模型"重新输出一遍正确写法"。
其中一种兜底实现的骨架参考:
import json import re def robust_parse(text: str): # 情况1:文本中混入了额外说明文字,尝试提取JSON区域 pattern = r"\{.*\}" match = re.search(pattern, text, re.DOTALL) if match: try: return json.loads(match.group()) except json.JSONDecodeError: pass # 情况2:用json-repair库做修复 from json_repair import repair_json return json.loads(repair_json(text))兜底的价值在于:它不会让主线流程因为一次意外中断,而是把出错重试的成本放在最末端。
4. 生产环境踩坑记录:那些在官方文档里搜不到的教训
工具流程能跑通只是第一步,生产环境里真正考验人的,是那些我称它为“大概率事件”的边界情况。下面几条经验,每条都是流血换来的。
4.1 提示词长度与上下文占位:不可忽视的隐性Buff
当你让模型输出结构化JSON时,它也同时处理了用户输入。如果你要求它输出的JSON schema极其复杂、字段高达数十个,那么模型在有限的上下文窗口里可能"记不住"完整schema,尤其是在上下文很长的情况下。
我来举个反例:我做某个数据清洗任务时,要求在系统提示词里塞入一张包含27个字段的表格schema,然后把一份12000字的原始文档一并交给模型处理。结果模型的JSON输出开始频繁跳字段,而且出现"幻觉字段"——它开始自己编造一些不在schema里的键名。把schema里所有字段名和描述精简到只留关键部分后(大概900字符),问题立刻缓解。
常规经验:
- 保持总输入长度低于上下文长度的50%,留足生成空间。
- 如果schema过于庞大,拆成多步完成,而不是一次提取全部。
- 尽可能把schema描述写在"最近的、离输出最近的"提示词区域,模型对尾部指令的关注度更高。
4.2 不同模型的兼容性差异与策略
这里必须提醒一下:OpenAI GPT-4系列和Claude 3.5 Sonnet在结构化输出方面是标杆级别的。但如果你用其他模型,情况会完全不同:
| 模型 | with_structured_output支持情况 | 实际体验 |
|---|---|---|
| GPT-4o / 4-turbo | 原生支持,JSON固定生成 | 最高 |
| Claude 3.5 Sonnet | 函数调用良好 | 优秀,偶尔需要提示兜底 |
| Qwen2.5(本地方案) | 支持Tools Calling | 不错,但复杂嵌套结构较吃力 |
| LLaMA 3.1 8B | 勉强支持 | 需要强提示词+解析兜底 |
| 一些小参数模型 | 几乎不支持 | 建议直接上结构化生成 |
所以策略上要分层——生产环境如果核心链路是付费API,优先选择原生支持好的模型;如果你迫于成本使用本地部署的开源模型,那么请务必上一套强提示词+解析器+兜底重试的链路,同时把JSON Schema尽量简化,字段用几十年跨度的老数据结构,这会是省心不折腾的关键。
4.3 关于结构化输出未来的展望
结构化输出正在成为大模型应用层的标配能力,从早期"提示词求模型"到现在的"函数调用"再到将来的"受限解码",技术演进方向非常明确:把格式控制从概率事情变成必然事件。
个人实操中,如果你面临一个固定格式的高频任务,我强烈建议投入时间学会一种SGLang/vLLM的结构化生成方案,它能在保证速度的同时做到零格式错误,特别适合批量处理、自动化脚本这些场景。我在本地的多个生产任务里用这套方案替代了纯提示词方案,稳定性直接提升了一个数量级,再也不用半夜爬起来看解析报错日志。
5. 最终建议:如何根据自身场景选择最适合的方案
说了这么多,核心原则其实非常朴素:稳定性是从架构里设计出来的,不是靠prompt驯化出来的。仅靠微调和写一段漂亮的提示词,没法彻底解决格式输出的随机性;但如果你在系统里加上schema约束、输出校验、异常重试和格式兜底,即使是开源小模型也能输出一个相对稳固的JSON结构。
换个角度说,从提示词、函数调用到结构化生成,稳定手段的强度在递增,成本也在递增。做原型推荐提示词+解析器,很快能出活;写正规服务或对接外部API,优先上函数调用;跑核心数据管道、容不得半点错时,选择结构化生成。这套思路不管你用哪家模型、哪种语言框架,基本都适用。
最后,结合我在这块踩过的大量坑,再送你两条真心话:第一,永远不要相信模型第一次返回的JSON一定符合要求,外加一层Pydantic校验,比什么都管用;第二,输出的字段名一定要和下游数据库表的字段名完全对齐,否则数据流会在最后一公里莫名其妙地断掉。结构化输出这块,没有一劳永逸的银弹,只有不断迭代和兜底,才是稳如泰山的根本。