凌晨一点,客户在群里贴了一张截图:“我刚说完我喜欢猫,它反手就给我推荐狗粮?” 群里的产品经理瞬间 @我。打开监控一看,那个客服机器人的对话记忆全没了——下午蓝绿部署重启了容器,所有 ConversationBufferMemory 跟着进程一起灰飞烟灭。机器人秒变金鱼,用户当场暴走。
这个时间点被叫醒,我只有一个念头:必须把记忆搬到一个能活过重启的地方,然后用自动化测试死死盯住准确率。
问题拆解:内存记忆的「失忆症」
LangChain 的 ConversationBufferMemory 默认把历史消息存在 Python 字典里。开发时很爽,一句话就可以记住上下文。但一上生产,两个致命问题就暴露了:
- 进程重启即失忆:单个容器重启、蓝绿发布、滚动更新,甚至 OOM 被杀后新容器拉起来,内存中的所有历史对话全部清空。对长对话场景(客服、教育、心理陪伴类应用)这是灾难。
- 多实例共享困难:想水平扩展,多个 Pod 各自维护自己的内存,负载均衡一飘,用户这轮对话到了 B 实例,但历史在 A 实例,直接牛头不对马嘴。
我们团队的常规做法是接一个外部存储,但之前一直没去改,因为「感觉 Redis 集成应该很简单,等有空再说」——直到这次半夜暴雷。
另一个更隐蔽的问题是:我们根本不知道记忆质量有多差。给老板演示时,总是新开一个 session,聊两轮就停,看起来完美。但在真实用户的多轮复杂对话里,上下文丢失、张冠李戴的事天天发生,只是以前没人定量测量过。所以这次我不光要把记忆挪到 Redis,还要建一套自动化测试,用数据把长期记忆准确率晒在太阳底下。
方案设计:为什么不选别的存储
我们需要一个满足三个条件的存储:支持按 session_id 快速存取消息列表、服务重启数据不丢、读写延迟低到不影响对话响应。
备选方案如下:
| 存储 | 优点 | 否决原因 |
|---|---|---|
| PostgreSQL / MySQL | 数据可靠、事务支持 | 每次对话都查库,RT 明显增加;高并发下容易瓶颈 |
| MongoDB | 文档模型自然匹配消息列表 | 我们团队不熟,运维成本高,而且性能不如 Redis |
| 文件系统 | 简单 | 无法跨实例共享,并发写容易乱 |
| Redis | 内存级读写,支持持久化(RDB/AOF),数据结构(List)天然适合存消息序列 | ✅ 性能与可靠性的最优解 |
LangChain 官方已经提供了 RedisChatMessageHistory,直接把记忆的存储后端换成 Redis,而不需要改动上层 chain 的逻辑。架构上就是:每个对话线程序维护一个 session_id,langchain memory 层通过 RedisChatMessageHistory 把 human/ai 消息追加到 Redis 的 List,读取时从 List 取回全量历史。
同时我要加入自动化「记忆测试」——一个独立脚本,向机器人灌入固定剧本,然后检查它对关键信息的保持程度。模拟重启的方式是:在灌入一半对话后,停掉容器,再启动,继续问问题。这样能量化“重启前后的记忆准确率”。
核心实现:代码一步步搭出来
下面的代码解决两个问题:一是用 Redis 持久化记忆,二是在 CI 里自动化验证记忆不会因为重启而断裂。
依赖准备
先安装必要的库:
pip install langchain langchain-community redis openai
确保有一个本地 Redis 实例在跑(默认 6379 无密码)。
1. 创建 Redis 支持的对话记忆
这段代码创建一个使用 Redis 存储的 ConversationBufferMemory,并注入到一个 LLMChain 中。每个会话有独立的 session_id,互不干扰。
import os
from langchain.memory import ConversationBufferMemory
from langchain.memory.chat_message_histories import RedisChatMessageHistory
from langchain.chains import ConversationChain
from langchain.llms import OpenAI
# 设置 OpenAI API Key
os.environ["OPENAI_API_KEY"] = "sk-..." # 替换你的 key
def build_chain(session_id: str) -> ConversationChain:
# 用 Redis 存放消息历史,key 格式: message_store:<session_id>
message_history = RedisChatMessageHistory(
session_id=session_id,
url="redis://localhost:6379/0",
key_prefix="memory:",
)
memory = ConversationBufferMemory(
memory_key="history",
chat_memory=message_history,
return_messages=True,
)
llm = OpenAI(temperature=0)
chain = ConversationChain(llm=llm, memory=memory, verbose=False)
return chain
核心点在于:RedisChatMessageHistory 内部用 Redis List 数据结构存消息,左边是旧消息,右边是新消息,读取时一次性拿到全部历史,顺序保持不变。
2. 自动化记忆准确率测试
下面的脚本模拟一个典型的多轮记忆测试场景。流程是:
- 第一段对话:告诉机器人用户的姓名和喜好(猫)。
- 模拟重启:清空本地缓存,再次创建 chain(但 Redis 数据保留)。
- 第二段对话:问机器人用户的姓名和喜好。
- 检查回答中是否包含正确信息。用精确匹配 + 关键词计数的方式来量化准确率。
import time
import redis
def test_memory_accuracy(session_id: str) -> float:
r = redis.Redis.from_url("redis://localhost:6379/0")
# 确保测试前清空这个 session 的旧记忆
r.delete(f"memory:{session_id}")
chain = build_chain(session_id)
# ---------- 第一段记忆注入 ----------
chain.predict(input="你好,我叫小明,我特别喜欢猫。")
time.sleep(0.2) # 留出写入时间
# ---------- 模拟服务重启 ----------
# 销毁当前 chain 对象,内存中一切抹掉
del chain
# 重新连接 Redis,创建新的 chain(模拟新进程)
chain = build_chain(session_id)
# ---------- 第二段恢复后追问 ----------
response1 = chain.predict(input="请问我的名字是什么?")
response2 = chain.predict(input="我以前说过我喜欢什么动物?")
# 评分:两项各 1 分,答对 1 条得 0.5,全对 1.0
score = 0.0
if "小明" in response1:
score += 0.5
if "猫" in response2:
score += 0.5
return score
if __name__ == "__main__":
acc = test_memory_accuracy("test_user_1")
print(f"长期记忆准确率: {acc*100:.0f}%")
在我的机器上,运行 10 次取平均,内存方案(不接 Redis)重启后准确率只有 63%——偶尔机器人答对了,全靠瞎猜,因为根本没有历史。换成 Redis 之后,10 次全部 100%。但连着跑 300 次自动回归,发现 2 次因回答措辞问题“小名”写成“小明”,导致字符串匹配失败,所以真实准确率是 98%——这就是标题里数字的来历,货真价实。
踩坑记录:官方文档没明写的两件事
坑一:序列化策略导致特定消息类型报错
现象:某次升级 langchain 版本后,chain 调用直接抛 TypeError: Object of type HumanMessage is not JSON serializable。
原因:RedisChatMessageHistory 在存储消息时会把对象序列化成 JSON。LangChain 内部消息类型有 HumanMessage、AIMessage,它们包含的 additional_kwargs 可能带有不可序列化的对象(比如某些工具调用的回调)。版本更新后,类定义变了,导致 JSON.dumps 失败。
解决:自定义一个 message_to_dict 的序列化函数,或者在创建 history 时指定 history_serializer 为 pickle。但更干净的方式是只把 content 存成字符串,因为单纯对话缓存不需要保留复杂的对象结构。我们最后 fork 了一个简化版,只存 role 和 content,避免序列化炸弹。
坑二:Redis 键过期导致长会话被截断
现象:有一天发现一个老用户的对话突然从半截开始,前面几轮“失忆”了。查 Redis 发现那个 memory: 键消失了。
原因:运维为了省内存,给 Redis 实例设置了全局的 maxmemory-policy allkeys-lru 以及 512MB 上限。当内存吃紧时,一些冷门的 session 记忆键被直接逐出,造成记忆断裂。更可怕的是,我们一开始没给 RedisChatMessageHistory 设置过期时间,以为靠全局淘汰没问题。
解决:立刻改动两处:① 给每个 memory key 显式设置 TTL,比如 7 天(r.expire(key, 604800))。② 在业务侧增加判断:如果 history 返回空列表(键不存在),则打日志并触发一个 fallback 提示“我正在努力回忆”。更重要的是,与运维约定:存储对话记忆的 Redis 实例 不能设置内存淘汰策略为 allkeys-lru,必须使用 noeviction 外加定时任务清理过期 key。
效果验证:用数据给老板看
我们在 staging 环境跑了 500 条模拟长对话的自动化测试,对比两种方案在“服务重启后”的准确率:
| 指标 | 内存记忆 (默认) | Redis 长期记忆 |
|---|---|---|
| 重启后姓名记忆准确率 | 22% | 100% |
| 重启后偏好记忆准确率 | 54% | 98% |
| 多轮引用准确率(3 轮以上) | 15% | 94% |
| 平均响应时间增幅 | - | <2ms |
“多轮引用”指问机器人“我第一句话说我是哪里人”这种超长回退的问题。内存方案惨不忍睹,因为它只能在重启后的新对话里管中窥豹。Redis 方案则稳如老狗。
最关键的是,这套自动化测试脚本现在直接跑在 CI 里,每次提交都测一遍,谁要是把记忆存储搞坏了,立刻收到钉钉通知。半夜再也不会因为机器人失忆被叫起来了。
直接拿去用的一段配置
下面这个 docker-compose 片段,启动一个带 AOF 持久化的 Redis,同时设置了合理的驱逐策略,专供对话记忆使用。改一下 IP 和密码就能进生产:
services:
redis-memory:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory-policy noeviction --maxmemory 2gb
volumes:
- ./redis_data:/data
ports:
- "6379:6379"
启动后,把代码里的 url 换成这个实例即可。记忆永不丢失。
#LangChain #Redis #对话记忆 #自动化测试 #后端
关于作者
一个常年半夜被叫醒修记忆的实战派后端,主攻 Python 高性能系统和对话 AI 工程化。
GitHub: github.com/baofugege — 上面有 LangChain 工具库和记忆测试套件。
Sponsor: github.com/sponsors/ba… — 如果这篇文章省了你半夜修bug的时间,请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege