第5章:Runnable 接口与任务编排系统
前言
大家好,我是鲫小鱼。是一名不写前端代码
的前端工程师,热衷于分享非前端的知识,带领切图仔逃离切图圈子,欢迎关注我,微信公众号:《鲫小鱼不正经》
。欢迎点赞、收藏、关注,一键三连!!
🎯 本章学习目标
- 彻底理解 Runnable 抽象:
invoke
、batch
、stream
、pipe
- 熟练使用常见实现:
RunnableLambda
、RunnableSequence
、RunnableParallel
、RunnablePassthrough
- 掌握编排模式:顺序流水线、条件分支、扇出/汇聚、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
:把任意函数包成 RunnableRunnableSequence
:按顺序将多个 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 字段(
summary
、tags
、style
),并显示original
- 结合 Callback/日志记录处理时间、失败率、token 使用
🧱 实战:RAG 数据处理流水线(ETL)
目标:将原始文档清洗、切分、嵌入、写入向量库,并输出入库统计报表。
流程:
- 读取文件 → 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 的关键路径
最后感谢阅读!欢迎关注我,微信公众号:
《鲫小鱼不正经》
。欢迎点赞、收藏、关注,一键三连!!!