第 7 课: RunnableParallel — 并行执行与数据编排

0 阅读3分钟

课程目标

掌握 RunnableParallel(RunnableMap)的并行执行机制、RunnablePassthrough 的透传技巧,以及它们组合形成的数据编排模式。


7.1 RunnableParallel 是什么?

RunnableParallel 让多个 Runnable 并行执行,各自处理同一个输入,然后将结果合并为一个对象:

import { RunnableParallel } from "@langchain/core/runnables";

const parallel = RunnableParallel.from({
  joke: jokeChain,     // 生成笑话
  poem: poemChain,     // 生成诗歌
  fact: factChain,     // 生成事实
});

const result = await parallel.invoke({ topic: "cats" });
// result = {
//   joke: "Why did the cat sit on the computer? ...",
//   poem: "Soft paws upon the windowsill...",
//   fact: "Cats sleep 12-16 hours a day..."
// }

注意:源码中 RunnableParallel 实际上是 RunnableMap 的别名:

// base.ts:2852
export class RunnableParallel<RunInput> extends RunnableMap<RunInput> {}

7.2 内部结构

源码位置: libs/langchain-core/src/runnables/base.ts:2261

export class RunnableMap<RunInput = any, RunOutput = Record<string, any>>
  extends Runnable<RunInput, RunOutput> {

  protected steps: Record<string, Runnable<RunInput>>;

  constructor(fields: { steps: RunnableMapLike<RunInput, RunOutput> }) {
    super(fields);
    this.steps = {};
    for (const [key, value] of Object.entries(fields.steps)) {
      this.steps[key] = _coerceToRunnable(value);  // 自动包装
    }
  }
}

关键设计

  • steps 是一个键值对:{ key: Runnable }
  • 每个 value 通过 _coerceToRunnable() 自动转换(函数 → RunnableLambda)
  • 所有 step 接收相同的输入
  • 输出是 { key: step.invoke(input) } 的合并对象

7.3 invoke 执行:真正的并行

async invoke(input, options) {
  const output = {};
  const promises = Object.entries(this.steps).map(
    async ([key, runnable]) => {
      output[key] = await runnable.invoke(
        input,
        patchConfig(config, {
          callbacks: runManager?.getChild(`map:key:${key}`),
        })
      );
    }
  );
  await raceWithSignal(Promise.all(promises), config.signal);
  return output;
}

执行模型

  • Promise.all() 确保所有 step 并行执行
  • raceWithSignal() 支持 AbortSignal 取消
  • 每个 step 的 callback 使用 map:key:${key} 命名,可在追踪中区分
输入 ──┬──→ steps["joke"].invoke(input) ──→ output.joke
       ├──→ steps["poem"].invoke(input) ──→ output.poem   (并行)
       └──→ steps["fact"].invoke(input) ──→ output.fact
                                              │
                                              ▼
                                        { joke, poem, fact }

7.4 流式:竞争式输出

RunnableMap 的 _transform() 实现了一个精巧的流式策略(base.ts:2339):

async *_transform(generator, runManager, options) {
  const steps = { ...this.steps };
  // 1. 用 atee 复制输入流,每个 step 各一份
  const inputCopies = atee(generator, Object.keys(steps).length);

  // 2. 每个 step 开始 transform
  const tasks = new Map(
    Object.entries(steps).map(([key, runnable], i) => {
      const gen = runnable.transform(inputCopies[i], config);
      return [key, gen.next().then((result) => ({ key, gen, result }))];
    })
  );

  // 3. 竞争式 yield:谁先有 chunk,就先 yield 谁
  while (tasks.size) {
    const { key, result, gen } = await Promise.race(tasks.values());
    tasks.delete(key);
    if (!result.done) {
      yield { [key]: result.value };  // yield 部分结果
      tasks.set(key, gen.next().then((result) => ({ key, gen, result })));
    }
  }
}

流式输出示例

for await (const chunk of await parallel.stream({ topic: "cats" })) {
  console.log(chunk);
}
// 输出顺序取决于哪个 step 先产出 chunk:
// { joke: "Why" }     ← joke chain 先产出
// { poem: "Soft" }    ← poem chain 紧跟
// { joke: " did" }    ← joke 继续
// { fact: "Cats" }    ← fact chain 开始
// ...

7.5 RunnablePassthrough — 透传原始输入

源码位置: libs/langchain-core/src/runnables/passthrough.ts

export class RunnablePassthrough<RunInput = any>
  extends Runnable<RunInput, RunInput> {

  async invoke(input, options) {
    if (this.func) {
      await this.func(input, config);  // 可选的副作用函数
    }
    return input;  // 原样返回
  }
}

核心用途:在 RunnableParallel 中保留原始输入:

const chain = RunnableParallel.from({
  // 保留原始输入
  original: new RunnablePassthrough(),
  // 同时做处理
  processed: someProcessingChain,
});

const result = await chain.invoke("hello");
// { original: "hello", processed: "..." }

7.6 RunnablePassthrough.assign — 扩展输入

assign() 是一个静态方法,创建 RunnableAssign,它将新的键值对添加到原始输入上:

// 语义:保留原始输入的所有字段,并添加新字段
const chain = RunnablePassthrough.assign({
  context: retriever,           // 新增 context 字段
  timestamp: () => Date.now(),  // 新增 timestamp 字段
});

const result = await chain.invoke({ question: "what is LangChain?" });
// {
//   question: "what is LangChain?",  ← 原始字段保留
//   context: [...documents],          ← 新增
//   timestamp: 1699000000000,         ← 新增
// }

RunnableAssign 的实现(base.ts:3210)内部用了 RunnableParallel:

export class RunnableAssign extends Runnable {
  mapper: RunnableMap;  // 内部用 RunnableMap 并行计算新字段

  async invoke(input, options) {
    const mapperResult = await this.mapper.invoke(input, config);
    return {
      ...input,        // 保留原始输入
      ...mapperResult, // 合并新字段
    };
  }
}

7.7 常见数据编排模式

模式 1:RAG 上下文注入

const ragChain = RunnablePassthrough.assign({
  context: (input) => retriever.invoke(input.question),
})
  .pipe(prompt)   // prompt 可以访问 question + context
  .pipe(model)
  .pipe(parser);

模式 2:并行查询多个数据源

const multiSource = RunnableParallel.from({
  faq: faqRetriever,
  docs: docRetriever,
  history: historyRetriever,
}).pipe((results) => ({
  context: [...results.faq, ...results.docs, ...results.history],
}));

模式 3:输入增强

const enriched = RunnablePassthrough.assign({
  language: (input) => detectLanguage(input.text),
  sentiment: (input) => analyzeSentiment(input.text),
  keywords: (input) => extractKeywords(input.text),
});

7.8 实战练习

练习:构建并行查询 + 合并链

import { RunnableParallel, RunnablePassthrough, RunnableLambda } from "@langchain/core/runnables";

const search = RunnableLambda.from(
  async (query: string) => `搜索结果: ${query}`
);
const translate = RunnableLambda.from(
  async (query: string) => `翻译: ${query}`
);
const summarize = RunnableLambda.from(
  async (query: string) => `摘要: ${query}`
);

// 并行执行三个任务
const parallel = RunnableParallel.from({
  searchResult: search,
  translation: translate,
  summary: summarize,
});

// 合并结果
const chain = parallel.pipe(
  (results) => `${results.searchResult}\n${results.translation}\n${results.summary}`
);

const result = await chain.invoke("LangChain.js");
// "搜索结果: LangChain.js\n翻译: LangChain.js\n摘要: LangChain.js"

7.9 源码精读路线

优先级文件关注点
P0runnables/base.ts:2261-2300RunnableMap 类定义、steps 结构、构造函数
P0runnables/base.ts:2299-2337invoke() 的 Promise.all 并行执行
P1runnables/base.ts:2339-2406_transform() 竞争式流输出(atee + Promise.race)
P1runnables/passthrough.tsRunnablePassthrough、透传 + 可选副作用
P2runnables/base.ts:3210-3345RunnableAssign(assign() 的实现)
P2runnables/base.ts:2852RunnableParallel(RunnableMap 的别名)

本课收获总结

级别你应该掌握的
🟢 基础学会用 RunnableParallel 并行处理多个子任务;理解 RunnablePassthrough 的透传语义
🔵 中阶理解 invoke 中 Promise.all 的并行执行;掌握 assign() 的输入扩展模式
🟡 高阶掌握 _transform 的竞争式流输出(Promise.race)和 atee 输入复制
🟠 资深分析并行执行中的错误处理策略;理解 RunnableAssign 与 RunnableMap 的组合
🔴 架构用 Parallel + Passthrough + Assign 实现 DAG 数据流编排

下一课预告

第 8 课讲 RunnableBranchRouterRunnable — 根据运行时条件动态选择不同的执行路径。