上周给 Agent 加了个"记住用户饮食偏好"的功能,测试环境好好的,上线后用户第 3 次对话就发现它"失忆"。一查日志,写入返回成功,召回就是空。最后发现 Redis 的 key 过期时间被一个不起眼的续命逻辑覆盖了。手动验证一次要 40 多分钟,我决定用 pytest + Docker 把这件事彻底自动化。
问题拆解
AI Agent 的记忆存储通常不是简单的数据库读写,它至少包含三个动作:写入记忆、按条件召回、按 TTL 过期。问题在于这三个动作不是同一条路径,写入时可能序列化成功,召回时却因为 key 结构或过期策略拿不到数据。
我们当时的根因有两个:一是用 HSET 把同一 agent 的记忆塞进一个 hash key,每次写入后再 EXPIRE 整个 key,结果频繁写入会不断续命,部分记忆永远不过期;二是测试环境用的 Redis 版本和线上不一样,本地手测根本复现不出过期行为。常规方案------mock Redis 或写单测------只能验证代码逻辑,暴露不了真实 Redis 的 TTL 语义和 Docker 环境差异。所以必须让测试跑在真实 Redis 容器里,并且每次跑完自动清理。
方案设计
核心选型:pytest 负责测试编排,Docker 用 testcontainers 启动真实 Redis 7.2,redis-py 作为客户端。
不用 mock redis 的原因很简单:mock 会掩盖 SET 和 EXPIRE 的顺序问题,也会把"服务还没监听端口就返回连接"这类环境问题全部吃掉。不用 docker-compose 手动管理,是因为每次跑测试前手动 up/down 太烦,而且 CI 上容易残留脏容器。testcontainers 随测试生命周期自动创建和销毁,正好适合这种一次性验证场景。
架构上,我们把记忆存储封装成一个 MemoryStore 类,每条记忆拆成独立 Redis key,这样 TTL 可以单独控制。测试里用 1 秒短 TTL 模拟过期,避免长时间等待;断言覆盖写入召回一致性、过期一致性、覆盖刷新 TTL 和批量写入四种情况。
核心实现
这段代码解决记忆存储的写入、召回、过期封装,把每条记忆拆成独立 key 避免 TTL 互相踩。
python
import json
from typing import Optional
import redis
class MemoryStore:
"""AI Agent 记忆存储:基于 Redis 的写入、召回、过期实现"""
def __init__(self, host: str, port: int, db: int = 0):
self.client = redis.Redis(host=host, port=port, db=db, decode_responses=True)
def remember(self, agent_id: str, memory_id: str, data: dict,
ttl_seconds: int = 3600) -> None:
key = f"agent:{agent_id}:memory:{memory_id}"
# SET 本身支持 ex 参数,一条命令完成写入 + TTL,避免先写后 expire 的窗口问题
self.client.set(key, json.dumps(data, ensure_ascii=False), ex=ttl_seconds)
def recall(self, agent_id: str, memory_id: str) -> Optional[dict]:
key = f"agent:{agent_id}:memory:{memory_id}"
raw = self.client.get(key)
if raw is None:
return None
return json.loads(raw)
这段 fixture 解决"测试环境必须用真实 Redis,且要等服务真正可用"的问题。
python
import time
import pytest
import redis
from testcontainers.redis import RedisContainer
@pytest.fixture(scope="session")
def redis_container():
# 启动真实 Redis 7.2 容器,避免 mock 带来的假阳性
with RedisContainer("redis:7.2-alpine") as container:
host = container.get_container_host_ip()
port = container.get_exposed_port(6379)
# 显式等待服务可用,避免容器端口刚映射出来但 Redis 还没监听
client = redis.Redis(host=host, port=port, decode_responses=True)
for _ in range(20):
try:
client.ping()
break
except redis.ConnectionError:
time.sleep(0.2)
else:
raise RuntimeError("Redis container not ready")
yield host, port
这段测试用例验证写入后召回、过期后不可召回、覆盖时刷新 TTL,以及批量写入的场景。
python
import time
import pytest
from memory_store import MemoryStore
@pytest.fixture
def store(redis_container):
host, port = redis_container
store = MemoryStore(host=host, port=port)
yield store
store.client.flushdb() # 每个测试后清空,保证隔离
def test_write_then_recall_returns_same_data(store):
memory = {"preference": "low_salt", "allergy": "seafood"}
store.remember("user_123", "diet", memory, ttl_seconds=10)
assert store.recall("user_123", "diet") == memory
def test_expired_memory_returns_none(store):
store.remember("user_456", "session_pref", {"theme": "dark"}, ttl_seconds=1)
time.sleep(1.5) # 等 TTL 过期
assert store.recall("user_456", "session_pref") is None
def test_overwrite_refreshes_ttl(store):
store.remember("agent_1", "config", {"v": 1}, ttl_seconds=2)
time.sleep(1)
store.remember("agent_1", "config", {"v": 2}, ttl_seconds=2)
time.sleep(1.2) # 如果覆盖没有刷新 TTL,key 早就过期了
assert store.recall("agent_1", "config") == {"v": 2}
@pytest.mark.parametrize("memory_id,data", [
("diet", {"preference": "low_salt"}),
("schedule", {"meeting_time": "09:30"}),
("contact", {"email": "x@example.com"}),
])
def test_multi_memory_roundtrip(store, memory_id, data):
store.remember("user_789", memory_id, data, ttl_seconds=10)
assert store.recall("user_789", memory_id) == data
踩坑记录
坑 1:用 HSET + EXPIRE 给 hash key 设置 TTL,结果 key 永远不过期。
现象是有些用户的长期记忆占用内存一直降不下来,偶尔还能召回按理说早该过期的内容。原因是我们早期实现把同一 agent 的所有记忆存进一个 hash,每次写入字段后执行 EXPIRE,这会把整个 hash key 的 TTL 刷新一遍。用户活跃时记忆一直续命,过期逻辑形同虚设。
解决:拆成每条记忆一个 key,或者 RDM 版本支持 field TTL 时单独设置字段过期。官方文档不会直接告诉你"频繁 HSET 会给整个 hash 续命",这个坑只能靠真实 Redis 测试暴露。
坑 2:testcontainers 容器启动后立刻 get_exposed_port,连接 Redis 报 ConnectionError。
现象是本机跑得好好的,CI 上偶发失败。原因在于 get_exposed_port 返回的是 Docker 已经完成端口映射,但 Redis 服务内部可能还没监听。解决:不要用固定 sleep(1),而是加一个 ping 重试循环,最多重试 20 次,每次间隔 0.2 秒。这样既抗抖动,又不会让测试变慢。
效果验证
| 维度 | 手测 | pytest + Docker |
|---|---|---|
| 完整一致性验证一轮 | 42 分钟 | 3 分钟 |
| 启动/清数据 | 每次手动 | 自动 |
| 过期场景覆盖 | 等 5 分钟 TTL | 1 秒 TTL 验证 |
| 可重复性 | 低 | CI 每次运行 |
整体效率提升约 13 倍。接入 CI 后一周内提前拦下 3 个 TTL 相关回归,其中两个是 HSET 续命问题,一个序列化后召回不到数据。
可直接用的代码/工具
把上面 redis_container fixture 复制进你的 conftest.py,安装依赖后执行:
bash
pip install pytest testcontainers redis
pytest -m memory -v
pytest -m memory 跑你标记为 memory 的 AI Agent 记忆一致性用例,容器用完自动删,不污染本地环境。
#Python #pytest #AI Agent #Docker #Redis #自动化测试
关于作者
作者是一个给 AI Agent 修过记忆 bug 的后端/架构方向开发者,专注用自动化测试把"偶发性失忆"变成确定性失败。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省下 40 分钟手测,请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege