LangChain:Runnable 与 LCEL——把多步链路写成可读表达式
当一个大模型应用只需要「单次问答」时,简单地调用一次 API 往往就够了。
但一旦出现「先检索、再调用模型、然后再解析、再写库、最后再发一条通知」这种多步链路,如果每一步都用普通函数互相调用,代码很快会变成难以阅读和调试的胶水。
这一篇围绕 LangChain 的 Runnable 与 LCEL(LangChain Expression Language),尝试说明:为什么需要一套链式表达方式、多步链路是如何被描述的,以及在工程实践中应该如何使用这些抽象。
1. 多步链路如果不用 LCEL,会长成什么样?
在没有 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 的设计目标,是提供一种:
- 既能表达「多步数据流动」;
- 又能在每个节点前后挂钩子观测;
的统一抽象。
2. Runnable:把每一步都看成「可组合的算子」
在 LangChain 中,可以把 Runnable 理解为:
- 一个「输入 → 输出」的可调用单元,可以是:
- 一个模型调用;
- 一个 PromptTemplate;
- 一个 OutputParser;
- 一个 Retriever;
- 一个自己写的函数。
只要满足「接收输入、返回输出」的约定,就可以被当成 Runnable:
- 支持串联:上一个 Runnable 的输出直接喂给下一个;
- 支持并联:把一个输入广播给多个 Runnable,再对结果做合并;
- 支持 map:对列表中的每一项应用某个 Runnable。
从工程角度看,这和在流式处理框架里定义「算子」的概念非常接近。
3. LCEL:用表达式把 Runnable 串成「可读的链」
LCEL(LangChain Expression Language)是在 Runnable 之上提供的一套表达方式,目标是:
- 用接近数据流/管道的语法,把「先做 A,再做 B,然后把结果拆成 C 和 D」这种流程写出来;
- 让这段表达在代码里一眼就能看清主干逻辑。
一个典型的链式结构可以抽象成:
promptTemplate→model→outputParser;- 或者更复杂一些:
- 输入先分成两支:一支检索文档、另一支直接传原始问题;
- 检索结果和原始问题合并后再送给模型。
用 LCEL 的好处是:
- 流程结构直接写在一处,不像传统写法那样被拆散在多个函数中;
- 任何人拿到这段链,就能快速理解「这条链到底干了几步、每步大致是什么」。
4. 观测:在链上的任何节点插钩子
Runnable / LCEL 另一个非常重要的设计,是对观测和调试的支持。
由于每个节点都是 Runnable,可以在其前后统一挂 callback:
- 在进入某个节点前记录输入;
- 在节点执行完后记录输出和时延;
- 对失败进行捕获和重试统计。
这样,在一个复杂的链路中:
- 可以方便地看到「当前请求走到了哪个节点」;
- 每个节点的输入输出是否符合预期;
- 哪些节点耗时最多、失败率最高。
对比传统写法:
- 不需要在每个业务函数里手动加 try/log;
- 可以通过统一的 callback 机制,把「观测逻辑」和「业务逻辑」分层。
5. 在工程里,用 LCEL 描述链路时需要注意什么?
Runnable / LCEL 本身是抽象层,真正的工程质量仍然取决于:
- 每个节点职责是否足够单一、可测试;
- 输入输出类型是否清晰、稳定;
- 链路是否过长、是否存在可以拆分的子链。
一些实践中的简单建议:
- 先画图再写链
- 先在白板/文档里画出「数据从哪里来、经过哪些处理、最后去哪」;
- 再用 LCEL 把这张图翻译成表达式,而不是边写边迭代。
- 为子链命名
- 把常用的子流程封装成独立的 Runnable 链;
- 起一个清晰的名字,方便在其他地方复用。
- 链越长,越需要观测
- 对关键节点增加详细日志与指标;
- 为长链设计重试与降级策略,避免某个节点出问题时拖垮整个应用。
6. 小结:为什么在 LangChain 里要关心 Runnable / LCEL?
从整体 LangChain 设计来看,Runnable / LCEL 处在「胶水层」的位置:
- 它不决定用哪个模型、哪个向量库、哪种工具调用方式;
- 但决定了「这些东西如何被组合在一起」以及「如何被观测和迭代」。
当应用规模较小时,可以只用最基础的调用方式,不必一开始就引入 LCEL;
但一旦出现跨模块、多步、可观测需求明显的链路,把这些流程用 Runnable / LCEL 表达出来,往往能大幅提升可维护性与可调试性。