LLM 对话记忆功能实现深度解析

1. 引言:赋予 LLM "记忆"

大型语言模型(LLM)的核心特性之一是其无状态性(Statelessness)。这意味着模型本身不会保留任何关于过去交互的信息。每一次调用都是一次独立的计算,它不会"记得"之前的任何对话。

然而,为了创造流畅、连贯的用户体验,让用户感觉像是在与一个能记住上下文的智能体对话,我们的应用程序实现了一套完整的外部记忆管理机制。本文档将深度解析我们的应用是如何巧妙地为每个用户、每个独立的对话实现记忆功能的,并详述我们为确保系统高性能和内存效率所做的优化。

2. 核心实现流程

我们的对话记忆功能依赖于一个清晰、分层的数据流。当用户发送一条新消息时,系统会执行以下四个核心步骤:a

  1. API 接收请求 : API层接收包含用户消息和当前 conversation_id 的请求。
  2. 加载历史记录 : 业务逻辑层使用 conversation_id 从数据库中加载相关的对话历史。
  3. 构建上下文: 将历史记录和新消息整合成一个格式化的、LLM可以理解的上下文(Prompt)。
  4. 调用 LLM: 将构建好的上下文发送给 LLM 以生成回应。

下面,我们将深入每一段代码来解析这个流程。


第一步:API 接口层 (chat_router.py)

流程的入口点是我们的 FastAPI 路由。它定义了 /chat 接口,负责接收前端的请求。

代码片段:

python 复制代码
# 文件: src/routers/chat_router.py

@router.post("/chat")
async def chat(
    request: ChatRequest,
    db: AsyncSession = Depends(get_db_session),
):
    """
    接收用户消息,保存它,检索对话历史,
    并以SSE流的形式返回模型的响应。
    """
    logger.info(f"收到对话 {request.conversation_id} 的聊天请求")

    # ... (LLM 服务初始化) ...

    return StreamingResponse(
        chat_service.stream_chat_response(request, llm_service, db),
        media_type="text/event-stream"
    )

代码解析:

  • 接口接收一个 ChatRequest 类型的对象,该对象中包含了关键的 conversation_id
  • 这个 conversation_id 是区分不同对话的唯一标识符。
  • 接口的核心职责是将请求传递给业务逻辑层的 chat_service.stream_chat_response 函数进行处理。这是实现分层架构的关键一步,保持了API层的简洁性。

第二步:业务逻辑层 (chat_service.py)

这是实现记忆功能的核心协调者。它负责定义和执行业务规则,例如历史记录的长度限制。

代码片段:

python 复制代码
# 文件: src/services/chat_service.py

async def stream_chat_response(
    request: ChatRequest, llm_service: LLMService, db: AsyncSession
):
    # 1. 保存新收到的用户消息
    user_message_to_save = MessageCreateSchema(...)
    await message_dao.create_message(db, message=user_message_to_save)

    # 2. 定义历史记录上限并从数据库加载对话历史
    MAX_HISTORY_LENGTH = 20
    history_from_db = await message_dao.get_messages_by_conversation(
        db, conversation_id=request.conversation_id, limit=MAX_HISTORY_LENGTH
    )
    
    # 将从数据库获取的倒序列表反转为正确的时序 (旧消息在前)
    history_from_db.reverse()

    # (后续步骤...)

代码解析:

  • 该函数首先将用户发送的新消息存入数据库,确保它成为历史记录的一部分。
  • 它定义了一个 MAX_HISTORY_LENGTH常量,这是我们进行性能优化的第一步(详见下文)。
  • 它调用数据访问层(DAO)的 get_messages_by_conversation 函数,传入 conversation_idlimit,精确地请求所需数量的历史消息。
  • 关键点 :由于数据库为了效率返回的是最新的20条(时间倒序),这里调用 history_from_db.reverse() 将列表反转,恢复了正确的对话时间线(旧消息在前,新消息在后),这对于保证LLM正确理解上下文至关重要。

第三步:数据访问层 (message_dao.py)

DAO层负责与数据库进行直接交互。它的职责是根据业务逻辑层的请求,执行高效、精确的SQL查询。

代码片段:

python 复制代码
# 文件: src/dao/message_dao.py

async def get_messages_by_conversation(db: AsyncSession, conversation_id: int, limit: int) -> List[dict]:
    """
    根据指定的 conversation_id 和数量限制,获取最新的消息。
    """
    query = select(messages_table).where(
        messages_table.c.conversation_id == conversation_id
    ).order_by(messages_table.c.created_at.desc()).limit(limit)
    
    result = await db.execute(query)
    messages = result.fetchall()
    return [msg._asdict() for msg in messages]

代码解析:

  • 函数签名现在包含一个 limit 参数,使其更加灵活和可重用。
  • where(messages_table.c.conversation_id == conversation_id): 确保查询只返回特定对话的消息,这是实现对话隔离的核心。
  • order_by(messages_table.c.created_at.desc()): 按创建时间降序 排序,以便 limit 能获取到最新的消息。
  • limit(limit): 这是我们性能优化的关键。它告诉数据库"我最多只需要这么多条记录",数据库因此只需扫描和返回有限的数据,极大地提升了效率。

第四步:上下文构建与 LLM 调用

在获取并整理好历史数据后,最后一步是将其转化为 LLM 能理解的格式并发送。

代码片段:

python 复制代码
# 文件: src/services/chat_service.py

    # ... (接第二步的代码) ...

    # 将历史记录格式化以适应 LLM
    chat_history = []
    for msg in history_from_db:
        if msg['role'] == 'user':
            chat_history.append(HumanMessage(content=msg['content']))
        elif msg['role'] == 'assistant':
            chat_history.append(AIMessage(content=msg['content']))

    # 调用 astream 方法
    llm_stream = llm_service.llm.astream(chat_history)

代码解析:

  • 代码遍历 history_from_db 列表。
  • 根据每条消息的 role('user' 或 'assistant'),它会创建一个 HumanMessageAIMessage 对象。这是 LangChain 框架的标准,它帮助 LLM 区分对话中的不同角色。
  • 最终形成的 chat_history 列表(例如 [HumanMessage, AIMessage, HumanMessage, ...]) 就是一个结构化的、完整的对话上下文。
  • 这个 chat_history 被直接传递给 LLM,LLM 会基于这个上下文生成连贯的回答。

3. 性能与内存优化

一个简单的实现可能会从数据库中加载所有历史记录,然后在应用内存中截取最后一部分。这种方法在对话初期可行,但随着对话变长,会导致严重问题:

  • 内存爆炸: 如果一个对话有数千条消息,将它们全部加载到应用服务器的内存中会迅速消耗资源,甚至导致服务崩溃。
  • 数据库和网络开销: 查询和传输大量不必要的数据会给数据库带来压力,并增加网络延迟。

基于您的专业建议,我们采用了更优化的数据库层限制方案:

  1. 在数据库层面进行限制:

    • 我们在 message_dao.py 的 SQL 查询中直接加入了 .order_by(...).limit(...)
    • 优势 : 这样一来,繁重的数据过滤工作由专门为此优化的数据库来完成。应用服务器永远不会看到超过 limit 数量的记录,从而从根本上避免了内存溢出的风险。无论对话历史有多长,应用的内存占用都保持在一个很小的、可预测的范围内。
  2. 参数化 limit:

    • 我们将硬编码的数字 20 从 DAO 层移到了业务逻辑层(Service层),并通过函数参数传递。
    • 优势: 这遵循了良好的软件设计原则。DAO 层保持通用性,只负责执行查询,而业务规则(比如历史记录应该多长)则保留在 Service 层。这使得未来如果需要根据不同用户等级或场景调整历史记录长度时,修改会非常容易。

4. 总结

通过将对话状态外部化到数据库,并遵循清晰的分层架构,我们的应用成功地为无状态的 LLM 赋予了强大的对话记忆能力。更重要的是,通过在数据库查询层面直接实现历史记录截断,并参数化配置,我们确保了该功能在长期使用中依然保持高性能、高效率和高可维护性。

相关推荐
电饭叔1 小时前
Luhn算法初介绍
python
badmonster01 小时前
实时代码库索引:用 CocoIndex 构建智能代码搜索的终极方案
python·rust
晓山清2 小时前
Meeting Summarizer Using Natural Language Processing论文理解
人工智能·python·nlp·摘要生成
zqy02272 小时前
python安装与环境配置
开发语言·python
Wise玩转AI2 小时前
从LLM到Agent:技术迁移的必然趋势
人工智能·python·语言模型·ai智能体
ada7_2 小时前
LeetCode(python)——94.二叉
python·算法·leetcode·链表·职场和发展
广都--编程每日问2 小时前
c++右键菜单统一转化文件为utf8编码
c++·windows·python
ZAz_2 小时前
DAY 28 元组和OS模块
python