很多人第一次学 LangChain 的 LCEL,都会有一个共同感受:概念不难,但很难判断它到底该用在什么地方。
因为从语法上看,LCEL 很像是在做一件很朴素的事:
- 把
PromptTemplate ChatModelOutputParserRetriever- 普通函数
这些原本分散的组件,串成一条 chain,然后统一 invoke、stream 或 batch。
如果只停留在这里,LCEL 很容易被理解成“更好看的链式写法”。但真实项目里,LCEL 的价值远远不止代码更短。
这篇文章我想讲清楚一个核心结论:
LCEL 最重要的价值,不是让 LangChain 代码更优雅,而是把 AI 应用从一堆离散调用,变成一条可组合、可观测、可治理的数据流。
为了把这件事讲透,这篇文章不走“API 目录式”讲法,而是直接围绕 4 类最典型的 AI 链路展开:
- 线性链:Prompt -> Model -> Parser
- 路由链:分类 -> 分支 -> 不同处理路径
- RAG 链:检索 -> 组装上下文 -> 生成答案
- Agent Step:工具调用 -> 结果写回 -> 再次决策
你会发现,LCEL 真正解决的问题,从来都不是“怎么调用一次模型”,而是“当链路越来越复杂时,代码怎么还能继续维护”。
为什么 AI 应用一复杂,过程式代码就会迅速失控
很多 demo 最初都长这样:
const promptText = await prompt.format(input);
const raw = await model.invoke(promptText);
const result = await parser.invoke(raw);
return result;
这段代码本身没问题。问题在于,真实 AI 应用不会永远只有三步。
很快你就会往里面继续塞东西:
- 前面加一个输入清洗
- 中间加一个意图识别
- 某些问题要先检索知识库
- 某些问题要调用工具
- 模型输出后还要结构化解析
- 某个步骤失败了需要重试
- 线上排查时还得打印节点级日志
写到这里,很多项目都会进入一个尴尬阶段:
- 每个步骤都能跑
- 但整条流程越来越难改
- 一改就怕动到别的分支
- 代码里全是
if / else / try / catch / await
这不是 LangChain 的问题,而是流程组织方式的问题。
过程式代码更擅长描述“按顺序做这几步”,但 AI 应用经常需要描述的是:
- 哪些步骤必须串行
- 哪些步骤可以并行
- 哪些条件下要分支
- 哪些节点需要单独重试
- 哪些中间结果要保留下来继续往后传
LCEL 本质上是在解决这件事。
先把 LCEL 放回 AI 应用全链路里理解
LCEL 的底层抽象是 Runnable。
你可以把 Runnable 理解成“一个可执行节点”,它至少统一了三种执行语义:
invoke:单次执行batch:批量执行stream:流式执行
LangChain 里很多看起来性质完全不同的东西,其实都能被当成 Runnable:
- Prompt Template
- Chat Model
- Output Parser
- Retriever
- 自定义函数
- 组合后的 Chain
这件事为什么重要?
因为它意味着你的 AI 应用可以不再围绕“大函数”来组织,而是围绕“节点”和“数据流”来组织。
从系统链路上看,各类组件的职责大致如下:
| 组件 | 它负责什么 |
|---|---|
| Loader / Splitter | 把原始资料切成可处理片段 |
| Embedding | 把文本映射成向量 |
| Vector DB | 存储和召回向量 |
| Retriever | 根据 query 找回候选上下文 |
| Prompt | 组织模型输入 |
| Model | 生成结果 |
| Parser | 把结果约束成需要的格式 |
| Runnable / LCEL | 把这些步骤编排成可执行链路 |
所以 LCEL 不替代 Prompt,不替代模型,也不替代向量库。
它解决的是“这些组件之间怎么连接”。
典型场景一:线性链最适合用来做稳定的结构化处理
先从最简单但最常见的场景开始。
假设你要做一个“需求录入助手”,把产品经理写的一段自然语言需求,整理成结构化结果,包含三部分:
- 功能概述
- 优先级
- 3 个实现关键词
这个场景很典型,因为它满足三件事:
- 步骤天然是线性的
- 输出格式必须稳定
- 后续很可能要接前端表单或数据库
这时最适合的 LCEL 结构就是:
Prompt -> Model -> StructuredOutputParser
import "dotenv/config";
import { z } from "zod";
import { ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import { StructuredOutputParser } from "@langchain/core/output_parsers";
import { RunnableSequence } from "@langchain/core/runnables";
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const parser = StructuredOutputParser.fromZodSchema(
z.object({
summary: z.string().describe("需求概述"),
priority: z.enum(["P0", "P1", "P2"]).describe("需求优先级"),
keywords: z.array(z.string()).length(3).describe("3 个关键词"),
})
);
const prompt = PromptTemplate.fromTemplate(`
你是一名资深产品分析助手。
请把下面的需求整理为结构化结果。
需求文本:
{text}
{format_instructions}
`);
const chain = RunnableSequence.from([
prompt,
model,
parser,
]);
const result = await chain.invoke({
text: "给管理后台增加批量导出订单功能,并允许按支付状态和时间范围筛选,最好本周上线。",
format_instructions: parser.getFormatInstructions(),
});
这条链为什么是 LCEL 的最佳入门案例
因为它非常清楚地体现了 LCEL 的三个基本价值:
1. 每个节点职责单一
prompt只负责组织输入model只负责生成parser只负责把输出约束成结构
2. 执行语义统一
你不需要分别手动 format、invoke、parse,只需要执行整条 chain。
3. 后续增强成本低
如果后面你想:
- 换模型
- 加流式输出
- 批量处理多条需求
- 给模型节点加 fallback
都不需要重写这条流程的主结构。
为什么这个场景里 Parser 比 tool 更适合
这也是工程里很典型的判断。
如果你的目标只是让模型输出一个稳定 JSON,优先考虑 OutputParser。
因为这里没有真实外部动作,不需要“调用工具”,只是需要“约束输出格式”。
tool 更适合:
- 真正要触发外部系统动作
- 需要模型显式决定是否调用某个能力
- 输出不仅是数据,而是一个操作意图
所以“结构化抽取”这类线性链,默认方案应该是:
Prompt + Model + Parser
而不是一上来就 tool call。
典型场景二:路由链的价值,不在分支本身,而在把分支边界显式化
线性链好理解,但真实业务里,大多数请求并不会走同一条路径。
例如做一个企业内部助手,用户问题通常至少分成三类:
- 闲聊:如“你是谁”“在吗”
- 知识库问答:如“报销流程怎么走”
- 执行动作:如“帮我创建一张采购审批单”
如果你把这三类问题都塞进同一个 Prompt,让模型自己兜底,短期看很省事,长期基本一定会失控:
- Prompt 越来越长
- 规则互相污染
- 调试时分不清到底哪个子场景出了问题
这时更合理的方式是先做一个轻量分类,然后显式路由。
LCEL 里这类问题最适合用 RunnableBranch。
import { RunnableBranch, RunnableLambda } from "@langchain/core/runnables";
const normalizeInput = RunnableLambda.from((input) => ({
...input,
question: input.question.trim().replace(/\s+/g, " "),
}));
const classifyIntent = RunnableLambda.from((input) => {
const text = input.question.toLowerCase();
if (/你好|hello|在吗/.test(text)) {
return { ...input, intent: "chat" };
}
if (/创建|提交|执行|审批/.test(text)) {
return { ...input, intent: "action" };
}
return { ...input, intent: "kb" };
});
const chatChain = RunnableLambda.from(async (input) => {
return { type: "chat", answer: "你好,我可以帮你查询制度或触发系统操作。" };
});
const kbChain = RunnableLambda.from(async (input) => {
return { type: "kb", answer: `我会把「${input.question}」交给知识库链处理。` };
});
const actionChain = RunnableLambda.from(async (input) => {
return { type: "action", answer: `我会把「${input.question}」交给工具执行链处理。` };
});
const routerChain = normalizeInput
.pipe(classifyIntent)
.pipe(
RunnableBranch.from([
[(state) => state.intent === "chat", chatChain],
[(state) => state.intent === "action", actionChain],
kbChain,
])
);
这类链为什么在工程里特别常见
因为很多 AI 应用表面上是一个入口,背后其实是多种任务类型的聚合。
比如:
- 企业助手:闲聊 / 知识问答 / 执行动作
- 客服助手:FAQ / 售后工单 / 人工转接
- 开发助手:代码解释 / 文档检索 / 执行命令
如果没有路由层,所有复杂度都会堆进一个超级 Prompt 里。
这里真正值得学的,不是 Branch 的写法,而是建模方式
这段代码的工程意义是:
- 先做输入归一化
- 再做意图分类
- 再在边界清晰的地方分流
也就是说,LCEL 让“分支点”变成了一等公民。
这比把分支写散在业务代码里的好处非常直接:
- 哪一步负责分类,一眼可见
- 哪些分支存在,一眼可见
- 哪条分支要换模型或换 Prompt,局部可改
这里还有一个很重要的经验:
路由链的分类节点,不一定非要上模型。
如果你的分类规则很稳定、很便宜,而且误判代价不高,优先用规则。
只有当规则明显不够用时,再把它升级成“模型分类 + 结构化输出”。
这样做的好处是成本更低、响应更快,而且可解释性更强。
典型场景三:RAG 链的重点不是“接上向量库”,而是把职责拆开
原始资料里有一个很典型的 demo:Milvus + 小说电子书语义问答。
这个 demo 很适合入门,但如果放到工程语境,我更推荐把它理解成一个更通用的企业知识库场景,比如:
员工提问报销、采购、入职、权限申请等内部制度问题,系统从知识库召回资料后生成回答。
这类链路通常至少包含 5 个节点:
- 归一化问题
- 向量检索
- 构建上下文
- Prompt 组织
- 调模型并输出
一个常见误区:把 RAG 写成“数据库查询 + 一次模型调用”
这会导致两个后果:
- 出问题时,很难判断是检索错了,还是 Prompt 组织错了
- 后续想插入 query rewrite、rerank、引用编号、上下文截断时,改动非常痛苦
更合理的方式,是把每一步都变成可观测节点。
import "dotenv/config";
import { ChatOpenAI, OpenAIEmbeddings } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { RunnableLambda, RunnableSequence } from "@langchain/core/runnables";
import { MilvusClient, MetricType } from "@zilliz/milvus2-sdk-node";
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0.2,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const embeddings = new OpenAIEmbeddings({
model: process.env.EMBEDDINGS_MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
dimensions: 1024,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const milvusClient = new MilvusClient({
address: "localhost:19530",
});
const normalizeQuestion = RunnableLambda.from(({ question, k = 5 }) => ({
question: question.trim(),
k,
}));
const retrieveDocs = RunnableLambda.from(async ({ question, k }) => {
const vector = await embeddings.embedQuery(question);
const result = await milvusClient.search({
collection_name: "company_kb",
vector,
limit: k,
metric_type: MetricType.COSINE,
output_fields: ["doc_id", "title", "section", "content"],
});
return {
question,
docs: result.results ?? [],
};
});
const buildContext = RunnableLambda.from(({ question, docs }) => {
if (!docs.length) {
return {
question,
context: "",
noContext: true,
};
}
const context = docs
.map((doc, index) => {
return `[资料 ${index + 1}]
标题: ${doc.title}
章节: ${doc.section}
内容: ${doc.content}`;
})
.join("\n\n-----\n\n");
return {
question,
context,
noContext: false,
};
});
const answerPrompt = PromptTemplate.fromTemplate(`
你是企业制度问答助手。
请严格依据已检索到的资料回答。
资料:
{context}
问题:
{question}
要求:
1. 优先给出直接结论
2. 如果资料不足,明确说明“当前资料不足以确认”
3. 不要编造制度细节
`);
const ragChain = RunnableSequence.from([
normalizeQuestion,
retrieveDocs,
buildContext,
answerPrompt,
model,
new StringOutputParser(),
]);
这条链里最关键的不是模型,而是节点边界
很多人做 RAG,最先关注的是模型选型。
但多数线上问题,首先不是模型不够强,而是链路拆分不合理。
比如上面这条链,至少有三个好处:
1. 检索和生成被分开了
这样你一眼就能看出来:
- 是没检索到
- 还是检索到了但上下文组织有问题
- 还是模型拿到上下文后答偏了
2. buildContext 是独立节点
这点非常重要,因为真实项目里,这一步往往是最容易不断演化的:
- 要不要加引用编号
- 要不要截断过长内容
- 要不要过滤低分片段
- 要不要把 metadata 一起传进去
如果你把这些逻辑直接塞进 Prompt 字符串里,后面非常难维护。
3. 这条链天然支持 stream
RAG 是非常适合流式输出的场景:
const stream = await ragChain.stream({
question: "出差报销需要哪些材料?",
k: 5,
});
for await (const chunk of stream) {
process.stdout.write(chunk);
}
这是 LCEL 很实用的一点。
你不是给“模型调用”加流式,而是给“整条生成链”切换执行模式。
RAG 场景里几个关键参数,应该怎么理解
1. k
k 决定召回多少片段。
它不是越大越好。
- 太小:容易漏掉关键资料
- 太大:会把噪音塞进 Prompt,反而让模型答偏
多数企业知识库问答,3~8 是一个比较合理的起点,5 常常是稳妥默认值。
2. temperature
RAG 问答的目标通常是“忠实回答”,不是“自由发挥”。
所以默认更推荐低温度,比如 0~0.3。
3. dimensions
如果你的 embedding 模型支持指定向量维度,那么 Milvus collection 的 schema 必须和它一致。
这不是调优问题,而是基础约束问题。
4. chunkSize 和 chunkOverlap
它们不在在线链路里,但直接决定在线效果。
一个很实用的经验是:
chunkSize先从300~800 tokens起试chunkOverlap先从10%~20%起试
块太小,语义断裂;块太大,召回变钝,还会占用上下文窗口。
所以 RAG 从来不是“把向量库接上去就完事”,而是离线切分策略 + 在线链路组织方式一起决定效果。
典型场景四:Agent Step 不是一条普通链,而是一条“会反复执行的一步”
到这里,LCEL 的基本价值已经比较清楚了。但很多开发者真正卡住的地方,其实是工具调用。
因为工具调用不是简单的线性流程,它更像这样:
- 把消息交给模型
- 模型决定是否调用工具
- 如果有 tool call,就执行工具
- 把工具结果写回上下文
- 再让模型继续思考
- 直到没有 tool call 为止
原始资料里用的是“高德 MCP + Chrome DevTools MCP”,这个例子很典型,因为它清楚地暴露了 Agent 的核心结构:
Agent 并不是一条无限神秘的超级链,而是“一步链 + 外层循环”。
其中“一步链”就非常适合用 LCEL 来表达。
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { MultiServerMCPClient } from "@langchain/mcp-adapters";
import { HumanMessage, ToolMessage } from "@langchain/core/messages";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import {
RunnableBranch,
RunnableLambda,
RunnablePassthrough,
RunnableSequence,
} from "@langchain/core/runnables";
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const mcpClient = new MultiServerMCPClient({
mcpServers: {
amap: {
url: "https://mcp.amap.com/mcp?key=" + process.env.AMAP_MAPS_API_KEY,
},
chrome: {
command: "npx",
args: ["-y", "chrome-devtools-mcp@latest"],
},
},
});
const tools = await mcpClient.getTools();
const prompt = ChatPromptTemplate.fromMessages([
["system", "你是一个可以调用 MCP 工具的智能助手。"],
new MessagesPlaceholder("messages"),
]);
const llmChain = prompt.pipe(model.bindTools(tools));
const toolExecutor = RunnableLambda.from(async ({ response, tools }) => {
const toolMessages = [];
for (const call of response.tool_calls ?? []) {
const target = tools.find((tool) => tool.name === call.name);
if (!target) continue;
const result = await target.invoke(call.args);
const content =
typeof result === "string"
? result
: result?.text || JSON.stringify(result);
toolMessages.push(
new ToolMessage({
content,
tool_call_id: call.id,
})
);
}
return toolMessages;
});
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,
})),
]),
]),
]);
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");
}
这段代码最关键的地方,不是 tool call,而是 state 设计
很多人一写 Agent 就把逻辑写进一个超长函数里,最后最混乱的不是模型,而是状态。
上面这条一步链,把状态拆得比较清楚:
messages:完整消息历史response:这一轮模型返回toolMessages:这一轮工具执行结果done:是否结束final:最终答案
这就是 LCEL 在 Agent 场景里的真正价值:
它把“模型决策”和“流程控制”分开了。
模型负责决定要不要调工具。
Chain 负责决定这一轮怎么推进。
为什么外层还要保留 for 循环
因为 LCEL 更适合描述“一步怎么跑完”,而不是把整个复杂 Agent 生命周期都塞进一条链里。
这也是一个非常重要的工程边界:
- 如果你只是轻量工具调用循环,LCEL 足够
- 如果你已经有复杂图状态、恢复、人工审批、checkpoint,应该上 LangGraph
不要为了“都用链式写法”而把图结构问题硬写成 Sequence。
Agent Step 里必须解释清楚的两个参数
1. maxIterations
这不是可有可无的参数,而是安全边界。
没有它,模型在异常情况下可能不断重复调用工具。
2. messages
为什么每轮都要把模型响应和工具结果写回 messages?
因为下一轮模型思考依赖的是“完整上下文”,而不是单次局部变量。
如果不把 ToolMessage 写回去,模型等于不知道工具已经返回了什么。
LCEL 不是只会串行,它更大的价值是“节点级治理”
如果 LCEL 只是 pipe 和 Sequence,那它只是更好的写法。
它真正走向工程化,是因为很多治理能力可以直接挂到节点上。
原始资料里给了几个很典型的例子:withRetry、withFallbacks、withConfig、callbacks。
这几类能力都很重要,而且它们对应的恰好是线上系统最常见的四种需求。
1. withRetry:网络型节点的基础韧性
const safeRetriever = retrieveDocs.withRetry({
stopAfterAttempt: 3,
});
适合挂在这些节点上:
- 向量检索
- 第三方 HTTP 工具
- 易抖动的服务接口
但不要无脑重试有副作用的写操作。
“能重试”和“应该重试”不是一回事。
2. withFallbacks:解决可用性,不解决准确性
const resilientModel = primaryModel.withFallbacks({
fallbacks: [backupModel],
});
这个能力解决的是:
- 主模型超时怎么办
- 主服务限流怎么办
- 某个 provider 暂时不可用怎么办
它不解决“答案质量不理想”。
fallback 是可用性策略,不是效果调优策略。
3. withConfig:把运行时配置和业务输入分开
const chainWithConfig = ragChain.withConfig({
configurable: {
tenantId: "finance",
locale: "zh-CN",
},
});
像租户、角色、语言、实验开关这类数据,本质上不属于用户问题本身。
把它们塞进业务输入里会污染链路语义,用 config 承载更合理。
4. callbacks:把观测从业务逻辑里剥离出去
如果你想知道某个节点到底输出了什么,最差的办法就是到处写 console.log。
更好的方式是把观测挂到 callback 上。
这样你可以做到:
- 不污染业务代码
- 节点级观测更清晰
- 后续更容易接 LangSmith 或自研 tracing
什么时候该用 LCEL,什么时候不该强上
LCEL 很有价值,但不是所有场景都必须用。
我更推荐这样判断:
适合直接过程式写法
- 只是一个 20 行以内的小 demo
- 没有分支
- 没有复用诉求
- 没有后续扩展计划
适合优先上 LCEL
- 链路已经有 3 个以上步骤
- 存在明确的节点边界
- 想支持
stream / batch - 有重试、降级、配置、观测需求
- 后面大概率还要接 RAG、Parser、Tool 或 LangSmith
应该考虑 LangGraph
- 多轮 Agent 状态显著复杂
- 有显式图结构
- 需要 checkpoint / 恢复
- 需要人工介入
- 有复杂回流和多分支循环
一句话概括就是:
链的问题,用 LCEL;图的问题,用 LangGraph。
写 LCEL 时最容易踩的 6 个坑
1. 把所有逻辑都塞进一个 RunnableLambda
这会让你失去 LCEL 的最大价值。
如果一个步骤未来可能单独重试、单独观测、单独替换,就应该拆成独立节点。
2. 以为链式写法只是“少写几行代码”
真正重要的不是链式,而是它让节点边界、数据流和执行方式都变清晰了。
3. RAG 场景里只调模型,不看检索
很多答案错,不是模型乱说,而是上游检索和上下文构建已经错了。
4. 把 Output Parser 和 tool 混用
- 想要稳定结构化结果,用 Parser
- 想要触发外部动作,用 tool
这两个问题不是一类问题。
5. Agent 没有安全边界
maxIterations、异常中止、超时控制,这些都应该显式存在。
6. 过早把所有链都复杂化
LCEL 的前提是“链路复杂度已经值得你建模”。
如果只是一个一次性小脚本,直接写并不丢人。
总结
如果只看语法,LCEL 很像一层表达式包装。
但真正把它放进工程里看,你会发现它解决的是一件更核心的事:
当 AI 应用从“单次模型调用”演化成“多节点工作流”时,代码应该如何继续保持可维护。
从这个角度出发,4 类最典型的 LCEL 用法其实很清楚:
- 线性链:适合结构化抽取、稳定格式输出
- 路由链:适合多意图入口的统一调度
- RAG 链:适合把检索、上下文构建、生成回答明确分层
- Agent Step:适合把工具调用流程拆成可推进的一步
再往上一层,withRetry、withFallbacks、withConfig、callbacks 这些能力,则让 LCEL 不只是“能组装”,而是真正具备了工程治理能力。
所以我对 LCEL 的判断一直很明确:
它不是 LangChain 的装饰层,而是 LangChain 里最值得尽早掌握的工程化抽象之一。
当你开始觉得 AI 项目不是“不会调模型”,而是“流程越来越难维护”时,基本就到了该认真用 LCEL 的时候了。