LLM结构化输出实战:OpenAI JSON Mode、PydanticAI与LangChain方案对比
1. 从“JSON又炸了”说起为什么LLM的结构化输出是个老大难问题每次看到LLM返回的JSON字符串心里都得咯噔一下。这玩意儿就跟拆盲盒似的你满怀期待地发出一条指令比如“请把用户反馈分类为‘功能建议’、‘Bug报告’或‘其他’并以JSON格式返回包含category和summary字段”结果它可能给你一个完美的JSON也可能给你一段夹杂着Markdown格式说明的文本或者干脆把JSON的引号给吃了甚至直接开始跟你聊天“好的我已经理解了您的要求以下是我的分析结果...”。这种不确定性在构建严肃的生产级应用时简直是灾难。你精心设计的API接口下游的数据处理逻辑全都建立在“返回一个可解析的JSON”这个脆弱的假设上。一旦LLM“自由发挥”整个流程就断了。这就是为什么“Structured Output”结构化输出成了当前LLM应用开发中最刚需、也最让人头疼的环节之一。它不是一个锦上添花的功能而是连接智能模型与确定性的业务逻辑之间的桥梁。没有它LLM的潜力就困在“玩具”阶段无法真正融入自动化流程、数据管道或复杂的Agent系统中。最近围绕这个痛点社区涌现了多种解决方案从模型原生支持到外部框架封装各有各的招数。今天我们不谈空泛的概念直接上手实测三种主流的、有代表性的方案OpenAI的JSON Mode、PydanticAI以及LangChain的Pydantic Output Parser。我会用同一份代码逻辑在同样的任务下对比它们的易用性、稳定性和“抗炸”能力并附上我踩过的坑和最终的代码示例。无论你是刚被LLM的随机JSON折磨过的开发者还是在为Agent系统选型的架构师这篇实测都能给你一个清晰的参考。2. 实测方案一OpenAI原生JSON Mode最直接但也最“裸奔”OpenAI的API在2023年下半年正式推出了response_format参数你可以将其设置为{ type: json_object }来要求模型强制返回JSON。这是最原生的支持理论上应该最稳定。2.1 基础用法与代码示例使用起来非常简单你只需要在调用ChatCompletion时在参数里加上这个字段即可。同时为了获得更好的效果系统提示词System Prompt的编写至关重要。import openai from pydantic import BaseModel import json # 定义我们期望的数据结构 class UserFeedback(BaseModel): category: str # 例如: bug, feature_request, other summary: str urgency: int # 紧急程度1-5 # 模拟用户反馈 user_input “登录页面点击提交按钮后页面卡住不动了刷新也没用。” # 构建Prompt system_prompt “”” 你是一个用户反馈分类助手。请严格分析用户的反馈内容并将其分类。 你必须返回一个JSON对象这个JSON对象必须符合以下结构 { “category”: “字符串只能是 ‘bug’、‘feature_request’ 或 ‘other’ 中的一个” “summary”: “字符串对反馈的简短总结” “urgency”: “整数范围1-5代表紧急程度5为最高” } 只返回这个JSON对象不要有任何额外的解释、标记或文本。 “”” client openai.OpenAI(api_key“your-api-key”) try: response client.chat.completions.create( model“gpt-3.5-turbo-1106”, # 或 gpt-4-turbo-preview这些版本对JSON Mode支持更好 messages[ {“role”: “system”, “content”: system_prompt}, {“role”: “user”, “content”: user_input} ], response_format{“type”: “json_object”}, # 关键参数 temperature0.1, # 为了稳定性温度可以设低 ) # 解析响应 content response.choices[0].message.content print(“Raw response:”, content) # 尝试直接解析为JSON result_dict json.loads(content) print(“Parsed dict:”, result_dict) # 进一步用Pydantic做数据验证和类型转换 feedback UserFeedback(**result_dict) print(“Validated Pydantic object:”, feedback) except json.JSONDecodeError as e: print(f“JSON解析失败原始内容: {content}”) print(f“错误信息: {e}”) except Exception as e: print(f“其他错误: {e}”)2.2 实测中的坑与稳定性分析理论上设置了response_format: json_object后模型应该100%返回合法JSON。但实测中它只是极大地提高了概率并非绝对保险。我遇到过以下情况JSON合法但结构不符这是最常见的问题。模型确实返回了JSON但字段名可能和你要求的不一样比如你用category它返回type或者多出一些字段。上面的代码在json.loads这步不会报错但在UserFeedback(**result_dict)这步如果字段不匹配Pydantic会抛出验证错误。极端情况下的非JSON响应在非常复杂的任务或模型“注意力不集中”时尽管有json_object约束我依然遇到过一两次返回以“json”代码块包裹的JSON或者前面带了一两句废话的情况。这会导致json.loads直接失败。对嵌套复杂结构的支持不稳定当你要求一个非常深或结构复杂的JSON时例如一个包含列表的列表列表里又是对象模型有时会“忘记”关闭括号或者搞乱嵌套关系产生无效的JSON。注意response_format: json_object有一个官方限制当设置此参数时系统提示词System Message或用户消息User Message中必须明确出现“JSON”这个词。否则API可能会报错。这就是为什么我在上面的system_prompt里反复强调“JSON对象”。为什么会有这些坑根本原因在于JSON Mode是一个“输出层”的约束。模型在生成token时会被引导向生成一个合法的JSON字符串但它底层仍然是基于概率预测下一个词。如果任务理解有偏差或者生成长序列时注意力漂移就可能产生格式错误。它不负责理解你定义的UserFeedback这个类它只负责输出像JSON的东西。适用场景适合需求简单、结构固定、且对偶尔的格式错误有一定容忍度例如有重试机制的场景。它的优点是零依赖直接调用API即可性能开销最小。3. 实测方案二PydanticAI让LLM输出强类型对象如果OpenAI的JSON Mode是“建议”模型输出JSON那么PydanticAI的思路则是“定义”一个你期望的输出类型然后由框架负责“教”模型如何生成并填充这个类型的实例。它是Pydantic团队就是定义上面BaseModel的那个库推出的专门用于构建AI应用的工具核心卖点就是结构化输出。3.1 核心概念与工作流程PydanticAI将你的Pydantic模型BaseModel作为“合同”。你创建一个Agent告诉它你的模型是什么以及任务是什么。框架内部会做几件事自动构建Prompt根据你的Pydantic模型结构生成详细的指令告诉模型需要生成哪些字段类型是什么。指导模型输出使用诸如函数调用Function Calling或工具调用Tool Calling等底层机制这些机制比原始的JSON Mode在模型侧有更强的结构约束。自动解析与验证拿到模型返回的结果后自动将其解析并实例化为你的Pydantic对象并进行数据验证。import asyncio from pydantic_ai import Agent from pydantic import BaseModel, Field # 定义输出模型使用Field提供更详细的描述 class UserFeedback(BaseModel): category: str Field(description“反馈类型必须是 ‘bug’、‘feature_request’ 或 ‘other’”, examples[“bug”]) summary: str Field(description“对反馈内容的精简总结”) urgency: int Field(description“紧急程度1到55最高”, ge1, le5, examples[3]) # 创建Agent指定模型和输出类型 agent Agent( model“openai:gpt-3.5-turbo”, result_typeUserFeedback, # 核心指定输出类型 system_prompt“你是一个专业的用户反馈分析员。”, ) async def main(): user_input “登录页面点击提交按钮后页面卡住不动了刷新也没用。” # 运行Agent result await agent.run(user_input) # result.data 就是UserFeedback的实例 feedback: UserFeedback result.data print(f“Category: {feedback.category}”) print(f“Summary: {feedback.summary}”) print(f“Urgency: {feedback.urgency}”) print(f“Full object: {feedback}”) # 你也可以访问原始消息 print(f“Model used: {result.model_name}”) print(f“Token usage: {result.usage}”) # 运行异步函数 asyncio.run(main())3.2 优势、局限与踩坑点优势非常明显开发者体验极佳你只需要关心定义数据模型和业务逻辑剩下的格式转换、Prompt工程、结果解析全由框架包办。代码简洁意图清晰。类型安全返回的就是一个Pydantic对象你可以立即使用.category、.summary这样的属性访问IDE有自动补全静态类型检查工具如mypy也能正常工作。底层机制更可靠PydanticAI默认会尝试使用模型的“函数调用”能力如OpenAI的function_call来实现结构化输出。这种机制是模型训练时专门优化过的对于返回复杂、嵌套的结构其可靠性和准确性通常比单纯的JSON Mode要高一个数量级。内置重试与修复一些高级配置下PydanticAI能在模型返回格式错误时自动尝试修复或重新询问模型这大大提升了鲁棒性。但是坑也不少依赖特定模型能力它强依赖后端LLM是否支持函数调用/工具调用。虽然OpenAI、Anthropic的主流模型都支持但如果你想用一些开源或小众模型可能就得回退到类似JSON Mode的文本生成模式稳定性会打折扣。性能开销框架的抽象带来了轻微的开销。对于超高频调用的简单场景可能不如裸调用API快。错误处理的黑盒性当解析失败时错误信息可能来自框架深层你需要花时间理解是Prompt构建的问题、模型的问题还是框架解析逻辑的问题。对动态结构的支持如果你的输出结构需要根据输入动态变化比如返回一个列表列表长度不定用静态的Pydantic模型定义起来会有点别扭可能需要更高级的用法。踩坑实录有一次我定义了一个字段tags: List[str]希望模型返回多个标签。但在某些反馈内容里可能没有明显的标签。模型有时会返回一个空列表[]这很好但有时它会返回null或直接省略这个字段导致Pydantic验证失败。解决方案是在字段定义中使用Optional[List[str]] None并为模型在Prompt中明确说明“如果没有请设为空列表或null”。这提醒我们框架不是魔法清晰的字段描述和考虑边界情况依然很重要。适用场景非常适合大多数严肃的AI应用开发尤其是当你需要将LLM输出集成到现有类型安全的Python代码库中时。它平衡了开发效率、代码健壮性和输出可靠性。4. 实测方案三LangChain的PydanticOutputParser生态链中的成熟组件如果你已经在使用LangChain来构建包含RAG、Agent工作流的复杂应用那么它的PydanticOutputParser是一个自然的选择。它是LangChain“输出解析器”生态中的一员设计理念与PydanticAI类似但更深度地集成在LangChain的链条中。4.1 集成方法与代码示例在LangChain中你通常需要构建一个PromptTemplate将输出格式的指令通过{format_instructions}变量注入然后使用解析器来解析模型的输出。from langchain.prompts import PromptTemplate from langchain.output_parsers import PydanticOutputParser from langchain_openai import ChatOpenAI from pydantic import BaseModel, Field # 1. 定义你的数据模型 class UserFeedback(BaseModel): category: str Field(description“反馈类型必须是 ‘bug’、‘feature_request’ 或 ‘other’”) summary: str Field(description“对反馈内容的精简总结”) urgency: int Field(description“紧急程度1到55最高”) # 2. 创建输出解析器 parser PydanticOutputParser(pydantic_objectUserFeedback) # 3. 构建Prompt模板{format_instructions}会被自动替换为详细的格式说明 prompt_template PromptTemplate( template“”” 你是一个用户反馈分类助手。 请分析以下用户反馈 {user_input} {format_instructions} 请只返回符合要求格式的内容。 “””, input_variables[“user_input”], partial_variables{“format_instructions”: parser.get_format_instructions()}, # 关键 ) # 4. 准备LLM和调用链 llm ChatOpenAI(model“gpt-3.5-turbo”, temperature0) chain prompt_template | llm | parser # 使用LangChain表达式语法 # 5. 执行 user_input “登录页面点击提交按钮后页面卡住不动了刷新也没用。” try: result: UserFeedback chain.invoke({“user_input”: user_input}) print(f“解析成功: {result}”) print(f“分类: {result.category}”) except Exception as e: print(f“解析失败: {e}”) # 可以在这里访问原始输出进行调试 # raw_output (prompt_template | llm).invoke({“user_input”: user_input}) # print(f“原始输出: {raw_output.content}”)4.2 在LangChain生态中的定位与实战陷阱PydanticOutputParser的本质是一个后处理器。它不改变模型调用方式默认还是普通的文本补全而是依靠parser.get_format_instructions()生成一段极其详细的文本指令塞进Prompt里让模型“照葫芦画瓢”。然后它再尝试用这段文本来解析模型的文本输出。它的优势在于与LangChain无缝集成如果你已经在用LangChain的LCELLangChain Expression Language组装链条那么加上一个| parser即可非常流畅。模型无关性因为它依赖的是文本指令所以理论上可以用于任何文本生成模型包括那些不支持函数调用的开源模型。灵活性你可以自定义指令模板或者结合其他LangChain组件如重试逻辑、后备模型来构建更健壮的流程。然而它的坑可能比前两者都深“指令墙”问题对于复杂模型get_format_instructions()生成的指令可能会非常长、非常啰嗦占用大量token可能干扰模型对主要任务内容的理解甚至被模型“忽略”。稳定性是三种方案中最弱的因为它完全依赖模型的“自觉性”来遵循一段复杂的文本指令其成功率低于原生JSON Mode更远低于使用函数调用的PydanticAI。在复杂任务中格式错误率较高。错误信息不友好当解析失败时它抛出的异常可能包含一长串模型返回的乱码文本和解析错误调试起来比较费劲。性能损耗冗长的指令意味着更多的输入token更高的成本和更慢的响应速度。实战陷阱我曾用它解析一个包含嵌套列表List[List[str]]的结构。get_format_instructions()生成的指令变得极其复杂。模型在生成时经常在列表的嵌套层级或逗号分隔上出错。最终不得不简化数据结构或者放弃使用这个解析器转而用更可控的方式。这给我的教训是对于简单、扁平的结构PydanticOutputParser可以工作对于复杂嵌套结构请谨慎使用或者准备好完善的重试和降级方案。适用场景最适合你已经深度使用LangChain框架并且处理的结构相对简单同时需要保持对多种模型后端的兼容性。它是一个方便的“生态内解决方案”但并非总是最优的结构化输出工具。5. 横向对比与选型指南什么场景用哪种方案经过上面的实测和踩坑我们可以从几个维度对这三种方案进行横向对比特性维度OpenAI JSON ModePydanticAILangChain PydanticOutputParser核心原理API参数约束引导模型输出JSON字符串。利用函数调用/工具调用模型直接返回结构化数据。将格式指令作为文本插入Prompt后解析文本输出。输出结果JSON字符串需自行解析。Pydantic对象直接使用。Pydantic对象直接使用。开发体验简单直接但需自写解析和验证。极佳声明式编程框架处理一切。较好与LangChain链集成方便。稳定性/可靠性较高有官方约束但对复杂结构可能出错。最高利用模型原生结构化能力。较低依赖模型遵循复杂文本指令。模型兼容性仅限支持该参数的OpenAI模型。依赖模型支持函数调用主流API模型都支持。最广任何文本生成模型均可尝试。处理复杂结构一般深度嵌套易出错。优秀函数调用擅长处理嵌套。差复杂指令易导致模型混乱。性能开销最低几乎无额外开销。较低有框架封装开销。较高冗长指令增加Token消耗。错误处理与重试需自行实现。框架提供一定支持如重试。可结合LangChain其他组件如RetryOutputParser实现。选型建议追求极致简单和性能且结构简单如果你的应用只调用OpenAI且输出的JSON结构非常简单一层字段少OpenAI JSON Mode是轻量高效的选择。记得做好json.loads的异常捕获。构建生产级、类型安全的严肃应用如果你需要高可靠性、清晰的代码结构并且主要使用支持函数调用的主流模型OpenAI, Anthropic等PydanticAI是目前的最佳选择。它极大地减少了心智负担和“JSON炸弹”的风险。已在LangChain生态中或需要多模型支持如果你已经在使用LangChain构建复杂流水线并且可能切换不同的模型提供商可以尝试LangChain PydanticOutputParser。但务必从简单结构开始并为其配备完善的错误处理和后备方案例如解析失败时用更简单的正则表达式或JSON Mode再试一次。处理极其复杂、动态的结构这三种方案都可能吃力。这时可能需要考虑更高级的模式比如让LLM分步骤输出先输出大纲再填充各部分或者使用GraphQL之类的查询语言来精确描述所需结构但这属于更专业的范畴了。6. 终极防御策略无论用哪种方案都必须做的几件事无论你选择了哪种方案都不要把所有的希望100%寄托在工具上。在LLM的世界里容错设计不是可选项而是必选项。以下是我从多次“炸服”中总结的防御性编程策略1. 多层解析与验证不要相信一次解析就能成功。构建一个解析管道def robust_parse(llm_raw_output: str, target_model): # 第一层尝试用选定的主方案解析如PydanticAI或json.loads try: return primary_parser(llm_raw_output) except (ValidationError, JSONDecodeError) as e1: logging.warning(f“主解析失败: {e1}”) # 第二层尝试清洗文本去除markdown代码块、多余换行等 cleaned_output clean_llm_output(llm_raw_output) try: return fallback_parser(cleaned_output) # 例如用正则提取关键字段 except Exception as e2: logging.error(f“备用解析也失败: {e2}”) # 第三层降级处理 return target_model( category“other”, summary“解析失败请人工处理”, urgency1 )2. 清晰的Prompt工程你的指令越模糊模型发挥的空间就越大出错概率也越高。在Prompt中明确指令使用“必须”、“严格遵循”、“只能”等强约束词。提供示例在System Prompt中给出1-2个清晰的输入输出示例Few-shot Learning效果比单纯描述格式好得多。指定字段格式对于枚举字段直接列出所有可能值。对于数字说明范围和类型。3. 设置合理的LLM参数降低Temperature对于结构化输出任务将temperature设置为0或接近0如0.1可以极大减少随机性提高输出一致性。使用特定模型OpenAI的gpt-3.5-turbo-1106及之后的版本以及gpt-4-turbo-preview等对JSON Mode和函数调用的支持更好。避免使用老旧的模型版本。4. 监控与告警记录每次解析的成功与失败。如果某个任务或某种输入格式的失败率突然升高你需要收到告警。这可能是模型服务不稳定、Prompt被污染或业务逻辑变化的信号。5. 人工审核兜底对于关键业务如客户投诉分类、财务数据提取设计一个流程当置信度低或解析失败时自动转交人工审核队列。不要试图用100%的自动化去覆盖LLM那1%的不确定性。回到开头的问题“LLM返回的JSON又炸了”——通过选择合适的工具我个人目前首选PydanticAI并辅以严谨的防御性编程我们可以把“炸”的概率降到最低从盲盒模式走向可控的工业化生产。结构化输出不再是阻碍而是释放LLM真正生产力的钥匙。