1. 为什么大模型需要外挂一个“解析器”
1.1 自由文本好读,程序不好用的根本原因
做 LLM 应用的人,迟早会撞上同一个问题:模型输出的内容“人看着没问题,代码一跑就报错”。这不是模型笨,而是模型本身的工作方式决定的。
大模型的生成过程,本质上是在做“token 的概率采样”。模型会基于你的输入,一个字一个字地“猜”接下来最可能出现的字符组合。这意味着它给你的回复,天然是一段自由文本,而不是一份符合某个数据结构的 JSON、XML 或是强类型的 Python 对象。
举一个最典型的场景:你想让模型从一段简历文本里抽取“姓名、工作年限、技能列表”,大多数人第一版会这样写:
请从以下简历中提取信息: 张三,5年后端经验,精通Python、Django、Redis 输出格式: 姓名: 工作年限: 技能:模型确实会给你一个看起来很像样的回答:
姓名:张三 工作年限:5年 技能:Python、Django、Redis但问题马上就来了:你的下游代码是拿这段文本去“拆字段”的。你可能得写正则匹配“姓名:”和“工作年限:”后面的内容,还要对技能列表做中文逗号、英文逗号的清洗。这还没完——下一次换一个提示词版本,模型突然给你输出成 JSON 了;或者字段顺序变了;或者它在末尾多加了一行“希望以上信息能帮到你”。当字段一多、逻辑一变,这套硬编码的解析代码就会迅速腐烂。
这就是 LangChain 输出解析器(Output parsers)要解决的核心矛盾:模型说人话,但程序只想听数据。
我当时第一次意识到这个问题的严重性,是在做一个批量信息抽取的小工具。某天模型突然在“工作年限”后面输出了“5年(含1年实习)”,我的正则一眼就匹配不上,整个任务直接中断。从那一刻起我就决定:凡是需要程序继续消费的模型输出,一律走解析器。
1.2 输出解析器到底在链路里干了哪几件事
LangChain 的输出解析器不是某个单一的函数,而是一套“前后夹击”的机制。它通常做两件事:
第一,在请求前,把“输出格式说明”注入到提示词里。你只需要定义一个结构(比如 JSON schema 或字段列表),解析器会自动生成一段模型能看懂的人类语言格式说明,拼接到你的 Prompt 里。这一步非常关键,因为它相当于提前“打招呼”:模型,你最后只输出这个结构,不要多余内容。
第二,在响应后,把模型的文本输出转换成目标结构。这一步可能是纯字符串清理,也可能是调json.loads,更可能是实例化一个 Pydantic 模型并做字段校验。转换失败时,解析器会抛出异常,让你的程序有能力感知“这次抽取结果不可信”。
配合 LangChain 的 LCEL 写法,一个最简单的解析链路长这样:
from langchain_core.output_parsers import JsonOutputParser from langchain_core.prompts import PromptTemplate prompt = PromptTemplate.from_template( "请从下面的文本中抽取字段,只输出 JSON。\n文本:{text}\n{format_instructions}" ) parser = JsonOutputParser() # llm 是你已经初始化好的模型对象,不同服务商的写法由你自己封装 chain = prompt | llm | parser result = chain.invoke({"text": "张三,5年后端经验"}) print(result)你看,在链路里,parser同时出现在两处位置:它的get_format_instructions()方法会把格式说明注入PromptTemplate,它的parse()方法会处理模型返回的原始文本。理解这一点,你就不会再把“提示词写格式”这件事和“解析器”看成两件无关的事。
1.3 从“人肉解析”到“自动校验”,差的不是一个函数
很多人觉得,只要模型输出 JSON,用json.loads不就完了?何必多学一个解析器?
实际做过的朋友会告诉你:模型输出的“JSON”,和标准 JSON 之间,经常隔着十万八千里。
我列几个真实遇到过的“模型输出垃圾”:
- 模型在 JSON 外面包了 ```json 代码块标记
- 字段名从
work_years漂移成work years、工作年限 - 布尔值输出成字符串
"true"而不是true - 该输出数组的地方,模型给了个以顿号分隔的字符串
这时候如果只用json.loads,你会得到一行相当不友好的报错;如果是用 Pydantic 解析器,异常信息会明确告诉你:哪个字段缺失、哪个字段类型不对、哪个枚举值超出允许范围。
所以我的建议很直接:只要你的下游代码需要访问“字段”,就不该让模型输出裸文本,也不该手动拼 JSON。解析器的价值在于把这套从“文本”到“对象”的过程标准化、可观测、可修复。
2. 常用解析器的适用边界:选错才是最大的坑
2.1 StrOutputParser:不是所有输出都要结构化
先泼一盆冷水:并不是每个场景都必须用 JSON 或 Pydantic。
LangChain 里最基础的输出解析器是StrOutputParser,它做的事情极其简单——把模型输出的内容再“洗”一下,去掉多余的前后缀包装,最终返回一个干净的字符串。
你可能会问:这有什么用?模型不是已经能返回字符串了吗?
用处在于:当你用chain | parser这样的 LCEL 链路组织代码时,链路最后一环需要一个规范化的输出类型。如果链路中间有 RAG 检索、有多步对话,最后直接把原始文本交给用户,那么StrOutputParser可以保证链路端到端都遵循“每一步输入输出都是可预期类型”的工程习惯。
适用场景也很明确:
- 摘要、翻译、改写这类只需要文本内容的任务
- 让模型直接输出 Markdown / CSV 文本,你后续用别的方式处理
- 对话型 Agent 的最后一步,需要回传一段自然语言给用户
我在做聊天机器人时,几乎每个“自由回复”分支的末尾都挂了一个StrOutputParser。它帮我挡掉过不少“模型在正文前后多加冒号、加解释性前言”的脏数据。
不过也要注意:如果你只是.invoke()一次,不写链,那确实不必强行加它。它的意义在于让链路协议统一,而不是给你变魔术。
2.2 JsonOutputParser 与 PydanticOutputParser:从“尽量能用”到“必须规范”
这两个是出场率最高的解析器,也是大家最容易搞混的一对。
JsonOutputParser的用法很轻:你只需要在提示词里告诉模型“输出 JSON”,它就会尝试把模型返回的内容解析成 Python 字典。它不强制任何字段,模型给什么键,你就拿到什么键。如果模型给的不是合法 JSON,解析会失败并抛异常。
PydanticOutputParser则重得多。你先定义一个 Pydantic 模型:
from pydantic import BaseModel, Field class ArticleInfo(BaseModel): title: str = Field(description="文章标题") category: str = Field(description="文章分类") tags: list[str] = Field(description="标签列表,至少3个")解析器拿到这个模型后,会自动生成一段格式说明,塞到提示词里,强制模型按这个 schema 输出。收到模型文本后,它再用 Pydantic 做完整校验,任何字段缺失、类型不符都会在这里被拦下来。
选择逻辑其实很朴素:
- 如果你只是“偶尔看下某个值”,对字段完整性不敏感,用
JsonOutputParser就够 - 如果你要做数据入库、下游计算、多步流转,建议直接用
PydanticOutputParser
同一份数据,两种解析器在失败时的表现也完全不同。JsonOutputParser失败时往往只告诉你“JSON 解析失败”,但到底是哪个字符出问题,你得自己盯。PydanticOutputParser的报错则会把具体字段和期望类型摆到你面前。
用一句我常放在心里的话来记:Json 是“能转就行”,Pydantic 是“必须合规”。
2.3 StructuredOutputParser 与 CommaSeparatedListOutputParser:老将仍有出场机会
StructuredOutputParser是 Pydantic 解析器还没火的时候常见的方案。它通过一系列的ResponseSchema来描述字段,每个字段有名字、类型、描述。解析器会把这个 schema 转成带编号的格式说明,引导模型逐个字段输出,最终生成一个字典。
它的优点是简单直观,不需要定义 Pydantic 类;缺点是只适合扁平字段,复杂嵌套结构写起来非常别扭。我一般只在“临时脚本”或“字段很少、只需要快速跑通”的时候用。
说到CommaSeparatedListOutputParser,它的作用就是让模型输出“逗号分隔的列表”,解析后得到一个 Python list。听起来很好用,但这里藏了个大坑:如果列表项本身包含逗号(比如“Python, Django”这种带英文逗号的技术名),解析就会错乱。模型输出的["Python, Django", "Redis"]会被错误拆成三项。
所以这类解析器我只推荐在输入内容可控的情况下使用。凡是列表项里可能出现标点符号的,一律换JsonOutputParser或 Pydantic 方案。
2.4 一种对号入座的选型表
为了方便决策,我把常用场景、推荐解析器、兜底方式整理成一张表:
| 场景 | 推荐解析器 | 失败时的表现 | 兜底建议 |
|---|---|---|---|
| 摘要、翻译、改写 | StrOutputParser | 几乎不失败 | 无需兜底 |
| 只要一个 JSON 字典 | JsonOutputParser | 抛 JSONDecodeError | 接 OutputFixingParser |
| 入库/下游计算/强类型校验 | PydanticOutputParser | 抛 ValidationError,含字段信息 | 接 RetryOutputParser,重试一次 |
| 少量扁平字段的快速脚本 | StructuredOutputParser | 字段格式不匹配时抛异常 | 提示词里写清编号规范 |
| 逗号分隔的简单列表 | CommaSeparatedListOutputParser | 列表项含逗号时错乱 | 列表项内容可控时使用 |
把这张表打印出来贴在工位上都不夸张。因为我在实际项目里见过太多“选错解析器导致解析率暴跌”的案例,这些人无一例外都是没想清楚自己要的是“格式”还是“规范”。
3. 手把手:从自由文本到强类型结构,完整跑通一条解析链路
3.1 一个带“必填、选填、枚举”的实战数据结构
光说不练假把式。这一章我拿一个常见需求来演示:让模型把一段技术文章的摘要整理成结构化元信息,包括标题、摘要、关键词列表、语气风格、预估阅读时长(可选字段)。
为什么选这个场景?因为它同时覆盖了:
- 字符串字段(标题、摘要)
- 列表字段(关键词)
- 枚举字段(语气风格)
- 可选数值字段(预估阅读分钟数)
用 Pydantic 定义如下:
from typing import Literal, Optional from pydantic import BaseModel, Field class ArticleMeta(BaseModel): title: str = Field(description="文章标题") summary: str = Field(description="不超过50字的摘要") keywords: list[str] = Field(description="3-5个关键词,按重要性排序") tone: Literal["formal", "casual", "academic"] = Field( description="文章语气,只允许三种取值:formal, casual, academic" ) read_minutes: Optional[int] = Field( default=None, description="预估阅读分钟数,给不出就省略" )这里有个细节值得重点说明:每个字段的 description 不是只给你自己看的,它会被 PydanticOutputParser 拼进提示词,成为模型生成时的重要约束。
特别是tone字段,我用Literal["formal", "casual", "academic"]来限定取值,相当于把枚举约束直接写进 schema。模型如果输出一个“literary”或者“文艺”,校验直接失败。
3.2 组装提示词:格式指令必须进 Prompt
接下来的核心操作,是把解析器的get_format_instructions()结果与你的业务提示词合并。
from langchain_core.output_parsers import PydanticOutputParser from langchain_core.prompts import PromptTemplate parser = PydanticOutputParser(pydantic_object=ArticleMeta) prompt = PromptTemplate.from_template( """你是一个专业的文章编辑。根据给定文章内容,生成结构化元信息。 文章内容: {article} 请严格按照以下格式输出: {format_instructions} """ ) # 关键步骤:把格式说明提前绑定进 Prompt prompt_with_format = prompt.partial(format_instructions=parser.get_format_instructions()) chain = prompt_with_format | llm | parser result = chain.invoke({"article": "我们在大规模分布式系统里用了分层缓存……"}) print(result)运行后,result会是一个ArticleMeta实例,不是字符串,也不是字典。你可以直接访问result.title、result.keywords,非常符合 Python 的直觉。
如果你更喜欢字典形式,可以调用result.model_dump()转成普通 dict,方便序列化入库。
整个过程你可以理解成三步:先定义 schema,再把格式说明塞给模型,最后用同一个 schema 去校验输出。三步里任何一步脱节,都会导致解析失败。
3.3 加上自动修复机制:解析失败不再需要人工救火
有一次我在本地跑一批 200 篇文章的元信息抽取,第一轮跑完,失败率大概在 8%。失败的报错大多是“json 里多了一个字段”或者“关键词数组少了个右括号”。这种问题如果靠人工去改提示词、重跑,不仅慢,还很容易把成功的样本也带偏。
LangChain 提供了两个“后悔药”型解析器:OutputFixingParser和RetryOutputParser。
OutputFixingParser的思路是:当首次解析失败时,把“错误的原始输出 + 报错信息”一起交给模型,让模型“修补”出一个符合要求的结果。
from langchain.output_parsers.fix import OutputFixingParser fixing_parser = OutputFixingParser.from_llm( parser=parser, llm=llm, ) chain = prompt_with_format | llm | fixing_parser result = chain.invoke({"article": "……"})它的实现方式相当直接:把原始输出和异常提示拼成一个修复 Prompt,交给同一个模型再跑一次。大部分场景下,这样一次修复就能把分数拉回 99% 以上。
RetryOutputParser则略有不同,它不是简单让模型“重新解析一次文本”,而是把原始 Prompt 和第一次的错误输出一起返回,让模型重新生成完整答案。如果你的失败原因是“模型第一次就理解错了字段要求”,用 Retry 的效果更好;如果只是“格式写歪了”,Fixing 更省 token。
这一套组合几乎是生产环境“结构化输出”的标准配置:定义 schema → 注入格式说明 → 解析 → 失败修复 → 二次校验。
4. 排错实录:解析失败的四种典型报错与修复链路
4.1 类型对不上:字符串里混进了不该有的引号
现象:Pydantic 报ValueError,字段summary需要的类型是str,但模型给了一段带引号的字符串。比如:
{ "summary": "\"我们提出了一个基于分层缓存的新方案\"" }结果校验时summary的值就是"\"我们提出了……\"",多了一对转义引号。
排查链路:我先看的是原始模型输出,确认 JSON 本身合法,Pydantic 也没报“字段缺失”,那问题就出在“值的内容不符合业务期望”。再往提示词里看去,发现业务提示词里写了“摘要请用双引号括起来”——模型忠实照做,于是把引号也当成了内容的一部分。
修复方式:删掉提示词里“加引号”这句多余指令。同时我给summary的 description 补了一句“直接输出正文内容,不要添加任何引号或包裹符号”。
这类问题很隐蔽,因为它不触发语法层错误,只触发业务层校验。唯一的排查方法就是把模型原始输出完整打出来看,确认不是解析器背锅。
4.2 枚举值漂移:模型“编造”了 schema 之外的属性
现象:tone字段只允许formal、casual、academic三选一,模型却输出"literary"。Pydantic 直接报 Literal 校验失败。
排查链路:查看模型原始输出,发现模型并没有不听话,而是它在输出里加了一个额外字段"style": "literary",而tone字段本身给的是没有问题的"formal"。
这里真正的根因是:模型从文章内容里推断出了一个“风格”概念,但我们的 schema 里没有这个字段。它在信息不确定时选择“多给一点”,这在自由文本任务里是合理行为,但在强类型场景下就是灾难。
修复方式:一类方案是给模型更明确的边界指令。我在PromptTemplate里加了一句“只允许输出 schema 中出现的字段,不要添加任何额外字段”。另一类方案是升级解析器,让 Pydantic 配置extra="forbid",让多余字段直接报错并触发 Fixing。
class ArticleMeta(BaseModel): model_config = {"extra": "forbid"} title: str = Field(description="文章标题") # ... 其余字段这样的好处是:不是“悄悄忽略”模型夹带私货,而是主动把它标记为异常,进入修复链路。
4.3 中文标点:全角冒号与 JSON 解析的相爱相杀
现象:模型直接输出:
{"title": "分布式缓存优化「实战」", "summary": "本文提出了一种新方案:分层缓存。"}看起来很正常,但json.loads报错。仔细一看,问题不是中文内容,而是模型在 JSON 键名之间混入了全角冒号“:”而不是半角“:”,或者用了中文引号。
排查链路:这种情况往往是“模型服务商”那边的评测差异。某些中文能力很强的模型,在切换上下文时会不自觉地用中文标点去写 JSON。你不一定能稳定复现,但一旦出现,整批数据就会突然失败。
修复方式:我常用的手段是给解析链路加一个“预处理清洗”步骤。在JsonOutputParser之前,先对模型输出文本做一次暴力替换:
import re def clean_model_output(text: str) -> str: # 去掉代码块包裹 text = re.sub(r"^```(?:json)?|```$", "", text.strip()) # 把全角冒号替换为半角,双引号同理 text = text.replace(":", ":").replace("“", '"').replace("”", '"') return text注意:这个清洗只处理“格式符号”,绝对不能动中文字符内容。我在项目里还会把清洗前后的文本一起写进日志,便于回溯。
如果你用的是PydanticOutputParser,它内部已经做了一定程度的容错,但面对中文标点时依然可能翻车。自己加一道清洗,成本低,收益稳定。
4.4 长文本被截断:残缺 JSON 是最难修的坑
现象:模型输出到一半被截断,JSON 少了一个右括号,甚至"keywords"字段只有部分元素。报错信息是Expecting ',' delimiter或直接Invalid \escape。
排查链路:这类问题不能把锅全甩给模型。我先排查的是推理参数:max_tokens设得是不是太短?模型需要输出的内容比 token 限制还长,必然截断。再排查输入:如果给模型的文章本身已经接近上下文窗口上限,模型会“压缩”输出空间,更容易截断。
修复方式:
- 调大生成参数,给足输出 token 额度
- 在提示词里明确“只输出 JSON,不要任何结束语”,防止模型把 token 浪费在废话上
- 把“长文章”切块处理,每个块只抽取一部分字段,再合并结果
有一条经验值得单独拿出来强调:如果你发现截断经常发生在数组最后一个元素之后,多半不是运气问题,而是输出长度预算确实不够。这时候不要硬调解析器,而是从模型参数和输入长度上动手。
5. 进阶必知:成本、兜底策略与性能权衡
5.1 修复型解析器的隐形成本:每次失败都可能多花一次模型调用
很多人把OutputFixingParser当成“万能胶水”,觉得只要挂上它,解析就不会失败。这句话有一半是对的,但它忽略了一个关键问题:修复意味着额外一次模型调用,也就是额外的时间和费用。
我做过一个粗略统计:假设你的基础解析成功率是 92%,每次失败都要调一次修复,那么平均 100 次请求里会有 8 次出现二次调用。如果你本来每百万次 token 的费用是 X,那么实际开销是100 次文本生成 + 8 次修复生成,并且修复 Prompt 比原始生成通常要长(因为它包含原始输出和报错信息)。
更可怕的是“链条拉长后的延迟”:原本 2 秒出结果,遇到需要修复时会变成 4 秒甚至更久。在实时交互场景里,这种延迟抖动很影响体验。
所以我的建议是分层次处理:
- 如果解析成功率高于 98%,挂修复解析器完全没问题
- 如果成功率低于 90%,说明你的提示词和 schema 设计有根本问题,先优化 Prompt 和字段描述,而不是一直加修复器
- 修复器不是“免费保险”,它是最后的兜底,不应该承担让垃圾 Prompt 起死回生的任务
5.2 在 Agent 流程里,解析器失败时要给“下一步”而不是干瞪眼
Agent 场景和单次链路有个显著区别:Agent 有循环、有工具调用、有状态。解析器一旦失败,你不可能单纯抛个异常让用户重试,因为 Agent 的上一步可能已经产生了副作用(比如调了一个写入接口)。
我在做一个带工具的客服 Agent 时,给所有“结构化输出步骤”都配了三条退路:
第一条:重试一次。直接调RetryOutputParser,让模型基于原问题重新生成一次。
第二条:降级为默认值。如果重试仍失败,就把关键字段填上默认值,比如{"status": "unknown", "need_human": true},把异常信息连同原始输出一起记录到日志。
第三条:交给人工/上游。如果业务不允许默认值,那么宁可返回“无法理解用户意图”,也不要硬编一个可能误导后续流程的结果。
具体代码可以是:
import logging try: result = chain.invoke({"input": user_input}) except Exception as e: logging.error(f"parsing failed: {e}, raw: {llm_output}") result = {"status": "unknown", "need_human": True}在 Agent 里,可观测性比一次解析成功更重要。你至少要知道是哪一步失败、原始输出长什么样,否则后续排查基本靠猜。
5.3 有些场景真的不需要解析器:别把每个输出都塞进 Schema
我见过最“用力过猛”的用法,是给一个“让模型写一段朋友圈文案”的功能强行套上PydanticOutputParser,要求字段必须包含{text: str, emoji_count: int, tone: str},结果模型为了凑字段,输出变得又僵又长。
这是一个典型的反模式。当你的用户最终消费的是“文本本身”时,用解析器只会降低生成质量和增加失败率。
以下场景我强烈建议不要用结构化解析器:
- 闲聊、情感分析前的前置自由回复
- 需要模型给出开放式建议、创意的任务
- 输出本身就是一段 markdown / HTML,你后续直接渲染
当然,如果这些场景中间环节需要“抽取词条”或者“判断意图类别”,可以拆成两个步骤:先用一个 StrOutputParser 拿自由文本,再在特定节点接一个窄范围的解析器。
6. 我的选型心得与三个顺手小技巧
6.1 按照项目阶段来决定“解析器的重量”
做技术方案有一个很重要的思路:不要一步到位,要让每一步都“恰好够用”。
原型验证阶段,我的建议是只上JsonOutputParser或者干脆在提示词里写“输出 JSON”,先用最简单的方式跑通业务逻辑。这个阶段的核心目标是验证“模型能不能完成这个任务”,而不是验证数据格式有多严格。你用PydanticOutputParser当然也行,但一旦 schema 定义不合理,调起来会拖慢迭代速度。
进入生产化阶段,再把PydanticOutputParser、OutputFixingParser、日志监控这一套完整铺开。这时候你的字段已经稳定,值得把类型错误、缺失字段等所有边界都严丝合缝地管起来。
一句话总结:原型期求快,生产期求稳,解析器的“重量”跟随阶段变化而变化。
6.2 三个小技巧,能让解析链路更顺手
第一个技巧:用prompt.partial预绑定格式说明。如果你在一个循环里多次调用同一个 Prompt,每次都去拼接format_instructions会造成不必要的重复。提前用partial绑定一次,后面只需要传业务变量就行。
第二个技巧:优先尝试模型自带的with_structured_output。现在不少大模型服务商已经支持结构化的 JSON 模式 / 函数调用,LangChain 里对应的接口是llm.with_structured_output(ArticleMeta)。它能把 schema 直接翻译成模型服务商支持的格式,由模型端保证结构化返回,成功率往往比“纯文本 + 提示词”更高。解析器这时变成了最后一道保险,而不是唯一的保险。
第三个技巧:记录所有解析失败的原始输出。我一般会在except里把raw_output、prompt、error_message三个字段一起写入日志或数据库。这些失败样本非常值钱,既能用来分析失败模式,也能在后续做小样本微调时作为负例。
6.3 最后一点忠告:解析器不是银弹
写到这里,还是想说点实在的。我在很长一段时间里把解析器当成“模型不听话的终极解药”,只要解析失败,就往上堆修复器、重试器,甚至自己写一堆 if-else 去给 JSON 打补丁。
后来我想明白了一件事:解析器只能解决“格式”问题,不能解决“语义”问题。如果模型压根没理解你要抽取什么,你给它再严格的 schema,它也只会生成一堆看起来合规但内容全错的字段。这时候你该做的是检查 Prompt 的表达是否清晰、字段描述是否充分、示例是否到位,而不是执着于解析器本身。
按我现在的习惯,每次解析失败率高企,我会按下面的顺序排查:
- 看原始输出里是否存在语义偏差
- 看 schema 字段描述和枚举说明是否足够清晰
- 看提示词里是否存在歧义指令
- 最后才检查解析器的配置和清洗逻辑
把这张检查清单放在手边,你大概率能少走很多弯路。输出解析器是 LangChain 里“小但关键”的零件,它不能保证模型永远正确,但能保证在模型出错时,你的程序不会稀里糊涂地把错误数据当成真理写进数据库。