LangChain Memory 踩坑实录:10种异常场景差点搞崩生产,我们写了一套自动化测试保命

凌晨两点十七分,手机震得比咖啡机还猛。打开监控一看,用户投诉"机器人胡言乱语",实际是 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 种异常场景清单:

  1. 连接超时(connect timeout)
  2. 读取超时(read timeout)
  3. 连接池耗尽
  4. 写入时 Redis OOM
  5. 序列化失败(不可序列化对象)
  6. 反序列化失败(数据损坏)
  7. 并发写入竞态
  8. 超大消息体(超过 max_chunk_size
  9. 存储后端完全不可用
  10. 多 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.Redisget 方法,让它直接 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

相关推荐
名字还没想好☜1 小时前
React useImperativeHandle 实战:让父组件安全地调用子组件的方法
前端·javascript·react.js·react·forwardref
CodeSheep1 小时前
FFmpeg 9.0正式发布:代号“Lei”,以纪念中国开发者雷霄骅
前端·后端·程序员
IT_陈寒1 小时前
小心!Java里的这个空指针问题绝对坑过你
前端·人工智能·后端
Canace1 小时前
AI 都能操作浏览器了,却读不了微信公众号文章
前端·人工智能·产品
铁皮饭盒1 小时前
阿里ASR语音转文字,用JS模拟微信
前端·javascript·后端
小比特-combat1 小时前
FreeRTOS任务通知
java·服务器·前端
柒@宝儿姐1 小时前
若依 B 端从浏览器访问升级为 Windows 安装包交付流程
前端·javascript·vue.js·windows
程序员黑豆6 小时前
鸿蒙应用开发:V1与V2版本数据持久化实战教程
前端·harmonyos