LangChain:Runnable 与 LCEL——把多步链路写成可读表达式

当一个大模型应用只需要「单次问答」时,简单地调用一次 API 往往就够了。
但一旦出现「先检索、再调用模型、然后再解析、再写库、最后再发一条通知」这种多步链路,如果每一步都用普通函数互相调用,代码很快会变成难以阅读和调试的胶水。
这一篇围绕 LangChain 的 Runnable 与 LCEL(LangChain Expression Language),尝试说明:为什么需要一套链式表达方式、多步链路是如何被描述的,以及在工程实践中应该如何使用这些抽象。

在没有 Runnable / LCEL 的情况下,多步流程大多写成:

  • 一串函数调用嵌套;
  • 或者一堆中间变量和辅助函数。

例如一个简单的 RAG + 解析 + 写库链路,伪代码可能是:

1const docs = await retriever.getRelevantDocuments(question);
2const prompt = buildPrompt(question, docs);
3const raw = await callModel(prompt);
4const parsed = parseOutput(raw);
5await saveToDb(parsed);
6return formatForUser(parsed);

这在简单场景下完全可以,但随着分支和并行处理增多,会出现几个问题:

  • 可读性下降
    • 逻辑散落在多个文件、多个辅助函数中,很难一眼看出整体流程;
    • 想插入新的步骤(比如增加一层重试、做 A/B)需要改很多地方。
  • 难以插桩与观测
    • 想在每一步前后记录日志、统计时延、测 token 成本,需要在每个函数里反复加类似逻辑;
    • 一旦链路变成「树状」或「图状」,观测更难。
  • 难以复用子链路
    • 某一小段链路(比如「检索 + 拼 prompt + 调用模型 + 解析」)想在多个场景复用,需要拆函数、复制代码或硬塞参数。

Runnable / LCEL 的设计目标,是提供一种:

  • 既能表达「多步数据流动」;
  • 又能在每个节点前后挂钩子观测;

的统一抽象。

在 LangChain 中,可以把 Runnable 理解为:

  • 一个「输入 → 输出」的可调用单元,可以是:
    • 一个模型调用;
    • 一个 PromptTemplate;
    • 一个 OutputParser;
    • 一个 Retriever;
    • 一个自己写的函数。

只要满足「接收输入、返回输出」的约定,就可以被当成 Runnable:

  • 支持串联:上一个 Runnable 的输出直接喂给下一个;
  • 支持并联:把一个输入广播给多个 Runnable,再对结果做合并;
  • 支持 map:对列表中的每一项应用某个 Runnable。

从工程角度看,这和在流式处理框架里定义「算子」的概念非常接近。

LCEL(LangChain Expression Language)是在 Runnable 之上提供的一套表达方式,目标是:

  • 用接近数据流/管道的语法,把「先做 A,再做 B,然后把结果拆成 C 和 D」这种流程写出来;
  • 让这段表达在代码里一眼就能看清主干逻辑。

一个典型的链式结构可以抽象成:

  • promptTemplatemodeloutputParser
  • 或者更复杂一些:
    • 输入先分成两支:一支检索文档、另一支直接传原始问题;
    • 检索结果和原始问题合并后再送给模型。

用 LCEL 的好处是:

  • 流程结构直接写在一处,不像传统写法那样被拆散在多个函数中;
  • 任何人拿到这段链,就能快速理解「这条链到底干了几步、每步大致是什么」。

Runnable / LCEL 另一个非常重要的设计,是对观测和调试的支持。
由于每个节点都是 Runnable,可以在其前后统一挂 callback:

  • 在进入某个节点前记录输入;
  • 在节点执行完后记录输出和时延;
  • 对失败进行捕获和重试统计。

这样,在一个复杂的链路中:

  • 可以方便地看到「当前请求走到了哪个节点」;
  • 每个节点的输入输出是否符合预期;
  • 哪些节点耗时最多、失败率最高。

对比传统写法:

  • 不需要在每个业务函数里手动加 try/log;
  • 可以通过统一的 callback 机制,把「观测逻辑」和「业务逻辑」分层。

Runnable / LCEL 本身是抽象层,真正的工程质量仍然取决于:

  • 每个节点职责是否足够单一、可测试;
  • 输入输出类型是否清晰、稳定;
  • 链路是否过长、是否存在可以拆分的子链。

一些实践中的简单建议:

  • 先画图再写链
    • 先在白板/文档里画出「数据从哪里来、经过哪些处理、最后去哪」;
    • 再用 LCEL 把这张图翻译成表达式,而不是边写边迭代。
  • 为子链命名
    • 把常用的子流程封装成独立的 Runnable 链;
    • 起一个清晰的名字,方便在其他地方复用。
  • 链越长,越需要观测
    • 对关键节点增加详细日志与指标;
    • 为长链设计重试与降级策略,避免某个节点出问题时拖垮整个应用。

从整体 LangChain 设计来看,Runnable / LCEL 处在「胶水层」的位置:

  • 它不决定用哪个模型、哪个向量库、哪种工具调用方式;
  • 但决定了「这些东西如何被组合在一起」以及「如何被观测和迭代」。

当应用规模较小时,可以只用最基础的调用方式,不必一开始就引入 LCEL;
但一旦出现跨模块、多步、可观测需求明显的链路,把这些流程用 Runnable / LCEL 表达出来,往往能大幅提升可维护性与可调试性。