8.1 对话缓冲与摘要记忆
从第 3 章的 Agent 类开始,minagent 就有了 history:每次 run() 结束把消息列表写回实例,下一轮自动带上。那是最朴素的记忆——把所有历史原样存下来、原样发回去。它在教学场景里无可挑剔,但只要你真的和 Agent 聊上十几轮,问题就来了:history 只增不减,每轮的输入 token 越堆越高,成本和延迟一起涨,最后撞上上下文窗口。
本节把 minagent 推进到
minagent/memory.py:定义统一的Memory协议(add/get_context),并落地两种短期记忆——BufferMemory(按条数或按 token 保留最近若干条)与SummaryMemory(超阈值时用模型把旧对话压成摘要)。本章的存储一律是内存里的列表,真正的向量检索留到第 9 章。
8.1.1 history 的天真之处
回看 3.3 节那张 history 增长表:第一轮 7 条,第二轮 9 条,每轮净增「用户消息 + 模型回复」。如果每轮再带上工具往返,增长更快。这些消息全部原样塞进下一轮请求——因为 Messages API 是无状态的,你不发,模型就不知道。
于是有两个绕不开的事实:
- 输入 token 随轮数近似平方增长。第 N 轮要重发前 N-1 轮的全部内容,累计输入是 O(N²)。
- 有用的信息分布极不均匀。开头那句「我叫什么、我在做什么」往往比中间十轮寒暄更重要,但按「保留最近」的规则恰恰最先被丢掉。
这就逼出「记忆」这个概念:我们需要一个独立于 history 的模块,专门决定哪些消息值得留下来、以什么形式留下来。它不改变「无状态 API」这个前提,只是在每轮组装请求时,替我们做一次取舍。
8.1.2 统一的 Memory 协议
先立接口。无论缓冲还是摘要,对外都只暴露两个动作:写入一条消息、取回当前应注入的上下文。用 Protocol 定义,结构化子类型即可,不必显式继承:
# minagent/memory.py
from __future__ import annotations
from typing import Protocol
from .types import Message
class Memory(Protocol):
def add(self, message: Message) -> None: ...
def get_context(self) -> list[Message]: ...
两个方法的语义要说清楚,否则后面会各自为政:
| 方法 | 语义 | 约束 |
|---|---|---|
add(message) | 把一条新消息纳入记忆 | 内部可以立刻触发裁剪或压缩 |
get_context() | 返回「本轮该发给模型」的消息列表 | 必须返回副本,调用方不得改到内部状态 |
get_context() 返回副本这一点很关键。循环拿到列表后往往还会 append 本轮的新消息,如果返回的是内部那个列表本身,调用方的追加会污染记忆。第 3 章 run() 开头 messages = list(self.history) 是同一个道理——边界处拷贝,是共享可变状态的第一条纪律。
有了协议,BufferMemory 与 SummaryMemory 就成了它的两个实现,循环只依赖 Memory,不关心背后是哪种策略。这与第 2 章「Model 协议 + FakeModel」是同一种设计:先定接口,再谈实现,实现之间可互换。
8.1.3 BufferMemory:按条数与按 token 裁剪
缓冲版是最直接的策略:保留最近若干条,更早的直接丢。实现只有一个滑动窗口:
# minagent/memory.py(续)
from .budget import approx_tokens
class BufferMemory:
def __init__(self, max_messages: int | None = None,
max_tokens: int | None = None) -> None:
self.max_messages = max_messages
self.max_tokens = max_tokens
self._items: list[Message] = []
def add(self, message: Message) -> None:
self._items.append(message)
self._trim()
def _trim(self) -> None:
if self.max_messages is not None:
self._items = self._items[-self.max_messages:]
if self.max_tokens is not None:
while self._items and self._count() > self.max_tokens:
self._items.pop(0)
def _count(self) -> int:
return sum(approx_tokens(m.content) for m in self._items)
def get_context(self) -> list[Message]:
return list(self._items)
两个上限可以同时给,也可以只给一个。它们的取舍不同:
| 上限 | 单位 | 优点 | 缺点 |
|---|---|---|---|
max_messages | 条数 | 稳定可预测,便于测试断言 | 长短消息一视同仁,长消息可能瞬间超窗 |
max_tokens | token | 直接对齐上下文窗口 | 每次写入都要重算,且估算有误差 |
max_tokens 分支用 while ... pop(0) 而不是切片,是因为 token 数无法从下标直接算出「从哪切」,只能从最老的开始一条条丢,直到落回预算内。approx_tokens 复用 2.3 节那个零依赖的字符近似函数(ASCII 约 4 字符 1 token,CJK 约 1 字符 1 token),不引入 tiktoken。
缓冲版的缺陷很明显:被丢掉的消息永久消失。「用户上周说他偏好简洁示例」这句话一旦滑出窗口,就再也找不回来。要解决它,要么在丢弃前留一份浓缩记录(就是下一节的摘要),要么把消息存到别处、按需召回(第 8.2 节与第 9 章)。缓冲版本身不解决遗忘,它只是把遗忘变得可控。
8.1.4 SummaryMemory:超阈值就压缩
摘要版的思路是:丢掉原文之前,先让模型把旧消息压成一句话。它内部同时维护两份东西——一段不断累积的摘要文本,和最近若干条原文:
# minagent/memory.py(续)
from .types import Model
class SummaryMemory:
def __init__(self, model: Model, max_messages: int = 4,
summarize_every: int = 3) -> None:
self.model = model
self.max_messages = max_messages
self.summarize_every = summarize_every
self.summary = ""
self._items: list[Message] = []
def add(self, message: Message) -> None:
self._items.append(message)
if len(self._items) > self.max_messages + self.summarize_every:
self._compact()
def _compact(self) -> None:
old = self._items[:-self.max_messages]
self._items = self._items[-self.max_messages:]
rendered = "\n".join(f"{m.role}: {m.content}" for m in old)
prompt = f"把下面这段对话压缩成一句话摘要:\n{rendered}"
resp = self.model.complete([Message(role="user", content=prompt)], [])
merged = resp.message.content
self.summary = f"{self.summary} {merged}".strip() if self.summary else merged
def get_context(self) -> list[Message]:
ctx: list[Message] = []
if self.summary:
ctx.append(Message(role="system", content=f"对话摘要:{self.summary}"))
ctx.extend(self._items)
return ctx
三个参数的分工值得说明:
max_messages:原文窗口,最近这么多条保持原样,不压缩。summarize_every:缓冲余量。只有当_items超过max_messages + summarize_every时才触发一次压缩。- 这个「余量」是为了避免每加一条就压一次。如果不留余量,压缩会变得极其频繁,而每次压缩都是一次模型调用——既费钱又慢。攒够一批再压,把调用次数摊薄。
压缩触发时,old = self._items[:-self.max_messages] 取出「窗口之外的全部旧消息」,一次性喂给模型;_items 只保留最近 max_messages 条。摘要采用滚动合并:新摘要拼在旧摘要之后(f"{self.summary} {merged}"),所以哪怕压了很多轮,最早的线索也不会因为这一轮没被选中而丢失。
注意 get_context() 把摘要包成一条 system 消息放在最前面。选择 system 角色而不是 user,是因为摘要属于对模型的背景交代,不是用户在说话。真实供应商通常也会把这类「元信息」放在系统提示里,模型对 system 内容的服从度更高。
8.1.5 跑一遍:缓冲版与摘要版的差异
写一段 8 条消息的长对话,让缓冲版和摘要版各自消费一遍,看它们注入的条数:
# demo_memory.py
convo = [
Message(role="user", content="我在做一个 Python 的 Agent 项目。"),
Message(role="assistant", content="好的,你希望它解决什么问题?"),
Message(role="user", content="主要用来做本地知识库问答。"),
Message(role="assistant", content="了解,需要接入向量检索吗?"),
Message(role="user", content="先不用,我想先把记忆模块跑通。"),
Message(role="assistant", content="明白,先做短期记忆。"),
Message(role="user", content="对,而且我偏好简洁的代码示例。"),
Message(role="assistant", content="收到,后面都用最小可运行示例。"),
]
buf = BufferMemory(max_messages=6)
for m in convo:
buf.add(m)
print("BufferMemory(6) 注入条数:", len(buf.get_context()))
tokbuf = BufferMemory(max_tokens=40)
for m in convo:
tokbuf.add(m)
print("BufferMemory(max_tokens=40) 注入条数:", len(tokbuf.get_context()))
fake = FakeModel([
"用户在做 Python 本地知识库问答 Agent,先跑通记忆模块,偏好简洁示例。",
])
summ = SummaryMemory(fake, max_messages=4, summarize_every=3)
for m in convo:
summ.add(m)
ctx = summ.get_context()
print("SummaryMemory 注入条数:", len(ctx))
print("摘要:", ctx[0].content)
print("保留的最近消息:", [m.content for m in ctx[1:]])
实测输出(Python 3.14.6):
BufferMemory(6) 注入条数: 6
BufferMemory(max_tokens=40) 注入条数: 3
SummaryMemory 注入条数: 5
摘要: 对话摘要:用户在做 Python 本地知识库问答 Agent,先跑通记忆模块,偏好简洁示例。
保留的最近消息: ['先不用,我想先把记忆模块跑通。', '明白,先做短期记忆。', '对,而且我偏好简洁的代码示例。', '收到,后面都用最小可运行示例。']
三组数字各说明了什么:
- 缓冲版(按条数)注入 6 条,正好是
max_messages。前两条「我在做 Python 项目」「用来做本地知识库问答」被丢掉了——注意,这两条恰恰是最该记住的项目背景,滑动窗口却优先牺牲了它们。 - 缓冲版(按 token)只注入 3 条。40 token 的预算下,前面几条较长的中文消息很快把额度吃满,只剩最近 3 条。可见 token 上限比条数上限更省、也更激进。
- 摘要版注入 5 条:1 条摘要 + 4 条原文。被丢弃的 4 条旧消息没有消失,而是被压成了那条摘要——项目背景被保住了。
这组对比把两种策略的取舍摊开了:缓冲版便宜、确定,但会遗忘;摘要版多一次模型调用,却能把「发生过什么」浓缩着带下去。摘要版那句 system 消息,正是它优于缓冲版的核心价值。
8.1.6 缓冲、摘要、滑动窗口对照
三种常见短期记忆策略放在一起:
| 维度 | 滑动窗口(按条) | 缓冲(按 token) | 摘要 |
|---|---|---|---|
| 保留方式 | 最近 N 条原文 | 最近若干 token 的原文 | 摘要 + 最近 N 条原文 |
| 额外模型调用 | 无 | 无 | 有(每次压缩一次) |
| 成本 | 最低 | 低 | 中 |
| 信息保真 | 原文,但会丢旧信息 | 原文,丢得更多 | 旧信息被浓缩,有损 |
| 确定可测 | 完全确定 | 完全确定 | 依赖模型输出,需假模型固定 |
| 适合场景 | 短对话、测试 | 严格控预算 | 长对话、要跨轮记住背景 |
选择的口径很朴素:对话短、预算宽,就用滑动窗口;预算卡得紧,就用 token 缓冲;一旦发现 Agent 频繁「忘掉前面说过的事」,就上摘要。 三者也可以叠加——先摘要、再按 token 兜底裁剪,这是第 6 章「上下文压缩」与本节的自然组合。
8.1.7 真实模型下会怎样
本节的摘要由 FakeModel 产出,内容是脚本写死的,所以每次运行逐字节一致,便于断言。真实模型下,_compact() 里的那次 complete() 会真的联网生成,带来三个差异:
- 摘要质量不稳定。同样一段对话,模型可能这次抓住「用户在做知识库问答」,下次只记住「用户在聊项目」,甚至编造细节。摘要是有损压缩,越压越容易失真,所以
summarize_every别设太小,也别指望一轮摘要就能保住全部信息。 - 调用有成本与延迟。压缩是额外的一次模型调用,会体现在账单与响应时间上。工程上常用一个更便宜的小模型专门做摘要,甚至让摘要异步进行,不阻塞主流程。
- 摘要本身也占 token。滚动合并会让摘要文本越滚越长,长到一定程度必须再压一次(对摘要做摘要)。本节的实现没有这一层,第 9 章的长期记忆会给出更系统的做法。
还有一点:真实模型的 complete() 可能返回工具调用而非纯文本,摘要调用时应显式不传工具(tools=[]),并约定「只回一句话」,否则摘要里可能混进一段 JSON。
到这里,minagent 有了「能留住近期对话」的短期记忆。但缓冲和摘要都只能记住最近的内容,用户上周说过的那句偏好依然找不回来。下一节 8.2 记忆检索注入 会让记忆支持「按相关度召回」。想回看会话状态是怎么存盘的,见 7.3 检查点与快照 。
阅读导航:上一节:7.3 检查点与快照 · 下一节:8.2 记忆检索注入 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。