LCEL 到底该怎么用:4 类典型 AI 链路的工程化写法

21 阅读17分钟

很多人第一次学 LangChain 的 LCEL,都会有一个共同感受:概念不难,但很难判断它到底该用在什么地方。

因为从语法上看,LCEL 很像是在做一件很朴素的事:

  • PromptTemplate
  • ChatModel
  • OutputParser
  • Retriever
  • 普通函数

这些原本分散的组件,串成一条 chain,然后统一 invokestreambatch

如果只停留在这里,LCEL 很容易被理解成“更好看的链式写法”。但真实项目里,LCEL 的价值远远不止代码更短。

这篇文章我想讲清楚一个核心结论:

LCEL 最重要的价值,不是让 LangChain 代码更优雅,而是把 AI 应用从一堆离散调用,变成一条可组合、可观测、可治理的数据流。

为了把这件事讲透,这篇文章不走“API 目录式”讲法,而是直接围绕 4 类最典型的 AI 链路展开:

  1. 线性链:Prompt -> Model -> Parser
  2. 路由链:分类 -> 分支 -> 不同处理路径
  3. RAG 链:检索 -> 组装上下文 -> 生成答案
  4. 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 个实现关键词

这个场景很典型,因为它满足三件事:

  1. 步骤天然是线性的
  2. 输出格式必须稳定
  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. 执行语义统一

你不需要分别手动 formatinvokeparse,只需要执行整条 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 个节点:

  1. 归一化问题
  2. 向量检索
  3. 构建上下文
  4. Prompt 组织
  5. 调模型并输出

一个常见误区:把 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. chunkSizechunkOverlap

它们不在在线链路里,但直接决定在线效果。

一个很实用的经验是:

  • chunkSize 先从 300~800 tokens 起试
  • chunkOverlap 先从 10%~20% 起试

块太小,语义断裂;块太大,召回变钝,还会占用上下文窗口。

所以 RAG 从来不是“把向量库接上去就完事”,而是离线切分策略 + 在线链路组织方式一起决定效果。


典型场景四:Agent Step 不是一条普通链,而是一条“会反复执行的一步”

到这里,LCEL 的基本价值已经比较清楚了。但很多开发者真正卡住的地方,其实是工具调用。

因为工具调用不是简单的线性流程,它更像这样:

  1. 把消息交给模型
  2. 模型决定是否调用工具
  3. 如果有 tool call,就执行工具
  4. 把工具结果写回上下文
  5. 再让模型继续思考
  6. 直到没有 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 只是 pipeSequence,那它只是更好的写法。
它真正走向工程化,是因为很多治理能力可以直接挂到节点上。

原始资料里给了几个很典型的例子:withRetrywithFallbackswithConfigcallbacks
这几类能力都很重要,而且它们对应的恰好是线上系统最常见的四种需求。

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:适合把工具调用流程拆成可推进的一步

再往上一层,withRetrywithFallbackswithConfigcallbacks 这些能力,则让 LCEL 不只是“能组装”,而是真正具备了工程治理能力。

所以我对 LCEL 的判断一直很明确:

它不是 LangChain 的装饰层,而是 LangChain 里最值得尽早掌握的工程化抽象之一。

当你开始觉得 AI 项目不是“不会调模型”,而是“流程越来越难维护”时,基本就到了该认真用 LCEL 的时候了。