跳转至

langchain-ai/langchain:把"调模型、查资料、跑工具"拼成一条链的编排框架

LangChain 是目前用户最多、生态最大的 LLM 应用开发框架。它要解决的问题很朴素:写一个像样的 AI 应用,往往要把"检索相关资料 → 拼 Prompt → 调用大模型 → 解析输出"这几步串起来,而且这几步经常还要支持异步、并发,出了问题还得能追踪到底是哪一步出的错。如果每个项目都手写这套胶水代码,零散地编写大模型 API 调用、Prompt 拼接、检索器提取和输出解析逻辑,很快就会导致代码极度面条化(Spaghetti Code)、异步处理困难、且难以挂起和跟踪langchain-ai/langchain 的核心贡献,就是给这些环节定义了一套统一的组件接口——Runnable 协议,并提供一种用 | 像管道一样拼接组件的写法——LCEL(LangChain Expression Language),把零碎的 AI 原语拼装为生产级、可观测的链式管道(Runnable Sequence)。


1. 最小例子:三行代码拼一条链

在深入内部机制之前,先看 LangChain 最基础的用法长什么样——不涉及并发、不涉及可观测性,就是把 Prompt、模型、输出解析器用 | 连起来:

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI

prompt = ChatPromptTemplate.from_template("用一句话介绍一下 {topic}")
model = ChatOpenAI()
parser = StrOutputParser()

# 用 | 把三个组件拼成一条链
chain = prompt | model | parser

print(chain.invoke({"topic": "LangChain"}))

这条链做的事情是:把输入字典填进 Prompt 模板 → 把生成的 Prompt 传给大模型 → 把模型返回的消息对象解析成纯文本字符串。三个环节原本类型完全不同(模板、聊天模型、字符串解析器),却能用同一个 | 符号顺次拼接——这背后就是 LangChain 的核心设计:Runnable 协议与 LCEL。


2. 核心设计思路:统一调用接口 Runnable,管道语法 LCEL

LangChain 做的第一件事,是把 Prompt、聊天模型、检索器、输出解析器这些看起来完全不同的组件,全部统一成同一种"可调用对象"——Runnable。只要是 Runnable,就一定支持同一套调用方式,也因此可以互相拼接、互相替换。这也是为什么上面例子里模板、模型、解析器能用同一个 | 无缝拼起来。

2.1 Runnable 统一支持哪几种调用方式

每一个继承自 Runnable 的组件,都强制实现了以下四种核心执行模式: * invoke / ainvoke:同步/异步单体执行。 * batch / abatch:并发批处理执行(内部利用高性能 ThreadPoolExecutor 自动并行化,大幅压低整体时延)。 * stream / astream:流式分发输出。 * astream_log:不仅流式返回大模型 Token,还能将中间步骤(如检索召回、中间变量变化)以标准 JSONPatch 格式实时流式向客户端推送。

2.2 | 是怎么工作的:LCEL 底层重载机理

再往下一层看,| 能拼接 Runnable 并不是什么特殊语法糖,而是 LangChain 重载了 Python 的魔法方法 __or____ror__:把 a | b 这行代码,翻译成"把 a 和 b 包装成一个按先后顺序执行的 RunnableSequence"。

# 框架层底层重载逻辑精炼示意
class Runnable:
    def __or__(self, other):
        # 动态将左侧与右侧的 Runnable 封装为统一的 RunnableSequence (有向单向拓扑)
        return RunnableSequence(first=self, last=other)

理解了这一层重载机制,就能明白为什么 LCEL 链本质上是一张有向图,而不只是"看起来像管道":

 业务输入 (Dict) 
       ├──> [RunnableParallel (并发双路解析)]
       │         ├──> [检索器路: query | retriever] ───────────────┐
       │         └──> [历史拼接路: history_loader] ─────────────────┤
       │                                                           v
       │                                               合并为统一上下文 (Combined Map)
       │                                                           │
       │                                                           v
       │                                               [PromptTemplate.format()]
       │                                                           │
       │                                                           v
       │                                               [ChatModel.generate()]
       │                                                           │
       │                                                           v
       │                                               [OutputParser.parse()]
 结构化输出 (JSON) <───────────────────────────────────────────────┘

3. 自动并发批处理与全链路可观测性

统一接口带来的好处不只是"写法好看",还直接换来了两项工程收益:批处理自动并发,以及可观测性零侵入接入。

  • 隐式并发批处理 (Auto-batching): 当调用 chain.batch([input1, input2]) 时,LCEL 引擎会自动分析拓扑图。对于不存在因果依赖的独立节点(例如:同时向数据库和向量库发起检索),底层执行引擎会自动将其调度进独立的线程池进行并发异步执行,这比手写 asyncio.gather 更安全、更内聚。
  • 可观测性链路总线 (Tracing Pipeline): 由于所有节点都继承自 Runnable,每次调用都会自动触发标准 Callback 生命周期钩子。这使得系统的每一次输入/输出、中间变量、Prompt Token 数,都能零侵入式地接入 LangSmith 或标准 OpenTelemetry 追踪大盘,实现生产级系统透明化。

4. 进阶示例:一条真实的并发 RAG 链

回到最开始的三行示例——真实场景往往不止一路检索,而是要同时查向量库、查数据库,再把结果一起交给大模型。下面这个例子在最小例子的基础上,加入了 RunnableParallel 做并发双路检索,展示 LCEL 在生产场景下的完整写法:

from typing import Dict
from langchain_core.runnables import RunnableParallel, RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.language_models import BaseChatModel

# 1. 模拟自定义的只读 RAG 检索节点与历史获取节点
def mock_vector_retriever(inputs: Dict) -> str:
    print(f"[RUNNABLE VECTOR] Fetching logs for user: {inputs['user_id']}")
    return "CREDIT LOGS: User accessed DB from IP 192.168.1.100."

def mock_database_metadata(inputs: Dict) -> str:
    print(f"[RUNNABLE RDBMS] Fetching metadata for user: {inputs['user_id']}")
    return "USER METADATA: Account active, security group A."

# 2. 声明通用 ChatPromptTemplate 边界
prompt = ChatPromptTemplate.from_template(
    """System: Review the user activity against corporate security rules.
Context 1: {vector_context}
Context 2: {db_context}
Question: {question}
Answer concisely."""
)

# 3. 初始化 Mock 模型与解析器
# 真实场景中替换为真实 client Bean,如 ChatOpenAI()
class MockLLM(BaseChatModel):
    def _generate(self, messages, stop=None, run_manager=None, **kwargs):
        from langchain_core.outputs import ChatGeneration, ChatResult
        from langchain_core.messages import AIMessage
        return ChatResult(generations=[ChatGeneration(message=AIMessage(content="VIOLATION DETECTED: IP is out of standard Security Group range."))])

    @property
    def _llm_type(self) -> str:
        return "mock_llm"

model = MockLLM()
parser = StrOutputParser()

# 4. 【核心步骤】:使用 LCEL 构建声明式有向单向执行链
# 使用 RunnableParallel 强制声明并发执行双路分支检索,压实 TTFT 时延
retrieval_stage = RunnableParallel(
    vector_context=mock_vector_retriever,
    db_context=mock_database_metadata,
    question=RunnablePassthrough() # 将用户的 Question 原样透传
)

# 表达式无感组合,利用魔法方法重载完成 DAG 组装
full_security_chain = retrieval_stage | prompt | model | parser

if __name__ == "__main__":
    # 5. 执行链路测试 (Invoke)
    input_payload = {
        "user_id": "42",
        "question": "Is the user access compliant?"
    }

    print("--- Invoking LCEL Sequence ---")
    result = full_security_chain.invoke(input_payload)
    print(f"\nFinal Compiled Output: {result}")

5. 常见故障与排查

了解了运行机制之后,也有必要知道这套设计在实际使用中容易在哪里踩坑:

故障现象 底层诱因 系统级表现 预防与排查手段
异步事件循环死锁 (Event Loop Block) 在同步的 Web 应用中(如 Flask/Django)混合调用 ainvoke,或在异步协程内执行同步 invoke 堵塞主线程。 接口响应卡死挂起,服务器 CPU 占用极低但吞吐率为 0。 1. 严格在协程环境中全链路使用异步 ainvoke / abatch
2. 绝不在异步 Lambda 节点内执行物理阻塞 I/O。
序列化崩溃 (Lambda Serialization Fail) 在 LCEL 中使用了不可序列化的自定义内联函数(Lambda),导致多线程调度失败。 进程崩溃,抛出 AttributeError: Can't pickle local object... 错误。 1. 严格使用顶级函数(Top-level Function)定义自定义 Runnable 节点。
2. 尽量使用 RunnableLambda 执行显式包裹。
管道节点类型错配 (Type Conflict) 前一个 Runnable 节点的 Output 格式与后一个 Runnable 节点的 Input 类型契约冲突。 启动或运行到该步骤时抛出 TypeError: ... 或缺失 Key 的字典错误。 1. 强制在每个关键节点处挂载输入参数校验。
2. 利用 IDE 类型注解或 LangSmith 的中间 Payload 追踪调试核对。

6. 资深系统架构师面试表达方案

面试提问:大模型应用为什么要引入 LangChain 框架?它底层的 LCEL 编排引擎在并发性能与工程设计上有什么独特的价值?

回答模版: 刚开始用 LangChain 的时候,说实话我对 LCEL 那套管道符号 | 是有点将信将疑的——总觉得这是把简单的函数调用包装得花里胡哨。真正体会到它的价值,是在需要并发发起向量检索和数据库查询这类场景:用 RunnableParallel 声明两路输入之后,底层自己丢进线程池并发跑,不用我再手写 asyncio.gather 或者操心线程安全,首字延迟肉眼可见地降下来了。

另一个让我改观的地方是可观测性。因为所有组件都被迫实现同一套 invoke/batch/stream 接口,每一次调用不管是模型还是检索器,都能被同一套 Callback 钩子捕获,接入 LangSmith 之后中间每一步的输入输出都能看得清清楚楚,调试起来比自己手搓的调用链省心不少。

代价也是有的——我们踩过一次序列化的坑:在 LCEL 链里塞了一个内联 Lambda 做自定义处理,一到多线程批处理就报 Can't pickle local object,后来改成顶层函数或者显式用 RunnableLambda 包一层才解决。整体感觉是,LCEL 把编排这件事标准化了,但用之前最好先理解清楚它底层"重载运算符构建 DAG"这套机制,不然遇到报错会一头雾水。