Java程序员必做项目!基于LangChain实现的ReAct智能体项目。助你拿offer!(第三期)

0 阅读15分钟

4、文档检索bug修复

可以看到,llm根据知识库文档检索出了内容并给出了答案,但是会发现,为什么检索的四篇相关的文档都是同一片文档呢?

根本原因就在于:根本原因在于:

我们的文档在分块(text splitting)时,被切成了多个重叠或高度相似的 chunk,而这些 chunk 都被向量化并存入了向量数据库。检索时,它们都与查询高度相似,因此都被返回了。

我们的java-thread-pool.md 文件开头是:

# Java 线程池最佳实践  
## 场景与目标  
- 统一管理线程...  
- 控制并发度...  
## 核心参数与含义  
- 核心线程数 corePoolSize...

这种标题 + 列表的结构,在分块时容易导致多个 chunk 都包含相同的开头部分(尤其是当 chunk_size 不够大时)。

而我们的分块策略是**CharacterTextSplitter 的分块策略**

text_splitter = CharacterTextSplitter(
    separator="\n\n", 
    chunk_size=500, 
    chunk_overlap=80
)
  • separator="\n\n":按空行切分
  • 但如果文档中段落很长,或者开头没有空行,它会 fallback 到按字符切(每 500 字切一次)
  • chunk_overlap=80:前后块重叠 80 字符 → 进一步增加重复
  • 结果:文件开头的大段内容被切成多个“几乎一样”的 chunk。

Chroma 默认返回 top-k 个最相似结果

我们设置了 search_kwargs={"k": 4},所以即使 4 个 chunk 内容高度重复,只要它们的 embedding 相似度都排前 4,就会全部返回。

那我们怎么去解决这个问题呢,最简单的是使用专业的MarkdownTextSplitter,这是langchain中专门分割md文档的分割器,还有就是调整我们的分割策略:

text_splitter = CharacterTextSplitter(
    separator="\n## ",  # 在二级标题处分割
    chunk_size=800,     # 增大块大小,减少碎片
    chunk_overlap=100,
    length_function=len,
    is_separator_regex=False
)

我们这里呢就使用MarkdownTextSplitter,修改对应的文档分割器代码

from langchain.text_splitter import MarkdownTextSplitter
​
# 如果主要是 .md 文件
text_splitter = MarkdownTextSplitter(
    chunk_size=500,
    chunk_overlap=50
)

然后在运行代码:

0it [00:00, ?it/s]
100%|██████████| 8/8 [00:00<00:00, 5464.00it/s]
0it [00:00, ?it/s]
/Users/fenghuanwang/PythonProject/langchain-test/agent/ai_client.py:75: LangChainDeprecationWarning: The class `Chroma` was deprecated in LangChain 0.2.9 and will be removed in 1.0. An updated version of the class exists in the :class:`~langchain-chroma package and should be used instead. To use it run `pip install -U :class:`~langchain-chroma` and import as `from :class:`~langchain_chroma import Chroma``.
  vectorstore = Chroma(
新增 23 个文档片段
RAG 链初始化完成
​
✅ 向量数据库检索到 4 篇相关文档:
  📄 [1] 来源: /Users/fenghuanwang/PythonProject/langchain-test/doc/java-thread-pool.md | 预览: "# Java 线程池最佳实践  ## 场景与目标 - 统一管理线程,避免频繁创建/销毁的开销 - 控制并发度与任务排队策略,匹配业务峰谷  ## 核心参数与含义 - 核心线程数 corePoolSize:常驻线程数量 - 最大线程数 maximumPoolSize:峰值并发上限 - 存活时间 kee..."
  📄 [2] 来源: /Users/fenghuanwang/PythonProject/langchain-test/doc/java-thread-pool.md | 预览: "```  ## 监控与降级 - 指标:活跃线程、队列长度、任务耗时分位、拒绝次数 - 降级:限流/丢弃非关键任务/降级功能开关  ## 常见坑 - 使用 Executors 工具类默认配置可能导致 OOM(无界队列/最大线程数不受控) - 线程泄漏:任务异常未捕获,线程死锁 - 任务不可中断:IO..."
  📄 [3] 来源: /Users/fenghuanwang/PythonProject/langchain-test/doc/java-thread-pool.md | 预览: "## 典型构造 ```java ExecutorService pool = new ThreadPoolExecutor(     corePoolSize,     maximumPoolSize,     60L, TimeUnit.SECONDS,     new LinkedBlockin..."
  📄 [4] 来源: /Users/fenghuanwang/PythonProject/langchain-test/doc/java-concurrency.md | 预览: "## 场景示例 - 高并发计数:LongAdder - 高读低写缓存:ReadWriteLock 或 Caffeine - 任务编排:CompletableFuture 并行拉取 + 超时兜底..."
​
自定义线程池需设置核心线程数、最大线程数、空闲存活时间、任务队列(建议有界)、线程工厂(命名规范)和拒绝策略。根据业务类型选择参数:CPU密集型用较小核心线程数,IO密集型适当增加。避免使用Executors默认无界队列,防止OOM。建议监控队列长度和拒绝次数,动态调整。
Process finished with exit code 0

这时候四篇文章就不会是相同的了,

ps:如果没有改变的话,是因为存在之前运行代码所缓存的向量数据,把存储向量数据的文件夹内的所有文件都删了就好了。

但是,这还不够,我们会发现,如果我们在问完一个问题之后,在想接着询问的时候llm不知道我们再说什么,也就是llm没有把之前的对话作为上下文。专业的说就是,现在我们所开发的llm还没有对话记忆的功能,那么下面,我们就来实现一下。

5、对话记忆实现(基于内存)

要想实现对话记忆,又很多方式,包括基于内存的对话存储,基于redis的,mysql的,sqllite的以及向量存储等诸多方式。那么我们先来尝试实现一下基于内存的记忆:

首先呢,在langchain的langchain.memory包下面的ChatMessageHistory,提供了一些方便的函数,可以保存人类的消息llm的消息。但是呢,这个只是底层的存储结构,只管存消息。我们真正使用的时候,使用的是ConversationBufferMemory这个是一个高层的记忆组建,内部默认使用的ChatMessageHistory来存储消息,并提供了与langchain链集成的接口(但是他还是不能嵌入到新式的Runnable中,也就是使用类似于Unix管道符的方式一层一层的向下传递处理好的信息),由于我们现在所使用的是新式的Runnable,所以我们不使用这个方式。我们来使用**RunnableWithMessageHistory**。

它是 LangChain 为 新时代 Runnable 专门设计的记忆包装器,底层可以接:

  • ChatMessageHistory(内存)
  • RedisChatMessageHistory(Redis)
  • 或任何 BaseChatMessageHistory 子类

不依赖 ConversationBufferMemory,而是直接操作 ChatMessageHistory,更轻量、更符合 Runnable 范式。

我们导入所需要的包:

from langchain.memory import ChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.messages import HumanMessage

然后初始化一个会话历史存储器,来实现单用户的多轮对话

# 内存中的会话存储:session_id -> ChatMessageHistory
# 目前我们只用一个固定 session_id,实现单用户多轮对话
store = {}

def get_session_history(session_id: str) -> ChatMessageHistory:
    if session_id not in store:
        store[session_id] = ChatMessageHistory()
    return store[session_id]

说明:

  • store 是一个字典,模拟多用户(虽然我们现在只用一个);
  • get_session_history("user1") 会返回该用户的对话历史对象;
  • 所有消息(Human/AI)都存在 ChatMessageHistory.messages 列表中。

这种写法是

然后修改原来的系统提示词

system_prompt = (
    "你是一名资深 Java 开发专家,精通 JDK 8-17、Spring 生态、并发、JVM 调优等。\n"
    "请基于以下检索到的上下文和之前的对话历史回答用户的问题。\n"
    "如果上下文不相关,请仅基于你的知识回答,不要编造。\n\n"
    "上下文:\n{context}\n\n"
    "对话历史:\n{chat_history}"
)

接着,修改提示词模板中的用户输入变量为input

from langchain_core.prompts import MessagesPlaceholder

prompt = ChatPromptTemplate.from_messages([
    ("system", 
     "你是一名资深 Java 开发专家,精通 JDK 8-17、Spring 生态、并发、JVM 调优等。\n"
     "请基于以下检索到的上下文回答用户的问题。\n"
     "如果上下文不相关,请仅基于你的知识回答,不要编造。\n\n"
     "上下文:\n{context}"
    ),
  # 历史消息占位
    MessagesPlaceholder(variable_name="chat_history"),  #  自动插入历史
    ("human", "{input}")
])

为什么必须要用input 呢,这是因为RunnableWithMessageHistory 默认从输入中取 "input" 字段作为用户消息。

然后,重构rag链(不带有底层记忆,只会处理一次问答),后续呢我们会把他升级成支持对话记忆的链。

# 单次问答的 RAG 链(无记忆)
from langchain_core.runnables import RunnablePassthrough

# 单次问答链()
rag_chain = (
    RunnablePassthrough.assign(context=itemgetter("input") | retriever | format_docs)
    | prompt
    | llm
    | StrOutputParser()
)

升级链:

# 带记忆的完整 RAG 链
rag_with_history = RunnableWithMessageHistory(
    rag_chain,
    get_session_history,
    input_messages_key="input",        # 用户输入字段
    history_messages_key="chat_history" # 历史消息字段(对应 MessagesPlaceholder)
)
  • get_session_history:告诉它如何根据 session_id 获取历史;
  • input_messages_key="input":用户输入在字典中的 key;
  • history_messages_key="chat_history":这个值会被自动注入到 prompt 的 {chat_history} 中。

下面是改造完成之后的完整的代码(包含测试用例):

# ai_client.py
import os
from langchain_community.chat_models import ChatTongyi
from langchain_community.document_loaders import DirectoryLoader, TextLoader, PyPDFLoader
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.text_splitter import CharacterTextSplitter
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.prompts import MessagesPlaceholder
from langchain.memory import ChatMessageHistory
from langchain_core.runnables import RunnablePassthrough
from langchain_core.runnables.history import RunnableWithMessageHistory
from operator import itemgetter
​
import config
​
# 注入 DashScope API Key
if config.DASHSCOPE_API_KEY:
    os.environ["DASHSCOPE_API_KEY"] = config.DASHSCOPE_API_KEY
​
# 初始化 LLM
llm = ChatTongyi(
    model=config.MODEL,
    temperature=config.TEMPERATURE,
)
​
# ========== 加载文档 ==========
all_docs = []
try:
    txt_docs = DirectoryLoader(
        config.DOC_DIR,
        glob="**/*.txt",
        loader_cls=TextLoader,
        show_progress=True,
        use_multithreading=True,
        silent_errors=True
    ).load()
    md_docs = DirectoryLoader(
        config.DOC_DIR,
        glob="**/*.md",
        loader_cls=TextLoader,
        show_progress=True,
        use_multithreading=True,
        silent_errors=True
    ).load()
    pdf_docs = DirectoryLoader(
        config.DOC_DIR,
        glob="**/*.pdf",
        loader_cls=config.DOC_DIR,
        show_progress=True,
        use_multithreading=True,
        silent_errors=True
    ).load()
    all_docs = (txt_docs or []) + (md_docs or []) + (pdf_docs or [])
except Exception as e:
    print(f"⚠️ 文档加载失败: {e}")
    all_docs = []
​
# 分块
text_splitter = CharacterTextSplitter(separator="\n\n", chunk_size=500, chunk_overlap=80)
texts = text_splitter.split_documents(all_docs) if all_docs else []
​
# 初始化 Embeddings
embeddings = DashScopeEmbeddings(model="text-embedding-v2")
​
# 初始化向量数据库(Chroma)
if texts:
    vectorstore = Chroma.from_documents(
        documents=texts,
        embedding=embeddings,
        persist_directory=config.EMBEDDINGS_DIR
    )
    vectorstore.persist()
else:
    # 如果没有文档,尝试从已有数据库加载(避免报错)
    try:
        vectorstore = Chroma(
            persist_directory=config.EMBEDDINGS_DIR,
            embedding_function=embeddings
        )
    except Exception as e:
        print("⚠️ 无文档且无法加载向量库,将使用纯 LLM 模式(无检索)")
        vectorstore = None
​
# 初始化检索器
if vectorstore:
    retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
else:
    # 无检索器时,返回空列表
    retriever = lambda query: []
​
# 格式化检索结果
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)
​
# ========== 构建带记忆的 RAG 链 ==========# Step 1: 修改 Prompt,加入聊天历史
system_prompt = (
    "你是一名资深 Java 开发专家,精通 JDK 8-17、Spring 生态、并发、JVM 调优等。\n"
    "请基于以下检索到的上下文和之前的对话历史回答用户的问题。\n"
    "如果上下文不相关,请仅基于你的知识回答,不要编造。\n\n"
    "上下文:\n{context}\n\n"
    "对话历史:\n{chat_history}"
)
​
prompt = ChatPromptTemplate.from_messages([
    ("system",
     "你是一名资深 Java 开发专家,精通 JDK 8-17、Spring 生态、并发、JVM 调优等。\n"
     "请基于以下检索到的上下文回答用户的问题。\n"
     "如果上下文不相关,请仅基于你的知识回答,不要编造。\n\n"
     "上下文:\n{context}"
    ),
  # 历史消息占位
    MessagesPlaceholder(variable_name="chat_history"),  # ← 自动插入历史
    ("human", "{input}")
])
​
# Step 2: 单次问答 RAG 链(无记忆)
rag_chain = (
    RunnablePassthrough.assign(context=itemgetter("input") | retriever | format_docs)
    | prompt
    | llm
    | StrOutputParser()
)
# Step 3: 内存中的会话历史存储
store = {}
​
def get_session_history(session_id: str) -> ChatMessageHistory:
    if session_id not in store:
        store[session_id] = ChatMessageHistory()
    return store[session_id]# Step 4: 用 RunnableWithMessageHistory 包装,自动管理历史
rag_with_history = RunnableWithMessageHistory(
    rag_chain,
    get_session_history,
    input_messages_key="input",        # 用户输入字段
    history_messages_key="chat_history" # 历史消息字段(对应 MessagesPlaceholder)
)
​
# ========== 多轮对话测试 ==========
if __name__ == "__main__":
    SESSION_ID = "user_session_1"  # 固定会话 ID,实现单用户多轮
​
    print("💬 欢迎使用 Java 专家问答系统(支持多轮对话)\n")
​
    # 第一轮
    question1 = "怎么自定义线程池,不用给我代码,简单说不超过200个字"
    print(f"👤 用户: {question1}")
    print("🤖 AI: ", end="", flush=True)
    for chunk in rag_with_history.stream(
        {"input": question1},
        config={"configurable": {"session_id": SESSION_ID}}
    ):
        print(chunk, end="", flush=True)
    print("\n")
​
    # 第二轮
    question2 = "那核心线程数一般设多少?"
    print(f"\n👤 用户: {question2}")
    print("🤖 AI: ", end="", flush=True)
    for chunk in rag_with_history.stream(
        {"input": question2},
        config={"configurable": {"session_id": SESSION_ID}}
    ):
        print(chunk, end="", flush=True)
    print("\n")
​
    # 第三轮
    question3 = "如果任务很多,队列会满吗?"
    print(f"\n👤 用户: {question3}")
    print("🤖 AI: ", end="", flush=True)
    for chunk in rag_with_history.stream(
        {"input": question3},
        config={"configurable": {"session_id": SESSION_ID}}
    ):
        print(chunk, end="", flush=True)
    print("\n")
​
    print("\n✅ 多轮对话测试完成!")

llm的回复如下:

image-20251018143506520

现在,我们的ai应用已经支持基于本地内存的多轮会话了!!!

6、对话记忆实现(基于Redis)

那我们接着来思考一下,仅仅是基于本地内存又有什么弊端呢?

我们的服务如果是部署在多个服务器上呢,如果我们的服务器内存不足呢,那么问题立马就暴露出来了,如果部署在多台服务器上的话,用户A第一次的请求被nginx负载均衡算法转发到了服务器A,那如果还是用户A,第二次请求被转发到了服务器B呢,这时候服务器B的内存中是没有用户A上一次的会话记忆的,就会出现一个类似于我们的多轮会话失效了的现象。

好,问题既然已经发现了,那怎么去解决呢,其实在上面我们提到了,LangChain不仅仅提供了基于本地内存的会话记忆,而且提供了基于Redis的会话记忆。想必Redis大家都应该听过吧,尤其是做Java后端开发的同学,什么缓存击穿,缓存雪崩,缓存穿透啊,但是现在我们用不到这些😄。因为我们就是把对话记录在本地内存存储的方式换成了存到Redis中,Redis是单线程(不过跟咱们现在这个项目没关系)的,并且可以搭建集群,同时呢Redis可以把对话通过生成rdb文件的方式持久化到磁盘,不管我们部署了多少个AI应用服务,部署了多少台服务器,都可以去Redis中查询对话记录。

首先我们要安装Redis,在这里,由于我现在使用的是macOS,所以就用docker的方式安装Redis了。win平台的可以自行去网上搜索安装方式

docker run -d \
  --name my-redis \
  -p 6379:6379 \
  -v redis-data:/data \
  --restart unless-stopped \
  redis:7.0 redis-server --appendonly yes

image-20251019163743463

出现这个界面,就代表Redis docker容器成功运行了

然后使用一款可视化的Redis管理工具:Another Redis Desktop Manager 连接到本地的Redis实例默认6379端口

image-20251019163718402

安装好Redis实例之后,我们还需要使用LangChain去操作redis,就需要在项目中安装langchain-redis,使用pip命令,或者使用PyCharm可视化安装:

这里我就使用pip命令安装了,进入到当前项目的路径,在终端中输入:

pip install langchain-redis

好了,我们回到ai_client.py,导入所需要的包:RedisChatMessageHistory

from langchain_community.chat_message_histories import RedisChatMessageHistory

删除原来的sore和函数get_session_history,改为下面的代码

def get_session_history(session_id: str) -> RedisChatMessageHistory:
    return RedisChatMessageHistory(
        session_id=session_id,
        url=config.REDIS_URL,          # e.g., "redis://localhost:6379/0"
        ttl=config.REDIS_TTL           # 可选:过期时间(秒),如 3600
    )

说明:

  • 每次调用都会创建一个绑定到session_id的Redis历史对象
  • LangChain会自动在Redis中以message_store:{session_id}为key存储消息
  • ttl,也就是存活时间,可以自动清理过期的会话,避免占用过多的内存

config.py中添加下面的配置:

# redis连接地址
REDIS_URL = "redis://localhost:6379/0"
# redis key过期时间
REDIS_TTL = 10000000

保持rag链不变,

修改之后完整的代码如下(包含测试用例):

# ai_client.py
import os
from langchain_community.chat_models import ChatTongyi
from langchain_community.document_loaders import DirectoryLoader, TextLoader, PyPDFLoader
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.text_splitter import CharacterTextSplitter
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import RunnablePassthrough
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_community.chat_message_histories import RedisChatMessageHistory
from operator import itemgetter
​
import config
​
# 设置 API Key
if config.DASHSCOPE_API_KEY:
    os.environ["DASHSCOPE_API_KEY"] = config.DASHSCOPE_API_KEY
​
# 初始化 LLM
llm = ChatTongyi(
    model=config.MODEL,
    temperature=config.TEMPERATURE,
)
​
# ========== 加载文档 ==========
all_docs = []
try:
    txt_docs = DirectoryLoader(
        config.DOC_DIR,
        glob="**/*.txt",
        loader_cls=TextLoader,
        show_progress=True,
        use_multithreading=True,
        silent_errors=True
    ).load()
    md_docs = DirectoryLoader(
        config.DOC_DIR,
        glob="**/*.md",
        loader_cls=TextLoader,
        show_progress=True,
        use_multithreading=True,
        silent_errors=True
    ).load()
    pdf_docs = DirectoryLoader(
        config.DOC_DIR,
        glob="**/*.pdf",
        loader_cls=PyPDFLoader,
        show_progress=True,
        use_multithreading=True,
        silent_errors=True
    ).load()
    all_docs = (txt_docs or []) + (md_docs or []) + (pdf_docs or [])
except Exception as e:
    print(f"⚠️ 文档加载失败: {e}")
    all_docs = []
​
# 分块
text_splitter = CharacterTextSplitter(separator="\n\n", chunk_size=500, chunk_overlap=80)
texts = text_splitter.split_documents(all_docs) if all_docs else []
​
# 初始化 Embeddings
embeddings = DashScopeEmbeddings(model="text-embedding-v2")
​
# 初始化向量数据库
if texts:
    vectorstore = Chroma.from_documents(
        documents=texts,
        embedding=embeddings,
        persist_directory=config.DB_PATH
    )
    vectorstore.persist()
else:
    try:
        vectorstore = Chroma(
            persist_directory=config.DB_PATH,
            embedding_function=embeddings
        )
    except Exception as e:
        print("⚠️ 无文档且无法加载向量库,将使用纯 LLM 模式")
        vectorstore = None
​
# 初始化检索器
if vectorstore:
    retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
else:
    retriever = lambda query: []
​
# 格式化检索结果
def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)
​
# ========== 构建 Prompt ==========
prompt = ChatPromptTemplate.from_messages([
    ("system",
     "你是一名资深 Java 开发专家,精通 JDK 8-17、Spring 生态、并发、JVM 调优等。\n"
     "请基于以下检索到的上下文回答用户的问题。\n"
     "如果上下文不相关,请仅基于你的知识回答,不要编造。\n\n"
     "上下文:\n{context}"
    ),
    MessagesPlaceholder(variable_name="chat_history"),  # 自动插入历史消息
    ("human", "{input}")
])
​
# ========== 构建 RAG 链 ==========
rag_chain = (
    RunnablePassthrough.assign(
        context=itemgetter("input") | retriever | format_docs
    )
    | prompt
    | llm
    | StrOutputParser()
)
​
# ========== Redis 会话历史 ==========
def get_session_history(session_id: str):
    return RedisChatMessageHistory(
        session_id=session_id,
        url=config.REDIS_URL,
        ttl=config.REDIS_TTL  # 自动过期(秒)
    )
​
# ========== 带记忆的完整链 ==========
rag_with_history = RunnableWithMessageHistory(
    rag_chain,
    get_session_history,
    input_messages_key="input",
    history_messages_key="chat_history"
)
​
# ========== 测试多轮对话 ==========
if __name__ == "__main__":
    SESSION_ID = "user_12345"  # 实际项目中可用用户ID或UUID
​
    print("💬 Java 专家问答系统(Redis 多轮对话)\n")
​
    # 第一轮
    q1 = "Java 中如何创建一个固定大小的线程池?"
    print(f"👤 用户: {q1}")
    print("🤖 AI: ", end="", flush=True)
    for chunk in rag_with_history.stream(
        {"input": q1},
        config={"configurable": {"session_id": SESSION_ID}}
    ):
        print(chunk, end="", flush=True)
    print("\n")
​
    # 第二轮
    q2 = "那它和 cachedThreadPool 有什么区别?"
    print(f"\n👤 用户: {q2}")
    print("🤖 AI: ", end="", flush=True)
    for chunk in rag_with_history.stream(
        {"input": q2},
        config={"configurable": {"session_id": SESSION_ID}}
    ):
        print(chunk, end="", flush=True)
    print("\n")
​
    # 第三轮
    q3 = "如果任务抛异常,线程池会怎样?"
    print(f"\n👤 用户: {q3}")
    print("🤖 AI: ", end="", flush=True)
    for chunk in rag_with_history.stream(
        {"input": q3},
        config={"configurable": {"session_id": SESSION_ID}}
    ):
        print(chunk, end="", flush=True)
    print("\n")
​
    print("✅ 对话已持久化到 Redis!")

运行代码控制台输出如下:

image-20251019165928716

Redis中已经成功存储了对话

image-20251019170037803

image-20251019170111468

至此,基于Redis的多轮对话记忆开发完毕!!