凌晨两点被报警电话叫醒,群里用户疯狂吐槽:"客服机器人每轮都在装傻,刚说的订单号它转头就问'请问您的订单号是?'"------上下文全丢了。打开日志一看,报错没几条,但对话历史干干净净,就像从来没聊过一样。那一刻我就知道,LangChain 的 memory 还是太"玩具",线上稍微来点并发和重启就能让它原形毕露。
问题拆解
我们给电商平台接了个基于 LangChain 的售后问答 Agent,核心逻辑不复杂:用 ConversationBufferMemory 存上下轮对话,跟着 Chain 走完 RunnableWithMessageHistory 的路子。开发环境一切正常,但一上生产就疯狂"失忆"。场景非常典型:
- 多个 Pod 共享一个 Redis,大家都往里写会话历史
- 用户偶尔会连续快速发送两条消息(网络重试或手快)
- 运维偶尔重启某个容器做滚动更新
根因其实就三件事:① 默认 InMemory 重启即丢失 ;② Redis 后端的 key 规则与并发读写没有原子保护 ;③ ConversationBufferMemory 的 memory_key 和 return_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 的
ConversationBufferMemory、RunnableWithMessageHistory、以及自定义的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:ConversationBufferMemory 的 return_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 这种顺序错乱。
原因 :RunnableWithMessageHistory 的 save_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.py 和 test_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