RAG开发学习总结:从 LangChain 入门到检索增强生成链路的全栈实战-4/6

**摘要:**本文系统梳理了黑马程序员《大模型 RAG 与 Agent 智能体项目实战教程》中体量最大的 LangChain 模块(共 29 集)。内容从环境部署与三类模型(LLM、ChatModel、Embeddings)的统一调用讲起,逐步深入到提示词模板体系(PromptTemplate、FewShotPromptTemplate、ChatPromptTemplate)、LCEL 链式表达式(Runnable、管道符、各类解析器、RunnableLambda),再到会话记忆、文档加载与分割、向量存储,最终拼装出一条可运行的 RAG 在线问答链路。文章还整理了踩坑清单、动手练习路径、与前后模块的衔接关系以及面试常考点,帮助读者从零搭建并理解 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)。可以说,本模块学的每一个类,后面项目里都会原样再用一遍。

学习目标可以拆成四句话:

  1. 能装、能跑、能排错 LangChain 1.x 开发环境,分清 langchain / langchain-community / langchain-core / langchain-ollama / langchain-chroma 各自管什么。

  2. 会用统一接口调用三类模型 :大语言模型(LLM)、聊天模型(ChatModel)、嵌入模型(Embeddings),并区分 invoke(阻塞)与 stream(流式)。

  3. 吃透 LCEL(LangChain Expression Language) :用管道符 | 把 Runnable 组件串成链,理解每一段"输入什么类型、输出什么类型"。

  4. 独立搭出一条最简 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"。这是面试必问,也是理解后面一切设计的动机:

  1. 知识非实时 / 知识滞后: 大模型的训练数据有截止日期,不知道今天发生的事,更不知道你公司内部的文档。

  2. 幻觉(Hallucination): 模型会一本正经地编造不存在的事实。

  3. 私域知识与数据安全: 企业数据不能随便拿去微调或公开,但又想让模型基于这些数据回答。

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 拼字符串?课程给了两个核心理由:

  1. 工程标准化: 变量多了以后,模板可复用、可维护。

  2. 能进链: 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 全链路流程清单

  1. 离线索引: 加载器读文件 → Document(page_content + metadata)。

  2. 文本分割 RecursiveCharacterTextSplitter → 切成 chunk。

  3. 嵌入模型把每个 chunk 转向量。

  4. 向量 + 原文 + metadata 存入向量库(Chroma 持久化)。

  5. 在线检索: 用户问题经同一嵌入模型转向量。

  6. 向量库做相似度搜索(余弦),取 top-k。

  7. RunnablePassthrough 把问题与检索到的资料拼成提示词字典。

  8. 提示词 → 聊天模型 → StrOutputParser → 返回答案。


六、踩坑与环境注意事项汇总

  1. import 路径变动: 新版 LangChain 1.x 里,模型在 langchain_community,核心抽象在 langchain_core,Ollama 在 langchain_ollama,Chroma 在 langchain_chroma。老教程的 from langchain.xxx import ... 容易报错,以本文章表格为准。

  2. Windows 编码: CSVLoader / TextLoader 必须显式传 encoding="utf-8",否则按 GBK 读中文文件会解码失败。

  3. 流式与 parser 配合: 聊天模型流式 chunk 是 AIMessage,要取 chunk.content;链尾加 StrOutputParser() 可省去手动 .content。

  4. JsonOutputParser 前置条件: 提示词里必须强制模型输出严格 JSON,否则解析纯文本会报错。

  5. 链中类型兼容: 每两个组件之间,上游输出类型必须等于下游输入类型(见 4.5 契约表),不对就插 parser 或 RunnableLambda 转换。

  6. Memory 与无状态链区别: 裸链是无状态的,每次调用互不记得;包上 RunnableWithMessageHistory 才有记忆,且必须配 session_id。

  7. embedding 模型选择: text-embedding-v1 默认 1536 维,够用;text-embedding-v4 效果更好、免费额度更足;本地可用 qwen3-embedding:4b。

  8. 向量库选型: 调试用 InMemoryVectorStore,生产用 Chroma(文件持久化);数据量大再考虑 Milvus / FAISS 等。

  9. 分块参数: chunk_size 太大检索不精准、太小丢上下文;chunk_overlap 一般设为 chunk_size 的 10%~20%,保证语义连贯。

  10. format vs invoke : 需要 MessagesPlaceholder / 进链时只能用 invoke,format 只返回纯字符串。

动手练习与调试技巧清单

课程反复强调"代码要手抄一遍再跑一遍",这里给出一条可照做的练习路径:

  1. 先打通模型调用: 分别用 Tongyi、ChatTongyi、DashScopeEmbeddings 各写一个最小 demo,确认 API Key 与网络正常;再把其中一个换成 ChatOllama,体会"只换类、业务不动"的统一接口好处。

  2. 打印中间产物: 在链里插一个"打印提示词"的旁路函数------它接收上游的 PromptValue,print(prompt.to_string()) 之后再原封不动 return prompt。这样模型到底收到了什么,一眼就能看到,是排查 RAG 检索质量的利器。

  3. 对照类型调试: 链报错时,顺着 4.5 的 I/O 契约表逐段检查:上游吐什么、下游要什么,类型对不上就插 parser 或 RunnableLambda。

  4. 先手工后 LCEL: 写 RAG 时先按 5.5 的手工版把"检索→拼字典→调用"跑通,再按 5.6 用 RunnablePassthrough 改写成纯链形态,体会两种写法等价、但后者更工程化。

  5. 持久化验证: 用 Chroma 跑一次入库后,把入库代码注释掉、只留检索,重启程序再查一次,确认数据确实落盘没丢。

  6. 记忆验证: 临时记忆跑完三轮后重启程序再问"总共有几只宠物",会发现模型一脸懵------这正是要上长期记忆的动机,亲手体会一次比读十遍都深刻。


七、与前后模块的衔接

  • 承接提示词工程: 前面手写的 system/human/ai 角色、零样本/少样本思想,在本模块被抽象成 PromptTemplate、FewShotPromptTemplate、ChatPromptTemplate。

  • 下接 RAG 项目实战: 本模块搭的是"最小可运行 RAG",下一阶段项目会把它工程化:文本上传 Web 服务、md5 去重、知识库离线更新、在线向量存储服务、rag 核心服务、历史会话持久化。本模块的每一个类都会在项目里复用。

  • 再往后接 Agent: Runnable / LCEL / Memory 这套机制,正是后面 Agent(ReAct 行动框架、middleware)的编排基础------Agent 本质上也是一条带工具调用的 Runnable 链。


八、面试与实战常考点

  1. 为什么需要 RAG? 答:解决大模型知识截止、幻觉、私域数据安全三大问题;不微调、外挂知识库即可动态更新。

  2. RAG 的完整流程? 答:离线索引(加载→分割→嵌入→入库)+ 在线检索(问题向量化→相似度搜索→拼提示词→生成)。

  3. 余弦相似度公式与含义? 答:A·B/(|A||B|),衡量方向一致性,取值 -1 到 1,越大越相似。

  4. LCEL 的 | 底层是什么? 答:Python __or__ 魔法方法,返回 RunnableSequence,后者仍是 Runnable,可无限串接。

  5. StrOutputParser 和 JsonOutputParser 的区别? 答:前者把 AIMessage 转 str,后者转 dict;多模型链里用来衔接类型不兼容的组件。

  6. RunnablePassthrough 在 RAG 里干嘛? 答:把用户问题一分为二,一路原样作为 input,一路送去检索作为 context,解决"检索器吃字符串、提示词要字典"的类型不匹配。

  7. 临时记忆和长期记忆区别? 答:InMemoryChatMessageHistory 内存存储、重启即丢;自实现文件历史(或 Redis)落盘持久化、跨重启保留。

  8. 文档分块 chunk_size / overlap 怎么设? 答:chunk_size 平衡精度与上下文,overlap 取 10%~20% 防止语义切断。

  9. Windows 下读 CSV/TXT 报错? 答:默认 GBK,要显式 encoding="utf-8"。

  10. 为什么用模板类而不手拼字符串? 答:模板类是 Runnable 子类,能进链做工程化编排;字符串不能。

  11. as_retriever() 是干嘛的? 答:向量库本身不是 Runnable、不能进链;调它返回一个 VectorStoreRetriever(是 Runnable 子类),吃字符串问题、吐 list[Document],从而能编进 LCEL 链。

  12. similarity_search 的 k 参数控制什么? 答:按余弦相似度从高到低返回的文档条数;k 太小可能漏资料,太大则把不相关内容也塞进提示词、稀释重点。

  13. 元数据过滤 filter 有什么用? 答:在语义检索之外再按来源、部门、时间等 metadata 做硬过滤,实现"既要语义相关、又必须来自指定范围"的精准召回。

  14. 离线索引和在线检索为什么要用同一个嵌入模型? 答:只有同一个嵌入模型,才能保证"入库时的向量空间"和"查询时的向量空间"一致,余弦比较才有意义;两边模型一旦不一致,检索结果会完全错乱。


学完本模块,你应该能合上书,从 pip install 开始,独立写出一条"文档 → 分割 → 嵌入 → Chroma 入库 → 检索 → RunnablePassthrough 拼提示词 → ChatTongyi 生成"的完整 RAG 链。这条链,就是后面整个 RAG 项目实战与 Agent 开发的最小内核。

回头看这 29 集,其实只讲清了一件事:LangChain 用一套统一的 Runnable 接口,把"模型、提示词、解析器、记忆、文档、向量库"这些原本割裂的零件,用 | 像搭积木一样拼成了一条数据自动流转的流水线。 掌握了"每个组件输入什么、输出什么、类型怎么衔接"这一条主线,无论后面接 RAG 项目还是 Agent,都只是往这条流水线上加新零件而已。建议把本文章节四的 I/O 契约表和章节五的 RAG 流程清单抄到便签上,写代码时随时对照------这比死记 API 名字有效得多,也能让你在后续项目与面试中举一反三、稳扎稳打。

相关推荐
LHR_friendship1 小时前
HCIP课程全部内容
网络·笔记·学习·hcip·华为ensp
CAE虚拟与现实1 小时前
MLP多层感知机(Multilayer Perceptron)
人工智能·机器学习·mlp
Chengbei111 小时前
拒绝无效洞!蚂蚁Src榜一Clown 2.0 SRC SKill 第二代带状态闭环的白盒 & 黑盒审计 Skill
网络·人工智能·安全·web安全·网络安全·自动化·安全架构
海绵宝宝转agent1 小时前
Pico 学习笔记 Harness 设计、历史压缩、三类目录与缓存复用
笔记·学习·缓存
胡家伟++2 小时前
我用 48 轮 AI 思维链做了一次研究战略推演:从“验证信号危机“到“验证栈“统一框架
人工智能·算法
红红谈说2 小时前
图库描述怎么批量生成?正文配图匹配的一次工程复盘
人工智能·相似度匹配·并发控制·图片描述·图库管理
DongQiShanRen2 小时前
裁决台账双向互校(上):名册与实物的第一道对账
java·linux·运维·数据库·人工智能·自然语言处理·数据挖掘
156082072192 小时前
使用JFMRFVU3P多通道同步相位测试
网络·人工智能
归秋1422 小时前
2026企业AI办公工具选型指南:从场景匹配到平台评估
大数据·运维·人工智能