如果你在 LangChain 里写过几次业务链,大概率会有同样的体会:大模型噼里啪啦返回了特别漂亮的一段话,但你的下游程序根本不知道该从哪里取字段。输出解析器(Output parsers)就是专门解决这个问题的核心组件。它把模型输出的自由文本按你定义的规则拆解成 Python 对象,让后续的校验、存储、展示都有了明确依据,而不是靠正则表达式满屏幕捞数据。
这篇文章我会把 LangChain 输出解析器的常见类型、真实使用场景、手写解析器的姿势以及我在项目里踩过的坑一次讲清楚。适合已经跑通基础 LLM 调用、正准备把输出接入业务系统的开发者,也适合打算从零开始搭建结构化数据管道的朋友。我会尽量用白话拆解原理,并给出能直接抄作业的代码。
1. 解析器是 LangChain 里最容易被低估的组件
1.1 模型输出为什么不能直接用
很多刚接触大模型应用的朋友都有个困惑:模型返回的明明是一段文字,我直接拿去用不就行了吗?实际做项目就会发现,直接用的代价非常大。
大模型本质是一个概率语言引擎,它生成的内容天然是自由文本。你说“请返回一个 JSON”,它可能返回标准 JSON,也可能在 JSON 外面套一对三引号,甚至开头带一句“好的,以下是你要的数据”。这类额外信息对人类阅读没影响,但后端程序拿到这种字符串后,轻则json.loads报错,重则字段被悄悄改成了字符串类型。你明明要一个整数year,它返回"2024",业务逻辑跑着跑着就出错了。
另外,模型的输出长度、语气、换行位置都是随机的。没有统一的格式约束,你就得针对每一种异常情况写正则,这本质上是在用“字符串匹配”对抗“概率生成”,永远修不完。
所以我们需要一个中间层:让模型输出有格式规范、有类型约束,再通过一个专门的解析器把字符串转换成强类型的 Python 数据结构。这就是 LangChain 输出解析器的价值。
1.2 解析器在链里承担什么角色
LangChain 的链式调用本质上是一个管道:prompt | model | parser。Prompt 负责告诉模型“我要什么”,模型负责“生成候选内容”,Parser 负责“把内容转成程序能直接消费的对象”。
你可以把解析器理解成翻译官。模型说的是“自然语言+偶尔夹带私货”,程序听的是“结构化数据”。解析器把这两者之间的缝隙填上了。更关键的是,解析器不只是一个事后处理的工具,它会反过来影响 prompt 的生成。在 LangChain 里,大多数解析器都实现了get_format_instructions()方法,这个方法会在拼 prompt 时自动注入一段“请严格按照以下格式输出”的指令。也就是说,解析器从上游就干预了模型的输出习惯,而不是等它乱说之后再费力纠正。
如果你使用过 LangChain 的ChatPromptTemplate,你会发现PromptTemplate里可以挂partial_variables,其中就包含解析器的格式指令。这形成了一个闭环:解析器告诉 prompt 怎么约束模型,模型生成的字符串再交回解析器校验。这也是我推荐把解析器当成链结构的“一等公民”而不是事后补丁的原因。
2. 内置解析器怎么选:先看清各自的脾气
LangChain 内置的解析器非常多,但核心思路只有几类。我整理了一张表,基本能覆盖大多数选择困惑。
| 解析器 | 输出结构 | 典型场景 | 复杂度 |
|---|---|---|---|
StrOutputParser | 纯字符串 | 摘要、翻译、聊天回复透传 | 极低 |
JsonOutputParser | Python dict(依赖模型输出 JSON) | 任意可 JSON 化的结构化数据 | 低 |
StructuredOutputParser | dict | 快速做多字段提取,不引入 Pydantic | 中 |
PydanticOutputParser | Pydantic 模型实例 | 强类型字段、嵌套数据、需要联动校验 | 中高 |
CommaSeparatedListOutputParser | list | 关键词、标签列表 | 低 |
DatetimeOutputParser | datetime 对象 | 日期时间抽取 | 低 |
EnumOutputParser | 枚举成员 | 分类、状态机 | 低 |
OutputFixingParser | 与内部解析器一致 | 解析失败时让模型自动修正 | 中 |
RetryOutputParser | 与内部解析器一致 | 解析失败时重新生成 | 中高 |
2.1 入门级:StringOutputParser 与简单文本透传
StrOutputParser可以说是最基础的解析器,但它恰恰是新手很容易错过的。它不改变模型的输出,只是把模型返回的AIMessage内容变成纯字符串。在 LangChain 的版本迭代中,model.invoke()返回的是消息对象而不是字符串,如果你直接把这个对象丢给下游函数,很容易出现属性错误。
from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_template("请用一句话说明什么是{concept}") chain = prompt | chat_model | StrOutputParser() result = chain.invoke({"concept": "递归"})这里result就是一个干净的字符串,直接打印、存储、拼接到文档里都没问题。别小看这一步,很多初学项目都会在这里卡住,因为模型返回的对象结构是content + additional_kwargs,直接拿去print还可以,一旦做字符串格式化就露馅了。
2.2 结构化输出:JSON、Structured、Pydantic 三兄弟怎么取舍
这一组是实际项目里出现频率最高的。
JsonOutputParser要求模型输出 JSON 字符串,然后用json.loads解析成 dict。它本身不做类型约束,适合你已经明确告诉模型“必须返回以下字段”的场景。
StructuredOutputParser是更老牌但依然有用的工具。它通过ResponseSchema定义多个字段,然后自动生成一段非常详细的格式说明。它的优点是不用额外定义一个 Pydantic 类,只用几个ResponseSchema就能快速生成多字段解析逻辑。缺点是字段校验能力较弱,返回的也只是普通 dict,没有自动类型转换。
PydanticOutputParser是目前我推荐的主力选手。你可以定义一个继承BaseModel的类,字段类型、默认值、描述都写在类里。解析成功时直接得到一个模型实例,访问属性就像操作对象一样,类型不对会直接报错而不是等到后面才炸。尤其适合嵌套结构,比如订单信息里包含用户信息和商品列表。
三者的选择逻辑其实很简单:如果只是临时提取两三个字段,用StructuredOutputParser;如果需要稳定对接业务对象、做类型校验,直接用PydanticOutputParser;如果你不想依赖 Pydantic 或者只需要原样 JSON,JsonOutputParser最轻量。
2.3 场景型:逗号分隔、日期、枚举
这几个解析器属于“小而美”的类型,用对地方能省很多事。
CommaSeparatedListOutputParser会把模型返回的“苹果, 香蕉, 橙子”变成 Python 列表。注意它会按逗号拆分,所以提示词里一定要强调使用英文逗号,否则中文逗号会导致拆出脏数据。
DatetimeOutputParser能把模型返回的日期字符串解析成datetime.datetime对象。模型经常会输出“2024年5月6日”这类人类友好文本,用这个解析器能省去一堆datetime.strptime的格式摸索。但它对格式比较严格,建议在 prompt 里明确要求输出 ISO 格式。
EnumOutputParser会把输出解析成你预设的枚举成员,适合做情感分类、状态判断。比如把评论划分为“正向、负向、中性”,利用枚举避免魔法字符串散布在代码里。
2.4 容错型:OutputFixingParser 与 RetryOutputParser
模型不可能永远按规矩输出。一旦解析失败,你有两条路可以走。
OutputFixingParser的思路是:用一个小模型(通常同一个模型)去“修正”格式错误。它会把原始输出和解析异常一起包装成一个新 prompt,让模型重新输出一份符合要求的版本。适合处理“JSON 多了一个逗号”、“字段名拼错”这类小瑕疵。缺点是会额外消耗一次模型调用,而且如果模型本身已经理解错需求,修正也无济于事。
RetryOutputParser则是把原始 prompt、错误信息和失败输出一起回传给模型,让模型重新生成。与OutputFixingParser不同,RetryOutputParser会连带重跑生成过程,而不是只修格式。适合“模型回答内容方向不对”或“字段缺失严重”的场景。
我通常在链外再包一层重试逻辑,设置最多重试 2 次,然后在日志里记录失败时的原始输出。这样既能防抖,又能在反复失败时定位到 prompt 设计问题。
3. 动手实现:从模型返回字符串到干净数据的完整链路
3.1 搭一个最基础的处理链
在具体写解析之前,先把环境准备好。我假设你已经安装了 LangChain 生态的核心包并配置好了一个chat_model对象(具体用哪种平台接入不重要,关键是你能用它完成对话调用)。
from langchain_core.output_parsers import StrOutputParser from langchain_core.prompts import ChatPromptTemplate prompt = ChatPromptTemplate.from_template( "你是数据提取助手。请从以下内容中提取关键词列表,用逗号分隔。\n内容: {text}" ) parser = CommaSeparatedListOutputParser() chain = prompt | chat_model | parser result = chain.invoke({"text": "LangChain在处理大模型应用时非常实用"}) print(result)注意我这里为了演示用了CommaSeparatedListOutputParser,它会把“关键词列表”转换成 list。如果你希望拿到一个字符串,就换成StrOutputParser。这个例子可以帮你直观地感受prompt | model | parser的管道模式。
3.2 路线一:用 PydanticOutputParser 拿到强类型对象
我日常最常用的组合是PydanticOutputParser。假设我正在做一个“电影评论信息抽取”的功能,需要从一段影评里提取电影名、评分和一句话总结。
from langchain_core.output_parsers import PydanticOutputParser from langchain_core.prompts import ChatPromptTemplate from pydantic import BaseModel, Field class MovieReview(BaseModel): title: str = Field(description="电影名称") score: float = Field(description="评分,0到10之间") summary: str = Field(description="一句话总结") parser = PydanticOutputParser(pydantic_object=MovieReview) prompt = ChatPromptTemplate.from_template( "请从用户的评论中提取信息。\n评论: {review}\n{format_instructions}" ).partial(format_instructions=parser.get_format_instructions()) chain = prompt | chat_model | parser review_text = "昨天看了《星际穿越》,特效震撼,剧情也感人,我给9分!" result = chain.invoke({"review": review_text}) print(result.title, result.score, result.summary)关键点在于get_format_instructions(),它会生成类似“你的输出必须符合以下 JSONSchema”的指令,并放进 prompt 里。模型看到这份格式说明后,输出的 JSON 结构基本就不会跑偏。而PydanticOutputParser会在拿到 JSON 后做字段校验和类型转换,score会变成float而不是字符串。
如果你希望字段缺失时不立即报错,可以在定义 Pydantic 模型时给字段默认值,或者使用Optional[...]。但要注意,模型一旦偷懒不填充必填字段,你宁可让它报错也别默默吞掉,否则下游数据缺陷很难发现。
3.3 路线二:用 StructuredOutputParser 快速生成格式说明
如果你不想定义一个 Pydantic 类,或者只是做一次性数据抽取,StructuredOutputParser能更快上手。
from langchain.output_parsers import StructuredOutputParser, ResponseSchema from langchain_core.prompts import ChatPromptTemplate response_schemas = [ ResponseSchema(name="name", description="用户姓名"), ResponseSchema(name="age", description="用户年龄,整数"), ] parser = StructuredOutputParser.from_response_schemas(response_schemas) prompt = ChatPromptTemplate.from_template( "提取用户信息。\n输入: {input}\n{format_instructions}" ).partial(format_instructions=parser.get_format_instructions()) chain = prompt | chat_model | parser data = chain.invoke({"input": "张三今年28岁"}) print(data["name"], data["age"])这里返回的data是 dict,键是你在ResponseSchema里定义的名字。我建议把age的 schema 描述写清楚,模型才会输出纯数字。不过StructuredOutputParser本身不会把"28"转成整数,类型转换还得自己做。所以如果你有强类型要求,直接上 Pydantic 更稳。
3.4 路线三:写一个自定义解析器完全掌控规则
内置解析器覆盖了绝大多数需求,但总有特殊情况。比如你要解析一种内部配置文件格式,或者模型输出的内容包含多个以KEY: VALUE排列的字段且带注释,这时候自定义一个解析器才是正解。
在 LangChain 里自定义解析器很简单,继承BaseOutputParser,实现parse方法,再补一个get_format_instructions方法即可。
from langchain_core.output_parsers import BaseOutputParser class KeyValueParser(BaseOutputParser[dict]): """解析形如 key: value 的多行文本,忽略以 # 开头的注释。""" def parse(self, text: str) -> dict: result = {} for line in text.splitlines(): line = line.strip() if not line or line.startswith("#"): continue if ":" not in line: raise ValueError(f"无法解析的行: {line}") key, value = line.split(":", 1) result[key.strip()] = value.strip() return result def get_format_instructions(self) -> str: return "请使用 key: value 格式输出,每个字段占一行,不要包含多余内容。"然后你就可以把它像内置解析器一样挂进链里。注意BaseOutputParser[dict]这里的类型参数不是必须的,但写上之后配合类型检查工具会更舒适。自定义解析器最大的好处是你可以完全控制错误抛出逻辑,甚至在parse里做额外的清洗,比如去除首尾空白、转换布尔值等。
4. 实战中的高频坑与排查方案
4.1 模型在 JSON 外面加了代码块
最典型的问题就是模型返回内容带 Markdown 代码块标记,比如:
```json {"name": "张三", "age": 28}这种字符串交给标准的 `json.loads` 一定会抛异常。LangChain 的 `JsonOutputParser` 在某些版本里会自动处理这种情况,但如果用的不是内置解析器,建议自己写一个 `clean_json` 函数:先判断字符串是否以 ` ``` ` 开头,提取代码块内部内容,再交给解析器。 我自己遇到过好几次类似情况,尤其在使用一些“中文能力很强但不那么守规矩”的模型时特别常见。现在我的习惯是:所有解析前都用一段统一的预处理逻辑,把多余的 Markdown 标记、首尾空白全部剥干净。宁可多写几行代码,也不要在解析异常后再用正则去猜。 ### 4.2 字段缺失或类型不匹配 模型输出少了一个字段,或者把数字写成了“二十八”,这是第二个高频坑。`PydanticOutputParser` 面对缺失字段会抛出 `OutputParserException`,这是好事,但错误信息对新手来说不够直观。 排查思路是:先把 `parser.get_format_instructions()` 打印出来看看,确认格式说明足够明确。很多情况下字段缺失是因为描述写得太模糊,比如定义 `summary` 时只写了 `description="总结"`,模型不知道要总结多长。我会在 Field description 里加上“不超过20字”这样的限制,效果立竿见影。 如果模型总是漏字段,可以在提示词里加一句“必须包含所有字段,如果信息不足,使用空字符串占位”。但这种方法要慎用,因为空字符串占位会掩盖信息缺失,导致下游校验困难。我更建议让解析器报错,然后走 `RetryOutputParser`,重试时把错误信息反馈给模型,让模型自己补上缺失字段。 ### 4.3 中文文本的编码与格式问题 中文处理最容易踩的是编码坑。当模型输出包含中文的 JSON 时,如果你用 `json.dumps` 生成示例,默认会输出 `\uXXXX` 转义序列,虽然模型能理解,但看起来非常不友好。更重要的是,有些解析逻辑用正则匹配中文字符时会因为使用了错误的编码或正则模式而出错。 我的建议是:在构造 prompt 时,`json.dumps(example, ensure_ascii=False)`,让格式说明里直接显示中文。这样模型更容易理解字段含义,输出质量会明显提高。同时,在写自定义解析器时,尽量用 Python 的原生字符串操作而不是正则匹配中文,避免 `re` 模块在 `\u` 转义上引发隐蔽错误。 ### 4.4 调试心得:先打印原始输出,再谈解析 这一步看起来简单,但很多人跳过它直接调解析器。我在这上面浪费的时间绝对超过两小时。一个稳定的调试习惯是:先用 `StrOutputParser` 或直接 `print(result)` 查看模型的原始返回,确认它到底输出了什么,再决定用哪个解析器。 如果一个解析错误反复出现,不要急着换一个解析器,先看原始文本里的“坏格式”长什么样。很多情况下,你不是选错了解析器,而是 prompt 里没有把格式说明讲清楚。把原始输出当成日志打出来,你会立刻发现模型其实一直在输出 `key: value`,而你却用了 `JsonOutputParser`,这当然会失败。 下面是我整理的高频问题速查表: | 现象 | 可能原因 | 处理建议 | | --- | --- | --- | | 首尾有多余 Markdown 代码块 | 模型自带模板习惯 | 预处理剥掉代码块标记 | | JSON 解析报错但肉眼看起来正常 | 隐藏字符或换行符 | 先 `repr()` 检查特殊字符 | | 字段类型始终是字符串 | 模型输出带引号 | 用 Pydantic 强类型校验 | | 字段一直缺失 | 格式说明不明确 | 在 Field description 中补充长度、示例 | | 中文被转成 \uXXXX | 示例生成时使用了转义 | `ensure_ascii=False` | | 重试两次仍然失败 | prompt 本身二义性 | 重写需求描述,拆分复杂任务 | ## 5. 从 Demo 到工程化:解析器的更多玩法 ### 5.1 把解析器接入链和智能体时的注意事项 在简单链里,解析器放在管道末端就可以了。但在复杂智能体里,解析器往往不止一个,而且不同节点需要不同格式的数据。 比如一个客服智能体,第一步需要从用户问题里抽取出“意图”和“槽位”,第二步根据槽位调用工具,第三步生成最终回复。这时候第一步的输出解析器用 `PydanticOutputParser` 会是一个包含 `intent` 和 `slots` 的对象,第二步的工具调用可能又需要的是 JSON 字符串。我的做法是:每个节点内部都定义自己的输出解析器,不要尝试全局共用一个 Pydantic 模型。 另外,在 LangChain 的 `Runnable` 协议里,链与链之间的数据传递默认是透传的。如果你在一个 `RunnableLambda` 里返回了一个 Pydantic 对象,下一个节点接到的就是这个对象本身。这种写法很自然,但也容易让下游忘记它拿到的已经不是字符串了。建议在代码里显式标注类型,或者在返回前统一 `dict()` 转成普通 dict,减少后续组件的认知负担。 ### 5.2 重试、降级与日志的配合 工程化不是“能跑就行”,而是“挂了之后能自愈”。解析器本身只是组件,真正保证稳定性的是外层策略。我经常采用的三层保险是: - 第一层:解析器直接解析,成功则继续。 - 第二层:失败后走 `OutputFixingParser`,让模型修正格式。 - 第三层:再失败则走 `RetryOutputParser`,重新生成完整回答。 每层之间记录日志,尤其是失败时的原始输出和解析异常类型。日志里不要只存“解析失败”四个字,要把截断后的模型输出存下来,否则事后根本没法定位是 prompt 问题还是模型抽风。 这里特别提醒:重试不是免费的,每次都会多消耗 token。要设置合理的最大重试次数,并且在业务上允许快速失败。我之前做一个批量处理任务时,因为没设重试上限,某一类格式错误导致模型反复重新生成,账单直接涨了一大截,后来发现是 prompt 里字段描述有歧义,模型每次都生成同样的错误格式。 ### 5.3 自己设计一套解析器规范 一旦你的项目里出现了五个以上的解析器,就该考虑统一设计了。我自己的习惯是:所有业务解析器都返回同一个“响应信封”结构,比如: ```python class BizResponse(BaseModel): status: int = Field(description="0表示成功,非0表示异常") data: dict | list | None = Field(default=None, description="业务数据") error: str | None = Field(default=None, description="错误信息文本")这样做的好处是:无论具体业务是抽取新闻要素还是提取商品属性,最外层永远一致。下游只需要判断status和data,不需要关心每个解析器内部的字段差异。哪怕模型输出格式跑偏导致内部解析失败,外层也能给出一个统一的可读错误信息让调用方感知。
当然,这个规范不是硬性标准,而是我在做了多个 Demo 之后沉淀出来的经验。小项目里没有必要套这么死,但如果你正在做一个会被多个模块调用的中间层,统一信封能省掉不少接口对接成本。
最后再分享一个小技巧:在调试任何解析器时,优先使用可控的假数据而不是直接调模型。你可以先手写一个符合格式说明的字符串,确认解析器能解析;再逐步加入模型输出中的异常情况。这种方式能把“模型生成问题”和“解析逻辑问题”彻底分开,定位速度会快很多。解析器看起来只是一个小环节,但它决定了整个大模型应用的最后一公里是否可靠,值得多花点心思。