首页/LangChain 指南/输出解析器
LangChain 指南 · 第 05 章

输出解析器

本章你将学会 - 为什么需要解析器:模型吐出来的是「带一大堆元数据的 AIMessage」,而你的业务代码想要的是 str / dict / list / 对象——解析器就是站在流水线末端的「海关拆箱员」 - St…

约 53 分钟21,214 字31 个代码示例

本章你将学会

  • 为什么需要解析器:模型吐出来的是「带一大堆元数据的 AIMessage」,而你的业务代码想要的是 str / dict / list / 对象——解析器就是站在流水线末端的「海关拆箱员」
  • StrOutputParser:最常用的一行代码,把 AIMessage 拆成纯文本(JS 旧名 StringOutputParser,1.x 统一改名,从 JS 迁移要留意)
  • Pydantic 基础速览:BaseModel + Field 怎么写,为什么 description 写得好能直接影响输出质量
  • 结构化输出两条路:模型原生的 with_structured_output(function_calling vs json_mode vs json_schema 三种方向盘)和提示词层面的 PydanticOutputParser
  • 列表解析器:CommaSeparatedListOutputParser 一行提示词换来一个 list[str]
  • JsonOutputParser:不卡字段、只要一段能 parse 的 JSON
  • RegexParser:用正则从纯文本里「抠」出结构化字段
  • 输出坏了怎么办: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:
{"properties": {"title": {"description": "书名", "type": "string"}, ...}}

拆开看这段提示词的四板斧——每一板都值得偷学到自己的 prompt 里:

  1. 先声明输出物:必须是符合 JSON Schema 的 JSON 实例;
  2. few-shot 示例:拿一个虚构 schema(foo 字段)示范「什么算合格」,正反例都给;
  3. 强调校验后果:「输出会被解析和类型检查」,给模型上压力;
  4. 细节提醒:别带尾逗号、别裹多余文本——都是真实翻车高发点。

最后才把你的字段(含 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 资料抄这个类):

  1. JS 版自带 getFormatInstructions()(能生成「请匹配这个正则」的提示词);Python 版没有这个方法(调用会抛 NotImplementedError)——格式约定要像上面那样自己写进提示词。
  2. 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

三个心智锚点:

  1. 解析器是链尾的 Runnable——invoke/stream/batch 全套通用,但除了 StrOutputParser,多数解析器必须等全文,流式会退化成单块。
  2. 引导在前,解析在后:get_format_instructions() 负责「教模型怎么输出」,parse 负责「把输出变结构」——只挂解析器不写格式说明,等于只设海关不发文规则。
  3. 约束越硬越省心:能上 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 · 文档加载与切割