把 AI Agent 记忆存储验证从 40 分钟手测压到 3 分钟,pytest + Docker 这套组合太顶了

上周给 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 会掩盖 SETEXPIRE 的顺序问题,也会把"服务还没监听端口就返回连接"这类环境问题全部吃掉。不用 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

相关推荐
stereohomology1 小时前
MS Edge很多事的一点是把地址栏复制的地址自动转标题文本
前端·edge
imaol11 小时前
链表 -- 环链表
java·前端·链表
imaol12 小时前
链表 -- 双向链表
java·前端·链表
东风破_2 小时前
大前端手里的 Next.js:从 #root 到 SEO,一份 HTML 的两种命运
前端·后端
IT_陈寒2 小时前
Vue的v-for不听话?我被这个Key的坑整懵了
前端·人工智能·后端
王林不想说话3 小时前
ES6 到 ES2026 全面进阶指南:新特性、原理、示例与工程落地
前端·javascript·面试
用户43523713814933 小时前
🚀 Vue3 + TypeScript 学习笔记:从泛型 T 到 Promise<T>,终于搞懂了 keyof 和 typeof
前端
跟着群主去吃肉3 小时前
Cinemachine的应用-第三人称视角
前端·unity3d·游戏开发
东方小月3 小时前
从零开发一个 Coding Agent(十):实现 CLI 参数解析与静态命令
前端·人工智能·开源