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.5zod@^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