在 LLM 应用的开发浪潮中,LangChain 框架迅速成为最受欢迎的工具之一。随着应用的主键复杂化,复杂的 RAG、智能体(Agent)、多步骤推理等需求变得普遍,早期的编程范式逐渐显露出了局限性,面向对象的链式构建方式在应对这种复杂性时显得非常笨拙和难以维护。所以 LangChain 在 2023 年 8 月推出了 LCEL(LangChain Expression Language, LangChain 表达式语言),进行了范式的升级。

1.1. 从命令式到声明式

在没有 LCEL 之前,构建链的方式很传统,通常依赖于命令式的、面向对象的方式,例如使用 LLMChain 类,开发者需要实例化类,配置参数,然后显式地调用执行方法,就像用基本的积木一块一块地手动拼接。这种方式虽然直观,但在构建复杂应用时会导致代码冗长、逻辑流难以追踪,并且深度定制化相对困难。

LCEL 引入了一种声明式的组合方法,开发者不再需要详细描述“如何”执行每一步,而是通过一种简洁的语法来描述“做什么”——定义数据应该如何在一个组件网络中流动。这种声明式的特性将执行优化的责任交给了 LangChain 框架本身。开发者只需构建一个由 Runnable 对象组成的计算图,框架就能在运行时智能地决定最高效的执行策略。这不仅极大地简化了代码,也为一系列强大的生产级特性奠定了基础。

做个对比吧。

LLMChain 的工作很简单,接受一个输入,使用提示模板(PromptTemplate)格式化输入,然后将格式化后的提示词发送给 LLM,最后返回 LLM 的输出。

from langchain.llms import OpenAI
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain

# 1. 实例化 LLM 模型
llm = OpenAI(temperature = 0.7)
# 2. 构建提示词模板
prompt = PromptTemplate(
	input_variables = ["product"],
    template = "What is a good name for a company that makes {product}?",
)
# 3. 构建链,将零件通过参数传入
chain = LLMChain(llm = llm, prompt = prompt)
# 4. 运行链,显示调用 run()
result = chain.run("colorful socks")
print(result)

可以看到有这么几个问题:

  1. 很简单的调用 LLM 回答问题,也需要四个步骤,那么对于复杂应用,嵌套和组合多个链会让代码变得很长
  2. 业务逻辑流程其实是比较难追踪的。比如 A 链的输出要作为 B 链的输入,就需要手动处理这个传递过程,当这类过程变得复杂,整体结构会变得不清晰。
  3. 若要深度定制也会有困难。如果想在链的执行过程中加入一些自定义的逻辑,比如对 LLM 的输出进行清洗之后再传递给下一步,通常就需要创建一个全新的、继承自 LLMChain 的子类,重写它的方法。这就显得很重了。

现在同样的需求,使用 LCEL 写一遍。

# 使用新的模块
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI

# 提示词与 LLM 实例化与之前类似
prompt = PromptTemplate.from_template(
	"What is a good name for a company that makes {product}?"
)
llm = ChatOpentAI(temperature = 0.7)

# 使用 | 操作符声明式组合链
# 含义:创建一个流程,数据先流向 prompt 模板进行格式化,然后其结果自动流向 llm 模型进行调用
chain = prompt | llm
# 使用统一的 invoke 方法执行
result = chain.invoke({"product": "colorful socks"})
print(result.content)

代码看起来真的清爽很多。

重点在 | 操作符,意味着『将左边组件的输出,作为右边组件的输入』。

以及 LCEL 链有一个统一的接口 .invoke() 进行执行。

LCEL 引入了一种全新的声明式编程模型,不用再关心“如何一步步执行”,而是声明”各个组件之间关系如何连接“,LCEL 会自动构建执行流程。

特性 LLMChain LCEL 优势
范式 命令式、面向对象 声明式、函数式 LCEL 更简洁,专注于定义“是什么”而不是“怎么做”
组合方式 通过类的构造函数传入参数 使用 ` ` 管道操作符
代码长度 冗长,步骤多 简洁 链的复杂度越高,LCEL 的优势越明显
逻辑清晰度 逻辑分散在多个实例化和调用中 逻辑在管道中一目了然 数据流清晰可见,易于理解和调试
定制化 困难,需创建子类 简单,使用标准接口 可以轻松插入任何实现了 .invoke() 方法的自定义函数到管道中
功能特性 基础 内置强大功能 LCEL 链原生支持异步、流式输出、批量处理等,无需额外代码

1.2. LCEL 原生能力

LCEL 之所以被誉为“为生产而生”,是因为它为所有通过它构建的链赋予了一系列开箱即用的核心能力,而这些能力在传统编程模式下需要大量的手动实现:

  • 异步支持

    任何使用 LCEL 构建的链都天然支持异步调用。通过 invoke()、abatch()、astream() 等异步方法,可以轻松的将 LangChain 应用集成到现代异步 Python Web 框架中比如 FastAPI,构建能处理高并发请求的服务。

  • 并行执行

    当数据流中存在可以并发处理分支的时候,LCEL 会自动利用 RunnableParallel 或 batch API 来并行执行这些任务,对于 IO 密集型操作比如多次调用 API,能显著降低应用的整体延迟。

  • 流式处理

    流式响应是提升 LLM 应用用户体验的关键。LCEL 让流式处理变得十分简单。任何链都可以通过 stream() 或这是 astream() 方法进行调用,框架会自动处理从模型到最终输出的整个过程的数据流,以最小化“首个令牌时间”(Time-to-First-Token)让用户能即时看到结果的生成过程。

  • 统一接口

    LCEL 的所有组件都遵循一个名为 Runnable 的标准协议。无论是模型、提示模板、输出解析器还是检索器,他们都共享一套标准方法,比如 invoke()、batch()、stream() 等。这种一致性大大增强了代码的可预测性和可组合性,让开发者可以像拼接乐高积木一样构建复杂的应用。

LCEL 的诞生并非简单的语法升级,而是 LangChain 框架走向成熟的标志。它体现了一种控制反转的设计哲学,即开发者将执行流程的控制权交给框架,以换取生产环境所必须的性能、健壮性和可观测性(比如与 LangSmith 平台的无缝集成),通过定义一个声明式的数据流图,框架获得了对整个执行过程的全局视角,从而能够施展那些在命令式代码中难以自动实现的优化,例如在遇到并行步骤时自动启用线程池(同步执行)或利用 asyncio.gather(异步执行)

2. RunnableSequence 与 | 管道操作符

LCEL 有两个核心概念:

  • 作为通用接口的 Runnable 协议
  • 用于顺序组合的 RunnableSequence

2.1. Runnable 协议

在 LCEL 的世界里,万物皆 Runnable。从最基础的提示词模板(PromptTemplate)和语言模型(LLM/ChatModel)到功能性的组件比如输出解析器(OutputParser)和文档检索器(Retriever)都实现了 Runnable 接口,这意味着他们都遵循一套共同的交互标准。即拥有了:

  • invoke,单输入单输出
  • batch,多输入多输出
  • stream,流式输出
  • ...等等标准方法

正是这种统一的接口,使得不同的组件可以被自由、无缝的组合在一起,构成 LCEL 强大可组合的基础。

2.2. RunnableSequence

RunnableSequence is the most important composition operator in LangChain as it is used in virtually every chain.

RunnableSequence 是 LangChain 中最重要的组合操作符,因为它几乎在每个链中都使用。

上面是官方文档原文。

RunnableSequence 的作用非常纯粹:将多个 Runnable 组件按照顺序链接起来,形成一个线性的处理管道,在这个管道中前一个组件的输出会直接作为后一个组件的输入。

可以通过构造函数 RunnableSequence(first=..., middle=..., last=...) 或 RunnableSequence(steps=[...]) 来显式地创建一个序列,但实际开发中几乎不这么用。绝大多数情况下都会使用一种更简洁、更具表达力的语法糖,即管道操作符 |

2.3. | 操作符:优雅背后的魔法

| 管道操作符允许开发者以一种极其直观的方式将 Runnable 组件链接在一起,就像在 Linux 命令行中使用管道一样。例如:

chain = prompt | model | output_parser

这种优雅语法的背后是 Python 的操作符重载机制在发挥作用。

当解释器遇到 runnable1 | runnable2 这样的表达式时,它会调用 runnable1 对象的 __or__ 特殊方法。在 LangChain 的 Runnable 基类中 __or__ 方法的实现逻辑就是创建一个新的 RunnableSequence 实例,将 self (即 runnable1) 作为第一个元素,other (即 runnable2) 作为第二个元素。同样Runnable 类也实现了 __ror__ 方法,这使得 dict | runnable 这样的表达式(其中字典本身不是 Runnable)也能正确工作。

说明很绕,可以看如下图。

2.4. 代码实践

构建一个经典的 Prompt | Model | Parser 链。

# 1. 导入必要的库
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

# 2. 初始化模型(请确保已设置 OPENAI_API_KEY 环境变量)
model = ChatOpenAI(model="gpt-3.5-turbo")

# 3. 创建提示模板
prompt = ChatPromptTemplate.from_template("讲一个关于 {topic} 的笑话。")

# 4. 创建输出解析器
output_parser = StrOutputParser()

# 5. 使用 | 操作符构建 RunnableSequence
chain = prompt | model | output_parser

# 6. 调用链(invoke 方法会同步执行整个链)
response = chain.invoke({"topic": "程序员"})
print(response)

可以看到prompt | model | output_parser 这行代码就隐式地创建了一个 RunnableSequence

当调用 chain.invoke({"topic": "程序员"}) 时,数据流程是这样的:

Logo

更多推荐