摘要: 拆解 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 层叠时(withConfig 套 withConfig,运行时再传 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 不可变优于可变
withConfig、withRetry、withFallbacks 全部返回新实例。这看似"浪费内存",实则消除了所有共享状态带来的心智负担。在生产环境中,一个 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 核心运行时。