别再把 LangChain 当成 API 胶水:Runnable 才是把 AI 流程工程化的关键接口

17 阅读15分钟

很多人第一次接触 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);

看起来只是少写了几行代码,但它背后其实发生了三件事:

  1. 你把“步骤”提升成了“节点”
  2. 你把“手动依次调用”提升成了“声明式组装”
  3. 你把“局部执行”提升成了“整链执行”

这也是为什么我不建议把 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 是并行执行多个节点,把结果收敛成一个对象;分支路由请用 RunnableBranchRouterRunnable

3. 以为有了 StructuredOutputParser 就一定能稳定出 JSON

不一定。

结构化输出的稳定性,通常取决于四件事:

  • 模型本身是否擅长遵循格式
  • prompt 里有没有明确给格式要求
  • schema 是否设计得过于复杂
  • temperature 是否过高

parser 不是魔法,它只是最后一道契约边界。

4. 以为 RunnableWithMessageHistory 就等于“系统有记忆”

它只能证明“链能读到之前几轮对话”,不代表系统已经具备可控、可检索、可持久化的长期记忆能力。

5. 把所有业务逻辑都塞进链里

链应该负责编排,不应该吞掉一切。

我的建议是:

  • 纯流程控制,放 Runnable
  • 复杂业务规则,放独立模块
  • 外部依赖访问,封装成清晰节点

这样链才会保持可读,而不是变成另一种形式的“大函数”。

工程上怎么落地更稳

如果你准备在真实项目里用 Runnable,我建议默认遵守这几个原则:

1. 先把输出边界定清楚,再写链

也就是先想清楚最终要返回什么字段,再决定 prompt 和 parser。
很多链越写越乱,本质上不是组合问题,而是输入输出契约一开始就没定义好。

2. 让每个节点尽量“单一职责”

一个节点只做一件事,后面替换和测试都会轻松很多。
比如“问题改写”和“结果解析”最好拆开,不要写成一个大 lambda。

3. 线性主链先跑通,再加分支和并行

不要一上来就把 BranchMapHistory 全叠进去。
先让最短链路可用,再逐步把复杂度接回来,这是最稳的推进方式。

4. 在 RAG 里把检索也当成链节点,而不是藏进黑盒函数

这样做的好处是:

  • 检索前后更容易插入 query rewrite
  • 更容易记录召回内容
  • 更容易替换 retriever 策略

5. 默认优先追求“稳定结构”,再追求“花哨表达”

尤其是企业问答、知识库助手、审批助手这类场景,输出结构比语言风格更重要。
Runnable 很适合把这种“稳定结构优先”的设计贯彻到链路里。

总结

很多人学 Runnable,会先记住一串 API 名字:RunnableSequenceRunnableLambdaRunnableMapRunnableBranchRunnablePassthroughRunnableWithMessageHistory。这些当然要会用,但更重要的是理解它们背后的统一思想。

Runnable 不是为了把 LangChain 写得更像链式调用,而是为了把 AI 应用写成一条真正可编排的数据流。

当你的系统还只有“一次 prompt + 一次模型调用”时,这个价值不算明显;但只要你进入下面这些场景,它就会立刻变得重要:

  • 多步骤调用
  • 多分支路由
  • 结构化输出
  • 批量处理
  • 流式返回
  • 多轮对话
  • RAG 在线链路编排

所以如果你现在刚开始学 LangChain,我的建议不是先把所有 Runnable API 背下来,而是先建立一个正确判断:

Prompt、Model、Parser 是能力组件;Runnable 才是把这些能力组织成系统的工程骨架。

一旦这个认知到位,你再回头看 LCEL,就不会觉得它只是“写法更优雅”,而会明白它为什么是 LangChain 里真正值得掌握的基础设施。