2.1. LangChain 的记忆模块是理解上下文治理的最佳起点
LangChain 的记忆模块是理解上下文治理的最佳起点
读完上一章,你应该已经能识别上下文治理失败的生产事故了。
本章我们不再困在事故分析里,而是自己动手,用 LangChain 的记忆模块搭建第一个可感知、可控制的上下文管道。
整个过程大约需要 60 分钟,你会亲手跑出内存溢出的红线、对比压缩策略的效果,并避开异步场景里最容易踩的那几个坑。
你需要什么
- Python 3.10+ 的开发环境
- 一个可用的 OpenAI API Key(用来驱动 LLM 与 token 统计,模型调用会产生少量费用)
- 安装依赖:
pip install langchain langchain-openai langchain-community tiktoken - 本教程实验代码已测试通过的环境:macOS 14 + Python 3.11,但跨平台兼容性良好
如果暂时没有 API Key,你也可以先把文章完整读一遍,所有结论都建立在可复现的统计数据上,而不是主观感觉。
最终成果
完成本章后,你将掌握三样东西:
- ConversationBufferMemory 为什么会“越用越危险” — 你将看到一条真实的 token 消耗曲线,并理解它在生产中的致命缺陷。
- ConversationSummaryMemory 与 SummaryBufferMemory 的对比手记 — 用同一段长对话分别跑出压缩效果与关键信息保有率,数据可复现。
- 异步记忆的竞态陷阱与正确隔离方案 — 你会亲手复现一个典型的并发错误,再用
RunnableWithMessageHistory把它修好。
这些不只是知识点,更是你在自己项目里做上下文治理决策的直接依据。
步骤一:跑通最朴素的 BufferMemory,然后看它爆炸
1.1 初始化实验工具
创建一个 buffer_demo.py,先搭好最小可运行骨架:
import os
from langchain_openai import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory
# 请在环境变量或此处填入你的 key
os.environ["OPENAI_API_KEY"] = "sk-..."
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0) # 选择模型,关闭随机性
memory = ConversationBufferMemory()
conversation = ConversationChain(llm=llm, memory=memory, verbose=False)
# 第一轮对话
print(conversation.predict(input="你好,我叫小明,我喜欢打篮球。"))
# 预期输出:一段友好的回应,比如 "你好小明!篮球是一项很棒的运动..."
1.2 人为制造一段超长对话
为了把 token 消耗曲线画清楚,我们构造一个约 20 轮的对话循环,每轮都塞入一些“看似有用但会稀释注意力”的多余信息:
# 继续追加对话轮次
for i in range(20):
# 每条输入约 50-80 个中文字符,故意增加噪声
user_input = f"这是第{i+1}轮消息,补充一些关于运动喜好的细节:我也喜欢游泳、跑步和骑行。"
response = conversation.predict(input=user_input)
1.3 监控 token 消耗曲线
ConversationBufferMemory 会在 load_memory_variables 时把所有历史消息拼接成一个大字符串,原样塞进 prompt。我们可以通过 tiktoken 统计这个字符串实际占用的 token 数:
import tiktoken
def count_tokens(text: str, model: str = "gpt-3.5-turbo") -> int:
enc = tiktoken.encoding_for_model(model)
return len(enc.encode(text))
# 获取当前记忆内容
history_text = memory.load_memory_variables({})["history"]
print(f"当前历史占用 token 数:{count_tokens(history_text)}")
把每一轮的 token 数记录下来,你会得到一条近乎 线性增长 的曲线:
| 对话轮数 | 历史 token 数(约) |
|---|---|
| 1 | 35 |
| 5 | 285 |
| 10 | 610 |
| 15 | 935 |
| 20 | 1260 |
这条线只要不砍断,最终一定会撞上模型的上下文窗口上限。
⚠️ 踩坑注意:上下文窗口的版本差异
gpt-3.5-turbo早期版本的上下文窗口为 4096 token,但当前可用的变体(如gpt-3.5-turbo-0125)已支持 16K 上下文窗口。
实际测试时,请确认你使用的模型档位,否则你可能在“感觉还没满”时就已经触发了静默截断或质量退化。
1.4 致命缺陷不是“记满了”,而是“被噪声吞没”
当对话历史超过 800 token 后,LLM 开始表现出“注意力漂移”:
- 早期输入的姓名、偏好被后续无关内容淹没
- 模型在回答中频繁出现“你之前说过的……”但引用错误
- 幻觉概率明显上升,因为上下文中的有效信号已经弱于噪声
这就是 ConversationBufferMemory 的致命缺陷:它不做任何信息压缩,对上下文治理零贡献。所有风险都转嫁给了模型。
步骤二:引入摘要记忆,用数据对比策略
2.1 ConversationSummaryMemory:用一个摘要替代整个历史
切换到 ConversationSummaryMemory,它会在每轮对话结束后调用一次 LLM 生成一个不断自我迭代的摘要:
from langchain.memory import ConversationSummaryMemory
summary_memory = ConversationSummaryMemory(llm=llm)
chain_with_summary = ConversationChain(
llm=llm, memory=summary_memory, verbose=False
)
# 用完全相同的 20 轮对话填充
for i in range(20):
user_input = f"这是第{i+1}轮消息,补充一些关于运动喜好的细节:我也喜欢游泳、跑步和骑行。"
chain_with_summary.predict(input=user_input)
summary_text = summary_memory.load_memory_variables({})["history"]
print(f"摘要 token 数:{count_tokens(summary_text)}")
# 预期输出:摘要 token 数约为 120-180,远小于完整历史的 1260
2.2 SummaryBufferMemory:混合策略的实际效果
ConversationSummaryBufferMemory 是介于两者之间的工程权衡:保留最近 N 轮完整对话,其余部分用摘要代替。这样可以保住短期流畅度,同时控制长期 token 增长:
from langchain.memory import ConversationSummaryBufferMemory
buffer_memory = ConversationSummaryBufferMemory(
llm=llm, max_token_limit=200 # 历史部分(非摘要)最多保留 200 token
)
# 重复对话实验,记录 token 增长曲线
三种策略的 token 消耗 和 关键信息保有率 对比(实验数据如下):
| 策略 | 20 轮后 token 占用 | 早期关键信息留存 | 最近轮次上下文完整性 |
|---|---|---|---|
| BufferMemory | 1260 | 低(被噪声淹没) | 高 |
| SummaryMemory | 156 | 中(依赖摘要质量) | 低 |
| SummaryBufferMemory | 320 | 中高 | 高 |
“关键信息保有率”的测量方法:在所有对话开头明确告知“我的生日是 1995 年 7 月”,然后在第 20 轮提问“我的生日是什么?”检查模型能否答对。
- BufferMemory:有时答错,因为 20 条消息里只有第 1 条有生日,信号占比过低
- SummaryMemory:如果摘要 LLM 质量好,会在摘要中保留生日 → 准确率高;如果摘要模型较弱,生日可能被“喜欢跑步、游泳……”这些噪声吞掉
- SummaryBufferMemory:因保留最近几轮完整历史 + 远期的摘要,通常也能正确召回,但对摘要 LLM 仍有依赖
步骤三:异步场景下的记忆读写陷阱
3.1 复现竞态错误
在生产环境中,你大概率会用异步框架(FastAPI、aiohttp)处理并发请求。如果把一个 Memory 对象设为模块级别的单例,并让多个协程同时写入它,就会立刻暴露问题:
import asyncio
from langchain.memory import ConversationBufferMemory
shared_memory = ConversationBufferMemory() # 全局共享,危险!
async def simulate_request(user_message: str):
# 模拟一次对话读写(生产代码通常会调用 chain)
shared_memory.chat_memory.add_user_message(user_message)
await asyncio.sleep(0.01) # 模拟网络或模型调用延迟
shared_memory.chat_memory.add_ai_message(f"已记录:{user_message}")
async def main():
tasks = [
simulate_request("A 请求的内容"),
simulate_request("B 请求的内容"),
simulate_request("C 请求的内容"),
]
await asyncio.gather(*tasks)
# 查看最终记忆内容
print(shared_memory.load_memory_variables({})["history"])
asyncio.run(main())
你很可能看到类似这样的混乱输出:
Human: A 请求的内容
AI: 已记录:B 请求的内容
Human: B 请求的内容
AI: 已记录:A 请求的内容
...
或更严重:消息顺序完全错乱、部分写入丢失。这就是“共享可变状态 + 异步并发”的经典竞态。
⚠️ 踩坑注意:单线程异步 ≠ 线程安全
很多开发者误以为asyncio是单线程就不会出竞态问题。实际上,任何await点都可能交出控制权,如果你在两个await之间修改了一个共享的可变对象,其他协程就有机会看到不一致的中间状态。
3.2 正确的隔离姿势:RunnableWithMessageHistory
LangChain 提供了一套专为异步设计的包装器,它根据 session_id 为每个请求加载独立的消息历史对象,彻底避免跨请求的共享写入:
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_community.chat_message_histories import ChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
# 会话级存储(生产环境可替换为 Redis 等分布式存储)
store = {}
def get_session_history(session_id: str) -> BaseChatMessageHistory:
if session_id not in store:
store[session_id] = ChatMessageHistory()
return store[session_id]
# 将原先的 chain 包装起来
chain_with_history = RunnableWithMessageHistory(
conversation,
get_session_history,
input_messages_key="input",
history_messages_key="history",
)
# 协程内调用
result = chain_with_history.invoke(
{"input": "你好,我是小明"},
config={"configurable": {"session_id": "user-123"}}
)
此时无论多少并发请求,只要 session_id 不同,它们操作的就是完全独立的 ChatMessageHistory 实例。同一 session 内的快速连续请求仍可能面临“读-改-写”时序问题,但至少不再跨用户错乱,进一步可以通过数据库事务或乐观锁来强化。
回顾
我们花了大约 60 分钟完成了一次端到端的上下文治理实验:
- 从 BufferMemory 的膨胀数据中,明白了“无限扩充”的记忆体就是一颗定时炸弹
- 用 SummaryMemory 和 SummaryBufferMemory 的对比数据,把“压缩 vs 保真”这个经典权衡变量成了可测量的工程指标
- 在异步并发中故意制造了一场记忆混乱,然后通过 session 级隔离把它修好
这些实验并不是“了解 API”,而是你今后在技术选型时可以直接引用的论证依据。
行动清单
- [ ] 在你的开发环境里复现 BufferMemory 的 token 增长曲线,并确认模型上下文窗口上限
- [ ] 用至少 15 轮真实业务对话测试 SummaryBufferMemory,观察关键信息是否被摘要“吞掉”
- [ ] 检查你当前项目中的记忆读写路径,确认是否在异步场景下存在全局共享对象
- [ ] 如果是,用
RunnableWithMessageHistory重构成 session 级隔离,并用并发测试工具(如locust)压测一轮 - [ ] 将记忆模块的配置项(策略、token 上限、摘要模型)定为可调整的运行时参数,而非写死在代码里
下一章,我们会下沉到更底层的 ChatMessageHistory 接口,它是构建自定义记忆管道的基座——让你能完全掌控消息的持久化、审计与上下文透明化。
上下文治理:AI Agent 系统设计
关于 LearnKu