很多人第一次接触 LangChain,会把它理解成一组“帮你调模型”的工具类:PromptTemplate 负责拼 prompt,ChatOpenAI 负责调模型,OutputParser 负责解析结果。这样理解没错,但只对了一半。
真正到了工程里,问题很快就不是“怎么调一次模型”,而是“怎么把一条会持续演化的 AI 流程组织好”。
比如一个看起来简单的企业问答助手,往往很快就会长成这样:
- 先清洗用户问题
- 再决定这是闲聊、任务型问题,还是知识问答
- 不同类型走不同 prompt
- 有的分支要结构化输出
- 有的分支要保留上下文
- 有的步骤能并行,有的步骤必须串行
这时候如果还沿用最原始的命令式写法,代码通常不会因为“模型调用”而失控,而是会因为“流程编排”而失控。
这正是 Runnable 的价值所在。
这篇文章的核心结论只有一句:
Runnable 的真正意义,不是少写几行 LangChain 代码,而是把 AI 应用从一堆分散的调用,提升成一条可组合、可复用、可切换执行模式的数据流。
理解了这一点,你才会知道为什么 LCEL 值得学,也才知道什么时候该用 RunnableSequence、什么时候该分支、什么时候该并行、什么时候该保留原始输入。
为什么 AI 应用一复杂,命令式写法就开始失控
先看最常见的一类代码:模板格式化一次,模型调用一次,解析一次。
const formattedPrompt = await prompt.format(input);
const rawResponse = await model.invoke(formattedPrompt);
const result = await parser.invoke(rawResponse);
这段代码的问题,不在于它不能跑,而在于它只适合“单段流程、单次调用、无分支、无复用”的场景。
一旦流程变长,问题会立刻出现:
- 调用顺序散落在业务代码里,复用困难
- 流式输出、批量处理、重试策略难以统一接入
- 分支逻辑和模型逻辑耦合在一起
- 原始输入、派生输入、最终输出之间的关系越来越乱
- 后面想接 tracing、memory、fallback,很容易把代码继续堆成一团
换句话说,命令式写法描述的是“每一步怎么做”,但 AI 应用更需要描述的是“这条链路由哪些节点组成,它们怎么连接”。
Runnable 解决的正是这个问题。
Runnable 到底是什么
Runnable 不是某一个具体组件,而是 LangChain 里一类统一的执行接口。
只要一个对象实现了 Runnable 协议,它就可以被当成链路中的一个节点来组合和执行。像下面这些常见组件,本质上都可以放进同一条链里:
- Prompt
- Model
- Output Parser
- 普通函数
- 分支逻辑
- 并行逻辑
- 带消息历史的对话链
它的核心价值是把这些原本性质不同的东西,统一成同一种“可执行节点”。
在使用层面上,Runnable 至少统一了三种执行方式:
invoke:单次调用batch:批量调用stream:流式输出
这点非常关键。因为一旦链路是按 Runnable 组织起来的,你切换的就不再是“某一步的调用方式”,而是“整条链的执行方式”。
Runnable 在整条 AI 应用链路中的位置
很多人学 Runnable 时,会把注意力放在 API 名字上,但真正应该先建立的是它在系统里的位置感。
如果从 AI 应用全链路来看,大致可以分成两段:
1. 离线准备阶段
- Loader 读取文档
- Splitter 切分文本
- Embedding 生成向量
- 向量数据库写入索引
这一段的重点是“把知识准备好”。
2. 在线执行阶段
- 接收用户输入
- 改写问题 / 分类意图
- 检索上下文
- 拼接 Prompt
- 调用大模型
- 解析输出
- 返回答案
这一段的重点是“把一次请求走完”。
Runnable 主要解决的是第二段,也就是在线执行链路的组织问题。
它不替代 Embedding,也不替代向量数据库,更不替代模型本身。它做的是把这些能力串起来,让系统从“能跑”变成“可维护、可演进、可组合”。
所以如果你在做 RAG,可以这样理解:
- Loader / Splitter / Embedding / Vector DB 负责准备知识
- Retriever 负责取回上下文
- Prompt / Model / Parser 负责生成答案
- Runnable 负责把这些步骤编排成一条真正可执行的链
LCEL 是什么,为什么它比“语法糖”更重要
LCEL,全称 LangChain Expression Language,可以理解成 LangChain 提供的一种链式表达方式。
它的核心不是某个新类,而是一种写法:把实现了 Runnable 的节点声明式地组合起来,再统一执行。
例如这类代码:
const chain = prompt.pipe(model).pipe(parser);
const result = await chain.invoke(input);
看起来只是少写了几行代码,但它背后其实发生了三件事:
- 你把“步骤”提升成了“节点”
- 你把“手动依次调用”提升成了“声明式组装”
- 你把“局部执行”提升成了“整链执行”
这也是为什么我不建议把 LCEL 理解成语法糖。语法糖只影响写法,LCEL 影响的是程序结构。
一个更接近真实业务的案例:企业制度问答助手
为了避免把 Runnable 讲成 API 清单,下面用一个更接近实际项目的例子来串起来:
场景是一个企业内部制度助手,支持两类输入:
- 简单寒暄或无业务问题,直接简短回答
- 制度类问题,要求结构化返回答案、引用依据和置信度
同时,这个助手需要保留多轮对话上下文,避免用户第二句提问时系统“失忆”。
整个数据流可以概括为:
用户问题
-> 清洗输入
-> 判断是否闲聊
-> 是:走简答链
-> 否:走制度问答链
-> 结构化解析
-> 写入会话历史
-> 返回结果
这个案例刻意没有把 Retriever 塞进来,因为本文的重点不是 RAG,而是先把“链怎么组织”讲清楚。等你理解 Runnable 后,再把检索节点插进去会非常自然。
先看不推荐的写法
const cleanQuestion = question.trim().replace(/\s+/g, " ");
if (/你好|hello|在吗/.test(cleanQuestion.toLowerCase())) {
const promptText = await chatPrompt.format({ question: cleanQuestion });
const raw = await model.invoke(promptText);
const result = await parser.invoke(raw);
return result;
}
const promptText = await policyPrompt.format({
question: cleanQuestion,
format_instructions: parser.getFormatInstructions(),
});
const raw = await model.invoke(promptText);
const result = await parser.invoke(raw);
return result;
它的问题不是代码风格不好,而是当你继续往里加下面这些能力时,会越来越难收拾:
- 多一个分支
- 多一个预处理函数
- 多一层上下文保留
- 多一种输出模式
这类代码很容易从“能看懂”变成“到处都是 if、await 和局部变量”。
用 Runnable 重组这条链
先准备模型和输出结构:
import "dotenv/config";
import { z } from "zod";
import { ChatOpenAI } from "@langchain/openai";
import {
ChatPromptTemplate,
MessagesPlaceholder,
} from "@langchain/core/prompts";
import { StructuredOutputParser } from "@langchain/core/output_parsers";
import {
RunnableBranch,
RunnableLambda,
RunnablePassthrough,
RunnableWithMessageHistory,
} from "@langchain/core/runnables";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
const model = new ChatOpenAI({
model: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0.2,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const answerSchema = z.object({
answer: z.string().describe("给用户的最终回答"),
source: z.string().describe("答案依据来源,闲聊场景可返回“无”"),
confidence: z.number().min(0).max(1).describe("答案置信度"),
});
const parser = StructuredOutputParser.fromZodSchema(answerSchema);
这里有三个点值得解释:
temperature: 0.2不是固定标准,但对知识问答和结构化输出更稳。温度越高,表达更活,但结构化约束更容易漂。StructuredOutputParser的价值不是“把字符串变对象”这么简单,它实际上是把输出格式前置成链路契约。confidence这种字段不一定天然可信,但它对工程上做降级、人工兜底、前端提示非常有用。
第一步:把普通预处理函数变成 Runnable
const normalizeQuestion = RunnableLambda.from((input) => ({
...input,
question: input.question.trim().replace(/\s+/g, " "),
}));
RunnableLambda 的作用,是把普通函数提升成链路中的标准节点。
为什么这一步重要?
因为业务里真正经常变化的,往往不是模型,而是这些“小逻辑”:
- 去空格
- 补默认值
- 改写字段名
- 清洗非法输入
如果这些函数不能自然进入同一条链,最后你还是会退回到命令式 orchestration。
第二步:定义两个分支链
const chatPrompt = ChatPromptTemplate.fromMessages([
["system", "你是企业内部助手。对于寒暄类问题,用一句简短的话直接回答。"],
new MessagesPlaceholder("history"),
["human", "{question}"],
]);
const policyPrompt = ChatPromptTemplate.fromMessages([
[
"system",
"你是企业制度助手。请根据问题给出清晰答案,并严格按照以下格式输出:\n{format_instructions}",
],
new MessagesPlaceholder("history"),
["human", "{question}"],
]);
const chatChain = chatPrompt.pipe(model).pipe(parser);
const policyChain = policyPrompt.pipe(model).pipe(parser);
这里有两个工程判断:
pipe()和RunnableSequence.from([...])本质一样,线性链路我更推荐pipe(),可读性更接近数据流。- 即使是闲聊链,我这里也走了同一个结构化 parser。这样前端消费结果时字段稳定,不需要对不同分支再写一层兼容逻辑。
第三步:用 RunnableBranch 表达路由逻辑
const isSmallTalk = (input) =>
/你好|hello|hi|在吗|谢谢/.test(input.question.toLowerCase());
const routeChain = RunnableBranch.from([
[isSmallTalk, chatChain],
policyChain,
]);
RunnableBranch 的本质就是链路级别的 if / else。
它适合“根据输入条件决定走哪条链”的场景,比如:
- 闲聊还是问答
- 中文问题还是英文问题
- 短问题直接答,长问题先改写
如果你的路由不是靠条件判断,而是明确传入一个业务 key,比如 "summary"、"translate"、"draft",那更适合 RouterRunnable。
判断分支用 RunnableBranch,显式路由用 RouterRunnable,这两个不要混。
第四步:把格式约束和原始输入一起带进链里
const baseChain = normalizeQuestion
.pipe(
RunnablePassthrough.assign({
format_instructions: () => parser.getFormatInstructions(),
original_question: (input) => input.question,
})
)
.pipe(routeChain);
RunnablePassthrough.assign() 非常实用,它适合做两件事:
- 保留已有字段
- 在不打断主链的情况下补充派生字段
这比你手动构造多个临时对象更清晰。
这里的 format_instructions 不是装饰品,而是结构化输出成功率的重要前提。很多人只记得“用了 parser”,却忘了把解析规则显式告诉模型,最后输出一漂移,就以为是 parser 不稳定。实际上,parser 只是约束边界,真正决定输出质量的还是 prompt 里的格式说明。
第五步:给整条链加上消息历史
const histories = new Map();
function getMessageHistory(sessionId) {
if (!histories.has(sessionId)) {
histories.set(sessionId, new InMemoryChatMessageHistory());
}
return histories.get(sessionId);
}
const chainWithHistory = new RunnableWithMessageHistory({
runnable: baseChain,
getMessageHistory,
inputMessagesKey: "question",
historyMessagesKey: "history",
});
这段代码要重点理解两个配置项:
inputMessagesKey:当前这次用户输入从哪个字段读historyMessagesKey:历史消息注入到 prompt 时写入哪个占位字段
如果这两个 key 和你的 prompt 模板对不上,链是能跑的,但历史不会按你预期生效。
调用时只需要带上 sessionId:
const result = await chainWithHistory.invoke(
{ question: "我刚才提到自己在哪个城市入职?" },
{
configurable: {
sessionId: "employee-42",
},
}
);
这里的 sessionId 不是语法细节,而是内存隔离边界。没有它,多用户上下文很容易串线。
为什么这种写法更适合工程落地
到这里,你应该能看出 Runnable 方案比命令式写法多出来的,不只是简洁,而是结构上的优势:
1. 节点职责更清楚
- 预处理只负责清洗输入
- Prompt 只负责表达任务
- Model 只负责生成
- Parser 只负责约束输出
- Branch 只负责决定走哪条链
当职责被拆清楚之后,调试、替换、复用都会容易得多。
2. 链路更容易扩展
如果你之后想接入 Retriever,通常只需要在 policyChain 前面插一个“取上下文”的节点,而不是重写整套调用逻辑。
这也是 Runnable 最适合 RAG 场景的原因之一。RAG 不是只有“检索 + 生成”,而是一条会不断生长的执行链。没有统一的执行接口,越往后越难维护。
3. 执行方式切换成本更低
今天你用 invoke,明天要处理批量问题,可以换成 batch。
后天你要前端打字机效果,可以换成 stream。
前提是:你的流程已经先被组织成 Runnable 链。
常见 Runnable 组件怎么选
Runnable 相关 API 不少,但真正常用、也最值得先掌握的其实就几类。
RunnableSequence / pipe
默认方案,适合固定顺序的线性链路。
判断:
- 大多数场景先用它
pipe更适合简洁线性表达RunnableSequence.from([...])更适合动态拼接数组
RunnableLambda
把普通函数纳入链路。
判断:
- 适合轻量输入清洗、字段转换、结果整理
- 不适合塞太重的业务逻辑
如果一个 RunnableLambda 已经写成 80 行,说明你该抽模块了,不该继续往“链节点”里塞。
RunnableMap
并行扇出,不是分支。
适合:
- 同一份输入派生多路结果
- 同时准备多个 prompt 变量
- 并行做多个独立计算
不适合:
- 互相依赖的步骤
- 需要共享可变状态的逻辑
RunnableBranch
条件分支,适合运行时决定走哪条链。
默认建议:
- 把它用在业务边界上
- 不要写成多层深嵌套
如果一个分支链里又套分支,再套分支,问题往往不是 Runnable 不够强,而是你的业务流已经该拆了。
RunnablePassthrough
保留原始输入,或者在原对象基础上扩展字段。
这是很多人低估的一个组件,但它对保持数据流清晰非常有帮助,尤其是你既要模型结果,又不想丢失原问题、原参数、trace 信息的时候。
RunnableWithMessageHistory
给链增加会话上下文。
要注意一件事:它是会话历史,不是长期记忆系统。
它适合:
- 多轮对话
- 同一 session 的上下文连续追问
它不适合承担:
- 用户画像
- 跨会话持久记忆
- 大规模可检索知识记忆
这些需求要么进数据库,要么进专门的 memory / profile / retrieval 方案,不能都指望对话历史顶上去。
几个容易踩的坑
1. 以为 Runnable 只是“代码更短”
如果你只把它当语法优化,很快就会在真正需要分支、并行、记忆时重新写回命令式代码。
正确理解是:Runnable 是统一执行抽象,LCEL 是在这个抽象之上的组合方式。
2. 以为 RunnableMap 是“按条件选一个分支”
不是。
RunnableMap 是并行执行多个节点,把结果收敛成一个对象;分支路由请用 RunnableBranch 或 RouterRunnable。
3. 以为有了 StructuredOutputParser 就一定能稳定出 JSON
不一定。
结构化输出的稳定性,通常取决于四件事:
- 模型本身是否擅长遵循格式
- prompt 里有没有明确给格式要求
- schema 是否设计得过于复杂
- temperature 是否过高
parser 不是魔法,它只是最后一道契约边界。
4. 以为 RunnableWithMessageHistory 就等于“系统有记忆”
它只能证明“链能读到之前几轮对话”,不代表系统已经具备可控、可检索、可持久化的长期记忆能力。
5. 把所有业务逻辑都塞进链里
链应该负责编排,不应该吞掉一切。
我的建议是:
- 纯流程控制,放 Runnable
- 复杂业务规则,放独立模块
- 外部依赖访问,封装成清晰节点
这样链才会保持可读,而不是变成另一种形式的“大函数”。
工程上怎么落地更稳
如果你准备在真实项目里用 Runnable,我建议默认遵守这几个原则:
1. 先把输出边界定清楚,再写链
也就是先想清楚最终要返回什么字段,再决定 prompt 和 parser。
很多链越写越乱,本质上不是组合问题,而是输入输出契约一开始就没定义好。
2. 让每个节点尽量“单一职责”
一个节点只做一件事,后面替换和测试都会轻松很多。
比如“问题改写”和“结果解析”最好拆开,不要写成一个大 lambda。
3. 线性主链先跑通,再加分支和并行
不要一上来就把 Branch、Map、History 全叠进去。
先让最短链路可用,再逐步把复杂度接回来,这是最稳的推进方式。
4. 在 RAG 里把检索也当成链节点,而不是藏进黑盒函数
这样做的好处是:
- 检索前后更容易插入 query rewrite
- 更容易记录召回内容
- 更容易替换 retriever 策略
5. 默认优先追求“稳定结构”,再追求“花哨表达”
尤其是企业问答、知识库助手、审批助手这类场景,输出结构比语言风格更重要。
Runnable 很适合把这种“稳定结构优先”的设计贯彻到链路里。
总结
很多人学 Runnable,会先记住一串 API 名字:RunnableSequence、RunnableLambda、RunnableMap、RunnableBranch、RunnablePassthrough、RunnableWithMessageHistory。这些当然要会用,但更重要的是理解它们背后的统一思想。
Runnable 不是为了把 LangChain 写得更像链式调用,而是为了把 AI 应用写成一条真正可编排的数据流。
当你的系统还只有“一次 prompt + 一次模型调用”时,这个价值不算明显;但只要你进入下面这些场景,它就会立刻变得重要:
- 多步骤调用
- 多分支路由
- 结构化输出
- 批量处理
- 流式返回
- 多轮对话
- RAG 在线链路编排
所以如果你现在刚开始学 LangChain,我的建议不是先把所有 Runnable API 背下来,而是先建立一个正确判断:
Prompt、Model、Parser 是能力组件;Runnable 才是把这些能力组织成系统的工程骨架。
一旦这个认知到位,你再回头看 LCEL,就不会觉得它只是“写法更优雅”,而会明白它为什么是 LangChain 里真正值得掌握的基础设施。