在构建智能对话应用时,最让开发者头疼的往往不是模型本身的推理能力,而是如何让它"记住"之前的对话内容。很多时候,我们花费大量精力调优提示词,却发现 AI 在长对话中频频"失忆",要么重复回答相同的问题,要么无法关联上文的关键信息。这种上下文断裂不仅影响用户体验,更限制了应用在客服、个人助理等复杂场景中的落地价值。
其实,问题的核心不在于模型大小,而在于我们是否建立了一套高效的记忆管理机制。通过引入专门的记忆存储与检索模块,我们可以让 AI 像人类一样拥有短期工作记忆和长期经验库。这不仅能让对话更加连贯自然,还能显著降低 Token 消耗,提升响应速度。对于正在开发聊天机器人、智能助手或需要处理多轮交互项目的开发者来说,掌握这套机制是必经之路。
本文将抛开复杂的理论堆砌,直接从实战角度出发,带你一步步搭建一个具备完整记忆管理能力的对话系统。我们将从环境配置开始,深入探讨如何初始化配置文件、实现基础的记忆存取代码,并重点讲解如何管理完整的对话上下文。同时,针对生产环境中常见的启动报错、性能瓶颈以及数据安全等问题,也会提供具体的排查思路和调优策略,帮助你打造一个既稳定又高效的智能对话后端。
① 核心概念解析与适用场景说明
在技术实现之前,我们需要明确"记忆"在对话系统中的具体含义。这里的记忆并非指模型参数中的隐式知识,而是显式的、可被程序读写的数据状态。它通常分为两类:短期记忆和长期记忆。短期记忆主要指当前会话的上下文窗口,包含了最近几轮的对话历史,用于维持对话的即时连贯性;而长期记忆则是指跨越多次会话的用户偏好、关键事实或业务数据,这些数据需要持久化存储,以便在用户再次访问时能够被快速召回。
这种机制的适用场景非常广泛。在智能客服领域,记忆管理可以让机器人在用户切换话题后依然记得之前的订单号或投诉细节,避免用户反复陈述;在个性化教育应用中,系统可以记录学生的学习进度和薄弱知识点,从而动态调整教学内容;而在角色扮演类游戏中,NPC 需要记住玩家之前的选择和行为,才能做出符合逻辑的剧情反馈。理解这些场景有助于我们在设计架构时,更好地平衡存储成本与检索效率。
为了更直观地理解两者的差异,下面从六个维度做一张对比表,方便你在设计架构时快速取舍:
| 对比维度 | 短期记忆 | 长期记忆 |
|---|---|---|
| 存储介质 | Redis 等内存型缓存,读写走内存 | SQLite / PostgreSQL 等关系型数据库,落盘持久化 |
| 生命周期 | 随会话结束或 TTL 到期自动失效,通常几十分钟到几小时 | 长期保留,直到用户主动删除或业务规则清理 |
| 读写频率 | 极高,几乎每轮对话都要读写一次 | 相对较低,通常在会话开始或关键节点触发 |
| 典型数据 | 最近几轮对话原文、当前任务状态、临时变量 | 用户偏好、身份信息、历史结论、会话摘要 |
| 召回方式 | 按 session_id 直接 Key 查询,O(1) 命中 |
按 user_id + 类型/关键词检索,按重要度排序 |
| 成本关注点 | 内存占用与过期策略,防止内存泄漏 | 存储容量、索引效率与数据安全合规 |
光看表格还不够直观,下面用一张时序图把一轮对话中两种记忆的读写顺序串起来:
"大模型" "数据库(长期记忆)" "Redis(短期记忆)" "对话服务" "用户" "大模型" "数据库(长期记忆)" "Redis(短期记忆)" "对话服务" "用户" #mermaid-svg-VRpakcms3mO6escr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-VRpakcms3mO6escr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VRpakcms3mO6escr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VRpakcms3mO6escr .error-icon{fill:#552222;}#mermaid-svg-VRpakcms3mO6escr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VRpakcms3mO6escr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VRpakcms3mO6escr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VRpakcms3mO6escr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VRpakcms3mO6escr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VRpakcms3mO6escr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VRpakcms3mO6escr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VRpakcms3mO6escr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VRpakcms3mO6escr .marker.cross{stroke:#333333;}#mermaid-svg-VRpakcms3mO6escr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VRpakcms3mO6escr p{margin:0;}#mermaid-svg-VRpakcms3mO6escr .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VRpakcms3mO6escr text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-VRpakcms3mO6escr .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VRpakcms3mO6escr .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-VRpakcms3mO6escr .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-VRpakcms3mO6escr .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-VRpakcms3mO6escr #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-VRpakcms3mO6escr .sequenceNumber{fill:white;}#mermaid-svg-VRpakcms3mO6escr #sequencenumber{fill:#333;}#mermaid-svg-VRpakcms3mO6escr #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-VRpakcms3mO6escr .messageText{fill:#333;stroke:none;}#mermaid-svg-VRpakcms3mO6escr .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VRpakcms3mO6escr .labelText,#mermaid-svg-VRpakcms3mO6escr .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-VRpakcms3mO6escr .loopText,#mermaid-svg-VRpakcms3mO6escr .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-VRpakcms3mO6escr .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-VRpakcms3mO6escr .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-VRpakcms3mO6escr .noteText,#mermaid-svg-VRpakcms3mO6escr .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-VRpakcms3mO6escr .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VRpakcms3mO6escr .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VRpakcms3mO6escr .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-VRpakcms3mO6escr .actorPopupMenu{position:absolute;}#mermaid-svg-VRpakcms3mO6escr .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-VRpakcms3mO6escr .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-VRpakcms3mO6escr .actor-man circle,#mermaid-svg-VRpakcms3mO6escr line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-VRpakcms3mO6escr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} "发送用户请求""按 session_id 取短期上下文""返回最近几轮对话""按 user_id 召回长期记忆""返回高优先级记忆""拼装 Prompt(System + 长期记忆 + 短期上下文)""调用模型生成回复""返回 assistant 回复""回写本轮完整对话""抽取关键事实写入长期记忆""返回回复内容"
可以看到,短期记忆负责"这一轮聊得下去",长期记忆负责"下次还认得你":请求进来时先读 Redis 拿到即时上下文,再查数据库补齐用户偏好与历史结论,两者拼装后一起交给模型;回复生成后再分别回写------完整对话进 Redis 供下一轮使用,抽取出的关键事实落库供跨会话召回。整个流程读写分离、各司其职,既保证了响应速度,又让记忆能够长期沉淀。
两者并不是二选一的关系,而是协同工作的:每轮对话开始时,系统先用 session_id 从 Redis 取出短期上下文,再用 user_id 从数据库召回高优先级的长期记忆,两者一起拼装进 Prompt 交给模型;对话结束后,把本轮新增的关键事实抽取出来写入长期记忆表,同时把完整对话回写到 Redis 供下一轮使用。这样既保证了即时对话的连贯性,又让 AI 在跨会话时依然"记得"这位用户。
② 运行环境准备与依赖安装步骤
工欲善其事,必先利其器。为了支撑高效的记忆读写操作,我们需要选择一个轻量且生态丰富的运行环境。这里推荐使用 Python 作为主要开发语言,配合 Redis 作为高速缓存层来处理短期记忆,使用 SQLite 或 PostgreSQL 作为持久化层来存储长期记忆。这种组合既保证了高频读写的低延迟,又确保了数据的安全持久。
首先,确保你的系统中已安装 Python 3.8 及以上版本。接着,创建一个独立的虚拟环境以避免依赖冲突:
bash
python -m venv venv
source venv/bin/activate # Windows 用户使用 venv\Scripts\activate
随后,安装必要的依赖库。我们需要 redis 库来操作缓存,sqlalchemy 用于 ORM 映射,以及 openai 或其他大模型 SDK 用于实际调用:
bash
pip install redis sqlalchemy openai python-dotenv
如果你打算在本地快速测试,可以直接安装 Docker 来一键启动 Redis 服务,无需手动配置复杂的数据库参数:
bash
docker run -d -p 6379:6379 --name chat-memory-redis redis:latest
这一步完成后,你的开发环境就具备了处理高并发记忆读写的基础能力。
③ 配置文件初始化与参数设置方法
硬编码配置是项目维护的大忌,尤其是涉及数据库连接地址、API 密钥等敏感信息时。我们需要建立一个统一的配置管理中心。建议在项目根目录下创建 .env 文件,利用 python-dotenv 库在运行时加载环境变量。
.env 文件的内容示例如下:
ini
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
DATABASE_URL=sqlite:///memory_store.db
API_KEY=your_api_key_here
MEMORY_TTL=3600
MAX_CONTEXT_LENGTH=4096
在项目代码中,我们可以创建一个 config.py 模块来集中管理这些参数,并提供类型校验和默认值 fallback 机制:
python
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///default.db")
MEMORY_TTL = int(os.getenv("MEMORY_TTL", 3600))
MAX_CONTEXT_LENGTH = int(os.getenv("MAX_CONTEXT_LENGTH", 4096))
config = Config()
这种方式的优点是灵活性极高,部署到不同环境(如开发、测试、生产)时,只需更换 .env 文件或注入环境变量,无需修改任何代码逻辑。同时,将 MEMORY_TTL(生存时间)和 MAX_CONTEXT_LENGTH 提取为配置项,方便后续根据业务需求动态调整记忆保留策略。
④ 快速启动服务与连接验证流程
配置就绪后,我们需要验证各个组件是否正常工作。编写一个简单的启动脚本,依次检查 Redis 连接和数据库初始化状态。这不仅能防止后续运行中出现莫名其妙的错误,也是 CI/CD 流程中的重要一环。
以下是一个简单的连接验证函数:
python
import redis
from sqlalchemy import create_engine, text
from config import config
def verify_connections():
# 验证 Redis
try:
r = redis.Redis(host=config.REDIS_HOST, port=config.REDIS_PORT, db=0)
r.ping()
print("✓ Redis 连接成功")
except Exception as e:
print(f"✗ Redis 连接失败:{e}")
return False
# 验证 数据库
try:
engine = create_engine(config.DATABASE_URL)
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
print("✓ 数据库连接成功")
except Exception as e:
print(f"✗ 数据库连接失败:{e}")
return False
return True
if __name__ == "__main__":
if verify_connections():
print("系统初始化完成,准备启动服务...")
else:
exit(1)
运行该脚本,如果看到两个对勾标志,说明基础环境已就绪。此时可以启动主服务进程,监听来自前端的对话请求。建议在此阶段加入日志记录,将连接尝试和结果写入日志文件,便于后续排查问题。
⑤ 基础记忆存储与检索代码实现
有了稳定的连接,接下来就是核心的记忆存取逻辑。对于短期记忆,我们利用 Redis 的 String 或 List 结构,以 session_id 为 Key 进行存储;对于长期记忆,则通过 SQLAlchemy 定义数据模型,存入关系型数据库。
下面是基于 Redis 实现短期记忆存取的封装类:
python
import json
import redis
from config import config
class ShortTermMemory:
def __init__(self):
self.client = redis.Redis(
host=config.REDIS_HOST,
port=config.REDIS_PORT,
decode_responses=True
)
self.ttl = config.MEMORY_TTL
def save_context(self, session_id: str, messages: list):
"""保存对话上下文,自动设置过期时间"""
key = f"session:{session_id}"
self.client.setex(key, self.ttl, json.dumps(messages))
def get_context(self, session_id: str) -> list:
"""获取对话上下文,若不存在返回空列表"""
key = f"session:{session_id}"
data = self.client.get(key)
return json.loads(data) if data else []
def clear_context(self, session_id: str):
"""手动清除会话记忆"""
key = f"session:{session_id}"
self.client.delete(key)
这段代码实现了最基本的 CRUD 操作。save_context 方法在写入时自动设置了过期时间,防止内存泄漏;get_context 则负责在每次用户发起新对话时,拉取历史记录并组装成 Prompt 发送给大模型。这种设计简单直接,足以应对大多数单轮或多轮对话场景。
短期记忆解决了"当前这轮对话记得住"的问题,但用户下次再来时,Redis 里的数据可能已经过期了。这时候就需要长期记忆登场:把用户的关键偏好、身份信息、历史结论等写入关系型数据库,实现跨会话的持久化召回。
先用 SQLAlchemy 定义长期记忆的数据模型。这里以 UserMemory 表为例,字段设计尽量覆盖常见业务需求:
python
from datetime import datetime
from sqlalchemy import (
Column, Integer, String, Text, DateTime, Index, create_engine
)
from sqlalchemy.orm import declarative_base, sessionmaker
from config import config
Base = declarative_base()
class UserMemory(Base):
"""长期记忆表:存储跨会话需要持久保留的用户信息"""
__tablename__ = "user_memory"
# 主键,自增 ID,用于唯一标识一条记忆记录
id = Column(Integer, primary_key=True, autoincrement=True)
# 用户唯一标识,通常对应业务系统的 user_id,用于按用户维度检索
user_id = Column(String(64), nullable=False, index=True)
# 记忆类型,如 preference(偏好)、fact(事实)、profile(画像)、summary(会话摘要)
memory_type = Column(String(32), nullable=False, default="fact")
# 记忆内容主体,使用 Text 以支持较长的自然语言描述
content = Column(Text, nullable=False)
# 记忆来源,如 chat(对话抽取)、manual(人工录入)、import(批量导入),便于溯源
source = Column(String(32), nullable=False, default="chat")
# 重要度评分(0-100),数值越高越优先召回,可用于检索排序
importance = Column(Integer, nullable=False, default=50)
# 创建时间,默认取当前时间,便于按时间倒序取最新记忆
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
# 更新时间,每次覆盖写入时刷新,用于判断记忆是否陈旧
updated_at = Column(
DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow
)
# 复合索引:按「用户 + 类型」检索是最常见的查询模式
__table_args__ = (
Index("idx_user_type", "user_id", "memory_type"),
)
# 创建引擎与会话工厂,echo=False 表示不打印 SQL 日志
engine = create_engine(config.DATABASE_URL, echo=False, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
def init_memory_table():
"""初始化表结构,首次启动时调用一次即可"""
Base.metadata.create_all(bind=engine)
模型定义好之后,再封装两个最常用的方法:一个负责写入(保存),一个负责读取(检索)。写入时做"同用户同类型同内容"的去重更新,避免重复记忆越积越多;检索时按重要度和时间排序,保证最相关的记忆优先返回:
python
def save_long_term_memory(
user_id: str,
content: str,
memory_type: str = "fact",
source: str = "chat",
importance: int = 50,
) -> int:
"""
保存一条长期记忆。
:param user_id: 用户唯一标识
:param content: 记忆内容
:param memory_type: 记忆类型(preference/fact/profile/summary)
:param source: 记忆来源(chat/manual/import)
:param importance: 重要度评分 0-100
:return: 记忆记录的主键 ID
"""
session = SessionLocal()
try:
# 先查是否已存在完全相同的记忆,存在则更新重要度与时间,避免重复写入
existing = (
session.query(UserMemory)
.filter(
UserMemory.user_id == user_id,
UserMemory.memory_type == memory_type,
UserMemory.content == content,
)
.first()
)
if existing:
existing.importance = max(existing.importance, importance)
existing.updated_at = datetime.utcnow()
session.commit()
return existing.id
memory = UserMemory(
user_id=user_id,
content=content,
memory_type=memory_type,
source=source,
importance=importance,
)
session.add(memory)
session.commit()
return memory.id
except Exception:
session.rollback()
raise
finally:
session.close()
def query_long_term_memory(
user_id: str,
memory_type: str = None,
keyword: str = None,
limit: int = 10,
) -> list:
"""
检索某个用户的长期记忆。
:param user_id: 用户唯一标识
:param memory_type: 可选,按记忆类型过滤
:param keyword: 可选,按内容模糊匹配
:param limit: 返回条数上限
:return: 记忆字典列表,按重要度、更新时间倒序
"""
session = SessionLocal()
try:
query = session.query(UserMemory).filter(UserMemory.user_id == user_id)
if memory_type:
query = query.filter(UserMemory.memory_type == memory_type)
if keyword:
query = query.filter(UserMemory.content.like(f"%{keyword}%"))
records = (
query.order_by(
UserMemory.importance.desc(),
UserMemory.updated_at.desc(),
)
.limit(limit)
.all()
)
return [
{
"id": r.id,
"memory_type": r.memory_type,
"content": r.content,
"source": r.source,
"importance": r.importance,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat(),
}
for r in records
]
finally:
session.close()
不过,上面的去重只覆盖了"文字完全相同"的情况。真实对话里,用户可能今天说"我以后都用中文回答",明天又说"请始终用中文和我交流",两句话语义几乎一致,但字符串不同,content == content 的判断完全失效,长期记忆表就会慢慢被这类"近义记忆"撑大。要解决这个问题,需要在写入前再做一层语义去重,常见做法有两种:向量相似度比对和关键词归一化。
思路一:向量相似度比对。 把每条记忆的 content 用 Embedding 模型转成向量,写入时先取该用户同类型的已有记忆,逐条算余弦相似度,超过阈值(一般取 0.85~0.92)就判定为"同一条记忆",走合并而不是新增。为了不引入额外的向量库,这里先用内存计算演示,生产环境建议把向量存进 pgvector 或 Milvus 并建索引:
python
import math
from datetime import datetime
# 相似度阈值:越高越严格,0.85 适合偏好类记忆,事实类可适当调高到 0.9
SIMILARITY_THRESHOLD = 0.88
def cosine_similarity(vec_a: list, vec_b: list) -> float:
"""计算两个向量的余弦相似度,取值 0~1"""
dot = sum(a * b for a, b in zip(vec_a, vec_b))
norm_a = math.sqrt(sum(a * a for a in vec_a))
norm_b = math.sqrt(sum(b * b for b in vec_b))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
def find_similar_memory(session, user_id: str, memory_type: str,
embedding: list, threshold: float = SIMILARITY_THRESHOLD):
"""
在「同用户 + 同类型」的已有记忆中,找出与新记忆语义最相近的一条。
返回 (记录, 相似度),没有超过阈值则返回 (None, 0.0)。
"""
candidates = (
session.query(UserMemory)
.filter(
UserMemory.user_id == user_id,
UserMemory.memory_type == memory_type,
)
.all()
)
best_record, best_score = None, 0.0
for record in candidates:
# 实际项目中 embedding 应持久化在表里,这里演示时按需重算
record_vec = embed_text(record.content)
score = cosine_similarity(embedding, record_vec)
if score > best_score:
best_record, best_score = record, score
if best_score >= threshold:
return best_record, best_score
return None, 0.0
思路二:关键词归一化。 如果暂时不想引入 Embedding 模型,可以先用轻量规则兜底:去掉标点、统一全半角、把"我以后都用中文回答""请始终用中文和我交流"这类句子抽成关键词集合(如 {中文, 回答}),用 Jaccard 相似度判断是否重复。它比向量方案粗糙,但零依赖、可解释性强,适合作为第一道过滤:
python
import re
def normalize_keywords(text: str) -> set:
"""把记忆内容归一化成关键词集合:去标点、去停用词、统一小写"""
stop_words = {"我", "你", "请", "以后", "都", "用", "和", "的", "了", "吧", "呢"}
# 只保留中英文与数字,其余字符视为分隔符
tokens = re.findall(r"[\u4e00-\u9fa5a-zA-Z0-9]+", text.lower())
return {t for t in tokens if t not in stop_words and len(t) > 1}
def jaccard_similarity(set_a: set, set_b: set) -> float:
"""Jaccard 相似度:交集 / 并集"""
if not set_a or not set_b:
return 0.0
return len(set_a & set_b) / len(set_a | set_b)
把这两层判断接进写入流程,就得到一个带语义去重的 save_long_term_memory_v2。命中相似记忆时不再新增,而是合并:内容取信息更全的一条(或按业务规则拼接),重要度取两者最大值,更新时间刷新为当前时间:
python
def save_long_term_memory_v2(
user_id: str,
content: str,
memory_type: str = "fact",
source: str = "chat",
importance: int = 50,
) -> int:
"""
带语义去重的长期记忆写入:
1. 先做完全相同的精确去重(复用原逻辑);
2. 再做向量相似度 + 关键词归一化的二次去重;
3. 命中相似记忆时合并,而不是新增一条。
"""
session = SessionLocal()
try:
# 第一层:精确去重
exact = (
session.query(UserMemory)
.filter(
UserMemory.user_id == user_id,
UserMemory.memory_type == memory_type,
UserMemory.content == content,
)
.first()
)
if exact:
exact.importance = max(exact.importance, importance)
exact.updated_at = datetime.utcnow()
session.commit()
return exact.id
# 第二层:语义去重(向量相似度)
embedding = embed_text(content)
similar, score = find_similar_memory(
session, user_id, memory_type, embedding
)
# 向量没命中时,再用关键词归一化兜底
if similar is None:
new_kw = normalize_keywords(content)
for record in (
session.query(UserMemory)
.filter(
UserMemory.user_id == user_id,
UserMemory.memory_type == memory_type,
)
.all()
):
if jaccard_similarity(new_kw, normalize_keywords(record.content)) >= 0.6:
similar = record
break
# 命中相似记忆:合并而非新增
if similar is not None:
# 内容合并规则:保留更长(信息更全)的一条,长度相同则保留新内容
if len(content) > len(similar.content):
similar.content = content
# 重要度取最大值,避免合并后重要信息被降权
similar.importance = max(similar.importance, importance)
# 更新时间刷新为当前时间,表示这条记忆刚刚被再次确认
similar.updated_at = datetime.utcnow()
session.commit()
return similar.id
# 都没命中,正常新增
memory = UserMemory(
user_id=user_id,
content=content,
memory_type=memory_type,
source=source,
importance=importance,
)
session.add(memory)
session.commit()
return memory.id
except Exception:
session.rollback()
raise
finally:
session.close()
关于合并时的字段更新规则,可以归纳成三条,建议在团队内统一约定:
importance取最大值 :max(旧值, 新值)。同一条记忆被反复提及时,说明它对用户越重要,取最大值能让它持续排在召回列表前列,而不是被新写入的低分覆盖。updated_at刷新为当前时间:合并意味着这条记忆"刚刚被再次确认",刷新时间能让它按时间排序时保持新鲜,也便于后续做"长期未更新记忆"的清理。content择优保留:优先保留信息更完整的一条(如取更长者),或按业务规则拼接成"旧内容 + 新表述"。不建议简单覆盖,否则可能丢掉旧记忆里的细节。
最后提醒一点:向量去重需要为 UserMemory 表增加一个 embedding 字段(PostgreSQL 可用 pgvector 的 Vector 类型,SQLite 可先存 JSON 字符串),并在写入时同步落库,避免每次比对都重新计算。相似度阈值也要按记忆类型分别调:偏好类可以放宽到 0.85,事实类(如订单号、金额)建议收紧到 0.92 以上,宁可多存一条,也不要错误合并掉关键事实。
调用方式非常直观:用户说"我以后都用中文回答",就调用 save_long_term_memory(user_id, "用户偏好中文回答", memory_type="preference", importance=80);下一轮对话开始前,用 query_long_term_memory(user_id, limit=5) 取出高优先级记忆,拼接到 System Prompt 中,模型就能自然地"记得"这位用户。
需要提醒的是,sessionmaker 每次请求都新建并关闭会话,适合中小规模应用;如果并发较高,建议接入连接池或改用异步的 AsyncSession,避免频繁建连带来的开销。
⑥ 完整对话上下文管理实战示例
在实际应用中,单纯的存取是不够的,我们需要管理整个对话的生命周期。当用户发送一条新消息时,系统需要先读取旧的历史记录,将新消息追加到列表末尾,然后判断总长度是否超过了模型的限制。如果超长,则需要采用滑动窗口策略,剔除最早的几条消息,只保留最近的上下文。
以下是一个完整的对话处理流程示例:
python
def handle_user_message(session_id: str, user_input: str, memory_manager: ShortTermMemory):
# 1. 获取历史上下文
history = memory_manager.get_context(session_id)
# 2. 构建当前消息对象
new_message = {"role": "user", "content": user_input}
history.append(new_message)
# 3. 滑动窗口裁剪 (假设最大允许 10 条消息)
max_messages = 10
if len(history) > max_messages:
history = history[-max_messages:]
# 4. 调用大模型生成回复 (伪代码)
# response = call_llm_api(history)
# assistant_message = {"role": "assistant", "content": response}
# history.append(assistant_message)
# 模拟回复以便演示
assistant_message = {"role": "assistant", "content": "收到,我已经记住了之前的内容。"}
history.append(assistant_message)
# 5. 更新存储
memory_manager.save_context(session_id, history)
return assistant_message["content"]
在这个流程中,关键在于第 3 步的裁剪逻辑。通过限制列表长度,我们确保了传递给模型的 Token 数量始终控制在安全范围内,既节省了成本,又避免了因上下文过长导致的截断错误。同时,每次交互后都立即回写存储,保证了状态的一致性。
不过,按"消息条数"裁剪有个明显的短板:一条消息可能只有几个字,也可能是一整段几千字的文档,固定条数根本无法反映真实的 Token 消耗。更稳妥的做法是直接按 Token 数裁剪------用 tiktoken 估算每条消息的 Token 量,累加超过 MAX_CONTEXT_LENGTH 时,从最早的消息开始逐条剔除,同时始终保留 System Prompt。
先安装 tiktoken:
bash
pip install tiktoken
下面是按 Token 数裁剪的完整实现:
python
import tiktoken
from config import config
# 选用与目标模型匹配的编码器;gpt-4o / gpt-4o-mini 等新模型可用 o200k_base
# 若使用 cl100k_base(gpt-3.5-turbo / gpt-4 系列),直接改这里即可
ENCODER = tiktoken.get_encoding("cl100k_base")
# 每条消息除正文外还有角色、分隔符等固定开销,OpenAI 官方建议每条消息额外计 4 个 Token
MESSAGE_OVERHEAD = 4
def count_message_tokens(message: dict) -> int:
"""估算单条消息的 Token 数:正文 Token + 固定开销"""
content = message.get("content") or ""
return len(ENCODER.encode(content)) + MESSAGE_OVERHEAD
def count_messages_tokens(messages: list) -> int:
"""估算整个 messages 列表的 Token 总量"""
return sum(count_message_tokens(m) for m in messages)
def trim_by_tokens(messages: list, max_tokens: int = None) -> list:
"""
按 Token 数裁剪上下文:
1. 始终保留开头的 System Prompt(若存在);
2. 从最早的非 System 消息开始逐条剔除,直到总量不超过 max_tokens;
3. 至少保留最后一条用户消息,避免把当前问题也裁掉。
"""
max_tokens = max_tokens or config.MAX_CONTEXT_LENGTH
# 拆出 System Prompt 与其余对话消息
system_messages = [m for m in messages if m.get("role") == "system"]
dialog_messages = [m for m in messages if m.get("role") != "system"]
# 先算 System Prompt 占用的 Token,剩余额度留给对话
system_tokens = count_messages_tokens(system_messages)
budget = max_tokens - system_tokens
# 从最早的消息开始逐条剔除,直到对话部分不超预算
while dialog_messages and count_messages_tokens(dialog_messages) > budget:
# 只剩最后一条时停止剔除,保证当前问题一定被保留
if len(dialog_messages) == 1:
break
dialog_messages.pop(0)
return system_messages + dialog_messages
把它接进前面的对话处理流程,只需把第 3 步的固定条数裁剪替换掉:
python
def handle_user_message(session_id: str, user_input: str, memory_manager: ShortTermMemory):
# 1. 获取历史上下文
history = memory_manager.get_context(session_id)
# 2. 追加当前用户消息
history.append({"role": "user", "content": user_input})
# 3. 按 Token 数裁剪,超出 MAX_CONTEXT_LENGTH 时从最早的消息开始剔除
history = trim_by_tokens(history, max_tokens=config.MAX_CONTEXT_LENGTH)
# 4. 调用大模型生成回复(伪代码)
# response = call_llm_api(history)
assistant_message = {"role": "assistant", "content": "收到,我已经记住了之前的内容。"}
history.append(assistant_message)
# 5. 更新存储
memory_manager.save_context(session_id, history)
return assistant_message["content"]
相比固定条数裁剪,按 Token 数裁剪的优势很直接:
- 预算精准:直接对齐模型的上下文上限,不会出现"10 条消息刚好超限"或"10 条短消息浪费大半预算"的情况。
- 成本可控:Token 就是计费单位,按 Token 裁剪等于按钱裁剪,长文档、长代码这类"单条超重"的消息也能被正确识别。
- 保留更多有效信息:短消息不会被无谓地砍掉,同样的预算下能装下更多轮对话。
同时也要注意几个边界情况:
- System Prompt 本身超限 :如果 System Prompt 加长期记忆拼装后就已经超过
MAX_CONTEXT_LENGTH,再怎么裁剪对话也没用,需要先精简 System Prompt 或减少召回的记忆条数。 - 单条消息超限:一条超长用户输入(如粘贴整篇文档)可能自己就撑爆预算,此时应做截断或摘要,而不是简单保留。
- 至少保留最后一条:裁剪逻辑里要显式保证当前用户问题不被剔除,否则模型会答非所问。
- 估算误差 :
tiktoken只是估算,不同模型、不同 SDK 的消息封装格式会带来额外开销,建议预留 5%~10% 的余量,别把MAX_CONTEXT_LENGTH卡到模型硬上限。 - 编码器要匹配模型 :
cl100k_base与o200k_base的切分结果不同,换模型时记得同步更换编码器,否则估算会偏差。
不过,滑动窗口只是最基础的一种裁剪思路。当对话轮次变多、单轮内容变长时,单纯"砍掉最早的消息"往往会丢掉关键信息。实际项目中常见的上下文裁剪策略主要有三种,下面从适用场景、Token 节省效果和实现复杂度三个角度做个对比,方便你按需选型:
| 裁剪策略 | 核心思路 | 适用场景 | Token 节省效果 | 实现复杂度 |
|---|---|---|---|---|
| 滑动窗口 | 只保留最近 N 条消息,超出部分直接丢弃 | 短会话、闲聊类、对历史细节要求不高的场景 | 高,Token 上限完全可控 | 低,几行代码即可实现 |
| 摘要压缩 | 把较早的对话交给模型总结成一段摘要,用摘要替代原文 | 长会话、客服工单、需要保留前情提要的场景 | 中高,压缩比通常可达 5~10 倍 | 中,需要额外调用一次模型并管理摘要更新 |
| 向量检索召回 | 把历史消息向量化入库,每轮按当前问题检索最相关的若干条 | 超长会话、知识密集型、需要精准回溯细节的场景 | 中,取决于召回条数,但信息密度最高 | 高,需要向量库、Embedding 模型与相似度检索 |
三种策略各有取舍,实际落地时不必只选一种,更推荐组合使用:
- 刚起步的小项目:直接用滑动窗口就够了,实现成本最低,先把对话跑通再说。
- 对话轮次明显变长、用户抱怨"忘了前面说的":在滑动窗口基础上叠加摘要压缩,把窗口外的历史压成一段摘要放在 Prompt 开头,兼顾成本与连贯性。
- 需要精准回溯历史细节(如订单号、合同条款、多轮排查记录):引入向量检索召回,按当前问题动态取回最相关的历史片段,再配合滑动窗口保留最近上下文。
一个常见的工程做法是"滑动窗口 + 摘要 + 检索"三层组合:最近几轮原文走滑动窗口保证即时连贯,较早历史压成摘要控制总量,关键事实则通过向量检索按需召回。这样既能把 Token 控制在预算内,又不会因为粗暴截断而丢失重要信息。
⑦ 自定义记忆生命周期管理技巧
默认的过期策略有时无法满足复杂业务需求。例如,VIP 用户的对话记录可能需要保留更久,或者某些关键业务会话需要永久保存直到用户手动删除。这时,我们需要引入自定义的生命周期管理技巧。
一种常见的做法是根据会话元数据动态设置 TTL。在保存记忆前,先查询用户等级或会话类型:
python
def save_with_custom_ttl(session_id: str, messages: list, user_type: str):
ttl = 3600 # 默认 1 小时
if user_type == "vip":
ttl = 86400 * 7 # VIP 保留 7 天
elif user_type == "permanent":
# 永久保存不使用 setex,改用 set
key = f"session:{session_id}"
memory_manager.client.set(key, json.dumps(messages))
return
key = f"session:{session_id}"
memory_manager.client.setex(key, ttl, json.dumps(messages))
此外,还可以引入"活跃度检测"机制。如果某个会话在短时间内频繁交互,自动延长其过期时间;反之,若长时间无操作,则加速其过期过程。这种动态调整机制能有效优化内存资源利用率,确保热点数据常驻内存,冷数据及时释放。
⑧ 常见启动报错与连接失败排查
在开发和部署过程中,连接失败是最常见的问题。以下是几种典型报错及其解决方案:
- Redis Connection Refused :通常是因为 Redis 服务未启动或防火墙拦截。检查 Docker 容器状态
docker ps,确认端口映射是否正确。如果是远程服务器,确保安全组已开放 6379 端口。 - Database Locked (SQLite):在高并发写入时,SQLite 可能会出现数据库锁定错误。解决方案是增加超时重试机制,或在生产环境迁移至 PostgreSQL。
- JSON Decode Error :当存储的数据格式损坏或非 JSON 字符串时发生。建议在
get_context中加入try-except捕获异常,并在解码失败时返回空列表并记录警告日志,防止程序崩溃。
调试时,开启详细日志级别至关重要。通过打印每一步的操作详情,可以快速定位是网络问题、权限问题还是数据格式问题。
⑨ 性能调优与大规模数据写入策略
随着用户量增长,单一的 Redis 实例可能成为瓶颈。此时可以考虑引入集群模式或分片策略。将不同 session_id 哈希到不同的 Redis 节点上,可以线性提升读写吞吐量。
对于大规模数据写入,批量操作比单次写入效率更高。虽然 Redis 的单条命令很快,但在高并发下网络往返耗时不容忽视。可以使用 Pipeline 技术将多条命令打包发送:
python
def batch_save_sessions(session_data_list):
pipe = memory_manager.client.pipeline()
for session_id, messages, ttl in session_data_list:
key = f"session:{session_id}"
pipe.setex(key, ttl, json.dumps(messages))
pipe.execute()
此外,定期清理过期数据也是必要的维护工作。虽然 Redis 有自动淘汰机制,但在内存紧张时,主动扫描并删除那些已经业务上失效但尚未过期的键,能进一步释放资源。
⑩ 安全注意事项与最佳实践建议
最后,安全性是不容忽视的一环。首先,切勿将 API 密钥或数据库密码硬编码在代码库中,必须严格依赖环境变量或专用的密钥管理服务。其次,对存入记忆的用户数据进行脱敏处理,特别是涉及个人隐私的信息,应在入库前进行加密或掩码。
在网络层面,Redis 默认没有密码保护,暴露在公网极其危险。务必在配置文件中启用 requirepass,并在应用连接时提供认证信息。同时,限制 Redis 仅监听内网接口,禁止绑定到 0.0.0.0。
最佳实践还包括实施严格的访问控制列表(ACL),区分读写权限;定期备份持久化数据以防丢失;以及对所有输入内容进行合法性校验,防止注入攻击。只有构建了全方位的安全防线,才能让记忆管理系统在 production 环境中稳健运行。
⑪ 总结与后续演进方向
到这里,一个具备完整记忆管理能力的对话系统就搭建完成了。回顾整篇文章,核心要点可以浓缩成下面几条:
- 短期记忆与长期记忆协同工作 :Redis 负责当前会话的即时上下文,按
session_id直接命中,保证对话连贯;数据库负责跨会话的用户偏好与关键事实,按user_id检索召回。每轮对话先读两者拼装 Prompt,回复后再分别回写,读写分离、各司其职。 - 上下文裁剪要按 Token 而非条数 :用
tiktoken估算消息 Token 总量,超出MAX_CONTEXT_LENGTH时从最早的消息开始剔除,同时始终保留 System Prompt 和最后一条用户消息,预算更精准、成本更可控。 - 裁剪策略按需组合:滑动窗口实现最简单,适合起步阶段;摘要压缩适合长会话保留前情;向量检索召回适合需要精准回溯细节的场景。工程上常用"滑动窗口 + 摘要 + 检索"三层组合。
- 生命周期管理要动态化:根据用户等级、会话类型动态设置 TTL,配合活跃度检测延长热点会话、加速冷数据释放,在内存占用与数据保留之间取得平衡。
- 安全实践不可省略 :密钥走环境变量、敏感数据脱敏加密、Redis 启用
requirepass并限制内网监听、配置 ACL 与定期备份,才能让系统在生产环境稳健运行。
如果你已经跑通了上面的流程,下面几个方向可以继续深入:
- 向量检索召回 :把长期记忆和历史对话向量化存入向量库(如 pgvector、Milvus),每轮按当前问题做相似度检索,替代简单的关键词
like匹配,让召回更精准,尤其适合知识密集型场景。 - 异步
AsyncSession与连接池 :当前sessionmaker每次请求新建会话,并发上来后建连开销明显。改用 SQLAlchemy 的AsyncSession配合连接池,能让数据库读写与模型调用真正并行,显著提升吞吐。 - 多租户隔离 :当系统服务多个业务方时,需要在
user_memory表上增加tenant_id字段并纳入索引,Redis 的 Key 也加上租户前缀,从存储层保证数据不串号,同时便于按租户做配额与清理。
记忆管理没有一劳永逸的方案,先按最小可用版本跑起来,再根据实际对话长度、并发量和召回效果逐步演进,才是更稳妥的落地路径。