**摘要:**本文系统梳理了黑马程序员《大模型 RAG 与 Agent 智能体项目实战教程》中体量最大的 LangChain 模块(共 29 集)。内容从环境部署与三类模型(LLM、ChatModel、Embeddings)的统一调用讲起,逐步深入到提示词模板体系(PromptTemplate、FewShotPromptTemplate、ChatPromptTemplate)、LCEL 链式表达式(Runnable、管道符、各类解析器、RunnableLambda),再到会话记忆、文档加载与分割、向量存储,最终拼装出一条可运行的 RAG 在线问答链路。文章还整理了踩坑清单、动手练习路径、与前后模块的衔接关系以及面试常考点,帮助读者从零搭建并理解 RAG 全链路的最小内核。
目录
- 一、模块定位与学习目标
- [二、知识块一:LangChain 入门、环境部署与三类模型调用](#二、知识块一:LangChain 入门、环境部署与三类模型调用)
- 三、知识块二:提示词模板体系
- [四、知识块三:LCEL 链式表达式------Runnable、管道符与各种解析器](#四、知识块三:LCEL 链式表达式——Runnable、管道符与各种解析器)
- [五、知识块四:Memory 记忆、文档加载与分割、向量存储与 RAG 链路](#五、知识块四:Memory 记忆、文档加载与分割、向量存储与 RAG 链路)
- 六、踩坑与环境注意事项汇总
- 七、与前后模块的衔接
- 八、面试与实战常考点
《RAG开发学习总结:从 LangChain 入门到检索增强生成链路的全栈实战》
相关链接
李沐深度学习191集课程全解析:模块拆解、学习路径-CSDN博客
吴恩达《面向开发者的提示词工程》-CSDN博客
吴恩达 MCP 教程(Model Context Protocol)(一)-CSDN博客
多模态大模型教程学习笔记 --- ViT · CLIP · SAM · GLIP · Stable Diffusion
一句话总结: 本模块用 29 集把"大模型应用开发框架 LangChain"从环境搭建、三类模型调用、提示词模板体系,到 LCEL 链式表达式(Runnable / 管道符 / 解析器 / 自定义函数)、会话记忆 Memory、文档加载与分割、向量存储与检索,最终拼成一条可运行的 RAG 在线问答链路------它不只是八股概念,而是下一阶段 RAG 项目实战与 Agent 智能体开发的全部地基。
一、模块定位与学习目标
本模块是黑马程序员《大模型 RAG 与 Agent 智能体项目实战教程》中体量最大的一块,共 29 集。它在课程图谱中处于承上启下的位置:
-
承上: 前面的"前置准备""OpenAI 库基础使用""提示词工程"三模块,教的是怎么用裸 API(requests / openai SDK)一次性调通模型、怎么手写 system/user/assistant 消息、怎么做零样本与少样本提示词优化。那些知识是"手工作坊",本模块把它们工程化、框架化。
-
启下: 本模块结束后立刻进入"RAG 项目实战"(文本上传 Web 服务、md5 工具、知识库更新、离线流程、在线向量存储、rag 核心服务、历史会话),再往后是"Agent 智能体"(ReAct、middleware)。可以说,本模块学的每一个类,后面项目里都会原样再用一遍。
学习目标可以拆成四句话:
-
能装、能跑、能排错 LangChain 1.x 开发环境,分清
langchain/langchain-community/langchain-core/langchain-ollama/langchain-chroma各自管什么。 -
会用统一接口调用三类模型 :大语言模型(LLM)、聊天模型(ChatModel)、嵌入模型(Embeddings),并区分
invoke(阻塞)与stream(流式)。 -
吃透 LCEL(LangChain Expression Language) :用管道符
|把 Runnable 组件串成链,理解每一段"输入什么类型、输出什么类型"。 -
独立搭出一条最简 RAG 链路:文档加载 → 分割 → 嵌入入库 → 检索 → 拼提示词 → 模型生成。
LangChain 核心组件速查表
课程开篇就给出 LangChain 的六大能力,这里整理成表,方便对照后面每一集在讲哪一块:
| 组件大类 | 课程中对应类 / 模块 | 干什么 | 本模块对应集数 |
|---|---|---|---|
| 模型 I/O | Tongyi / ChatTongyi / DashScopeEmbeddings |
统一封装云端通义千问与本地 Ollama | RAG开发-06~10 |
| 提示词 | PromptTemplate / ChatPromptTemplate / FewShotPromptTemplate |
模板化、变量注入、少样本、多轮历史 | RAG开发-11~14 |
| 链式调用 | Runnable / ` | /RunnableLambda` / 各种 Parser |
把组件串成可自动流转的执行链 |
| 记忆 | RunnableWithMessageHistory / InMemoryChatMessageHistory / 自实现文件历史 |
自动附加多轮对话历史 | RAG开发-21~22 |
| 文档加载 | CSVLoader / JSONLoader / TextLoader / PyPDFLoader |
把异构文件统一读成 Document |
RAG开发-23~26 |
| 向量存储 | InMemoryVectorStore / Chroma |
存向量、做相似度检索 | RAG开发-27~29 |
二、知识块一:LangChain 入门、环境部署与三类模型调用
2.1 LangChain 是什么,为什么需要它
LangChain 本质上就是一个 Python SDK(第三方包) 。它解决的痛点是:市面上大模型千千万(OpenAI、阿里云百炼、本地 Ollama......),如果每个都写一套不同的调用代码,工程上根本无法维护。LangChain 做的事情是统一编程接口------你换模型时只换 import 的那个类,业务代码几乎不动。
课程把它的价值归纳为六点:提示词优化、多平台模型支持、历史消息管理、文档处理、链式执行、智能体(Agent)构建。这六点正好对应后面 29 集的内容主线。
2.2 环境部署:一行 pip 与五个包
课程(RAG开发-02)里实际安装的命令是:
bash
pip install langchain langchain-community langchain-ollama dashscope chromadb -i https://pypi.tuna.tsinghua.edu.cn/simple
这五个包各有分工,必须分清(这是后面 import 路径不报错的前提):
| 包名 | 职责 | 你会从里面 import 什么 |
|---|---|---|
langchain |
核心包,1.x 后主要做编排与兼容 | 一般不直接 import 具体类 |
langchain-core |
所有基础抽象(Runnable、Message、Prompt、Parser、VectorStore 基类) | langchain_core.messages、langchain_core.prompts、langchain_core.output_parsers |
langchain-community |
社区贡献的第三方模型/加载器适配 | ChatTongyi、Tongyi、DashScopeEmbeddings、各 Loader |
langchain-ollama |
Ollama 本地模型支持 | ChatOllama、OllamaLLM、OllamaEmbeddings |
chromadb |
轻量向量数据库(底层 SQLite) | 被 langchain-chroma 调用 |
课程实测版本是 langchain 1.2.1 。验证安装成功的方式是进 Python 解释器 import langchain 不报错。
踩坑提示: 1.x 版本里,很多类从旧的
langchain.llms、langchain.chat_models搬到了langchain_community或langchain_core。老教程里from langchain import PromptTemplate在新版会告警甚至失效,统一用from langchain_core.prompts import PromptTemplate才稳。
2.3 为什么要 RAG:先把问题讲透
在写代码之前,课程(RAG开发-03)先用一整集讲清楚"为什么需要 RAG"。这是面试必问,也是理解后面一切设计的动机:
-
知识非实时 / 知识滞后: 大模型的训练数据有截止日期,不知道今天发生的事,更不知道你公司内部的文档。
-
幻觉(Hallucination): 模型会一本正经地编造不存在的事实。
-
私域知识与数据安全: 企业数据不能随便拿去微调或公开,但又想让模型基于这些数据回答。
RAG 之所以有效,本质上是把"模型的参数化知识"和"外挂的非参数化知识"做了一次拼接:模型本身的能力(语言组织、逻辑推理)不动,而把最新、最私密、最需要精确引用的事实,临时从外部捞出来喂给它。这样一来,更新知识库只需要往向量库里增删文档,完全不用重新训练或微调模型,成本极低、时效性极强。同时,因为提示词里明确写了"请依据参考资料回答",模型有据可依,胡乱编造的空间被大大压缩;即便它不知道,也更容易老老实实地说"资料里没有",而不是硬编一个答案。这就是 RAG 在企业落地中比微调更受欢迎的根本原因。
课程特别强调,RAG 不是一个单点技术,而是一条离线建库 + 在线问答的流水线:离线那一侧可以源源不断地爬取、清洗、切片、向量化,把知识库越攒越厚;在线那一侧才是每次用户提问时实时发生的检索与生成。理解了这两条线的分工,后面项目里"知识库更新服务"和"在线 rag 服务"为什么要拆成两个模块,也就顺理成章了。
具体到实现,它分两条线:
-
离线索引线: 持续抓知识 → 加载成文档 → 分割 → 嵌入成向量 → 存入私有向量库。
-
在线检索线: 用户提问 → 提问向量化 → 在向量库做相似度搜索 → 把命中的片段拼进提示词 → 交给模型生成。
2.4 向量与余弦相似度:RAG 的数学地基
RAG 能"语义检索",靠的是两个扩展集(RAG开发-04、05)。
向量是什么? 向量是文本语义的数学表示------一串固定长度的浮点数。人没法直接判断两句话语义是否相同,但计算机可以比较两个数字数组。嵌入模型(如阿里云 text-embedding-v1,1536 维;text-embedding-v4 性能更好、免费额度更充足)的作用就是把字符串转成这串数字。维度越高,语义特征刻画越细,但存储与计算成本越高,需要平衡。
怎么判断两个向量语义相近? 用余弦相似度。它只看两个向量的方向、不看长度,公式是:
text
cos(θ) = (A · B) / (|A| × |B|)
课程里手写 NumPy 验证过:两向量方向完全一致时余弦值为 1.0,垂直为 0,反向约 -0.996。所以在向量库里,"语义最像"等价于"余弦相似度最大"。
为什么不直接用字符串精确匹配?因为"我喜欢你"和"我稀饭你"字面上几乎没有公共字符,但语义几乎一样;传统的关键词检索做不到这一点,而嵌入模型会把它们映射到向量空间里相邻的位置。这就是 RAG 能"听懂人话"而不是"死匹配关键词"的原因。课程还特意提醒:向量维度不是越高越好------维度越高,对语义特征的刻画越细腻,但每条文档要存的数字就越多、相似度计算越慢,要在精度和性能之间取平衡;实际项目里用模型默认维度即可,不要自己硬凑维度。
2.5 三类模型的统一调用
课程把 LangChain 能调的模型分三类,调用方式高度统一,但输入输出 API 有差异,必须分清:
| 模型类别 | 云端通义千问类 | 本地 Ollama 类 | 输入 | 输出 | 调用方法 |
|---|---|---|---|---|---|
| 大语言模型 LLM | langchain_community.llms.Tongyi |
langchain_ollama.OllamaLLM |
字符串 | 字符串 | invoke / stream |
| 聊天模型 ChatModel | langchain_community.chat_models.ChatTongyi |
langchain_ollama.ChatOllama |
Message 列表 | AIMessage |
invoke / stream |
| 嵌入模型 Embeddings | langchain_community.embeddings.DashScopeEmbeddings |
langchain_ollama.OllamaEmbeddings |
字符串 / 字符串列表 | 向量列表 | embed_query / embed_documents |
调用大语言模型(RAG开发-06):
python
from langchain_community.llms import Tongyi
llm = Tongyi(model="qwen-max")
print(llm.invoke("用一句话介绍 Python"))
注意:做单纯生成、不涉及多轮对话时用 qwen-max;qwen3-max 是聊天模型。
流式输出(RAG开发-07): 把 invoke 换成 stream,它返回一个迭代器,逐块吐出,避免用户干等:
python
for chunk in llm.stream("写一首关于秋天的诗"):
print(chunk, end="", flush=True)
print(..., end="", flush=True) 是关键:end="" 取消自动换行,flush=True 让每一块立刻刷新到屏幕。流式不只是模型级别的能力------LCEL 链也支持流式,你把 chain.invoke(...) 换成 chain.stream(...),整条链上每个组件都会以流式方式逐级吐出,最终用户感受到的就是答案一个字一个字地蹦出来,而不是干等几秒后一次性刷屏。这在聊天类产品里是必须的体验。
调用聊天模型(RAG开发-08): 聊天模型按"角色"组织消息,有三类:HumanMessage(用户)、AIMessage(AI 回复)、SystemMessage(系统设定)。
python
from langchain_community.chat_models import ChatTongyi
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
chat = ChatTongyi(model="qwen3-max")
messages = [
SystemMessage(content="你是一个边塞诗人"),
HumanMessage(content="写一首唐诗"),
AIMessage(content="大漠孤烟直,长河落日圆。"),
HumanMessage(content="按上一首的格式再写一首"),
]
for chunk in chat.stream(messages):
print(chunk.content, end="", flush=True)
这里有个经典坑 :聊天模型流式吐出的每个 chunk 是 AIMessage 对象,直接 print(chunk) 会打出一堆对象信息,必须取 chunk.content 才是纯文本。切到本地 Ollama 时只换 import 和类:
python
from langchain_ollama import ChatOllama
chat = ChatOllama(model="qwen3:4b")
消息简写形式(RAG开发-09): 上面那种 SystemMessage(content=...) 是"静态写法",一步到位得到消息对象。LangChain 还支持二元元组的"动态写法":
python
messages = [
("system", "你是一个边塞诗人"),
("human", "写一首唐诗"),
("ai", "大漠孤烟直,长河落日圆。"),
("human", "按上一首的格式再写一首"),
]
角色只接受 "system" / "human" / "ai" 三种字符串。简写的好处:① 不用再 import 三个 Message 类;② 写起来短;③ 最关键------它支持占位符变量注入(如 ("{name}")),这为后面 ChatPromptTemplate 动态拼历史埋下伏笔。
调用嵌入模型(RAG开发-10): 嵌入模型不做对话,只做"文本→向量",所以 API 不一样:
python
from langchain_community.embeddings import DashScopeEmbeddings
embed = DashScopeEmbeddings() # 默认 model = text-embedding-v1,1536 维
print(embed.embed_query("我喜欢你")) # 单条文本 → 一个向量(浮点数列表)
print(embed.embed_documents(["我喜欢你", "我稀饭你", "晚上吃啥"])) # 批量 → 二维列表
embed_query 一次转一条,embed_documents 批量转多条。本地 Ollama 版本是 from langchain_ollama import OllamaEmbeddings; OllamaEmbeddings(model="qwen3-embedding:4b"),用前先 ollama pull qwen3-embedding:4b。
三、知识块二:提示词模板体系
手写字符串拼接提示词在小 demo 里没问题,但工程化时变量一多就乱。LangChain 提供三类模板,它们都是 Runnable 子类,因此能进链。
3.1 PromptTemplate:通用提示词模板(零样本)
通用模板把一段固定文案里的变量用 {变量名} 占住,调用时再注入具体值。示例:
python
from langchain_core.prompts import PromptTemplate
from langchain_community.llms import Tongyi
prompt = PromptTemplate.from_template(
"我的邻居姓{last_name},刚生了{gender},你帮我起个名字,简单回答。"
)
text = prompt.format(last_name="张", gender="女儿") # format 返回纯字符串
llm = Tongyi(model="qwen-max")
print(llm.invoke(text))
为什么不直接 f-string 拼字符串?课程给了两个核心理由:
-
工程标准化: 变量多了以后,模板可复用、可维护。
-
能进链:
PromptTemplate是 Runnable 子类,字符串不是。所以可以写成chain = prompt | llm,用chain.invoke({"last_name": "张", "gender": "女儿"})一步跑通。这种模板不提供示例,属于**零样本(Zero-shot)**思想。
3.2 format 与 invoke 的区别(RAG开发-13)
这是一个易混点,课程专门用一集讲清:
| 方法 | 返回类型 | 能否进链 | 说明 |
|---|---|---|---|
format() |
纯字符串 str |
否 | 最简单的字符串替换,适合直接拿去喂 LLM |
invoke() |
PromptValue 对象 |
是(Runnable 协议) | 支持结构化占位符解析,是链式调用的入口 |
invoke 返回的是 PromptValue,需要 .to_string() 才能看到纯文本;而正因为它是 Runnable,才能被 | 串进链。
3.3 FewShotPromptTemplate:少样本提示词模板
少样本思想就是"给几个例子,再让模型照猫画虎"。FewShotPromptTemplate 有五个核心参数:
python
from langchain_core.prompts import PromptTemplate, FewShotPromptTemplate
example_prompt = PromptTemplate.from_template("单词:{word}\n反义词:{antonym}")
examples = [
{"word": "大", "antonym": "小"},
{"word": "上", "antonym": "下"},
]
few_shot = FewShotPromptTemplate(
example_prompt=example_prompt, # 示例长什么样
examples=examples, # 示例数据:list 套 dict
prefix="告知我单词的反义词,我提供如下示例:",
suffix="请基于上面的示例,告诉我{input_word}的反义词是?",
input_variables=["input_word"], # 声明后缀里需要注入的变量
)
print(few_shot.invoke({"input_word": "左"}).to_string())
它的精妙之处在于:示例模板只写一次,想加更多例子只需往 examples 列表里追加字典,不用改模板本身。
3.4 ChatPromptTemplate:聊天提示词模板与历史注入
聊天是多轮的,PromptTemplate.from_template 只能接一条文本,不够用。ChatPromptTemplate.from_messages 接收一个消息列表:
python
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
chat_prompt = ChatPromptTemplate.from_messages([
("system", "你是一个乐于助人的助手"),
MessagesPlaceholder("history"), # 历史消息占位符
("human", "请回答:{input}"),
])
history_data = [
("human", "写一首唐诗"),
("ai", "床前明月光,疑是地上霜。"),
("human", "好诗,再来一首"),
]
pv = chat_prompt.invoke({"history": history_data, "input": "你好"})
print(pv.to_string())
关键点:
-
MessagesPlaceholder("history")是一个类对象,占住一个位置,运行时把一整段消息列表塞进来。 -
必须用
invoke,不能用format------因为format返回纯字符串,不支持消息占位符。 -
元组形式的
("human", ...)在运行时会被自动转换成对应 Message 对象。
三类模板对比:
| 模板类 | 适用场景 | 输入 | 特色 |
|---|---|---|---|
PromptTemplate |
单条文本、零样本 | dict | 最简单,format 出纯字符串 |
FewShotPromptTemplate |
需要示例引导 | dict | 五参数,examples 可无限追加 |
ChatPromptTemplate |
多轮对话 | dict | from_messages + MessagesPlaceholder 注入历史 |
四、知识块三:LCEL 链式表达式------Runnable、管道符与各种解析器
这一块是整个模块的"灵魂",也是 RAG 项目和 Agent 的写法基础。
4.1 链是什么:上一个的输出当下一个的输入
链(Chain) 就是把多个组件用 |(竖线,Python 的"或运算符")串起来,前一个组件的输出自动成为后一个组件的输入。要求只有一个:每个组件都得是 Runnable 的子类对象。
python
chain = chat_prompt | model | parser
chain.invoke({"history": history_data, "input": "..."}).content
4.2 或运算符的底层:or 魔法方法(扩展集)
为什么 | 能串东西?课程用一集扩展讲透:Python 里 A | B 本质是调用 A.__or__(B)。LangChain 在 Runnable 接口里重写了 __or__,它只做一件事------return RunnableSequence(self, other),把两个组件包进一个 RunnableSequence。而 RunnableSequence 本身又是 Runnable 子类,所以你可以无限 | 下去:
python
prompt | model1 | parser | model2 | parser ...
不管串多少个,结果永远是 RunnableSequence,永远能调 invoke(阻塞式)和 stream(流式)。这就是 LCEL 的语法地基。
4.3 Runnable 协议
课程点进源码验证:PromptTemplate、ChatModel、OutputParser......这些类沿着继承链往上找,最终都继承自 Runnable。Runnable 规定了统一的 invoke / stream / batch 方法,并且实现了 __or__。所以你只要记住一句话:"能不能进链,看它是不是 Runnable 子类"。
调用链时还要搞清楚一件事:你 chain.invoke(...) 传进去的那份原始输入,只会喂给整条链最左边的第一个组件 ,然后逐级向后流转。比如 chain = chat_prompt | model | parser,你传的字典 {"history": ..., "input": ...} 不是给模型的,而是先给提示词模板去填占位符;模板吐出 PromptValue 才给模型;模型吐 AIMessage 才给 parser。理解了"输入进头、逐级向后",后面排查类型不匹配的报错时,你就能准确知道问题出在哪两个组件之间。
4.4 StrOutputParser:把 AIMessage 转成字符串
链里第一个坑就来了。假设你想"第一次模型起名 → 把结果再喂给第二次模型":
python
chain = prompt | model | model # 直接这样写会报错!
为什么报错?因为聊天模型 model.invoke() 的输出是 AIMessage 对象 ,而模型要求的输入是 PromptValue / 字符串 / Message 列表 。AIMessage 不符合,于是抛 ValueError: input type ...。
解决办法是在中间插一个 StrOutputParser,它专门把 AIMessage 拆成纯字符串:
python
from langchain_core.output_parsers import StrOutputParser
parser = StrOutputParser()
chain = prompt | model | parser | model | parser
插在链尾的 parser 还有个好处:最终返回值从 AIMessage 变成了 str,你就不用再写 .content 了。
4.5 JsonOutputParser:把 AIMessage 转成字典
更标准的多模型链不是"把 A 的回答直接塞给 B",而是"A 输出结构化数据 → 组装成新提示词 → 再问 B"。这时需要 JsonOutputParser,它把 AIMessage 转成 dict:
python
from langchain_core.output_parsers import JsonOutputParser
json_parser = JsonOutputParser()
chain = (
first_prompt # 要求模型严格按 JSON 返回:{"name": "xxx"}
| model
| json_parser # AIMessage → dict,例如 {"name": "张若曦"}
| second_prompt # second_prompt 里有 {name} 占位符,dict 正好喂进去
| model
| StrOutputParser()
)
关键坑: 用 JsonOutputParser 时,必须在提示词里明确要求模型"严格输出 JSON、key 为 name"。否则模型只回一个名字"张若曦",解析器拿到纯文本想转 dict 会直接报错。这印证了一个原则:链能不能跑通,取决于前后组件的输入输出类型是否兼容。
课程整理的组件 I/O 契约表(务必背下来):
| 组件 | 输入类型 | 输出类型 |
|---|---|---|
PromptTemplate / ChatPromptTemplate |
dict(占位符变量) |
PromptValue |
聊天模型 ChatModel |
PromptValue / str / Message 列表 |
AIMessage |
StrOutputParser |
AIMessage |
str |
JsonOutputParser |
AIMessage |
dict |
RunnableLambda 包装的函数 |
你自己定义 | 你自己返回 |
4.6 RunnableLambda:把自定义函数加进链
除了现成解析器,你还能把任意函数塞进链,做自定义数据转换:
python
from langchain_core.runnables import RunnableLambda
def to_name_dict(a_msg):
# 入参是上游传来的 AIMessage
return {"name": a_msg.content} # 返回 dict,供下一个提示词模板注入
my_func = RunnableLambda(to_name_dict)
chain = first_prompt | model | my_func | second_prompt | model | StrOutputParser()
甚至可以直接在链里写 lambda------LCEL 的 __or__ 兼容 callable,运行时会自动帮你包成 RunnableLambda:
python
chain = first_prompt | model | (lambda m: {"name": m.content}) | second_prompt | model
课程建议显式写成 RunnableLambda,可读性更好。
五、知识块四:Memory 记忆、文档加载与分割、向量存储与 RAG 链路
5.1 Memory:会话记忆(临时 vs 长期)
裸调聊天模型是"无状态"的------你不把历史消息手动塞进 messages 列表,模型就不记得上文。LangChain 提供 RunnableWithMessageHistory 给链自动附加历史。
临时记忆(RAG开发-21): 用内存存储,程序一关就没。
python
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.chat_history import InMemoryChatMessageHistory
store = {}
def get_history(session_id: str):
if session_id not in store:
store[session_id] = InMemoryChatMessageHistory()
return store[session_id]
base_chain = chat_prompt | model | StrOutputParser()
conversational = RunnableWithMessageHistory(
base_chain,
get_history,
input_message_key="input", # 用户提问在模板里的占位 key
history_message_key="chat_history", # 历史在模板里的占位 key
)
config = {"configurable": {"session_id": "user001"}}
conversational.invoke({"input": "小明有两只猫"}, config=config)
conversational.invoke({"input": "小刚有一只狗"}, config=config)
print(conversational.invoke({"input": "总共有几只宠物?"}, config=config)) # 三只
要点:① 多用户靠 session_id 区分,不同用户各自有独立历史;② 配置项 {"configurable": {"session_id": ...}} 是固定写法,漏了会报 Missing key session_id;③ 提示词模板里用 MessagesPlaceholder("chat_history") 接住历史更标准。
长期记忆(RAG开发-22): 内存存不住,就自己实现一个基于 JSON 文件的历史类,继承 BaseChatMessageHistory,实现三个方法:
python
import os, json
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.messages import message_to_dict, messages_from_dict
class FileChatMessageHistory(BaseChatMessageHistory):
def __init__(self, session_id, storage_path):
self.session_id = session_id
self.file_path = os.path.join(storage_path, f"{session_id}.json")
os.makedirs(os.path.dirname(self.file_path), exist_ok=True)
def add_messages(self, messages):
all_messages = list(self.messages) + list(messages) # 新老消息合并
data = [message_to_dict(m) for m in all_messages] # 消息对象 → dict
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
@property
def messages(self):
try:
with open(self.file_path, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
return []
return messages_from_dict(data) # dict → 消息对象列表
def clear(self):
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump([], f)
然后把 get_history 改成 return FileChatMessageHistory(session_id, "./chat_history") 即可。踩坑: messages 必须加 @property 装饰器;导入时别把 message_to_dict(单数)和 messages_to_dict(复数)搞混。这样程序重启后历史依然在。
| 记忆类型 | 存储类 | 生命周期 | 适用 |
|---|---|---|---|
| 临时会话 | InMemoryChatMessageHistory |
程序退出即丢 | 调试、单用户演示 |
| 长期会话 | 自实现 FileChatMessageHistory(或 Redis 等) |
落盘持久化 | 生产环境多用户 |
5.2 文档加载器:把异构文件统一成 Document
无论什么文件,加载后都变成统一的 Document 对象,它只有两部分:page_content(正文字符串)和 metadata(来源、页码等字典)。所有加载器都继承 BaseLoader,统一提供 load()(一次性批量加载)和 lazy_load()(惰性迭代器,省内存)。
| 加载器 | import 路径 | 关键参数 | 特点 |
|---|---|---|---|
CSVLoader |
langchain_community.document_loaders |
file_path, encoding, csv_args, source_column |
每行一个 Document |
JSONLoader |
同上 | file_path, jq_schema, text_content, json_lines |
依赖 pip install jq |
TextLoader |
同上 | file_path, encoding |
整个文件只返回一个 Document |
PyPDFLoader |
同上 | file_path, mode="page"/"single", password |
依赖 pip install pypdf |
python
from langchain_community.document_loaders import CSVLoader, TextLoader, PyPDFLoader
csv_loader = CSVLoader("data/stu.csv", encoding="utf-8") # Windows 必须指定 utf-8,否则默认 GBK 报错
docs = csv_loader.load() # list[Document]
pdf_loader = PyPDFLoader("data/pdf1.pdf", mode="page") # 每页一个 Document
for doc in pdf_loader.lazy_load():
print(doc.page_content, doc.metadata)
JSONLoader 用 jq 语法抽取:.name 取对象字段,.hobby[1] 取数组元素,.[].name 取数组里每个对象的 name;抽出来是 dict 时要设 text_content=False;每行一个独立 JSON 的文件要设 json_lines=True。
5.3 文档分割器:RecursiveCharacterTextSplitter
TextLoader 把整个 txt 塞成一个 Document,太大、检索不精准。需要用递归字符文本分割器切成小块(chunk):
python
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
docs = TextLoader("data/python基础语法.txt", encoding="utf-8").load()
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 每个 chunk 最大字符数
chunk_overlap=50, # 相邻 chunk 重叠字符数,保证语义连贯
separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", ",", " ", ""],
length_function=len,
)
split_docs = splitter.split_documents(docs) # 一个 Document → 几十个小 Document
chunk_overlap 的意义:让相邻两段保留一点重叠文字,避免一句话被从中间切断导致上下文断裂。课程用一个直观例子说明为什么必须分割:如果整篇 Python 语法笔记只存成一个 Document,那你无论搜什么,命中的都是这同一份巨大文档,检索既不精准、塞进提示词又会超长;切成几十个小 chunk 后,搜"逻辑运算符"就能精准命中只讲这个知识点的那一小段,喂给模型的参考资料又短又准。这正是"分块越细、匹配越准,但太细会丢上下文"的 trade-off,也是后面项目里要反复调参的地方。
5.4 VectorStores 向量存储
向量存储统一提供三个 API:add_documents(增)、delete(删)、similarity_search(相似度检索)。
python
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_community.embeddings import DashScopeEmbeddings
embed = DashScopeEmbeddings(model="text-embedding-v4")
vs = InMemoryVectorStore(embedding=embed)
vs.add_documents(documents=docs, ids=[f"id{i}" for i in range(1, len(docs) + 1)])
vs.delete(ids=["id1", "id2"])
results = vs.similarity_search(query="Python是不是简单易学", k=3) # 按相关度从高到低返回
InMemoryVectorStore 是内存版,程序一关就没。生产用 Chroma(底层 SQLite 文件数据库,持久化):
python
from langchain_chroma import Chroma
vs = Chroma(
collection_name="test", # 相当于表名
embedding_function=DashScopeEmbeddings(model="text-embedding-v4"),
persist_directory="./chroma_db", # 数据落盘目录
)
它还支持元数据过滤:similarity_search("怎么减肥", k=3, filter={"source": "黑马程序员"})------哪怕某条语义最像,只要来源不匹配就会被过滤掉。
5.5 最简 RAG 在线链路(手工版)
先手动把流程跑一遍(RAG开发-28):
python
vs.add_texts(["减肥要少吃高热量食物", "减肥期间要注意均衡营养", "学Python要注意休息"])
input_text = "怎么减肥"
hits = vs.similarity_search(input_text, k=2)
context = "\n".join(doc.page_content for doc in hits)
rag_prompt = ChatPromptTemplate.from_messages([
("system", "请依据以下参考资料回答:\n{context}"),
("human", "{input}"),
])
chain = rag_prompt | ChatTongyi(model="qwen3-max") | StrOutputParser()
print(chain.invoke({"context": context, "input": input_text}))
5.6 RunnablePassthrough:把检索也编进链(最终形态)
上面是"先手动检索、再手动拼字典"。能不能让向量检索本身也进链?问题在于:
-
检索器
retriever的输入是字符串 (用户问题),输出是list[Document]。 -
提示词模板要的输入是字典 (同时包含
input问题和context资料)。
类型对不上,而且问题一旦喂给检索器就会被"丢掉"。解法是 as_retriever() 把向量库变成 Runnable,再用 RunnablePassthrough 做"输入一分为二":
python
from langchain_core.runnables import RunnablePassthrough
retriever = vs.as_retriever(search_kwargs={"k": 2})
def format_docs(docs):
if not docs:
return "无相关参考资料"
return "\n".join(doc.page_content for doc in docs)
rag_chain = (
{
"input": RunnablePassthrough(), # 原样接住用户问题
"context": retriever | RunnableLambda(format_docs), # 同一问题去检索,转字符串
}
| rag_prompt
| ChatTongyi(model="qwen3-max")
| StrOutputParser()
)
print(rag_chain.invoke("怎么减肥")) # 注意:这里直接传字符串,不再传字典
执行逻辑:用户问题 "怎么减肥" 作为整条链的入口,被同时 分两路------一路经 RunnablePassthrough 原样成为字典里的 input;另一路进 retriever 检索、经 format_docs 拼成参考资料字符串成为 context。两路汇成 {"input": ..., "context": ...} 字典,正好喂给提示词模板,后面就是模型 + 解析器。这就是标准 LCEL RAG 链。
这里还有个容易忽略的细节:链最左边那个字典本身也能进链 。课程点源码说明,Runnable 的 __or__ 除了接受另一个 Runnable、还兼容 callable(函数)和 mapping(字典)。字典里每个 key 对应的 value 可以是一个子链------这就是上面 {"input": RunnablePassthrough(), "context": retriever | ...} 能成立的语法依据。RunnablePassthrough() 的作用则像一个"节流阀/占位符":它把链入口传进来的原始输入原封不动地接住,再放进字典的对应位置。没有它,用户问题只会被检索器吃掉、没法同时作为 input 传给提示词。理解了这个"字典即组件、RunnablePassthrough 即原样透传",你就真正掌握了 LCEL 编排的精髓。
RAG 全链路流程清单
-
离线索引: 加载器读文件 →
Document(page_content + metadata)。 -
文本分割
RecursiveCharacterTextSplitter→ 切成 chunk。 -
嵌入模型把每个 chunk 转向量。
-
向量 + 原文 + metadata 存入向量库(Chroma 持久化)。
-
在线检索: 用户问题经同一嵌入模型转向量。
-
向量库做相似度搜索(余弦),取 top-k。
-
RunnablePassthrough把问题与检索到的资料拼成提示词字典。 -
提示词 → 聊天模型 →
StrOutputParser→ 返回答案。
六、踩坑与环境注意事项汇总
-
import 路径变动: 新版 LangChain 1.x 里,模型在
langchain_community,核心抽象在langchain_core,Ollama 在langchain_ollama,Chroma 在langchain_chroma。老教程的from langchain.xxx import ...容易报错,以本文章表格为准。 -
Windows 编码:
CSVLoader/TextLoader必须显式传encoding="utf-8",否则按 GBK 读中文文件会解码失败。 -
流式与 parser 配合: 聊天模型流式
chunk是AIMessage,要取chunk.content;链尾加StrOutputParser()可省去手动.content。 -
JsonOutputParser 前置条件: 提示词里必须强制模型输出严格 JSON,否则解析纯文本会报错。
-
链中类型兼容: 每两个组件之间,上游输出类型必须等于下游输入类型(见 4.5 契约表),不对就插 parser 或
RunnableLambda转换。 -
Memory 与无状态链区别: 裸链是无状态的,每次调用互不记得;包上
RunnableWithMessageHistory才有记忆,且必须配session_id。 -
embedding 模型选择:
text-embedding-v1默认 1536 维,够用;text-embedding-v4效果更好、免费额度更足;本地可用qwen3-embedding:4b。 -
向量库选型: 调试用
InMemoryVectorStore,生产用Chroma(文件持久化);数据量大再考虑 Milvus / FAISS 等。 -
分块参数:
chunk_size太大检索不精准、太小丢上下文;chunk_overlap一般设为chunk_size的 10%~20%,保证语义连贯。 -
formatvsinvoke: 需要MessagesPlaceholder/ 进链时只能用invoke,format只返回纯字符串。
动手练习与调试技巧清单
课程反复强调"代码要手抄一遍再跑一遍",这里给出一条可照做的练习路径:
-
先打通模型调用: 分别用
Tongyi、ChatTongyi、DashScopeEmbeddings各写一个最小 demo,确认 API Key 与网络正常;再把其中一个换成ChatOllama,体会"只换类、业务不动"的统一接口好处。 -
打印中间产物: 在链里插一个"打印提示词"的旁路函数------它接收上游的
PromptValue,print(prompt.to_string())之后再原封不动return prompt。这样模型到底收到了什么,一眼就能看到,是排查 RAG 检索质量的利器。 -
对照类型调试: 链报错时,顺着 4.5 的 I/O 契约表逐段检查:上游吐什么、下游要什么,类型对不上就插 parser 或
RunnableLambda。 -
先手工后 LCEL: 写 RAG 时先按 5.5 的手工版把"检索→拼字典→调用"跑通,再按 5.6 用
RunnablePassthrough改写成纯链形态,体会两种写法等价、但后者更工程化。 -
持久化验证: 用 Chroma 跑一次入库后,把入库代码注释掉、只留检索,重启程序再查一次,确认数据确实落盘没丢。
-
记忆验证: 临时记忆跑完三轮后重启程序再问"总共有几只宠物",会发现模型一脸懵------这正是要上长期记忆的动机,亲手体会一次比读十遍都深刻。
七、与前后模块的衔接
-
承接提示词工程: 前面手写的 system/human/ai 角色、零样本/少样本思想,在本模块被抽象成
PromptTemplate、FewShotPromptTemplate、ChatPromptTemplate。 -
下接 RAG 项目实战: 本模块搭的是"最小可运行 RAG",下一阶段项目会把它工程化:文本上传 Web 服务、md5 去重、知识库离线更新、在线向量存储服务、rag 核心服务、历史会话持久化。本模块的每一个类都会在项目里复用。
-
再往后接 Agent: Runnable / LCEL / Memory 这套机制,正是后面 Agent(ReAct 行动框架、middleware)的编排基础------Agent 本质上也是一条带工具调用的 Runnable 链。
八、面试与实战常考点
-
为什么需要 RAG? 答:解决大模型知识截止、幻觉、私域数据安全三大问题;不微调、外挂知识库即可动态更新。
-
RAG 的完整流程? 答:离线索引(加载→分割→嵌入→入库)+ 在线检索(问题向量化→相似度搜索→拼提示词→生成)。
-
余弦相似度公式与含义? 答:
A·B/(|A||B|),衡量方向一致性,取值 -1 到 1,越大越相似。 -
LCEL 的
|底层是什么? 答:Python__or__魔法方法,返回RunnableSequence,后者仍是 Runnable,可无限串接。 -
StrOutputParser 和 JsonOutputParser 的区别? 答:前者把 AIMessage 转 str,后者转 dict;多模型链里用来衔接类型不兼容的组件。
-
RunnablePassthrough 在 RAG 里干嘛? 答:把用户问题一分为二,一路原样作为
input,一路送去检索作为context,解决"检索器吃字符串、提示词要字典"的类型不匹配。 -
临时记忆和长期记忆区别? 答:
InMemoryChatMessageHistory内存存储、重启即丢;自实现文件历史(或 Redis)落盘持久化、跨重启保留。 -
文档分块 chunk_size / overlap 怎么设? 答:chunk_size 平衡精度与上下文,overlap 取 10%~20% 防止语义切断。
-
Windows 下读 CSV/TXT 报错? 答:默认 GBK,要显式
encoding="utf-8"。 -
为什么用模板类而不手拼字符串? 答:模板类是 Runnable 子类,能进链做工程化编排;字符串不能。
-
as_retriever()是干嘛的? 答:向量库本身不是 Runnable、不能进链;调它返回一个VectorStoreRetriever(是 Runnable 子类),吃字符串问题、吐list[Document],从而能编进 LCEL 链。 -
similarity_search的k参数控制什么? 答:按余弦相似度从高到低返回的文档条数;k太小可能漏资料,太大则把不相关内容也塞进提示词、稀释重点。 -
元数据过滤
filter有什么用? 答:在语义检索之外再按来源、部门、时间等 metadata 做硬过滤,实现"既要语义相关、又必须来自指定范围"的精准召回。 -
离线索引和在线检索为什么要用同一个嵌入模型? 答:只有同一个嵌入模型,才能保证"入库时的向量空间"和"查询时的向量空间"一致,余弦比较才有意义;两边模型一旦不一致,检索结果会完全错乱。
学完本模块,你应该能合上书,从
pip install开始,独立写出一条"文档 → 分割 → 嵌入 → Chroma 入库 → 检索 → RunnablePassthrough 拼提示词 → ChatTongyi 生成"的完整 RAG 链。这条链,就是后面整个 RAG 项目实战与 Agent 开发的最小内核。
回头看这 29 集,其实只讲清了一件事:LangChain 用一套统一的 Runnable 接口,把"模型、提示词、解析器、记忆、文档、向量库"这些原本割裂的零件,用 | 像搭积木一样拼成了一条数据自动流转的流水线。 掌握了"每个组件输入什么、输出什么、类型怎么衔接"这一条主线,无论后面接 RAG 项目还是 Agent,都只是往这条流水线上加新零件而已。建议把本文章节四的 I/O 契约表和章节五的 RAG 流程清单抄到便签上,写代码时随时对照------这比死记 API 名字有效得多,也能让你在后续项目与面试中举一反三、稳扎稳打。