凌晨一点,老板在群里@我:"客服机器人怎么回事,用户刚说完名字,下一条消息又问一遍,跟金鱼似的。"我摸黑打开日志,好家伙,Redis里session_id对应的上下文队列干干净净,就好像从没被写过一样。这已经是本月第四次"记忆蒸发"事件了。之前每次都是靠重启服务、重新部署来糊弄,但那一晚我决定不再惯着这破毛病------我要用Pytest把AI Agent的上下文持久化逻辑从头到脚测个透。
问题拆解:Agent记忆到底丢在哪里
AI Agent的"记忆"说白了就是一个消息列表,每次对话把用户输入和AI回复压进去,下次请求时拉出来拼到prompt里,让模型知道之前聊了什么。常规实现长这样:
python
from langchain.memory import ConversationBufferMemory
memory = ConversationBufferMemory(return_messages=True)
# 每轮对话后 memory.save_context({"input": user_msg}, {"output": ai_msg})
# 后续从 memory.load_memory_variables({}) 取历史
当我们把它挂上Redis来持久化(比如RedisChatMessageHistory),理论上只要Redis不挂,记忆就不会丢。但现实是:有时会话切到另一台机器,序列化的JSON/Pickle里丢失了字段;有时异步连接池耗尽,save_context没有真正flush就返回了;更鬼的是return_messages=True会在load_memory_variables里把SystemMessage、HumanMessage对象列出来,但序列化时如果用了pickle,服务端部署的类定义稍有不同就会反序列化失败,直接给你一个空列表而没有任何报错。
这些问题有一个共同点:在多轮并发对话场景下才会暴露,手工测试根本复现不了。你用自己的电脑单步调试一点毛病没有,一上生产就间歇性失忆。没有一套自动化的、模拟真实多轮交互的测试,就等于把定时炸弹埋在新发版里。
方案设计:为什么是Pytest + fakeredis,而不是真实Redis集群
我需要一套可以跑在CI环境、毫秒级执行、且能精确复现各种边界条件的测试方案。当时有几个选择摆在面前:
- 真实Redis + Testcontainers:启动真实Redis容器,非常接近生产,但每次测试启动容器要几秒钟,CI流水线会拖慢不少;而且需要管理Docker依赖,开发团队里有人用M1 Mac,某些镜像兼容性又是一堆坑。
- unittest + mock:用Python标准库mock把Redis客户端偷梁换柱,能测逻辑,但写起来极啰嗦,每个Redis命令都要mock一次,而且mock不会暴露序列化/反序列化的真实行为。
- Pytest + fakeredis :
fakeredis是一个纯Python的Redis模拟库,支持90%以上的常用命令,能完整模拟序列化、过期时间、数据结构,而且不需要任何外部依赖。配合Pytest的fixture、参数化和pytest-asyncio,可以把记忆存储的所有核心逻辑测到锃亮。
不选真实Redis集群的理由 还有一条:我需要测试的某些场景依赖Redis的精确错误响应(比如OOM、连接超时),用fakeredis模拟这些异常反而比真实Redis更方便,因为可以注入特定错误。这样一来,测试环境就成了"比生产更懂你的代码",能暴露出那些偶发的边界case。
最终架构很简单:一个MemoryStore抽象类,封装Agent需要用到的记忆操作(添加消息、获取历史、裁剪、清空),底层实现可以是Redis,也可以是内存Dict。测试时用fakeredis注入,开发时直接复用这套case去验证任何新的存储后端。
核心实现:从Fixture到多轮对话测试
这段代码解决"每次测试都要手写Redis连接"的问题
先用Pytest fixture构建一个隔离的测试用记忆存储,确保各测试互不干扰:
python
# conftest.py
import pytest
import fakeredis.aioredis # 异步fakeredis
from myapp.memory_store import RedisMemoryStore
@pytest.fixture
async def memory_store():
fake_redis = fakeredis.aioredis.FakeRedis(decode_responses=True)
store = RedisMemoryStore(redis_client=fake_redis)
yield store
await store.clear_all() # 每个测试后清空,不留痕迹
await fake_redis.aclose()
RedisMemoryStore 是我们自己封装的,不直接依赖LangChain的History类,这样可以单独测试序列化逻辑,也方便未来换成DynamoDB或PostgreSQL。
这段代码验证最核心的"存得进去、取得出来"
python
# test_memory_basic.py
import pytest
from myapp.schemas import ChatMessage
pytestmark = pytest.mark.asyncio # 整个模块都需要异步
async def test_save_and_retrieve_messages(memory_store):
session_id = "session-001"
messages = [
ChatMessage(role="user", content="我叫老王"),
ChatMessage(role="assistant", content="记住了,老王"),
]
await memory_store.add_messages(session_id, messages)
history = await memory_store.get_history(session_id)
assert len(history) == 2
assert history[0].role == "user"
assert history[0].content == "我叫老王"
assert history[1].role == "assistant"
这里的ChatMessage是pydantic模型,序列化用.json()存进Redis Hash,避免pickle带来的类版本耦合。
这段代码捕捉并发场景下消息丢失的幽灵bug
真实场景下,同一会话可能在极短时间内收到两条消息(用户手快连续发送)。如果存储实现不是原子操作,就可能覆盖掉第一条消息。我用asyncio.gather模拟并发写入,然后断言消息总数:
python
import asyncio
async def test_concurrent_message_writes_no_loss(memory_store):
session_id = "session-002"
msg1 = ChatMessage(role="user", content="并发消息A")
msg2 = ChatMessage(role="user", content="并发消息B")
await asyncio.gather(
memory_store.add_message(session_id, msg1),
memory_store.add_message(session_id, msg2),
)
history = await memory_store.get_history(session_id)
# 必须两条都在,且顺序不影响(实际可用时间戳排序)
contents = {m.content for m in history}
assert contents == {"并发消息A", "并发消息B"}, f"消息丢失! 只收到: {contents}"
如果没有做原子队列(比如用Redis List的RPUSH),两个并发RPUSH会各自成功,但如果你先用GET再SET,那第二条就会覆盖第一条------这个bug我就是在Pytest里用asyncio.gather逼出来的,一次跑10遍这个case能稳定复现。
参数化测试覆盖多轮对话和记忆裁剪
记忆不可能无限堆积,需要按token数裁剪最早的消息。这个裁剪逻辑最容易产生丢新消息、丢系统提示之类错误。我用Pytest的参数化技术,一次性验证多种极端情况:
python
@pytest.mark.parametrize("total_msgs, max_tokens, expected_remaining", [
(100, 500, 10), # 大量消息,强烈裁剪
(5, 1000, 5), # 不足max_tokens,全部保留
(3, 10, 1), # max_tokens极小,至少保留一条
(0, 100, 0), # 空历史
])
async def test_trim_history(memory_store, total_msgs, max_tokens, expected_remaining):
session_id = "session-trim"
for i in range(total_msgs):
await memory_store.add_message(
session_id, ChatMessage(role="user", content=f"消息{i}")
)
await memory_store.trim_by_token_limit(session_id, max_tokens=max_tokens)
remaining = await memory_store.get_history(session_id)
assert len(remaining) == expected_remaining, \
f"预期保留{expected_remaining}条,实际{len(remaining)}条"
# 更进一步检查:裁剪后第一条消息的消息索引是否符合预期,确保剪的是最旧的
这组参数化测试在CI里跑了不下五百次,成功拦截过两次裁剪算法的索引错位bug。
踩坑记录:官方文档没告诉你的事
坑一:fakeredis的decode_responses与async模式的暗坑
现象 :用fakeredis.aioredis.FakeRedis()不加decode_responses=True时,get_history返回的字符串全是b'xxx',断言总失败;加上之后,某些情况下hset会抛TypeError,说bytes不能序列化到JSON。
原因 :fakeredis在异步模式下,如果给decode_responses=True,它会试图把从hash/get拿到的值自动decode为str,但如果你的底层数据是pickle序列化的bytes形态,这个自动解码会把bytes当作UTF-8去解码,直接炸掉。而我们后续换成JSON存储后这个问题消失,因为JSON本身就是str类型。
解决 :决定彻底告别pickle,统一用ChatMessage.model_dump_json()存成字符串,读取时type.parse_raw()恢复。这样decode_responses=True安全且方便调试(Redis Desk可以直接看JSON内容)。
坑二:LangChain的ConversationBufferMemory在return_messages=True时悄悄插入了多余消息
现象:从LangChain memory load出来历史和直接查Redis里的数据不一致,总是多两条AI开头的空消息。
原因 :翻LangChain源码发现,ConversationBufferMemory在初始化时可选参数chat_memory,如果你不传,它会创建一个默认的ChatMessageHistory,然后在save_context之外,load_memory_variables方法会额外生成一条SystemMessage?? 并不,实际是因为我错误地把memory = ConversationBufferMemory(return_messages=True)直接当存储用,然后又在外面套了一层自己写的Redis封装,导致两条通路各自写了一份,load的时候拼接了起来。
解决:完全放弃LangChain的Memory封装,自己做消息历史管理,LLM侧只接收单纯的消息列表。这也让测试用例不再依赖LangChain内部实现细节,稳定性大幅提升。
效果验证
在引入这套Pytest测试套件之前,我们通过手工点击网页模拟对话来"感觉一下记忆有没有丢",上下文丢失率大概在8%~12%(根据用户反馈估算)。接入CI流水线后,test_memory模块包含23个用例,覆盖单消息写入、并发写入、裁剪、会话隔离、Redis断连恢复等。连续运行一万轮参数化测试,上下文丢失率从8%直接归零。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 上下文丢失率 | 8%~12% | 0% |
| 测试覆盖存储逻辑 | 0% | 95% (branch) |
| 单次回归测试耗时 | 手工20分钟 | CI自动化 9秒 |
| 上线信心指数 | 😨 "祈祷别丢" | 😎 "随时欢迎报bug" |
可直接拿去用的代码
下面这个test_memory_contract.py是记忆存储的通用契约测试类,你只要实现make_store fixture,就能复用所有用例,不管是Redis、PostgreSQL还是文件存储:
python
# test_memory_contract.py
from abc import abstractmethod
class MemoryStoreContract:
@abstractmethod
async def make_store(self):
"""子类实现返回一个MemoryStore实例"""
...
async def test_save_and_load(self):
store = await self.make_store()
await store.add_message("s1", ChatMessage(role="user", content="hello"))
hist = await store.get_history("s1")
assert len(hist) == 1
在你的具体实现测试文件中继承它,只需重写make_store。这就是我给你的可直接上战场的工具。
#Python #AI Agent #Pytest #Redis #后端实战
关于作者
我是宝哥,一个专啃硬骨头的老后端,擅长用Python把生产事故扼杀在CI流水线里。
GitHub: github.com/baofugege --- 文章中提到的MemoryStore测试模板会放在那里。
Sponsor: github.com/sponsors/ba... --- 如果这篇文章让你少熬了一次夜,请我喝杯咖啡。
提供服务:Python后端性能优化 / 内部工具定制 / 技术咨询,联系 Telegram @baofugege