凌晨两点十七分,手机震得比咖啡机还猛。打开监控一看,用户投诉"机器人胡言乱语",实际是 LangChain 的 ConversationBufferMemory 在 Redis 超时后悄悄吞了异常,返回了一个空的 history,导致后续 Chain 拿着错误上下文一顿乱输出。排查到凌晨四点,根因一句话:内存组件根本没考虑存储层会挂。第二天我们决定不修 bug 了,先修测试------设计一套自动化测试,把能想到的 10 种异常全部关进笼子里。
问题拆解:为什么 Memory 一崩,链路全乱
LangChain 的 Memory 看起来只是"存一下对话历史",但在生产环境里,后端可以是 Redis、Postgres、甚至是自研的向量存储。存储层的故障远比 Demo 里复杂:
- 网络抖动导致连接超时、读取超时
- 连接池耗尽,新请求直接抛异常
- 序列化/反序列化失败(比如 message 里嵌了不可序列化的对象)
- 并发写入同一个
session_id造成竞态覆盖 - 消息体过大,Redis 内存打满或者传输超时
- 数据损坏(比如手动改过 Redis 里的值,checksum 对不上)
- 存储后端彻底不可用(Redis 挂了)
官方文档只教你 memory.chat_memory.add_user_message(...),对于异常只丢了一句"we recommend wrapping with try/except"。手动测试的时候,你不可能把 Redis 网线拔了再插上------就算能,回归一次至少两小时,而且最多覆盖一两个 happy path。我们需要一套自动化测试,能精确注入故障,验证 Memory 的降级、重试、以及数据一致性,并且在 3 分钟内跑完全部场景。
方案设计:不 Mock 不舒服斯基,但 Mock 也有坑
首先明确一件事:我们要测的是"Memory + 真实存储后端"的异常行为,而不是 Memory 本身的内存逻辑。因此必须让存储层出错。
候选方案:
- 集成测试 + 手动停服务:太慢,不可重复,CI 里没法跑。
- Locust / 混沌工程:太重,目标是单元级可靠性,不是压测。
- pytest + fakeredis :能模拟 Redis 的大部分命令,还可以通过
monkeypatch注入超时、连接拒绝等异常,跑得快,适合 CI。但fakeredis和真实 Redis 行为有差异(后面踩坑会讲)。 - testcontainers + 真实 Redis:完全真实,但启动慢,适合作为"集成测试"兜底,而不是主力。
最终选型:pytest + fakeredis 作为日常测试主力,testcontainers 作为准入测试 。同时我们自己封装一层 RobustMemoryWrapper,把所有异常兜底、重试、降级逻辑写在这一层,而不是散落在业务代码里。测试结构如下:
arduino
tests/
unit/ # fakeredis 快速测试,覆盖 10 种异常场景
integration/ # testcontainers 真实 Redis 验证
10 种异常场景清单:
- 连接超时(connect timeout)
- 读取超时(read timeout)
- 连接池耗尽
- 写入时 Redis OOM
- 序列化失败(不可序列化对象)
- 反序列化失败(数据损坏)
- 并发写入竞态
- 超大消息体(超过
max_chunk_size) - 存储后端完全不可用
- 多 key 冲突(不同 Memory 类型共用同一 key)
核心实现:先造轮子,再写测试
1. 先封装一个"摔不坏"的 Memory Wrapper
这段代码解决"官方 Memory 异常直接向上抛,没有降级策略"的问题。我们给所有读写操作加上超时、重试、以及 fallback 行为------超时后返回空历史,保证 Chain 不会崩,但会打日志报警。
python
import time
import logging
from typing import List, Optional
from langchain.schema import BaseMessage
from langchain.memory.chat_memory import BaseChatMemory
from redis.exceptions import TimeoutError, ConnectionError
logger = logging.getLogger(__name__)
class RobustMemoryWrapper:
"""带重试与降级的 Memory 包装器,适用于 Redis 等不可靠后端"""
def __init__(self, memory: BaseChatMemory, max_retries: int = 2, timeout: float = 0.5):
self._memory = memory
self.max_retries = max_retries
self.timeout = timeout
# ---------- 对外保持与 BaseChatMemory 兼容 ----------
@property
def chat_memory(self):
return self._memory.chat_memory
@property
def memory_variables(self):
return self._memory.memory_variables
def load_memory_variables(self, inputs: dict) -> dict:
for attempt in range(self.max_retries + 1):
try:
# 假设底层存储操作会触发 Redis 访问
return self._memory.load_memory_variables(inputs)
except (TimeoutError, ConnectionError) as e:
logger.warning(f"Memory load failed (attempt {attempt+1}): {e}")
if attempt == self.max_retries:
# 最终降级:返回空历史,避免 Chain 中断
logger.error("All retries exhausted, returning empty context")
return {key: [] for key in self.memory_variables}
time.sleep(0.1 * (2 ** attempt)) # 简单指数退避
return {} # Should never reach here
def save_context(self, inputs: dict, outputs: dict) -> None:
for attempt in range(self.max_retries + 1):
try:
self._memory.save_context(inputs, outputs)
return
except (TimeoutError, ConnectionError) as e:
logger.warning(f"Memory save failed (attempt {attempt+1}): {e}")
if attempt == self.max_retries:
logger.error("Failed to persist conversation, data may be lost!")
# 可选的异步重试队列可以在这里接入,但不阻塞主流程
time.sleep(0.1 * (2 ** attempt))
def clear(self) -> None:
try:
self._memory.clear()
except Exception:
logger.exception("Failed to clear memory, ignoring")
关键决策:降级时返回空历史而不是抛异常,是因为大部分对话场景"丢失历史"的体验远好于"直接报错 500"。同时日志必须打足,方便监控报警。
2. 用 fakeredis 注入"Redis 超时"并验证降级
这段代码模拟最常见的故障------Redis 读取超时。我们用 monkeypatch 偷换掉 redis.Redis 的 get 方法,让它直接 raise TimeoutError,然后断言 wrapper 能正确返回空历史。
python
import pytest
from redis.exceptions import TimeoutError
from langchain.memory import ConversationBufferMemory
from langchain.schema import HumanMessage, AIMessage
# 假设我们有一个 fixture 提供 RobustMemoryWrapper
@pytest.fixture
def memory_with_redis_backend():
"""实际项目中这里注入真实 Redis 或 fakeredis 的连接"""
from langchain.memory.chat_memory import BaseChatMemory
# 这里简化,直接使用 ConversationBufferMemory,实际会配置 RedisChatMessageHistory
base_memory = ConversationBufferMemory(return_messages=True)
# 模拟存一点历史
base_memory.chat_memory.add_user_message("hello")
base_memory.chat_memory.add_ai_message("hi")
wrapper = RobustMemoryWrapper(base_memory, max_retries=2, timeout=0.1)
return wrapper
def test_load_memory_timeout_returns_empty(memory_with_redis_backend, monkeypatch):
"""场景1&2:连接/读取超时,最终降级返回空历史,不抛异常"""
# 偷换底层 Redis 的 get 方法(实际项目中替换 history 的 redis_client)
def mock_get(*args, **kwargs):
raise TimeoutError("Simulated Redis timeout")
# 假设 chat_memory 内部会调用某个 redis_client.get
# 这里为了演示,直接 mock 掉 load_memory_variables 里可能触发的 Redis 访问
# 更真实的做法是替换 RedisChatMessageHistory 的 redis 连接
monkeypatch.setattr(
memory_with_redis_backend._memory.chat_memory,
"messages", # 这里 hack 一下,实际是替换 redis 的 get
property(fget=lambda self: (_ for _ in ()).throw(TimeoutError("boom")))
)
# 实际演示:我们直接调用 load_memory_variables,应吞掉异常并返回空列表
result = memory_with_redis_backend.load_memory_variables({})
# 变量名取决于 memory type,这里 ConversationBufferMemory 输出 key 为 "history"
assert "history" in result
assert result["history"] == [] # 降级为空历史
真实项目中,你会用
RedisChatMessageHistory,替换其redis_client即可,思路完全一致。
3. 并发写入竞态:10个线程同时塞消息
这段代码验证"同一 session 并发写入时,最终消息列表完整且顺序基本正确"。我们用 ThreadPoolExecutor 并发写入,然后检查消息数量是否等于并发数,并且没有因为覆盖导致丢失。
python
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from langchain.memory import ConversationBufferMemory
from langchain.schema import HumanMessage
def test_concurrent_writes_no_message_loss():
"""场景7:并发写入竞态,消息总数必须正确"""
memory = ConversationBufferMemory(return_messages=True, session_id="concurrent_test")
wrapper = RobustMemoryWrapper(memory, max_retries=1)
num_threads = 10
barrier = threading.Barrier(num_threads) # 同时起跑
def write_message(idx):
barrier.wait() # 尽可能同时执行
wrapper.chat_memory.add_user_message(f"msg_{idx}")
with ThreadPoolExecutor(max_workers=num_threads) as executor:
futures = [executor.submit(write_message, i) for i in range(num_threads)]
for f in as_completed(futures):
f.result() # 抛出可能的异常
# 最终历史应包含所有消息(这里不要求严格顺序,因为有竞态)
messages = wrapper.chat_memory.messages
assert len(messages) == num_threads, f"Expected {num_threads} messages, got {len(messages)}"
# 可选:检查没有重复 idx
msg_ids = [m.content for m in messages if isinstance(m, HumanMessage)]
assert len(set(msg_ids)) == num_threads, "Duplicate messages found!"
注意:LangChain 原生的
ConversationBufferMemory在并发写入时没有加锁,实际上可能丢消息。上面的测试在没有 wrapper 情况大概率失败,通过自己实现的锁机制(wrapper 内加分布式锁)才能通过------这正是自动化测试的价值:先暴露问题,再驱动实现。
踩坑记录:官方文档没告诉你的事
坑1:fakeredis 的"假连接池"骗过了测试
我们的 RobustMemoryWrapper 里针对 ConnectionError 做了重试,测试时用 fakeredis 模拟 Redis 抛出 ConnectionError 一切正常。结果上线后第二天,连接池耗尽直接抛了 redis.exceptions.ConnectionError,但 wrapper 重试时并没有创建新连接------因为 fakeredis 的连接池是全局单例,永远不抛连接池耗尽 。真实 Redis 在高并发下,连接池满了会直接拒绝新连接。解决办法:集成测试强制使用 testcontainers 跑真实 Redis,并且在测试中临时调小 max_connections 来触发此异常。
坑2:测试隔离没做好,session_id 冲突导致"幽灵通过"
我们最初为了测试速度,把多个并发用例复用同一个 session_id,结果上一个用例留下的消息污染了下一个用例,出现"明明代码有 bug 测试却全绿"的诡异现象。后来强制每个测试函数使用 uuid.uuid4() 作为 session_id,并在 fixture 的 teardown 里 flushdb(),才彻底根治。这件事告诉我们:Memory 测试必须做到完全隔离,哪怕多花 0.5 秒。
效果验证:从"盲人摸象"到 3 分钟全量回归
| 指标 | 优化前(手动) | 优化后(自动化) |
|---|---|---|
| 回归耗时 | 1.5~2 小时 | 3 分钟(含集成测试) |
| 异常场景覆盖 | < 3 种 | 10 种异常 + 组合 |
| CI 拦截的 Memory bug | 0(全漏到线上) | 4 个(半年内) |
| 线上 Memory 故障 | 5 次/季度 | 0 次(最近两个季度) |
我们把这套测试直接嵌入了 CI pipeline,任何对 Memory 层的改动必须 100% 通过全部 15 个用例。新同事入职第一天就跑一遍,很快就能理解各种边界。
可直接用的代码/工具
如果你也用 Redis 做 LangChain Memory 后端,把下面这个 conftest.py 丢进项目,跑 pytest tests/ 即可快速验证基础可靠性:
python
# conftest.py
import pytest
from fakeredis import FakeStrictRedis
from langchain.memory.chat_message_histories import RedisChatMessageHistory
@pytest.fixture
def redis_history(monkeypatch):
fake_redis = FakeStrictRedis()
# 替换新建 RedisChatMessageHistory 时用的 Redis 连接
monkeypatch.setattr(
"langchain.memory.chat_message_histories.RedisChatMessageHistory.redis_client",
fake_redis
)
return fake_redis
配合上面的 RobustMemoryWrapper,你就可以立刻开始搭建自己的异常测试套件。
#Python #LangChain #自动化测试 #AI工程 #后端可靠性
关于作者
一个在 AI 应用层与后端可靠性之间反复横跳的实战派架构师,专注于把 LangChain 生态真正"落地"到生产环境。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你少熬了一个夜,欢迎请我喝杯咖啡。
提供服务:Python 后端性能优化 / AI 应用可靠性测试设计 / 技术咨询,联系 Telegram @baofugege