LCEL 不是语法糖:它解决的是 AI 工作流的工程组织问题

16 阅读14分钟

很多开发者第一次接触 LangChain 的 LCEL 时,直觉都是一样的:这不就是把原来一段一段的调用,换成了 pipeRunnable 的写法吗?

如果只是做一个“用户提问 -> Prompt -> 模型回答”的单轮调用,这个判断不算错。问题在于,真实 AI 应用很少停留在这一步。一旦链路里开始出现工具调用、RAG 检索、分支判断、重试、降级、流式输出、埋点观测,你会发现真正难维护的,从来不是“调一次模型”,而是“怎么把一条越来越复杂的 AI 流程组织清楚”。

这就是 LCEL 的真正价值。

我的结论先放在前面:

LCEL 最重要的意义,不是让代码更像函数式编程,而是把 Prompt、Model、Retriever、Tool、Parser 这些原本分散的步骤,统一抽象成可组合、可观测、可治理的 Runnable 节点。

它解决的是工程组织问题,而不只是语法问题。


为什么 AI 流程一复杂,代码就开始失控

很多人最早写 LangChain 项目,通常是过程式风格:

  1. 先拼 Prompt
  2. 调一次模型
  3. 如果模型返回了 tool call,就执行工具
  4. 再把工具结果塞回上下文
  5. 如果要做 RAG,再插入一次检索
  6. 如果接口不稳定,再补一层重试
  7. 如果线上要排查,再往代码里塞日志

开始时很顺,写到第三个需求就乱了。

原因不是你不会写代码,而是这种风格天然有三个问题:

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 补上 responsetoolMessages 等中间结果

这里最容易踩的坑是把 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 家酒店,获取图片,并在浏览器中分别打开

这条链路至少有四个步骤:

  1. 把消息交给带工具能力的模型
  2. 判断模型有没有产生 tool_calls
  3. 有的话执行工具,并把工具结果写回消息历史
  4. 没有的话结束本轮,返回最终答案

这就是一个非常典型的 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. chunkSizechunkOverlap

这两个参数不在在线查询链里,但决定了检索上限,必须一起考虑。

一个实用经验是:

  • 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 变成工程代码时必须跨过去的一步。