5. LCEL 链

Runnable 协议是 LangChain 的灵魂。所有东西(model、prompt、tool、parser)都实现 Runnable 接口:

interface Runnable {
    invoke(input): Promise<output>  // 接收输入,返回输出
    stream(input): AsyncGenerator   // 流式
    batch(inputs): Promise<outputs> // 批量
}

因为大家都实现同样的接口,所以可以像管道一样拼起来:

const chain = prompt | model
// prompt 的输出 → model 的输入

pipe 这个词来自 Unix/Linux 的管道符 |。设想一条流水线:

原材料 → [工序A] → [工序B] → [工序C] → 成品

每个工序接收上一步的输出,作为自己的输入,处理完传给下一步。.pipe() 就是这么个东西:

A.pipe(B).pipe(C)
// 等价于:A 的输出 → 喂给 B → B 的输出 → 喂给 C

这让你能像搭积木一样构建复杂流程,而不用嵌套回调。

pipe 示例

import { ChatPromptTemplate } from '@langchain/core/prompts'
import { StringOutputParser } from '@langchain/core/output_parsers'

const prompt = ChatPromptTemplate.fromTemplate('用一句话解释 {topic}')

const chain = prompt.pipe(model).pipe(new StringOutputParser())
// 或用 | 管道符(需要 Runnable 类型支持,ts不支持这种写法):
// const chain = prompt | model | new StringOutputParser()

const result = await chain.invoke({ topic: 'Docker' })
// 直接是字符串(StringOutputParser 把 AIMessage.content 提取出来)

prompt.pipe(model).pipe(parser) 在不 pipe 的情况下,等价于这样写:

// 不用 pipe,手动一步步传
const step1 = await prompt.invoke({ topic: 'Docker' })
//   step1 = [HumanMessage('...Docker...')]

const step2 = await model.invoke(step1)
//   step2 = AIMessage { content: 'Docker 是...' }

const step3 = await parser.invoke(step2)
//   step3 = 'Docker 是...'(纯字符串)

console.log(step3)

pipe 就是把上面这三步自动串起来,中间变量都不用写。所以:

// 这两段代码完全等价
const chain = prompt.pipe(model).pipe(parser)
const result = await chain.invoke({ topic: 'Docker' })

// 等价于手动三步
const step1 = await prompt.invoke({ topic: 'Docker' })
const step2 = await model.invoke(step1)
const step3 = await parser.invoke(step2)

整个流程就是这样的:

chain.invoke({ topic: 'Docker' })
 │
 ├─ 1. 把 { topic: 'Docker' } 传给 prompt.invoke()
 │      → 返回 [HumanMessage('用一句话解释什么是 Docker?')]
 │
 ├─ 2. 把上面这组消息传给 model.invoke()
 │      → 返回 AIMessage { content: 'Docker 是...' }
 │
 └─ 3. 把这个 AIMessage 传给 parser.invoke()
        → 返回 'Docker 是...'(纯字符串)

注意:pipe 不是随便拼都行,他有个要求是:前一个的输出类型必须能喂给后一个。比如:

// ❌ 类型对不上
const badChain = model.pipe(prompt)

为什么错?

  • model 的输出是 AIMessage
  • prompt 的输入是 { topic: string }(对象)
  • AIMessage 不能当 {topic} 用 → 类型不匹配 → 运行时出错

正确的应该是这样:

// ✅ 类型能衔接
const goodChain = prompt.pipe(model)
//  prompt 输出:消息 → model 输入:消息 ✅

和 ai SDK 对照

ai SDK 没有这种"链"的概念——它靠参数嵌套(比如 generateText({ model, messages, tools }))。LangChain 的 LCEL 是更通用的组合范式,学习成本高但灵活性强。

重点理解

  • Runnable 协议 = LangChain 的统一接口,万物皆可 invoke/stream/batch。
  • pipe / | = 把多个 Runnable 拼成一个链。
  • 后面的 RAG、Agent 本质上都是用 LCEL 拼出来的链或图。

完整示例:

import 'dotenv/config'
import { ChatDeepSeek } from '@langchain/deepseek'
import { ChatPromptTemplate } from '@langchain/core/prompts'
import { StringOutputParser } from '@langchain/core/output_parsers'

const model = new ChatDeepSeek({
    model: 'deepseek-v4-flash',
    apiKey: process.env.DEEPSEEK_API_KEY,
})

async function main() {
    console.log('=== 1. 用 .pipe() 拼链 ===\n')
    // prompt → model → parser 三段拼起来
    // StringOutputParser 把 AIMessage.content 提取成纯字符串
    const prompt = ChatPromptTemplate.fromTemplate('用一句话解释什么是 {topic}')

    const chain = prompt.pipe(model).pipe(new StringOutputParser())

    const result = await chain.invoke({ topic: 'Docker' })
    console.log('类型:', typeof result)   // string(不再是 AIMessage)
    console.log('结果:', result)
    console.log()

    console.log('=== 2. 链可以复用 + 批量调用 ===\n')
    // 链是 Runnable,支持 batch(并行调用多个输入)
    const results = await chain.batch([
        { topic: 'Redis' },
        { topic: 'GraphQL' },
        { topic: 'WebSocket' },
    ])
    results.forEach((r, i) => {
        const topics = ['Redis', 'GraphQL', 'WebSocket']
        console.log(`${topics[i]}: ${r}`)
    })
    console.log()

    console.log('=== 3. 更复杂的链:带 system 设定 ===\n')
    const chatPrompt = ChatPromptTemplate.fromMessages([
        ['system', '你是一个{role},回答要{style}'],
        ['human', '{question}'],
    ])
    const smartChain = chatPrompt.pipe(model).pipe(new StringOutputParser())
    const answer = await smartChain.invoke({
        role: '技术作家',
        style: '通俗易懂,用比喻',
        question: '什么是消息队列?',
    })
    console.log(answer)
}

main().catch(err => {
    console.error('运行失败:', err)
    process.exit(1)
})

附1:OutputParser

前面的例子中,我们用到了 StringOutputParser,这是 LangChain 的 **Output Parser(输出解析器)**中的一个组件,其实还有很好几个这样的解析器,他们的作用都是是“接收模型输出,做某种转换”:

Parser 输入 输出 用途
StringOutputParser AIMessage string 提取纯文本
JsonOutputParser AIMessage
(内容是 JSON 字符串)
对象 解析 JSON
CommaSeparatedListOutputParser AIMessage string[] 把逗号分隔的文本拆成数组
StructuredOutputParser AIMessage 对象 按 schema 解析,老 API,现在不推荐

StringOutputParser 是其中最简单也是最常用的——只做一件事:AIMessage → string。

它们都在链里的位置:链的"出口",一条链通常长这样:

[prompt] → [model] → [parser]
  入口                 出口
  • prompt 是入口:负责把你的输入(对象)变成模型能理解的消息
  • model 是中间:负责生成回答
  • parser 是出口:负责把模型的回答转成你想要的格式

所以 parser 永远在链的末尾,它决定你最终拿到什么类型:

  • 不加 parser → 拿到 AIMessage
  • 加 StringOutputParser → 拿到 string
  • 加 JsonOutputParser → 拿到对象

和 .withStructuredOutput() 的区别

我们前面使用过 model.withStructuredOutput(bookListSchema),用来设定想要的返回数据格式。实际上他和我们前面说的 Output Parser 完全不是同一个东西。

  • OutputParser(输出解析器)——链尾的"转换器",是对返回的AIMessage 进行转换。

    prompt → model → OutputParser
                      ↑
                   它在这里:链的最末端,一个独立的 Runnable 组件
    
  • withStructuredOutput ——model 的"特殊模式",他是model的一个方法,工作方式是:通过工具调用(tool_choice)强制模型直接输出符合 schema 的结构化数据,模型不是"先生成文本再被解析",而是直接生成结构化数据。

    prompt → structuredModel(内部就用工具调用强制输出)
             ↑
          它改变了 model 本身的行为,不是链尾的独立组件
    
两者对比
维度 OutputParser withStructuredOutput
是什么 链尾的独立 Runnable model 的方法,返回新 model
怎么工作 模型先自由生成,parser 再解析 强制模型用工具调用直接输出结构化
可靠性 低(模型可能生成不规范的 JSON) 高(底层是工具调用,API 层面保证格式)
在链里的位置 永远在最末端 替换掉 model 本身
在 pipe 里怎么用 .pipe(parser) 替换 model,.pipe(structuredModel)

Q1: withStructuredOutput 在管道里怎么用?

直接替换掉 model 就行,不要再加 parser(加了反而会出错,因为 structuredModel 输出已经是对象,不是 AIMessage):

// ✅ 正确
const structuredModel = model.withStructuredOutput(schema)
const chain = prompt.pipe(structuredModel) // prompt 输出消息 → structuredModel 输出对象(直接)
const result = await chain.invoke({ ... }) // result 是 { name: '张三', age: 25 },直接是对象
// ❌ 错误:再接 StringOutputParser 会报错
const chain = prompt.pipe(structuredModel).pipe(new StringOutputParser())
// StringOutputParser 期望输入是 AIMessage,但 structuredModel 输出是对象,类型对不上

所以记住这条规则:withStructuredOutput 用在 pipe 里时,它就是链的终点,后面不能再接 parser。

附2:@langchain/community

LangChain 官方的"第三方集成大杂烩"包,把 LangChain 接各种外部服务(向量库、文档加载器、LLM、工具等)的代码做了统一的调用封装。

安装:

npm i @langchain/community --legacy-peer-deps

为什么必须加 --legacy-peer-deps?

community 声明了 115+ 个 peerDependencies(对等依赖)。其中 @browserbasehq/stagehand(一个浏览器自动化库)要求:dotenv@^16.4.5、zod@^3.23.8,其实这些版本已经很好了,如果你之前已有这几个包,就会报错装不上。

装了它到底装了多少东西?

常见误解:community 有 100+ peer,装它等于装了几十个包。
真相:实际只新增几个小包。

类型 数量 npm 会装吗
dependencies(直接依赖) 9 个 ✅ 会装(@langchain/openai、langsmith、flat、uuid 等小包)
peerDependencies(对等依赖) 115 个 ❌ 不会装(只是声明,npm 不主动安装)
其中 optional peer 几乎全部 缺失不报错

净增量:装 community 实际只增加约 5-7 个小包(几十 MB),那些"几百 MB"的传说,是把 peerDependencies 里 puppeteer/playwright/aws-sdk 这种不会被装的重型包算进去了的误解。

内部结构(22 个类别,249 个集成)

按数量排序,community 包含这些大类:

类别 数量 干什么的 独立包?
document_loaders 44 文档加载器(PDF/Word/网页/Notion...) ❌ 还在 community
vectorstores 43 向量库(Chroma/Pinecone/PG/Faiss...) ⚠️ 部分(Pinecone/MongoDB 已独立,Chroma 还在)
tools 27 工具(搜索/计算/数据库查询...) ❌ 大部分在 community
chat_models 22 聊天模型(通义/Bedrock/TogetherAI...) ⚠️ 主流已独立(@langchain/openai 等)
stores 20 状态存储 ❌
llms 19 传统 LLM ❌
embeddings 18 embedding 模型 ❌
retrievers 12 检索器 ❌
memory 5 对话记忆 ❌
agents 5 Agent 工具包(含 stagehand) ❌
其它(caches/indexes/graphs/callbacks 等) ~34 各种辅助 ❌

community 是"适配器集合"

关键认知:community 提供"封装层",底层解析库要你自己装。

你的代码
   │
   ▼
┌──────────────────────────────────────────────┐
│ @langchain/community(已装,不用再装)         │
│                                               │
│   PDFLoader 类(封装代码)  ← 这个有了         │
│      │                                        │
│      │ 调用                                   │
│      ▼                                        │
│   pdf-parse(底层库)       ← 这个要自己装      │
└──────────────────────────────────────────────┘
  • 第一层(封装代码):装 community 就有。提供 LangChain 统一接口(load() 返回 Document[] 等)
  • 第二层(底层解析库):用到才装。比如 PDF 解析靠 pdf-parse,浏览器控制靠 puppeteer

为什么这么设计?

  • 避免强制拉重型包(你只用 PDF 时,不该被迫装几百 MB 的 puppeteer)
  • 版本解耦(pdf-parse v1/v2 API 差很大,让你自己选)
  • 复用宿主已装的包(chromadb 你早装了,community 直接用)

类比:community 是"万能电源转接头",转接头买了就有,但每种插座(底层库)用到才装。

注意:目前这个包已经停止维护了。https://github.com/langchain-ai/langchainjs-community/issues/61,LangChain 团队正在把 community 拆成更细粒度的独立包(比如 @langchain/pinecone、@langchain/mongodb 等已经独立),很多集成(包括 Chroma、PDF 加载器)还没独立出来,只能继续用 community短期内 community 还能用(只是有警告),但长期会被逐步拆光。

已拆分的看一查看:https://docs.langchain.com/oss/javascript/integrations/providers/overview