AI 客服突然“失忆”,排查 6 小时发现 LangChain 记忆存储的 3 个致命坑

凌晨两点被报警电话叫醒,群里用户疯狂吐槽:"客服机器人每轮都在装傻,刚说的订单号它转头就问'请问您的订单号是?'"------上下文全丢了。打开日志一看,报错没几条,但对话历史干干净净,就像从来没聊过一样。那一刻我就知道,LangChain 的 memory 还是太"玩具",线上稍微来点并发和重启就能让它原形毕露。

问题拆解

我们给电商平台接了个基于 LangChain 的售后问答 Agent,核心逻辑不复杂:用 ConversationBufferMemory 存上下轮对话,跟着 Chain 走完 RunnableWithMessageHistory 的路子。开发环境一切正常,但一上生产就疯狂"失忆"。场景非常典型:

  1. 多个 Pod 共享一个 Redis,大家都往里写会话历史
  2. 用户偶尔会连续快速发送两条消息(网络重试或手快)
  3. 运维偶尔重启某个容器做滚动更新

根因其实就三件事:① 默认 InMemory 重启即丢失② Redis 后端的 key 规则与并发读写没有原子保护ConversationBufferMemorymemory_keyreturn_messages 配置和生产 Prompt 不匹配,导致 history 以字符串形式塞进去却解析失败 。常规方案------比如直接用 RedisChatMessageHistory 配个 RunnableWithMessageHistory------能跑,但一旦出现上面这些边界情况,官方文档里那些"开箱即用"突然就不灵了。你不可能靠肉眼盯着 Redis 来保证 100% 不丢上下文,你需要的是一套自动化测试死死咬住这些边界。

方案设计

我选了 Pytest + Testcontainers 的真集成方案,而不是 mock 掉存储层的单元测试。理由很简单:你 mock 掉 Redis,就永远测不出 hgetall 返回空列表时 messages_to_dict 的反序列化异常;你 mock 掉 ChatMessageHistory,就没法抓到会话 key 冲突导致的历史覆盖。

技术选型思路:

  • 测试运行器 :Pytest,加上 pytest-asyncio 来对付异步 chain
  • 基础设施testcontainers 拉真实 Redis / Postgres 镜像,不做任何 mock
  • 被测对象 :LangChain 的 ConversationBufferMemoryRunnableWithMessageHistory、以及自定义的 ChatMessageHistory 工厂
  • 为什么不选其他方案 :考虑过直接用 unittest 搭个本地 Redis,但每次清理数据太麻烦,CI 上也不友好。Testcontainers 可以随意起销毁,隔离性拉满。也考虑过用 langsmith 的测试工具,但它的定位是追踪而非回归防御,我更想要一个能挡在 CI 里的"门神"。

架构很简单:写一个 conftest.py,里面用 fixture 启动 Redis 容器,构造 ChatMessageHistory 实例;然后针对不同的 session_id 生成、消息写入、重启后读取、并发写入等场景,分别写测试函数。每个测试都模拟一次完整的 Chat 调用链,断言返回的 AIMessage 是否包含了历史上下文。

核心实现

1. 不再裸写 ChatMessageHistory,用 fixture 封装真实的 Redis 连接

这段代码解决"每次测试都要手动起 Redis"的问题,同时保证每个测试的 session 隔离。

python 复制代码
# conftest.py
import pytest
from testcontainers.redis import RedisContainer
from langchain_community.chat_message_histories import RedisChatMessageHistory

@pytest.fixture(scope="session")
def redis_container():
    """会话级别只启动一次 Redis 容器,避免重复拉镜像"""
    container = RedisContainer("redis:7-alpine")
    container.start()
    yield container
    container.stop()

@pytest.fixture
def history_factory(redis_container):
    """每次测试调用此工厂,产出一个全新的 history 实例"""
    def _factory(session_id: str):
        url = f"redis://{redis_container.get_container_host_ip()}:{redis_container.get_exposed_port(6379)}"
        return RedisChatMessageHistory(
            session_id=session_id,
            url=url,
            key_prefix="chat:test:"   # 与生产前缀隔离
        )
    return _factory

2. 模拟多轮对话,验证 history 是否真的被读回 Prompt

这段测试想复现的场景:用户说过订单号后,机器人必须在下轮回答中引用它,否则就判"失忆"。

python 复制代码
# test_memory_persistence.py
import pytest
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_openai import ChatOpenAI

@pytest.mark.asyncio
async def test_chat_keeps_order_number(history_factory):
    """
    用户提供订单号 → 机器人应记住并复述
    """
    prompt = ChatPromptTemplate.from_messages([
        ("system", "你是客服,必须记住订单号。"),
        MessagesPlaceholder(variable_name="history"),
        ("human", "{input}"),
    ])
    chain = prompt | ChatOpenAI(model="gpt-3.5-turbo", temperature=0)

    # RunnableWithMessageHistory 会从 history_factory 按 session_id 拉取历史
    with_history = RunnableWithMessageHistory(
        chain,
        history_factory,
        input_messages_key="input",
        history_messages_key="history",
    )

    session_id = "user-123"

    # 第一次:告知订单号
    resp1 = await with_history.ainvoke(
        {"input": "我的订单号是 ORD-9999"},
        config={"configurable": {"session_id": session_id}},
    )
    assert "ORD-9999" in resp1.content   # 确保即时回复包含

    # 第二次:问状态,不应再问订单号
    resp2 = await with_history.ainvoke(
        {"input": "这个订单发货了吗?"},
        config={"configurable": {"session_id": session_id}},
    )
    # 关键断言:回答里不能出现"请问您的订单号",否则就是失忆
    assert "请问您的订单号" not in resp2.content
    assert "ORD-9999" in resp2.content or "发货" in resp2.content

3. 测试 Redis 重启后记忆是否保留

模拟运维重启最怕的场景:容器挂掉再起来,会话还能继续吗?这里不 mock,直接 kill 重建 RedisChatMessageHistory 对象。

python 复制代码
@pytest.mark.asyncio
async def test_memory_survives_restart(history_factory):
    session_id = "survivor-001"
    # 写入一条消息
    hist1 = history_factory(session_id)
    hist1.add_user_message("我叫张三")
    hist1.add_ai_message("你好张三,有什么可以帮您?")
    # 模拟重启:新建一个 history 实例,底层 Redis 不变
    hist2 = history_factory(session_id)
    messages = hist2.messages
    assert len(messages) == 2
    assert messages[0].content == "我叫张三"
    assert messages[1].content == "你好张三,有什么可以帮您?"

踩坑记录

坑1:ConversationBufferMemoryreturn_messages=True 会破坏 Prompt 中的字符串插入

现象 :本地跑得好好的,一上生产机器人突然在回答里吐出一堆 Python 对象的 repr,比如 [HumanMessage(content='...')]。关键词直接变成乱码。

原因 :LangChain 的 ConversationBufferMemory 如果设置 return_messages=True,它会把历史当成 List[BaseMessage] 塞进 history 变量。如果你在 Prompt 模板里用 {history} 而不是 MessagesPlaceholder,Jinja2 会直接对这个列表调用 str(),输出对象字符串。

解决 :要么统一用 MessagesPlaceholder 并保持 return_messages=True,要么显式设置 return_messages=False 让它返回拼接好的字符串。更推荐前者,因为字符串形式对 token 长度控制不友好。官方文档在 RunnableWithMessageHistory 那一节其实暗示了需要 MessagesPlaceholder,但它在新旧 Memory API 混杂时很容易让人踩雷。

坑2:Redis session_id 包含冒号导致 key 扫描失效

现象:监控发现 Redis 内存逐渐增长,但过期 key 清理脚本从来不删历史会话。生产里部分用户反馈上下文跨天后还是老数据(违反业务规则)。

原因RedisChatMessageHistory 生成的 key 格式是 {key_prefix}:{session_id},业务方直接把用户手机号 138:xxxx 作为 session_id,冒号在 Redis 里恰好是 key 层级分隔符。清理脚本用 SCAN chat:test:* 匹配时根本扫不到这些 key。消息越积越多。

解决 :session_id 生成时做一次 URL-safe base64 编码,或者统一用 UUID。同时配置 Redis 的 TTL 兜底:RedisChatMessageHistory 支持 ttl 参数,设个 24 小时,防呆。

坑3:并发快速发送时消息顺序错乱(官方文档没提)

现象 :压测时发现,当用户快速连续发两条消息(比如手抖点了两下),第二条的 AI 回复有时会丢失第一条的上下文。看 Redis 里的消息列表,偶尔会出现 human, human, ai, ai 这种顺序错乱。

原因RunnableWithMessageHistorysave_context 默认在 chain 结束时调用,但两个请求几乎同时到达时,它们从 Redis 读到的历史都只有之前的 N 条,然后各自追加自己的消息写回,造成丢失对方写入。本质是 read-modify-write 没有锁。

解决 :这个坑最狠,因为 LangChain 本身没带锁。我的方案是:在 save_context 之前用 Redis 的 WATCH + 事务,或者利用 Redlock 的分布式锁,但最终为了简单,改用单个用户的会话串行化(asyncio.Lock per session_id)。这要求你的 history_factory 和 Runnable 能在应用层排队,好在 Pytest 能测出这个竞态:用 asyncio.gather 同时发两个请求,断言消息列表长度正确、顺序一致。加入锁后再跑,丝滑通过。

效果验证

上线前手动抽样 200 轮对话,上下文丢失率(定义为回答中询问已提供信息)从旧版的 27% 直线掉到 0%。CI 里这块测试用例从 5 条补到 18 条,每次有同事改 Memory 配置,PR 都会被挡下来。下面是我们监控对比:

指标 优化前 优化后
上下文丢失率(手动评估) 27% (54/200) 0% (0/200)
跨重启丢失率 100% 0%
并发写入乱序概率 约 15% 0%
Redis 内存泄漏趋势 持续增长 稳定 + TTL 清除

可直接用的代码 / 工具

直接把上面的 conftest.pytest_memory_persistence.py 放入你的项目,pip install pytest-asyncio testcontainers langchain-openai 就能跑。记得把 OpenAI key 放进环境变量 OPENAI_API_KEY。一套测试下来不到 8 秒,CI 无压力。

#LangChain #Python测试 #AI上下文 #后端踩坑


关于作者

一名天天跟 LLM 应用打交道的后端工程师,专注把原型方案打磨成生产级实现,目前主攻 Python 微服务与 AI Agent 工程化。

GitHub: github.com/baofugege

Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省下几个小时排错,欢迎请我喝杯咖啡。

提供服务:Python 后端性能优化 / RAG 系统落地 / 技术咨询,联系 Telegram @baofugege

相关推荐
IT_陈寒2 小时前
Vue的v-if和v-show,我竟然用错了这么久
前端·人工智能·后端
云絮.2 小时前
计算机相关工作原理
服务器·前端·数据库
随风一样自由2 小时前
【前端+React+缓存竞态】导航切换缓存刷新机制
前端·react.js·缓存
恋猫de小郭2 小时前
Flutter 开发怎么做 Agent ?从工程实战详细给你解读下
android·前端·flutter
IT_陈寒2 小时前
React状态更新延迟搞晕我,原来是闭包在捣鬼
前端·人工智能·后端
晓得迷路了2 小时前
栗子前端技术周刊第 136 期 - pnpr、Flow 重构移植 Rust、Astryx...
前端·javascript·css
柳杉2 小时前
别再手写大屏了!用这套「3D 地图 + 拖拽脚手架」示例源码,交付效率提升 80%
前端·three.js·数据可视化
乌夷2 小时前
Vue.js 3 的设计思路
前端·javascript·vue.js
丨我是张先生丨2 小时前
英语单词 Web Page
前端·css·css3