大模型应用实战(一):如何解决大模型开发中的上下文窗口与成本难题?
你是否曾满怀激情地调用大模型API,却发现它像金鱼一样,只有七秒记忆,刚刚才告诉它的事情,转头就忘得一干二净?或者,你只是跑了几个测试,月底收到账单时,才发现数字高得令人心跳加速?
如果这些场景让你感同身受,那么恭喜你,你已经踩过了每个大模型开发者必经的两个“大坑”:恼人的上下文窗口(Context Window)限制和高昂的API调用成本。
别担心,这并不是你一个人的困扰。这两个问题,是横亘在无数创意和落地应用之间的两座大山。而本系列教程的目的,就是带你一起,亲手凿穿它们。我们将摒弃空洞的理论,从零开始,手把手地带你构建一个完整的项目。在这个过程中,你不仅会理解问题的本质,更将学会一套行之有效的解决方案,一套能帮你“省钱又高效”的开发心法。
准备好了吗?让我们一起,从最基础的概念开始,踏出坚实的第一步。
第一部分:拨开迷雾 —— 理解两大核心挑战
在动手写代码之前,我们必须先弄清楚我们的“敌人”究竟是谁。
1. 什么是“上下文窗口”(Context Window)?
你可以把大模型的“上下文窗口”想象成它的短期记忆。当你和模型进行一次对话(或者给它一段文本让它处理)时,它并不是真的“理解”了全部内容,而只是在它有限的记忆范围内进行分析和回应。
这个记忆的容量,就是由“上下文窗口”的大小决定的。一旦你输入的信息超出了这个窗口,模型就会像一个记性不好的朋友,忘记掉最开始的部分。
这个容量是如何计算的呢?答案是 Token。
Token是模型处理文本的最小单位。对于英文来说,一个单词约等于1.3个Token;对于中文,一个汉字大约是2个Token。例如,一个拥有4096 Token上下文窗口的模型,意味着它一次最多只能“记住”大约3000个英文单词或2000个汉字的内容。
实际影响是什么?
假设你想让模型总结一本长达数万字的小说。如果你简单地将整本小说的内容直接丢给它,模型很可能只会根据它能“看到”的最后几千字来给出总结,而忽略了开头的关键情节。这就是上下文窗口限制带来的最直接的问题——信息丢失。
2. “API成本”是如何产生的?
天下没有免费的午餐,强大的模型背后是昂贵的计算资源。目前,主流的大模型服务商,如OpenAI,其API调用是按量计费的,计费单位同样是 Token。
通常,成本分为两部分:
- 输入(Prompt Tokens): 你发送给模型的文本所包含的Token数量。
- 输出(Completion Tokens): 模型为你生成的回答所包含的Token数量。
这意味着,你和模型说的每一句话,它回复的每一个字,都在燃烧你的经费。在开发调试阶段,我们可能会频繁地发送请求,如果不加控制,即使是处理一些中等长度的文本,成本也会迅速累积。
核心矛盾
现在,我们面临的核心矛盾就显而易见了:
- 我们希望模型能处理尽可能长的文本,拥有更全面的“记忆”,以完成复杂的任务(扩展上下文)。
- 我们希望尽可能减少发送和接收的Token数量,以控制我们的项目预算(降低成本)。
这看似不可调和的矛盾,正是我们这篇教程要用技术和智慧去解决的。
第二部分:环境准备 —— 搭建我们的实验平台
理论讲完了,让我们开始动手实践。
1. 项目目标
我们将构建一个简单而实用的**“智能文档问答机器人”**。这个机器人可以读取你提供的一个本地文本文档,然后根据文档内容,回答你的任何提问。这个项目虽小,却五脏俱全,能让我们直面并解决前面提到的两大核心挑战。
2. 技术选型与理由
-
编程语言:Python (3.8或更高版本)
- 为什么? Python是目前人工智能和数据科学领域当之无愧的王者,拥有最庞大、最活跃的社区和最丰富的第三方库支持。我们接下来要用到的所有工具,几乎都是为Python优先打造的。
-
核心框架:LangChain
- 为什么? LangChain是一个强大的大模型应用开发框架。它将与大模型交互的许多复杂、重复的步骤(如文本分割、API调用、数据管理等)都封装成了简单易用的模块。使用它,我们可以不必从最底层的API请求开始写起,从而极大地提高开发效率。
-
大模型API:OpenAI API
- 为什么? OpenAI的GPT系列模型是当前行业的标杆,其API接口设计成熟、文档清晰,非常适合初学者入门。我们将使用它的模型来驱动我们的机器人。
3. 一步一脚印:环境搭建指南
让我们像搭建乐高一样,一步步构建起我们的开发环境。
第一步:创建并激活虚拟环境
为了不污染你电脑上全局的Python环境,也为了让我们的项目依赖清晰明了,使用虚拟环境是一个非常好的习惯。
打开你的终端(在Windows上是命令提示符或PowerShell,在macOS或Linux上是Terminal),进入你打算存放项目的文件夹,然后执行以下命令:
# 创建一个名为 "venv" 的虚拟环境文件夹
python -m venv venv
# 激活这个环境
# 在 macOS 或 Linux 上:
source venv/bin/activate
# 在 Windows 上:
.\venv\Scripts\activate
激活成功后,你会看到终端提示符前面出现 (venv) 的字样。这表示你现在所有的操作都将在这个独立的环境中进行。
第二步:安装必要的Python库
在激活的虚拟环境中,执行以下命令来安装我们项目所需的所有第三方库:
pip install openai langchain python-dotenv chromadb tiktoken
我们来解释一下每个库的作用:
openai: OpenAI官方提供的Python库,是与GPT模型API交互的桥梁。langchain: 我们选择的核心开发框架,用于组织和管理整个应用流程。python-dotenv: 一个实用的小工具,可以帮助我们从一个名为.env的文件中加载环境变量,这样我们就不需要把敏感的API密钥直接写在代码里。chromadb: 一个开源的、轻量级的向量数据库。它将成为我们机器人的“外挂大脑”,用来存放文档的“记忆片段”。tiktoken: OpenAI官方出品的Token计算工具。它能帮助我们在将文本发送给模型之前,就精确地计算出它会消耗多少Token,是成本控制的关键。
第三步:获取并配置API密钥
- 访问 OpenAI的官方网站 并注册一个账户。
- 登录后,在个人中心的“API keys”页面创建一个新的密钥。
- 重要: 这个密钥只会出现一次,请立即复制并妥善保管。不要泄露给任何人,也不要直接上传到GitHub等公开代码库中。
第四步:初始化我们的项目结构
现在,让我们把项目的文件结构建立起来。
- 在你选择的工作目录下,创建一个新的文件夹,命名为
llm_cost_saver。 - 进入这个文件夹,创建两个文件:
- 一个名为
.env的文件。 - 一个名为
main.py的文件。
- 一个名为
打开 .env 文件,在里面写下如下内容,并将sk-xxxxxxxxxx替换为你刚刚复制的OpenAI API密钥:
OPENAI_API_KEY="sk-xxxxxxxxxx"
这个.env文件专门用来存放我们的密钥等敏感信息。通过python-dotenv库,我们的Python代码可以自动读取这个文件,既方便又安全。
main.py将是我们的主战场,所有的代码逻辑都将在这里编写。
至此,我们的准备工作全部完成!一个干净、独立、配置齐全的开发环境已经就绪。下一部分,我们将正式进入核心技术的实战环节。
第三部分:核心技术实战 —— RAG(检索增强生成)
还记得我们提到的上下文窗口限制吗?如果我们想让模型处理一篇长文档,直接把全文丢过去是行不通的。那么,该怎么办呢?答案就是使用一种名为RAG (Retrieval-Augmented Generation,检索增强生成) 的技术。
1. 解决上下文窗口的“魔法”—— RAG是什么?
RAG的核心思想非常直观:既然模型的“短期记忆”有限,那我们就不把所有信息都硬塞给它。取而代之,我们给它建立一个外部的、可以随时查询的“知识库”(就像一个外挂大脑)。
当用户提出一个问题时,我们不再直接把问题丢给模型,而是先进行一步“预处理”:
- 检索(Retrieval): 首先,我们拿着用户的问题,去我们的“知识库”里,找到与问题最相关的几段信息。
- 增强(Augmented): 然后,我们将这些检索到的相关信息,连同用户的原始问题一起,打包成一个新的、信息更丰富的提示(Prompt)。
- 生成(Generation): 最后,我们把这个“增强”后的提示发送给大模型。此时,模型看到的不再是一个孤零零的问题,而是问题以及解决这个问题所需的所有背景资料。它只需要根据这些现成的资料,进行总结和回答即可。
通过这种方式,无论原始文档有多长,我们每次喂给模型的都只是与当前问题最相关的几个“片段”。这不仅完美绕过了上下文窗口的限制,还极大地提高了回答的准确性,并显著降低了API调用的Token消耗。
下面是RAG的工作流程图,能帮助你更清晰地理解整个过程:
graph TD
subgraph "准备阶段 (数据预处理)"
G[你的原始长文档] --> H{文本分割器};
H -- 切分成小块 --> I[文本片段 Chunks];
I --> J{Embedding模型};
J -- 转化为向量 --> K[向量数据];
K --> L[存入向量数据库];
end
subgraph "问答阶段 (实时查询)"
A[用户提出问题] --> B{Embedding模型};
B -- 同样转化为向量 --> C[问题向量];
C --> D{检索模块};
L -- 供检索 --> D;
D -- 在数据库中查找最相似的向量 --> E[相关的文档片段];
E --> F[Prompt拼接模块];
A -- 原始问题 --> F;
F -- 拼接成最终Prompt --> H_LLM{大语言模型 (LLM)};
H_LLM --> I_Final[生成最终答案];
end
2. 代码实战:从零构建RAG流程
现在,让我们打开main.py文件,用代码将上面的流程一步步实现。
准备工作:创建一个示例文档
在llm_cost_saver文件夹中,创建一个名为my_document.txt的文本文件,并粘贴以下内容:
项目背景:
“奥德赛计划”是一项旨在探索A-7行星系中潜在生命形式的长期太空探索项目。该计划由全球航天联合会(GSA)于2042年启动,核心任务是利用“探索者四号”无人探测器对该星系的三颗主要行星(阿尔法、贝塔、伽马)进行详细勘察。
技术细节:
“探索者四号”探测器搭载了最新的光谱分析仪、地质采样钻和高分辨率成像系统。其动力来源是微型核聚变反应堆,理论上可以支持长达50年的持续运作。探测器通过超光速量子纠缠通信技术与地球指挥中心保持联系,数据传输延迟仅为数分钟。
项目进展:
目前,“探索者四号”已成功抵达A-7星系,并完成了对阿尔法星的初步探测。数据显示,阿尔法星大气中含有微量的甲烷和氧气,这为存在微生物生命提供了可能性,但尚无直接证据。下一步,探测器将前往贝塔星,预计航程为六个月。贝塔星地表被厚厚的冰层覆盖,科学家猜测冰层下可能存在液态水海洋。伽马星由于轨道不稳定,暂被列为低优先级目标。
这个文档将成为我们机器人的知识库。
第一步:加载并配置环境
在main.py的顶部,我们先导入必要的库,并加载.env文件中的API密钥。
import os
from dotenv import load_dotenv
# 导入LangChain相关的库
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings, OpenAI
# 加载 .env 文件中的环境变量
# 这行代码会查找当前目录下的 .env 文件,并把里面定义的变量加载到系统的环境变量中
load_dotenv()
# 从环境变量中获取 OpenAI API Key
# 这样做的好处是避免将敏感信息硬编码在代码里
api_key = os.getenv("OPENAI_API_KEY")
# 检查 API Key 是否成功加载
if not api_key:
raise ValueError("未找到 OpenAI API Key,请检查你的 .env 文件。")
print("环境配置成功!")
# 定义我们将要使用的文档路径
DOCUMENT_PATH = "my_document.txt"
第二步:加载与分割文档
我们需要把my_document.txt读入程序,并将其切分成更小的、易于处理的块(Chunks)。
# ... (接上面的代码)
def split_document(doc_path):
"""
加载并分割指定的文本文档。
:param doc_path: 文档的路径。
:return: 分割后的文本块列表。
"""
print(f"正在加载文档: {doc_path}")
# 1. 加载文档
# TextLoader 是 LangChain 提供的一个工具,专门用来加载纯文本文档。
loader = TextLoader(doc_path, encoding='utf-8')
documents = loader.load()
# 2. 分割文档
# RecursiveCharacterTextSplitter 是一个智能的文本分割器。
# 它会尝试按段落、句子、单词等顺序来分割文本,以保持语义的完整性。
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=200, # 每个块的目标大小(字符数)。可以根据文档内容和模型上下文窗口调整。
chunk_overlap=20, # 相邻块之间的重叠字符数。这有助于在块的边界处保持上下文的连续性。
length_function=len, # 使用 Python 内置的 len 函数来计算文本长度。
)
splitted_docs = text_splitter.split_documents(documents)
print(f"文档分割完成,共得到 {len(splitted_docs)} 个文本块。")
return splitted_docs
# 执行分割
document_chunks = split_document(DOCUMENT_PATH)
第三步:向量化与存储
接下来,我们需要将这些文本块“翻译”成模型能理解的数学语言——向量(Embeddings),并存入我们的向量数据库Chroma中。
# ... (接上面的代码)
def create_vector_store(docs, embeddings):
"""
根据文档块和指定的 embedding 模型,创建并持久化一个向量数据库。
:param docs: 分割后的文档块列表。
:param embeddings: 用于生成向量的 embedding 模型实例。
:return: 创建好的向量数据库实例。
"""
print("正在创建向量数据库...")
# 使用 Chroma.from_documents 方法可以一步到位:
# 1. 为每个文档块生成 embedding。
# 2. 将文档和其对应的 embedding 存入 Chroma 数据库中。
# persist_directory 参数指定了数据库在磁盘上的存储位置。
vector_store = Chroma.from_documents(
documents=docs,
embedding=embeddings
)
print("向量数据库创建成功!")
return vector_store
# 实例化 OpenAI 的 embedding 模型
# 这个模型专门负责将文本转换成高维向量
openai_embeddings = OpenAIEmbeddings(openai_api_key=api_key)
# 创建向量数据库
vector_store = create_vector_store(document_chunks, openai_embeddings)
第四步:创建检索器并发起提问
万事俱备!现在我们可以创建一个检索器(Retriever),它能根据我们的问题,从向量数据库中找出最相关的文档片段。
# ... (接上面的代码)
# 实例化一个大语言模型(LLM)
# 我们在这里使用 OpenAI 的基础模型,它在成本和性能之间取得了很好的平衡
llm = OpenAI(openai_api_key=api_key, temperature=0) # temperature=0 表示我们希望模型给出更确定性的、而非创造性的回答
# 从向量数据库创建一个检索器
# .as_retriever() 是一个便捷方法,它将向量数据库转换为一个标准的 LangChain Retriever 接口。
retriever = vector_store.as_retriever()
# 使用 LangChain 的 RetrievalQA 链
# 这个“链”将检索和问答两个步骤优雅地串联了起来。
from langchain.chains import RetrievalQA
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff", # "stuff" 是最简单的链类型,它会把检索到的所有文档片段“塞”进一个 Prompt 中。
retriever=retriever
)
# 开始提问!
question1 = "“奥德赛计划”的目标是什么?"
answer1 = qa_chain.invoke(question1)
print(f"问题: {question1}")
print(f"答案: {answer1['result']}\n")
question2 = "探测器使用什么技术与地球通信?"
answer2 = qa_chain.invoke(question2)
print(f"问题: {question2}")
print(f"答案: {answer2['result']}\n")
question3 = "探测器下一步要去哪个星球?为什么?"
answer3 = qa_chain.invoke(question3)
print(f"问题: {question3}")
print(f"答案: {answer3['result']}\n")
现在,将以上所有代码片段整合到main.py文件中,然后在终端中运行它:
python main.py
你应该能看到,程序会准确地根据my_document.txt的内容回答你的问题,而它并没有将整个文档都发送给OpenAI。我们成功了!我们用RAG技术,漂亮地解决了上下文窗口的难题。
第四部分:成本控制的艺术 —— 做一个精明的开发者
解决了上下文问题,我们再来看看另一个核心——成本。虽然RAG本身已经通过减少发送的文本量大大节约了成本,但我们还可以做得更好。
策略一:选择合适的模型
OpenAI提供了多种不同能力的模型,它们的价格差异巨大。例如,GPT-4的功能强大,但价格昂贵;而GPT-3.5-Turbo等模型,在处理许多常规任务时已经绰绰有余,价格却便宜得多。
核心原则:杀鸡焉用牛刀。
在LangChain中切换模型非常简单。你只需要在实例化LLM时,指定model_name即可。例如,如果你想使用成本更低的gpt-3.5-turbo-instruct模型(一个针对指令优化的GPT-3.5模型),可以这样修改代码:
# 将
# llm = OpenAI(openai_api_key=api_key, temperature=0)
# 修改为
llm = OpenAI(model_name="gpt-3.5-turbo-instruct", openai_api_key=api_key, temperature=0)
仅仅是这一行代码的改动,就可能让你的API成本降低90%以上,而回答质量在很多场景下并无明显下降。
策略二:精确的Token计算
要想控制成本,首先要能度量成本。tiktoken库就是我们的“成本计算器”。我们可以在发送API请求之前,就精确地知道将要花费多少Token。
让我们来编写一个简单的函数来计算Token数量:
import tiktoken
def count_tokens(text: str, model: str = "gpt-3.5-turbo") -> int:
"""
计算给定文本在特定模型下会产生的Token数量。
:param text: 需要计算的文本。
:param model: 目标模型的名称。
:return: Token数量。
"""
try:
# 获取对应模型的编码器
encoding = tiktoken.encoding_for_model(model)
except KeyError:
# 如果模型没有特定的编码器,使用一个通用的
encoding = tiktoken.get_encoding("cl100k_base")
# 对文本进行编码,并计算编码后的token数量
num_tokens = len(encoding.encode(text))
return num_tokens
# 示例:
my_prompt = "“奥德赛计划”的目标是什么?"
token_count = count_tokens(my_prompt)
print(f"'{my_prompt}' 这段文本的Token数量是: {token_count}")
这个函数非常有用。在复杂的应用中,你可以在每次调用LLM之前都用它来监控Token消耗,当消耗即将超出预算时,可以进行报警或中断操作。
策略三:优化Prompt
发送给模型的Prompt,是我们成本的直接来源之一。因此,言简意赅是优化Prompt的核心原则。
- 避免冗余: 删除不必要的寒暄、背景描述和重复的指令。
- 使用清晰的指令: 直接告诉模型要做什么,而不是让它去猜测。
示例对比:
啰嗦的Prompt (高成本):
“你好,GPT。我现在正在处理一个关于太空探索的项目文档,文档里提到了一个叫‘奥德赛计划’的东西。我希望你能仔细阅读我接下来提供给你的相关资料,然后帮我总结一下,这个所谓的‘奥德赛计划’,它最主要的目标和任务究竟是什么呢?请用简洁的语言告诉我。”
精炼的Prompt (低成本):
“根据以下资料,总结‘奥德赛计划’的目标:\n[此处插入检索到的资料]”
通过对比两者的Token数量,你会发现精炼后的Prompt能为你省下可观的费用,尤其是在高频调用的场景下。
策略四:巧妙利用检索结果
在我们的RAG流程中,从向量数据库中检索回多少文档片段,也是一个可以优化的参数。
在创建retriever时,我们可以通过search_kwargs来控制检索的数量:
# k=2 表示我们每次只从数据库中检索出最相关的2个文档片段
retriever = vector_store.as_retriever(search_kwargs={"k": 2})
k值的选择需要权衡:
- k值太小: 可能导致提供给模型的上下文信息不足,无法准确回答问题。
- k值太大: 会增加Prompt的Token数量,从而增加API成本,甚至可能因为信息过多、掺入噪声而干扰模型的判断。
通常,从k=3或k=4开始试验,是一个不错的选择。你可以根据实际应用的效果来动态调整这个值。
好的,我们继续这个系列。在上一篇文章中,我们成功构建了一个能够理解长文档并回答问题的“智能文档问答机器人”,解决了上下文窗口与初步的成本问题。现在,让我们来解决下一个关键挑战:让我们的机器人拥有记忆。
大模型应用实战(二):如何为你的知识库机器人添加对话记忆?
在上一篇教程中,我们从零开始,成功构建了一个能够基于本地文档回答问题的“智能文档问答机器人”。它很棒,因为它能“读懂”长篇大论,但它也有个明显的缺点:它是个“健忘症患者”。
你和它的每一次互动都是一次全新的开始。你无法问它一个跟进的问题,因为它根本不记得你们上一秒聊了些什么。比如,你尝试这样和它对话:
你: “奥德赛计划的目标是什么?”
机器人: (基于文档给出了准确的回答…)
你: “那它是由哪个组织启动的?”
机器人: “对不起,我不理解‘它’指的是什么。”
看到问题了吗?这种“一问一答”式的交互,离我们想要的流畅“对话”还相去甚远。
在本篇实战教程中,我们将为我们的机器人进行一次关键的升级——植入“对话记忆”(Conversational Memory)。我们将让它告别健忘,学会联系上下文,真正理解我们的意图,从一个“问答工具”蜕变为一个真正的“对话伙伴”。
准备好给你的造物赋予记忆了吗?让我们开始吧。
第一部分:为什么“记忆”如此重要?
从“问答”到“对话”,看似一词之差,实则是一次质的飞跃。
单一的问答(Q&A)是无状态的(Stateless)。每一次请求都是完全独立的,就像在搜索引擎里一次又一次地输入查询。我们上一篇文章中使用的RetrievalQA链就是这种模式。它非常适合一次性的信息查询,但无法处理连续的交流。
而真正的对话是有状态的(Stateful)。后续的话语往往依赖于之前的内容。代词(“它”、“那个”)、省略的成分(“下一步去哪?”)、话题的延续,都建立在一个共享的上下文之上。
为了跨越这道鸿沟,我们的应用必须具备“记忆”能力。在大型语言模型应用中,记忆模块的核心作用有两个:
- 存储对话历史: 这是最基本的功能。它会像聊天记录一样,把用户和机器人之间的每一轮对话都保存下来。
- 重构后续问题: 这是实现流畅对话的关键。当用户提出一个依赖上下文的模糊问题时(例如,“它的动力来源呢?”),记忆系统会和LLM协作,结合之前存储的对话历史,将这个问题“翻译”成一个独立的、信息完整的、可供检索系统使用的新问题。比如,它会自动重构为:“‘探索者四号’探测器的动力来源是什么?”。
有了这个重构后的清晰问题,我们上一章构建的RAG系统才能准确地在向量数据库中找到相关信息,并给出正确的答案。
第二部分:LangChain中的“记忆”模块
幸运的是,强大的LangChain框架为我们提供了开箱即生的Memory组件。
它的设计理念,就是提供一个标准化的接口,来管理和操作对话的状态。开发者可以像插拔U盘一样,轻松地为自己的应用链(Chain)装上不同类型的“记忆芯片”。
虽然LangChain提供了多种记忆类型,但我们先了解几个最常见的:
ConversationBufferMemory: 这是最直接的一种记忆。它会把所有的对话历史原封不动地存储在一个变量里。它就像一个无限容量的记事本,简单、直观,非常适合我们的入门项目和不太长的对话场景。ConversationBufferWindowMemory: 这是BufferMemory的升级版。它只保留最近的k轮对话。这就像一个只能记住最近几件事的朋友,好处是能防止对话历史变得过长,从而撑爆模型的上下文窗口,导致成本失控。ConversationSummaryMemory: 当对话变得非常长时,前面两种方式都可能不太适用。这种记忆类型会调用一个LLM,在后台悄悄地把越来越长的对话历史总结成一段精炼的摘要。这是一种更高级、成本也更高的“记忆压缩”技术。
本次升级,我们将选用最基础也是最核心的 ConversationBufferMemory。它的原理足够我们理解记忆的工作机制,并且完全能满足当前的需求。
第三部分:代码升级 —— 植入记忆芯片
理论讲完,立刻开干!让我们回到上一篇创建的main.py文件,对其进行一次“大脑手术”。
1. 迎接新主角:ConversationalRetrievalChain
首先,我们需要认识到一个重要事实:我们之前使用的RetrievalQA链,其设计初衷就是为了无状态的问答,它本身并不支持接入记忆模块。
为了实现对话功能,我们需要请出一位新主角——ConversationalRetrievalChain。这个链是LangChain专门为“可对话的检索问答”场景量身打造的,它内建了处理对话历史和重构问题的逻辑。
它的内部工作流程如下图所示,这正是我们前面理论部分所描述的:
graph TD
A[用户后续问题<br><i>(例如: "它的动力是什么?")</i>] --> B{ConversationalRetrievalChain};
C[对话历史 (Memory)] --> B;
B --> D[1. 调用LLM进行问题重构];
D -- 生成 --> E[独立的、完整的新问题<br><i>(例如: "探索者四号的动力是什么?")</i>];
E --> F{2. 检索模块 (Retriever)};
G[向量数据库] --> F;
F -- 查找相关文档 --> H[相关文档片段];
E --> I{3. 调用LLM进行问答};
H --> I;
I -- 生成 --> J[最终答案];
B -- 输出 --> J;
J --> C; A --> C
2. 动手改造 main.py
现在,让我们对代码进行修改。请注意,改动的地方并不多,但每一步都很关键。
import os
from dotenv import load_dotenv
# --- 1. 导入新的模块 ---
from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferMemory
# --- (其他导入保持不变) ---
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings, OpenAI
# 加载环境变量
load_dotenv()
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("未找到 OpenAI API Key,请检查你的 .env 文件。")
# 定义文档路径
DOCUMENT_PATH = "my_document.txt"
def split_document(doc_path):
"""加载并分割指定的文本文档。"""
print(f"正在加载文档: {doc_path}")
loader = TextLoader(doc_path, encoding='utf-8')
documents = loader.load()
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=200,
chunk_overlap=20,
length_function=len,
)
splitted_docs = text_splitter.split_documents(documents)
print(f"文档分割完成,共得到 {len(splitted_docs)} 个文本块。")
return splitted_docs
def create_vector_store(docs, embeddings):
"""根据文档块和 embedding 模型,创建向量数据库。"""
print("正在创建向量数据库...")
vector_store = Chroma.from_documents(documents=docs, embedding=embeddings)
print("向量数据库创建成功!")
return vector_store
# --- 主逻辑开始 ---
# 1. 分割文档
document_chunks = split_document(DOCUMENT_PATH)
# 2. 创建并持久化向量数据库
openai_embeddings = OpenAIEmbeddings(openai_api_key=api_key)
vector_store = create_vector_store(document_chunks, openai_embeddings)
# 3. 实例化 LLM
llm = OpenAI(model_name="gpt-3.5-turbo-instruct", openai_api_key=api_key, temperature=0)
# --- 2. 实例化 Memory ---
# 创建一个对话缓冲记忆实例
# memory_key="chat_history" 是告诉链(Chain),对话历史应该存储在哪个变量中。
# return_messages=True 表示记忆的输出将是消息对象列表,而不是简单的字符串。这是推荐的做法。
memory = ConversationBufferMemory(
memory_key="chat_history",
return_messages=True
)
# --- 3. 创建带有记忆的对话检索链 ---
# 我们使用 ConversationalRetrievalChain.from_llm 来创建一个实例。
# 这个工厂方法为我们配置好了大部分默认设置。
qa_chain = ConversationalRetrievalChain.from_llm(
llm=llm,
retriever=vector_store.as_retriever(),
memory=memory # 将我们刚刚创建的 memory 实例关联到链上
)
# --- 4. 改造主逻辑为可持续对话的循环 ---
print("\n你好!我是你的文档问答机器人。输入 '退出' 来结束对话。")
chat_history = [] # 用于在循环外部手动跟踪历史,方便打印
while True:
try:
query = input("你: ")
if query.lower() == '退出':
print("机器人: 很高兴为您服务,再见!")
break
# 调用链来获取回答
# 注意:现在调用链时需要传入问题和空的 chat_history
# 链会自动从 memory 中读取并管理历史记录
result = qa_chain.invoke({"question": query, "chat_history": chat_history})
# 从结果中提取答案
answer = result['answer']
print(f"机器人: {answer}")
except Exception as e:
print(f"发生错误: {e}")
break
代码改动解析:
- 导入新模块: 我们从
langchain.chains导入了ConversationalRetrievalChain,并从langchain.memory导入了ConversationBufferMemory。 - 实例化Memory: 我们创建了一个
ConversationBufferMemory的实例。这是我们机器人的“记忆芯片”。 - 创建新Chain: 我们用
ConversationalRetrievalChain.from_llm替换了原来的RetrievalQA。最关键的一步是,我们将memory实例传递给了它。这样,链和记忆就关联起来了。 - 改造为对话循环: 我们用一个
while True:循环代替了原来单次的提问代码。这让我们可以和机器人进行连续的对话,直到我们输入“退出”。注意,在调用qa_chain.invoke时,虽然我们传入了一个空的chat_history,但链内部会自动从我们设置的memory对象中拉取并更新真正的对话历史。
第四部分:见证奇迹 —— 测试我们的新机器人
保存好你的main.py文件。现在,回到你的终端,像之前一样运行脚本:
python main.py
程序启动后,请你严格按照下面的顺序,来和你的机器人进行一次对话,亲身感受“记忆”的力量:
第一轮对话(建立初始上下文):
你: 奥德赛计划是做什么的?
机器人: (它应该会准确回答,这是关于探索A-7行星系潜在生命形式的太空项目…)
第二轮对话(测试代词指代):
你: 那它是由哪个组织启动的?
机器人: (见证奇迹的时刻!它现在应该能理解“它”指的就是“奥德赛计划”,并回答出“全球航天联合会(GSA)”。)
第三轮对话(测试省略主语):
你: 下一步要去哪里?
机器人: (它同样应该能联系上下文,理解你问的是“探测器”的下一步计划,并回答出将前往贝塔星。)
结果分析:
是不是感觉完全不同了?你的机器人不再是一个冷冰冰的查询工具,它开始能够“理解”你们之间的对话流了。每一次提问,ConversationalRetrievalChain都在后台默默地帮你把模糊的问题变得清晰,然后再去知识库里寻找答案。这背后的一切,都归功于我们刚刚植入的ConversationBufferMemory。


被折叠的 条评论
为什么被折叠?



