很多开发者第一次接触 LangChain 的 LCEL 时,直觉都是一样的:这不就是把原来一段一段的调用,换成了 pipe 和 Runnable 的写法吗?
如果只是做一个“用户提问 -> Prompt -> 模型回答”的单轮调用,这个判断不算错。问题在于,真实 AI 应用很少停留在这一步。一旦链路里开始出现工具调用、RAG 检索、分支判断、重试、降级、流式输出、埋点观测,你会发现真正难维护的,从来不是“调一次模型”,而是“怎么把一条越来越复杂的 AI 流程组织清楚”。
这就是 LCEL 的真正价值。
我的结论先放在前面:
LCEL 最重要的意义,不是让代码更像函数式编程,而是把 Prompt、Model、Retriever、Tool、Parser 这些原本分散的步骤,统一抽象成可组合、可观测、可治理的 Runnable 节点。
它解决的是工程组织问题,而不只是语法问题。
为什么 AI 流程一复杂,代码就开始失控
很多人最早写 LangChain 项目,通常是过程式风格:
- 先拼 Prompt
- 调一次模型
- 如果模型返回了 tool call,就执行工具
- 再把工具结果塞回上下文
- 如果要做 RAG,再插入一次检索
- 如果接口不稳定,再补一层重试
- 如果线上要排查,再往代码里塞日志
开始时很顺,写到第三个需求就乱了。
原因不是你不会写代码,而是这种风格天然有三个问题:
1. 业务逻辑和流程控制混在一起
“检索知识库”和“如果没有命中就走兜底回复”本来是两类不同问题。前者是业务节点,后者是流程控制。但在过程式代码里,它们通常被写在同一个函数里,最后谁都不清楚边界。
2. 节点能力无法复用buz
你今天给 Milvus 检索加了重试,明天给模型加了 fallback,后天又想给工具执行加 callback。如果每个地方都是手写 try/catch、手写日志、手写参数透传,复用成本会越来越高。
3. 统一调用语义消失
有的地方是同步函数,有的地方是异步函数,有的地方支持流式,有的地方不支持。调用方式一旦不统一,链路级能力就很难做,比如批量执行、节点观测、统一配置、统一重试。
LCEL 的设计,就是为了解决这三个问题。
先把 LCEL 放回整条 AI 应用链路里理解
LCEL 的核心对象是 Runnable。你可以把它理解成一个“可执行节点”,它至少具备三种统一能力:
invoke:单次执行batch:批量执行stream:流式执行
在 LangChain 里,很多看起来完全不同的东西,本质上都能被当成 Runnable:
- Prompt Template
- Chat Model
- Output Parser
- Retriever
- 自定义函数
- 组合后的 Chain
这意味着你可以把 AI 应用拆成一串职责明确的节点,而不是一坨大函数。
常见 Runnable 组件各自解决什么问题
| 组件 | 它负责什么 | 适合什么时候用 |
|---|---|---|
RunnableSequence | 线性串联多个步骤 | “先检索,再拼 Prompt,再调模型” |
RunnableBranch | 根据条件走不同分支 | “有 tool call 继续执行,没有就结束” |
RunnableLambda | 承接自定义业务逻辑 | 轻量的数据整形、状态转换、胶水逻辑 |
RunnablePassthrough.assign | 在原状态上追加字段 | 给 state 补上 response、toolMessages 等中间结果 |
这里最容易踩的坑是把 RunnableLambda 用成“大杂烩函数”。
如果你把所有逻辑都塞进一个 Lambda,形式上是 LCEL,实际上还是过程式代码,只是换了个壳。
LCEL、过程式写法、LangGraph,分别该在什么场景下用
工程里不要神化 LCEL,它不是所有问题的默认答案。
直接写过程式代码
适合:
- 单轮调用
- 节点很少
- 几乎没有分支
- 主要目标是快速验证
优点是直接,缺点是后续扩展性差。
用 LCEL 组装 Chain
适合:
- 链路已经有 3 个以上节点
- 节点之间存在明确的数据流
- 需要分支、重试、fallback、流式输出、统一配置
- 希望后续能接 LangSmith 做观测
LCEL 最擅长的是“有结构的工作流”,尤其是 RAG、Tool Calling、格式化输出这类流程。
直接上 LangGraph
适合:
- 多轮 Agent 循环明显复杂
- 有长期状态
- 有 checkpoint / human-in-the-loop
- 需要显式状态机或图结构
一个实用判断是:
如果你的流程本质上还是“链”,优先用 LCEL;如果你的流程已经更像“图”,尤其有复杂循环和状态恢复,再上 LangGraph。
实战一:把 MCP 工具调用写成可维护的 Agent Step
原始 demo 的方向是“高德 MCP + Chrome DevTools MCP”。这个场景本身非常适合说明 LCEL 的价值,因为 Tool Calling 最大的问题从来不是工具怎么调,而是“这一轮该继续,还是该结束”。
假设我们要做一个差旅助手,用户输入:
北京南站附近的 3 家酒店,获取图片,并在浏览器中分别打开
这条链路至少有四个步骤:
- 把消息交给带工具能力的模型
- 判断模型有没有产生
tool_calls - 有的话执行工具,并把工具结果写回消息历史
- 没有的话结束本轮,返回最终答案
这就是一个非常典型的 Sequence + Branch + Lambda + State 组合。
第一步:模型和 Prompt 本身就是 Runnable
const prompt = ChatPromptTemplate.fromMessages([
["system", "你是一个可以调用 MCP 工具的智能助手。"],
new MessagesPlaceholder("messages"),
]);
const llmChain = prompt.pipe(model.bindTools(tools));
这段代码的重点不在“能跑”,而在职责边界很清楚:
prompt只负责组织消息model.bindTools(tools)只负责让模型具备工具调用能力pipe负责把两者串起来
如果后面你要给模型加 tracing、fallback 或 provider 切换,改动点也比较明确。
第二步:把工具执行逻辑单独封成一个节点
const toolExecutor = RunnableLambda.from(async ({ response, tools }) => {
const toolMessages = [];
for (const call of response.tool_calls ?? []) {
const tool = tools.find((item) => item.name === call.name);
if (!tool) continue;
const result = await tool.invoke(call.args);
const content =
typeof result === "string" ? result : JSON.stringify(result);
toolMessages.push(
new ToolMessage({
content,
tool_call_id: call.id,
})
);
}
return toolMessages;
});
这里的关键不是 for...of,而是“工具执行”已经被隔离成了独立节点。
这意味着:
- 你可以只给这个节点加重试
- 可以只给这个节点打日志
- 可以只给这个节点做超时控制
- 后面要替换 MCP 工具来源,也不会影响上游 Prompt 和下游分支逻辑
第三步:用 Branch 明确表达“继续还是结束”
const agentStepChain = RunnableSequence.from([
RunnablePassthrough.assign({
response: llmChain,
}),
RunnableBranch.from([
[
(state) => !state.response?.tool_calls?.length,
RunnableLambda.from(async (state) => ({
...state,
messages: [...state.messages, state.response],
done: true,
final: state.response.content,
})),
],
RunnableSequence.from([
RunnableLambda.from(async (state) => ({
...state,
messages: [...state.messages, state.response],
})),
RunnablePassthrough.assign({
toolMessages: toolExecutor,
}),
RunnableLambda.from(async (state) => ({
...state,
messages: [...state.messages, ...(state.toolMessages ?? [])],
done: false,
})),
]),
]),
]);
这段代码真正值得学的,不是 API 名字,而是表达方式:
assign({ response: llmChain })表示“在当前 state 上新增一份模型输出”RunnableBranch表示“这里是明确的流程分叉点”done: true / false把流程控制状态显式化
很多过程式 Agent 代码难读,不是因为逻辑复杂,而是“状态变化”是隐式的。
LCEL 的优势恰恰在于,它逼着你把状态和分支写清楚。
为什么循环没有硬塞进 chain 内部
外层一般还会保留一个很薄的驱动循环:
async function runAgent(query, maxIterations = 10) {
let state = {
messages: [new HumanMessage(query)],
tools,
done: false,
final: null,
};
for (let i = 0; i < maxIterations; i += 1) {
state = await agentStepChain.invoke(state);
if (state.done) return state.final;
}
throw new Error("Agent exceeded maxIterations");
}
这是一个很重要的工程判断:
LCEL 很适合描述“一步 Agent 是怎么工作的”,但如果你的循环、状态恢复、人工审批越来越复杂,这时更适合把控制层交给 LangGraph,而不是继续把 LCEL 硬拽成图引擎。
另外,maxIterations 不是装饰参数,它是必要的安全阀,用来避免模型在异常情况下无限调用工具。
实战二:用 LCEL 组装一条真正可解释的 RAG 查询链
原始资料里用了 Milvus 做《天龙八部》电子书语义问答。这个 demo 用来演示“检索 -> 拼 Prompt -> 调模型”没有问题,但如果放到工程语境,我更推荐把它理解为一个更通用的企业知识库问答链。
因为 RAG 的核心,不是“把向量库接上模型”,而是把各个职责拆开:
- Loader / Splitter:离线把原始文档切片
- Embedding:把切片转成向量
- Vector DB:负责存储和召回
- Retriever:根据用户问题取回候选片段
- Prompt:把上下文和问题组织成模型能理解的输入
- LLM:生成答案
- Parser:规范化输出
一个常见误区:把离线索引链路和在线问答链路混为一谈
很多教程把这两部分揉在一起写,看起来很完整,实际上很难讲清楚问题。
更合理的拆法是:
- 离线链路:文档入库
- 在线链路:用户提问时检索并回答
LCEL 这篇文章主要关心的是在线链路,因为它最适合被描述成 Chain。
先把检索节点单独抽出来
const retrieveFromMilvus = RunnableLambda.from(async ({ question, k = 5 }) => {
const queryVector = await embeddings.embedQuery(question);
const result = await milvusClient.search({
collection_name: "kb_docs",
vector: queryVector,
limit: k,
metric_type: MetricType.COSINE,
output_fields: ["doc_id", "section", "content"],
});
return {
question,
docs: result.results ?? [],
};
});
这个节点只做一件事:召回候选文档。
几个参数值得单独解释:
k:不是越大越好。k太小容易漏召回,太大又会把无关片段带进 Prompt。多数知识库问答可以从3~8开始试,5是比较稳妥的起点。metric_type:如果你的 embedding 向量适合余弦相似度,就用COSINE;前提是你清楚模型输出向量的特性,而不是盲选。output_fields:只拿回答需要的字段,不要把一堆无关元数据全塞回来,否则会增加后续处理成本。
还有一个很容易被忽略的点:
向量维度必须和集合 schema 一致。
如果你用的是可配置维度的 embedding 模型,比如某些 OpenAI 兼容模型允许指定 dimensions,那 Milvus collection 的向量字段维度必须一开始就对齐,否则不是效果差,而是根本写不进去或查不出来。
第二步:把“检索结果如何变成 Prompt 上下文”单独抽出来
const buildPromptInput = RunnableLambda.from(({ question, docs }) => {
if (!docs.length) {
return {
question,
context: "",
noContext: true,
};
}
const context = docs
.map((doc, index) => {
return `[片段 ${index + 1}]
章节: ${doc.section}
内容: ${doc.content}`;
})
.join("\n\n-----\n\n");
return {
question,
context,
noContext: false,
};
});
为什么这一步不要直接塞进 PromptTemplate 里?
因为“拼上下文”本身就是一个独立职责:
- 你可能要做截断
- 可能要加引用编号
- 可能要过滤低分片段
- 可能要在日志中打印召回结果
把它独立成节点,后面就很好扩展。
第三步:Prompt、模型、Parser 组成标准生成链
const answerPrompt = PromptTemplate.fromTemplate(`
你是企业知识库助手。只能基于已检索到的内容回答问题。
上下文:
{context}
问题:
{question}
要求:
1. 优先依据上下文回答
2. 如果上下文不足,明确说明不知道
3. 不要编造制度、流程或结论
`);
const ragChain = RunnableSequence.from([
retrieveFromMilvus,
buildPromptInput,
answerPrompt,
model,
new StringOutputParser(),
]);
这就是一条结构非常清楚的在线 RAG 链:
问题 -> 向量检索 -> 上下文构建 -> Prompt -> 模型 -> 文本输出
这条链里最值得改的参数,不是模型名,而是这些
很多人调 RAG,第一反应是换更强的模型。实际上,先看这几个参数通常更有效:
1. temperature
知识库问答优先追求稳定和忠实,默认更推荐低温度,比如 0 ~ 0.3。
如果你把它设成 0.7 甚至更高,模型会更“会说”,但未必更“说对”。
2. k
k 影响的是召回广度。
如果系统经常“答非所问”,别只怪模型,先看看是不是召回的上下文本身就不对。
3. chunkSize 和 chunkOverlap
这两个参数不在在线查询链里,但决定了检索上限,必须一起考虑。
一个实用经验是:
chunkSize可以从300~800 tokens起试chunkOverlap一般取10%~20%
块太小,语义容易断;块太大,召回会变钝,还会挤占 Prompt 上下文窗口。
所以 RAG 不是“向量库接好了就行”,而是离线切分策略和在线召回策略一起决定效果。
LCEL 真正适合做的,不只是“把节点串起来”
如果 LCEL 只能 pipe,那它只是更好看的写法。它真正的工程价值,在于你可以把治理能力精确地挂到节点上。
1. withRetry:给不稳定节点补韧性
const safeRetriever = retrieveFromMilvus.withRetry({
stopAfterAttempt: 3,
});
这很适合挂在网络抖动明显的节点上,比如:
- 向量检索
- 外部 HTTP 工具
- 第三方服务调用
但不要无脑给所有节点加重试。
有副作用的写操作,或者已经发生状态变更的工具调用,盲目重试可能把问题放大。
2. withFallbacks:做服务降级,而不是伪装准确性
const resilientModel = primaryModel.withFallbacks({
fallbacks: [backupModel],
});
它解决的是“主服务不可用怎么办”,不是“答案质量不够怎么办”。
fallback 是可用性策略,不是准确性策略,这两个问题不要混淆。
3. withConfig:把运行期配置和业务输入分开
const chainWithConfig = ragChain.withConfig({
configurable: {
tenantId: "acme",
locale: "zh-CN",
},
});
像租户信息、角色、地区、实验开关这类数据,本质上不是用户问题本身,而是运行环境配置。
把它们放进 config,比混进业务输入对象更干净。
4. callbacks:做节点级观测
如果你想知道某一步到底输出了什么,不要把 console.log 到处塞进业务逻辑。
用 callback 做观测,链路会更干净,也更容易接到 LangSmith 这类 tracing 系统。
写 LCEL 时最常见的几个误区
误区一:一上来就全量 LCEL 化
如果你的流程只有“Prompt -> Model -> Parser”,那直接写也完全可以。
LCEL 不是必须品,它在复杂链路里价值最大。
误区二:把所有东西都塞进一个 RunnableLambda
这会让你失去 LCEL 最大的好处:可组合和可观测。
判断标准很简单,凡是未来可能单独重试、单独打点、单独替换的步骤,都应该尽量做成独立节点。
误区三:RAG 只调模型参数,不看检索链路
很多“幻觉”并不是模型乱说,而是你给模型的上下文本来就不对。
RAG 调优首先看召回,其次看 Prompt,最后才是模型。
误区四:Tool Calling 写成无限循环
Agent Step 可以循环,但循环必须有边界。
maxIterations、超时控制、失败中止条件,这些都应该显式存在。
误区五:把 LCEL 当成 LangGraph 的替代品
LCEL 很强,但它不是图状态机。
一旦你的流程开始出现复杂分叉回流、人工审批、长期记忆和断点恢复,就应该正面引入 LangGraph,而不是继续堆 chain。
实战里怎么选,给一个够用的判断标准
如果你正在做 AI 应用,我的建议很直接:
- 单轮调用或小 demo:先用过程式写法,快
- 有 3 个以上节点的稳定链路:优先 LCEL
- 出现复杂 Agent 状态管理:切 LangGraph
另外再补三条更偏工程的建议:
1. 节点边界按“可观测性”来划分
不是按代码行数划分,而是按“这个步骤需不需要单独看结果、单独重试、单独替换”来划分。
2. 在线链路和离线链路分开建模
RAG 的切片、Embedding、入库,是索引构建问题;检索、Prompt、回答,是在线问答问题。不要混成一个巨型脚本。
3. 先把结构搭对,再谈高级优化
很多项目一开始就纠结模型选型、召回算法、重排序,但链路结构本身就没拆清楚。
结构没搭对,后面的调优很容易沦为碰运气。
总结
LCEL 真正值得学的,不是几个 Runnable API 的名字,而是一种工程化思路:
- 把 AI 应用拆成职责清晰的节点
- 用统一的调用语义管理这些节点
- 在节点上叠加重试、降级、配置和观测能力
从这个角度看,LCEL 的价值就非常明确了:
它不是为了把代码写得更“优雅”,而是为了让 AI 工作流在复杂起来之后,依然能被拆解、被维护、被扩展。
如果你现在写 LangChain 还停留在“调模型 + 写 if/else”,那 LCEL 不是可选装饰,而是你把 demo 变成工程代码时必须跨过去的一步。