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

28 阅读10分钟

7、对话记忆(基于PostgreSQL)

But,Redis是本质上还是基于内存的,就算Redis有持久化逻辑,Redis重启之后数据还是会丢失的。最佳实践就是直接把对话存储到像MySQL类的数据库中,那么在这个项目中呢,我们就暂时不用MySQL去实现对话的持久化存储了,在这里我们使用PostgreSQL。

关于PostgreSQL:

  • PostgreSQL(通常简称为 Postgres)是一个功能强大、开源、对象-关系型数据库管理系统(ORDBMS)。它以稳定性、可扩展性、标准兼容性和丰富的功能著称,被广泛用于企业级应用、Web 后端、数据分析、GIS 系统等领域。

    官网:www.postgresql.org/


    核心特点

    1. 完全开源 & 免费

    • 使用 PostgreSQL License(类似 MIT),允许自由使用、修改、分发,甚至用于商业产品,无版权费用。

    2. 高度兼容 SQL 标准

    • 支持 SQL:2016 大部分特性,包括窗口函数、CTE(公共表表达式)、JSON、全文搜索等。
    • 被认为是最符合 SQL 标准的开源数据库之一。

    3. 强大的数据类型支持

    • 基础类型:整数、浮点、字符串、日期时间等

    • 高级类型:

      • JSON / JSONB(二进制优化的 JSON,支持索引和查询)
      • 数组(如 TEXT[]
      • 范围类型(如 int4range, tsrange
      • 几何类型(点、线、多边形)
      • 网络地址类型inet, cidr, macaddr
      • 自定义类型(通过 CREATE TYPE

    4. 扩展性强

    • 支持自定义函数(用 SQL、PL/pgSQL、Python、Perl、C 等编写)

    • 支持

      扩展插件

      ,例如:

      • PostGIS:地理空间数据处理(GIS)
      • pg_trgm:模糊文本匹配
      • uuid-ossp:生成 UUID
      • TimescaleDB:时序数据(基于 Postgres)

    5. ACID 事务 & 并发控制

    • 完整支持 ACID(原子性、一致性、隔离性、持久性)
    • 使用 MVCC(多版本并发控制),读不阻塞写,写不阻塞读,高并发性能好。

    6. 安全性高

    • 细粒度的权限控制(表、列、行级安全)
    • 支持 SSL/TLS 加密连接
    • 多种认证方式(密码、LDAP、Kerberos、证书等)
    • 通过 pg_hba.conf 精确控制谁可以从哪里连接

    7. 高可用与复制

    • 支持流复制(Streaming Replication)
    • 支持逻辑复制(Logical Replication,用于跨版本或选择性同步)
    • 可搭配 Patronirepmgrpgpool-II 等实现自动故障转移

    8. 活跃的社区与生态

    • 拥有全球活跃的开发者和用户社区
    • 云厂商广泛支持(AWS RDS/Aurora、Google Cloud SQL、Azure Database for PostgreSQL 等)

    🗃️ 典型应用场景

    场景说明
    Web 应用后端Django、Ruby on Rails、Node.js 等框架首选数据库
    地理信息系统(GIS)通过 PostGIS 插件支持空间查询(如“附近 5 公里的商店”)
    数据分析与 BI支持窗口函数、聚合、物化视图,可替代部分数据仓库
    微服务架构每个服务可拥有独立数据库,保证解耦
    时序数据配合 TimescaleDB 插件高效存储监控、IoT 数据

而且PostgreSQL 可以高效存储和查询向量数据(是通过pgvector插件实现的),而且PostgreSQL的流行度已经超过了MySQL

这里我直接使用docker的方式直接运行一个PostgreSQL的容器,或者说你有云服务器的话,并且已经安装了宝塔面板的话,直接在软件商店就能安装PostgreSQL。

docker run --name pgvector-db \
  -e POSTGRES_PASSWORD=E628L1..3K220 \
  -e POSTGRES_USER=postgres \
  -e POSTGRES_DB=postgres \
  -p 5433:5432 \
  -v ~/pgvector-data:/var/lib/postgresql/data \
  -d \
  pgvector/pgvector:pg16

image-20251021170219114

这里的数据库名和密码以及用户名是在docker命令就设置好的

对于LangChain来说,也内置的基于SQL的对话历史解决方案,使用langchain_community.chat_message_histories包中的SQLChatMessageHistory可是实现基于SQL的对话历史持久化存储。

我们在该项目的根目录下新建一个message_history文件夹,并新建py文件sql_message_history.py

image-20251021161839775

导入相关的包并编写如下代码:

# message_history/sql_message_history.py
from message_history.async_pg_history import AsyncPostgresChatMessageHistory
​
def get_session_history(session_id: str):
    return AsyncPostgresChatMessageHistory(
        session_id=session_id,
        table_name="langchain_chat_messages",
    )

这里我们使用SQLAlchemy,这是一个流行的 Python ORM 和数据库工具库)中用于异步数据库操作的模块。

注意,这里的AsyncPostgresChatMessageHistory是我自定义的一个异步类,那为什么要自定义呢?

目前 langchain_community.chat_message_histories.SQLChatMessageHistory 并不原生支持 SQLAlchemy 的异步引擎(如 AsyncEngine —— 它的 async_mode=True 本质上是“在异步上下文中运行同步数据库操作”,并非真正的异步数据库驱动。

解决方案:自定义一个真正的异步 BaseChatMessageHistory

我们可以基于 SQLAlchemy Core + asyncpg,自己实现一个支持 PostgreSQL 异步读写 的消息历史类,完全兼容 LangChain 的异步接口(如 .aget_messages(), .aadd_messages())。

这段代码大家如果看不懂的话,直接复制粘贴就好(别忘了安装依赖sqlalchemy asyncpg):

# message_history/async_pg_history.py
import json
from typing import List, Optional
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.messages import BaseMessage, messages_from_dict, messages_to_dict
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy import text
import config
​
# 创建全局异步引擎(推荐单例)
engine = create_async_engine(
    config.DATABASE_URL,
    echo=False,          # 生产环境设为 False
    pool_size=10,
    max_overflow=20,
    pool_pre_ping=True,
)
​
# 初始化表结构(可单独运行一次)
async def init_chat_history_table(table_name: str = "langchain_chat_messages"):
    async with engine.begin() as conn:
        await conn.execute(text(f"""
            CREATE TABLE IF NOT EXISTS {table_name} (
                id SERIAL PRIMARY KEY,
                session_id TEXT NOT NULL,
                message JSONB NOT NULL,
                created_at TIMESTAMPTZ DEFAULT NOW()
            );
        """))
        await conn.execute(text(f"""
            CREATE INDEX IF NOT EXISTS idx_session_id_{table_name} 
            ON {table_name} (session_id);
        """))
​
​
class AsyncPostgresChatMessageHistory(BaseChatMessageHistory):
    def __init__(self, session_id: str, table_name: str = "langchain_chat_messages"):
        if not session_id:
            raise ValueError("session_id must be provided")
        self.session_id = session_id
        self.table_name = table_name
​
    async def aget_messages(self) -> List[BaseMessage]:
        async with AsyncSession(engine) as session:
            result = await session.execute(
                text(f"""
                    SELECT message 
                    FROM {self.table_name} 
                    WHERE session_id = :session_id 
                    ORDER BY id ASC
                """),
                {"session_id": self.session_id}
            )
            rows = result.fetchall()
            # SQLAlchemy + asyncpg 自动将 JSONB 反序列化为 dict
            message_dicts = [row[0] for row in rows]  # row[0] is already a dict
            return messages_from_dict(message_dicts)
​
    async def aadd_messages(self, messages: List[BaseMessage]) -> None:
        if not messages:
            return
        async with AsyncSession(engine) as session:
            message_dicts = messages_to_dict(messages)
            for msg in message_dicts:
                # 🔧 关键修复:将 dict 序列化为 JSON 字符串
                await session.execute(
                    text(f"""
                        INSERT INTO {self.table_name} (session_id, message)
                        VALUES (:session_id, :message)
                    """),
                    {
                        "session_id": self.session_id,
                        "message": json.dumps(msg)  # 传 str 而非 dict
                    }
                )
            await session.commit()
​
    async def aclear(self) -> None:
        """异步清空当前 session 的所有消息"""
        async with AsyncSession(engine) as session:
            await session.execute(
                text(f"DELETE FROM {self.table_name} WHERE session_id = :session_id"),
                {"session_id": self.session_id}
            )
            await session.commit()
​
    # 同步方法:提示用户使用异步版本
    def get_messages(self) -> List[BaseMessage]:
        raise NotImplementedError("Use 'aget_messages()' in async context.")
​
    def add_messages(self, messages: List[BaseMessage]) -> None:
        raise NotImplementedError("Use 'aadd_messages()' in async context.")
​
    def clear(self) -> None:
        raise NotImplementedError("Use 'aclear()' in async context.")

同时在配置文件中加上DATABASE_URL = "postgresql+asyncpg://user:password@localhost:5432/mydb"

8、异步数据库操作

那什么是异步数据库操作呢?

1、同步vs异步

- 同步:程序执行到数据库操作的时候会阻塞,直到数据库返回结果之后才继续执行下一行代码,但是在高并发场景下,或者是比较耗费时间的任务,这种等待会浪费资源
- 异步:程序发起数据库请求之后不会等待,可以去做别的事情,等待数据库准备好结果之后在回调或者`await`获取结果

2. 异步数据库的意义

  • 提高 I/O 密集型应用(如 Web API)的并发性能
  • 配合异步 Web 框架(如 FastAPIStarletteQuart)使用,避免阻塞事件循环。
  • 适用于高并发、低延迟的场景。

现在修改我们的ai_client.py代码

主要修改如下:

获取对话历史函数,调用我们自定义的类,这时候直接传入一个sessionid就好了

def get_session_history(session_id: str):
    return AsyncPostgresChatMessageHistory(session_id=session_id)

调用方法改为异步的

async for chunk in rag_with_history.astream(...):
    print(chunk, end="", flush=True)

完整代码如下(包含测试用例):

# ai_client.py
import os
import asyncio
from message_history.async_pg_history import AsyncPostgresChatMessageHistory
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 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=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")
​
# 初始化向量数据库(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)
​
# ========== 构建 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()
)
​
# 获取对话历史 ,
def get_session_history(session_id: str):
    return AsyncPostgresChatMessageHistory(session_id=session_id)
​
# ========== 带记忆的完整链 ==========
rag_with_history = RunnableWithMessageHistory(
    rag_chain,
    get_session_history,
    input_messages_key="input",
    history_messages_key="chat_history"
)
​
# ========== 异步对话测试函数 ==========
async def run_conversation():
    SESSION_ID = "user_session_1"
​
    print("欢迎使用 Java 专家问答系统(PostgreSQL 多轮对话)\n")
​
    # 第一轮
    question1 = "怎么自定义线程池,不用给我代码,简单说不超过200个字"
    print(f"用户: {question1}")
    print("AI: ", end="", flush=True)
    async for chunk in rag_with_history.astream(
        {"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)
    async for chunk in rag_with_history.astream(
        {"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)
    async for chunk in rag_with_history.astream(
        {"input": question3},
        config={"configurable": {"session_id": SESSION_ID}}
    ):
        print(chunk, end="", flush=True)
    print("\n")
​
    print("\n多轮对话测试完成!对话已存入 PostgreSQL。")
​
# ========== 主入口 ==========
if __name__ == "__main__":
    # 初始化数据库表(可选)
    from message_history.async_pg_history import init_table
    asyncio.run(init_table())
​
    # 运行对话
    asyncio.run(run_conversation())

ps:这时候需要通过Navicat登录到PostgreSQL去创建表,如果遇到权限问题就使用postgres用户

执行以下建表语句

DROP TABLE IF EXISTS langchain_chat_messages;
CREATE TABLE langchain_chat_messages (
    id SERIAL PRIMARY KEY,
    session_id TEXT NOT NULL,
    message JSONB NOT NULL,          -- ← JSONB
    created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_session_id ON langchain_chat_messages (session_id);

运行代码:

控制台输出如下

image-20251022103836486

然后再看以下PostgreSQL的对话记录,可以看到已经成功存入了PostgreSQL

image-20251022104057562

ps:什么是jsonb格式的数据

JSONBPostgreSQL 特有的数据类型,用于高效地存储和查询 JSON(JavaScript Object Notation)数据。它的名字中 “B” 代表 Binary(二进制) ,意味着它在内部以二进制格式存储 JSON 数据,而不是原始的文本字符串。

JSONB vs JSON(PostgreSQL 中的两种 JSON 类型)

特性JSONJSONB
存储格式原始 JSON 文本(保留空格、键顺序、重复键)二进制解析后的结构(压缩、去重、键无序)
写入速度快(直接存字符串)稍慢(需解析为二进制)
读取/查询速度慢(每次都要解析)(已解析,支持索引)
支持索引❌ 不支持 GIN 索引优化查询✅ 支持 GIN 索引,可高效查询内部字段
重复键处理保留所有键只保留最后一个值
键顺序保留不保留(视为无序对象)
存储空间较大更紧凑(无冗余空格等)

绝大多数场景推荐使用 JSONB,尤其是需要查询、过滤或索引 JSON 内容时(比如聊天记录、配置、日志等)。

在 Python + SQLAlchemy + asyncpg 中使用的时候,可以传 dict(SQLAlchemy 自动序列化为 JSON 字符串再转为 JSONB)数据类型,然后存入到PostgreSQL

至此,基于PostgreSQL的持久化多轮对话实现完毕!我们已经实现了多轮会话,RAG,等功能,已经可以解决我们的基本的对话,提问需求了,但是好像还缺点什么。严格的来说目前这个还算不上智能体,那什么是真正的智能体(ReAct)呢?