如果你跑过真实的 LLM 应用,大概率见过这种名场面:提示词里明明写了“只输出 JSON”,模型却回你一句“好的,这是整理后的结果”,然后跟着一坨 Markdown 代码块。更离谱的是,JSON 里还多出一个没要求的字段,少掉一个你明确要的id。我在某信息抽取系统里做结构化改造时,几乎每个迭代都在跟这种“话痨输出”搏斗。后来把 LangChain 的输出解析器体系彻底吃透,这个问题才算稳定解决。
这篇笔记适合谁?适合所有被 LLM 非结构化输出折磨过的工程师,不管你是刚接触 LangChain,还是已经在生产环境里跑管道,都能从中找到可抄的代码和可避的坑。我会从根源说起,把 PydanticOutputParser、StructuredOutputParser、JSON 系、XML 系、重试兜底这些常用方案的取舍逻辑讲透,再给一份完整的模拟业务落地代码,最后分享我在实际项目里反复踩出来的三条教训。
1. 先搞清楚问题根源:LLM 的“自由表达欲”为什么是生产事故
1.1 一个让人血压升高的真实复现场景
假设你要做一个简历信息抽取功能,用户上传一段生平介绍,你需要拿到候选人的姓名、技能、工作经历。于是你很自然地在提示词里写:“请从以下文本中抽取信息,输出 JSON。”第一次调用你可能觉得还行,第二次就翻车了。
我复现过太多次了,模型会给你这些花样:
- 在 JSON 前后加解释:“以下是抽取结果:”“希望这些信息能帮到你。”
- 把 JSON 塞进 ```json 代码块,字段名带空格,值带换行符。
- 数组里混入字符串说明,布尔值写成
yes/no。 - 深层嵌套对象漏字段,比如工作经验里的
end_date直接省略。
核心原因不是模型太笨,而是“输出 JSON”在模型眼里只是一个高频请求,不是一条语法约束。语言模型是逐 token 采样的,模型只会生成“看起来像 JSON 的文本”,不会去校验括号成对、键名齐全、类型匹配。你找再聪明的模型,只要还用自然语言提示词约束格式,失败率就不会归零,只是从百分之三十降到百分之五。
1.2 非结构化输出的三种典型形态
我在某业务平台里把失败样例攒了个小库,大致可以归成三类,方便你理解后续每个解析器到底在解决哪一部分:
第一类是“混入杂讯”。模型输出里掺解释、感谢语、代码块标记。这类问题最好解决,清洗字符串、定位花括号就能救回来一大半。
第二类是“结构漂移”。JSON 语法本身合法,但字段和你的预期不一致:多字段、少字段、字段名改了、数组套错了层级。这种问题字符串清洗救不了,必须靠 schema 校验。
第三类是“类型失控”。字段还在,但类型不对,years输出成了字符串"五年",枚举输出成自定义词。这种问题必须靠强类型对象在解析阶段拦住。
理解了这三类,你就明白为什么不能只靠“提示词里再多说几句”——你在请求里加再多的“务必注意”,也拦不住类型失控。你需要的是一道硬性的校验闸门。
1.3 为什么不能靠“提示词里多说几句”根治
我在早期也天真过,把提示词写得越来越长,又是强调又是重复,甚至把 JSON Schema 抄进去。实测效果是:模型短期内会老实一点,但只要换个措辞、换条文本、换批测试数据,马上原形毕露。
原因是语义约束和语法约束有本质区别。提示词对模型来说是“强烈建议”,“强烈建议”在采样时只影响概率,不能保证 100% 命中。而解析器做的事是在模型输出之后执行确定性代码:要么通过校验,给你一个类型正确的对象;要么直接抛异常,让你的重试机制接手。
一句话总结:**提示词负责提高正确率,解析器负责保证正确性。**两者缺一不可,但只有后者能作为工程上的“契约”。
2. 输出解析器的工作机制拆解:它到底做了哪两件事
2.1 第一件事:把 schema 翻译成模型能听懂的命令
LangChain 的输出解析器,准确叫 Output Parser,核心逻辑其实只有两步。第一步是生成格式指令,也就是get_format_instructions()。你定义一个 Pydantic 模型,解析器会把模型转成 JSON Schema,再把 Schema 渲染成一段模型容易遵循的说明文字,塞进提示词。
我第一次看到打印出来的格式指令时挺震撼的,它会详细到:“completed字段必须是布尔值,projects必须是数组,数组中每个对象必须包含name和tech_stack,其中tech_stack为字符串数组。”也就是说,它把类型约束、必填要求、枚举范围全部翻译成了自然语言和示例,让模型在生成之前就“看见”标准答案的样子。
这段指令是模板的一部分,通常通过partial_variables注入。你可以在构建 PromptTemplate 时提前算好,避免每次调用重复生成。
2.2 第二件事:接收输出并做严格验证
第二步是parse,接收模型输出的原始字符串,先做 JSON 反序列化,再用你的 Pydantic 模型做model_validate_json。这一步才是真正的“质检员”。
我举一个细节:PydanticOutputParser 在 Pydantic v2 下会直接调用model_validate_json,而不是先json.loads再model_validate。这意味着它不仅校验字段类型,还会帮你处理一些 JSON 和 Python 类型之间的微妙差异,比如datetime字符串解析、Literal枚举校验、嵌套模型的递归校验。
一旦校验失败,它会明确告诉你:哪个字段缺失、哪个类型不匹配、枚举值不对。这个异常信息非常关键,后面讲重试机制时你会看到它的价值。
2.3 一个容易被忽略的设计:解析失败时绝不自动降级
这是我最欣赏 LangChain 解析器的一点:默认行为是“失败即抛出异常”,而不是帮你把脏 JSON 悄悄修好。别小看这个设计,很多人第一次用的时候会嫌它太“严苛”,等上了生产才知道,静默降级才是灾难的开始。
想象一下,你解析一个订单信息,模型少输出了价格字段,解析器如果自动填个0或None,你的下游计费逻辑就会莫名跑出奇怪结果。主动抛异常,你才能有机会重试、告警、记录 bad case。解析器给的是确定性,而确定性在工程里比“有时候对”重要得多。
3. 常用解析器哪家强:一张表讲清四种选择
3.1 PydanticOutputParser:强约束优先
这是我日常用得最多的解析器。适用场景很明确:字段多、有嵌套、有必填项、有其他系统需要消费结果。比如简历抽取、工单分类、信息解析、审批判断,这类任务对字段完整性和类型正确性要求很高,Pydantic 的校验能力能直接把一半问题挡在解析层。
用法核心就两行:
from langchain.output_parsers import PydanticOutputParser from pydantic import BaseModel, Field parser = PydanticOutputParser(pydantic_object=MyModel) format_instructions = parser.get_format_instructions()剩下的事情交给提示词模板。我在后面会放一版完整代码。
3.2 StructuredOutputParser:字典结构也能打
如果你不想为了一个小任务单独定义一堆 Pydantic 模型,这个解析器更轻。它只需要你声明一组ResponseSchema,每个 schema 由字段名和描述组成,解析结果返回普通字典。
from langchain.output_parsers import StructuredOutputParser, ResponseSchema schema = [ ResponseSchema(name="sentiment", description="评论情绪,正面/负面/中性"), ResponseSchema(name="score", description="情绪分数,0到10的整数"), ] parser = StructuredOutputParser.from_response_schemas(schema)实测中它适合字段少、结构平、类型要求不高的场景。理解它是给“半结构化抽取”用的就够了。
3.3 Pydantic 之外的 JSON 系与 XML:取舍逻辑
再往轻走,还有 JsonOutputParser、SimpleJsonOutputParser,以及 XMLOutputParser。它们的共同点是约束更弱、语法要求更低、只保证“合法”,不保证“字段齐全”。
我用一个判断标准来决定是否选它们:如果下游只关心几个关键值,而且缺失字段可以被业务容忍,就选轻量解析器;如果下游要写库、要计算、要依赖字段强一致,就选 Pydantic。
XML 解析器我提一下使用场景:有些遗留系统交接靠 XML,或者模型输出本身就是半结构化的 XML 片段,需要转成 Element 对象下游解析。它不常被提起,但真的遇到 XML 场景时你会谢天谢地。日常建议不要用它处理需要强校验的 JSON 任务,那是大炮打蚊子还落不了地。
3.4 我的选型原则
| 解析器 | 返回类型 | 校验强度 | 指令复杂度 | 典型场景 |
|---|---|---|---|---|
| PydanticOutputParser | Pydantic 对象 | 最强,字段/类型/嵌套全查 | 高,会生成完整 JSON Schema 指令 | 简历抽取、订单信息、审批结果、多字段强一致 |
| StructuredOutputParser | dict | 中,按 ResponseSchema 检查字段存在 | 中,指令简洁 | 少量字段的情绪/标签抽取 |
| JsonOutputParser | dict | 弱,只要求合法 JSON | 低,指令最短 | 只要拿到 JSON 即可,字段随意 |
| XMLOutputParser | XML Element | 弱,只要求合法 XML | 中 | 下游是 XML 系统,或对 XML 有硬性要求 |
| RetryOutputParser | 按原解析器 | 继承原校验 | 无独立指令 | 作为前置解析失败后的二次兜底 |
这张表不是权威,但按它选型,我几乎没有后悔过。
4. 实战 Pull Request:用 Pydantic 解析器重构一个“简历信息抽取”功能
4.1 建模:把候选人的信息结构写成数据类
这部分我直接给你一份可复制的代码,以“某信息抽取系统”里的简历解析模块为例。第一步是定义模型,注意每个字段都要给Field(description=...),因为模型生成格式指令时,description 就是它对字段语义的全部理解。
from typing import List from enum import Enum from pydantic import BaseModel, Field class SkillLevel(str, Enum): beginner = "初学" intermediate = "进阶" expert = "精通" class ProjectItem(BaseModel): name: str = Field(description="项目名称,不要带引号") tech_stack: List[str] = Field(description="该项目使用的主要技术,3到6个") is_open_source: bool = Field(description="该项目是否开源,只能是 true 或 false") class ResumeData(BaseModel): name: str = Field(description="候选人姓名") target_position: str = Field(description="求职意向岗位名称") years: float = Field(description="工作年限,按年计算,保留一位小数") skills: List[str] = Field(description="技能标签,至少5个") projects: List[ProjectItem] = Field(description="项目经历,最多3个") level: SkillLevel = Field(description="整体技能等级,只能是枚举值中的一个")有人会问:为什么SkillLevel的枚举值用中文而不用英文?因为模型读到的简历文本大概率是中文,枚举值贴近原文,命中率更高。如果你硬要输出英文,解析成功后自己再映射也行,但没必要让模型在第一步就做额外翻译。
4.2 组装 prompt:不要让格式指令淹没正文
这是很多人会犯的错:格式指令很长,直接把它塞在正文前面,结果模型把注意力全放在“格式说明”上,正文反而被忽略。我试过一个有效做法:正文在前,格式指令放最后,用分隔线隔开,并且明确声明“只输出 JSON 对象,不要代码块”。
from langchain.output_parsers import PydanticOutputParser from langchain_core.prompts import PromptTemplate from langchain_openai import ChatOpenAI model = ChatOpenAI(model="gpt-4o-mini", temperature=0) parser = PydanticOutputParser(pydantic_object=ResumeData) prompt = PromptTemplate( template="""你是简历信息抽取器。请从下面的简历文本中抽取结构化信息。 简历文本: ---------------- {resume_text} ---------------- 要求: 1. 只输出一个 JSON 对象,不要输出任何解释、Markdown 代码块或前后缀文字。 2. 所有字段都必须填充;文本中找不到的值统一填 null。 3. 严格按以下格式说明输出: {format_instructions}""", input_variables=["resume_text"], partial_variables={"format_instructions": parser.get_format_instructions()}, )顺序真的很重要。我踩过把格式指令放最前导致抽取结果全乱的情况,改成现在这个顺序后,失败率肉眼可见地降了。
4.3 调用与解析:自己动手写一段独立解析逻辑
接下来是最爽的部分,LangChain 的链语法把整个流程串起来了:
chain = prompt | model | parser result = chain.invoke({ "resume_text": "张三,5年后端开发经验,精通Python和Go,主导过三个开源项目。" })result就是一个ResumeData实例,你可以直接result.name取字段,也能result.model_dump()转成字典给下游。
如果你处于调试阶段,建议把链路拆开,一段一段看输出:
prompt_value = prompt.invoke({"resume_text": sample_text}) llm_output = model.invoke(prompt_value) print("LLM 原始输出:", llm_output.content) parsed = parser.invoke(llm_output) print("解析后对象:", parsed)这一步能帮你快速区分:到底是模型没听指令,还是解析器约束太严。很多新手报 bug 时说不清是哪段出的问题,就是少了这个拆解习惯。
4.4 跑通后的验证检查单
我每次写完解析链路,会拿至少五条不同风格的样本文本过一遍,重点检查这几项:
- 模型是否把学历、职称这类非简历信息错塞进了
skills。 - 嵌套数组
projects是否被模型平铺成单个对象。 - 枚举
level是否出现“高级”而不是“精通”这类近似词。 - 返回结果里有没有多余的
notes、summary等自发字段。
如果出现上述问题,优先改 description 而不是在解析后强行清洗。模型读不懂字段语义,你清洗得再狠也只是在收拾残局。
5. 重试与自愈:当模型输出不符合 schema,如何优雅补救
5.1 单次修复:RetryOutputParser 的用法
解析器再强,面对模型乱来,一次通过率也不可能是 100%。所以标准的工程姿势是:主链路用 PydanticOutputParser 保底,失败之后再用带重试的解析器兜底。
RetryOutputParser做的事情很简单:拿到第一次失败的输出,把“用户原本的问题 + 模型当前回答 + 解析错误信息”再塞给模型,让它重新输出。它不会替换主解析器,而是作为parse阶段失败后的二次机会。
from langchain.output_parsers import RetryOutputParser base_parser = PydanticOutputParser(pydantic_object=ResumeData) retry_parser = RetryOutputParser(parser=base_parser, llm=model) # 注意这里不能再直接 chain = prompt | model | parser # 需要把原始 prompt 也传进去,便于模型理解上下文 llm_output = model.invoke(prompt_value) recovered = retry_parser.parse_with_prompt(llm_output, prompt_value)老版本 SDK 里还有OutputFixingParser,用法差不多,但只能做“修改错误”,不带上下文重试。如果你还在维护老代码,看到这个名字不慌就行。
5.2 带错误上下文的重试:RetryWithErrorOutputParser 的更强版本
我更推荐RetryWithErrorOutputParser,它会把错误信息原封不动传给模型,类似告诉它:“你刚才这么写不对,具体错在这,请重来。”实测效果比闷头重试好不少,因为模型能定位自己的问题。
from langchain.output_parsers import RetryWithErrorOutputParser retry_parser = RetryWithErrorOutputParser.from_llm( parser=base_parser, llm=model, ) def _retry_parse(raw: str): return retry_parser.parse_with_prompt(raw, prompt_value) chain_with_retry = prompt | model | _retry_parse有两点要提醒:第一,重试会带来额外一次模型调用,意味着成本和延迟上升,需要评估你的并发量;第二,重试不是无限循环,我建议最多重试 1 到 2 次,再多就会无限烧钱。
5.3 为什么我推荐把解析失败当业务异常而非静默处理
接着刚才说的,解析失败别吞异常。我在某业务平台上线初期,图省事给解析包了一层 try-except,失败就返回空字典。结果日志里一片祥和,业务侧却间歇性出现“抽取结果为空”的投诉。后来改成失败即抛异常,再配上重试和告警,问题立刻量化了:某 linter 模板的 prompt 导致模型总是漏years字段,一改 description 就好了。
你可以这样处理:
try: result = chain.invoke({"resume_text": text}) except Exception as e: sentry_logger.error("resume_parse_failed", text=text, error=str(e)) # 走重试链路,或降级人工处理把失败当成信号,而不是噪音,你的系统才会越跑越稳。
6. 易踩的坑清单:从 schema 到调试的具体事项
6.1 嵌套模型的字段描述必须给出
这是新手翻车最集中的地方。对ProjectItem这种嵌套模型,内部字段的Field(description=...)不能省。你不写,模型就只能靠字段名猜,而字段名叫name、status这种通用词时,猜错的概率极高。
我自己见过最典型的:ProjectItem.name没写 description,模型把“项目名称”猜成了人的名字,输出结构瞬间崩坏。写清“项目名称,不要带引号”这种带约束的描述,比在提示词里外加强调有效得多。
6.2 LLM 的枚举会越界:如何让解析前先做额外规范化
聊到枚举,模型对枚举范围的尊重也是概率性的。你定义了SkillLevel只有“初学/进阶/精通”,它可能会输出“高级”,然后解析器直接抛Literal校验错误。
一种做法是放宽枚举的输出约束,在 description 里写明“只能是初学、进阶、精通其中一个词,不要用近义词”。另一种做法是解析前做一层正则归一化:把“高级”“资深”“熟练”统一映射成“精通”。我用得比较多的是后者,因为业务资源本身就想把松散描述归拢到几个层级。
注意:归一化要放在parser.parse之前,放之后已经抛异常了。
6.3 流式输出与长输出场景的注意点
流式输出和严格解析天然有冲突。你不能在每个 token 到达时就去跑 Pydantic 校验,因为一个 JSON 对象在生成到 80% 的时候可能确实是“语法不完整”的。
我的方案是:展示层用流式,落库层用完整输出。也就是说,用户可以看到逐 token 蹦出来的效果,但最终要写入的结构化对象一定来自完整响应的解析。如果你为了省一次请求,想在流式过程中同追校验,大概率会被半截 JSON 折磨到怀疑人生。
长输出场景同理,模型输出超过上下文时容易截断,截断后 JSON 必然不完整。与其在解析层死磕,不如把任务拆小,或者要求模型优先输出结构化字段,解释部分放到另一个非强制字段里。
6.4 调试技巧:把 format_instructions 单独打印出来看
这个习惯我强烈安利。遇到解析问题,先执行这段代码:
print(parser.get_format_instructions())你会发现很多困惑迎刃而解。比如模型老是漏字段,看一眼就知道是required列表没覆盖全;模型枚举老是出怪词,一眼就知道描述里没给足可选值。
再配合一次“无解析器”的裸调用,对比模型听指令前后的差异,基本十分钟内能定位到问题是出在提示词顺序、字段描述,还是解析器配置上。这套流程我已经不知道用了多少次了。
7. 我最终沉淀下来的取舍原则:解析器不是万能胶
聊了这么多,得说说反方向。有些场景真的不适合硬上输出解析器。
一是自由文本生成,比如写摘要、写文案、写回复,这类任务的核心价值就在于表达自然,强行套 JSON 反而会让模型发挥受限。二是对话流式场景,逐 token 展示时追求严格 schema 体验极差,我宁可用最终轮解析。三是你对输出结构容忍度极高、下游又没人消费结构化字段时,别为解析而解析,那只是给自己增加延迟和失败点。
我现在的通用判断就一句话:只要下游要落库、要计算、要对接其他系统,就用输出解析器把结果做硬;只要下游只是展示给人看,就直接用模型原始输出。
另外补充一个小技巧:如果模型本身支持结构化输出或函数调用能力,优先用官方原生机制,LangChain 解析器作为保底。我实测过,原生机制一般在格式命中率上更高,但灵活性较弱,两套配合能覆盖绝大多数业务。
说回解析器本身,它真正解决的是让 LLM 从“好玩”变成“可用”的最后一公里。模型永远会犯错,工程的价值不是消除错误,而是让错误可被发现、可被修复、可被重试。希望你读完这篇,能做到下一次模型再“胡言乱语”时,不是一拍脑袋改提示词,而是有一套完整链路稳稳接住它。