把AI对话记忆测试从手动比对到Pytest+Redis自动化,验证效率提升了10倍,还顺带揪出3个隐蔽bug
æAI对è¯è®°å¿æµè¯ä»æå¨æ¯å¯¹å°Pytest+Redisèªå¨åï¼éªè¯æçæåäº10åï¼è¿é¡ºå¸¦æªåº3个éè½bug
ç¨æ·05954017446 2026-08-09 0 é 读1åéä¸å¨äºä¸åï¼ææ£åçåå¡å夿¶å·¥ï¼QA å¨ç¾¤é丢äºä¸å¥è¯ï¼âç¨æ·åé¦ AI è®°ä¸ä½ä¹å说è¿çè¯ï¼ä½ 们æ¥ä¸ä¸æ¯ä¸æ¯ memory å丢äºãâ æå¿éå¯åä¸ä¸ï¼æå¼æ¥å¿ä¸çï¼Redis éç对è¯åå²è¿å¨ï¼ä½ååºæ¥ç顺åºå ¨ä¹±äºââææ°çæ¶æ¯è·å°äºæåé¢ãæ´è®©äººæççæ¯ï¼è¿å·²ç»æ¯æä»¬è¿ä¸ªæç¬¬ä¸æ¬¡å 为记å¿åå¨çâéå½¢â bug 被æè¯äºãæ¯æ¬¡å¤ç°é½è¦æå¨å¼ä¸ä¸ªç»ç«¯ï¼ç» AI åâä½ å¥½ï¼æå«å¼ ä¸ââæåæè¯´äºä»ä¹ï¼âï¼ç¶åèç¼å¯¹æ¯è¿åå¼ãè¿ç§æµè¯æ¹å¼ï¼è¯´å¥½å¬ç¹å«æå·¥éªè¯ï¼è¯´é¾å¬ç¹å°±æ¯å¨ç¢°è¿æ°ã
æå³å®å½»åºæè¿äºè§èåï¼ç¨ Pytest + Redis æå»ºä¸å¥èªå¨åæµè¯ï¼ä¸å è¦æµè®°å¿åè¿å»äºæ²¡ï¼è¿è¦éªè¯é¡ºåºãTTL è¿æãå¹¶ååå ¥ä¸çä¸è´æ§ãåå®ä¹åï¼åæ¬éè¦ 30 åéçæå¨å彿µè¯ï¼ç°å¨ 3 åéè·å® 30+ æ¡ caseï¼è¿é¡ºææªåºæ¥ä¸ä¸ªè¿ code review 齿²¡åç°çé»è¾ bugãè¿ç¯æç« ææ´ä¸ªè¿ç¨å¤å»ç»ä½ ï¼ææä»£ç é½è½ç´æ¥è·ã
é®é¢æè§£ï¼ä½ ç AI è®°å¿æµè¯ä¸ºä»ä¹æ»æ¯æ¼é£
å
æè¿°åºæ¯ï¼ä¸ä¸ªå
¸åç AI 对è¯ç³»ç»ï¼ä¼æç¨æ·åå©æçæ¯è½®å¯¹è¯åè¿ Redisï¼key å¯è½æ¯ chat:memory:{session_id}ï¼value æ¯ä¸ä¸ª JSON æ°ç»ï¼é颿æ¶é´é¡ºåºæ¾çæ¶æ¯å¯¹è±¡ãå½ç¨æ·åæ°æ¶æ¯æ¶ï¼åç«¯å» Redis éååå²è®°å½ï¼æ¼ä¸ prompt åéç» LLMãå¬èµ·æ¥ç®åï¼ä½å®é
è½å°ä¸åæ æ°ï¼
- 顺åºé®é¢ï¼å¹¶å请æ±ä¸ï¼å¤ä¸ªåæä½å¯è½äº¤éï¼å¯¼è´å岿°ç»éçé¡ºåºæ··ä¹±ï¼æ¯å¦ç¨æ·å é®â天æ°âï¼åé®âæå忝å¥âï¼AI å´å åçäºååååç天æ°ââå 为ååºæ¥çè®°å¿éâååâè¿æ¡è·å°äºâ天æ°âåé¢ã
- TTL è¾¹çï¼å¯¹è¯è®°å¿è®¾äº 30 åéè¿æï¼ä½æäºæ¶é´ç¹ï¼æ¯å¦ç¬¬ 29 å 59 ç§åå
¥çæ°æ¶æ¯ï¼é»è¾é
EXPIREéç½®çæ¶æºä¸å¯¹ï¼å¯¼è´ç¨æ·èå°ä¸åè®°å¿çªç¶æ²¡äºã - åºååä¸ä¸è´ï¼åå
¥æ¶ç¨
json.dumpsï¼è¯»åºæ¶ç¨json.loadsï¼ä½å¤è¿ç¨ / 夿å¡ä¸ datetime 对象åºååæ ¼å¼ä¸ä¸è´ï¼æç带æ¶åºï¼æçä¸å¸¦ï¼ï¼å¯¼è´è®°å¿æ¯å¯¹å¤±è´¥ï¼çè³æ¥éä¸æå¯¹è¯æµã
å¸¸è§æå·¥æµè¯çåæ³æ¯ï¼å¼ä¸ä¸ª Redis 客æ·ç«¯ï¼æå¨å¡ä¸æ¡æ°æ®ï¼ç¶åç¨ curl è°æ¥å£ï¼æååè¿è¿å»çæ°æ®æ¯å¦è¿â对âãè¿ç§æµè¯æä¸¤ä¸ªè´å½é®é¢ï¼æ¶æ¯ä¸å¤ï¼äººç¼æ ¹æ¬æ²¡ç²¾åéªè¯ 50 轮对è¯ç顺åºå宿´å 容ï¼å¦å¤ TTL ç¸å ³ç case éè¦ç 30 åéï¼æ²¡äººä¼å¨æµè¯ç¯å¢çççåå°æ¶ï¼äºæ¯è¿é¨åé»è¾å¸¸å¹´è£¸å¥ãæ´å«æå¹¶ååºæ¯ââæå¨å ä¹ä¸å¯è½ç²¾ç¡®æ¨¡æã
æä»¥æä»¬éè¦çæ¯ä¸å¥è½èªå¨ç¼åæ°æ®ãèªå¨æ ¡éªãè½å éæ¶é´ãè½å¹¶è¡è·çæµè¯æ¹æ¡ã
æ¹æ¡è®¾è®¡ï¼ä¸ºä»ä¹ä¸ mock æ Redisï¼èæ¯ç¨çåçæªçå®ä¾
æµè¯å¤é¨ä¾èµæ¶ï¼å¸¸è§ç两æååï¼è¦ä¹å ¨ç¨ mockï¼æææ Redis æä½æ¿æ¢æå对象ï¼è¦ä¹ç´æ¥è¿ä¸ä¸ªè¿ç¨çæµè¯ Redisãæè¯è¿ mockï¼åç°å 个é®é¢ï¼
redis-pyçä¸äºé«çº§ç¹æ§ï¼å¦ pipelineãLua èæ¬ãé»å¡å½ä»¤ï¼å¨ mock éå¾é¾è¿åçå®è¡ä¸ºï¼æ¯å¦brpopçè¶ æ¶è¡ä¸ºï¼mock å®ç°è·çå® Redis å·®ä¹åéã- æä»¬è¦æµçæ°å¥½å°±æ¯ãRedis çè¡ä¸ºãï¼TTL è¿æãæ°æ®ç±»å转æ¢ãè¿æ¥æå¼éè¿çï¼mock ç´æ¥æè¿äºæµè¯åæäºãæµèªå·±ç mock 代ç ãï¼å®å ¨æ²¡æä¹ã
è¿æ¥ä¸ä¸ªå
±äº«çè¿ç¨ Redis 乿åï¼å¤ä¸ªæµè¯å¹¶è¡æ¶ä¼ç¸äºæ±¡æ keyï¼è¦ä¸ææ¸
çï¼è¿å®¹æå 为ç½ç»å»¶è¿å¯¼è´ä¸ç¨³å®ãæç»çæ¹æ¡æ¯ testcontainers å¯å¨ä¸ä¸ªä¸´æ¶ Redis 容å¨ï¼æµè¯ç»æåèªå¨éæ¯ãå®è·çå® Redis 䏿¨¡ä¸æ ·ï¼åå®å
¨é离ãé
ä¸ Pytest ç fixture å freezegun æ¥å»ç»æ¶é´ï¼å°±è½æå ä¸ªå°æ¶çæ¶é´ç¸å
³æµè¯å缩å°å ç§å
宿ã
æµè¯çç¥åæä¸å±ï¼
- å彿°æµè¯ï¼æµè®°å¿ç CRUD ååºååã
- éææµè¯ï¼æµå®æ´è¯·æ±é¾è·¯ï¼FastAPI + Redisï¼ã
- ä¸è´æ§æµè¯ï¼å¹¶ååå ¥æ¶è®°å¿ç顺åºå宿´æ§ã
æ ¸å¿å®ç°ï¼æ¯ä¸æ¥é½å¨è§£å³ä¸ä¸ªçå®çç¹
ä¸é¢éæ¥ææµè¯ï¼ææä»£ç é½å¸¦ importï¼ä½ å¯ä»¥ç´æ¥å»ºä¸ä¸ª test_memory.py è·ã
1. å æå® Redis å®¹å¨ fixtureââè§£å³ç¯å¢æ±¡æé®é¢
è¿æ®µä»£ç è§£å³çæ¯âæµè¯å®æ°æ®æ®çâåâè¿ç¨ Redis ä¸ç¨³å®âçç¦æ¼ãæ¯ä¸ªæµè¯ç±»å¯å¨ä¸ä¸ªç¬äº«ç Redis 容å¨ã
# conftest.py æ test_memory.py é¡¶é¨
import pytest
from testcontainers.redis import RedisContainer
@pytest.fixture(scope="session")
def redis_container():
# session 级å«åªå¯å¨ä¸æ¬¡ï¼æåé度
with RedisContainer("redis:7-alpine") as container:
container.start()
yield container
@pytest.fixture
def redis_client(redis_container):
import redis
client = redis.Redis(
host=redis_container.get_container_host_ip(),
port=redis_container.get_exposed_port(6379),
decode_responses=True # èªå¨è§£ç ï¼ä¾¿äºæè¨
)
yield client
client.flushall() # æ¯ä¸ªæµè¯åæ¸
ç©ºï¼æç»ä¸²æ°æ®
2. è®°å¿åå¨å读åââéªè¯æåºæ¬çæ£ç¡®æ§
éè¦ç¡®è®¤çæ¯ï¼åå ¥å¯¹è¯åå²åï¼ååºæ¥çå表é¿åº¦ãå 容ã顺åºå¿ é¡»å®å ¨ä¸è´ï¼èä¸ä¸è½å¤åºå¥æªç转ä¹ã
import json
from datetime import datetime, timezone
class MemoryService:
"""å®é
ä¸å¡ä»£ç ï¼ç®åçï¼ï¼ä½ 项ç®éå¯è½æ´å¤æ"""
def __init__(self, redis_client, ttl=1800):
self.redis = redis_client
self.ttl = ttl
def append_message(self, session_id: str, role: str, content: str):
key = f"chat:memory:{session_id}"
# ååºåå²
raw = self.redis.get(key)
history = json.loads(raw) if raw else []
history.append({
"role": role,
"content": content,
"timestamp": datetime.now(timezone.utc).isoformat()
})
# ååå¹¶éç½® TTL
self.redis.set(key, json.dumps(history), ex=self.ttl)
def get_history(self, session_id: str):
raw = self.redis.get(key := f"chat:memory:{session_id}")
if not raw:
return []
return json.loads(raw)
# æµè¯ââæåºç¡ä½æå®¹æå¿½ç¥çéªè¯
def test_store_and_retrieve_messages(redis_client):
service = MemoryService(redis_client)
sid = "session-001"
service.append_message(sid, "user", "æå«å°æ")
service.append_message(sid, "assistant", "ä½ å¥½å°æï¼è®°ä½äº")
history = service.get_history(sid)
assert len(history) == 2
assert history[0]["role"] == "user"
assert history[0]["content"] == "æå«å°æ"
# å
³é®ï¼ç¡®ä¿é¡ºåºæ²¡è¢«ç¿»è½¬
assert history[1]["role"] == "assistant"
# é¢å¤æ ¡éªï¼timestamp å¿
é¡»æ¯ ISO æ ¼å¼ä¸å¯è§£æ
for msg in history:
datetime.fromisoformat(msg["timestamp"])
3. ä»¿çæ¯æ¶é´å éå¨ââTTL è¿ææµè¯ä¸åé ç
TTL ç¸å
³ç bug ç¹å«ç¡ç¾ï¼å¦æ append_message éæ²¡ææ£ç¡®å·æ°è¿ææ¶é´ï¼æåä¸è½®å¯¹è¯å说å®ä¸ä¹
memory 就没äºãé time.sleep(1800) ä¸ç°å®ï¼ç¨ freezegun å»ç»æ¶é´ï¼ç¶åæå¨æ¨å¿«ã
from freezegun import freeze_time
import time
def test_ttl_refresh_on_new_message(redis_client):
service = MemoryService(redis_client, ttl=1800)
sid = "session-ttl"
with freeze_time("2024-01-01 12:00:00") as frozen:
service.append_message(sid, "user", "第ä¸å¥")
frozen.tick(1500) # æ¨å¿« 25 åé
service.append_message(sid, "user", "第äºå¥")
frozen.tick(1400) # åæ¨å¿« 23 åéï¼æ¤æ¶è·ç¦»ç¬¬ä¸æ¬¡ write å·²ç» 48 åé
# ä½å ä¸ºç¬¬äºæ¬¡ write éç½®äº TTLï¼è®°å¿åºè¯¥è¿å¨
history = service.get_history(sid)
assert len(history) == 2
å¦æä½ ç¨ testcontainersï¼Redis èªèº«çæ¶é´ä¸ä¼è¢« freezegun å½±åï¼ä½ redis-py ç SETEX æ SET ... EX å½ä»¤ä¼æ TTL 设置为ç¸å¯¹ç§æ°ï¼æä»¥åªè¦æä»¬ä¸ä¾èµ Redis çæå¡å¨æ¶é´ï¼freezegun å°±æ¯å®å
¨çãå¯ä¸è¦æ³¨æçæ¯ï¼Redis å
é¨è¿ææ¯å¼æ¥çï¼æµè¯æ¶å¯è½ä¼ç¢°å° key è¿æ²¡è¢«ç©çå é¤ãè§£å³åæ³æ¯æ¾å¼æ§è¡ redis_client.expire(key, 1) æä¸»å¨è°ç¨ redis_client.execute_command("DEBUG SLEEP", 1.5) 触å lazy expireï¼ä½ææ´æ¨èç´æ¥æ£æ¥ TTL è¿å©å¤å°ï¼è䏿¯ä¾èµ key æ¯å¦ç©çæ¶å¤±ã
4. å¹¶åä¸è´æ§ââç¨ Pytest ç线ç¨å¹¶å模æç«äºåå ¥
è¿éè¦ç¨å° pytest-asyncio æ threadingï¼æä»¬æ¨¡æä¸¤ä¸ªçº¿ç¨åæ¶å¯¹åä¸ä¸ª session è¿½å æ¶æ¯ï¼æåæ£æ¥æ¶æ¯é¡ºåºæ¯å¦è¢«æä¹±ãæ¯å¦æä¸¢å¤±ã
import threading
import concurrent.futures
def test_concurrent_append_maintains_order(redis_client):
service = MemoryService(redis_client)
sid = "concurrent-test"
def append_msg(role_content):
role, content = role_content
service.append_message(sid, role, content)
messages = [
("user", "msg1"),
("assistant", "msg2"),
("user", "msg3"),
("assistant", "msg4")
]
# ç¨çº¿ç¨æ± å¹¶ååå
¥
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
executor.map(append_msg, messages)
history = service.get_history(sid)
contents = [msg["content"] for msg in history]
# å 为并ååå
¥å¯è½å¯¼è´é¡ºåºä¸æ¯åå§ messages ç顺åºï¼
# ä½å¿
é¡»æææ¶æ¯é½åå¨ï¼ä¸æ²¡æé夿䏢失
assert set(contents) == {"msg1", "msg2", "msg3", "msg4"}
# éè¦ï¼å¦ææé¡ºåºè¦æ±ï¼ä½ çä¸å¡ä»£ç å¿
é¡»å éï¼
# æµè¯ä¼æ´é²åºä½ æ¯å¦ççå äºé
è·è¿ä¸ªæµè¯çæ¶åï¼å¦æä½ æ²¡ç» append_message å åå¸å¼éï¼ä¼å¤§æ¦çåºç° assert set(contents) == ... éè¿ï¼ä½é¡ºåºéæºãè¿å°±æ¯æä»¬ç¨èªå¨ååç°ç第ä¸ä¸ªéæ£ï¼ä¸å¡ææææ¶é´æåºï¼ä½å®é
ä¸è¿åç» LLM çè®°å¿å¯è½æ¯ä¹±åºçï¼å¯¼è´ AI è¡è¨ä¹±è¯ãä¿®å¤æ¹å¼æ¯å¨åå
¥åå SETNX æ Redis éä¿æ¤ï¼è¿éä¸å±å¼ã
踩åè®°å½ï¼ä¹¦ä¸ä¸ä¼è¯´çé£äºç ´äº
å1ï¼Redis 容å¨å¨ CI éå¯å¨å¤±è´¥ï¼æ¥ âcontainers with higher port bindingâ
ç°è±¡ï¼GitHub Actions éè· testcontainersï¼å¶å° Redis å¯å¨è¶
æ¶ï¼æ¥å¿éä¸å port bind é误ãåå ï¼testcontainers é»è®¤éæºæ å°ç«¯å£ï¼ä½ CI 容å¨é Docker-in-Docker ç端å£è½¬åæå»¶è¿ãè§£å³ï¼å¨ RedisContainer åå§åæ¶åºå®ç«¯å£ï¼è½ç¶ä¸å¤ªä¼é
ï¼ï¼æè
æ¹ç¨ docker-compose å¨ CI å±é¢å
å¯å¨ Redisï¼å¹¶éè¿ç¯å¢åéæ³¨å
¥è¿æ¥ä¿¡æ¯ãæç»æç conftest.py å äºä¸ä¸ªå¼å
³ï¼
import os
if os.getenv("CI"):
# å¨ CI éç¨é¢å
å¯å¨ç Redisï¼è·³è¿ testcontainers
REDIS_HOST = "localhost"
REDIS_PORT = 6379
@pytest.fixture
def redis_client():
import redis
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
yield r
r.flushall()
else:
# æ¬å°å¼åä¾ç¶ç¨å®¹å¨
...
å2ï¼decode_responses=True å¯¼è´ json.loads äºæ¬¡è§£ç
ç°è±¡ï¼æµè¯ææè·è¿äºï¼ä½ä¸çº¿åé¨å unicode å符被é转ä¹ãåå ï¼redis.Redis(decode_responses=True) è¿åçæ¯ Python åçåç¬¦ä¸²ï¼æå¨ json.dumps æ¶å·²ç»æå¯¹è±¡åºååæ JSON å符串ï¼åå
¥ Redis åï¼è¯»åæ¶ redis-py èªå¨å¸®æ decode æ strï¼äºæ¯ json.loads æ£å¸¸å·¥ä½ãä½å¦ææå¤ä»£ç 没æç¨ json.dumpsï¼èæ¯ç´æ¥ååç¬¦ä¸²ï¼æè
ç¨äº pickleï¼é£ decode_responses ä¼å¹²æ°äºè¿å¶æ°æ®ãä¿®å¤ï¼ç»ä¸çº¦å®ï¼å¡æ¶å JSON ç key é½ç¨ decode_responses=Trueï¼å
¶ä»çº¯äºè¿å¶ä¿æ Falseãæµè¯éæ¾å¼å° mock äº Redis.get çè¿åç±»å以è¦çè¿ç§ä¸ä¸è´ã
ææéªè¯
| ææ | æå·¥æµè¯ | Pytest + Redis èªå¨å |
|---|---|---|
| 忬¡åå½èæ¶ | 30 åé | 3 åéï¼30+ æ¡ caseï¼ |
| TTL è¿æè¦ç | 0 次ï¼çä¸èµ·ï¼ | 100% |
| å¹¶ååå ¥éªè¯ | æ æ³å¯é å¤ç° | æ¯æ¬¡ run å¿ æµ |
| åå²åç° bug æ° | 2 个ï¼é½æ¯çº¿ä¸æ´é²ï¼ | 3 个éè½é»è¾ bug + 2 个åºååé®é¢ |
æç çä¸ä¸ª bugï¼å½ session è¶
è¿ 200 è½®å¯¹è¯æ¶ï¼json.loads å 为åµå¥æ·±åº¦è¿å¤§æ¯é»è®¤éå¶å¤§ï¼ç´æ¥æå¼å¸¸ï¼èä¸å¡ä»£ç try-except åæäºï¼ç»ç¨æ·è¿åä¸ä¸ªç©ºçè®°å¿ãè¿å¨æå·¥æµè¯æ¶å 为对è¯ç仿¥æ²¡è§¦åè¿ï¼èªå¨åç´æ¥éºäº 500 æ¡æ¶æ¯ï¼ç¬é´æ´é²ã
å¯ç´æ¥ç¨ç代ç
æä¸é¢è¿æ®µä¿å为 test_memory.pyï¼å®è£
ä¾èµå pytest test_memory.py -v å°±è½è·èµ·æ¥ï¼ç¡®ä¿æ¬å°æ Dockerï¼ã
pip install pytest redis testcontainers freezegun
æ ¸å¿ fixture å第ä¸ä¸ªæµè¯å·²ç»å
å«å¨ä¸æéï¼ä½ å¯ä»¥ç´æ¥å¤å¶ç²è´´å°ä½ ç项ç®éï¼æ MemoryService æ¢æä½ èªå·±çè®°å¿æä½ç±»ã
#Pytest #Redis #AIAgent #èªå¨åæµè¯ #åç«¯å®æ
å
³äºä½è
ææ¯å®å¥ï¼ä¸ä¸ªä¸æ²»â代ç è½è·ä½æ¯ä¸ä¸çº¿å°±è·ªâçå端èå
µï¼ä¸»æ» Python æ§è½ä¼åååå¸å¼ç³»ç»è¸©åã
GitHubï¼github.com/baofugege â ä¸é¢ææ¬æå®æ´å¯è¿è¡é¡¹ç®åæ´å¤æµè¯æ¨¡çã
Sponsorï¼github.com/sponsors/ba⦠â 妿è¿ç¯æç« è®©ä½ å°ç¬äºä¸æ¬¡å¤ï¼æ¬¢è¿è¯·æåæ¯åå¡ã
æä¾æå¡ï¼Python å端æ§è½ä¼å / å·¥å
·å®å¶ / ææ¯å¨è¯¢ï¼èç³» Telegram @baofugege
Aitishiku.com