把Agent记忆测试从Mock换到真实Redis,漏测率从30%降到0

23 阅读11分钟

凌晨2点被PagerDuty叫醒,用户群里已经炸锅——AI客服Agent突然“失忆”了,上一条消息还跟用户确认手机号,下一条就问“请问您怎么称呼”。翻日志才发现,Redis连接池在连接抖动时抛了个ConnectionError,我们那个自以为可以兜底的Mock测试根本就没模拟过这个异常,代码直接跳过了记忆保存,上下文全丢。更让人后背发凉的是,这个回归测试是亮绿灯的。

这就是只有Mock没有真实中间件测试的典型灾难。我们花了两周,用pytest + 真实Redis把Agent记忆模块的自动化验证彻底重做,最终线上记忆相关的故障从之前的30%漏测率降到了0。这篇文章把完整方案、代码和踩过的坑一次性讲透。

1. 问题拆解:为什么Mock测不出Agent记忆模块的真实风险?

Agent的记忆模块不是简单的key-value存取,它要管三件事:

  • 短时记忆:最近N轮对话历史,用Redis List或ZSet存,带TTL自动淘汰。
  • 长时摘要:用LLM把长对话压缩成摘要,存Redis String或Hash,过期时间更长。
  • 上下文组装:从Redis取记忆拼成prompt,任何一步失败都会导致Agent“说胡话”。

Mock测试的做法通常是unittest.mock.patchredis.Redis,或者自己写一个假客户端。这样测下来覆盖率到80%看起来没什么问题,但线上真实世界根本不存在“永远成功的存储”:

  • 网络闪断时连接池耗尽,实际会抛redis.exceptions.ConnectionError,Mock版只会返回True
  • 序列化/反序列化并不总是无差错,比如pickle protocol版本不一致,或者复杂嵌套对象中含有不可序列化的lambda,Mock测试里根本触及不到。
  • Redis的内存淘汰策略(例如allkeys-lru)会在接近maxmemory时悄悄驱逐key,你的Agent备忘录就丢了,而Mock永远返回None只在你显式设时才有。

根源在于:Mock模拟的是你“以为”的Redis,而不是真实的Redis。常规单测能验证逻辑分支,但无法暴露出进程边界、网络边界和数据一致性问题。对于Agent这种强依赖外部存储状态的模块,集成测试必须落在真实Redis上,否则线上迟早还债。

2. 方案设计:为什么不用其他方案

面对“必须上真实Redis测试”,我们考虑了三种路线:

  • 手动维护一个开发Redis实例:测试前先手动清库,跑完再手动看结果。缺点是容易忘、环境漂移、CI上没人给你起Redis。
  • 用Embedded Redis(例如embedded-redis):JVM生态有,Python这边选择少且版本落后,跟我们用的Redis 7特性不匹配。
  • 用Docker容器化的Redis + pytest自动管理生命周期:这才是正道。测试启动Redis容器,测试结束销毁,环境干净可重复,CI一条命令搞定。

我们选择了 pytest + redis-py + testcontainers-python (备选docker-compose),原因很简单:testcontainers可以像写代码一样在conftest里声明Redis容器,自动等待端口就绪,测试结束自动销毁,不用额外写脚本。同时配合pytest的scope="session"共享容器,每个测试函数用独立的Redis命名空间(prefix或db编号)隔离,既保证真实环境,又不会相互污染。

为什么不直接在生产Redis的某个db上跑测试?——万一配置写错,flushdb没拦住,就是另一个血泪故事了。

3. 核心实现:一步步把测试从Mock迁移到真实Redis

下面给出三个关键代码块,分别是容器管理fixture、记忆存储实现和被测试的用例。目的是让你可以直接复制到项目里跑起来。

3.1 自动管理Redis容器的pytest fixture

这段代码解决“怎么在跑测试时自动获得一个干净的Redis实例”的问题。我们用testcontainers来启动Redis 7,并加上了wait_for确保容器真正可用再交给测试。

# conftest.py
import pytest
import redis
from testcontainers.redis import RedisContainer

@pytest.fixture(scope="session")
def redis_container():
    """session级Redis容器,整个测试会话只启动一次"""
    container = RedisContainer("redis:7-alpine")
    container.with_exposed_ports(6379)
    container.start()
    # 等待Redis真正ready(官方wait策略偶尔不够)
    client = redis.Redis(
        host=container.get_container_host_ip(),
        port=container.get_exposed_port(6379),
    )
    client.ping()  # 如果失败会在这里抛出,直接结束
    yield container
    container.stop()

@pytest.fixture
def redis_client(redis_container):
    """每个测试函数独立的Redis客户端,使用不同的db避免干扰"""
    client = redis.Redis(
        host=redis_container.get_container_host_ip(),
        port=redis_container.get_exposed_port(6379),
        db=0,
        decode_responses=True,  # 避免手动decode
    )
    # 每次测试前清空当前db
    client.flushdb()
    yield client
    client.close()

为什么用decode_responses=True Agent记忆模块里存的多是自然语言文本,如果返回bytes类型,每个地方都要.decode(),测试代码会充满噪音,容易掩盖真正的断言逻辑。

3.2 被测试的Agent记忆存储模块

这是简化版的生产代码,放在memory_store.py里。它负责用Redis Hash存储会话上下文,并设置TTL。我们故意保留了真实代码里容易出问题的序列化路径(使用json而不是pickle,但会校验数据完整性)。

# memory_store.py
import json
import logging
from typing import Optional, Dict, Any
from redis import Redis, RedisError

logger = logging.getLogger(__name__)

class MemoryStore:
    """Agent记忆存储,基于Redis Hash"""
    def __init__(self, client: Redis, ttl_seconds: int = 3600):
        self.client = client
        self.ttl = ttl_seconds

    def save_context(self, session_id: str, context: Dict[str, Any]) -> bool:
        """
        保存会话上下文到Redis。
        返回True表示成功,False表示存储失败(上层需重试或降级)。
        """
        key = f"agent:memory:{session_id}"
        try:
            # 序列化:使用JSON,避免pickle的版本问题
            payload = json.dumps(context, ensure_ascii=False)
            self.client.setex(key, self.ttl, payload)
            return True
        except (TypeError, ValueError) as e:
            logger.error(f"序列化失败 session={session_id}: {e}")
            return False
        except RedisError as e:
            logger.error(f"Redis写入失败 session={session_id}: {e}")
            return False

    def load_context(self, session_id: str) -> Optional[Dict[str, Any]]:
        """从Redis加载会话上下文,如果不存在或已过期返回None"""
        key = f"agent:memory:{session_id}"
        try:
            raw = self.client.get(key)
            if raw is None:
                return None
            return json.loads(raw)
        except json.JSONDecodeError as e:
            logger.error(f"反序列化失败 session={session_id}: {e}")
            return None
        except RedisError as e:
            logger.error(f"Redis读取失败 session={session_id}: {e}")
            return None

3.3 用真实Redis验证记忆存储的pytest用例

这组测试是核心:它不仅测成功路径,还模拟了序列化失败、Redis连接异常以及TTL过期的行为。而这些在Mock测试里根本触发不了。

# test_memory_store.py
import pytest
import redis
from memory_store import MemoryStore

class TestMemoryStoreWithRealRedis:
    """必须连接真实Redis才能通过的测试类"""

    def test_save_and_load_normal(self, redis_client):
        store = MemoryStore(redis_client)
        ctx = {"history": ["你好", "我在查订单"], "order_id": "12345"}
        assert store.save_context("sess1", ctx)
        loaded = store.load_context("sess1")
        assert loaded == ctx

    def test_ttl_expiry(self, redis_client):
        """验证TTL到期后记忆自动消失——Mock测试最容易忽略这个"""
        store = MemoryStore(redis_client, ttl_seconds=2)
        store.save_context("sess2", {"key": "value"})
        assert store.load_context("sess2") is not None
        import time
        time.sleep(3)  # 真实等待过期
        assert store.load_context("sess2") is None

    def test_serialization_failure_graceful(self, redis_client):
        """传不可序列化的对象时,应该返回False而不是抛异常"""
        store = MemoryStore(redis_client)
        # lambda不能被json序列化,save_context内部应捕获TypeError
        result = store.save_context("sess3", {"callback": lambda x: x})
        assert result is False

    def test_connection_error_handling(self, redis_client, monkeypatch):
        """模拟Redis连接断开,验证上层不会崩溃"""
        store = MemoryStore(redis_client)

        # 使用monkeypatch让下一次get操作抛出ConnectionError
        original_get = redis_client.get
        def failing_get(*args, **kwargs):
            raise redis.exceptions.ConnectionError("模拟连接断开")
        monkeypatch.setattr(redis_client, "get", failing_get)

        loaded = store.load_context("sess4")
        assert loaded is None  # 应该优雅返回None,而不是崩掉整个调用链

    def test_flushdb_isolation(self, redis_client):
        """验证测试间db隔离:前一个测试的数据不能污染当前测试"""
        store = MemoryStore(redis_client)
        assert store.load_context("nonexistent") is None

你可能会发现,test_ttl_expiry里有一个time.sleep(3),这在实际测试中会拖慢速度。真实项目里我们会用redis_client.expire强制提前过期,但这里保留sleep是为了展示真实TTL行为——这也是Mock测试里你永远闻不到的味道。

4. 踩坑记录:官方文档没告诉你的三个细节

坑1:decode_responses=True与JSON双重编码

现象:测试读出来的数据是字符串,但json.loads报错,看起来像是被编码了两次。

原因:我们在MemoryStore里已经json.dumps得到字符串,存进Redis。但如果在某个地方错误地又对字符串做了encode('utf-8'),再配合decode_responses=True,取出来时会出现编码不一致。真实场景往往是日志埋点或监控中间件在写入前对数据做了多余处理。

解决:统一所有MemoryStore操作不额外编码,测试里增加一个test_json_roundtrip_integrity,每次存入后取回直接比较字典,而不是字符串,确保没有隐形编码层。

坑2:Session级别的Redis连接没有显式关闭导致CI卡死

现象:本地跑全绿,一到GitHub Actions就卡在teardown阶段,进程不退出,最终超时被kill。

原因redis_client fixture scope为function,每次测试结束后client.close()会释放连接。但我们早期图省事把redis_client做成了session scope,测试完成后client还在连接池里挂着,Docker容器stop时TCP连接没有干净关闭,导致容器卡在退出状态。

解决:必须将client的scope控制在function级别,并且在teardown里显式client.close()。如果确实需要session级共享连接(比如测试耗时敏感),则要在conftest里用pytest_sessionfinish钩子统一关闭所有连接。

坑3:flushdb()在并发跑测试时的惊险竞态

现象pytest-xdist多进程并行跑时,偶尔某个测试读到了另一个测试刚写入的数据,导致断言随机挂。

原因flushdb()是按db操作的,而我们的fixture明明给每个测试函数分配了独立的db编号,却因为参数化测试在同一进程中复用fixture,没有重新调用flushdb()。更隐蔽的是,多进程时容器IP和端口是一致的,db编号分配依赖一个全局计数器,这个计数器在并行模式下不可靠。

解决:用进程安全的锁生成db编号,或者更简单的方案——仍然使用db=0,但在每个测试的key前面加上test_name前缀,flushdb改为按前缀删除模式,或者干脆每个测试函数结束时删除自己写入的所有key。我们最终选择每个测试结束后keys = client.keys("test:*"); if keys: client.delete(*keys),既保留了并行能力,又不用依赖全局db计数器。

5. 效果验证:从三个线上事故到零漏测

在仅使用Mock测试的阶段,我们三个月内记录了三次与记忆存储相关的线上故障:

  • 序列化异常导致记忆写空:传入了一个numpy.float64,json.dumps直接抛TypeError,记忆未保存,对话上下文丢失。
  • TTL失效:因为代码里TTL设置成了0(相当于永不过期),内存逐渐增长触发Redis淘汰,部分用户记忆被LRU清空。
  • 连接泄漏:每次请求创建一个新的Redis连接但没用连接池,高并发下连接数打满。

当我们把测试方案切换到pytest + 真实Redis后,第一个在CI上跑的全量测试就直接拦截了序列化问题和TTL设置错误——因为test_serialization_failure_gracefultest_ttl_expiry直接红了。连接泄漏问题也通过一个test_connection_pool_not_exhausted的压测用例(真实并发写入100次)在pre-prod环境抓了出来。

最终我们统计:引入方案后,记忆模块在所有回归测试中暴露出7个之前Mock测试未覆盖的bug,修复后三个月线上零记忆相关事故。定性来说,漏测率从大约30%降到了0

指标Mock测试阶段真实Redis测试阶段
记忆模块单测覆盖率82%91%
集成级bug发现数07
线上记忆相关故障/季度3次0次

6. 可直接用的:一键启动Redis环境跑测试

如果你现在就想在项目里落地,把下面这个docker-compose.ymlconftest.py放到项目根目录,搭配上面的测试用例,执行pytest就能立刻看到效果。

docker-compose.yml

version: "3.8"
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379"  # 随机端口避免conflict

conftest.py 无testcontainers版(适合不想引入额外依赖的团队)

import pytest
import redis
import subprocess
import time

@pytest.fixture(scope="session")
def redis_client():
    # 启动docker-compose中的redis服务
    subprocess.run(["docker-compose", "up", "-d", "redis"], check=True)
    time.sleep(2)  # 等待启动,生产级可换healthcheck轮询
    client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
    client.ping()
    yield client
    client.close()
    subprocess.run(["docker-compose", "down"], check=True)

#Python #pytest #Redis #AI Agent #自动化测试

关于作者
一个实战派后端/架构方向开发者,专注于Python生态的高可用系统和AI工程化落地,写代码也修生产故障。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你避开了凌晨的报警,请我喝杯咖啡
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege