前端转 agent -【python】- 14 记忆系统优化:摘要与遗忘p
一个没有记忆的 AI,只是一个瞬间的函数映射:输入 → 输出,然后遗忘。
上回我们用 LangGraph 搭建了多 Agent 协作系统,让不同的 Agent 各司其职、协同工作。但有一个问题被我们忽略了------记忆。
写 Vue 3 的同学都知道,组件之间的状态管理是个大问题。你用 Pinia 还是 provide/inject?状态多了怎么办?是不是得做数据归一化、定期清理?
AI Agent 面临同样的问题------对话越来越长,上下文窗口撑爆了怎么办?用户上次说了什么,这次还记得吗?
今天我们来聊聊记忆系统的摘要与遗忘机制,用 Ollama + Qwen3:4b 来实现一个会"记住"也会"忘记"的智能对话系统。
为什么 Agent 需要"遗忘"?
上下文窗口和记忆是两码事。上下文窗口只是让模型一次看到更多对话,像是扩大了工作台。但记忆不同,它让 Agent 能保存、更新和选择性回忆信息。
现在大家都在追求更大的上下文窗口,从 8K 到 32K,再到 128K、1M。但这种暴力扩张有个问题:计算成本呈二次方增长。处理 100K tokens 的成本是 10K tokens 的百倍。
更关键的是------人类会遗忘,不是大脑容量不够,而是遗忘让我们更高效。想象一下,如果你记得生活中的每个细节,这些信息会淹没真正重要的记忆。
Agent 也需要这种机制:
- 存储成本:每个用户的历史数据都完整保存,存储需求会爆炸式增长
- 检索效率:在海量历史中找到相关信息越来越慢
- 注意力分散:太多无关信息会干扰 Agent 的决策
- 隐私风险:永久保存所有对话增加了数据泄露的风险
Vue 3 类比:就像你不会把所有的路由历史、组件状态、用户操作日志都塞进一个 Pinia store 里。你会做数据归一化、按需加载、定期清理。Agent 的记忆系统也是同样的道理。
记忆系统的分层架构
在开始写代码之前,我们先建立一个心智模型。AI Agent 的记忆系统可以分成几个层次:
| 层级 | 说明 | 类比 Vue |
|---|---|---|
| 短期记忆(工作记忆) | 当前对话的上下文,几 K 到几十 K tokens | 组件的 reactive 数据 |
| 中期记忆(摘要记忆) | 对历史对话的压缩摘要 | Pinia store 中的持久化状态 |
| 长期记忆 | 跨会话的结构化知识、用户偏好 | 浏览器 localStorage |
今天我们的重点是中期记忆------当对话太长时,如何自动把历史对话"摘要"成精简版本,而不是简单地丢掉。
环境准备
bash
# 安装 Ollama(如果还没装)
# Mac: brew install ollama
# Linux: curl -fsSL https://ollama.com/install.sh | sh
# 拉取 Qwen3:4b 模型
ollama pull qwen3:4b
# 安装 Python 依赖
pip install ollama langchain langchain-community
第一步:最朴素的实现------全量记忆
先来看看最原始的做法:把所有的对话历史都存下来,每次请求都全量发给模型。
python
# full_memory.py
import ollama
class FullMemoryChat:
def __init__(self, model="qwen3:4b"):
self.model = model
self.messages = [] # 全部存下来,一个不漏
def chat(self, user_input: str) -> str:
self.messages.append({"role": "user", "content": user_input})
response = ollama.chat(
model=self.model,
messages=self.messages
)
reply = response["message"]["content"]
self.messages.append({"role": "assistant", "content": reply})
return reply
def get_history(self):
return self.messages
# 使用示例
chat = FullMemoryChat()
chat.chat("你好,我叫小明")
chat.chat("我喜欢编程")
chat.chat("我最近在学 Python")
print(f"总消息数: {len(chat.get_history())}")
# 输出: 总消息数: 6
问题很明显 :对话越长,messages 数组越大,每次请求的 token 数不断膨胀,最终撑爆上下文窗口。
Vue 类比:就像你在一个组件里用了一个巨大的数组存储所有用户操作日志,从不清理。页面越用越卡,最后直接崩溃。
第二步:滑动窗口------最简单的"遗忘"
最粗暴的优化:只保留最近 N 轮对话,远的直接丢掉。
python
# memory_demo/sliding_window.py
import ollama
from collections import deque
class SlidingWindowChat:
def __init__(self, model="qwen3:4b", max_turns=5):
self.model = model
self.max_turns = max_turns
# 用 deque 自动管理容量
self.messages = deque(maxlen=max_turns * 2) # 每条消息算一个
def chat(self, user_input: str) -> str:
self.messages.append({"role": "user", "content": user_input})
# 转成 list 给 ollama
messages_list = list(self.messages)
response = ollama.chat(
model=self.model,
messages=messages_list
)
reply = response["message"]["content"]
self.messages.append({"role": "assistant", "content": reply})
return reply
def get_history(self):
return list(self.messages)
# 使用示例
chat = SlidingWindowChat(max_turns=2)
chat.chat("我叫小明")
chat.chat("我喜欢编程")
chat.chat("我最近在学 Python")
chat.chat("你觉得我适合做什么工作?")
print(f"保留的消息数: {len(chat.get_history())}")
# 输出: 保留的消息数: 4(只保留了最近2轮对话)
优点 :实现简单,成本低。缺点:粗暴截断,丢失了重要的上下文信息。用户半小时前说的"我是后端开发",现在问"推荐什么技术栈",模型已经忘了。
Vue 类比 :就像你用 keep-alive 缓存组件,但设置了 max=3,最早的组件被销毁,状态丢失。用户切回来发现之前填的表单数据全没了。
第三步:摘要记忆------聪明的"压缩"
滑动窗口的问题是"遗忘"太彻底。更好的做法是:把旧对话压缩成一段摘要,和新对话一起发给模型。
这就是 ConversationSummaryBufferMemory 的核心思想------结合了缓冲区和摘要两种策略。
python
# summary_memory.py
import ollama
from typing import List, Dict
class SummaryMemoryChat:
def __init__(self, model="qwen3:4b", max_tokens=500):
self.model = model
self.max_tokens = max_tokens # 缓冲区最大 token 数
self.buffer: List[Dict[str, str]] = [] # 最近的对话
self.summary: str = "" # 历史摘要
def _count_tokens(self, messages: List[Dict[str, str]]) -> int:
"""粗略估算 token 数(实际生产用 tiktoken)"""
return sum(len(m["content"]) // 2 for m in messages)
def _generate_summary(self, messages: List[Dict[str, str]]) -> str:
"""用 LLM 生成摘要"""
# 把要摘要的消息转成文本
history_text = "\n".join([
f"{m['role']}: {m['content']}" for m in messages
])
prompt = f"""请将以下对话历史压缩成一段简短的摘要(50字以内),保留关键信息(如用户身份、偏好、已讨论的话题等):
{history_text}
摘要:"""
response = ollama.generate(
model=self.model,
prompt=prompt
)
return response["response"].strip()
def chat(self, user_input: str) -> str:
# 1. 把用户消息加入缓冲区
self.buffer.append({"role": "user", "content": user_input})
# 2. 构建完整的上下文:摘要 + 缓冲区
context_messages = []
# 如果有摘要,先加进去(作为 system 消息)
if self.summary:
context_messages.append({
"role": "system",
"content": f"【历史对话摘要】{self.summary}"
})
# 再加缓冲区里的消息
context_messages.extend(self.buffer)
# 3. 调用模型
response = ollama.chat(
model=self.model,
messages=context_messages
)
reply = response["message"]["content"]
self.buffer.append({"role": "assistant", "content": reply})
# 4. 检查缓冲区是否超限,超限则触发摘要
if self._count_tokens(self.buffer) > self.max_tokens:
self._compress()
return reply
def _compress(self):
"""压缩缓冲区:把早期对话变成摘要"""
if len(self.buffer) <= 2:
return
# 保留最新的 2 条消息,其余全部拿去生成摘要
keep = 2
to_summarize = self.buffer[:-keep]
self.buffer = self.buffer[-keep:]
# 生成新摘要(合并旧摘要)
if self.summary:
# 如果已有摘要,把旧摘要也放进去一起压缩
combined = [{"role": "system", "content": f"之前的摘要:{self.summary}"}] + to_summarize
new_summary = self._generate_summary(combined)
else:
new_summary = self._generate_summary(to_summarize)
self.summary = new_summary
print(f"[摘要触发] 已压缩 {len(to_summarize)} 条消息,当前摘要:{self.summary}")
# 使用示例
chat = SummaryMemoryChat(max_tokens=200) # 设置小一点方便触发摘要
for i in range(10):
chat.chat(f"这是第{i+1}条消息,请记住我说的话")
print(f"第{i+1}轮结束,缓冲区大小: {len(chat.buffer)}")
if chat.summary:
print(f"当前摘要: {chat.summary[:50]}...")
核心逻辑:
- 维护一个 缓冲区(存最近的几条消息)
- 维护一个 摘要(存历史对话的压缩版本)
- 每次请求 = 摘要 + 缓冲区 一起发给模型
- 缓冲区超限时,把早期消息"摘要化",并入摘要中
Vue 类比:就像你用 Pinia 管理状态时,不会把所有操作日志都存下来。你会定期做"数据聚合"------把过去一周的点击事件聚合成"用户最常点击的模块 Top 5",然后清空原始日志。下次需要分析时,看聚合数据就够了。
第四步:加入遗忘机制------让记忆"自然衰减"
上面的摘要机制虽然聪明,但还有个问题:摘要永远不会被遗忘。随着对话越来越多,摘要会越来越长,最终还是会撑爆上下文。
真正的记忆系统需要遗忘机制------让旧信息随时间自然衰减。
DeepSeek 的做法很有启发:近期对话用高分辨率保存,一周前的对话降到中等分辨率,久远的记忆压缩到最小。
我们来实现一个带"衰减"的摘要系统:
python
# memory_demo/decay_memory.py
import ollama
import time
from typing import List, Dict, Optional
from dataclasses import dataclass
@dataclass
class MemoryItem:
content: str
timestamp: float
importance: float # 0-1,重要性分数
class DecayMemoryChat:
def __init__(self, model="qwen3:4b", max_buffer_tokens=300, decay_rate=0.01):
self.model = model
self.max_buffer_tokens = max_buffer_tokens
self.decay_rate = decay_rate # 衰减速率
self.buffer: List[Dict[str, str]] = [] # 近期对话
self.long_term: List[MemoryItem] = [] # 长期记忆(带时间戳)
self.summary: str = ""
def _get_decay_factor(self, item: MemoryItem) -> float:
"""计算衰减因子:时间越久,值越小"""
age = time.time() - item.timestamp
# 指数衰减:e^(-decay_rate * age)
return max(0.1, min(1.0, 2.71828 ** (-self.decay_rate * age / 60)))
def _should_forget(self, item: MemoryItem) -> bool:
"""判断是否应该遗忘"""
decay = self._get_decay_factor(item)
# 重要性 * 衰减因子 < 阈值 => 遗忘
return item.importance * decay < 0.1
def _extract_importance(self, message: Dict[str, str]) -> float:
"""用 LLM 判断消息的重要性"""
prompt = f"""判断以下对话内容的重要性(0-1之间),只输出一个数字:
- 1.0: 非常关键(用户身份、核心偏好、重大决策)
- 0.5: 一般重要(普通讨论)
- 0.1: 不重要(闲聊、寒暄)
内容:{message['content']}
重要性分数:"""
response = ollama.generate(
model=self.model,
prompt=prompt
)
try:
return float(response["response"].strip())
except:
return 0.5
def chat(self, user_input: str) -> str:
# 1. 加入缓冲区
self.buffer.append({"role": "user", "content": user_input})
# 2. 构建上下文
context = []
if self.summary:
context.append({"role": "system", "content": f"【历史摘要】{self.summary}"})
# 加入长期记忆中"存活"的项目
alive_items = [
item for item in self.long_term
if not self._should_forget(item)
]
if alive_items:
memory_text = "\n".join([
f"- {item.content}" for item in alive_items[-5:] # 只取最近的5条
])
context.append({"role": "system", "content": f"【长期记忆】\n{memory_text}"})
context.extend(self.buffer)
# 3. 调用模型
response = ollama.chat(
model=self.model,
messages=context
)
reply = response["message"]["content"]
self.buffer.append({"role": "assistant", "content": reply})
# 4. 提取重要信息存入长期记忆
for msg in self.buffer[-2:]: # 只处理最新的两条
if msg["role"] == "user":
importance = self._extract_importance(msg)
if importance > 0.6: # 只有足够重要的才存入长期记忆
self.long_term.append(MemoryItem(
content=msg["content"],
timestamp=time.time(),
importance=importance
))
print(f"[记忆存储] 重要性={importance:.2f}: {msg['content'][:30]}...")
# 5. 清理被遗忘的项目
forgotten = [item for item in self.long_term if self._should_forget(item)]
if forgotten:
self.long_term = [item for item in self.long_term if not self._should_forget(item)]
print(f"[遗忘触发] 遗忘了 {len(forgotten)} 条记忆")
# 6. 缓冲区压缩
if sum(len(m["content"]) // 2 for m in self.buffer) > self.max_buffer_tokens:
self._compress()
return reply
def _compress(self):
"""压缩缓冲区到摘要"""
if len(self.buffer) <= 2:
return
keep = 2
to_summarize = self.buffer[:-keep]
self.buffer = self.buffer[-keep:]
history_text = "\n".join([
f"{m['role']}: {m['content']}" for m in to_summarize
])
prompt = f"将以下对话压缩为一句摘要(30字内):\n{history_text}\n摘要:"
response = ollama.generate(model=self.model, prompt=prompt)
if self.summary:
self.summary = f"{self.summary};{response['response'].strip()}"
else:
self.summary = response["response"].strip()
# 摘要太长时,再次压缩
if len(self.summary) > 200:
prompt = f"将以下摘要进一步压缩为20字以内:\n{self.summary}\n压缩后:"
response = ollama.generate(model=self.model, prompt=prompt)
self.summary = response["response"].strip()
print(f"[摘要更新] {self.summary}")
# 使用示例
chat = DecayMemoryChat(decay_rate=0.02)
# 模拟多轮对话
chat.chat("我叫小明,是一名后端开发,主要用 Python")
chat.chat("我最近在研究 AI Agent")
chat.chat("今天天气不错")
chat.chat("我想做一个智能客服系统")
# 查看长期记忆状态
print(f"\n长期记忆条数: {len(chat.long_term)}")
for item in chat.long_term:
decay = chat._get_decay_factor(item)
print(f" - {item.content[:30]}... (重要性={item.importance:.2f}, 衰减={decay:.2f})")
这个设计模仿了人类的记忆机制:
- 重要性判断:重要的信息(用户身份、核心偏好)被标记为高重要性
- 时间衰减:再重要的信息,时间久了也会慢慢淡化
- 主动遗忘:重要性 × 衰减因子 < 阈值时,信息被主动遗忘
Vue 类比:就像你在做前端监控时,不会把所有日志都存下来。你会给日志打上"严重程度"标签,Error 级别的永久保留,Info 级别的 7 天自动清理。这就是"重要性 + 时间衰减"的遗忘机制。
总结
今天我们聊了 AI Agent 记忆系统的三个层次:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 全量记忆 | 信息完整 | token 爆炸 | 短对话 |
| 滑动窗口 | 简单高效 | 信息丢失严重 | 无关紧要的闲聊 |
| 摘要记忆 | 保留关键信息 | 摘要可能膨胀 | 中等长度对话 |
| 衰减记忆 | 模拟人类遗忘 | 实现复杂 | 长期、大规模对话系统 |
核心 takeaways:
- 记忆不是存得越多越好------遗忘是一种过滤机制,帮我们保留有价值的信息
- 摘要 + 缓冲区是性价比最高的方案------既保留了上下文,又控制了 token 成本
- 重要性 + 时间衰减是更高级的遗忘策略------让记忆系统像人一样"自然老化"
- 记忆系统不是"让模型变聪明",而是让智能体"不会重复犯同样的错"
记忆系统的关键在于:不是存储,而是组织和衰减。对话历史是日志,向量检索只是工具,真正的记忆需要结构化、冲突解决和定期维护。