在 LangChain 表达式语言(LCEL)中,RunnablePassthrough() 的核心作用极其简单直接:"原封不动地传递输入,不做任何修改"。
但在你提供的这段代码中,它的妙用在于配合 RunnableParallel(即字典 {})实现数据的"分流"与"合并"。
为了让你彻底看懂,我把这段代码中的数据流拆解成三步:
1. 它在代码中具体做了什么?
在你的 chain 最开头,定义了一个字典(并行执行器):
python
{
"input": RunnablePassthrough(), # 支路 A:直通
"context": RunnableLambda(format_for_retriever) | retriever | format_document # 支路 B:检索
}
当用户调用 chain.invoke({"input": "针织毛衣如何保养?", "history": [...]}) 时:
- 支路 B(检索) :拿着用户的原始问题去向量库检索相关资料,加工成
context。 - 支路 A(直通) :
RunnablePassthrough()什么都不干 ,直接把用户传进来的整个原始字典{"input": "针织毛衣...", "history": [...]}赋值给了"input"这个键。
此时,经过这一步处理后,数据变成了:
python
{
"input": {"input": "针织毛衣如何保养?", "history": [...]}, # 原封不动的原始输入
"context": "文档片段:针织毛衣应冷水手洗...\n文档元数据:..." # 检索到的资料
}
2. 为什么非要加这一句?(面试高频追问)
如果没有 RunnablePassthrough(),写成 "input": "随便写个字符串",那用户的原始输入和对话历史就丢了。
之所以这么设计,是因为后续的 format_for_prompt_template 函数需要从 value["input"]["history"] 中提取对话记录。
- 没有它 :
value["input"]只是一个字符串(比如"用户问题"),你没法从字符串里取history,程序会报错。 - 有了它:它像一条"旁路",把最原始的请求完整地保留了下来,供后面的 Prompt 模板使用。
3. 直观类比
想象一个快递分拣中心(chain):
- 你送来一个包裹(原始输入),里面装着**"问题"和"聊天记录"**。
- 支路 B(检索) :拆开包裹,看一眼"问题",跑去仓库(向量库)找相关的资料(
context)。 - 支路 A(
RunnablePassthrough) :不拆封,直接把整个原包裹贴上一个"原始数据"的标签,送到下一站。
最后,下一站的组装员(Prompt模板)手里有两样东西:刚找来的资料(context) 和 没拆封的原包裹(input),他打开原包裹拿出"问题"和"聊天记录",三者一起组装成最终发给大模型的提示词。
补充:一个易混淆点
LangChain 中还有另一个常用方法叫 RunnablePassthrough.assign(),这里要区分一下:
RunnablePassthrough()(你代码中的) :保持输入原样输出。RunnablePassthrough.assign(...):保持输入不变,额外追加新的键值对(常用于在不丢失原数据的情况下,添加计算后的新字段)。
在你的代码中,不需要追加,所以直接用基础版本作为字典中 "input" 键的值,完美实现了"保留原始请求上下文"的需求。
总结一句话 :在你的 RAG 链中,RunnablePassthrough() 充当了**"数据保留中转站",确保用户的完整请求(包括对话历史)在并行检索时不会被消耗掉,能安全地流入最后的 Prompt 组装环节。
这段代码是 LangChain 表达式语言(LCEL)的 核心执行链**。它利用管道操作符 |,将数据像流水线一样,从左到右经过层层加工,最终生成答案。
为了帮你彻底看懂,我把它拆解为 5 个核心阶段,并追踪一份"虚拟数据"在其中的流转过程:
阶段零:并行检索(字典内部)
python
"context": RunnableLambda(format_for_retriever) | retriever | format_document
含义 :这是链条的起点 ,发生在前面的 {}(并行字典)内部。
RunnableLambda(format_for_retriever):将用户输入{"input": "问题", "history": [...]}转化为纯字符串"问题"(因为检索器只接受字符串)。| retriever:拿着纯文本问题去向量库搜索,返回一堆Document对象。| format_document:将这堆Document对象格式化为 Prompt 能看懂的大段文本字符串(包含内容和元数据)。
此时产出 :并行字典
{"input": 原始请求, "context": "格式化的参考文档"}进入下一阶段。
阶段一:数据整形(RunnableLambda(format_for_prompt_template))
python
} | RunnableLambda(format_for_prompt_template)
含义 :将上一步产生的字典,重新构造成 Prompt 模板所需要的精确结构。
看你的 format_for_prompt_template 函数:
python
def format_for_prompt_template(value):
# value = {"input": {"input": "问题", "history": [...]}, "context": "文档内容"}
new_value["input"] = value["input"]["input"] # 提取出纯问题字符串
new_value["context"] = value["context"] # 直接拿过来
new_value["history"] = value["input"]["history"]# 提取对话历史
return new_value
为什么要有这一步? 因为你的 Prompt 模板变量名是 {input}、{context}、{history},这一步相当于把杂乱的数据"拧"成模板能直接匹配的钥匙。
此时产出 :
{"input": "针织毛衣如何保养?", "context": "文档内容...", "history": [("user","你好"), ...]}
阶段二:填充模板(self.prompt_template)
python
| self.prompt_template
含义 :将上一步的三个键值对,填入你定义好的 ChatPromptTemplate 中。
最终拼接成发给大模型的完整字符串(包含 System 提示词、History 对话记录、User 最新提问)。
此时产出 :一个完整的
PromptValue对象(包含所有消息结构)。
阶段三:调试钩子(print_prompt)
python
| print_prompt
含义 :这是你自定义的调试函数(def print_prompt(prompt): print(...); return prompt)。
它只拦截查看,不修改数据。打印出的内容能帮你确认"发给大模型的提示词长什么样",在排错时极其有用。
此时产出 :原封不动的
PromptValue对象。
阶段四:模型推理(self.chat_model)
python
| self.chat_model
含义:调用通义千问(Qwen)的 API。
模型接收上一步拼好的完整提示词,进行推理,返回一个包含完整回答的 AIMessage 对象(包含 content 属性和一些元数据)。
此时产出 :
AIMessage(content="针织毛衣建议冷水手洗,平铺晾干...")
阶段五:提取字符串(StrOutputParser())
python
| StrOutputParser()
含义:这是最后一关。模型返回的是对象(有各种属性),但我们只需要文本内容。
- 输入 :
AIMessage(content="...") - 输出 :纯字符串
"针织毛衣建议冷水手洗,平铺晾干..."
最终返回给前端/用户:干干净净的纯文本答案。
总结(一句话概括)
这条链的逻辑是:取问题(Lambda)→ 查资料(Retriever)→ 整理资料(Lambda)→ 拼提示词(Template)→ 打印看效果 → 发给大模型(ChatModel)→ 只拿文字出来(Parser)。
这就像一条自动化奶茶生产线:(放杯子 → 加茶底 → 加小料)→ 贴标签 → 递给奶茶师傅(大模型)→ 师傅做完后撕掉外卖单(Parser),只把奶茶递给顾客 。
如果你对其中某个环节(比如 RunnableLambda 为什么非要套一层,或者 StrOutputParser 还有别的用法)还想深挖,我可以随时为你细化。
这段代码是 RAG 链路中非常关键的**"数据格式化"**环节。它的核心作用是把向量数据库查出来的"原始答案碎片",加工成大模型能看懂、能引用的"标准参考资料"。
1. 代码逐行解读
python
def format_document(docs: list[Document]):
# 1. 防御性检查:如果检索器没找到任何相关资料
if not docs:
return "无相关参考资料" # 返回一个占位符,告知大模型"我没查到资料"
formatted_str = "" # 初始化一个空字符串,准备拼接内容
# 2. 遍历检索到的每一个文档片段
for doc in docs:
# 3. 按固定格式拼接:把文本内容和来源信息都加进去
formatted_str += f"文档片段:{doc.page_content}\n文档元数据:{doc.metadata}\n\n"
return formatted_str # 返回一个完整的大段文本
这段代码的逻辑很直白 :把一堆文档对象变成一段结构清晰的文本。特别注意 if not docs 这一句,如果不加这句,当知识库没相关内容时,大模型收到的 {context} 就是空的,极大概率会"编造"答案;加上这句话,模型就会知道"没有参考资料",进而触发系统提示词中"不知道就说不知道"的规则。
2. 代码里的 Document 到底是什么?
Document 是 LangChain 框架中定义的一个基础数据类,你可以把它理解为 LangChain 世界里流通的"通用货币"或"标准快递箱"。
只要数据(无论是 PDF、Word、网页还是数据库记录)被喂进 LangChain 体系,最终都会被封装成 Document 对象,方便后续的检索器(Retriever)、分割器(Splitter)统一处理。它的核心结构非常简单:
| 属性 | 类型 | 含义(必记) |
|---|---|---|
page_content |
str |
真正的文本内容。比如你切分出来的那一段文字、那一句话。 |
metadata |
dict |
附带的元数据(标签) 。是个字典,用来记录这段文本的"出身",比如:{"source": "针织手册.pdf", "page": 15, "author": "张三"}。这在做答案溯源时极其有用------你可以知道模型引用的内容来自哪份文件。 |
id (可选) |
str |
文档的唯一标识符(在某些向量库里会用到)。 |
3. 结合你的 RAG 链路看具体数据流
在你之前的代码中,retriever(检索器)执行 similarity_search 后,返回的数据类型正是 List[Document] (文档列表)。而你的 format_document 函数就是在处理这个列表。
假设用户问"针织毛衣如何保养?",向量库召回了 2 个 Document,它们在内存中大致长这样:
原始数据(List[Document]):
- 文档1 :
page_content="针织毛衣建议使用30度以下冷水手洗...",metadata={"file": "保养指南.pdf", "page": 3} - 文档2 :
page_content="清洗后应平铺晾干,避免悬挂导致变形...",metadata={"file": "保养指南.pdf", "page": 4}
经过 format_document 处理后(变成字符串):
text
文档片段:针织毛衣建议使用30度以下冷水手洗...
文档元数据:{'file': '保养指南.pdf', 'page': 3}
文档片段:清洗后应平铺晾干,避免悬挂导致变形...
文档元数据:{'file': '保养指南.pdf', 'page': 4}
最终,这段字符串会被填入你 Prompt 模板的 {context} 位置,大模型看到的就是带有清晰来源标记的参考资料。
4. 面试官如果追问(加分点)
问:为什么要把 Document 转成字符串,而不是直接丢给大模型?
答:因为大模型(LLM)的 API 接收的是纯文本或消息列表(Messages),它不认识 Python 对象。我们必须把
Document里的文本和元数据"提取"并"格式化"成自然语言,注入到 System Prompt 中。此外,把元数据(如页码、文件名)拼进去,能让大模型在回答时附带引用出处,极大增强答案的可信度。
问:这样拼接字符串会不会超过大模型的上下文长度(Context Window)限制?
答:这是 RAG 工程中的核心痛点。通常我们会在
retriever检索时通过top_k参数(比如只取前 5 个)来控制返回的Document数量;或者在检索前对Document做进一步的长度截断。这里只是纯粹的格式化,不做截断 ,截断逻辑通常放在检索器参数或前置的文本分割器中。这行代码是 Python 中非常经典的类型安全检查。
字面意思 :判断变量 stream 是否属于 Python 内置的 字符串(str)类型 (或者是 str 的子类)。
为什么用 isinstance 而不是 type(stream) == str?
因为 isinstance 支持继承关系,更符合 Python 的"鸭子类型"哲学。如果 stream 是某种自定义字符串子类,isinstance 依然返回 True,而 type 会返回 False,代码兼容性更强。
在 AI / RAG 编程中的特定场景(必看)
在之前的对话中,我们提到了 StrOutputParser 会提取纯字符串。这行代码通常出现在自定义流式输出处理器 、回调函数 或复杂的链式解析中。
结合上下文变量名 stream,它代表**"从大模型 API 返回的一个流式数据包(Chunk)"。这行代码的核心意图是兼容并包**:
由于不同大模型厂商(如通义千问、OpenAI、智谱)返回的流式数据格式不统一:
- 有的厂商返回的是 纯字符串(如某些开源模型直接吐文本)。
- 有的厂商返回的是 字典 / JSON 对象 (如包含
choices[0].delta.content)。
这行代码的实际作用就是做"分流处理",防止程序在拼接答案时崩溃。伪代码逻辑如下:
python
# 模拟接收流式数据包
for chunk in response_stream:
# 如果是纯字符串(直接就是文本),直接拼接
if isinstance(chunk, str):
full_answer += chunk
# 如果是字典(例如 OpenAI 格式),就深入内部提取文本
elif isinstance(chunk, dict):
full_answer += chunk["choices"][0]["delta"]["content"]
# 否则忽略或报错
如果面试官追问(加分项)
问:如果我把 if isinstance(stream, str) 改为 if type(stream) is str,有什么隐患?
答 :如果大模型 SDK 内部封装了自定义字符串类(例如继承自
str的CustomString),type严格比较会返回False,导致代码将其误判为"非字符串"而跳过处理,造成输出为空。使用isinstance更安全,它承认"子类也是父类"。
问:既然你提到了大模型流式,那 StrOutputParser 是干嘛的,和这个判断有什么关系?
答 :
StrOutputParser负责在非流式 场景下把完整的AIMessage对象转成字符串。但在流式场景 下,我们处理的是一个个碎片,无法直接套用 Parser。所以通常会自定义一个RunnableL ambda或生成器函数,在函数内部利用isinstance(stream, str)这样的判断,把各种五花八门的碎片统一"洗"成文本,再拼起来返回给前端。
别担心,isinstance() 是 Python 里最基础也最常用的内置函数之一,理解它很简单。我们抛开复杂术语,用**"查户口"**的方式来理解。
1. 它的"身份证"长什么样?
python
isinstance(对象, 类型)
- 功能:问你第一个参数(对象)是不是第二个参数(类型)的"家庭成员"(实例)。
- 返回值 :是则返回
True,否则返回False。
2. 生活中的类比(秒懂)
想象你面前有一个不锈钢保温杯 (这就是对象)。
- 你问它:"你是杯子吗?" ------ 是!(返回
True) - 你问它:"你是苹果吗?" ------ 不是!(返回
False)
用代码翻译就是:
python
cup = "一个不锈钢保温杯" # 这是个字符串对象
print(isinstance(cup, str)) # 问:它是字符串吗? 输出 True
print(isinstance(cup, int)) # 问:它是数字吗? 输出 False
print(isinstance(cup, list)) # 问:它是列表吗? 输出 False
3. 为什么一定要用它?(type() 和 isinstance() 的区别)
很多新手会问:"我用 type(cup) == str 不也能判断吗?"
核心区别在于:isinstance 认"干儿子"和"家族血脉",而 type 只认"亲儿子"。
在 Python 里,我们可以继承一个类(相当于认了个干儿子)。举个例子:
python
# 1. 定义一个父类:动物
class Animal:
pass
# 2. 定义一个子类(继承自动物):狗
class Dog(Animal):
pass
# 3. 创建一只狗
my_pet = Dog()
# 用 type 判断(只看眼前这张身份证)
print(type(my_pet) == Animal) # 输出 False (因为身份证上写的是 Dog,不是 Animal)
# 用 isinstance 判断(查户口,问祖宗)
print(isinstance(my_pet, Animal)) # 输出 True (因为 Dog 的老祖宗是 Animal)
结论 :isinstance 更宽容、更安全。在大型项目中,为了代码的健壮性,绝大多数情况下都推荐用 isinstance。
4. 回到你之前的那行代码 if isinstance(stream, str):
结合上面的知识,这行代码就是在问变量 stream:
"你是不是一个字符串(str)类型的东西?"
- 如果大模型传回来的数据包是 纯文本 (比如
"你好"),问它是不是str,回答是True,于是程序直接拼接。 - 如果大模型传回来的数据包是 字典 (比如
{"text": "你好"}),问它是不是str,回答是False,于是程序就走else分支,去字典里把"你好"挖出来。
5. 进阶用法(面试可能追问)
isinstance 还能同时检查多种类型,只要满足其中一个就算通过:
python
# 问:stream 是不是字符串 或者 字典?
if isinstance(stream, (str, dict)):
print("它是这两种中的某一种!")
帮你总结一句话 :isinstance(变量, 类型) 就是**"老板,帮我查一下这个变量是不是属于这个类型大家族",返回值只有 真 或 假。它的作用就是让程序在面对不同格式的数据时,能做出不同的应对,防止程序报错崩溃。😊
如果还想了解 type() 和 isinstance() 在面向对象继承**中的具体区别,或者在实际 API 请求中怎么用它做校验,随时可以告诉我。