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 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅6 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax