LangChain 是一个开源框架,专门用于帮助开发者更轻松、高效地构建基于大型语言模型(LLMs)(如 ChatGPT、Gemini、Claude 等)的应用程序。
如果把大语言模型比作一个非常聪明但被关在房间里的大脑(它拥有海量的静态知识,但无法获取实时信息,也不能直接操作电脑),那么 LangChain 就是为这个大脑装上"眼睛"、"手脚"和"记忆"的工具箱。
LangChain 的核心组件
LangChain 主要是通过提供一系列标准化的模块来简化开发流程的,它的核心概念包括:
-
模型 (Models): 提供统一的 API 接口来调用各种不同的底层大模型。这意味着如果你想把应用底层的 AI 从 OpenAI 切换到某个开源模型,只需改动极少的代码。
-
提示词 (Prompts): 用于管理、格式化和优化提示词(Prompt Templates),将用户的简单输入自动拼接成能让 AI 给出最佳回答的复杂指令。
-
链 (Chains): 这是 LangChain 的名字由来。它允许你将多个组件"链接"成一个工作流。例如:
接收用户问题 -> 检索相关文档 -> 拼接提示词 -> 发送给 LLM -> 解析最终输出,这整个过程就是一个"Chain"。 -
数据检索 (Retrieval): 解决 LLM 知识停留在训练阶段的问题。LangChain 提供了极其丰富的工具来加载外部数据(PDF、网页、数据库)、切割文本,并结合向量数据库(Vector Databases)实现检索增强生成(RAG)。这让你可以基于自己的私有数据来问答。
-
记忆 (Memory): 原生的 LLM 接口是无状态的(记不住上一句话)。LangChain 提供了记忆组件,让应用能够记住上下文,从而实现连贯的上下文对话。
-
代理 (Agents): 这是最强大的功能之一。它赋予了 AI "使用工具"的能力。你可以给 AI 提供搜索引擎、计算器、API 接口等工具,当用户提出复杂问题时,Agent 会自主思考并决定调用哪些工具来一步步获取答案。
在使用 LangChain 之前,开发者如果想让 AI 结合公司内部的 PDF 文档回答问题,需要自己写大量的代码去处理文件读取、文本切分、向量化、计算相似度、构建 API 等等。而 LangChain 预先封装好了所有这些流程,开发者只需要几行代码就能把这些模块拼装在一起。
常见的应用场景包括:
-
专属知识库问答: 上传公司的规章制度或产品手册,打造一个"百事通"智能客服。
-
智能长文总结: 快速提取长篇 PDF 或网页的核心观点。
-
全能个人助理: 结合 Agents 功能,打造不仅能聊天,还能帮你查天气、写邮件、跑代码的助手。
为了保证学习效果,本教程将基于最新的 LangChain 生态(重点关注 LCEL - LangChain 表达式语言) 以及 Python 语言进行讲解。
第 1 课:环境搭建与 LangChain 架构全貌
1. 环境搭建与依赖安装
首先,确保你的环境中安装了 Python(建议版本 >= 3.9)。
核心包安装
在一个全新的 Python 虚拟环境中,执行以下命令:
# 安装 LangChain 主包
pip install langchain
# 安装 OpenAI 的特定集成包(后续示例我们将使用 OpenAI)
pip install langchain-openai
# 安装环境变量管理工具
pip install python-dotenv
环境变量配置
LangChain 绝大多数组件在底层都需要与外部 API(如大模型 API、数据库 API)进行交互。最标准的做法是将 API 密钥通过环境变量注入,而不是硬编码在代码里。
在你的项目根目录下创建一个名为 .env 的文件,写入:
OPENAI_API_KEY="sk-你的真实API密钥"
# 如果你在国内使用代理,可能还需要配置 BASE_URL
# OPENAI_BASE_URL="你的代理地址/v1"
在 Python 代码中加载这些环境变量:
import os
from dotenv import load_dotenv
# 加载 .env 文件中的环境变量
load_dotenv()
# 验证是否加载成功
print("API Key loaded:", os.environ.get("OPENAI_API_KEY") is not None)
2. 深入理解 LangChain 的模块化架构
早期版本的 LangChain 把所有的功能都塞在一个包里,导致安装极其臃肿。现在的 LangChain 被拆分成了多个层级,了解这些层级对于你后续查找文档和导入类库至关重要。
以下是现代 LangChain 的包结构关系(自底向上):
langchain-core (核心基础层): 这是整个生态的最底层基石。
-
它定义了所有基础的接口抽象 (例如大语言模型应该有什么方法,输出解析器应该长什么样,消息格式
SystemMessage/HumanMessage的定义)。 -
特点: 不包含任何第三方 API 依赖,极其轻量。
langchain-[provider] (官方合作方集成层):
-
专门为特定的大型厂商独立拆分的包。
-
例如
langchain-openai,langchain-anthropic,langchain-google-genai。 -
特点: 如果你要用 OpenAI,就导入这个独立的包。这样做的好处是 OpenAI API 更新时,只有这个包需要更新,不影响其他组件。
langchain-community (社区集成层):
-
包含所有由社区维护的第三方集成库。
-
比如各种小众向量数据库的接入代码、各类文件(PDF、Word)的加载器。
-
特点: 这是一个大杂烩,如果你用的不是主流头部厂商的模型或工具,通常需要从这里导入。
langchain (认知架构层):
-
这是主包,它依赖于
langchain-core。 -
它包含了用于构建具体应用的链(Chains) 、代理(Agents)和检索策略(Retrieval Strategies)的组装逻辑。
导入代码实例对比
了解了架构后,你就明白为什么要这样导入代码了
# 1. 导入基础数据结构(来自 core)
from langchain_core.messages import HumanMessage, SystemMessage
# 2. 导入具体的模型实现(来自独立的 provider 包)
from langchain_openai import ChatOpenAI
# 3. 导入某个社区维护的第三方 PDF 解析器(来自 community)
from langchain_community.document_loaders import PyPDFLoader
# 4. 导入用于组装代理的高级工具(来自主包)
from langchain.agents import create_tool_calling_agent
随堂测试(第 1 课)
请回答以下两道题目:
1. 单选题: 如果你想在你的应用中自定义一个新的数据类型,用于规范输入给大模型的数据格式,你应该去阅读哪一个包的源码以确保你的实现符合 LangChain 的底层标准?
A. langchain
B. langchain-core
C. langchain-community
D. langchain-openai
2. 简答题: 假设你在 GitHub 上看到一段旧代码,里面写着 from langchain.chat_models import ChatOpenAI。根据现代 LangChain 的架构,这段代码在最新的版本中应该被替换成什么导入语句?为什么 LangChain 要做这种架构上的拆分?
第 2 课:模型接口 (LLMs vs. Chat Models)
在 LangChain 中,与大模型交互的接口主要分为两类:LLMs 和 Chat Models。理解它们的区别,是构建现代 AI 应用的基础。
1. 核心概念:Text in vs. Messages in
-
LLMs (纯文本模型):
-
这是早期的模型形态(比如 OpenAI 的
text-davinci-003)。 -
接口逻辑:
纯字符串输入 -> 纯字符串输出。 -
你给它一段未完成的文本,它帮你续写。
-
-
Chat Models (聊天模型):
-
这是目前的主流形态(比如
gpt-3.5-turbo,gpt-4o,claude-3)。 -
它们底层虽然也是 LLM,但经过了专门的指令微调(Instruction Tuning),习惯于以对话的形式进行交互。
-
接口逻辑:
消息列表 (List of Messages) 输入 -> 单条消息 (Message) 输出。
-
注意:在现代应用开发中,我们几乎 99% 的时间都在使用 Chat Models。接下来的教程我们将重点围绕它展开。如果对这个有兴趣可看我上一个博客的关于这个的深入解析。
2. LangChain 的三大基础消息类型 (Message Types)
因为 Chat Models 需要"消息列表"作为输入,LangChain 在 langchain-core 中定义了三种标准的消息类型来统一格式:
-
SystemMessage(系统消息): 用于给 AI 设定人设、背景或全局指令。通常放在消息列表的第一位。 -
HumanMessage(人类消息): 代表用户的输入或提问。 -
AIMessage(AI 消息): 代表大模型返回的回答。我们在记录历史对话时会用到它。
3. 代码实例:调用 Chat Model
我们来看一段完整的、标准的调用 OpenAI 聊天模型的代码:
python
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
# 1. 加载环境变量 (确保你的 .env 文件中有 OPENAI_API_KEY)
load_dotenv()
# 2. 实例化 Chat Model
# temperature 控制随机性 (0: 最严谨/固定, 1: 最具创造性/随机)
chat_model = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.7)
# 3. 构建消息列表
messages = [
SystemMessage(content="你是一个资深的 Python 程序员。请用严谨、简洁的代码风格回答问题。"),
HumanMessage(content="请写一个计算斐波那契数列的 Python 函数。")
]
# 4. 调用模型 (使用 invoke 方法)
response = chat_model.invoke(messages)
# 5. 查看输出结果
print(type(response))
# 输出: <class 'langchain_core.messages.ai.AIMessage'>
print(response.content)
# 输出生成的代码文本
4. 底层逻辑分析:LangChain 替你做了什么?
你可能会问:"我直接用 OpenAI 的官方 Python SDK 也能发消息,为什么要用 LangChain 包一层?"
答案是:标准化与解耦。
如果你用 OpenAI 的官方 SDK,你需要构建这样的字典: {"role": "system", "content": "..."}。 如果你明天老板要求换成 Anthropic 的 Claude 模型,或者国内的文心一言,它们的 API 数据结构、字段名(比如不叫 role,叫 message_type)完全不同,你的代码必须重写。
在 LangChain 中,SystemMessage 和 HumanMessage 是抽象层 。 当调用 chat_model.invoke(messages) 时:
-
LangChain 识别到当前的底层实现是
ChatOpenAI。 -
它在底层自动将
SystemMessage翻译成了 OpenAI 认识的{"role": "system", ...}格式并发送 HTTP 请求。 -
收到 OpenAI 的原始 JSON 响应后,LangChain 提取其中的内容,并将其封装成标准的
AIMessage对象返回给你。
这意味着,如果要把上面的代码切换到 Claude 模型,你只需要改动第2步 (将 ChatOpenAI 换成 ChatAnthropic),其他构建 Message 和处理返回结果的代码一行都不用改!
随堂测试(第 2 课)
我们使用 response = chat_model.invoke(messages) 调用模型后,response 变量直接就是一个字符串格式的回答吗?如果不是,它是什么类型,我们应该如何获取真正的文本回答?
不是纯字符串,而是包含了各种格式和元数据(比如消耗了多少 Token、模型 ID 是什么)。在 LangChain 中,这个返回值的严格类型是 AIMessage 对象。不过我们不需要手动写代码去"清洗"它,只需要通过 response.content 就可以直接提取出纯文本的回答了。
第 3 课:提示词模板 (Prompt Templates)
在上一课中,我们将用户的输入和系统指令硬编码(Hardcode)在了一起。但在实际开发中,系统指令通常是固定的,而用户的输入是动态变化的。
如果只用 Python 自带的字符串拼接(比如 f-string),当应用变复杂时,代码会变得极难维护。LangChain 提供了 Prompt Templates(提示词模板) 来优雅地解决这个问题。
1. 核心概念:为什么要用模板?
提示词模板的作用,就像是 Web 开发中的 HTML 模板。它允许你预先挖好"坑位"(占位符变量),然后在运行时将用户的数据精准地"填"进去,最终生成可以被模型接收的消息列表。
针对上一课学的 Chat Models,我们主要使用 ChatPromptTemplate。
2. 代码实例:使用 ChatPromptTemplate
构建聊天提示词模板最常见、最清晰的方式是使用 from_messages 方法。它允许你用元组 (角色, 内容模板) 的形式快速定义。
python
from langchain_core.prompts import ChatPromptTemplate
# 1. 定义模板:用大括号 {} 作为变量占位符
prompt_template = ChatPromptTemplate.from_messages([
("system", "你是一个资深的{domain}专家。请用{style}的语气回答问题。"),
("human", "请解释一下什么是 {concept}?")
])
# 2. 注入变量(格式化模板)
# 使用 invoke 方法,传入一个字典来替换变量
formatted_messages = prompt_template.invoke({
"domain": "天体物理学",
"style": "幽默且通俗易懂",
"concept": "黑洞"
})
# 3. 打印结果,看看 LangChain 在底层做了什么
print(type(formatted_messages))
# 输出: <class 'langchain_core.prompt_values.ChatPromptValue'>
print(formatted_messages.to_messages())
# 输出:
# [SystemMessage(content='你是一个资深的天体物理学专家。请用幽默且通俗易懂的语气回答问题。'),
# HumanMessage(content='请解释一下什么是 黑洞?')]
你传入的是一个包含三个字符串变量的字典。
ChatPromptTemplate.invoke() 在底层不仅完成了字符串的替换,更重要的是,它自动帮你实例化了 SystemMessage 和 HumanMessage 对象!
这意味着你可以把生成的 formatted_messages 直接无缝丢给上节课的 chat_model 去执行。
3. 进阶技巧:部分格式化 (Partial Formatting)
有时,你不能一次性拿到所有的变量。比如,应用的"风格(style)"在程序启动时就确定了,但"概念(concept)"需要等用户在网页上输入。
这时可以使用 partial 方法先填入一部分变量:
python
# 假设我们只知道 style,不知道 concept
partial_template = prompt_template.partial(domain="计算机", style="极客")
# 等用户输入后,再注入剩下的变量
final_messages = partial_template.invoke({"concept": "内存泄漏"})
随堂测试(第 3 课)
1. 思考题: 既然 Python 自带的 f"{user_input}" 也可以替换字符串,为什么 LangChain 还要专门设计一个 ChatPromptTemplate 类?(结合上面的代码实例和底层逻辑剖析来思考)。
2. 代码填空题: 假设你有以下模板:
python
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "将以下{source_lang}翻译成{target_lang}。"),
("human", "{text}")
])
请写出一行代码,使用 invoke 方法,将 source_lang 设为 "中文",target_lang 设为 "法文",text 设为 "你好",并将生成的结果赋值给变量 messages。
现在,我们有了大模型,也学会了用模板动态拼接输入。但目前还有一个大问题:大模型返回给我们的永远是一段自然语言文本(字符串)。
在实际开发中,如果你想把大模型的提取结果存入数据库,或者传给前端渲染界面的卡片,你需要的是 JSON 或特定的数据结构,而不是一堆啰嗦的自然语言。
第 4 课:输出解析器 (Output Parsers)
输出解析器(Output Parsers)的作用就是把大模型输出的"非结构化文本",转换成程序可以直接使用的"结构化对象"(如 Python 字典、列表或 Pydantic 对象)。
1. 核心概念:解析器在干什么?
一个标准的 LangChain 输出解析器在底层其实做了两件事:
-
提供格式化指令 (Format Instructions): 它会生成一段文字,明确告诉大模型:"请你严格按照以下 JSON 格式输出,不要说废话"。我们会把这段指令自动塞进 Prompt 里。
-
执行解析 (Parse): 拿到大模型返回的文本后,它会使用 JSON 解析库或正则表达式,把文本转换成 Python 对象。
2. 代码实例:使用 PydanticOutputParser
在现代 Python 开发中,Pydantic 是用来定义数据结构最流行的库。LangChain 完美融合了它。
假设我们要让 AI 从一段杂乱的文本中提取人物信息:
python
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import PydanticOutputParser
from pydantic import BaseModel, Field
load_dotenv()
chat_model = ChatOpenAI(model="gpt-3.5-turbo", temperature=0) # 提取任务通常将温度设为0,保证输出稳定
# 1. 定义你期望的输出结构 (使用 Pydantic)
class PersonInfo(BaseModel):
name: str = Field(description="人物的名字")
age: int = Field(description="人物的年龄,如果未提及请设为 0")
hobbies: list[str] = Field(description="人物的爱好列表")
# 2. 实例化解析器
parser = PydanticOutputParser(pydantic_object=PersonInfo)
# 3. 获取解析器生成的"底层指令"
format_instructions = parser.get_format_instructions()
# 此时如果你打印 format_instructions,会看到一大段英文,大意是:
# "The output should be formatted as a JSON instance that conforms to the JSON schema below..."
# 4. 构建提示词模板 (将指令注入进去)
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个信息提取助手。\n{format_instructions}"),
("human", "从以下文本中提取信息:{text}")
])
# 5. 执行流程
# 组装变量
messages = prompt.invoke({
"format_instructions": format_instructions,
"text": "今天我遇到了张三,他今年28岁了,平时特别喜欢打篮球,周末经常去游泳。"
})
# 调用模型
response = chat_model.invoke(messages)
# 解析结果
# 注意:response.content 是纯文本字符串 (如 '{"name": "张三", "age": 28, "hobbies": ["打篮球", "游泳"]}')
parsed_result = parser.invoke(response)
# 6. 查看最终的魔法
print(type(parsed_result))
# 输出: <class '__main__.PersonInfo'> (不再是字符串,而是真正的 Python 对象)
print(parsed_result.name) # 输出: 张三
print(parsed_result.hobbies) # 输出: ['打篮球', '游泳']
3. 常见问题:如果模型没有严格输出 JSON 怎么办?
早期的模型经常会在 JSON 外面包一些废话,比如:"好的,这是你要的 JSON:json { ... } " 。 LangChain 的 Parser 底层非常健壮,它内置了正则表达式 ,会自动去寻找文本中的 { } 或 [ ] 块,把外围的废话剥离掉,再进行解析。
随堂测试
1. 判断题: 输出解析器(Output Parser)能够改变大模型底层的运行机制,让它在生成文本之前就直接在内存里构建一个 JSON 对象。 A. 对 B. 错
2. 场景题: 假设你想让 AI 根据用户的需求生成一段 Markdown 格式的代码(不需要 JSON)。根据我们刚才学的底层逻辑,你觉得你一定需要 用到 PydanticOutputParser 吗?如果不用,通常怎么做?
3. 概念连线题(复习巩固): 请用自己的话,把以下三个概念和它们在一条"流水线"上扮演的角色对应起来:
-
ChatPromptTemplate扮演的角色是:? -
ChatOpenAI扮演的角色是:? -
OutputParser扮演的角色是:?
第 5 课:LangChain 表达式语言 (LCEL) 基础
LCEL(LangChain Expression Language) 并不是一门新的编程语言,而是 LangChain 独创的一种代码编写风格(语法糖) 。它的核心目标只有一个:让你能用极简的代码,把零散的组件拼接成强大的流水线(Chain)。
1. 核心概念:Linux 管道符 | 与 Runnable 协议
在 Linux 终端里,你可以用 |(管道符)把上一个命令的输出作为下一个命令的输入。LangChain 借用了这个极其优雅的概念。
为了让不同组件能够通过 | 连接,LangChain 底层重写了所有的核心类(Prompt, Model, Parser 等),让它们都继承自一个叫做 Runnable 的基础类。
只要是 Runnable 对象,就都拥有统一的标准方法,最常用的三个是:
-
invoke(): 传入单条输入,同步获取结果。 -
stream(): 传入单条输入,流式返回结果(打字机效果)。 -
batch(): 传入一个列表,并发处理多条输入。
2. 代码实例:传统写法 vs. LCEL 写法
我们来实现一个简单的"专业名词解释器",对比一下新旧写法。
先准备好三大组件:
python
import os
from dotenv import load_dotenv
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser # 最简单的字符串解析器
load_dotenv()
# 1. 加工员 (Prompt)
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个用大白话解释复杂概念的专家。"),
("human", "请解释一下什么是 {concept}?")
])
# 2. 大脑 (Model)
model = ChatOpenAI(model="gpt-3.5-turbo")
# 3. 包装员 (Parser)
parser = StrOutputParser()
以前的繁琐写法(手动传递):
python
# 每次都要调用 invoke,还需要引入中间变量
messages = prompt.invoke({"concept": "量子纠缠"})
response = model.invoke(messages)
final_result = parser.invoke(response)
print(final_result)
现在的 LCEL 写法:
python
# 使用 | 符号,将三个组件串联成一个全新的 Runnable 对象:chain
chain = prompt | model | parser
# 只需要对组装好的 chain 调用一次 invoke
# 输入的字典会先给 prompt,prompt 的输出自动给 model,model 的输出自动给 parser
final_result = chain.invoke({"concept": "量子纠缠"})
print(final_result)
3. LCEL 的降维打击:原生支持流式输出 (Streaming)
如果在以前,你想让大模型像 ChatGPT 那样一个字一个字地把结果弹出来,你需要写非常复杂的异步代码和回调函数。
但有了 LCEL,因为底层的 Runnable 协议统一了接口,你只需要把 invoke 换成 stream,流式输出就自动生效了!
python
# 直接调用 stream 方法,它会返回一个生成器
for chunk in chain.stream({"concept": "API 接口"}):
# chunk 是实时返回的字符串片段
print(chunk, end="", flush=True)
随堂测试(第 5 课)
LCEL 的语法是整个现代 LangChain 的灵魂,理解它至关重要。
1. 代码阅读题: 假设我定义了这样一个链: my_chain = prompt | model (注意,这里没有接 OutputParser)。 如果我执行 result = my_chain.invoke({"topic": "AI"}),请问 result 变量的数据类型是什么?(提示:回忆一下模型直接返回的是什么)。
-
如果是
prompt | model,流水线到model就结束了,输出的就是未经处理的AIMessage。 -
只有加上了解析器:
prompt | model | parser,最后的parser(包装员)才会把AIMessage拆开,提取出里面的字符串给你。
2. 简答题: 结合你的编程经验,Python 中默认的 | 符号通常用于按位或运算(Bitwise OR)或者集合的并集。LangChain 是使用了 Python 的什么高级特性(语法机制),才让 prompt | model 这种写法没有报错,反而实现了数据的传递?
在 Python 中,| 符号原本是用来做数学运算(按位或)的。但是,Python 允许程序员重新定义这个符号的功能。LangChain 的开发者在底层的代码里写了一个特殊的方法叫做 __or__。当你敲下 A | B 时,Python 在底层其实是悄悄执行了类似 A.__or__(B) 的函数代码,从而实现了把 A 和 B 连接起来的功能。
第 6 课:LCEL 进阶操作 (数据的传递与拼装)
在上一课的 prompt | model | parser 中,我们给流水线的输入是一个字典 (比如 {"concept": "量子纠缠"})。 但在实际开发中,用户在网页输入框里输入的通常只是一个单纯的字符串(比如 "量子纠缠")。
那么,我们怎么把一个单纯的字符串,转换成 Prompt 模板需要的字典格式呢?这就需要用到 LCEL 的进阶工具。
1. 核心概念:RunnablePassthrough (数据透传)
RunnablePassthrough 翻译过来就是"让数据直接穿过去"。你可以把它想象成一根空心水管。当数据流经它时,它什么都不做,直接把原数据原封不动地交到下一步。
它最常见的用法,是和字典配合使用,把用户的单个输入组装成复杂的格式。
2. 代码实例
我们来看一个场景:我们要写一个 AI 翻译器,固定把用户输入的话翻译成英文。
python
# 导入操作系统相关库,用于与你的电脑系统打交道(比如读取隐藏的环境变量)
import os
# 导入 dotenv 库。它的作用是帮你悄悄加载 .env 文件里存着的密码或秘钥(比如 OpenAI API Key),这样你就不用把密码明文写在代码里了
from dotenv import load_dotenv
# 从 LangChain 工具箱里导入我们需要的三大核心零件:
# 1. 提示词模板(用来设计我们跟 AI 说话的格式/剧本)
from langchain_core.prompts import ChatPromptTemplate
# 2. OpenAI 的聊天模型(这就是那个负责思考的"AI大脑")
from langchain_openai import ChatOpenAI
# 3. 字符串输出解析器(用来把 AI 回复的复杂代码结构剥开,只留下纯人类语言文字)
from langchain_core.output_parsers import StrOutputParser
# 【重点引入】导入 RunnablePassthrough,你可以把它想象成一截"空心水管"或者"万能接球手"
from langchain_core.runnables import RunnablePassthrough
# 运行这个函数,它会去读取你藏好的 API Key,让后面的大模型能顺利连上网
load_dotenv()
# ==========================================
# 1. 创建模型和解析器(准备好干活的工具)
# ==========================================
# 雇佣一个 OpenAI 的聊天机器人,并指定让他用 "gpt-3.5-turbo" 这个脑子(模型版本)
model = ChatOpenAI(model="gpt-3.5-turbo")
# 准备一个"翻译官",它的唯一工作就是把机器人回复的复杂数据包,变成干净的字符串
parser = StrOutputParser()
# ==========================================
# 2. 创建 Prompt 模板(写好给机器人的剧本)
# ==========================================
# 我们在这里定下规矩:
# 第一句告诉 AI 它的身份(翻译官),第二句是留给用户输入内容的"填空题"。
# 注意:这里的 {text} 就像一张试卷上的"横线(坑位)",等着后面把真正要翻译的句子填进来。
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个翻译官,请将用户的输入翻译成英文。"),
("human", "{text}") # {text} 就是挖好的坑
])
# ==========================================
# 3. 组装 LCEL 链条(把工具连成一条自动化流水线)
# ==========================================
# 这里用到了"管道"符号 `|`,作用是把上一步的结果直接扔给下一步。
chain = (
# 第一步:处理用户的原始输入。
# 为什么要把 RunnablePassthrough() 放在 {"text": ...} 这个字典里?
# 因为上面的 prompt 模板需要一个包裹着 "text" 标签的数据去填坑。
# 用户最后只会传进来一句干巴巴的话(比如"你好"),RunnablePassthrough 的作用就是一把接住这句话,原封不动地贴上 "text" 的标签,变成 {"text": "你好"}。
{"text": RunnablePassthrough()}
| prompt # 第二步:把刚才贴好标签的数据 {"text": "用户的输入"} 扔给剧本,把 {text} 那个坑填上。
| model # 第三步:剧本拼接完整了,直接扔给 AI 大脑去思考、翻译。
| parser # 第四步:AI 思考完吐出了一堆包含很多附加信息的数据,交给解析器,把外壳脱掉,只留下纯文字的翻译结果。
)
# ==========================================
# 4. 运行链条(按下流水线的启动按钮)
# ==========================================
# invoke 的意思是"调用/启动"。我们按下启动键,并往流水线里扔进一句话:"今天天气真不错"。
# 这句话会瞬间经历上面的四个步骤:
# 1. 被接住并包装成 {"text": "今天天气真不错"}
# 2. 填入模板变成完整的对话提示
# 3. 交给模型翻译
# 4. 提取出纯文字
result = chain.invoke("今天天气真不错")
# 把最后流水线吐出来的结果打印在屏幕上
print(result) # 输出结果应该是类似: "The weather is really nice today."
RunnablePassthrough() (到底透传了个啥?)
字面意思: 可运行的透传工具(Pass-through 意味着直接穿过去,不改变内容)。
为什么需要它? 因为我们的 prompt(剧本)是个傲娇的组件,它不接受一句话,它只接受一个"字典"(比如 {"text": "今天天气真不错"})。
但是!作为用户,我们调用 chain.invoke() 时,又只想简单粗暴地传一句话进去("今天天气真不错")。
所以,我们需要一个中间人来包装一下:{"text": RunnablePassthrough()}。当那个干巴巴的句子传进来时,RunnablePassthrough() 张开双手接住它,啥也不改,直接把它塞进字典里对应的位置。
3. 补充概念:字典会自动变成"并行处理"
在上面的代码中,流水线的最开头是一个字典 {"text": RunnablePassthrough()}。 在 LCEL 的规则中,如果在一个流水线步骤中出现了一个字典,LangChain 会同时并行执行字典里的所有的值。
虽然上面的例子只有一个键值对,体现不出并行的威力。但在未来的"检索增强生成 (RAG)"中,我们会经常看到类似这样的伪代码:
python
# 这里的字典有两个键值对,LangChain 会同时去执行它们
setup_and_retrieval = {
# 去数据库里搜索相关文档
"context": 数据库检索器,
# 原样保留用户的原始问题
"question": RunnablePassthrough()
}
# 真正的 RAG 流水线:先准备数据,再拼接提示词,再提问,再解析
rag_chain = setup_and_retrieval | prompt | model | parser
随堂测试(第 6 课)
为了确保你理解了今天这两段代码(特别是第一段详细注释的代码),请试着回答以下两道题:
1. 基础理解题: 在第一段代码的第 3 步中,如果我把字典写成 {"user_input": RunnablePassthrough()},运行代码会报错吗?为什么?(提示:回头看一下第 2 步定义的 Prompt 模板里挖的坑叫什么名字)。
2. 代码填空题: 假设我有一个计算器 Prompt 模板,它需要两个变量:{num1} 和 {num2}。
prompt = ChatPromptTemplate.from_messages([
("human", "请计算 {num1} 加上 {num2} 等于多少?")
])
如果我希望流水线的开头,能够接收一个单纯的数字(比如 5)作为 num1,而 num2 我想永远固定写死为 10。 请你模仿第一段代码的第 3 步,帮我补全下面的字典(只需写出字典部分即可)
chain = (
{
# 请在这里填写代码
}
| prompt
| model
| parser
)
第 7 课:文档加载与文本切割 (Loaders & Splitters)
RAG(Retrieval-Augmented Generation,检索增强生成) 是解决大模型"胡说八道"和"不知道最新/私有知识"的终极方案。
它的核心思想是:在向大模型提问之前,先去我们自己的知识库(比如公司文档、PDF)里搜索相关的段落,然后把这些段落和问题一起发给大模型,让它"开卷考试"。
为了实现 RAG,第一步就是把我们现有的文件变成程序能认识的数据。这就是 Loaders(加载器) 和 Splitters(切割器) 的工作。
1. 核心概念:为什么要加载和切割?
-
Document Loaders (加载器): 你的硬盘里有 PDF、Word、TXT、CSV,格式千奇百怪。加载器的作用是把这些不同格式的文件,统统转换成 LangChain 统一的标准对象:
Document对象。 -
Text Splitters (切割器): 大模型单次能阅读的字数是有限的(上下文窗口限制)。如果你把一本 10 万字的书直接塞给它,不仅会报错,而且非常费钱。因此,我们需要把长文档切成一段段几百字的小块(Chunks),方便后续搜索和发给模型。
2. 代码实例:
这次我们将演示如何读取一个普通的 TXT 文本,并把它切割成小块。
python
# ==========================================
# 准备工作:导入需要的工具包
# ==========================================
# 1. 导入文档加载器(这里以加载纯文本 .txt 文件为例)
# 为什么它在 community (社区包) 里?因为读取各种各样的文件(PDF、Word、TXT、网页)属于"杂活",
# 官方把这些千奇百怪的第三方读取工具都放在了社区包里。
from langchain_community.document_loaders import TextLoader
# 2. 导入文本切割器
# RecursiveCharacterTextSplitter 是目前最常用、最"通人性的"文本切割工具(切片机)
from langchain_text_splitters import RecursiveCharacterTextSplitter
# ---------------- 第一步:加载文档(把书搬进内存) ----------------
# 假设我们在电脑桌面上有一个叫做 "company_rules.txt" 的文件(比如公司的规章制度长文)
# 我们请来一个专属的"文本搬运工"(TextLoader),告诉他文件在哪,
# 并特别叮嘱 encoding="utf-8"(这是告诉搬运工:请用标准的中文方式去读,千万别读出乱码来)。
loader = TextLoader("company_rules.txt", encoding="utf-8")
# 让搬运工正式开始干活:调用 load() 方法,真正把硬盘里的文件内容读取到电脑内存里。
# docs 是一个列表(你可以理解为一个大箱子),里面装的是被打包好的标准包裹(LangChain 特有的 Document 对象)。
docs = loader.load()
# 打印出来看看我们搬了几份文件进箱子
# (如果你只加载了一个 txt,那这里的长度就是 1)
print("一共加载了", len(docs), "个文档")
# 每一份 Document 包裹其实分两层:
# 第一层叫 page_content(里面装的纯纯的文字内容)
# 第二层叫 metadata(贴在包裹外面的标签,记录了这堆文字是从哪个文件来的、第几页等信息)
# print(docs[0].page_content) # 如果把这行代码前面的 # 删掉,就会把文件的所有字都打印在屏幕上
# ---------------- 第二步:切割文档(把长文切成小块,方便喂给 AI) ----------------
# 买一台"智能切片机",并设置好切片的规则:
# Python 语法小贴士:把参数名写出来再赋值(比如 chunk_size=100),这叫"关键字参数",能让你以后看代码时一秒懂它是什么意思。
text_splitter = RecursiveCharacterTextSplitter(
chunk_size = 100, # 【最大块头】规定每块"肉"最多只能包含 100 个字符,防止 AI 一次吃太多"噎住"
chunk_overlap = 20, # 【首尾相连】切下一块时,要倒退 20 个字符。也就是让相邻的两块有 20 个字的重合部分(防止一句话刚好被一刀两断,导致 AI 读不懂上下文意思)
separators = ["\n\n", "\n", "。", ",", " "] # 【下刀的优先顺序】告诉机器:先试着按段落(双换行)切;如果一段还是太长(超过100字),再按单换行切;还不行,就按句号切;再不行按逗号切......
)
# 启动切片机:调用 split_documents 方法,把刚才搬运进来的那个长篇大论的大箱子 (docs) 倒进机器里切碎
# 返回的 splits 也是一个箱子,但里面装的已经是切得大小匀称的"小块 Document 包裹"了
splits = text_splitter.split_documents(docs)
# 打印看看这台机器一共切出了多少个小块
print("长文档被切成了", len(splits), "个小块。")
# 我们可以随便拿第一小块出来,看看里面切出了什么字
print("第一块的内容是:")
print(splits[0].page_content)
3. 底层逻辑:为什么有 chunk_overlap (重叠)?
假设有一句话:"LangChain / 是一个非常强大的 / 开发框架"。 如果没有重叠,恰好切在了斜杠处。 第一块是:"LangChain " 第二块是:"是一个非常强大的" 当用户搜索"LangChain的评价"时,可能只能搜出第一块,大模型拿到"LangChain"这个词,根本不知道后面其实有一句"非常强大",从而给出错误的回答。 设置了 chunk_overlap=20 后,第二块可能会变成"LangChain 是一个非常强大的",这样大模型就能联系上下文了。
随堂测试(第 7 课)
这节课没有过于复杂的语法,主要是理解 RAG 的第一步逻辑。请回答:
1. 简答题: 在上述代码中,最终切分出来的 splits 变量是一个列表。请问这个列表里装的元素,它的数据类型是纯粹的 Python 字符串(String),还是 LangChain 的某种特定对象?如果是特定对象,它通常包含哪两个重要属性?(提示:回顾一下代码注释)
-
LangChain 的
Document对象,而不是纯字符串。 -
为什么不直接用字符串? 假设我们在 500 页的 PDF 里切出了一句话:"公司规定迟到罚款 50 元"。如果这只是个纯字符串,大模型读到它时,完全不知道这句话是出自哪份文件、第几页。
-
因此,LangChain 用
Document对象把文本包装了起来。它包含两个核心属性:-
page_content: 真正的文字内容("公司规定迟到...")。 -
metadata(元数据) : 附加信息的字典,比如{"source": "员工手册.pdf", "page": 42}。有了这个,AI 甚至可以告诉你:"根据员工手册第42页的规定..."。
-
2. 场景选择题: 如果公司给你一份长达 500 页的《员工手册 PDF》,并让你做一个基于这份手册的问答机器人。在写代码时,为了保证 AI 每次回答既准确又不超出字数限制,你最需要 调整 RecursiveCharacterTextSplitter 中的哪一个参数? A. chunk_size (每块的字符数) B. chunk_overlap (重叠的字符数) C. separators (切割符)
第 8 课:向量化与向量数据库 (Embeddings & Vectorstores)
传统的数据库(比如 MySQL)搜索靠的是"关键字完全匹配"。比如你搜"苹果手机",如果文章里写的是"iPhone",传统数据库是搜不出来的,因为字完全不一样。
但在 AI 时代,我们希望实现"语义搜索"(根据意思来搜)。为了让计算机理解"意思",我们需要把文字变成计算机能懂的语言------数字(更准确地说是:多维数组 / 向量)。
1. 核心概念:Embeddings 与 Vectorstores
-
Embeddings (向量化/嵌入模型): 这是一种特殊的人工智能模型(OpenAI 也有专门的 Embedding 模型,极其便宜)。它的唯一工作就是接收一段文字,然后吐出一长串浮点数(比如
[0.012, -0.045, 0.887, ...],通常有上千个数字)。- 核心逻辑: 意思越相近的句子,它们变成数字后的坐标在空间中离得就越近。"苹果手机"和"iPhone"算出来的数字列表是非常相似的。
-
Vectorstores (向量数据库): 专门用来存这些长串数字的数据库。当你提出一个问题时,向量数据库会计算你问题的向量与库里所有文档向量的"距离",找出距离最近(最相关)的那几块文本给你。
2. 代码实例:极其详细的注释版
在这个例子中,我们将使用一款非常流行的、可以跑在本地内存里的开源向量数据库:Chroma。
(注意:运行此代码前,你需要在终端里安装 chroma 包:pip install langchain-chroma)
python
import os
from dotenv import load_dotenv
# 1. 导入 OpenAI 的向量化模型 (注意,它不是普通的聊天模型,而是专用的 Embeddings 模型)
from langchain_openai import OpenAIEmbeddings
# 2. 导入 Chroma 向量数据库集成包
from langchain_chroma import Chroma
# 3. 导入上一节课学过的 Document 对象,为了方便演示,我们手动创建几个文档片段
from langchain_core.documents import Document
load_dotenv()
# ================= 第一步:准备好切割后的小块文本 =================
# 在实际开发中,这里的数据应该是上一节课 text_splitter 切出来的结果 (splits)
# 这里我们手动写 3 个文档对象来模拟
documents = [
Document(page_content="iPhone 15 Pro 采用了钛金属边框,非常轻便。"),
Document(page_content="今天中午食堂的红烧肉很好吃。"),
Document(page_content="苹果公司最新款的智能手机在重量上做了很大的优化。")
]
# ================= 第二步:初始化向量化模型 =================
# 实例化一个 OpenAI 提供的嵌入模型
# "text-embedding-3-small" 是目前 OpenAI 最新、性价比最高的向量化模型
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
# ================= 第三步:将文本存入向量数据库 =================
print("正在将文字转换成数字并存入数据库,请稍候...")
# 调用 Chroma 的 from_documents 方法
# 这个方法会在底层自动做两件事:
# 1. 拿着 documents 里的文字去请求 OpenAI 的 Embeddings API,把文字变成数字。
# 2. 把生成的数字和原本的文字一起存到 Chroma 数据库里。
vectorstore = Chroma.from_documents(
documents=documents, # 要存入的文档列表
embedding=embeddings_model # 指定使用哪个模型把文字变成数字
)
print("数据库构建完成!\n")
# ================= 第四步:执行语义搜索 =================
# 假设用户提出了一个问题,他并没有直接说 "iPhone" 也没有说 "苹果手机"
user_query = "哪款通讯设备材质变轻了?"
print(f"用户问题: '{user_query}'")
print("正在数据库中寻找最相关的文档...\n")
# 调用 similarity_search (相似度搜索) 方法
# k=2 表示我们只想要最相关的前 2 条结果
# 底层逻辑:它会先把你提问的 user_query 变成向量,然后在数据库里找跟它距离最近的 2 个向量
results = vectorstore.similarity_search(user_query, k=2)
# 打印找出来的结果
print("搜索到的结果:")
for i, doc in enumerate(results):
print(f"第 {i+1} 条: {doc.page_content}")
# 运行结果应该会精准地把第1条(iPhone)和第3条(苹果公司)找出来,
# 而把不相关的第2条(红烧肉)过滤掉。
3. 为什么 RAG 是目前最火的技术?
如果没有向量数据库,你每次问大模型问题,都要把 500 页的 PDF 原封不动塞进 Prompt 里,这可能要花几十块钱的 Token 费。 有了向量数据库,你可以免费地在本地(比如上面的 Chroma)以毫秒级的速度找出最相关的 2-3 个段落,然后只把这几百个字发给大模型。既准,又省钱。
随堂测试(第 8 课)
这节课的代码逻辑非常顺畅:准备文档 -> 初始化模型 -> 存入数据库 -> 搜索。请回答以下问题:
1. 概念判断题: 我们在构建 RAG 应用时,处理用户的提问(user_query)需要用到大模型。那么在上面的代码流程中,当执行 vectorstore.similarity_search(user_query, k=2) 这行代码时,LangChain 在底层调用的是用于聊天的 ChatOpenAI 还是用于向量化的 OpenAIEmbeddings 模型?
2. 场景应用题: 小明用上面的代码做了一个系统,把公司的1000条报销规定存进了 Chroma 数据库。当用户输入"我不小心把发票弄丢了,还能报销打车费吗?"这段话时,Chroma 数据库返回的 results 变量,它的数据类型是什么?(提示:回忆一下它存进去的是什么格式,拿出来的就是什么格式)。
第 9 课:检索器与完整的 RAG 链组装 (Retrievers & RAG Chain)
现在,我们的桌面上放着四样东西:
-
向量数据库 (Vectorstore):装满了公司规定的知识库。
-
提示词模板 (Prompt):挖了坑的系统指令。
-
聊天模型 (Model):GPT-3.5 的聪明大脑。
-
输出解析器 (Parser):负责提取纯文字的质检员。
今天,我们要用第二阶段学过的 LCEL (管道符 |),把它们串联成一个真正能用的智能客服。
1. 核心概念:检索器 (Retriever)
在上一课中,我们用的是 vectorstore.similarity_search("问题")。这只是一个普通的函数调用,它不是 Runnable 协议,所以不能直接放进 | 流水线里。
为了让向量数据库能接入 LCEL 流水线,LangChain 提供了一个极其简单的方法:as_retriever() 。 调用这个方法后,数据库就会摇身一变,成为一个符合 Runnable 标准的组件:输入是一个字符串问题,输出是一个 Document 列表。
2. 代码实例
这段代码将展示现代 LangChain 中最标准、最优雅的 RAG 实现方式:
python
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
# 引入 RunnablePassthrough,我们马上要用它接住用户的问题
from langchain_core.runnables import RunnablePassthrough
from langchain_core.documents import Document
load_dotenv()
# ================= 准备工作 (回顾前两课的内容) =================
documents = [
Document(page_content="公司规定:员工迟到一次扣款 50 元。"),
Document(page_content="公司规定:晚上加班超过 9 点,可以报销打车费,上限 100 元。"),
Document(page_content="公司规定:年假每年 5 天,不可跨年累积。")
]
embeddings_model = OpenAIEmbeddings(model="text-embedding-3-small")
# 把文档存入 Chroma 向量数据库
vectorstore = Chroma.from_documents(documents, embeddings_model)
# ================= 核心步骤 1:创建检索器 =================
# 把普通的向量数据库,转化为 LCEL 兼容的 Retriever (检索器)
# 并且通过 search_kwargs={"k": 2} 告诉它:每次只帮我找出最相关的 2 句话即可
retriever = vectorstore.as_retriever(search_kwargs={"k": 2})
# ================= 核心步骤 2:创建组件 =================
# 大脑
model = ChatOpenAI(model="gpt-3.5-turbo")
# 解析器
parser = StrOutputParser()
# Prompt 模板:非常关键!注意看我们挖了哪两个坑
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个公司 HR 助手。请严格根据以下提供的参考资料回答问题,不知道就说不知道。\n\n参考资料:\n{context}"),
("human", "{question}")
])
# ================= 核心步骤 3:用字典组装并行的开头 =================
# 这是一个非常经典的用法!当我们向流水线丢入一个字符串时,字典会并行处理它。
setup_and_retrieval = {
# 动作1:把用户输入传给 retriever,去数据库搜出 2 条 Document 列表。
# 搜出来的 Document 列表,会自动填入到 prompt 的 {context} 坑位中。
"context": retriever,
# 动作2:用 RunnablePassthrough() 把用户的原始输入原封不动地接住。
# 这个原始输入,会自动填入到 prompt 的 {question} 坑位中。
"question": RunnablePassthrough()
}
# ================= 核心步骤 4:LCEL 一键组装 =================
# 完美的 RAG 链诞生了!
rag_chain = setup_and_retrieval | prompt | model | parser
# ================= 执行提问 =================
# 用户在网页端输入了这句话:
user_input = "我昨天晚上加班到晚上 10 点,打车花了 120 块钱,能报销吗?"
print("AI 正在思考...")
# 一次 invoke,完成:搜索 -> 填坑 -> 思考 -> 输出字符串
result = rag_chain.invoke(user_input)
print("\n最终回答:")
print(result)
# 预期输出类似:"根据参考资料,加班超过 9 点可以报销打车费,但上限是 100 元。您打车花了 120 元,最多只能报销 100 元。"
3. 底层流程回放(数据在管道里是怎么流动的?)
当你执行 rag_chain.invoke("我昨天...") 时,底层发生了这几件事:
-
字符串
"我昨天..."进入字典。 -
"context"拿到字符串,触发检索,得到:[Document("加班超过9点...")]。 -
"question"拿到字符串,什么都不做,直接传递。 -
现在数据变成了一个组装好的大字典:
{"context": [Document(...)], "question": "我昨天..."}。 -
这个大字典被传给
prompt。prompt把这两个值塞进模板里,变成了一段完整的给 OpenAI 的话。 -
OpenAI 接收到长长的话,根据"参考资料"进行阅读理解。
-
返回结果给
parser,输出纯净的回答。
随堂测试
1. 代码阅读题: 如果在上面的代码中,我把 Prompt 模板改写成这样:
prompt = ChatPromptTemplate.from_messages([
("system", "根据资料:{info} 回答问题。"),
("human", "{user_q}")
])
那么为了让程序不报错,我们在第 3 步构建 setup_and_retrieval 字典时,里面的两个键(Key)分别应该被改成什么名字?
2. 逻辑填空题: 大语言模型在做 RAG 回答时,它之所以能够给出准确的私有知识,是因为知识是由( A )提供的,而大语言模型本质上扮演的是一个拥有极强( B )能力的角色,它本身并不记忆这些私有知识。 请从以下选项中选出 A 和 B 对应的词语: 选项:向量数据库、文本生成与阅读理解、提示词模板、输出解析器。 A 是:___ B 是:___
请给出你的答案。答对后,我们将进入第四阶段:如何让 AI 拥有记忆 (Memory)!
第 10 课:对话历史的管理
如果你自己写过调用 OpenAI API 的代码,你会发现一个让人崩溃的事情:大模型本质上是"失忆"的(无状态的)。 你对它说:"我叫张三。" 它回答:"你好张三。" 紧接着你问:"我叫什么名字?" 它会回答:"抱歉,我不知道你的名字。"
为了让 AI 拥有记忆,我们必须在每次提问时,把之前聊过的所有话,连同最新的问题一起打包发给它。LangChain 提供了非常优雅的工具来自动完成这种"打包"工作。
2. 代码实例:
这段代码将展示如何给 AI 装上记忆,并且同时和"张三"、"李四"两个人聊天而不串号。
python
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
# 【新引入 1】这是一个专门用来占位的符,用于在 Prompt 中给"历史记录"留位置
from langchain_core.prompts import MessagesPlaceholder
# 【新引入 2】存在内存里的聊天记录本
from langchain_core.chat_history import InMemoryChatMessageHistory
# 【新引入 3】这就是我们说的"记忆外套"
from langchain_core.runnables.history import RunnableWithMessageHistory
load_dotenv()
# ================= 第一步:创建基础链条 =================
model = ChatOpenAI(model="gpt-3.5-turbo")
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个友好的 AI 助手。"),
# 核心魔法:我们在系统提示词和用户当前问题之间,插一个占位符。
# 它的名字叫 "history"。一会 LangChain 会自动把过去所有的对话列表塞进这里。
MessagesPlaceholder(variable_name="history"),
("human", "{question}") # 用户当前最新的问题
])
# 这是一个没有记忆的基础链条
chain = prompt | model
# ================= 第二步:创建一个大账本,用来管理不同人的聊天记录 =================
# 这是一个普通的 Python 字典,用来存不同 Session ID 对应的历史记录
store = {}
# 定义一个提取历史记录的函数。LangChain 每次运行前都会自动调用它。
def get_session_history(session_id: str):
# 如果这个 session_id 之前没聊过天(不在账本里)
if session_id not in store:
# 就给它新建一个空白的聊天记录本
store[session_id] = InMemoryChatMessageHistory()
# 返回对应的聊天记录本
return store[session_id]
# ================= 第三步:给链条穿上"记忆外套" =================
# 将普通链条升级为带记忆的链条
chain_with_history = RunnableWithMessageHistory(
runnable=chain, # 要穿外套的基础链条
get_session_history=get_session_history, # 获取账本的函数
input_messages_key="question", # 告诉它:用户输入的新问题叫什么名字 (对应 prompt 里的 {question})
history_messages_key="history" # 告诉它:历史记录应该填到 prompt 里的哪个坑位 (对应 MessagesPlaceholder)
)
# ================= 测试记忆功能 =================
# 1. 模拟张三 (session_id = "zhangsan_chat") 来聊天
print("--- 张三的聊天 ---")
response1 = chain_with_history.invoke(
{"question": "你好,我叫张三,我最喜欢吃红烧肉。"},
# 注意:我们在这里通过 config 传入 session_id
config={"configurable": {"session_id": "zhangsan_chat"}}
)
print("AI回答:", response1.content)
response2 = chain_with_history.invoke(
{"question": "我刚刚说我叫什么名字?我最爱吃什么?"},
config={"configurable": {"session_id": "zhangsan_chat"}} # 还是张三的 ID
)
print("AI回答:", response2.content)
# AI 会成功回答:"你叫张三,最喜欢吃红烧肉。" (它记住了!)
# 2. 模拟李四 (session_id = "lisi_chat") 来聊天
print("\n--- 李四的聊天 ---")
response3 = chain_with_history.invoke(
{"question": "嗨,请问你知道我是谁吗?"},
config={"configurable": {"session_id": "lisi_chat"}} # 换了一个新的 ID
)
print("AI回答:", response3.content)
# AI 会回答类似:"抱歉,我不知道你是谁,我们好像是第一次聊天。" (因为不同 session_id 记录是隔离的)
3. 底层机制揭秘
当你第二次调用 invoke 问"我叫什么名字"时:
-
LangChain 看到你传了
session_id="zhangsan_chat"。 -
它去
store字典里找,拿出了张三之前的聊天记录([HumanMessage("你好..."), AIMessage("你好张三...")])。 -
它把这些记录塞进 Prompt 的
MessagesPlaceholder(variable_name="history")坑位里。 -
它把你最新的问题塞进
{question}里。 -
整个一大段文字打包发给 OpenAI,所以 OpenAI 显得"有记忆"了。
随堂测试(第 10 课)
这节课的代码稍微多了一点,但逻辑非常清晰。请思考并回答以下问题:
1. 代码逻辑题: 如果在上面代码的"测试记忆功能"环节中,我在运行第二句"我刚刚说我叫什么名字?"时,忘记写 config={"configurable": {"session_id": "zhangsan_chat"}} 这个配置项,直接执行了 chain_with_history.invoke({"question": "我刚刚说我叫什么名字?"})。 你认为程序会发生什么? A. AI 也能记住张三的名字,因为默认只有一个用户。 B. 程序会直接报错,因为必须提供 session_id 来找聊天记录本。
2. 原理简答题: 我们在第 7 课讲 RAG 的文本切割时,提到过大模型是有"上下文窗口(字数)限制"的。那么根据这节课讲的记忆原理(把历史记录都打包发给模型),如果用户和一个 AI 聊了整整一天,说了几万句话,会遇到什么致命问题?
第 11 课:高级记忆策略 (防止爆显存与破产)
为了解决"历史记录无限变长"的问题,业界有两种最主流的解决方案:
-
滑动窗口记忆 (Sliding Window): 简单粗暴,永远只记住最近聊的 N 句话,太久远的话直接丢弃。
-
对话摘要记忆 (Summary): 让 AI 每隔一段时间,就把前面的废话总结成一段精简的"背景提要"。
由于我们目前使用的是最新的 LCEL 架构,处理这两种策略最优雅的方式,就是在流水线中间加一个"拦截器",把过长的消息列表修剪掉。
1. 核心概念:消息修剪 (Message Trimming)
今天我们重点演示最常用、最好懂的"滑动窗口记忆"。 在 Python 中,我们要实现"只保留最后几句话",不需要用什么高深的技术,只需要用到 Python 的基础语法:列表切片 (List Slicing)。
2. 代码实例:极其详细的注释版
这段代码将展示,如何在 Prompt 填完坑之后,传给 Model 之前,把消息列表强行"剪短"。
python
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.chat_history import InMemoryChatMessageHistory
load_dotenv()
# ================= 准备基础组件 =================
model = ChatOpenAI(model="gpt-3.5-turbo")
store = {}
def get_session_history(session_id: str):
if session_id not in store:
store[session_id] = InMemoryChatMessageHistory()
return store[session_id]
# ================= 第一步:定义 Prompt =================
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个聪明的助手。"), # 注意:这是整个列表的第 0 条消息
MessagesPlaceholder(variable_name="history"),
("human", "{question}")
])
# ================= 第二步:写一个"修剪器"函数 (核心重点!) =================
# 这个函数的作用是:接收一长串消息列表,然后只返回其中一部分
def trim_messages(messages):
# 假设我们现在 messages 里面有 10 条消息。
# Python 列表基础:len(messages) 可以获取列表里有几条消息
if len(messages) <= 5:
# 如果总数不到 5 条,不用修剪,直接原样返回
return messages
# 如果超过了 5 条,我们就需要修剪了!
# 但是注意:我们【绝不能】把第 0 条删掉,因为第 0 条通常是 SystemMessage(你是一个聪明的助手...)
# 如果把人设删了,AI 就会"精神分裂"。
# Python 列表切片语法:
# messages[0] 取出第 0 条 (系统提示词)
# messages[-4:] 取出最后 4 条 (也就是最近的两轮对话)
# 把它们拼成一个新的列表返回
trimmed = [messages[0]] + messages[-4:]
return trimmed
# ================= 第三步:组装 LCEL 链条 =================
# 见证奇迹的时刻:我们把 trim_messages 这个函数,直接塞进流水线里!
chain = prompt | trim_messages | model
# ================= 第四步:穿上记忆外套 =================
chain_with_history = RunnableWithMessageHistory(
runnable=chain,
get_session_history=get_session_history,
input_messages_key="question",
history_messages_key="history"
)
# ================= 运行测试 =================
# 我们给它塞入大量历史对话来测试
history = get_session_history("my_chat")
# 手动往账本里塞入 6 条历史记录 (凑够长度)
history.add_user_message("我叫张三。")
history.add_ai_message("你好张三。")
history.add_user_message("我今年 25 岁。")
history.add_ai_message("好的,25 岁。")
history.add_user_message("我住在北京。")
history.add_ai_message("北京是个好地方。")
# 现在历史账本里已经有 6 句话了。
# 如果我们现在问问题,总消息数 = 1(系统) + 6(历史) + 1(新问题) = 8 条。
# 但是经过流水线中间的 trim_messages 拦截,最终发给大模型的只有 5 条!
print("AI 思考中...")
response = chain_with_history.invoke(
{"question": "请问我叫什么名字?"}, # 注意:名字是在最早的第一句话里说的
config={"configurable": {"session_id": "my_chat"}}
)
print("\n最终回答:")
print(response.content)
# 预期输出:AI 会说"抱歉,我不知道你的名字"。
# 为什么?因为包含名字的最早那轮对话,已经被我们的 trim_messages 扔进垃圾桶啦!
# 这就成功实现了"滑动窗口",只记最新的话,永远不会爆显存!
3. 底层机制回放:数据在管道中如何变形?
-
Prompt 填坑前:只有系统指令和新问题。
-
Prompt 填坑后 :变成
[System, 历史1, 历史2, 历史3, 历史4, 历史5, 历史6, 新问题](共 8 条)。 -
经过 trim_messages :被强行切断,变成
[System, 历史5, 历史6, 新问题](这只保留了最后的话题)。 -
传给 Model:大模型收到的是被修剪过的超短消息,处理飞快,极其省钱。
第 12 课:函数调用与工具 (Function Calling & Tools)
在默认情况下,大模型只能"说"不能"做"。如果你问它"今天北京的天气怎么样?",它只能瞎猜或者告诉你它没有实时联网能力。
工具(Tools) 的出现打破了这个限制。我们可以把 Python 函数(比如查天气、搜网页、操作数据库)封装成工具,交给大模型使用。
1. 核心底层逻辑:大模型真的能运行 Python 代码吗?
绝不!这是一个极其常见的误区。 OpenAI 的服务器绝对不会、也不可能直接运行你电脑上的 Python 代码。
所谓的"大模型调用工具",底层的真实流程是这样的(请务必理解这个流程):
-
开发者:写好一个 Python 函数,并且写一段说明书(告诉模型这个函数叫什么名字,能干什么,需要什么参数)。
-
大模型:你提问"北京天气怎样?"。模型看了看说明书,发现自己解决不了,但有个查天气的工具能解决。
-
大模型(发号施令) :它停止生成普通文本,转而输出一段特殊的 JSON 指令(类似:"喂,人类,请帮我执行
get_weather函数,参数是{"city": "北京"}")。 -
LangChain(跑腿小弟):收到指令,在你的本地电脑上真正执行这个 Python 函数,拿到结果("晴,25度")。
-
大模型(总结汇报):LangChain 把结果送回给模型,模型看了之后,最终对用户说:"今天北京的天气是晴天,25度。"
今天这节课,我们先学如何完成前面的 第 1 步到第 3 步(也就是定义工具,并让模型学会"发号施令")。
2. 代码实例:
这段代码将展示如何用最优雅的 @tool 装饰器定义工具,并用 bind_tools 把工具绑定到模型上。
python
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
# 【新引入】导入 tool 装饰器,这是把普通函数变成 LangChain 工具的魔法
from langchain_core.tools import tool
load_dotenv()
# ================= 第一步:定义工具 =================
# 使用 @tool 装饰器把普通的 Python 函数变成 LangChain 认识的工具
@tool
def get_weather(location: str) -> str:
"""
获取指定城市的当前天气情况。
参数:
location: 城市的名称,例如 "北京" 或 "上海"
"""
# 这里我们不用真实的 API,直接用假数据模拟一下运行逻辑
print(f"\n[本地代码执行中...] 正在查询 {location} 的天气...")
if "北京" in location:
return "晴天,气温 25度,适合微服私访。"
elif "上海" in location:
return "阴有小雨,气温 20度,记得带伞。"
else:
return "未知城市,查不到天气。"
# 我们来看看 LangChain 到底把这个函数变成了什么样
# print(get_weather.name) # 输出: get_weather
# print(get_weather.description) # 输出的就是你刚才写的多行注释!
# ================= 第二步:绑定工具到模型 =================
# 1. 实例化聪明的大脑
model = ChatOpenAI(model="gpt-3.5-turbo")
# 2. 准备一个工具箱 (列表)
tools = [get_weather]
# 3. 核心魔法:bind_tools()
# 这个方法并不会把代码传给 OpenAI。
# 它在底层做的事是:把你写的注释(说明书)翻译成 OpenAI 认识的 JSON 格式,
# 然后悄悄塞进提示词里告诉模型:"嘿,你现在拥有这些超能力了!"
model_with_tools = model.bind_tools(tools)
# ================= 第三步:测试模型反应 =================
# 场景 A:问一个普通问题,不需要工具
print("--- 场景 A:普通提问 ---")
response_normal = model_with_tools.invoke("你好,请问 1+1 等于几?")
print("AI的回答是字符串吗?:", response_normal.content != "")
print("回答内容:", response_normal.content)
# 结果:模型觉得不需要工具,直接像平时一样回答了。
# 场景 B:问一个需要工具的问题
print("\n--- 场景 B:触发工具的提问 ---")
response_tool = model_with_tools.invoke("请问今天北京的天气怎么样?")
# 注意看这里的魔法!
print("AI 有直接回答文本吗?:", response_tool.content == "") # 返回 True,因为模型没有输出普通文字!
# 当模型决定调用工具时,它会把指令存在 `tool_calls` 这个特殊属性里
print("模型想要调用的工具列表:", response_tool.tool_calls)
# 打印出来大概长这样:
# [{'name': 'get_weather', 'args': {'location': '北京'}, 'id': 'call_xxx'}]
3. 重点避坑指南:Python 的"文档字符串 (Docstring)"就是 Prompt!
在普通的 Python 开发中,函数下面的三引号注释 """...""" 只是写给其他程序员看的,你可以随便写甚至不写。 但在 LangChain 开发中,这段注释就是大模型的 Prompt!是它认识这个工具的唯一途径!
如果你不写注释,或者写得极其模糊(比如:"""这是一个好用的工具"""),大模型就完全不知道什么时候该用它,或者会乱传参数导致程序报错。你必须像教三岁小孩一样,把工具的用途和参数格式写得清清楚楚。
第 13 课:Agent 架构与 ReAct 原理
1. 核心概念:什么是 ReAct 原理?
如果我问你:"我买了两部单价 5999 元的手机,外加一个 199 元的耳机,总共多少钱?"
你大脑的思考过程是:
-
思考 (Thought): 我需要先算手机的钱,也就是
。
-
行动 (Action): 拿出计算器,输入
。
-
观察 (Observation): 计算器显示 11998。
-
思考 (Thought): 手机花了 11998,现在还要加上耳机的 199,也就是
。
-
行动 (Action): 再次按计算器,输入
。
-
观察 (Observation): 计算器显示 12197。
-
最终回答 (Final Answer): 总共是 12197 元。
这就是著名的 ReAct (Reason + Act,推理+行动) 框架!
Agent 的本质,就是一个写满死循环(while True)的 Python 代码:它让大模型不断地重复"思考 -> 调工具 -> 看结果 -> 再思考"的过程,直到它觉得找出了最终答案,循环才停止。
2. 代码实例:
今天我们要打造一个"数学天才 Agent"。大语言模型本身的数学计算极差,但只要给它配备了计算器工具,它就能算对一切!
python
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain_core.prompts import ChatPromptTemplate
# 【新引入 1】MessagesPlaceholder 用于给 agent 留一张"草稿纸"
from langchain_core.prompts import MessagesPlaceholder
# 【新引入 2】专门用来创建工具调用 Agent 的快捷函数
from langchain.agents import create_tool_calling_agent
# 【新引入 3】AgentExecutor 是真正帮你跑死循环的"执行器"
from langchain.agents import AgentExecutor
load_dotenv()
# ================= 第一步:准备工具箱 =================
# 我们给 Agent 提供加法和乘法两个工具
@tool
def add(a: int, b: int) -> int:
"""计算两个数字的加法。"""
return a + b
@tool
def multiply(a: int, b: int) -> int:
"""计算两个数字的乘法。"""
return a * b
tools = [add, multiply]
# ================= 第二步:准备大脑和提示词 =================
model = ChatOpenAI(model="gpt-3.5-turbo")
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个非常有用的数学助手。请一定要使用工具来计算,不要自己瞎猜。"),
("human", "{input}"), # 用户的提问填在这里
# 核心重点:【Agent 的草稿纸】
# 当 Agent 第一次调用工具拿到结果后,它需要记住这个中间结果,才能进行下一步思考。
# "agent_scratchpad" (草稿纸) 就是用来记录它"每次调用的历史和结果"的。
MessagesPlaceholder(variable_name="agent_scratchpad"),
])
# ================= 第三步:组装 Agent 与执行器 =================
# 1. 创建 Agent 逻辑 (告诉大模型:这是你的提示词,这是你的工具箱)
agent = create_tool_calling_agent(model, tools, prompt)
# 2. 创建 Agent 执行器 (也就是那个 while True 死循环的引擎)
# verbose=True 是一个非常棒的参数,它会在控制台把 Agent 每一步在想什么、做了什么全部打印出来!
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
# ================= 第四步:见证奇迹的时刻 =================
print("=== 开始执行 Agent ===")
# 我们问一个必须分两步计算的复杂问题
user_question = "我买了两部单价 5999 元的手机,外加一个 199 元的耳机,总共多少钱?"
# 直接调用 invoke 即可,执行器会自动帮你搞定所有循环!
result = agent_executor.invoke({"input": user_question})
print("\n=== 最终结果 ===")
print(result["output"])
3. 底层机制回放:如果我们在控制台看着它运行,会发生什么?
因为开启了 verbose=True,你会看到控制台输出类似这样充满魔力的绿色文字:
> Entering new AgentExecutor chain...
(第一轮思考) Invoking:
multiplywith{'a': 5999, 'b': 2}(大模型自己决定调用乘法工具) 11998 (这是你在本地 Python 代码里5999 * 2返回的真实数字)(第二轮思考:它看了看 11998) Invoking:
addwith{'a': 11998, 'b': 199}(它把上一步的结果带入到了第二步,决定调用加法工具) 12197 (本地 Python 返回结果)(第三轮思考:大模型觉得答案齐了,输出最终文本) 总共是 12197 元。
> Finished chain.
这一切都是全自动的!在 AgentExecutor.invoke 结束前,这段代码在底层偷偷和 OpenAI 进行了足足 3次 的网络通信交互(大模型 -> 你的电脑算乘法 -> 大模型 -> 你的电脑算加法 -> 大模型得出结论)。
第 14 课:回调系统 (Callbacks) 与运行监控
当你把应用部署到生产环境(让真实用户去用)时,你会面临两个极其现实的问题:
-
用户体验: AI 思考太慢了,网页一直转圈圈。我要怎么让它像 ChatGPT 那样一个字一个字地往外蹦(流式输出)?
-
老板算账: API 是按字数(Token)收费的。我怎么才能在后台偷偷记录下每次对话究竟花了多少钱?
这就需要用到 LangChain 底层极其强大的回调系统(Callback System)。
1. 核心概念:什么是 Callback(回调)?
你可以把大模型的运行过程想象成一趟行驶的绿皮火车。 火车会经过不同的站点(比如:开始思考、生成一个字、调用工具、遇到报错、结束运行)。 Callback 就像是你派去驻扎在各个站点的"侦察兵"。 每当火车经过某个站点,侦察兵就会立刻给你发一条微信报告情况。
LangChain 提供了一个基类 BaseCallbackHandler,里面定义了各种"站点"的方法,比如:
-
on_llm_start: 大脑开始思考时触发 -
on_llm_new_token: 每生成一个新字时触发 (极其重要,用于流式打字效果) -
on_tool_start: 开始跑腿调用工具时触发 -
on_chain_end: 整个流水线全部跑完时触发
2. 代码实例:极其详细的注释版
今天我们要自己写一个"侦察兵",它可以同时做到两件事:在控制台实现打字机效果 ,并且在最后统计总共消耗了多少 Token。
python
import os
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
# 【新引入】导入基础的回调处理器
from langchain_core.callbacks import BaseCallbackHandler
load_dotenv()
# ================= 第一步:自定义我们的侦察兵 (Callback) =================
# 我们写一个类,继承自 BaseCallbackHandler
class MyCustomHandler(BaseCallbackHandler):
def __init__(self):
# 准备一个小本本,用来记总共收到了多少个字
self.token_count = 0
# 重写 on_llm_new_token 方法。
# 每当 OpenAI 的服务器返回一个字的包裹,LangChain 就会自动调用这个方法一次!
def on_llm_new_token(self, token: str, **kwargs) -> None:
self.token_count += 1 # 计数器加 1
# print 默认是会换行的,加上 end="" 和 flush=True,就能实现一个字一个字往后拼的打字效果
print(token, end="", flush=True)
# 重写 on_llm_end 方法。
# 当整段话全部生成完毕时,LangChain 会自动调用它。
def on_llm_end(self, response, **kwargs) -> None:
# 打字结束了,我们换个行,然后汇报一下计费情况
print(f"\n\n[后台监控] 报告老板!本次回答一共生成了 {self.token_count} 个 Token。")
# ================= 第二步:组装流水线 =================
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个讲故事的专家。请写一段 50 字左右的微小说。"),
("human", "{topic}")
])
# 注意看!
# 要想让模型一个字一个字地吐出来,必须在实例化模型时开启 streaming=True
model = ChatOpenAI(model="gpt-3.5-turbo", streaming=True)
chain = prompt | model
# ================= 第三步:带上侦察兵去执行 =================
print("AI 开始创作:\n")
# 我们在 invoke 的时候,通过 config 里的 callbacks 参数,把我们的侦察兵派出去
# 注意它是一个列表,意味着你可以同时派好几个不同的侦察兵去干不同的事
chain.invoke(
{"topic": "赛博朋克与流浪猫"},
config={"callbacks": [MyCustomHandler()]}
)
3. 进阶玩法:LangSmith 一键监控
在实际的公司项目里,大家通常不会自己写那么复杂的 Print 回调代码。LangChain 官方做了一个极其强大的平台叫 LangSmith。
只要你在 .env 文件里配好三个变量:
LANGCHAIN_TRACING_V2=true
LANGCHAIN_ENDPOINT="https://api.smith.langchain.com"
LANGCHAIN_API_KEY="你的_LangSmith_密钥"
你的代码连一行都不用改! LangChain 会在底层自动安插侦察兵,把你整个代码的耗时、花了多少钱、Prompt 是怎么拼接的、甚至报了什么错,全部以极漂亮的图表展示在他们的网页端后台上。
(由于这是外部服务,我们这里仅作概念介绍,无需强制运行)
第 15 课:自定义组件 (接入公司私有模型与搜索)
在前面的课程里,我们用的都是 ChatOpenAI 这样的官方现成组件。 但在真实的企业级开发中,你极大概率会遇到以下情况:
-
私有模型: 你们公司为了保密,花重金在内网部署了一个自己微调过的大模型(比如基于 Llama 3 或 Qwen),没有任何现成的 LangChain 包可以直接用。
-
私有搜索: 你们公司的文档不存在 Chroma 向量数据库里,而是存在公司自己研发的老旧搜索引擎里。
这个时候,你就必须学会"手搓"组件。只要你搓出来的组件符合 LangChain 的底层协议(Runnable),你就能把它无缝接入到之前学过的所有 LCEL 流水线里!
1. 核心概念:继承与重写接口
在 Python 面向对象编程中,LangChain 规定好了"模具"(基类)。你只需要拿一个模具过来,按它的要求把核心业务逻辑填进去即可。
-
自定义纯文本模型: 继承
LLM基类,重写_call方法。 -
自定义检索器: 继承
BaseRetriever基类,重写_get_relevant_documents方法。
2. 代码实例:极其详细的注释版
这段代码将演示:如何把一个完全虚构的"公司内部烂接口",包装成高级的 LangChain 组件,并用 LCEL | 连起来。
python
import time
from typing import Any, List, Optional
from langchain_core.callbacks.manager import CallbackManagerForLLMRun
from langchain_core.language_models.llms import LLM
from langchain_core.retrievers import BaseRetriever
from langchain_core.documents import Document
from langchain_core.prompts import PromptTemplate
# ================= 第一步:自定义公司内部的大模型 =================
# 继承 LangChain 底层的 LLM 基类
class MyCompanyInternalLLM(LLM):
# 你可以定义一些自定义参数,比如控制回答字数的限制
max_words: int = 100
# 【必须重写 1】告诉 LangChain 你这个模型叫什么类型 (用于后台日志记录)
@property
def _llm_type(self) -> str:
return "my_company_internal_model"
# 【必须重写 2】这是核心运行逻辑!当调用 invoke 时,其实底层就是跑这个函数
def _call(
self,
prompt: str, # 这是处理好之后传进来的纯文本提示词
stop: Optional[List[str]] = None,
run_manager: Optional[CallbackManagerForLLMRun] = None,
**kwargs: Any,
) -> str:
# ---------------- 模拟调用公司内网 API 的过程 ----------------
print(f"\n[内网通信] 正在将 Prompt 发送给公司机房...\nPrompt内容: {prompt}")
time.sleep(1) # 假装网络延迟
# 模拟模型瞎编的一个回答
fake_response = f"这是来自公司内网模型的绝密回答。你问的问题太深奥了,我只能说:无可奉告!(限制字数:{self.max_words})"
# -----------------------------------------------------------
# 返回纯字符串即可,LangChain 会自动帮你把它包装好
return fake_response
# ================= 第二步:自定义公司内部的搜索引擎 =================
# 继承 LangChain 的 BaseRetriever 基类
class MyCompanyRetriever(BaseRetriever):
# 【必须重写】获取相关文档的核心逻辑
def _get_relevant_documents(
self, query: str, *, run_manager
) -> List[Document]:
# ---------------- 模拟调用公司老旧数据库 ----------------
print(f"\n[内部搜索] 正在公司古老的数据库里搜索关键字: '{query}'...")
time.sleep(1) # 假装搜索很慢
# 假设我们查到了两条数据,我们必须把它包装成 LangChain 标准的 Document 对象!
doc1 = Document(page_content="内部机密文件 001:公司禁止带宠物上班。")
doc2 = Document(page_content="内部机密文件 002:食堂周五免费加鸡腿。")
# ---------------------------------------------------------
return [doc1, doc2]
# ================= 第三步:见证奇迹!用 LCEL 无缝组装自定义组件 =================
print("开始组装全自研 RAG 系统...")
# 1. 实例化我们手搓的组件
custom_retriever = MyCompanyRetriever()
custom_llm = MyCompanyInternalLLM(max_words=50)
# 2. 准备提示词模板
# 这里为了简便,我们用基础的纯文本 PromptTemplate
prompt = PromptTemplate.from_template(
"请根据以下参考资料回答问题:\n{context}\n\n问题:{question}"
)
# 3. 经典的并行字典开头
setup_dict = {
"context": custom_retriever, # 使用手搓的检索器
"question": lambda x: x # 这是一个 Python 小技巧,等同于 RunnablePassthrough(),原样传递
}
# 4. LCEL 组装链条!注意看,用法和之前连接 OpenAI 时一模一样!
custom_rag_chain = setup_dict | prompt | custom_llm
# ================= 第四步:执行 =================
user_input = "明天周五食堂有什么好吃的?"
print(f"\n用户提问: {user_input}")
# invoke 触发整个全自研流水线
result = custom_rag_chain.invoke(user_input)
print("\n最终拿到结果:")
print(result)
3. 为什么掌握这一点才算真正入门了 LangChain?
很多初学者抱怨:"LangChain 太臃肿了,我的业务特殊,它现成的工具根本满足不了我,我还不如不用它。" 这其实是因为没有理解 LangChain 的接口解耦设计 。 LangChain 从来不是要限制你用什么工具,而是为你提供了一个统一的插座板(Runnable 协议)。只要你按照上面的方法,把你们公司的系统插头稍微改造一下,插到这个插座板上,你就能瞬间白嫖 LangChain 所有的生态能力(比如自动拥有流式输出、自动拥有重试机制、自动接入 LangSmith 监控、能接进 Agent 框架)。