第7章 输出解析与结构化输出
本书章节导航
- 前言
- 第1章 为什么需要理解 LangChain
- 第2章 架构总览
- 第3章 Runnable 与 LCEL 表达式语言
- 第4章 消息系统与多模态
- 第5章 语言模型抽象层
- 第6章 提示词模板引擎
- 第7章 输出解析与结构化输出 (当前)
- 第8章 工具系统
- 第9章 文档加载与文本分割
- 第10章 向量存储与检索器
- 第11章 Chain 组合模式
- 第12章 回调与可观测性
- 第13章 记忆与会话管理
- 第14章 Agent 架构与执行循环
- 第15章 工具调用与 Agent 模式
- 第16章 序列化与配置系统
- 第17章 Partner 集成架构
- 第18章 设计模式与架构决策
语言模型的原始输出是自由格式的文本流。对于构建可靠的 AI 应用而言,仅仅拿到一段文字是远远不够的 -- 我们需要将模型的非结构化响应转换为程序可以直接操作的结构化数据。这一需求催生了 LangChain 输出解析系统的诞生。
从最简单的字符串透传,到 JSON/Pydantic 对象的精确验证,再到 XML 流式增量解析,LangChain 构建了一套层次分明、功能递进的输出解析器体系。更重要的是,它巧妙地将解析器统一到 Runnable 协议之下,使得解析操作能够天然地融入 LCEL 链式调用。本章将深入解析这套体系的设计思路与实现细节。
:::tip 本章要点
- 理解
BaseLLMOutputParser->BaseOutputParser->BaseTransformOutputParser->BaseCumulativeTransformOutputParser的四层继承体系 - 掌握
StrOutputParser、JsonOutputParser、PydanticOutputParser、XMLOutputParser四大核心解析器的实现原理 - 理解流式 Transform 解析的累积与增量差异机制
- 了解
ListOutputParser族的流式分块解析策略 - 理解
with_structured_output如何在模型层面替代传统输出解析器 :::
7.1 从 Generation 到结构化数据:解析器的使命
语言模型的每一次调用,无论是补全模型(LLM)还是聊天模型(ChatModel),最终都会产出 Generation 对象。Generation 封装了模型输出的原始文本(text 属性)及其元数据。而输出解析器的核心使命,就是将这些 Generation 对象转换为应用程序所需的结构化类型。
这里有一个关键的设计考量:解析器需要同时支持两种输入 -- 原始字符串和 BaseMessage 对象。因为在 LCEL 链中,解析器既可能直接接收模型的文本输出,也可能接收 AIMessage 等消息对象。LangChain 通过在 invoke 方法中进行类型分发来解决这一问题:
# langchain_core/output_parsers/base.py
class BaseOutputParser(BaseLLMOutputParser, RunnableSerializable[LanguageModelOutput, T]):
def invoke(
self,
input: str | BaseMessage,
config: RunnableConfig | None = None,
**kwargs: Any,
) -> T:
if isinstance(input, BaseMessage):
return self._call_with_config(
lambda inner_input: self.parse_result(
[ChatGeneration(message=inner_input)]
),
input, config, run_type="parser",
)
return self._call_with_config(
lambda inner_input: self.parse_result([Generation(text=inner_input)]),
input, config, run_type="parser",
)
这段代码揭示了一个精巧的设计:无论输入是字符串还是消息,都会被包装成 Generation 或 ChatGeneration,然后统一交给 parse_result 处理。_call_with_config 确保了回调系统的正确触发,run_type="parser" 则让 LangSmith 等可观测工具能够正确识别这是一次解析操作。
7.2 解析器类层级结构
LangChain 的输出解析器采用了经典的模板方法模式,构建了四层继承体系。每一层都引入了特定的能力维度:
classDiagram
class BaseLLMOutputParser~T~ {
<<abstract>>
+parse_result(result: list~Generation~, partial: bool) T
+aparse_result(result: list~Generation~, partial: bool) T
}
class BaseGenerationOutputParser~T~ {
+invoke(input, config) T
+ainvoke(input, config) T
}
class BaseOutputParser~T~ {
<<abstract>>
+parse(text: str) T*
+parse_result(result, partial) T
+parse_with_prompt(completion, prompt) Any
+get_format_instructions() str
+invoke(input, config) T
}
class BaseTransformOutputParser~T~ {
+_transform(input: Iterator) Iterator~T~
+_atransform(input: AsyncIterator) AsyncIterator~T~
+transform(input, config) Iterator~T~
+atransform(input, config) AsyncIterator~T~
}
class BaseCumulativeTransformOutputParser~T~ {
+diff: bool
+_diff(prev, next) T
+_transform(input) Iterator~Any~
+_atransform(input) AsyncIterator~T~
}
BaseLLMOutputParser <|-- BaseGenerationOutputParser
BaseLLMOutputParser <|-- BaseOutputParser
BaseOutputParser <|-- BaseTransformOutputParser
BaseTransformOutputParser <|-- BaseCumulativeTransformOutputParser
7.2.1 BaseLLMOutputParser -- 最底层的协议
BaseLLMOutputParser 是整个体系的根基,它定义了一个极简接口:接收 list[Generation],返回类型 T。它是泛型的,使用 Generic[T] 参数化输出类型。注意它并不继承 Runnable,因此不能直接用于 LCEL 链。
7.2.2 BaseOutputParser -- 核心抽象层
BaseOutputParser 同时继承了 BaseLLMOutputParser 和 RunnableSerializable,这是一个关键的设计决策。通过混入 RunnableSerializable,解析器获得了完整的 Runnable 协议支持:invoke/ainvoke/batch/stream 等方法,使其能够作为 LCEL 链中的一个环节。
它引入了三个核心抽象方法:
| 方法 | 职责 | 备注 |
|---|---|---|
parse(text) | 将字符串解析为目标类型 | 子类必须实现 |
get_format_instructions() | 返回格式指令字符串 | 注入 Prompt 引导模型 |
parse_result(result, partial) | 从 Generation 列表提取并解析 | 默认取第一个 Generation 的 text |
parse_result 的默认实现非常简洁:return self.parse(result[0].text)。它总是取候选列表中的第一个结果(通常是最高置信度的输出),然后委托给 parse 方法。
7.2.3 BaseTransformOutputParser -- 流式能力
BaseTransformOutputParser 在 BaseOutputParser 的基础上增加了 _transform 和 _atransform 方法,使解析器能够处理流式输入。其默认实现是"逐块独立解析"策略:
# langchain_core/output_parsers/transform.py
class BaseTransformOutputParser(BaseOutputParser[T]):
def _transform(self, input: Iterator[str | BaseMessage]) -> Iterator[T]:
for chunk in input:
if isinstance(chunk, BaseMessage):
yield self.parse_result([ChatGeneration(message=chunk)])
else:
yield self.parse_result([Generation(text=chunk)])
这种策略适用于那些每个 chunk 都是完整可解析单元的场景。对于需要累积 chunk 才能解析的场景,就需要下一层的 BaseCumulativeTransformOutputParser。
7.2.4 BaseCumulativeTransformOutputParser -- 累积解析
这是解析器体系中最复杂的一层。它的核心思想是:不断累积输入 chunk,每次累积后尝试解析,只在解析结果发生变化时才 yield 输出。
# langchain_core/output_parsers/transform.py(简化)
class BaseCumulativeTransformOutputParser(BaseTransformOutputParser[T]):
diff: bool = False
def _transform(self, input: Iterator[str | BaseMessage]) -> Iterator[Any]:
prev_parsed = None
acc_gen = None
for chunk in input:
# 将 chunk 包装为 GenerationChunk 并累积
if isinstance(chunk, BaseMessageChunk):
chunk_gen = ChatGenerationChunk(message=chunk)
else:
chunk_gen = GenerationChunk(text=chunk)
acc_gen = chunk_gen if acc_gen is None else acc_gen + chunk_gen
# 尝试解析累积结果
parsed = self.parse_result([acc_gen], partial=True)
if parsed is not None and parsed != prev_parsed:
if self.diff:
yield self._diff(prev_parsed, parsed)
else:
yield parsed
prev_parsed = parsed
这段代码有几个精妙之处:
- 累积机制:利用
GenerationChunk的__add__运算符进行文本拼接,实现增量累积 - 变化检测:通过
parsed != prev_parsed过滤掉重复的中间结果,避免不必要的输出 - 差异模式:当
diff=True时,不输出完整的解析结果,而是输出前后两次解析结果的差异
flowchart TD
A[输入 chunk 流] --> B{chunk 类型?}
B -->|BaseMessageChunk| C[包装为 ChatGenerationChunk]
B -->|str| D[包装为 GenerationChunk]
C --> E[累积: acc_gen = acc_gen + chunk_gen]
D --> E
E --> F[parse_result with partial=True]
F --> G{parsed 有变化?}
G -->|否| A
G -->|是| H{diff 模式?}
H -->|否| I[yield parsed]
H -->|是| J[yield _diff prev, parsed]
I --> K[更新 prev_parsed]
J --> K
K --> A
7.3 StrOutputParser:最简单却最常用的解析器
StrOutputParser 是整个体系中最简单的实现 -- 它的 parse 方法只有一行代码:return text。但这并不意味着它不重要。恰恰相反,它是 LCEL 链中使用频率最高的解析器。
# langchain_core/output_parsers/string.py
class StrOutputParser(BaseTransformOutputParser[str]):
@override
def parse(self, text: str) -> str:
"""Returns the input text with no changes."""
return text
它继承自 BaseTransformOutputParser[str],因此天然支持流式操作。当你在 LCEL 链中写 chain = prompt | llm | StrOutputParser() 时,StrOutputParser 的作用是将模型返回的 AIMessage 对象"剥壳",提取其中的文本内容。
因为 BaseTransformOutputParser._transform 的默认实现是逐块解析,而 StrOutputParser.parse 只是透传文本,所以在流式场景下,每个 token chunk 都会被原样 yield 出来。这使得 StrOutputParser 在流式场景下几乎是零开销的。
# 典型用法:流式提取文本
from langchain_core.output_parsers import StrOutputParser
parser = StrOutputParser()
stream = model.stream("Tell me a story")
for chunk in parser.transform(stream):
print(chunk, end="", flush=True) # 逐 token 输出
7.4 JsonOutputParser:结构化 JSON 输出
JsonOutputParser 是实现结构化输出最直接的方式之一。它继承自 BaseCumulativeTransformOutputParser,支持流式增量解析 JSON。
7.4.1 核心解析逻辑
# langchain_core/output_parsers/json.py
class JsonOutputParser(BaseCumulativeTransformOutputParser[Any]):
pydantic_object: type[TBaseModel] | None = None
def parse_result(self, result: list[Generation], *, partial: bool = False) -> Any:
text = result[0].text.strip()
if partial:
try:
return parse_json_markdown(text)
except JSONDecodeError:
return None
else:
try:
return parse_json_markdown(text)
except JSONDecodeError as e:
msg = f"Invalid json output: {text}"
raise OutputParserException(msg, llm_output=text) from e
partial 参数的引入是流式解析的关键。在流式模式下(partial=True),JSON 解析失败不会抛出异常,而是返回 None。这允许解析器在 JSON 字符串尚未完整时优雅地等待更多数据。parse_json_markdown 函数能够处理被 Markdown 代码块包裹的 JSON,这在实际应用中非常常见。
7.4.2 差异模式与 JSONPatch
当 diff=True 时,JsonOutputParser 利用 jsonpatch 库生成 JSONPatch 操作序列:
def _diff(self, prev: Any | None, next: Any) -> Any:
return jsonpatch.make_patch(prev, next).patch
这对于前端实时渲染部分 JSON 结果特别有用 -- 前端可以直接应用 JSONPatch 操作来增量更新 UI,而不需要每次都处理完整的 JSON 对象。
7.4.3 格式指令生成
get_format_instructions 方法根据是否提供了 pydantic_object 来生成不同的指令:
def get_format_instructions(self) -> str:
if self.pydantic_object is None:
return "Return a JSON object."
schema = dict(self._get_schema(self.pydantic_object).items())
reduced_schema = schema
if "title" in reduced_schema:
del reduced_schema["title"]
if "type" in reduced_schema:
del reduced_schema["type"]
schema_str = json.dumps(reduced_schema, ensure_ascii=False)
return JSON_FORMAT_INSTRUCTIONS.format(schema=schema_str)
这个方法会将 Pydantic 模型的 JSON Schema 嵌入到格式指令中,告诉模型应当输出什么样的 JSON 结构。指令文本中特别强调不要使用 Markdown 代码块包裹,确保输出可以被直接解析。
flowchart LR
subgraph 非流式
A1[模型输出文本] --> B1[parse_json_markdown]
B1 --> C1{解析成功?}
C1 -->|是| D1[返回 dict/list]
C1 -->|否| E1[抛出 OutputParserException]
end
subgraph 流式累积
A2[chunk 流] --> B2[累积文本]
B2 --> C2[parse_json_markdown]
C2 --> D2{解析成功?}
D2 -->|是| E2{结果有变化?}
D2 -->|否| F2[返回 None, 继续累积]
E2 -->|是| G2{diff 模式?}
E2 -->|否| F2
G2 -->|否| H2[yield 完整 JSON]
G2 -->|是| I2[yield JSONPatch]
end
7.5 PydanticOutputParser:类型安全的解析
PydanticOutputParser 继承自 JsonOutputParser,在 JSON 解析的基础上增加了 Pydantic 模型验证。它是类型安全的结构化输出解析器的代表。
# langchain_core/output_parsers/pydantic.py
class PydanticOutputParser(JsonOutputParser, Generic[TBaseModel]):
pydantic_object: type[TBaseModel]
def _parse_obj(self, obj: dict) -> TBaseModel:
try:
if issubclass(self.pydantic_object, pydantic.BaseModel):
return self.pydantic_object.model_validate(obj)
if issubclass(self.pydantic_object, pydantic.v1.BaseModel):
return self.pydantic_object.parse_obj(obj)
except (pydantic.ValidationError, pydantic.v1.ValidationError) as e:
raise self._parser_exception(e, obj) from e
def parse_result(self, result: list[Generation], *, partial: bool = False):
try:
json_object = super().parse_result(result)
return self._parse_obj(json_object)
except OutputParserException:
if partial:
return None
raise
设计亮点在于:
- 双版本兼容:同时支持 Pydantic v1(
parse_obj)和 v2(model_validate),这是 LangChain 在 Pydantic 版本迁移期间的务实选择 - 优雅的流式降级:在
partial=True模式下,验证失败返回None而非抛出异常 - 继承复用:通过继承
JsonOutputParser,自动获得了 JSON 解析和流式累积的全部能力
典型用法:
from pydantic import BaseModel
from langchain_core.output_parsers import PydanticOutputParser
class MovieReview(BaseModel):
title: str
rating: float
summary: str
parser = PydanticOutputParser(pydantic_object=MovieReview)
# 获取格式指令,注入到 Prompt 中
instructions = parser.get_format_instructions()
# 解析模型输出
result: MovieReview = parser.parse('{"title": "Inception", "rating": 9.5, "summary": "A mind-bending thriller"}')
7.6 XMLOutputParser:XML 格式的流式解析
XMLOutputParser 是一个独具特色的解析器。与 JSON 不同,XML 的流式解析可以利用 SAX/Pull 解析器的事件驱动特性,在每个标签闭合时就产出结果,而不需要等待整个文档完成。
7.6.1 流式解析架构
XMLOutputParser 使用了一个内部的 _StreamingParser 类来封装流式 XML 解析的状态:
# langchain_core/output_parsers/xml.py
class _StreamingParser:
def __init__(self, parser: Literal["defusedxml", "xml"]) -> None:
self.pull_parser = ET.XMLPullParser(["start", "end"], _parser=parser_)
self.xml_start_re = re.compile(r"<[a-zA-Z:_]")
self.current_path: list[str] = []
self.current_path_has_children = False
self.buffer = ""
self.xml_started = False
关键状态包括:
pull_parser:Python 标准库的XMLPullParser,支持增量喂入数据current_path:维护当前 XML 路径(如["root", "item"]),用于构建嵌套结构buffer:未处理的文本缓冲区xml_started:标记是否已经遇到了第一个 XML 标签
stateDiagram-v2
[*] --> WaitingForXml: 初始化
WaitingForXml --> WaitingForXml: 非 XML 文本
WaitingForXml --> Parsing: 检测到 XML 起始标签
Parsing --> Parsing: start 事件 - 压入 path
Parsing --> YieldElement: end 事件 - 叶子节点
YieldElement --> Parsing: 继续解析
Parsing --> WaitingForXml: 根标签闭合
WaitingForXml --> [*]: 流结束
7.6.2 安全考量
XML 解析器默认使用 defusedxml 库,以防御 XML 炸弹(Billion Laughs Attack)等安全威胁:
class XMLOutputParser(BaseTransformOutputParser):
parser: Literal["defusedxml", "xml"] = "defusedxml"
这是一个值得注意的安全设计决策。在处理不可信的 LLM 输出时,标准库的 XML 解析器可能受到某些 XML 漏洞的攻击,而 defusedxml 默认启用了所有安全防护。
7.6.3 嵌套结果构建
流式解析产出的结果使用 AddableDict 类型,支持通过 + 运算符合并:
def nested_element(path: list[str], elem: ET.Element) -> Any:
if len(path) == 0:
return AddableDict({elem.tag: elem.text})
return AddableDict({path[0]: [nested_element(path[1:], elem)]})
这个递归函数将 XML 路径和元素转换为嵌套字典。AddableDict 的可加性使得多次 yield 的结果可以被上层(如 LCEL 的 stream 聚合器)正确地合并在一起。
7.7 ListOutputParser 族:列表解析
LangChain 提供了三种列表解析器,均继承自 ListOutputParser:
| 解析器 | 格式 | 分隔模式 |
|---|---|---|
CommaSeparatedListOutputParser | foo, bar, baz | CSV 解析 |
NumberedListOutputParser | 1. foo\n2. bar | 正则 \d+\.\s([^\n]+) |
MarkdownListOutputParser | - foo\n- bar | 正则 ^\s*[-*]\s([^\n]+)$ |
7.7.1 流式列表解析的 droplastn 策略
列表解析器的流式实现面临一个独特挑战:在文本持续到达的过程中,最后一个元素可能是不完整的。解决方案是一个巧妙的 droplastn 函数:
# langchain_core/output_parsers/list.py
def droplastn(iter: Iterator[T], n: int) -> Iterator[T]:
"""Drop the last n elements of an iterator."""
buffer: deque[T] = deque()
for item in iter:
buffer.append(item)
if len(buffer) > n:
yield buffer.popleft()
这个函数使用一个长度为 n 的 deque 缓冲区,总是延迟 n 个元素输出。在列表解析的流式场景中,n=1 意味着最后一个匹配项被暂时保留(因为它可能是不完整的),只有当下一个匹配项到达后,前一个才会被确认输出。
# ListOutputParser._transform 中的核心逻辑
def _transform(self, input: Iterator[str | BaseMessage]) -> Iterator[list[str]]:
buffer = ""
for chunk in input:
buffer += chunk
try:
done_idx = 0
for m in droplastn(self.parse_iter(buffer), 1):
done_idx = m.end()
yield [m.group(1)]
buffer = buffer[done_idx:]
except NotImplementedError:
# 回退到非流式解析
parts = self.parse(buffer)
if len(parts) > 1:
for part in parts[:-1]:
yield [part]
buffer = parts[-1]
# 流结束,输出最后的缓冲内容
for part in self.parse(buffer):
yield [part]
这段代码的结构非常精巧:优先尝试基于正则迭代器的流式解析(parse_iter),如果子类未实现,则回退到基于完整解析的分块策略。两种路径都保持了"最后一个元素延迟输出"的不变量。
flowchart TD
A[输入 chunk] --> B[追加到 buffer]
B --> C{parse_iter 实现?}
C -->|是| D[正则匹配迭代]
C -->|否| E[parse 完整解析]
D --> F[droplastn 延迟 1 个]
F --> G[yield 已确认的匹配项]
G --> H[截断 buffer 到最后匹配位置]
E --> I{结果 > 1 项?}
I -->|是| J[yield 除最后一项外的所有项]
J --> K[buffer = 最后一项]
I -->|否| L[保持 buffer 不变]
H --> M{还有更多 chunk?}
K --> M
L --> M
M -->|是| A
M -->|否| N[yield buffer 中剩余内容]
7.8 OpenAI Tools/Functions 输出解析器
除了基于文本格式的解析器,LangChain 还提供了专门解析 OpenAI tool_calls 结构的解析器。这些解析器位于 output_parsers/openai_tools.py 和 output_parsers/openai_functions.py 模块中。
7.8.1 JsonOutputToolsParser
JsonOutputToolsParser 继承自 BaseCumulativeTransformOutputParser,它的输入不是纯文本,而是 AIMessage 中的 tool_calls 字段。其 parse_result 方法直接从 ChatGeneration 的消息对象中提取结构化数据:
# 简化的核心逻辑
def parse_result(self, result, *, partial=False):
generation = result[0]
if not isinstance(generation, ChatGeneration):
raise OutputParserException("This parser only works with ChatGeneration")
message = generation.message
if isinstance(message, AIMessage) and message.tool_calls:
tool_calls = message.tool_calls
# 从 tool_calls 中提取参数
return [{"type": tc["name"], "args": tc["args"]} for tc in tool_calls]
这种解析方式比文本解析更加可靠,因为模型 API 返回的 tool_calls 结构已经是合法的 JSON,不存在格式异常的风险。
7.8.2 PydanticToolsParser
PydanticToolsParser 在 JsonOutputToolsParser 的基础上增加了 Pydantic 模型验证,将原始字典转换为类型安全的 Pydantic 实例。它接受一个 tools 参数,包含一个或多个 Pydantic 模型类,并根据 tool_call 的 name 字段选择对应的模型进行验证。
7.8.3 两种解析路径的对比
flowchart LR
subgraph 文本解析路径
A1[模型输出文本] --> B1[JsonOutputParser]
B1 --> C1["parse_json_markdown(text)"]
C1 --> D1[dict]
end
subgraph Tool Call 解析路径
A2[模型输出 tool_calls] --> B2[JsonOutputToolsParser]
B2 --> C2["message.tool_calls"]
C2 --> D2["list of dict"]
end
文本解析路径需要处理 Markdown 代码块、不完整 JSON 等各种边界情况;Tool Call 解析路径则直接从 API 结构中提取数据,省去了文本解析的复杂性。在使用支持 function calling 的模型时,Tool Call 路径是更推荐的选择。
7.9 with_structured_output:模型层面的结构化输出
LangChain 在模型层面提供了 with_structured_output 方法,这是一种更现代、更推荐的结构化输出方式:
# langchain_core/language_models/chat_models.py
class BaseChatModel:
def with_structured_output(
self,
schema: dict[str, Any] | type,
*,
include_raw: bool = False,
**kwargs: Any,
) -> Runnable[LanguageModelInput, dict[str, Any] | BaseModel]:
...
with_structured_output 返回一个新的 Runnable,它将模型的结构化输出能力(如 function calling、tool use、JSON mode)与输出解析组合在一起。与传统的 OutputParser 相比,它有几个显著优势:
flowchart TD
subgraph 传统方式
A1[Prompt + 格式指令] --> B1[模型生成文本]
B1 --> C1[OutputParser 解析文本]
C1 --> D1{解析成功?}
D1 -->|否| E1[OutputParserException]
D1 -->|是| F1[结构化结果]
end
subgraph with_structured_output
A2[Prompt] --> B2[模型直接生成结构化数据]
B2 --> C2[API 返回 tool_calls]
C2 --> D2[Pydantic 验证]
D2 --> F2[结构化结果]
end
关键区别:
| 维度 | OutputParser 方式 | with_structured_output 方式 |
|---|---|---|
| 结构约束 | 通过 Prompt 指令引导 | 通过 API 参数强制 |
| 可靠性 | 依赖模型遵循指令 | API 级别保证 |
| 流式支持 | 需要累积解析 | 模型原生支持 |
| 适用范围 | 所有模型 | 支持 tool calling 的模型 |
| 错误处理 | 解析异常 | 验证异常 |
include_raw=True 选项可以同时返回原始消息和解析结果,便于调试和错误处理:
model = ChatOpenAI(model="gpt-4o")
structured_model = model.with_structured_output(MovieReview, include_raw=True)
result = structured_model.invoke("Review the movie Inception")
# result = {
# "raw": AIMessage(...),
# "parsed": MovieReview(title="Inception", ...),
# "parsing_error": None
# }
7.10 OutputParserException:统一的错误处理
当解析失败时,所有解析器都通过 OutputParserException 报告错误。这个异常类携带了关键的调试信息:
class OutputParserException(ValueError):
def __init__(self, error, observation=None, llm_output=None, send_to_llm=False):
super().__init__(error)
self.observation = observation or error
self.llm_output = llm_output
self.send_to_llm = send_to_llm
| 属性 | 含义 |
|---|---|
observation | 可选的人类可读错误描述 |
llm_output | 导致解析失败的原始 LLM 输出 |
send_to_llm | 是否应将错误信息返回给 LLM 进行修正 |
send_to_llm 标志特别有趣 -- 当设为 True 时,Agent 框架可以将解析错误作为反馈发送给模型,让模型重新生成符合格式要求的输出。这种"自我修正"机制可以与 RetryOutputParser(重试解析器)配合使用,自动重试失败的解析。
在实际开发中,一个常见的模式是将格式指令和错误反馈组合使用:
from langchain_core.output_parsers import PydanticOutputParser
from pydantic import BaseModel
class Answer(BaseModel):
result: str
confidence: float
parser = PydanticOutputParser(pydantic_object=Answer)
# 将格式指令注入 Prompt
prompt = f"""Answer the question.
{parser.get_format_instructions()}
Question: What is 2+2?"""
# 如果解析失败,捕获异常并获取原始输出
try:
answer = parser.parse(model_output)
except OutputParserException as e:
print(f"Failed to parse: {e.llm_output}")
7.11 解析器在 LCEL 链中的位置
理解解析器在典型 LCEL 链中的位置和数据流向,有助于在实践中正确使用它们:
flowchart LR
A[PromptTemplate] -->|PromptValue| B[ChatModel]
B -->|AIMessage| C[OutputParser]
C -->|T| D[下游处理]
style A fill:#e1f5fe
style B fill:#fff3e0
style C fill:#e8f5e9
style D fill:#f3e5f5
在流式场景下,数据流变为:
PromptTemplate -> ChatModel.stream() -> AIMessageChunk流 -> OutputParser.transform() -> T类型chunk流
每种解析器在流式链中的行为不同:
| 解析器 | 流式行为 | 输出粒度 |
|---|---|---|
StrOutputParser | 每个 chunk 直接输出 | token 级别 |
JsonOutputParser | 累积后输出部分 JSON | 每当有新 key 完成时 |
PydanticOutputParser | 累积到完整时输出 | 完整对象或 None |
XMLOutputParser | 每个标签闭合时输出 | 标签级别 |
ListOutputParser | 确认一项完成时输出 | 列表项级别 |
这种差异化的流式行为使得开发者可以根据 UI 需求选择合适的解析器。例如,实时显示聊天回复使用 StrOutputParser;渐进式填充表单使用 JsonOutputParser;流式解析结构化配置使用 XMLOutputParser。
7.12 设计决策分析
为什么解析器是 Runnable?
将解析器统一为 Runnable 是 LangChain 设计中最重要的决策之一。这意味着解析器可以通过 | 运算符与其他组件自由组合:
chain = prompt | model | JsonOutputParser()
这不仅带来了语法上的简洁,更重要的是它使得回调、追踪、重试等横切关注点可以统一处理。每次解析操作都会触发 on_chain_start/on_chain_end 回调,LangSmith 可以记录每一次解析的输入输出。
为什么同时支持 parse 和 parse_result?
parse(text: str) 是面向开发者的简洁接口,而 parse_result(result: list[Generation]) 是面向系统内部的通用接口。大多数子类只需要实现 parse,而 parse_result 提供了一个扩展点 -- 某些解析器可能需要从 Generation 的其他字段(如 generation_info)中提取信息。
流式解析的两种范式
LangChain 提供了两种流式解析范式:
- 逐块独立解析(
BaseTransformOutputParser):适用于每个 chunk 都是完整可解析单元的场景,如StrOutputParser - 累积后解析(
BaseCumulativeTransformOutputParser):适用于需要累积完整文本才能解析的场景,如JsonOutputParser
这种分层设计让子类可以根据自己的语义选择合适的基类,而不需要关心流式调度的细节。
格式指令的局限性
get_format_instructions() 本质上是一种"协作式"约束 -- 它通过自然语言告诉模型应该输出什么格式。这种方式的问题在于模型不一定会遵循指令。LangChain 的解决方案是双管齐下:一方面通过格式指令尽量引导模型,另一方面通过严格的解析逻辑和 OutputParserException 来检测并报告格式错误。with_structured_output 则从根本上解决了这一问题,通过 API 层面的约束来确保输出格式。
7.13 小结
LangChain 的输出解析系统展现了精心设计的层次结构:从 BaseLLMOutputParser 的最小接口,到 BaseOutputParser 的 Runnable 集成,再到 BaseTransformOutputParser 和 BaseCumulativeTransformOutputParser 的流式支持,每一层都在前一层的基础上增添恰到好处的能力。
核心解析器家族 -- StrOutputParser、JsonOutputParser、PydanticOutputParser、XMLOutputParser、以及 ListOutputParser 族 -- 覆盖了绝大多数结构化输出需求。它们通过统一的 Runnable 协议无缝融入 LCEL 链,通过分层的流式解析策略(逐块 vs 累积)适应不同的解析语义。
随着模型能力的演进,with_structured_output 代表了结构化输出的未来方向 -- 从"解析模型的文本输出"转向"让模型直接生成结构化数据"。但传统的输出解析器在兼容性、灵活性和对非 tool-calling 模型的支持方面,仍然有着不可替代的价值。