把 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

相关推荐
ℋᙚᵐⁱᒻᵉ鲸落3 小时前
移动端滑动手势冲突:overflow: auto导致外层横向滑动失效
前端·javascript·css·vue.js·html
默_笙4 小时前
🎃 前端学了 Next.js,后端该学啥?NestJS 就是 Node 版的蜜雪冰城
前端·javascript
前端snow5 小时前
ai agent --- 实现 openclaw 定时间效果
前端
码云之上5 小时前
换模型之后,Chatbot 为什么要自己做 compact?
前端·agent·前端工程化
星栈5 小时前
自定义 UI-UX-Pro-Max 的 CSV 知识库,把 AI 生成的后台拉回行业该有的样子
前端·agent·weui
研☆香6 小时前
简单的图片上传 删除 预览
前端
ITresearchGuest6 小时前
我用 AI 一小时写了一个世界杯数据可视化平台|前端 VibeCoding 初体验
前端·人工智能·信息可视化
慧一居士7 小时前
Vite项目中使用Less步骤,详细使用示例
前端·css
花间相见7 小时前
【LangChain组件03】—— LangChain Agents 执行与状态:工作流程与状态管理实战
java·前端·langchain
kyriewen8 小时前
面试官让我用 AI 重构一个 8 年陈的 React 组件——他说他不看代码,只看我会不会拆
前端·javascript·面试