第11章 Chain 组合模式
本书章节导航
- 前言
- 第1章 为什么需要理解 LangChain
- 第2章 架构总览
- 第3章 Runnable 与 LCEL 表达式语言
- 第4章 消息系统与多模态
- 第5章 语言模型抽象层
- 第6章 提示词模板引擎
- 第7章 输出解析与结构化输出
- 第8章 工具系统
- 第9章 文档加载与文本分割
- 第10章 向量存储与检索器
- 第11章 Chain 组合模式 (当前)
- 第12章 回调与可观测性
- 第13章 记忆与会话管理
- 第14章 Agent 架构与执行循环
- 第15章 工具调用与 Agent 模式
- 第16章 序列化与配置系统
- 第17章 Partner 集成架构
- 第18章 设计模式与架构决策
引言
在 LangChain 的早期架构中,Chain 是最核心的抽象之一。它提供了一种将多个组件(模型、提示词、输出解析器、检索器等)串联起来形成完整处理流程的标准化方式。Chain 的设计理念源自函数式编程中的管道思想:每个 Chain 接收结构化输入,执行内部逻辑,产出结构化输出,输出又可以作为下一个 Chain 的输入。
然而,随着 LangChain 的演进,传统 Chain 类正在被更灵活的 LCEL(LangChain Expression Language)所取代。在源码中,我们可以清晰地看到这一演变:Chain 基类继承自 RunnableSerializable,这意味着每个传统 Chain 本身就是一个 Runnable,可以无缝融入 LCEL 管道。与此同时,许多传统 Chain 子类已被标记为 @deprecated,并提供了基于 LCEL 的替代方案。
本章将深入剖析 Chain 体系的设计与实现,理解传统 Chain 的工作机制,并对比 LCEL 的现代替代方式,帮助读者在实际项目中做出正确的技术选型。
:::tip 本章要点
- Chain 基类继承自
RunnableSerializable,是连接传统 API 与现代 Runnable 体系的桥梁 - 文档处理链族(Stuff/MapReduce/Refine/Reduce)为 RAG 场景提供了多种文档合并策略
RetrievalQA和ConversationalRetrievalChain是经典的检索问答链,现已被create_retrieval_chain取代SequentialChain和RouterChain提供了顺序编排和动态路由能力- LCEL 以管道操作符
|替代了大部分传统 Chain 的使用场景,是当前推荐的实践方式 :::
11.1 Chain 基类:连接两个时代的桥梁
11.1.1 类继承结构
Chain 基类定义在 langchain_classic/chains/base.py 中,它的继承关系揭示了 LangChain 架构演进的脉络:
Chain 继承自 RunnableSerializable[dict[str, Any], dict[str, Any]],这个泛型参数明确了 Chain 的输入输出类型:接收字典、返回字典。这一设计既保留了传统 Chain 的接口契约,又使其自动获得了 Runnable 接口的全部能力。
11.1.2 核心属性与抽象方法
Chain 基类定义了四个核心属性和两个抽象方法:
python
class Chain(RunnableSerializable[dict[str, Any], dict[str, Any]], ABC):
memory: BaseMemory | None = None
callbacks: Callbacks = Field(default=None, exclude=True)
verbose: bool = Field(default_factory=_get_verbosity)
tags: list[str] | None = None
metadata: dict[str, Any] | None = None
@property
@abstractmethod
def input_keys(self) -> list[str]:
"""Keys expected to be in the chain input."""
@property
@abstractmethod
def output_keys(self) -> list[str]:
"""Keys expected to be in the chain output."""
@abstractmethod
def _call(
self,
inputs: dict[str, Any],
run_manager: CallbackManagerForChainRun | None = None,
) -> dict[str, Any]:
"""Execute the chain."""
input_keys 和 output_keys 构成了 Chain 的静态类型契约。每个子类必须声明它期望的输入键和将产出的输出键。_call 是实际执行逻辑的模板方法,由子类实现具体的业务逻辑。
11.1.3 invoke 方法的执行流程
invoke 方法是 Chain 与 Runnable 接口的对接点。它的实现揭示了一个精心编排的执行流程:
python
def invoke(
self, input: dict[str, Any], config: RunnableConfig | None = None, **kwargs: Any
) -> dict[str, Any]:
config = ensure_config(config)
callbacks = config.get("callbacks")
tags = config.get("tags")
metadata = config.get("metadata")
run_name = config.get("run_name") or self.get_name()
inputs = self.prep_inputs(input)
callback_manager = CallbackManager.configure(
callbacks, self.callbacks, self.verbose, tags, self.tags, metadata, self.metadata
)
run_manager = callback_manager.on_chain_start(None, inputs, name=run_name)
try:
self._validate_inputs(inputs)
outputs = self._call(inputs, run_manager=run_manager)
final_outputs = self.prep_outputs(inputs, outputs, return_only_outputs)
except BaseException as e:
run_manager.on_chain_error(e)
raise
run_manager.on_chain_end(outputs)
return final_outputs
这个流程包含六个关键步骤:
其中 prep_inputs 和 prep_outputs 是 Memory 集成的关键:
python
def prep_inputs(self, inputs: dict[str, Any] | Any) -> dict[str, str]:
if not isinstance(inputs, dict):
_input_keys = set(self.input_keys)
if self.memory is not None:
_input_keys = _input_keys.difference(self.memory.memory_variables)
inputs = {next(iter(_input_keys)): inputs}
if self.memory is not None:
external_context = self.memory.load_memory_variables(inputs)
inputs = dict(inputs, **external_context)
return inputs
def prep_outputs(self, inputs, outputs, return_only_outputs=False):
self._validate_outputs(outputs)
if self.memory is not None:
self.memory.save_context(inputs, outputs)
if return_only_outputs:
return outputs
return {**inputs, **outputs}
这种设计使得 Memory 对子类完全透明:子类的 _call 方法无需感知 Memory 的存在,所有的记忆加载和保存都由基类自动处理。
11.2 文档处理链族
RAG(检索增强生成)是 LangChain 最核心的应用场景之一,而文档处理链负责将检索到的文档转化为语言模型可以消费的格式。LangChain 提供了三种主要的文档合并策略。
11.2.1 BaseCombineDocumentsChain
所有文档处理链的基类定义在 chains/combine_documents/base.py 中:
python
class BaseCombineDocumentsChain(Chain, ABC):
input_key: str = "input_documents"
output_key: str = "output_text"
@abstractmethod
def combine_docs(self, docs: list[Document], **kwargs: Any) -> tuple[str, dict]:
"""Combine documents into a single string."""
def _call(self, inputs, run_manager=None):
_run_manager = run_manager or CallbackManagerForChainRun.get_noop_manager()
docs = inputs[self.input_key]
other_keys = {k: v for k, v in inputs.items() if k != self.input_key}
output, extra_return_dict = self.combine_docs(
docs, callbacks=_run_manager.get_child(), **other_keys
)
extra_return_dict[self.output_key] = output
return extra_return_dict
注意 combine_docs 返回一个元组 (str, dict),第一个元素是合并后的文本,第二个元素是额外的返回字典。这个设计允许子类在主输出之外返回附加信息(如中间结果)。
11.2.2 StuffDocumentsChain:填充策略
Stuff(填充)是最简单直接的策略:将所有文档拼接成一个字符串,一次性传递给语言模型。
python
class StuffDocumentsChain(BaseCombineDocumentsChain):
llm_chain: LLMChain
document_prompt: BasePromptTemplate
document_variable_name: str
document_separator: str = "\n\n"
def combine_docs(self, docs, callbacks=None, **kwargs):
inputs = self._get_inputs(docs, **kwargs)
return self.llm_chain.predict(callbacks=callbacks, **inputs), {}
def _get_inputs(self, docs, **kwargs):
doc_strings = [format_document(doc, self.document_prompt) for doc in docs]
inputs = {k: v for k, v in kwargs.items()
if k in self.llm_chain.prompt.input_variables}
inputs[self.document_variable_name] = self.document_separator.join(doc_strings)
return inputs
Stuff 策略的优势在于简单高效,适合文档数量少、总长度不超过模型上下文窗口的场景。其劣势也很明显:当文档总量超过上下文限制时无法使用。
11.2.3 ReduceDocumentsChain:递归缩减策略
当文档总量超过上下文窗口时,需要分批处理。ReduceDocumentsChain 通过递归折叠实现这一目标:
python
class ReduceDocumentsChain(BaseCombineDocumentsChain):
combine_documents_chain: BaseCombineDocumentsChain
collapse_documents_chain: BaseCombineDocumentsChain | None = None
token_max: int = 3000
def _collapse(self, docs, token_max=None, callbacks=None, **kwargs):
result_docs = docs
length_func = self.combine_documents_chain.prompt_length
num_tokens = length_func(result_docs, **kwargs)
_token_max = token_max or self.token_max
while num_tokens is not None and num_tokens > _token_max:
new_result_doc_list = split_list_of_docs(
result_docs, length_func, _token_max, **kwargs
)
result_docs = [
collapse_docs(docs_, _collapse_docs_func, **kwargs)
for docs_ in new_result_doc_list
]
num_tokens = length_func(result_docs, **kwargs)
return result_docs, {}
split_list_of_docs 函数是分组的核心,它贪心地将文档添加到当前组中,当组的 token 数超过阈值时开始新的一组:
python
def split_list_of_docs(docs, length_func, token_max, **kwargs):
new_result_doc_list = []
_sub_result_docs = []
for doc in docs:
_sub_result_docs.append(doc)
_num_tokens = length_func(_sub_result_docs, **kwargs)
if _num_tokens > token_max:
if len(_sub_result_docs) == 1:
raise ValueError("A single document was longer than the context length")
new_result_doc_list.append(_sub_result_docs[:-1])
_sub_result_docs = _sub_result_docs[-1:]
new_result_doc_list.append(_sub_result_docs)
return new_result_doc_list
collapse_docs 函数将一组文档合并为一个文档,同时智能合并元数据:
python
def collapse_docs(docs, combine_document_func, **kwargs):
result = combine_document_func(docs, **kwargs)
combined_metadata = {k: str(v) for k, v in docs[0].metadata.items()}
for doc in docs[1:]:
for k, v in doc.metadata.items():
if k in combined_metadata:
combined_metadata[k] += f", {v}"
else:
combined_metadata[k] = str(v)
return Document(page_content=result, metadata=combined_metadata)
11.2.4 三种策略的对比
| 策略 | LLM 调用次数 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|---|
| Stuff | 1 | 文档总量小 | 简单高效,上下文完整 | 受限于上下文窗口 |
| Map-Reduce | N+1 | 大量文档 | 可并行处理,扩展性好 | 可能丢失跨文档关联 |
| Refine | N | 需要深度理解 | 逐步精炼答案质量高 | 串行执行速度慢 |
| Reduce | 变化 | 超长文档集 | 自适应递归压缩 | 多轮压缩可能丢失信息 |
11.3 检索问答链
11.3.1 RetrievalQA:经典的检索问答
RetrievalQA 是最经典的 RAG Chain,它将检索器和文档处理链组合在一起:
python
class BaseRetrievalQA(Chain):
combine_documents_chain: BaseCombineDocumentsChain
input_key: str = "query"
output_key: str = "result"
return_source_documents: bool = False
def _call(self, inputs, run_manager=None):
_run_manager = run_manager or CallbackManagerForChainRun.get_noop_manager()
question = inputs[self.input_key]
docs = self._get_docs(question, run_manager=_run_manager)
answer = self.combine_documents_chain.run(
input_documents=docs, question=question,
callbacks=_run_manager.get_child()
)
if self.return_source_documents:
return {self.output_key: answer, "source_documents": docs}
return {self.output_key: answer}
class RetrievalQA(BaseRetrievalQA):
retriever: BaseRetriever
def _get_docs(self, question, *, run_manager):
return self.retriever.invoke(
question, config={"callbacks": run_manager.get_child()}
)
11.3.2 ConversationalRetrievalChain:会话式检索
ConversationalRetrievalChain 在 RetrievalQA 的基础上增加了会话历史处理能力。它的核心设计是使用一个独立的 question_generator 链将原始问题和聊天历史转化为独立的检索查询:
python
class BaseConversationalRetrievalChain(Chain):
combine_docs_chain: BaseCombineDocumentsChain
question_generator: LLMChain
rephrase_question: bool = True
return_source_documents: bool = False
def _call(self, inputs, run_manager=None):
question = inputs["question"]
chat_history_str = get_chat_history(inputs["chat_history"])
if chat_history_str:
new_question = self.question_generator.run(
question=question, chat_history=chat_history_str,
callbacks=_run_manager.get_child()
)
else:
new_question = question
docs = self._get_docs(new_question, inputs, run_manager=_run_manager)
if self.rephrase_question:
new_inputs["question"] = new_question
answer = self.combine_docs_chain.run(
input_documents=docs, callbacks=_run_manager.get_child(), **new_inputs
)
return {self.output_key: answer}
11.3.3 create_retrieval_chain:LCEL 时代的替代方案
create_retrieval_chain 是传统检索链的现代替代品,它返回一个纯粹的 LCEL Runnable:
python
def create_retrieval_chain(
retriever: BaseRetriever | Runnable[dict, RetrieverOutput],
combine_docs_chain: Runnable[dict[str, Any], str],
) -> Runnable:
if not isinstance(retriever, BaseRetriever):
retrieval_docs = retriever
else:
retrieval_docs = (lambda x: x["input"]) | retriever
return (
RunnablePassthrough.assign(
context=retrieval_docs.with_config(run_name="retrieve_documents"),
).assign(answer=combine_docs_chain)
).with_config(run_name="retrieval_chain")
这个实现体现了 LCEL 的精髓:通过 RunnablePassthrough.assign 在数据字典中逐步添加字段。首先执行检索将结果赋给 context 键,然后将整个字典(包含 input 和 context)传递给 combine_docs_chain 生成答案赋给 answer 键。
配合 create_stuff_documents_chain,一个完整的 RAG 管道可以这样构建:
python
def create_stuff_documents_chain(llm, prompt, *, output_parser=None,
document_prompt=None, document_separator="\n\n",
document_variable_name="context"):
_document_prompt = document_prompt or DEFAULT_DOCUMENT_PROMPT
_output_parser = output_parser or StrOutputParser()
def format_docs(inputs: dict) -> str:
return document_separator.join(
format_document(doc, _document_prompt)
for doc in inputs[document_variable_name]
)
return (
RunnablePassthrough.assign(**{document_variable_name: format_docs})
.with_config(run_name="format_inputs")
| prompt | llm | _output_parser
).with_config(run_name="stuff_documents_chain")
11.4 SequentialChain:顺序编排
SequentialChain 将多个 Chain 串联执行,前一个 Chain 的输出自动成为后续 Chain 的可用输入:
python
class SequentialChain(Chain):
chains: list[Chain]
input_variables: list[str]
output_variables: list[str]
return_all: bool = False
def _call(self, inputs, run_manager=None):
known_values = inputs.copy()
_run_manager = run_manager or CallbackManagerForChainRun.get_noop_manager()
for _i, chain in enumerate(self.chains):
callbacks = _run_manager.get_child()
outputs = chain(known_values, return_only_outputs=True, callbacks=callbacks)
known_values.update(outputs)
return {k: known_values[k] for k in self.output_variables}
SequentialChain 在构造时会执行严格的验证,确保每个 Chain 的输入键都能在已知变量中找到对应值,且不同 Chain 的输出键不会发生冲突:
python
@model_validator(mode="before")
@classmethod
def validate_chains(cls, values):
chains = values["chains"]
known_variables = set(values["input_variables"])
for chain in chains:
missing_vars = set(chain.input_keys).difference(known_variables)
if missing_vars:
raise ValueError(f"Missing required input keys: {missing_vars}")
overlapping_keys = known_variables.intersection(chain.output_keys)
if overlapping_keys:
raise ValueError(f"Chain returned keys that already exist: {overlapping_keys}")
known_variables |= set(chain.output_keys)
return values
SimpleSequentialChain 是简化版,要求每个子链只有一个输入键和一个输出键,上一个链的输出直接作为下一个链的输入:
python
class SimpleSequentialChain(Chain):
chains: list[Chain]
def _call(self, inputs, run_manager=None):
_input = inputs[self.input_key]
for i, chain in enumerate(self.chains):
_input = chain.run(_input, callbacks=_run_manager.get_child(f"step_{i + 1}"))
return {self.output_key: _input}
11.5 RouterChain:动态路由
RouterChain 实现了基于输入内容动态选择执行路径的能力:
python
class Route(NamedTuple):
destination: str | None
next_inputs: dict[str, Any]
class RouterChain(Chain, ABC):
@property
def output_keys(self) -> list[str]:
return ["destination", "next_inputs"]
def route(self, inputs, callbacks=None) -> Route:
result = self(inputs, callbacks=callbacks)
return Route(result["destination"], result["next_inputs"])
class MultiRouteChain(Chain):
router_chain: RouterChain
destination_chains: Mapping[str, Chain]
default_chain: Chain
silent_errors: bool = False
def _call(self, inputs, run_manager=None):
callbacks = _run_manager.get_child()
route = self.router_chain.route(inputs, callbacks=callbacks)
if not route.destination:
return self.default_chain(route.next_inputs, callbacks=callbacks)
if route.destination in self.destination_chains:
return self.destination_chains[route.destination](
route.next_inputs, callbacks=callbacks
)
if self.silent_errors:
return self.default_chain(route.next_inputs, callbacks=callbacks)
raise ValueError(f"Received invalid destination chain name '{route.destination}'")
MultiRouteChain 的设计体现了策略模式:RouterChain 负责决策,destination_chains 映射表负责执行。silent_errors 参数提供了优雅降级能力,当路由到不存在的目标时自动回退到默认链。
11.6 create_history_aware_retriever:历史感知检索
这是一个用 LCEL 构建的现代函数,替代了 ConversationalRetrievalChain 的问题改写逻辑:
python
def create_history_aware_retriever(llm, retriever, prompt):
retrieve_documents = RunnableBranch(
(
lambda x: not x.get("chat_history", False),
(lambda x: x["input"]) | retriever,
),
prompt | llm | StrOutputParser() | retriever,
).with_config(run_name="chat_retriever_chain")
return retrieve_documents
RunnableBranch 在这里实现了条件路由:如果没有聊天历史,直接将输入传递给检索器;如果有聊天历史,先通过 LLM 改写问题再检索。
11.7 从传统 Chain 到 LCEL 的迁移
11.7.1 设计决策:为何弃旧迎新
传统 Chain 存在几个固有限制:
- 输入输出格式固化 :必须是
dict[str, Any],不够灵活 - 静态类型声明 :
input_keys和output_keys是硬编码的属性 - 组合性受限 :需要专门的 SequentialChain 来编排,不如
|操作符直观 - 流式支持不完善:传统 Chain 难以实现细粒度的流式输出
LCEL 通过 Runnable 协议解决了这些问题:任意类型的输入输出、自动类型推导、操作符组合、原生流式支持。
11.7.2 迁移对照表
| 传统 Chain | LCEL 替代方案 |
|---|---|
LLMChain(llm, prompt) |
`prompt |
StuffDocumentsChain |
create_stuff_documents_chain(llm, prompt) |
RetrievalQA |
create_retrieval_chain(retriever, combine_docs) |
ConversationalRetrievalChain |
create_history_aware_retriever + create_retrieval_chain |
SequentialChain([c1, c2]) |
`c1 |
RouterChain + MultiRouteChain |
RunnableBranch(...) |
11.7.3 源码中的弃用标记
在源码中,几乎所有传统 Chain 子类都已被标记为弃用:
python
@deprecated(since="0.1.17", alternative="RunnableSequence, e.g., `prompt | llm`", removal="1.0")
class LLMChain(Chain): ...
@deprecated(since="0.2.13", removal="1.0",
message="Use the `create_stuff_documents_chain` constructor instead.")
class StuffDocumentsChain(BaseCombineDocumentsChain): ...
@deprecated(since="0.1.17", removal="1.0",
message="Use the `create_retrieval_chain` constructor instead.")
class RetrievalQA(BaseRetrievalQA): ...
弃用消息中都提供了迁移指引的文档链接,这是一种负责任的 API 演进方式。
11.8 小结
Chain 体系是 LangChain 架构演进的一个缩影。从最初的面向对象 Chain 类继承体系,到如今基于 LCEL 的函数式管道组合,LangChain 在保持向后兼容的同时完成了范式转换。
Chain 基类通过继承 RunnableSerializable 巧妙地桥接了两个时代:传统 Chain 可以直接参与 LCEL 管道的组合。invoke 方法中精心编排的 Memory 注入、Callback 管理和异常处理,展示了一个生产级框架应有的健壮性。
文档处理链族(Stuff/Reduce)为不同规模的文档集提供了适当的合并策略。检索问答链(RetrievalQA/ConversationalRetrievalChain)虽已弃用,但其设计思想被 create_retrieval_chain 和 create_history_aware_retriever 完整继承。RouterChain 的动态路由能力在 LCEL 中由 RunnableBranch 接班。
对于新项目,建议直接使用 LCEL 的 create_* 工厂函数和 Runnable 管道。对于维护中的旧项目,由于传统 Chain 本身就是 Runnable,可以渐进式地将内部逻辑迁移到 LCEL,而不需要一次性重写整个应用。