首页 / 文章

从原始文本到管道:LangChain中的解析器、LCEL、可运行对象与内存机制

LangChain 如何将原始模型文本转换为结构化数据,如何利用 LCEL 和 Runnable 接口组合各步骤,以及目前是如何处理对话记忆的。

发布者:REACTAPP.TOP阅读约 10 分钟2276 词

来源: LangChain Explained: Output Parsers, Chains, Runnables, and Memory

使用精心设计的提示模板调用语言模型仅是实际应用的第一步。模型仍然会返回非结构化的字符串,每次调用都会忘记之前的所有内容,而且手动将提示、模型和后处理步骤串联起来很快就会变成重复性的样板代码。本指南介绍了四个能够解决这些问题的LangChain概念:输出解析器、用LCEL构建的链式结构、实现组件化功能的Runnable接口以及内存管理机制。掌握这些内容后,你应能理解任何LCEL流程,为特定任务选择合适的解析器或Runnable,并了解当前LangChain中对话状态存储的位置。

本文假设你已经了解LangChain中的LLM封装器与提示模板是什么,因为后续所有内容都是基于它们构建的。

输出解析器:将文本转换为数据

解析器解决的问题

无论你向模型提出什么问题,得到的回应都是自由格式的文本。对于那些仅在屏幕上显示答案的简单聊天机器人来说,这已经足够了。但一旦你的代码需要对答案进行操作,比如将其写入数据库、传递给其他函数或以JSON格式通过API返回,单纯的字符串就会成为负担。

输出解析器位于模型与程序的其他部分之间。它接收生成的文本,并将其转换为结构化的数据:列表、字典、JSON对象或你自定义的Python类实例。可以将其想象成一种解释器:模型用松散的散文形式表达内容,而解析器则将这些散文转换成代码能够依赖的结构。

为何不应手动解析

  • 模型的输出格式并不确定;相同的提示在不同运行中可能会产生略有不同的结构。
  • API、数据库和用户界面都需要一致且可预测的数据。
  • 临时的字符串分割方法或正则表达式十分脆弱,一旦出现意外变化就会失效。
  • 解析器还可以进行验证,这样缺失的字段或格式错误的值就能在边界处被捕获,而不会在后续流程中引发令人困惑的错误。
  • 内置解析器系列

    LangChain提供了多种解析器,每种都针对不同的数据格式设计:

    • 字符串输出解析器(StrOutputParser):最简单的选项,可从模型的响应消息中提取纯文本。当您需要显示干净的字符串或将其传递给其他处理步骤时可以使用它。
    • 列表解析器:将响应拆分为Python列表,当您需要获取多个项目(如关键词或建议)时十分方便。
    • JSON输出解析器:将响应转换为JSON对象,是在需要结构化数据时最常使用的解析器之一。
    • Pydantic输出解析器:直接将响应映射到Pydantic模型上,从而在结构之外实现类型验证。当数据必须遵循严格的模式时,这是最佳选择。
    • 日期时间解析器:生成真正的Python datetime对象,而非以文本形式存在的日期。
    • 枚举解析器:将输出限制在预定义集合中的一个值,并返回该值。
    • XML解析器:读取以XML标签形式编写的输出,并将其转换为字典。

    结构化解析器提供了一个get_format_instructions()方法。该方法会返回一段文本,你可以将其插入提示词中,明确告知模型如何组织答案,以便解析器能够读取。而普通字符串解析器则无需执行任何格式约束,因此是该系列中不适用于格式指令的成员。

    解析器与原生结构化输出

    许多现有模型能够直接返回结构化数据,LangChain通过with_structured_output()功能实现了这一点。当该功能可用时,它通常可以替代解析器,因为数据格式是由模型提供方强制规定的,而非通过提示词来请求。当模型不具备原生结构化输出功能,或者你需要比提供方所保证的更严格的验证或转换时,解析器依然具有其存在的价值。

    链与LCEL:连接各个步骤

    什么是链式流程

    链式流程是一系列步骤的组合,每个步骤的输出会成为下一个步骤的输入。无需分别编写三段代码来格式化提示词、调用模型以及解析结果,只需将它们整合为一个可重复使用的流程即可。

    典型的链式流程包含三个阶段:提示词模板、模型,以及输出解析器。用户只需提供原始输入,如主题或问题,整个流程就会自动完成提示词格式化、模型调用以及结果解析,所有操作都在同一流程中完成。

    链式流程的优势

    • 减少冗余代码。步骤间的连接逻辑只需编写一次,无需在每次调用时重复编写。
    • 模块化设计。提示词模板、模型和解析器都是独立的组件,因此可以单独更换其中任意一个,而不会影响其他部分。
  • 可读性。 即使工作流程十分复杂,链条也能让人一目了然地看出数据流的方向。
  • 可重用性。 一旦定义完成,该链条就可以使用不同的输入反复调用。
  • 使用管道运算符组合元素

    LangChain表达式语言LCEL让构建链条变得轻便易行。它使用|运算符来连接各个组件,就像在Unix shell中用管道串联命令一样,而非将函数调用相互嵌套。

    下面的两行代码构建了上文所述的三个阶段的链条,并使用字典输入运行了一次。请注意,字典中的键topic必须与提示模板中的变量相匹配。

    chain = prompt | model | output_parser
    result = chain.invoke({"topic": "black holes"})
    

    输入首先进入prompt模块,该模块会生成格式化的提示语;随后提示语被传递给model模型,而模型的回复则会被送到output_parser解析器。每个组件都会将其处理结果直接传递给下一个组件,result变量则存储着解析器返回的内容。

    每个LCEL链都配有标准的一组执行方法:

    • .invoke():针对单个输入仅执行一次该链。
    • .batch():一次性处理输入列表中的所有内容。
    • .stream():在输出生成过程中逐步提供结果,无需等待完整答案。只有当链中的每个步骤都能处理流式数据时,才会逐个token地传递结果;如果某个步骤需要先获取全部输入,则会进行缓冲处理。

    这种声明式风格正是让LCEL具备组合性的重要原因。你只需描述需要发生什么以及顺序如何,而无需关心每一步是如何调用下一步的实现机制。

    可运行对象:底层接口

    为何一切都能通过管道连接

    在LCEL背后,无论是提示词、模型、解析器还是你自己编写的函数,所有组件都是可运行对象。可运行对象是一种通用接口,任何组件都可以实现它,正是这种统一的契约使得|符号能够发挥作用。由于各个组件都提供了相同的方法,因此无论其内部实现如何,都可以相互连接。

    正是由于相同的接口,与链式结构的交互才显得一致。无论某个组件是调用模型、重新处理数据还是同时运行多个分支,都可以通过相同的 .invoke()、.batch() 和 .stream() 方法来控制它。由多个组件构成的链本身也是一个 Runnable 对象,因此可以直接将多个链嵌套在更大的链中,无需任何特殊处理。

    最常使用的 Runnable 对象

    • RunnableSequence 会严格按顺序执行各个步骤,将每个步骤的输出传递给下一个步骤。使用 | 连接各个组件时,系统会在后台自动创建这样的对象。典型应用场景:先输入提示信息,再调用模型,最后进行解析。
  • RunnableParallel 可以在相同输入上同时运行多个可执行对象,并将它们的结果按名称作为键汇总到一个字典中。典型用途是同时对文本进行摘要处理和语言检测,而非依次执行。
  • RunnablePassthrough 会原样返回输入内容。它通常出现在 RunnableParallel 中,用于在处理后的结果旁保留原始输入。典型用途是同时返回用户的原始问题及生成的答案。
  • RunnableLambda 用于封装普通的 Python 函数,使其能够成为处理链中的一环。典型用途是在各处理步骤之间进行自定义的预处理或后处理,如文本清洗、单词统计或应用业务规则。
  • RunnableBranch 可实现 if/else 路由功能,根据特定条件将输入发送到不同的可执行组件。典型用途是将技术类问题导向某一处理链,而将普通问题导向另一处理链。
  • 一旦意识到模型、解析器和处理链其实都是共享同一接口的可执行组件,LangChain 就不再显得像是一堆特例的集合。它变成了普通的组合结构:具有相同结构的小组件被组合成同样结构的更大单元。

    关于过度组合的注意事项

    由于任何内容都可以成为可运行对象,人们很容易倾向于将所有应用逻辑都用嵌套的链式结构来表达。对于简单的步骤来说这确实可行,但深度嵌套的分支结构和匿名函数反而会比普通的 Python 代码更难调试。一个合理的做法是:将面向模型的处理流程用 LCEL 实现,而将复杂的控制流保留在普通代码中;如果涉及循环和状态,则将其移至图框架中处理。

    内存:在多轮对话间保持上下文

    此处所谓的内存含义

    默认情况下,每次对模型的调用都是无状态的。除非应用程序重新发送之前的对话内容,否则模型不会知晓你刚才询问了什么。在 LangChain 中,内存是一组用于存储对话上下文并在之后检索它的机制,这样应用程序就能延续对话,而无需将每条消息都当作第一次交流来处理。

    为什么对话应用需要它

    • 如果没有记忆功能,聊天机器人一旦收到新消息就会丢失所有信息:既没有连贯性,也无法进行后续回应,更无法共享上下文。
    • 自然的对话依赖于之前的交流内容,例如要明白后续消息中的“他”指的是两条消息之前提到的人。
    • 通过记忆上下文,助手才能显得逻辑连贯、具有对话感,而不会孤立地回复每条消息。
    • 这是客服机器人、个人助手以及任何其他多轮对话应用的基本要求。

    经典的记忆分类

    传统方法使用运行在进程内部的记忆类。最简单的方式是保存整个对话的缓冲区;另一种方式则是将较旧的消息压缩成持续的摘要,以节省计算资源。每次发起新请求时,存储的上下文会被重新注入到提示语中。

    现在的记忆存储位置

    由于 LangChain 将对话状态移至 LangGraph 的持久化层,那些进程内存类已被弃用。在新的模型中,检查点工具会为每个会话(由线程标识)保存对话状态,而独立的存储则用于保存应在不同会话间保留的长期记忆。核心理念并未改变:之前的上下文必须存储在某个地方以便重复使用。变化在于推荐的实现方式,它现在更加可靠,也更适合生产环境,因为状态可以持久化到真正的数据库中,而不会在进程重启时丢失。如果您想了解检查点工具究竟是如何存储这些状态的,这篇关于 LangGraph InMemorySaver 的文章详细介绍了其内部机制。

    合理的学习路径是先理解经典的记忆概念,因为它能解释为什么记忆如此重要以及提示中应包含哪些内容,之后再针对实际应用采用基于持久化的方法。由于该领域发展迅速,在开发之前请查阅最新的 LangChain 和 LangGraph 文档,了解具体的 API 接口。

    总结

    结合 LLM 封装工具和提示模板,这些概念构成了 LangChain 的核心工具集。单独来看每个部分都较为简单,但它们的价值在于相互结合:模板为模型提供输入,解析器将模型的文本转换为数据,各个组件通过 | 连接成链式结构,而持久化层则负责记录多次调用之间的对话内容。

    • 每当代码需要使用模型的输出时,可使用输出解析器,或利用模型支持的本地结构化输出方式。
  • 将任何 LCEL 流水线从左到右视为 RunnableSequence;当流程并非直线型时,则使用 RunnableParallel、RunnablePassthrough、RunnableLambda 和 RunnableBranch。
  • 请记住,流式处理的进度仅取决于最慢的步骤。
  • 可将传统内存类视为需要学习的概念,而在实际应用中则应使用 LangGraph 的检查点机制与存储功能。
  • 掌握了这些基础组件后,接下来的自然话题就是检索增强生成与智能体,这两者正是由这些组件构建而成的。