万物皆 Runnable:LangChain 如何用一个接口统一模型、链与图

摘要: 拆解 Runnable 如何用统一接口让模型、链与图以相同方式执行和组合,揭示从抽象契约到 Pregel 引擎的全链路运行机制。


一、从五个痛点开始

如果你用过 LangChain 的早期版本,大概体验过这种痛苦:

typescript 复制代码
// 模型有自己的调用方式
const llmResult = await model.generate([messages]);

// 解析器有自己的调用方式
const parsed = await parser.parse(llmResult.generations[0].text);

// Chain 又是一种方式
const result = await chain.run({ question: "What is LCEL?" });

// 想要流式?每层都要自己处理

五个组件,五种调用方式。想把它们串起来?手写适配代码。想端到端流式?各环节延迟累加。想在 LangSmith 里看完整调用树?各组件独立接追踪,无法形成统一视图。

更深层的问题是 LangGraph 与 LangChain 的割裂 ------图的 invoke 和 Chain 的 run 是两套完全不同的接口。图无法嵌入 Chain,Chain 无法作为图节点。

Runnable 的诞生就是为了解决这一切:用一套抽象,让任意计算单元都能以相同方式被调用、组合、追踪和容错。


二、三条设计原则

Runnable 的设计并非简单的"接口统一",它背后有三条清晰的工程原则。

2.1 单一接口原则

所有可执行单元实现同一个 RunnableInterface

typescript 复制代码
interface RunnableInterface<RunInput, RunOutput, CallOptions> {
  invoke(input, options?): Promise<RunOutput>;       // 同步调用
  stream(input, options?): AsyncIterable<RunOutput>; // 流式输出
  batch(inputs[]): Promise<RunOutput[]>;             // 批量执行
  transform(generator, options): AsyncGenerator;     // 流式组合
}

llm.invoke()chain.invoke()graph.invoke() ------ 完全相同的调用方式。用户不需要关心内部是函数、模型还是图。

2.2 不可变装饰原则

所有增强操作------绑定配置、添加重试、注册降级方案------都返回新实例

typescript 复制代码
const model = new ChatOpenAI({ model: "gpt-4o" });
const configured = model.withConfig({ temperature: 0.7 }); // 新实例
const retryable = configured.withRetry({ maxAttempts: 3 }); // 又一个新实例

// 原 model 完全不受影响,可在多处安全复用

这消除了"我 bind 了参数会不会影响别处用到这个模型"的焦虑。

2.3 渐进式能力原则

子类仅需实现 invoke() 即可获得全部能力。需要优化的可以逐步覆盖:

覆盖什么 获得什么
只实现 invoke() 自动获得 batch / stream / pipe / withRetry
覆盖 _streamIterator() token 级流式能力
覆盖 transform() 流式组合能力
覆盖 batch() 原生批量能力(如并行 API 调用)

这意味着写一个新的 LLM Provider 只需实现一个 _generate() 方法,其余一切由基类打理。


三、_callWithConfig:看不见的追踪神经

每个 Runnable 执行时都经过 _callWithConfig,它是一条自动化的追踪管线:

scss 复制代码
_callWithConfig(func, input, options)
  │
  ├── ensureConfig(options)
  │     └── 合并 AsyncLocalStorage 隐式配置 + 显式配置 + 默认值
  │
  ├── getCallbackManagerForConfig(config)
  │     └── 创建或复用 CallbackManager
  │
  ├── handleChainStart(serialized, input)
  │     └── 在 LangSmith 中创建一个新的 Span
  │
  ├── func(input, config, runManager)
  │     └── 执行实际逻辑
  │
  ├── handleChainEnd(output)    // 成功时关闭 Span
  └── handleChainError(error)   // 失败时记录错误

关键细节 :子 Runnable 调用时通过 patchConfig(config, { callbacks: runManager?.getChild('step:N') }) 派生子 run。这保证了追踪树的层级结构正确------父 Span 自动包含所有子 Span,无需手动管理。

配置的六种合并策略

当多个 config 层叠时(withConfigwithConfig,运行时再传 config),mergeConfigs 按以下规则合并:

字段 策略 为什么
metadata / configurable 浅合并,后者覆盖 配置项独立,后者优先
tags 去重合并 标签可累积
timeout 取最小值 更严格的约束
signal AbortSignal.any() 任一取消即生效
callbacks 6 种组合 回调可叠加

ensureConfig 的 timeout 归一化

ensureConfig 有一个精巧的设计:timeout 转为 AbortSignal.timeout(),然后删除 timeout 字段

typescript 复制代码
if (config.timeout != null) {
  const timeoutSignal = AbortSignal.timeout(config.timeout);
  config.signal = config.signal
    ? AbortSignal.any([config.signal, timeoutSignal])
    : timeoutSignal;
  delete config.timeout;  // ← 关键:防止下游重复归一化
}

为什么删除?因为如果保留,下游每次 ensureConfig 都会再创建一个 AbortSignal.timeout(),导致信号堆叠。一次性归一化确保所有取消逻辑统一走 signal


四、组合器:LCEL 的积木系统

Runnable 不只是一个执行接口,它还是一套组合原语。@langchain/core/runnables 提供了一整套组合器,让你像搭积木一样构建执行管线。

4.1 pipe():最核心的操作

typescript 复制代码
pipe(coerceable) {
  return new RunnableSequence({
    first: this,
    last: _coerceToRunnable(coerceable),
  });
}

a.pipe(b).pipe(c) 创建嵌套的 RunnableSequence,这就是 LCEL(LangChain Expression Language)的底层数据结构。

_coerceToRunnable 接受三种输入:

typescript 复制代码
_coerceToRunnable(coerceable) {
  if (typeof coerceable === "function") return new RunnableLambda({ func: coerceable });
  if (Runnable.isRunnable(coerceable))  return coerceable;
  if (typeof coerceable === "object")   return new RunnableMap({ steps: ... });
}

函数、Runnable 实例、对象映射------统统能 pipe。

4.2 RunnableSequence:链式执行

typescript 复制代码
class RunnableSequence extends Runnable {
  first: Runnable;
  middle: Runnable[];
  last: Runnable;

  async invoke(input) {
    let result = await this.first.invoke(input);
    for (const step of this.middle) result = await step.invoke(result);
    return await this.last.invoke(result);
  }
}

看似简单,但 RunnableSequence 的真正威力在于流式组合------后面会详述。

4.3 RunnableMap:并行扇出

typescript 复制代码
class RunnableMap extends Runnable {
  steps: Record<string, Runnable>;

  async invoke(input) {
    // 所有步骤并发执行,共享同一输入
    const entries = Object.entries(this.steps);
    const results = await Promise.all(entries.map(([k, v]) => v.invoke(input)));
    return Object.fromEntries(entries.map(([k], i) => [k, results[i]]));
  }
}

输入一个问题,同时跑"搜索"和"总结",输出 { search: ..., summary: ... }

4.4 RunnableBinding:装饰器模式

typescript 复制代码
class RunnableBinding extends Runnable {
  bound: Runnable;       // 被装饰的 Runnable
  config: RunnableConfig; // 绑定的配置

  async invoke(input, options) {
    return this.bound.invoke(input, await this._mergeConfig(options));
  }
}

所有需要"包裹"另一个 Runnable 的场景都继承它:

子类 增强什么
RunnableRetry 指数退避重试
PregelNode 通道读写 + 重试/缓存/超时策略
ConfigurableModel 动态模型选择
RunnableWithMessageHistory 自动读写消息历史

五、流式协议:端到端零延迟的秘密

Runnable 的流式设计是它最精妙的部分。传统框架的"流式"往往是假流式------每个组件内部等全部结果就绪再传给下游,端到端延迟等于各环节延迟之和。

Runnable 通过三层协议实现了真端到端流式

第一层:_streamIterator

基础流式单元。LLM 子类覆盖它实现 token 级流式:

typescript 复制代码
// BaseChatModel 内部
async *_streamIterator(input) {
  for await (const chunk of this._streamResponseChunks(messages)) {
    yield chunk;  // 每收到一个 token 就 yield
  }
}

第二层:stream()

_streamIterator 包装为 IterableReadableStream,增加回调事件发射。通常不需要覆盖。

第三层:transform()------关键创新

transform() 接受一个 AsyncGenerator 作为输入,返回一个新的 AsyncGenerator。这是流式组合的基础:

typescript 复制代码
// RunnableSequence 的 _streamIterator
async *_streamIterator(input) {
  let stream = this.first.stream(input);        // 第一步产出 AsyncGenerator
  for (const step of this.middle) {
    stream = step.transform(stream);             // 每步消费并产出新的 Generator
  }
  yield* this.last.transform(stream);            // 最后一步逐块 yield
}
scss 复制代码
prompt.stream(input)
  → model.transform(promptStream)     // 消费 prompt 的 AsyncGenerator,逐 token yield
    → parser.transform(modelStream)   // 消费 model 的 token 流,逐步解析并 yield
      → 最终输出(逐块到达用户)

效果:用户看到的第一个 token 不需要等待 prompt 模板渲染完成、模型生成完成、解析完成------prompt 渲染完的第一个 token 立刻流入模型,模型产出的第一个 token 立刻流入解析器。端到端延迟从"各环节之和"降低为"第一个 token 穿越管线的时间"。


六、语言模型如何融入 Runnable 体系

语言模型是 Runnable 体系最重要的用户之一。继承链如下:

scss 复制代码
Runnable
  └── BaseLanguageModel            ← 模板方法:generate() 定义骨架
        ├── BaseChatModel          ← 覆写 invoke / _streamIterator / bindTools
        │     └── ChatOpenAI / ChatAnthropic / ...
        └── BaseLLM                ← 文本模型
              └── LLM / SimpleChatModel

BaseLanguageModel:模板方法模式

typescript 复制代码
abstract class BaseLanguageModel extends Runnable {
  abstract _generate(messageArrays, options, runManager): Promise<LLMResult>;

  async generate(messages, options, callbacks) {
    // 1. 分离 RunnableConfig 与模型特定参数
    // 2. 检查缓存(相同输入可跳过模型调用)
    // 3. 调用 _generateUncached(内含追踪逻辑)
    // 4. 聚合结果
  }
}

generate() 定义了骨架流程,子类只实现 _generate()。追踪、缓存、回调逻辑集中在基类------Provider 开发者不需要关心这些横切关注点。

BaseChatModel:三个关键覆写

方法 做了什么
invoke() messages → ChatPromptValue → _generate → AIMessageChunk
_streamIterator() 检查子类是否覆盖 _streamResponseChunks:是则逐块 yield;否则退化为 invoke
bindTools() 返回 RunnableBinding,将工具定义注入 config

Provider 扩展契约 :各 Provider(OpenAI、Anthropic 等)必须重写 _generate / _streamResponseChunks,而非直接实现 invoke。这保证了回调、缓存、追踪逻辑始终集中在基类。


七、LangGraph 的 Runnable 扩展:一切皆 Runnable

LangGraph 在 @langchain/core 的 Runnable 体系之上做了最关键的扩展------将图执行的每一个内部组件都变成了 Runnable。

7.1 RunnableCallable:函数到 Runnable 的桥梁

typescript 复制代码
class RunnableCallable extends Runnable {
  func: (...args: any[]) => any;
  trace: boolean = true;    // 是否生成 LangSmith trace
  recurse: boolean = true;  // 函数返回 Runnable 时是否递归 invoke
}

trace: false 用于内部基础设施组件(ChannelRead / ChannelWrite),避免追踪噪音。recurse: true 允许函数返回 Runnable 并自动展开。

7.2 ChannelRead / ChannelWrite:通道的 Runnable 化

这是 LangGraph 的核心创新------将通道读写封装为 Runnable,使节点执行变成标准的 RunnableSequence:

typescript 复制代码
class ChannelRead extends RunnableCallable {
  channel: string;

  // 不直接持有状态数据,通过 config.configurable[CONFIG_KEY_READ] 回调读取
  static doRead(config, channel, fresh, mapper) {
    const read = config.configurable[CONFIG_KEY_READ];
    return mapper ? mapper(read(channel, fresh)) : read(channel, fresh);
  }
}

class ChannelWrite extends RunnableCallable {
  writes: ChannelWriteEntry[];

  static async doWrite(config, writes) {
    // PASSTHROUGH 占位符在运行时替换为实际输入
    // 通过 config.configurable[CONFIG_KEY_SEND] 写入通道
  }
}

7.3 PregelNode:节点的完整运行时包装

typescript 复制代码
class PregelNode extends RunnableBinding {
  channels: Record<string, string>;  // 输入通道映射
  triggers: string[];                // 触发条件
  writers: Runnable[];               // 后置写入器
  bound: Runnable;                   // 用户逻辑本体
}

PregelNode.getNode() 返回节点执行的完整管线:

scss 复制代码
ChannelRead(输入通道) → 用户逻辑(bound) → ChannelWrite(输出通道)

7.4 Branch:条件路由的 Runnable 化

typescript 复制代码
class Branch {
  path: Runnable;  // 条件判断逻辑(invoke 返回目标节点名)
  ends?: Record<string, string>;

  run(writer) {
    // 返回 RunnableCallable,注册为源节点的 writer
    // 内部调用 path.invoke() → 通过 writer 将路由结果写入 branch:* 通道
  }
}

条件路由不是特殊逻辑,它是一个 Runnable------完美融入 Pregel 的消息传递模型。

7.5 Pregel:图即 Runnable

typescript 复制代码
class Pregel extends Runnable {
  nodes: Record<string, PregelNode>;
  channels: Record<string, BaseChannel>;

  override async stream(input, options) {
    // 完整的 Pregel 执行循环
  }
}

编译后的图本身就是 Runnable。这意味着:

typescript 复制代码
// 图可以直接 pipe
const pipeline = prompt.pipe(model).pipe(graph).pipe(parser);

// 图可以作为子图嵌入另一个图
parentGraph.addNode("sub", childGraph);

// 图可以绑定配置
const threadGraph = graph.withConfig({ configurable: { thread_id: "abc" } });

八、全链路运行:从 invoke 到 Superstep

理解 Runnable 的最后一块拼图是看它如何驱动整个执行链路。当用户调用 graph.invoke(input) 时,背后发生了什么?

8.1 调用链全貌

scss 复制代码
graph.invoke(input)
  → Pregel.stream(input, config)
    → ensureLangGraphConfig(options)
    → mergeConfigs(this.config, options)
    → runWithConfig(config, ...)
      → _streamIterator()
        → 创建 PregelLoop
        → 创建 PregelRunner
        → 循环 runner.tick() + loop.tick()

8.2 单次 Superstep 的 Runnable 数据流

在一个 Superstep 中,PregelRunner 并发执行所有就绪任务。每个任务的执行都是一个 RunnableSequence:

arduino 复制代码
PregelRunner.tick()
  │
  ├── task.runnable.invoke(taskInput, taskConfig)
  │   │
  │   │  ← PregelNode.getNode() 返回的 RunnableSequence:
  │   │
  │   ├── ChannelRead.invoke()
  │   │   └── config.configurable[CONFIG_KEY_READ](channel)
  │   │       → 从通道读取当前值
  │   │
  │   ├── bound.invoke(channelValues, config)
  │   │   └── 执行用户函数
  │   │
  │   └── ChannelWrite.invoke(nodeOutput, config)
  │       └── config.configurable[CONFIG_KEY_SEND](writes)
  │           → 写入通道
  │
  └── 收集 writes → PregelLoop.applyTaskWrites()

核心洞察 :整个执行过程只使用了一种调用方式------runnable.invoke(input, config)。无论是读通道、执行用户逻辑、写通道、条件路由还是子图执行,全部通过 Runnable 的 invoke 方法驱动。

这就是 Runnable 作为"对内组合原语"的真正含义:Pregel 引擎不需要为每种组件类型编写特殊调用逻辑,它只需要知道一件事------"拿到 Runnable,调用 invoke"。

8.3 配置传播路径

scss 复制代码
用户调用
  graph.invoke(input, { configurable: { thread_id: "123" } })
    │
    ▼
Pregel.stream()
  ├── ensureLangGraphConfig()    ← 补全默认配置
  ├── mergeConfigs()             ← 合并图级配置
  ├── runWithConfig()            ← 设置 AsyncLocalStorage
  │
  ▼
PregelLoop.initialize()
  ├── 注入 CONFIG_KEY_READ       ← 通道读取回调
  ├── 注入 CONFIG_KEY_SEND       ← 通道写入回调
  ├── 注入 CONFIG_KEY_STREAM     ← 流式事件回调
  ├── 注入 CONFIG_KEY_CALL       ← 函数式 API 调度
  │
  ▼
PregelRunner.tick()
  ├── patchConfigurable(taskId)
  │
  ▼
task.runnable.invoke(input, taskConfig)
  ├── ChannelRead → 从 taskConfig.configurable[CONFIG_KEY_READ] 读
  ├── bound       → 用户函数(可通过 getContext() 获取 config)
  └── ChannelWrite → 向 taskConfig.configurable[CONFIG_KEY_SEND] 写

8.4 AsyncLocalStorage:函数式 API 的基石

Runnable 利用 Node.js 的 AsyncLocalStorage 实现隐式配置传递。这使得 call()interrupt()getContext() 等函数式 API 能在不显式传递 config 的情况下获取运行时上下文:

typescript 复制代码
// 用户代码中无需 import config 或传递参数
function myNode(state) {
  const context = getContext();                    // 从 ALS 获取
  const decision = interrupt({ question: "确认?" }); // 从 ALS 获取
  const result = call(subTask, arg1, arg2);         // 从 ALS 获取
  return { ...state, decision };
}

九、设计模式速查

Runnable 体系至少运用了 8 种设计模式:

模式 在哪里 怎么用的
装饰器 PregelNode, RunnableBinding 继承 RunnableBinding,在 bound 外包裹通道读写
组合 RunnableSequence 串联 ChannelRead → 用户逻辑 → ChannelWrite
策略 Branch.path 持有 Runnable,invoke 返回路由目标
桥接 RunnableCallable 普通函数 → Runnable 接口
代理 RemoteGraph 以相同接口代理远程 Pregel 实例
依赖注入 ChannelRead/Write 通过 config.configurable 注入读写回调
模板方法 BaseLanguageModel generate() 定义骨架,子类实现 _generate()
流式管线 transform() AsyncGenerator 链式链接,逐块传递

十、Runnable 的双重身份

回看整个设计,Runnable 承担了两个层次的角色:

对外:统一接口llm.invoke()chain.invoke()graph.invoke() 使用完全相同的调用方式。用户可以自由组合:

typescript 复制代码
const pipeline = prompt
  .pipe(model.withRetry({ maxAttempts: 3 }))
  .pipe(graph)
  .pipe(parser);

const result = await pipeline.invoke(input);

对内:组合原语 。图的内部组件------节点、通道读写、条件路由、工具执行------全部是 Runnable 子类。Pregel 引擎只需要 runnable.invoke() 一种调用方式就能驱动一切。

这种内外一致的抽象使得 LangChain 和 LangGraph 的代码可以无缝互操作------CompiledGraph 是 Runnable,可以直接用于 LangChain Chain;LangChain 的 Tool 也是 Runnable,可以直接作为图节点。


十一、设计启示

11.1 一个接口解决一类问题

Runnable 的设计目标不是"提供一个通用接口",而是"用一个接口解决组合性的一类问题"。它没有试图统一存储、统一部署或统一监控------它只统一执行。这种聚焦使得抽象足够简单、足够通用。

11.2 不可变优于可变

withConfigwithRetrywithFallbacks 全部返回新实例。这看似"浪费内存",实则消除了所有共享状态带来的心智负担。在生产环境中,一个 Runnable 可能在多处复用,不可变装饰确保了安全。

11.3 渐进式 > 一步到位

子类只需实现 invoke() 就能获得完整能力。这让新 Provider 的接入成本极低------实现一个 _generate() 方法就能获得追踪、缓存、流式、重试的全部能力。需要优化时,逐步覆盖特定方法即可。

11.4 通道读写 Runnable 化是最关键的桥接

LangGraph 将 ChannelRead / ChannelWrite 封装为 Runnable,看似多余,实则让节点执行变成了标准的 RunnableSequence。这使得 Pregel 引擎不需要为"读通道"和"写通道"编写特殊逻辑------一切都是 Runnable,一切都通过 invoke 驱动。


十二、关键源码索引

@langchain/core

文件 核心职责
runnables/base.ts Runnable 抽象基类、全部组合器、_coerceToRunnable
runnables/types.ts RunnableInterface 契约、RunnableConfig 配置类型
runnables/config.ts ensureConfig / mergeConfigs / patchConfig
language_models/chat_models.ts BaseChatModel 聊天模型基类

@langchain/langgraph

文件 核心职责
pregel/index.ts Pregel 类(图即 Runnable)
pregel/read.ts ChannelRead / PregelNode
pregel/write.ts ChannelWrite / PASSTHROUGH
pregel/loop.ts PregelLoop 执行循环
pregel/runner.ts PregelRunner 任务执行器
graph/state.ts StateGraph 编译

本文基于 LangChain.js 和 LangGraph.js 源码分析,版本对应 @langchain/core@langchain/langgraph 核心运行时。

相关推荐
科技圈快迅1 小时前
解决续费痛点,轻量化AI订阅替代方案深度解读
大数据·人工智能
Tbisnic1 小时前
23.大模型开发:深度学习----CNN 卷积神经网络 与 RNN 循环神经网络
人工智能·python·rnn·深度学习·cnn·ai大模型
灵机一物1 小时前
2026算力推荐进入专业化阶段:超互联品牌如何构建企业AI选型能力矩阵
服务器·人工智能·决策树·云平台
2zcode1 小时前
基于MATLAB语音信号分析与机器学习的帕金森病智能检测系统
人工智能·机器学习·matlab
冬奇Lab1 小时前
MCP Server 入门开发 + 协议调试 + 生产级部署
人工智能·llm·mcp
冬奇Lab1 小时前
开源项目第157期:archify — 用自然语言生成可主题切换、高分辨率导出的架构图
人工智能·架构·claude
2301_800256111 小时前
人工智能入门学习——ANN、CNN基本概念和考试核心代码
人工智能·学习·cnn
A hao1 小时前
HDR LED显示屏工作原理:高动态范围显示技术解析
大数据·图像处理·人工智能
IT_陈寒1 小时前
SpringBoot自动配置的坑,我的Bean怎么不生效?
前端·人工智能·后端