输出解析器
本章你将学会 - 为什么需要解析器:模型吐出来的是「带一大堆元数据的 AIMessage」,而你的业务代码想要的是 str / dict / list / 对象——解析器就是站在流水线末端的「海关拆箱员」 - St…
本章你将学会
- 为什么需要解析器:模型吐出来的是「带一大堆元数据的 AIMessage」,而你的业务代码想要的是
str/dict/list/ 对象——解析器就是站在流水线末端的「海关拆箱员」StrOutputParser:最常用的一行代码,把AIMessage拆成纯文本(JS 旧名StringOutputParser,1.x 统一改名,从 JS 迁移要留意)- Pydantic 基础速览:
BaseModel+Field怎么写,为什么description写得好能直接影响输出质量- 结构化输出两条路:模型原生的
with_structured_output(function_callingvsjson_modevsjson_schema三种方向盘)和提示词层面的PydanticOutputParser- 列表解析器:
CommaSeparatedListOutputParser一行提示词换来一个list[str]JsonOutputParser:不卡字段、只要一段能parse的 JSONRegexParser:用正则从纯文本里「抠」出结构化字段- 输出坏了怎么办:
OutputFixingParser的 auto-fix 思路
上一章我们把流式和并发玩明白了,但有个问题一直被绕着走:模型输出的本质是一坨字符串(外加一堆对你业务没用的元数据),而「拾光书屋」的代码需要的是能直接用的东西——荐语要 str、书评卡片要对象、书单要 list、数据接口要 JSON。03 章链尾挂的那个 StrOutputParser,就是本章的主角之一。
比喻:模型像一家只发「大纸箱」的快递公司。箱子里有你要的商品(content),也塞满了填充物(token 统计、模型名、耗时……)。解析器就是流水线末端的海关拆箱员——按你申报的报关单(schema),把商品取出来、摆成你要的样子,填充物就地丢弃。申报单写得越细,拆箱员工作越准;不申报,你就得自己开箱翻找。
技术基线:Python + LangChain 1.x(langchain + langchain-ollama),本地 Ollama 跑 qwen3:4b。本章大量示例不需要模型(解析器是纯函数性质的数据变换),demo 离线可跑。
0. 一张 JS → Python 对照表(先收藏)
| 原课程 JS 写法 | 本章 Python 写法 | 备注 |
|---|---|---|
StringOutputParser(@langchain/core) |
StrOutputParser(langchain_core.output_parsers) |
JS 1.x 也改名成 StrOutputParser 了,两边对齐 |
StructuredOutputParser.fromNamesAndDescriptions |
首选 with_structured_output(PydanticModel);备选 PydanticOutputParser |
字段名+描述那套轻量玩法,Python 1.x 主推 Pydantic 路线;from_response_schemas 还在 langchain-classic 里 |
z.object({...})(Zod schema) |
class Xxx(BaseModel)(Pydantic) |
Zod 的 Python 亲家,字段描述同样会进提示词 |
model.withStructuredOutput(zodSchema) |
model.with_structured_output(PydanticModel) |
Python 版多一个 method 参数可选方向盘 |
CommaSeparatedListOutputParser |
同名(langchain_core.output_parsers) |
Python 版的 parse 底层走 CSV 语义,见第 6 节 |
JsonOutputParser |
同名(langchain_core.output_parsers) |
1.x 的格式指令措辞更严格了,能力不变 |
RegexParser(langchain/output_parsers) |
RegexParser(langchain_classic.output_parsers) |
1.x 里老解析器家族搬去了 langchain-classic 包,需额外安装 |
| ——(JS 版没讲) | OutputFixingParser(langchain_classic) |
输出损坏时的「返厂维修」通道,本章第 9 节 |
一个总原则先立住:解析器也是 Runnable。03 章讲过 | 管道和 invoke/stream/batch 三兄弟——解析器继承这套接口,所以它能挂在链尾、能被 batch、能参与流式(是否真流式,取决于解析器种类,后面逐个说)。
1. 为什么需要解析器:先看看模型到底吐了什么
不挂解析器直接调模型,拿到的是 AIMessage——一个「商品+填充物」大纸箱:
from langchain_ollama import ChatOllama
model = ChatOllama(model="qwen3:4b", temperature=0.7)
resp = model.invoke("用一句话推荐《夜航船》")
print(type(resp)) # <class 'langchain_core.messages.ai.AIMessage'>
print(resp.content) # "一本明代文人的冷知识小百科……" ← 你要的商品
print(resp.response_metadata) # {'model': 'qwen3:4b', 'done_reason': 'stop', ...} ← 填充物
print(resp.usage_metadata) # {'input_tokens': 12, 'output_tokens': 34, ...} ← 填充物
元数据不是垃圾——统计 token、排查耗时、审计调用时都用得上。但对「把荐语展示给读者」这个业务来说,它是噪音:你要的只是 resp.content 那一行。
当然你可以每次都手写 resp.content,但麻烦会滚雪球:
- 不同模型的返回细节不完全一致(有的包一层、有的带额外字段);
- 链上好几处都要用时,
xxx.content会散落一地; - 万一后面接的是「非消息」环节(比如提示词模板的输出),类型就对不上了。
解析器的职责就是把「拿原始输出 → 转成你要的结构」这件事固化成一个可复用的链上零件:提取 JSON、列表、对象、按正则抠字段、纯文本清洗,全都是它的活。03 章讲过 Tool 和 Agent 的参数提取离不开它——那是后话,先从最简单的开始。
2. StrOutputParser:拆箱取文字
作用:把 AIMessage(或任意消息)拆成纯 str。最基础、出场率最高的解析器——凡是要把模型文字直接给人看的链,链尾都是它。
一个改名提醒:JS 版旧名 StringOutputParser,LangChain 1.x 起统一为 StrOutputParser。Python 侧一直就叫 StrOutputParser,所以从 JS 代码迁过来时,别照抄旧名。
签名(简化后):
from langchain_core.output_parsers import StrOutputParser
class StrOutputParser(BaseTransformOutputParser[str]):
def parse(self, text: str) -> str: ...
它继承的是转换型解析器:链式调用时逐块转换(流式友好),invoke 时直接吃进消息吐出文字。输入既可以是 AIMessage,也可以就是普通 str(它只管「保证你拿到 str」)。
最小示例——荐书官的三件套,第 03 章的老朋友:
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_ollama import ChatOllama
prompt = ChatPromptTemplate.from_template(
"你是拾光书屋的荐书官,用不超过两句话向读者推荐《{book}》"
)
model = ChatOllama(model="qwen3:4b", temperature=0.7)
chain = prompt | model | StrOutputParser()
text: str = chain.invoke({"book": "夜航船"}) # 拿到的直接是 str
print(text)
不挂它,chain.invoke 返回 AIMessage,你得自己 .content;挂上它,链尾类型从「消息」收敛成「文字」——下游不管是 print、写库还是拼前端文案,都干净了。
离线也能感受它的行为(不需要模型):
from langchain_core.messages import AIMessage
from langchain_core.output_parsers import StrOutputParser
parser = StrOutputParser()
print(parser.invoke(AIMessage(content="夜航人的掌灯"))) # 夜航人的掌灯
print(parser.invoke("山月记")) # 山月记
流式:第 04 章已经体验过 chain.stream(...) 逐块拿文字——那正是 StrOutputParser 在逐块转发 AIMessageChunk 的 content,它不会把流攒成一坨。
常见坑:
- 它只取文字,不做任何「解析」。模型回了一段废话、夹了一句英文、带了思考过程,它原样给你。内容层面的清洗得自己加
RunnableLambda(04 章 4.4 节的校验器思路)。 - 别把
resp.content和解析器混着用。链尾挂了StrOutputParser之后,invoke的返回值已经是str,再写.content会直接AttributeError。 - 流式时每块是碎片。
"夜航" + "船"分两块到达,别在循环里对单块做「完整句子」级别的判断。
3. Pydantic 速览:给模型填一张「数据表格」
(原课程在这一讲安排了 Zod 基础,Python 侧的对应物是 Pydantic——先把表格画好,后面两节的结构化输出全靠它。)
它解决什么痛点:Python 的类型标注是「给人和 IDE 看的」,运行时不设防。resp: dict 装着什么字段、字段是什么类型,跑起来才知道——API 返回的数据、模型生成的 JSON,全是这种「动态数据」。Pydantic 的思路和 Zod 同源:把类型定义写成类,定义即校验——数据进来当场验,不合格当场报错,合格则变成带类型提示的对象。
最小示例——拾光书屋的「书评卡片」:
from pydantic import BaseModel, Field
class BookReview(BaseModel):
title: str = Field(description="书名,例如《夜航船》")
rating: int = Field(description="评分,1 到 5 的整数")
comment: str = Field(description="一句话点评,不超过 30 字")
spoiler_free: bool = Field(default=True, description="是否无剧透")
card = BookReview(title="夜航船", rating=5, comment="掌灯人的小百科")
print(card.rating) # 5 —— 属性访问,IDE 全程提示
bad = BookReview(title="夜航船", rating="五星") # 类型不对
# pydantic.ValidationError: Input should be a valid integer ...
Zod → Pydantic 对照(写过 JS 的按这张表迁移):
| Zod 写法 | Pydantic 写法 | 一句话 |
|---|---|---|
z.object({ name: z.string() }) |
class M(BaseModel): name: str |
对象 = 类,字段 = 标注 |
z.string().describe("...") |
Field(description="...") |
描述会进 schema,模型看得见 |
z.number().int() / z.string() |
int / str |
类型即标注 |
z.array(z.string()) |
list[str] |
列表泛型 |
.optional() |
str \| None = None 或 Optional[str] |
可选字段(缺失不报错) |
.default(v) |
Field(default=v) |
默认值 |
z.union([A, B]) |
A \| B(或 Union[A, B]) |
联合类型 |
.refine(fn, { message }) |
@field_validator 装饰器 |
自定义校验 |
为什么 description 能影响输出质量:后面两节里,这张「表格」会被翻译成提示词(或工具 schema)发给模型。模型看不到你的字段名有多自明,只看得到 description 写了什么。"评分" 和 "评分,1 到 5 的整数,5 为最高",生成结果的靠谱程度完全不同——描述写给模型看,不是写给自己看,把取值范围、格式、示例都塞进去。
常见坑:
Field两个高频参数别混:description是给模型的说明,default是数据缺失时的兜底值,前者影响生成、后者影响校验。- 可选 ≠ 随便缺。
rating: int | None = None时缺rating能过;rating: int时缺了直接ValidationError。想让模型「尽量给」但允许不给,用前者。 - 校验发生在解析器里,不在模型里。模型完全可能生成不合规数据(比如
rating: "五星"),是 Pydantic 在解析时把它拦下来的——这正是第 9 节要处理的场景。 - Python 3.10+ 才能直接写
int | None;要兼容更老版本用Optional[int](从typing导入)。
4. 结构化输出(上):with_structured_output,让模型自己填表
作用:不靠提示词「求」模型输出 JSON,而是把你的 Pydantic 表格直接下发给模型,让它在生成阶段就被 schema 约束住。这是 1.x 里做结构化输出的首选,对应 JS 版的 withStructuredOutput(zodSchema)。
比喻:PydanticOutputParser(下一节)像给作家寄一张空白表格让他「照着填」,填成什么样看手感;with_structured_output 像给作家换了一台表格打字机——机器物理上只允许打出表格里的内容。
签名(langchain-ollama 1.x 实测):
def with_structured_output(
self,
schema: dict | type, # Pydantic 类 / TypedDict / JSON Schema dict
*,
method: Literal["function_calling", "json_mode", "json_schema"] = "json_schema",
include_raw: bool = False,
) -> Runnable[LanguageModelInput, dict | BaseModel]: ...
返回一个新的 Runnable(不是原来的模型),链尾自动带解析——invoke 直接吐 Pydantic 实例或 dict。
最小示例——把荐书官升级成「书评卡生成器」:
from langchain_ollama import ChatOllama
model = ChatOllama(model="qwen3:4b", temperature=0)
structured = model.with_structured_output(BookReview) # 第 3 节定义的卡片
card = structured.invoke("给《夜航船》写一张书评卡片")
print(card.title, card.rating) # 直接当对象用,已通过校验
注意这里不用再写「请输出 JSON」的提示词——schema 已经从协议层下发,提示词只描述任务本身。
4.1 三种方向盘:method 参数
method |
原理 | 特点 |
|---|---|---|
"json_schema"(默认) |
Ollama 的结构化输出 API,format=schema |
硬约束,模型端强制贴合 schema,首选 |
"function_calling" |
走 Ollama 工具调用,schema 包装成工具 | 语义上借「工具参数」的格式训练;字段描述遵循工具参数写法效果最好 |
"json_mode" |
只设 format='json',不管字段 |
只保证「是合法 JSON」,字段结构要你自己写进提示词 |
json_mode 的坑(最容易翻车):它约束的只是「输出必须是 JSON」,并不知道你有哪些字段。这样写会拿到「格式正确但字段随缘」的结果:
loose = model.with_structured_output(BookReview, method="json_mode")
# 提示词里不写字段要求 -> 模型可能回 {"书名": "夜航船", "评分": 5}
补救:用 json_mode 时,把字段要求写进提示词(手动塞 BookReview.model_json_schema(),或者干脆用下一节的 get_format_instructions())。而 json_schema / function_calling 都不需要这步。
4.2 include_raw=True:原始输出和解析结果都要
解析失败时默认直接抛异常。想拿到「原文 + 解析结果 + 报错」三件套自己处置,加 include_raw=True:
runnable = model.with_structured_output(BookReview, include_raw=True)
result = runnable.invoke("给《夜航船》写一张书评卡片")
print(result["raw"]) # 原始 BaseMessage
print(result["parsed"]) # BookReview 实例;解析失败时是 None
print(result["parsing_error"]) # 异常对象;成功时是 None
常见坑:
- 传 Pydantic 类才有校验。
with_structured_output(BookReview)输出实例、解析时校验字段;传 dict 形式的 JSON Schema 时输出裸dict,不做校验——字段缺了不报错,坑在下游。 - schema 的字段描述依然是质量关键。约束住的是「形状」,填得好不好还是看
Field(description=...)(第 3 节)。 - 结构化输出和流式是两套节奏。
json_schema/function_calling模式下模型一次性产出完整 JSON,链尾解析器要拿到全文才能校验——别指望 token 级流式;要流式 + 结构化,得退回StrOutputParser边流边攒、攒完再解析。 - 方法名换模型要复查。
method的可选项是 Ollama 集成自己的约定,换 OpenAI 等其他集成时可选值不同,不能无脑照搬。
5. 结构化输出(下):PydanticOutputParser,提示词层面的引导
作用:不换模型、不动协议,纯靠一段精心生成的提示词引导模型按 schema 输出 JSON,再由解析器转成 Pydantic 实例。原课程 JS 用 StructuredOutputParser.fromNamesAndDescriptions({字段: 描述}) 走的就是这条路;Python 1.x 的对应主力是 PydanticOutputParser。
签名:
from langchain_core.output_parsers import PydanticOutputParser
class PydanticOutputParser(BaseOutputParser[TBaseModel]):
pydantic_object: type[BaseModel] # 你的「表格」类
def parse(self, text: str) -> TBaseModel: ...
def get_format_instructions(self) -> str: ...
关键配角 get_format_instructions():它自动生成一段「生产级」格式提示词,把模型容易犯的错提前都堵上了。看它实际吐什么(截选):
The output should be formatted as a JSON instance that conforms to the JSON schema below.
As an example, for the schema {"properties": {"foo": {...}}}, ... ← few-shot 示例
Here is the output schema:
拆开看这段提示词的四板斧——每一板都值得偷学到自己的 prompt 里:
- 先声明输出物:必须是符合 JSON Schema 的 JSON 实例;
- few-shot 示例:拿一个虚构 schema(
foo字段)示范「什么算合格」,正反例都给; - 强调校验后果:「输出会被解析和类型检查」,给模型上压力;
- 细节提醒:别带尾逗号、别裹多余文本——都是真实翻车高发点。
最后才把你的字段(含 description)逐一嵌进去。原课程对这段自动生成文本的分析结论依然成立:这是拿来即用的 prompt 工程范本。
最小示例——问答带出处(原课程的「答案/依据/置信度」场景):
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_ollama import ChatOllama
from pydantic import BaseModel, Field
class BookQA(BaseModel):
answer: str = Field(description="用户问题的答案")
evidence: str = Field(description="回答所依据的书本内容")
confidence: str = Field(description="置信度,百分数格式,如 85%")
parser = PydanticOutputParser(pydantic_object=BookQA)
prompt = PromptTemplate.from_template(
"回答读者的问题:\n{format_instructions}\n\n问题:{question}"
)
model = ChatOllama(model="qwen3:4b", temperature=0)
chain = (
prompt.partial(format_instructions=parser.get_format_instructions())
| model
| parser
)
res: BookQA = chain.invoke({"question": "《夜航船》的作者是谁?"})
print(res.answer, res.confidence)
prompt.partial(...) 把格式说明提前「焊」进模板,之后每次 invoke 只传业务变量——比每次手动传 format_instructions 干净。
解析这头也不是傻子:模型输出带 ```json 围栏时它能自动剥掉再转对象;字段缺失或类型不对时抛 OutputParserException(错误里带着 pydantic 的校验详情),这是第 9 节的主角。
和 with_structured_output 怎么选:
- 模型/运行时支持结构化能力(Ollama 支持)→ 优先
with_structured_output,协议层硬约束,稳; - 要显式控制提示词、要在链上灵活拼装、或者用的模型只有文本接口 →
PydanticOutputParser; - 只想「字段名 + 描述」快速起个架子,不想写 Pydantic 类 →
langchain_classic.output_parsers里还留着老朋友StructuredOutputParser.from_response_schemas([ResponseSchema(name=..., description=...)]),输出是dict(无校验)。
常见坑:
- 忘了把
get_format_instructions()接进提示词,只挂了解析器——模型根本不知道你要 JSON,parse阶段当场爆炸。这是原课程演示过的经典报错现场:解析器管「收」,格式说明管「教」,缺一不可。 - 不支持流式。它必须等模型吐完全文才能
parse——chain.stream()会退化成「攒完一次性给」。列表解析器同理(第 6 节)。 PydanticOutputParser在链尾吃的是AIMessage。单独调用它的parse(str)也可以,但挂链上时它会从消息里取文字再解析。- 小模型输出 JSON 的稳定性不如结构化 API——同一个
qwen3:4b,走提示词引导的失败率高于json_schema模式,重要业务优先第 4 节。
6. 列表解析器:一行提示词换一个 list[str]
作用:场景很具体——「给我列 3 本适合失眠夜读的书」,你想要的是 ["夜航船", "山月记", "小王子"] 这样的列表,而不是一段话。CommaSeparatedListOutputParser 负责把逗号分隔的文本切成 list[str]。
签名:
from langchain_core.output_parsers import CommaSeparatedListOutputParser
class CommaSeparatedListOutputParser(ListOutputParser):
def get_format_instructions(self) -> str: ... # 固定一句话,无参数
def parse(self, text: str) -> list[str]: ...
它的格式说明是固定文案(不接受参数):
Your response should be a list of comma separated values, eg: `foo, bar, baz` or `foo,bar,baz`
最小示例:
from langchain_core.output_parsers import CommaSeparatedListOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_ollama import ChatOllama
parser = CommaSeparatedListOutputParser()
prompt = PromptTemplate.from_template(
"{format_instructions}\n列出3本适合{mood}时读的书,只给书名。"
)
model = ChatOllama(model="qwen3:4b", temperature=0)
chain = prompt.partial(format_instructions=parser.get_format_instructions()) | model | parser
books: list[str] = chain.invoke({"mood": "失眠"})
print(books) # ['《夜航船》', '《山月记》', '《小王子》']
parse 的隐藏技能:它底层用的是 csv.reader(带引号、skipinitialspace)。所以除了逗号,换行分隔的输入它也能切,甚至支持引号包裹(书名里带逗号也不怕):
parser = CommaSeparatedListOutputParser()
parser.parse("《夜航船》\n《山月记》\n《小王子》") # 也能切成 3 个元素
parser.parse('"百年孤独, 精装版", 红楼梦') # ['百年孤独, 精装版', '红楼梦']
把它理解成「宽松的 CSV 单行解析」比「切逗号」更接近真相。
两位表亲(同一个 ListOutputParser 家族,按模型的输出习惯挑):
from langchain_core.output_parsers import NumberedListOutputParser, MarkdownListOutputParser
NumberedListOutputParser().parse("1. 夜航船\n2. 山月记") # ['夜航船', '山月记']
MarkdownListOutputParser().parse("- 夜航船\n- 山月记") # ['夜航船', '山月记']
小模型经常不理你「用逗号分隔」的要求、自顾自编起号来——这时换 NumberedListOutputParser 往往事半功倍。
另一条路:Pydantic 的 list 字段。要的列表更复杂(每项是对象),就别用逗号解析器,直接让结构化输出上:
class BookList(BaseModel):
books: list[str] = Field(description="书名列表,每项形如《书名》")
reason: str = Field(description="为什么推荐这组书")
runnable = model.with_structured_output(BookList)
常见坑:
- 切完的元素可能带前后空格以外的脏东西。它做了基础清理,但「1. 夜航船」(编号没去掉)这类输入逗号解析器是无能为力的——编号格式交给
NumberedListOutputParser。 - 同样不支持流式(要全文才能切),流式场景攒完再解析一次。
- 别对它指望嵌套结构。它是平面
list[str]专用,要「列表套对象」走 Pydantic 路线。 - 提示词里加一句「只给书名」很重要——否则模型爱在每项后面附一句简介,切出来的元素全成了长句。
7. JsonOutputParser:只要一段能 parse 的 JSON
作用:介于「字符串」和「严格 schema」之间——不卡字段结构,只要模型吐出一段合法 JSON,就帮你转成 dict。适合快速原型、字段不固定的场景。
和 PydanticOutputParser 的分工(原课程这组对比很清晰,照着记):
PydanticOutputParser:结构要完全听我的——字段、类型、描述都定死,模型照表格填;JsonOutputParser:只要能 parse 就行——字段叫什么、有几个,随模型发挥,我拿到dict自己看。
签名:
from langchain_core.output_parsers import JsonOutputParser
class JsonOutputParser(BaseCumulativeTransformOutputParser[Any]):
pydantic_object: type[BaseModel] | None = None # 可选!给了就进格式说明
def parse(self, text: str) -> Any: ...
def get_format_instructions(self) -> str: ...
注意 pydantic_object 是可选的——这是它和 PydanticOutputParser 最大的配置差异:不给 schema 也能用(纯「给我 JSON」);给了 schema,则字段说明会写进格式提示词,但解析结果仍是 dict,不做校验。
最小示例——拾光书屋的「门店信息卡」,字段先不定死:
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_ollama import ChatOllama
parser = JsonOutputParser() # 不传 schema:只要 JSON
prompt = PromptTemplate.from_template(
"用 JSON 介绍一下《{book}》这本书的基本信息"
)
model = ChatOllama(model="qwen3:4b", temperature=0.3)
chain = prompt | model | parser
info: dict = chain.invoke({"book": "夜航船"})
print(info["author"]) # dict 随取随用(字段名模型说了算)
想约束字段又不想上校验?把 schema 描述写进提示词即可(原课程的做法:在模板里列字段名),或者把 Pydantic 类传给 pydantic_object——此时 get_format_instructions() 会带上字段说明,但输出仍是宽松的 dict。
容错细节:它对「裹了 markdown 围栏」的输出有免疫力——```json {...} ``` 会自动剥掉围栏再 json.loads;真遇到无法解析的文本,抛 OutputParserException: Invalid json output: ...。
常见坑:
- 输出类型是
Any(实际多为dict)。IDE 不知道里面有什么字段,info["author"]拼错键名要到运行时才炸——要类型安全,转投第 5 节。 - 传了
pydantic_object≠ 有校验。字段说明只是「写进了提示词」,模型不守约也不报错,拿到缺字段的dict照样往下走。要硬校验必须用PydanticOutputParser或with_structured_output。 - 1.x 的格式说明分两档措辞:不传
pydantic_object时只有一句Return a JSON object.;传了则是「STRICT OUTPUT FORMAT」开头的命令式清单(要求裸 JSON、禁解释文字)。用法和 0.x 完全一致,从旧资料迁过来不用改代码。
8. RegexParser:用正则从纯文本里抠字段
作用:有时候你既不想让模型输出 JSON,也没法用结构化 API——模型只能吐固定格式的纯文本(很多老模型、纯补全类接口就是这样)。RegexParser 让你拿一个正则表达式,从文本里「抠」出命名的捕获组,直接变 dict。
归属变化:这个解析器不在 langchain_core 里——1.x 把老解析器家族搬去了 langchain-classic 包:
pip install langchain-classic
from langchain_classic.output_parsers import RegexParser
签名(实测源码):
class RegexParser(BaseOutputParser[dict[str, str]]):
regex: str # 正则表达式
output_keys: list[str] # 捕获组依次对应的字段名
default_output_key: str | None = None # 兜底字段名
def parse(self, text: str) -> dict[str, str]: ...
parse 的规则(实测,和 JS 版有出入,见下):
re.search匹配成功:捕获组按output_keys顺序对号入座,组数不够会IndexError;- 没匹配上且
default_output_key为None:抛ValueError; - 没匹配上且
default_output_key有值:output_keys里的字段全部置空串,原文只有当default_output_key恰好也在output_keys里时才被保留。
最小示例——书屋编目员按固定格式吐「书名+评分」:
from langchain_classic.output_parsers import RegexParser
parser = RegexParser(
regex=r"Title:\s*(.+)\nScore:\s*(\d{1,3})",
output_keys=["title", "score"], # 第 1、2 个捕获组分别叫 title、score
)
print(parser.parse("Title: 夜航船\nScore: 92"))
# {'title': '夜航船', 'score': '92'}
兜底行为实测(这里最容易踩 JS 的坑):照 JS 习惯写 default_output_key="raw" 但 raw 不在 output_keys 里——Python 版不会像 JS 那样把原文放进 raw 键,而是所有字段置空串、原文静默丢弃:
parser = RegexParser(
regex=r"Title:\s*(.+)\nScore:\s*(\d{1,3})",
output_keys=["title", "score"],
default_output_key="raw", # raw 不在 output_keys 里!
)
print(parser.parse("今天心情不错,不想编目"))
# {'title': '', 'score': ''} ← 原文没了,raw 键也没出现
想要「不匹配时原文可追查」,要么接受抛 ValueError(不设 default_output_key),要么用 try/except 自己接住原文——别指望 JS 那种 raw 键。
配套的链(提示词里得自己写格式约定,见下):
from langchain_core.prompts import PromptTemplate
from langchain_ollama import ChatOllama
prompt = PromptTemplate.from_template(
"为下面这段书摘拟一个书名标签和一个 0-100 的贴合度评分。\n"
"严格按此格式输出(两行,不要任何其他内容):\n"
"Title: <书名>\nScore: <整数>\n\n书摘:{excerpt}"
)
model = ChatOllama(model="qwen3:4b", temperature=0)
chain = prompt | model | parser
result: dict[str, str] = chain.invoke({"excerpt": "天下学问,惟夜航船中最难对付……"})
JS → Python 的两个硬差异(别照 JS 资料抄这个类):
- JS 版自带
getFormatInstructions()(能生成「请匹配这个正则」的提示词);Python 版没有这个方法(调用会抛NotImplementedError)——格式约定要像上面那样自己写进提示词。 - JS 版兜底是「不匹配时把全文放进
raw键」;Python 版如前所述,raw不在output_keys里就静默丢弃。
为什么正则这条路依然有生命力:格式简单、可预测(Title: xxx\nScore: 92)时,正则抠取比 JSON 更稳——没有引号转义、没有尾逗号这些 JSON 翻车点,小模型照葫芦画瓢的成功率反而高。
常见坑:
- 捕获组数量必须和
output_keys对上。正则里( )的个数少于output_keys的长度,匹配成功也会IndexError: no such group;组数多了倒是无妨(多出的组丢弃)。不想要某个组就用非捕获分组(?:...)。 - 值全是
str。score拿到的是'92'不是92,参与运算前自己int()。 - 兜底不是「软失败信号」而是「静默失败」。Python 版里没匹配上时字段全空、原文大概率被丢(见上面的实测)——坏数据会一声不吭地入库。想要显式失败,干脆不设
default_output_key,让它抛ValueError。 - 正则别写得太贪。
(.+)会一路吃到行尾,输出里如果混了解释文字,全进了title——格式约束要靠提示词和正则两头夹。
9. 输出坏了怎么办:OutputFixingParser 与 auto-fix 思路
问题现场:不管提示词写得多好,模型总有失手的时候——JSON 尾逗号、字段名拼错、"五星" 塞进 int 字段。第 5 节说过,这时 PydanticOutputParser 会抛 OutputParserException(里面裹着 pydantic 的 ValidationError,信息很全)。整条链就地为一次手滑买单,值不值?
比喻:质检员发现包装不合格,正常流程是把货退回原厂返修(把坏输出和报错发回模型重做),而不是直接扔进海里。
9.1 OutputFixingParser:包一层「返修通道」
来自 langchain_classic(同样需要 pip install langchain-classic):
from langchain_classic.output_parsers import OutputFixingParser
思路(JS 生态叫 auto-fix parser,同一个设计):解析失败时不抛错,而是把「格式要求 + 坏输出 + 报错信息」拼成一段修复提示词,再调一次模型让它照着改,然后重新解析。max_retries 控制返修次数。
签名:
@classmethod
def from_llm(
llm: Runnable, # 用来执行修复的模型(任意 Runnable)
parser: BaseOutputParser[T], # 被包住的原始解析器
max_retries: int = 1, # 最多返修几次
) -> OutputFixingParser[T]: ...
最小示例——给书评卡解析器穿「返修马甲」:
from langchain_classic.output_parsers import OutputFixingParser
from langchain_core.output_parsers import PydanticOutputParser
from langchain_ollama import ChatOllama
inner = PydanticOutputParser(pydantic_object=BookReview)
model = ChatOllama(model="qwen3:4b", temperature=0)
fixer = OutputFixingParser.from_llm(llm=model, parser=inner)
# 第一份输出坏掉(rating 类型不对),fixer 会拿它找模型返修,再解析
card = fixer.parse('{"title": "夜航船", "rating": "五星"}')
print(card) # 修复成功则拿到合法 BookReview;返修后仍失败才抛错
离线也能看清它的机制——返修用的 llm 只要求是 Runnable,塞个假模型进去(本章 demo 就是这么做的):
from langchain_core.messages import AIMessage
from langchain_core.runnables import RunnableLambda
def fake_fix_llm(fix_prompt) -> AIMessage:
print("被发回返修的提示里包含:", "Instructions" in str(fix_prompt))
return AIMessage(content='{"title": "夜航船", "rating": 5}')
fixer = OutputFixingParser.from_llm(llm=RunnableLambda(fake_fix_llm), parser=inner)
print(fixer.parse('{"title": "夜航船", "rating": "五星"}')) # 正常返回修好的实例
9.2 修不好怎么办:include_raw + 自己的兜底链
OutputFixingParser 返修后仍失败,异常照样抛。更工程化的姿势是把它和 04 章的回退组合:解析环节挂上 include_raw=True(第 4.2 节),失败时 parsing_error 有值、parsed 为 None——你可以在下游用 RunnableLambda 把「软失败」翻译成「硬失败」抛出,让 with_fallbacks 接管。三件套的分工:
| 环节 | 用什么 | 职责 |
|---|---|---|
| 预防 | with_structured_output(method="json_schema") |
协议层让模型别犯错 |
| 返修 | OutputFixingParser(或重试链) |
偶发手滑,自动修 |
| 兜底 | include_raw + 校验器 + with_fallbacks |
修不好,走备用路,别掀桌 |
常见坑:
- 修复调用的是模型,要花钱花时间。失败一次 = 多一轮完整生成;对延迟敏感的链,宁可让它抛、走 fallback。
max_retries不是「总尝试次数」的语义,别设成 0 还指望它修复。- 返修提示词是英文模板(源码里的默认
PromptTemplate),小模型对着英文说明修中文 JSON 偶尔会「修着修着跑题」,必要时传自定义prompt参数。 - 更简单的替代:
with_structured_output(..., include_raw=True)+ 自己写RunnableLambda判断重试——机制透明,可控性强,代价是样板代码多。
10. 本章小结
一张表带走全部解析器(1.x 实测):
| 你要什么 | 用什么 | 输出类型 | 流式 | 硬校验 | 额外依赖 |
|---|---|---|---|---|---|
| 纯文字 | StrOutputParser |
str |
✅ | —— | 无 |
| 结构化对象(首选) | model.with_structured_output(Model) |
Model 实例 |
❌ | ✅ | 无 |
| 结构化对象(提示词路线) | PydanticOutputParser |
Model 实例 |
❌ | ✅ | 无 |
| 字段名+描述快速起架 | StructuredOutputParser.from_response_schemas |
dict |
❌ | ❌ | langchain-classic |
| 平面字符串列表 | CommaSeparatedListOutputParser |
list[str] |
❌ | ❌ | 无 |
| 列表+字段都齐全 | with_structured_output(list 字段的 Model) |
Model 实例 |
❌ | ✅ | 无 |
| 宽松 JSON | JsonOutputParser |
Any/dict |
❌ | ❌ | 无 |
| 固定格式纯文本 | RegexParser |
dict[str, str] |
❌ | ❌(兜底会静默丢原文) | langchain-classic |
| 坏输出返修 | OutputFixingParser.from_llm(...) |
同内层 | ❌ | ✅ | langchain-classic |
三个心智锚点:
- 解析器是链尾的 Runnable——
invoke/stream/batch全套通用,但除了StrOutputParser,多数解析器必须等全文,流式会退化成单块。 - 引导在前,解析在后:
get_format_instructions()负责「教模型怎么输出」,parse负责「把输出变结构」——只挂解析器不写格式说明,等于只设海关不发文规则。 - 约束越硬越省心:能上
with_structured_output就别纯靠提示词;能写 Pydantic 校验就别拿裸dict裸奔。
配套 demo(本章 demo/ 目录):
demo_parser.py:单文件演示StrOutputParser/ Pydantic 结构化 / 列表 /JsonOutputParser/RegexParser/OutputFixingParser的真实行为——全部离线可跑(用固定假响应喂给解析器,解析器本身不需要模型);设USE_MODEL=1可追加真模型环节(需本地 Ollama +qwen3:4b)。其中RegexParser/OutputFixingParser两节需要pip install langchain-classic(缺了会自动跳过,不影响其余演示)。
下一章:06 · 文档加载与切割