解决方案
旧链迁移到 LCEL

RetrievalQA 与 LLMChain 迁移到 LCEL:对等性与调用次数实测

LangChain v1 把 LLMChain、RetrievalQA 这类旧链移到了兼容包 langchain-classic(导入路径的变化见导入兼容矩阵)。官方的处理方式是把旧链放进兼容包,原样继续使用;官方没有给出从旧链到 prompt | model | parser 这类 LCEL 链的对照指南,本页要补的就是这一块。

改写本身只有十几行,真正难的是怎么证明改写之后行为没变。本页写给正在维护旧 RAG 问答服务的开发者:用一个不联网的脚本化模型和一个关键词检索器,把旧链与 LCEL 链放在同一个脚本里逐项对照,量出四个具体问题:发给模型的内容是否相同、返回值哪里不一样、一次提问要调用几次模型、检索结果为空时会发生什么。

适用版本与验证信息

项目内容
验证日期2026-10-26
Python3.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 质量评估与回归。

先说结论

  1. 发给模型的内容可以做到逐字一致。 stuff 类型的 RetrievalQA 发给模型的是一条 system 消息(固定的提示语加上以空行拼接的文档)和一条用户问题。手写的 LCEL 链用同样的提示模板,模型收到的消息与旧链完全相同(实验 1)。迁移改变的是外壳,不是提示词。
  2. 真正的破坏点是返回值形状。 旧 RetrievalQA 返回字典 {query, result, source_documents},旧 LLMChain 返回 {输入变量, text};LCEL 链默认只返回字符串。所有读取 result、text 或 source_documents 的调用方都会出问题。一个很薄的适配器就可以保住旧形状(实验 1)。
  3. 旧链的输入键很严格。 传 {"question": …} 会抛 ValueError: Missing some input keys: {'query'};传纯字符串反而可以。LCEL 链的输入键取决于你怎么写(实验 1)。
  4. chain_type 决定一次提问调用几次模型。 检索到 3 篇文档时,stuff 调用 1 次,map_reduce 调用 4 次,refine 调用 3 次(实验 2)。手写的 LCEL 链等价于 stuff,想要另外两种策略要自己编排。
  5. 检索结果为空时,旧链仍然会调用模型。 系统提示语末尾的上下文是空的,模型照常回答。LCEL 里可以显式分支,空检索时一次模型都不调用(实验 2、3)。
  6. LCEL 链只依赖 langchain-core。 在没有安装 langchain-classic 的环境里可以直接运行(实验 3)。兼容层里的旧链和 0.3.27 里的旧链,在本页验证的几项上行为一致,两个环境的输出只有导入路径一行不同。

实验 0:脚本化模型与检索器

所有脚本共用同一个测试夹具:关键词检索器按“文档中包含多少个不同的查询字符”排序,并列时保持原顺序;模型记录每次收到的消息,回答固定为 answer 序号。get_token_ids 用字符数代替分词器,这样 map_reduce、refine 的 token 预算逻辑不需要额外安装分词器依赖也能运行。

fakes.py
"""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 检索链,同时记录两边发给模型的消息;然后用一个适配器还原旧输出形状;最后试几种输入形态:

parity_check.py
"""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 下调用了几次模型,并试一个检索不到任何文档的问题:

chain_types.py
"""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 检索链,并演示怎样给空检索加一个显式分支:

lcel_only.py
"""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 质量评估与回归。

相关阅读

来源

官方文档没有一篇专门讲 RetrievalQA 迁移到 LCEL 的指南,所以“对等性”“调用次数”“空检索”这几项都来自本页的实测,不是官方结论。以上来源均于 2026-10-26 核对,本页所有输出来自站点仓库 labs/retrievalqa-to-lcel/ 在上表版本下的实际运行。

维护信息

  • 编辑:LangChain 中文网编辑部(内容标准见编辑与纠错说明);
  • 最近验证:2026-10-26;
  • 复现:先执行 labs/setup-envs.sh legacy v1 v1c 重建环境,再执行 python3 labs/run.py --lab retrievalqa-to-lcel;
  • 发现错误请按联系我们反馈。