LangChain.js 完全开发手册(五)Runnable 接口与任务编排系统

第5章:Runnable 接口与任务编排系统

前言

大家好,我是鲫小鱼。是一名不写前端代码的前端工程师,热衷于分享非前端的知识,带领切图仔逃离切图圈子,欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!

🎯 本章学习目标

  • 彻底理解 Runnable 抽象:invokebatchstreampipe
  • 熟练使用常见实现:RunnableLambdaRunnableSequenceRunnableParallelRunnablePassthrough
  • 掌握编排模式:顺序流水线、条件分支、扇出/汇聚、Map/Reduce、回退与重试
  • 将 Prompt、LLM、Parser、Retriever、工具调用整合为可复用的工作流
  • 在 Next.js/Node 中落地一套可观测、可扩展、可测试的编排引擎
  • 完成两个实战:内容智能处理流水线、RAG 数据处理流水线

📖 Runnable 是什么

5.1 核心理念

Runnable 是 LangChain.js 的通用可执行单元抽象。它统一了"输入 → 处理 → 输出"的模式,使得 Prompt、模型、解析器、检索器、工具甚至你自定义的函数,都能以同一套接口进行组合与编排。

它不是"另一个框架层",而是"让你的代码天然可组合、可流式、可批处理"的一层薄抽象。

5.2 标准接口

typescript 复制代码
interface Runnable<Input, Output> {
  invoke(input: Input, options?: RunnableConfig): Promise<Output>;
  stream(input: Input, options?: RunnableConfig): AsyncGenerator<Output>;
  batch(inputs: Input[], options?: RunnableConfig): Promise<Output[]>;
  pipe<NewOutput>(next: Runnable<Output, NewOutput>): Runnable<Input, NewOutput>;
}
  • invoke:单次调用
  • stream:流式产出(如 token 流、分片结果)
  • batch:批处理输入,提高吞吐
  • pipe:将当前 Runnable 的输出作为下一个 Runnable 的输入,形成链式流水线

5.3 常用实现

  • RunnableLambda:把任意函数包成 Runnable
  • RunnableSequence:按顺序将多个 Runnable 串联
  • RunnableParallel:并行执行多个 Runnable 并汇总
  • RunnablePassthrough:原样透传输入,通常配合 map 构建复合对象

备注:不同版本接口细节略有出入,本文以 LangChain.js 0.3 的主流用法为例。


🧩 从 0 到 1:最小可用流水线

5.4 将 Prompt → 模型 → 解析器 串起来

typescript 复制代码
// 文件:src/ch05/sequence-basic.ts
import { PromptTemplate } from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
import { StringOutputParser } from "@langchain/core/output_parsers";

const prompt = PromptTemplate.fromTemplate(
  `你是{role},请对下面内容给出要点总结:\n{content}`
);

const model = new ChatOpenAI({ modelName: "gpt-3.5-turbo", temperature: 0 });
const parser = new StringOutputParser();

// 组合为处理链:Input -> Prompt -> LLM -> Parser -> Output(string)
const chain = prompt.pipe(model).pipe(parser);

export async function run() {
  const out = await chain.invoke({ role: "技术作者", content: "React 并发特性..." });
  console.log(out);
}

if (require.main === module) run();

5.5 用 RunnableLambda 承接异构逻辑

typescript 复制代码
// 文件:src/ch05/lambda-basic.ts
import { RunnableLambda } from "@langchain/core/runnables";

// 统一输入输出形状
type Input = { text: string };

const normalize = new RunnableLambda<Input, Input>((input) => ({
  text: input.text.trim().slice(0, 2000),
}));

export async function run() {
  const out = await normalize.invoke({ text: "   hello runnable    " });
  console.log(out); // { text: "hello runnable" }
}

if (require.main === module) run();

5.6 RunnableSequence.from([...]) 的好处

typescript 复制代码
// 文件:src/ch05/sequence-from.ts
import { RunnableSequence, RunnableLambda } from "@langchain/core/runnables";

const trim = new RunnableLambda((x: string) => x.trim());
const exclaim = new RunnableLambda((x: string) => x + "!");

const seq = RunnableSequence.from<string, string>([
  trim,
  exclaim,
]);

export async function run() {
  console.log(await seq.invoke(" hello ")); // "hello!"
}

if (require.main === module) run();

🔀 条件分支与扇出/汇聚

5.7 条件分支(Branch)

有时我们需要根据输入内容选择不同的子链。可以用 RunnableLambda 写一个路由器来返回不同的 Runnable:

typescript 复制代码
// 文件:src/ch05/branch-router.ts
import { RunnableLambda } from "@langchain/core/runnables";

const toUpper = new RunnableLambda((x: string) => x.toUpperCase());
const toLower = new RunnableLambda((x: string) => x.toLowerCase());

// 路由:包含数字走 A,否则走 B
const router = new RunnableLambda<string, string>(async (input, config) => {
  const hasDigit = /\d/.test(input);
  const chosen = hasDigit ? toUpper : toLower;
  return chosen.invoke(input, config);
});

export async function run() {
  console.log(await router.invoke("Hello123")); // HELLO123
  console.log(await router.invoke("Hello"));    // hello
}

if (require.main === module) run();

如果你使用的是提供条件节点的更高级封装,也可以实现更直观的分支,但上面的纯 Runnable 写法灵活且可测试。

5.8 扇出/汇聚(Parallel/Fan-in)

将一个输入同时交给多个处理器,最后再合并结果:

typescript 复制代码
// 文件:src/ch05/parallel-basic.ts
import { RunnableParallel, RunnableLambda } from "@langchain/core/runnables";

const a = new RunnableLambda((x: string) => `A:${x.length}`);
const b = new RunnableLambda((x: string) => `B:${x.split(" ").length}`);
const c = new RunnableLambda((x: string) => `C:${x.includes("AI")}`);

const parallel = new RunnableParallel({ a, b, c });

const merge = new RunnableLambda((out: { a: string; b: string; c: string }) =>
  `${out.a} | ${out.b} | ${out.c}`
);

export async function run() {
  const res = await parallel.pipe(merge).invoke("AI makes front-end better");
  console.log(res);
}

if (require.main === module) run();

📦 复合对象与 Passthrough/Map 模式

5.9 让链路同时携带多个字段

RunnablePassthrough 可把上游输入原样透传,配合对象结构轻松组装复杂输入:

typescript 复制代码
// 文件:src/ch05/passthrough-map.ts
import { RunnableLambda, RunnablePassthrough, RunnableSequence } from "@langchain/core/runnables";

const tokenize = new RunnableLambda((x: string) => x.split(/\s+/));
const count = new RunnableLambda((xs: string[]) => xs.length);

// 产出形如 { raw, tokens, count }
const pipeline = RunnableSequence.from([
  new RunnableLambda((raw: string) => ({ raw })),
  new RunnableLambda(async ({ raw }) => ({ raw, tokens: await tokenize.invoke(raw) })),
  new RunnableLambda(async ({ raw, tokens }) => ({ raw, tokens, count: await count.invoke(tokens) })),
  RunnablePassthrough.from(),
]);

export async function run() {
  const out = await pipeline.invoke("hello runnable world");
  console.log(out);
}

if (require.main === module) run();

🚰 流式处理与聚合

5.10 将流式输出聚合为一段文本

typescript 复制代码
// 文件:src/ch05/stream-collect.ts
import { ChatOpenAI } from "@langchain/openai";

const model = new ChatOpenAI({ modelName: "gpt-3.5-turbo", streaming: true });

export async function run() {
  const stream = await model.stream("请用 5 句介绍 Runnable 的优势");
  let acc = "";
  for await (const chunk of stream) {
    process.stdout.write(chunk.content);
    acc += chunk.content;
  }
  console.log("\n---\n汇总:\n", acc);
}

if (require.main === module) run();

5.11 流水线中的流式片段

typescript 复制代码
// 文件:src/ch05/stream-transform.ts
import { ChatOpenAI } from "@langchain/openai";

export async function run() {
  const llm = new ChatOpenAI({ streaming: true });
  const stream = await llm.stream("分点说明 Runnable 的 4 个关键方法");
  let i = 0;
  for await (const chunk of stream) {
    console.log(`[${i++}]`, chunk.content);
  }
}

if (require.main === module) run();

🧱 稳健性:错误、重试与回退

5.12 链路级错误处理

typescript 复制代码
// 文件:src/ch05/errors-basic.ts
import { RunnableLambda, RunnableSequence } from "@langchain/core/runnables";

const parseIntSafe = new RunnableLambda((x: string) => {
  const n = Number.parseInt(x, 10);
  if (Number.isNaN(n)) throw new Error("不是合法整数");
  return n;
});

const square = new RunnableLambda((n: number) => n * n);

const seq = RunnableSequence.from([parseIntSafe, square]);

export async function run() {
  try {
    console.log(await seq.invoke("16"));
    console.log(await seq.invoke("oops"));
  } catch (e) {
    console.error("失败:", (e as Error).message);
  }
}

if (require.main === module) run();

5.13 回退策略(Fallback)

typescript 复制代码
// 文件:src/ch05/fallback.ts
import { RunnableLambda } from "@langchain/core/runnables";

const primary = new RunnableLambda((x: string) => {
  if (x.length < 5) throw new Error("太短");
  return x.toUpperCase();
});

const fallback = new RunnableLambda((x: string) => `[fallback] ${x}`);

async function withFallback(x: string) {
  try {
    return await primary.invoke(x);
  } catch {
    return await fallback.invoke(x);
  }
}

export async function run() {
  console.log(await withFallback("hello"));
  console.log(await withFallback("hi"));
}

if (require.main === module) run();

5.14 简单重试(指数退避)

typescript 复制代码
// 文件:src/ch05/retry.ts
import { RunnableLambda } from "@langchain/core/runnables";

const flaky = new RunnableLambda(async (n: number) => {
  if (Math.random() < 0.7) throw new Error("临时错误");
  return n * 2;
});

async function retry<T>(fn: (x: T) => Promise<T>, x: T, times = 3) {
  let attempt = 0;
  while (attempt < times) {
    try { return await fn(x); } catch (e) {
      await new Promise(r => setTimeout(r, 2 ** attempt * 200));
      attempt++;
    }
  }
  throw new Error("重试失败");
}

export async function run() {
  const out = await retry((x) => flaky.invoke(x), 10, 4);
  console.log(out);
}

if (require.main === module) run();

🧪 批处理与并行度

5.15 batch 提升吞吐

typescript 复制代码
// 文件:src/ch05/batch.ts
import { RunnableLambda } from "@langchain/core/runnables";

const upper = new RunnableLambda((s: string) => s.toUpperCase());

export async function run() {
  const inputs = ["a", "b", "c", "d"];
  const outs = await upper.batch(inputs);
  console.log(outs);
}

if (require.main === module) run();

5.16 RunnableParallel 控制扇出规模

typescript 复制代码
// 文件:src/ch05/parallel-limit.ts
import { RunnableParallel, RunnableLambda } from "@langchain/core/runnables";

const slow = new RunnableLambda(async (s: string) => {
  await new Promise(r => setTimeout(r, 200));
  return s.repeat(2);
});

const parallel = new RunnableParallel({ a: slow, b: slow, c: slow });

export async function run() {
  console.time("p");
  console.log(await parallel.invoke("x"));
  console.timeEnd("p");
}

if (require.main === module) run();

🏗️ 组合实践:构建可复用工作流

5.17 内容智能处理流水线(清洗 → 识别 → 翻译 → 总结 → 分类 → 结构化)

目标:给定用户输入的任意文本,完成:

  • 预处理清洗(去噪/截断/安全)
  • 语言识别
  • 需要时翻译为中文
  • 摘要生成
  • 风格分类与标签抽取(并行)
  • 结构化输出 JSON,便于前端直接渲染
typescript 复制代码
// 文件:src/ch05/pipeline-content.ts
import { RunnableLambda, RunnableParallel, RunnableSequence } from "@langchain/core/runnables";
import { ChatOpenAI } from "@langchain/openai";
import { PromptTemplate } from "@langchain/core/prompts";
import { JsonOutputParser } from "@langchain/core/output_parsers";

type Input = { text: string };

const sanitize = new RunnableLambda<Input, Input>(({ text }) => ({
  text: text.replace(/\s+/g, " ").trim().slice(0, 4000),
}));

const detectPrompt = PromptTemplate.fromTemplate(
  `判断以下文本的语言:\n{txt}\n只返回语言名称,如 Chinese/English/Japanese`
);
const llm = new ChatOpenAI({ temperature: 0 });
const detect = detectPrompt.pipe(llm);

const translatePrompt = PromptTemplate.fromTemplate(
  `将以下文本翻译成中文:\n{txt}`
);
const translate = translatePrompt.pipe(llm);

const summaryPrompt = PromptTemplate.fromTemplate(
  `用要点总结下面文本(最多 5 条):\n{txt}`
);
const summary = summaryPrompt.pipe(llm);

const stylePrompt = PromptTemplate.fromTemplate(
  `判断文本风格标签(技术/营销/新闻/随笔/其他):\n{txt}\n只输出一个标签`
);
const tagsPrompt = PromptTemplate.fromTemplate(
  `从文本中抽取 3 个关键词(中文):\n{txt}\n以逗号分隔`
);

const parallelClassify = new RunnableParallel({
  style: stylePrompt.pipe(llm),
  tags: tagsPrompt.pipe(llm),
});

const pack = new RunnableLambda(async (ctx: {
  original: string; lang: string; textZh: string; summary: string; style: any; tags: any;
}) => {
  return {
    lang: ctx.lang.trim(),
    text: ctx.textZh.trim(),
    summary: ctx.summary.trim(),
    style: String(ctx.style.content || ctx.style).trim(),
    tags: String(ctx.tags.content || ctx.tags)
      .split(/[,,]/).map(s => s.trim()).filter(Boolean).slice(0, 5),
    original: ctx.original,
  };
});

const schemaPrompt = PromptTemplate.fromTemplate(
  `将以下对象重新组织为严格 JSON(keys: lang,text,summary,style,tags,original):\n{obj}`
);
const parser = new JsonOutputParser<any>();
const schema = schemaPrompt.pipe(llm).pipe(parser);

export const contentPipeline = RunnableSequence.from([
  new RunnableLambda((input: Input) => ({ original: input.text })),
  new RunnableLambda(async ({ original }) => ({ original, cleaned: await sanitize.invoke({ text: original }) })),
  new RunnableLambda(async ({ original, cleaned }) => ({ original, cleaned, lang: (await detect.invoke({ txt: cleaned.text })).content })),
  new RunnableLambda(async ({ original, cleaned, lang }) => ({
    original,
    lang,
    textZh: lang.toLowerCase().startsWith("chinese") ? cleaned.text : (await translate.invoke({ txt: cleaned.text })).content,
  })),
  new RunnableLambda(async ({ original, lang, textZh }) => ({ original, lang, textZh, summary: (await summary.invoke({ txt: textZh })).content })),
  new RunnableLambda(async ({ original, lang, textZh, summary }) => ({
    original, lang, textZh, summary,
    classify: await parallelClassify.invoke({ txt: textZh }),
  })),
  new RunnableLambda(({ original, lang, textZh, summary, classify }) => ({
    original, lang, textZh, summary,
    style: classify.style, tags: classify.tags,
  })),
  pack,
  new RunnableLambda(async (obj) => schema.invoke({ obj: JSON.stringify(obj, null, 2) })),
]);

export async function run() {
  const input = { text: "LangChain unifies prompts, LLMs, retrievers into composable pipelines." };
  const out = await contentPipeline.invoke(input);
  console.log(out);
}

if (require.main === module) run();

5.18 API 集成与前端渲染

  • Next.js API Route 接收 text,调用 contentPipeline.invoke
  • 前端直接渲染 JSON 字段(summarytagsstyle),并显示 original
  • 结合 Callback/日志记录处理时间、失败率、token 使用

🧱 实战:RAG 数据处理流水线(ETL)

目标:将原始文档清洗、切分、嵌入、写入向量库,并输出入库统计报表。

流程:

  1. 读取文件 → 2) 清洗与分块 → 3) 过滤短片段与重复 → 4) 并行嵌入 → 5) 写入向量库 → 6) 生成报告
typescript 复制代码
// 文件:src/ch05/rag-etl.ts
import { RunnableLambda, RunnableParallel, RunnableSequence } from "@langchain/core/runnables";

// 伪实现:真实项目请替换为各自 Loader/Embedding/VectorStore
async function loadFiles(glob: string): Promise<{ id: string; text: string }[]> {
  return [
    { id: "a", text: "LangChain.js 是构建 LLM 应用的 JS 框架" },
    { id: "b", text: "Runnable 提供统一的 invoke/stream/batch 接口" },
  ];
}

async function split(docs: { id: string; text: string }[]) {
  return docs.flatMap(d => {
    const parts = d.text.match(/.{1,20}/g) || [];
    return parts.map((t, i) => ({ id: `${d.id}-${i}`, text: t }));
  });
}

async function dedup(chunks: { id: string; text: string }[]) {
  const seen = new Set<string>();
  return chunks.filter(c => { const k = c.text.trim(); if (seen.has(k)) return false; seen.add(k); return true; });
}

async function embedBatch(texts: string[]) { return texts.map(t => ({ vector: Array(8).fill(0).map((_,i)=> (t.length*(i+1))%7) })); }
async function upsertVectors(items: { id: string; vector: number[] }[]) { return items.length; }

const pipeline = RunnableSequence.from([
  new RunnableLambda((input: { glob: string }) => input.glob),
  new RunnableLambda(async (glob) => await loadFiles(glob)),
  new RunnableLambda(async (docs) => await split(docs)),
  new RunnableLambda(async (chunks) => chunks.filter(c => c.text.trim().length >= 4)),
  new RunnableLambda(async (chunks) => await dedup(chunks)),
  new RunnableLambda(async (chunks: { id: string; text: string }[]) => {
    const textList = chunks.map(c => c.text);
    const parallel = new RunnableParallel({
      vectors: new RunnableLambda(async () => embedBatch(textList)),
      ids: new RunnableLambda(async () => chunks.map(c => c.id)),
    });
    const { vectors, ids } = await parallel.invoke(undefined as any);
    return ids.map((id, i) => ({ id, vector: vectors[i].vector }));
  }),
  new RunnableLambda(async (items) => ({ upserted: await upsertVectors(items), total: items.length })),
]);

export async function run() {
  const report = await pipeline.invoke({ glob: "docs/**/*.md" });
  console.log("报告:", report);
}

if (require.main === module) run();

要点:

  • 把"长链路"拆分成若干小的 Runnable,便于测试和复用
  • 批量嵌入与并行 upsert 可显著提升吞吐
  • 产出结构化"报告",便于接入监控/告警/看板

🌐 与 Next.js 的工程化落地

5.19 API 设计建议

  • 输入输出皆为结构化 JSON,前端渲染无需再解析自然语言
  • 支持 stream 模式,前端实现打字机/进度条
  • 约定 requestId,贯穿日志与埋点

5.20 前端体验

  • 提交后立即显示"任务队列中/开始处理/处理完成"三态
  • 流式展示"阶段性产出":如先出摘要,再出标签,再出引用
  • 失败时保留最后一次成功输出,允许"继续/重试/反馈"

🔍 可观测性与调试

5.21 日志与回调

  • 为关键 Runnable 注入回调,打印输入长度、token 成本、耗时
  • Runnable 的 tags/metadata 用于聚合报表

5.22 断点与最小化重现

  • 将长链拆分为多文件/多 Runnable,单元测试逐段验证
  • 最小化输入复现实例,便于提交 issue 或复盘

🧪 测试策略

5.23 单元测试

  • 对纯函数型 RunnableLambda 做输入 → 输出断言
  • 对链路结构断言:确保顺序/分支/并行按预期执行

5.24 集成测试

  • 使用固定样例(golden set),对主要流水线做回归
  • 为 LLM 节点打桩(mock),只测试编排逻辑

⚙️ 性能与成本建议

  • 尽量复用 Prompt 与模型实例,减少对象创建
  • 合理选择并行度;I/O 密集型节点可并行,LLM 节点受限于配额/成本
  • 控制输出长度、压缩中间态,减少 token
  • 引入缓存:对昂贵节点做键控缓存(如输入 hash → 输出)

🔐 安全与健壮性

  • 过滤与逃逸:对用户输入做清洗,避免 prompt 注入
  • 失败回退:为关键环节准备降级策略
  • 审计与追踪:保留请求链路与关键中间结果,便于事后审查

📚 延伸阅读与资源

  • LangChain.js(JS)文档:https://js.langchain.com/
  • Runnable 指南与示例:官方教程中的 Runnable 章节
  • 流式处理与 SSE:MDN 与 Next.js Route Handlers 文档
  • 性能与成本:模型选择、batch、并行与缓存

✅ 本章小结

  • 掌握了 Runnable 的核心能力与组合方式
  • 能将 Prompt/LLM/Parser/Tool/Retriever 编排为稳定的工作流
  • 学会了错误处理、回退与简单重试
  • 完成内容处理与 RAG ETL 两个完整流水线

🎯 下章预告

下一章《Vector 向量化技术与语义搜索》中,我们将:

  • 深入 Embedding 与向量相似度的原理与实践
  • 对比 Chroma/Pinecone/Weaviate,并动手实现检索器
  • 构建混合搜索与重排序,打通 RAG 的关键路径

最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!

相关推荐
江城开朗的豌豆4 小时前
解密useEffect依赖数组
前端·javascript·react.js
江城开朗的豌豆4 小时前
React Hooks必杀技:前端工程师小杨带你玩转常用API!
前端·javascript·react.js
江城开朗的豌豆4 小时前
Redux状态更新:异步还是同步?
前端·javascript·react.js
前端小巷子4 小时前
Vue 项目性能优化实战
前端·vue.js·面试
Jooolin4 小时前
9.3 阅兵上的网络空间部队都做些什么国防工作?
安全·ai编程
Aphasia3114 小时前
useEffect 中Clean up 函数的执行机制
前端·react.js·面试
xw54 小时前
我的后台管理项目报Error: spawn …esbuild.exe ENOENT了
前端
夏小花花4 小时前
关于牙科、挂号、医生类小程序或管理系统项目 项目包含微信小程序和pc端两部分
前端·javascript·vue.js·微信小程序·小程序
IT_陈寒4 小时前
SpringBoot 3.2 踩坑实录:这5个‘自动配置’的坑,让我加班到凌晨三点!
前端·人工智能·后端