1. 引言:赋予 LLM "记忆"
大型语言模型(LLM)的核心特性之一是其无状态性(Statelessness)。这意味着模型本身不会保留任何关于过去交互的信息。每一次调用都是一次独立的计算,它不会"记得"之前的任何对话。
然而,为了创造流畅、连贯的用户体验,让用户感觉像是在与一个能记住上下文的智能体对话,我们的应用程序实现了一套完整的外部记忆管理机制。本文档将深度解析我们的应用是如何巧妙地为每个用户、每个独立的对话实现记忆功能的,并详述我们为确保系统高性能和内存效率所做的优化。
2. 核心实现流程
我们的对话记忆功能依赖于一个清晰、分层的数据流。当用户发送一条新消息时,系统会执行以下四个核心步骤:a
- API 接收请求 : API层接收包含用户消息和当前
conversation_id的请求。 - 加载历史记录 : 业务逻辑层使用
conversation_id从数据库中加载相关的对话历史。 - 构建上下文: 将历史记录和新消息整合成一个格式化的、LLM可以理解的上下文(Prompt)。
- 调用 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_id和limit,精确地请求所需数量的历史消息。 - 关键点 :由于数据库为了效率返回的是最新的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'),它会创建一个HumanMessage或AIMessage对象。这是 LangChain 框架的标准,它帮助 LLM 区分对话中的不同角色。 - 最终形成的
chat_history列表(例如[HumanMessage, AIMessage, HumanMessage, ...]) 就是一个结构化的、完整的对话上下文。 - 这个
chat_history被直接传递给 LLM,LLM 会基于这个上下文生成连贯的回答。
3. 性能与内存优化
一个简单的实现可能会从数据库中加载所有历史记录,然后在应用内存中截取最后一部分。这种方法在对话初期可行,但随着对话变长,会导致严重问题:
- 内存爆炸: 如果一个对话有数千条消息,将它们全部加载到应用服务器的内存中会迅速消耗资源,甚至导致服务崩溃。
- 数据库和网络开销: 查询和传输大量不必要的数据会给数据库带来压力,并增加网络延迟。
基于您的专业建议,我们采用了更优化的数据库层限制方案:
-
在数据库层面进行限制:
- 我们在
message_dao.py的 SQL 查询中直接加入了.order_by(...).limit(...)。 - 优势 : 这样一来,繁重的数据过滤工作由专门为此优化的数据库来完成。应用服务器永远不会看到超过
limit数量的记录,从而从根本上避免了内存溢出的风险。无论对话历史有多长,应用的内存占用都保持在一个很小的、可预测的范围内。
- 我们在
-
参数化
limit:- 我们将硬编码的数字
20从 DAO 层移到了业务逻辑层(Service层),并通过函数参数传递。 - 优势: 这遵循了良好的软件设计原则。DAO 层保持通用性,只负责执行查询,而业务规则(比如历史记录应该多长)则保留在 Service 层。这使得未来如果需要根据不同用户等级或场景调整历史记录长度时,修改会非常容易。
- 我们将硬编码的数字
4. 总结
通过将对话状态外部化到数据库,并遵循清晰的分层架构,我们的应用成功地为无状态的 LLM 赋予了强大的对话记忆能力。更重要的是,通过在数据库查询层面直接实现历史记录截断,并参数化配置,我们确保了该功能在长期使用中依然保持高性能、高效率和高可维护性。