RetrievalQA 与 LLMChain 迁移到 LCEL:对等性与调用次数实测
LangChain v1 把 LLMChain、RetrievalQA 这类旧链移到了兼容包 langchain-classic(导入路径的变化见导入兼容矩阵)。官方的处理方式是把旧链放进兼容包,原样继续使用;官方没有给出从旧链到 prompt | model | parser 这类 LCEL 链的对照指南,本页要补的就是这一块。
改写本身只有十几行,真正难的是怎么证明改写之后行为没变。本页写给正在维护旧 RAG 问答服务的开发者:用一个不联网的脚本化模型和一个关键词检索器,把旧链与 LCEL 链放在同一个脚本里逐项对照,量出四个具体问题:发给模型的内容是否相同、返回值哪里不一样、一次提问要调用几次模型、检索结果为空时会发生什么。
适用版本与验证信息
| 项目 | 内容 |
|---|---|
| 验证日期 | 2026-10-26 |
| Python | 3.12.12 |
| 旧版环境 | langchain==0.3.27、langchain-core==0.3.86,旧链从 langchain.chains 导入 |
| 兼容层环境 | langchain==1.4.7、langchain-core==1.7.0、langchain-classic==1.0.8,旧链从 langchain_classic.chains 导入 |
| 纯 v1 环境 | langchain==1.4.7、langchain-core==1.7.0,没有安装 langchain-classic |
| 模型与检索器 | 记录型脚本化模型(每次返回固定计数文本,并记下收到的消息)和按字符命中数排序的关键词检索器,不联网、不需要 API Key |
| 官方来源 | v1 迁移指南 (opens in a new tab)、v1 发布说明 (opens in a new tab)、知识库教程 (opens in a new tab)、NVIDIA 检索器集成页 (opens in a new tab),对照官方文档仓库 langchain-ai/docs (opens in a new tab) 提交 b8d4ced(2026-10-16) |
| 实验代码 | 站点仓库 labs/retrievalqa-to-lcel/,依赖锁定在 labs/requirements/ 下的 legacy.txt、v1.txt、v1c.txt |
本页只验证 RetrievalQA 和 LLMChain,没有验证 ConversationalRetrievalChain、load_qa_chain 等其他旧链,也没有验证异步调用和流式输出。检索器是关键词匹配,不是向量检索,所以本页不涉及召回质量,召回的评估见 RAG 质量评估与回归。
先说结论
- 发给模型的内容可以做到逐字一致。
stuff类型的RetrievalQA发给模型的是一条 system 消息(固定的提示语加上以空行拼接的文档)和一条用户问题。手写的 LCEL 链用同样的提示模板,模型收到的消息与旧链完全相同(实验 1)。迁移改变的是外壳,不是提示词。 - 真正的破坏点是返回值形状。 旧
RetrievalQA返回字典{query, result, source_documents},旧LLMChain返回{输入变量, text};LCEL 链默认只返回字符串。所有读取result、text或source_documents的调用方都会出问题。一个很薄的适配器就可以保住旧形状(实验 1)。 - 旧链的输入键很严格。 传
{"question": …}会抛ValueError: Missing some input keys: {'query'};传纯字符串反而可以。LCEL 链的输入键取决于你怎么写(实验 1)。 chain_type决定一次提问调用几次模型。 检索到 3 篇文档时,stuff调用 1 次,map_reduce调用 4 次,refine调用 3 次(实验 2)。手写的 LCEL 链等价于stuff,想要另外两种策略要自己编排。- 检索结果为空时,旧链仍然会调用模型。 系统提示语末尾的上下文是空的,模型照常回答。LCEL 里可以显式分支,空检索时一次模型都不调用(实验 2、3)。
- LCEL 链只依赖
langchain-core。 在没有安装langchain-classic的环境里可以直接运行(实验 3)。兼容层里的旧链和 0.3.27 里的旧链,在本页验证的几项上行为一致,两个环境的输出只有导入路径一行不同。
实验 0:脚本化模型与检索器
所有脚本共用同一个测试夹具:关键词检索器按“文档中包含多少个不同的查询字符”排序,并列时保持原顺序;模型记录每次收到的消息,回答固定为 answer 序号。get_token_ids 用字符数代替分词器,这样 map_reduce、refine 的 token 预算逻辑不需要额外安装分词器依赖也能运行。
"""Offline test doubles: a keyword retriever and a chat model that records every prompt."""
from typing import Any, List, Optional
from langchain_core.callbacks import CallbackManagerForRetrieverRun
from langchain_core.documents import Document
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, BaseMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.retrievers import BaseRetriever
DOCS = [
Document(page_content="个人套餐购买后七天内可以申请全额退款。", metadata={"source": "refund"}),
Document(page_content="发票在订单详情页申请,一至三个工作日内开具。", metadata={"source": "invoice"}),
Document(page_content="分享链接默认有效期为七天,创建者可以随时取消分享。", metadata={"source": "share"}),
Document(page_content="删除的文件会在回收站保留三十天。", metadata={"source": "trash"}),
]
class KeywordRetriever(BaseRetriever):
"""Rank documents by how many distinct query characters they contain; ties keep corpus order."""
k: int = 2
def _get_relevant_documents(self, query: str, *, run_manager: CallbackManagerForRetrieverRun) -> List[Document]:
chars = {c for c in query if not c.isspace()}
scored = [(len(chars & set(d.page_content)), -i, d) for i, d in enumerate(DOCS)]
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
return [d for score, _, d in scored[: self.k] if score > 0]
class RecordingModel(BaseChatModel):
"""Answers 'answer N' and keeps every message list it received."""
calls: int = 0
seen: List[List[BaseMessage]] = []
@property
def _llm_type(self) -> str:
return "recording"
def get_token_ids(self, text: str) -> List[int]:
"""Count characters as tokens so token-budget logic works without a tokenizer download."""
return [ord(c) for c in text]
def _generate(
self,
messages: List[BaseMessage],
stop: Optional[List[str]] = None,
run_manager: Any = None,
**kwargs: Any,
) -> ChatResult:
self.calls += 1
self.seen.append(list(messages))
return ChatResult(generations=[ChatGeneration(message=AIMessage(content=f"answer {self.calls}"))])这个夹具证明的是框架的行为,不是任何真实模型的回答质量。
实验 1:新旧写法给模型的内容一样吗,返回值有什么不同
下面的脚本在旧版环境和兼容层环境里各运行一次。它分四步:先比较 LLMChain 与 prompt | model | StrOutputParser();再比较 RetrievalQA 与 LCEL 检索链,同时记录两边发给模型的消息;然后用一个适配器还原旧输出形状;最后试几种输入形态:
"""Run the old chains and their LCEL replacements side by side and compare what really differs."""
import warnings
from fakes import KeywordRetriever, RecordingModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnableParallel, RunnablePassthrough
try:
from langchain_classic.chains import LLMChain, RetrievalQA
IMPORT_PATH = "langchain_classic.chains"
except ImportError:
from langchain.chains import LLMChain, RetrievalQA
IMPORT_PATH = "langchain.chains"
warnings.filterwarnings("ignore") # the chains emit deprecation warnings on every use
print(f"old chains imported from: {IMPORT_PATH}")
QUESTION = "个人套餐七天内能退款吗"
def texts(model):
return [(m.type, m.content) for m in model.seen[0]]
print()
print("1. LLMChain versus prompt | model | parser")
prompt = ChatPromptTemplate.from_messages([("human", "请用一句话回答:{question}")])
old_model, new_model = RecordingModel(), RecordingModel()
old = LLMChain(llm=old_model, prompt=prompt).invoke({"question": QUESTION})
new = (prompt | new_model | StrOutputParser()).invoke({"question": QUESTION})
print(" old result:", old)
print(" new result:", repr(new))
print(" model saw identical messages:", texts(old_model) == texts(new_model))
print()
print("2. RetrievalQA (stuff) versus an LCEL retrieval chain")
retriever = KeywordRetriever(k=2)
old_model, new_model = RecordingModel(), RecordingModel()
old_qa = RetrievalQA.from_chain_type(llm=old_model, chain_type="stuff", retriever=retriever, return_source_documents=True)
old = old_qa.invoke({"query": QUESTION})
rag_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"Use the following pieces of context to answer the user's question.\n"
"If you don't know the answer, just say that you don't know, don't try to make up an answer.\n"
"----------------\n{context}",
),
("human", "{question}"),
]
)
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
answer = (
RunnableLambda(lambda x: {"context": format_docs(x["docs"]), "question": x["question"]})
| rag_prompt
| new_model
| StrOutputParser()
)
rag = RunnableParallel(docs=retriever, question=RunnablePassthrough()).assign(answer=answer)
new = rag.invoke(QUESTION)
print(" old keys:", list(old))
print(" new keys:", list(new))
print(" model saw identical messages:", texts(old_model) == texts(new_model))
print(" same answer text:", old["result"] == new["answer"])
print(" same source documents:", [d.metadata for d in old["source_documents"]] == [d.metadata for d in new["docs"]])
print()
print("3. A thin adapter keeps the old output shape for existing callers")
as_old_shape = rag | RunnableLambda(
lambda x: {"query": x["question"], "result": x["answer"], "source_documents": x["docs"]}
)
adapted = as_old_shape.invoke(QUESTION)
print(" adapted keys:", list(adapted))
same_values = all(adapted[key] == old[key] for key in ("query", "source_documents"))
print(" query and source_documents equal the old output:", same_values)
print()
print("4. Input shape")
for label, call in (
("old chain, {'question': ...}", lambda: old_qa.invoke({"question": QUESTION})),
("old chain, plain string", lambda: old_qa.invoke(QUESTION)),
("new chain, plain string", lambda: rag.invoke(QUESTION)),
):
try:
call()
print(f" {label:<30} accepted")
except Exception as exc:
print(f" {label:<30} {type(exc).__name__}: {exc}")旧版环境(langchain==0.3.27)的输出:
old chains imported from: langchain.chains
1. LLMChain versus prompt | model | parser
old result: {'question': '个人套餐七天内能退款吗', 'text': 'answer 1'}
new result: 'answer 1'
model saw identical messages: True
2. RetrievalQA (stuff) versus an LCEL retrieval chain
old keys: ['query', 'result', 'source_documents']
new keys: ['docs', 'question', 'answer']
model saw identical messages: True
same answer text: True
same source documents: True
3. A thin adapter keeps the old output shape for existing callers
adapted keys: ['query', 'result', 'source_documents']
query and source_documents equal the old output: True
4. Input shape
old chain, {'question': ...} ValueError: Missing some input keys: {'query'}
old chain, plain string accepted
new chain, plain string accepted- 第 1 步:
LLMChain返回{'question': …, 'text': 'answer 1'},LCEL 返回字符串'answer 1',模型收到的消息完全相同; - 第 2 步:旧链的键是
query、result、source_documents,LCEL 链的键是docs、question、answer,但模型收到的消息一致,答案文本一致,召回的文档一致; - 第 3 步:用一个只有几行的适配器,把 LCEL 的输出映射回旧形状。键完全一致,
query与source_documents的值与旧链相同。result没有放进比较,因为两条链各用一个独立的脚本化模型,计数不同,不具备可比性; - 第 4 步:旧链对
{"question": …}直接抛ValueError,对纯字符串则接受。
兼容层环境(langchain-classic==1.0.8)的输出与上面逐行相同,只有第一行导入路径是 langchain_classic.chains,完整记录在 labs/retrievalqa-to-lcel/expected/parity_check.v1c.out。这说明从 langchain.chains 换成 langchain_classic.chains,在这几项上行为没有变化。
实验 2:chain_type 与空检索
迁移时容易忽略的是旧链隐含的调用次数。下面的脚本统计同一个问题在三种 chain_type 下调用了几次模型,并试一个检索不到任何文档的问题:
"""How many model calls does one RetrievalQA question cost, per chain_type? And with no documents?"""
import warnings
from fakes import KeywordRetriever, RecordingModel
try:
from langchain_classic.chains import RetrievalQA
except ImportError:
from langchain.chains import RetrievalQA
warnings.filterwarnings("ignore")
QUESTION = "个人套餐七天内能退款吗"
print("three retrieved documents")
for chain_type in ("stuff", "map_reduce", "refine"):
model = RecordingModel()
qa = RetrievalQA.from_chain_type(llm=model, chain_type=chain_type, retriever=KeywordRetriever(k=3))
result = qa.invoke({"query": QUESTION})
print(f" chain_type={chain_type:<11} model_calls={model.calls} result={result['result']!r}")
print()
print("a question that matches no document")
model = RecordingModel()
qa = RetrievalQA.from_chain_type(llm=model, retriever=KeywordRetriever(k=2))
result = qa.invoke({"query": "zzz"})
system_prompt = model.seen[0][0].content
print(f" model_calls={model.calls} result={result['result']!r}")
print(f" system prompt ends with: {system_prompt[-20:]!r}")three retrieved documents
chain_type=stuff model_calls=1 result='answer 1'
chain_type=map_reduce model_calls=4 result='answer 4'
chain_type=refine model_calls=3 result='answer 3'
a question that matches no document
model_calls=1 result='answer 1'
system prompt ends with: 'r.\n----------------\n'stuff:把检索到的文档拼成一段上下文,调用 1 次模型;map_reduce:每篇文档各问一次,再合并一次,3 篇文档共 4 次;refine:第一篇文档先回答,后面每篇文档各精炼一次,3 篇文档共 3 次;- 空检索:没有文档命中时,模型仍然被调用 1 次,system 消息以分隔线结尾,后面是空的上下文。旧链不会替你判断“没有检索到东西,就不要回答”。
次数随检索条数 k 线性增加(本脚本固定 k=3,没有测试其他取值)。如果线上的 RetrievalQA 用了 map_reduce 或 refine,改成只有一步的 LCEL 链之后,延迟和费用会明显下降,但回答的组织方式也变了,应当当作行为变更来验收,而不是当作无损替换。兼容层环境里的次数与上面完全相同,记录在 expected/chain_types.v1c.out。
实验 3:只靠 langchain-core 的 LCEL 链
最后一个脚本运行在没有安装 langchain-classic 的纯 v1 环境里,先确认旧链确实不可用,再运行 LCEL 检索链,并演示怎样给空检索加一个显式分支:
"""The LCEL chain needs only langchain-core: it runs where langchain-classic is not installed."""
import importlib.util
from fakes import KeywordRetriever, RecordingModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
print("langchain_classic installed:", importlib.util.find_spec("langchain_classic") is not None)
print("langchain.chains importable:", importlib.util.find_spec("langchain.chains") is not None)
retriever = KeywordRetriever(k=2)
prompt = ChatPromptTemplate.from_messages(
[("system", "Answer using only this context:\n{context}"), ("human", "{question}")]
)
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
def build(model):
return (
{"context": retriever | RunnableLambda(format_docs), "question": RunnablePassthrough()}
| prompt
| model
| StrOutputParser()
)
def build_guarded(model):
"""Skip the model call when nothing was retrieved, instead of asking with an empty context."""
chain = build(model)
def route(question):
if not retriever.invoke(question):
return "no relevant document found"
return chain.invoke(question)
return RunnableLambda(route)
print()
print("a question with matching documents")
model = RecordingModel()
print("answer:", repr(build(model).invoke("个人套餐七天内能退款吗")))
print("system prompt the model saw:", repr(model.seen[0][0].content))
print()
print("a question that matches no document")
model = RecordingModel()
print("plain chain -> model_calls:", (build(model).invoke("zzz"), model.calls)[1])
model = RecordingModel()
result = build_guarded(model).invoke("zzz")
print(f"guarded chain -> result: {result!r}, model_calls: {model.calls}")langchain_classic installed: False
langchain.chains importable: False
a question with matching documents
answer: 'answer 1'
system prompt the model saw: 'Answer using only this context:\n个人套餐购买后七天内可以申请全额退款。\n\n发票在订单详情页申请,一至三个工作日内开具。'
a question that matches no document
plain chain -> model_calls: 1
guarded chain -> result: 'no relevant document found', model_calls: 0- 在纯 v1 环境里,
langchain_classic和langchain.chains都找不到,LCEL 链照常运行,这个写法只依赖langchain-core; - 模型收到的 system 消息里,两篇文档以空行拼接,与实验 1 里旧链的拼接方式一致;
- 普通链对无命中的问题仍然调用模型 1 次,和旧链一样;
- 带分支的链在检索为空时返回固定提示,模型调用 0 次。这个分支在旧链里没有对应的开关,需要自己写,迁移是补上这一层判断的好时机。
旧写法与 LCEL 写法对照
| 旧写法 | LCEL 写法 | 关键差异 | 依据 |
|---|---|---|---|
LLMChain(llm=…, prompt=…) | prompt | model | StrOutputParser() | 返回值从字典变成字符串,模型收到的消息不变 | 实验 1 第 1 步 |
RetrievalQA.from_chain_type(chain_type="stuff") | 检索器、format_docs、提示模板、模型、解析器串联 | 提示词可以逐字一致,返回值形状不同 | 实验 1 第 2 步 |
return_source_documents=True | 用 RunnableParallel(...).assign(...) 把文档带到输出 | 文档要自己保留,不会自动返回 | 实验 1 第 2 步 |
| 依赖旧输出的调用方 | 在 LCEL 链后接一个映射函数 | 调用方不用同时改,回滚也只需切换对象 | 实验 1 第 3 步 |
chain.invoke({"query": …}) | chain.invoke("问题") 或自定义字典键 | 旧链固定键名,新链自己决定 | 实验 1 第 4 步 |
chain_type="map_reduce" / "refine" | 没有一一对应的内置写法 | 调用次数 4 次、3 次,改成单步会改变回答组织方式 | 实验 2 |
| 空检索时照常回答 | 显式分支,检索为空不调用模型 | 减少一次无依据的调用 | 实验 2、3 |
from langchain.chains import … | from langchain_classic.chains import …(过渡)或改写为 LCEL | 过渡期行为与旧版一致 | 实验 1、2 |
官方的 LCEL 写法可以参考 NVIDIA 检索器集成页 (opens in a new tab) 里的检索链示例:字典里放 retriever | format_docs 和 RunnablePassthrough(),后面依次接提示、模型和 StrOutputParser()。官方知识库教程 (opens in a new tab)说明,检索器本身是 Runnable,支持 invoke 与 batch。
排障对照表
| 现象 | 最可能的原因 | 怎么验证 | 处理 |
|---|---|---|---|
ImportError 或 ModuleNotFoundError: langchain.chains | 升级到 v1 之后旧链已移出 langchain | 看导入路径和已安装的包 | 安装 langchain-classic 并改成 langchain_classic.chains,或改写为 LCEL(实验 3) |
ValueError: Missing some input keys: {'query'} | 旧 RetrievalQA 的输入键固定为 query | 打印传入的字典键 | 改键名,或传纯字符串(实验 1 第 4 步) |
调用方报 KeyError: 'result' 或 'text' | LCEL 链默认返回字符串 | 打印链的返回值类型 | 在链后接适配器保持旧形状(实验 1 第 3 步) |
| 迁移后答案“变了”,但提示词没改 | 实际发给模型的消息和旧链不同(拼接方式、system 提示语) | 用脚本化模型打印两边收到的消息再对比(实验 1 的 texts 比较) | 先让消息一致,再比较答案 |
| 迁移后调用次数或费用明显变化 | 旧链用了 map_reduce 或 refine,新链是单步 | 统计模型调用次数(实验 2) | 把它当作行为变更,重新做质量验收 |
| 检索不到内容时仍然编造答案 | 旧链空检索照常调用模型 | 用无命中的问题测一次(实验 2) | 加显式分支(实验 3) |
source_documents 没了 | LCEL 链没有保留检索结果 | 看输出的键 | 用 RunnableParallel 并 assign 答案(实验 1 第 2 步) |
失败与回滚
- 双轨过渡:兼容层
langchain-classic里的LLMChain、RetrievalQA行为与 0.3.27 一致(实验 1、2)。可以先只改导入路径上线,再逐条链改写;整体步骤见迁移 Runbook; - 对等性验收:改写每条链时,都用实验 1 的方法检查“模型收到的消息是否逐字相同”。这一步不需要真实模型,也不会花钱,是最便宜的回归检查;
- 回滚:适配器保住了旧输出形状,调用方没有变化,回滚只需切回旧链对象。如果没有加适配器,调用方和链是一起改的,回滚要连调用方一起回;
- 局限:只验证了
stuff、map_reduce、refine三种RetrievalQA和单步LLMChain;检索器是关键词匹配;模型是脚本化的,所以回答质量的变化需要另外用评估集验证,见 RAG 质量评估与回归。
相关阅读
- 先把旧代码里的 import 路径量出来:LangChain 从 v0.x 升级到 v1;
- 迁移之后怎样评估答案质量没有退化:RAG 质量评估与回归;
- 切分与检索参数怎样影响召回:RAG 切分与 Retriever 基准;
- 线上报错对号入座:LangChain v1 与 LangGraph 生产故障诊断中心。
来源
- v1 迁移指南 (opens in a new tab):旧链(
LLMChain、ConversationChain等)、检索器、索引 API 与 hub 移到langchain-classic,导入改为langchain_classic.*; - v1 发布说明 (opens in a new tab):
langchain-classic包含的内容与安装方式; - 知识库教程 (opens in a new tab):检索器是 Runnable,支持
invoke与batch; - NVIDIA 检索器集成页 (opens in a new tab):
retriever | format_docs加RunnablePassthrough()的检索链写法。
官方文档没有一篇专门讲 RetrievalQA 迁移到 LCEL 的指南,所以“对等性”“调用次数”“空检索”这几项都来自本页的实测,不是官方结论。以上来源均于 2026-10-26 核对,本页所有输出来自站点仓库 labs/retrievalqa-to-lcel/ 在上表版本下的实际运行。