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,你也可以先把文章完整读一遍,所有结论都建立在可复现的统计数据上,而不是主观感觉。

最终成果

完成本章后,你将掌握三样东西:

  1. ConversationBufferMemory 为什么会“越用越危险” — 你将看到一条真实的 token 消耗曲线,并理解它在生产中的致命缺陷。
  2. ConversationSummaryMemory 与 SummaryBufferMemory 的对比手记 — 用同一段长对话分别跑出压缩效果与关键信息保有率,数据可复现。
  3. 异步记忆的竞态陷阱与正确隔离方案 — 你会亲手复现一个典型的并发错误,再用 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 分钟完成了一次端到端的上下文治理实验:

  1. 从 BufferMemory 的膨胀数据中,明白了“无限扩充”的记忆体就是一颗定时炸弹
  2. 用 SummaryMemory 和 SummaryBufferMemory 的对比数据,把“压缩 vs 保真”这个经典权衡变量成了可测量的工程指标
  3. 在异步并发中故意制造了一场记忆混乱,然后通过 session 级隔离把它修好

这些实验并不是“了解 API”,而是你今后在技术选型时可以直接引用的论证依据。

行动清单

  • [ ] 在你的开发环境里复现 BufferMemory 的 token 增长曲线,并确认模型上下文窗口上限
  • [ ] 用至少 15 轮真实业务对话测试 SummaryBufferMemory,观察关键信息是否被摘要“吞掉”
  • [ ] 检查你当前项目中的记忆读写路径,确认是否在异步场景下存在全局共享对象
  • [ ] 如果是,用 RunnableWithMessageHistory 重构成 session 级隔离,并用并发测试工具(如 locust)压测一轮
  • [ ] 将记忆模块的配置项(策略、token 上限、摘要模型)定为可调整的运行时参数,而非写死在代码里

下一章,我们会下沉到更底层的 ChatMessageHistory 接口,它是构建自定义记忆管道的基座——让你能完全掌控消息的持久化、审计与上下文透明化。

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
讨论数量: 0
发起讨论 查看所有版本


暂无话题~