我先描述一个场景,做 AI Agent 的人大概率都遇见过:对话刚到第 8 轮,Agent 开始重复问你早就答过的需求;到第 15 轮,它把你开场定的目标忘干净;第 30 轮,上下文窗口顶满,你不得不截断历史,或者花大把 token 让它把前面的内容重新压缩成摘要。我管这个叫“上下文自残”:不是模型笨,是架构把数据库该干的活,硬塞给了上下文。解决思路其实很直接——上下文只放当前这一轮需要的东西,其他的全部落进数据库,等要用的时候再按需查回来。下面直接把我从 SQLite 到向量检索摸出来的那套记忆层方案摊开讲,适合正在做 AI Agent、RAG 应用,或者被长对话搞到头大的开发者和产品负责人。
1. 健忘症的病根:上下文窗口从来就不是“记忆”
1.1 模型是无状态计算器,不是有记忆的大脑
很多人在 Agent 健忘时第一反应是“换个上下文更长的模型”,或者“再调一调 prompt”。但我建议先认清一个底层事实:大模型本身是没有状态的。每次 API 调用都是一次全新的独立计算,所谓“对话连续”,其实是 Agent 框架把历史消息原封不动地塞进新的请求里,让模型自己“看到”之前说过什么。
也就是说,模型不是忘了,是每次请求都被迫重新读一遍历史。这里的“历史”一旦超过模型的上下文窗口,框架只能做三件事:截断、压缩、或者直接报错。很多 Agent 框架里提到的“执行上下文”,本质就是当前请求中模型可见的文本快照,不是一个可以随取随用的长期空间。快照注定会过期,放在快照里的“记忆”自然也保不住。
如果你做过稍微复杂的 Agent,应该已经发现:长对话跑得越久,模型越容易把早期信息搞丢。那不是幻觉,那是上下文被截断之后的必然结果。我甚至见过一个项目,用户第一轮明确说了“最终交付物要用 PostgreSQL”,结果 20 轮之后 Agent 用 MySQL 写了一整套建表 SQL。用户以为模型“疯了”,其实是前面那行约束早被挤出窗口了。
1.2 上下文自残的三种常见姿势
在真实项目里,上下文被“自残”大致有三种姿势。
第一种是硬截断。框架按 token 上限,把最早的历史消息直接丢掉。这种方式实现最简单,但代价也最直接:最早的消息往往是用户目标、项目约束、决策记录这些锚点信息,丢掉之后 Agent 就会开始自由发挥。
第二种是摘要压缩。把多轮对话交给模型总结成几句话,然后只保留摘要。这看起来聪明,但摘要本身就是有损压缩,里面的细节、数字、边界条件很容易丢。更麻烦的是,如果每次满了都压缩一次,压缩出来的摘要又会在下一次被继续压缩,错误和遗漏会逐层累积。
第三种是无限重放。也就是不压缩也不截断,每次把全部历史都塞进 prompt,直到窗口爆炸。这种方式在小 demo 里能跑,但一旦对话轮次变多,token 成本会涨到让人肉疼,请求延迟也会明显上升。
这三种姿势的共同问题在于:我们让上下文同时承担了“工作台”和“仓库”两个角色。工作台需要快速操作,仓库需要海量存储,硬把仓库里的所有东西都堆到工作台上,自然放不下。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把提示词当存储,等于用便签纸记全家账
2.1 为什么 token 成本会失控
先算一笔账。假设上一轮对话历史有 8000 token,下一轮模型生成回复时,需要把 8000 token 的历史全部重新读一遍。这还不是最可怕的,最可怕的是每多一轮,历史就越长,模型每生成一个字都要面对越来越长的上下文。
第 3 轮可能是 24000 token 的重复阅读,到第 10 轮累计就是接近 100000 token 的重复阅读。这个成本不是线性增长,而是接近 O(n²) 的累积。再加上 Agent 场景里的函数调用、工具返回结果、检索结果注入,上下文用量会比你想象中更快触顶。
所以你会看到很多“上下文用量满了怎么办”的问题。不管是用 Cursor 的压缩上下文命令,还是在 Claude Desktop 里调整上下文压缩设置,甚至用 workbuddy 问上下文用量满了怎么处理,本质上都是在同一个困境里打转:我们用上下文承载了太多本不该由上下文承载的东西。工具能帮你临时止痛,但下一次对话开始后,问题又会回来。
2.2 工作记忆和长期记忆要分离
人不会在每次谈话前把自己整个人生从头回忆一遍,而是根据当前场景,只提取相关的片段。比如提到老同事,你只会想起和他有关的几件事,不会把高中物理公式也翻出来。
Agent 也应该这样。每个轮次的上下文窗口只应该负责“这次任务需要的工作记忆”:当前目标、最近几步动作、关键约束。真正需要长期保留的事实、决策、经验、教训,应该落到外部存储里。每次对话开始前,系统从数据库里检索出最相关的记忆,拼接到 prompt 里。
这就是“用数据库硬刚健忘症”的核心思想:不指望模型记住,而是让系统随时能查到。模型仍然无状态,但整个系统是有记忆的。你把它做成一个可检索、可更新、可过期的数据库层,Agent 的“记忆力”就不再受上下文窗口限制。
3. 数据库选型:不是一上来就要向量库
3.1 三类记忆不能塞一个字段里
很多人一听“Agent 记忆”就想到向量数据库,这是一个很大的误区。向量检索确实适合模糊语义匹配,但很多记忆根本不需要那么复杂。我在实际项目里会把记忆拆成三类来存。
| 记忆类型 | 典型内容 | 推荐存储 | 读取方式 |
|---|---|---|---|
| 事实型记忆 | 用户名字、项目代号、技术栈、截止时间、关键决策 | 关系型数据库,如 SQLite、PostgreSQL | 等值查询 + 最新版本判断 |
| 对话事件 | 某一步做了什么、调用了什么工具、执行结果是否成功 | 追加写入的日志表 | 时间倒序 + 按条件过滤 |
| 语义回忆 | “上个月你提过一个去重思路”“那次线上排查的结论” | 向量数据库,如 pgvector、sqlite-vec、Qdrant | 向量 topK + 关键词混合召回 |
事实型记忆要求准确,适合关系型数据库的关系模型;对话事件要求完整和有序,适合简单快速写入;语义回忆要求和问题“意思接近”,适合向量检索。把这三类混在一张 message 大表里,统一靠向量相似度召回,结果往往是要么召回不准,要么关键事实被淹掉。
提示:项目初期不要盲目引入向量库。先把手头的关系型数据库用明白,再决定要不要加向量字段。
3.2 最小闭环:SQLite 一张表打通
如果你做的是个人项目、内部工具、或者还在验证阶段的 Agent,SQLite 完全够用。几十万条记录以内,SQLite 的读写性能不会成为瓶颈,而且零部署、零运维,调试的时候可以直接打开数据库文件看里面到底存了什么。
以下是我实际用过的表结构,不需要一开始就设计得很复杂,先把字段留够,后面加索引也好加。
sql复制CREATE TABLE IF NOT EXISTS agent_memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id TEXT NOT NULL,
memory_type TEXT NOT NULL,
content TEXT NOT NULL,
metadata_json TEXT,
created_at TEXT DEFAULT (datetime('now')),
valid_to TEXT,
supersedes_id INTEGER
);
CREATE INDEX IF NOT EXISTS idx_agent_time
ON agent_memories(agent_id, memory_type, created_at);
CREATE INDEX IF NOT EXISTS idx_agent_valid
ON agent_memories(agent_id, valid_to);
解释几个字段。memory_type 存事实、事件、摘要等类型;valid_to 表示这条记忆何时失效,默认为 NULL 表示当前有效;supersedes_id 表示这条记忆替代了哪条旧记忆。这样设计,后面处理“用户改主意了”这种场景会非常顺手。
如果你已经有团队研发用的库,比如 PostgreSQL、MySQL,或者企业里常见的达梦、Oracle,也没关系,思路完全一样:多建一张表,多几个索引,剩下的逻辑照搬。
4. 手把手给 Agent 接一个“记忆层”
4.1 写入策略:不是每句话都值得进数据库
我见过最典型的失败,就是把每一轮用户消息和 Agent 回复都往数据库里塞。这样做的后果是:检索出来的内容全是噪音,真正重要的事实反而被淹没。数据库不是对话录音机,是决策笔记。
在我的方案里,下面这几类内容会写入记忆表:
- 已经确认的事实:用户所处行业、项目名称、目标用户群、预算范围。
- 明确做出的决策:数据库选型、技术方案、UI 风格、某个问题采用的处理方式。
- 当前任务状态:进行到哪个阶段、下一步要做什么、卡在哪个问题上。
- 阶段性结论:某次排查的最终原因、某个验证的结果。
下面这几类内容不值得写:
- 寒暄。
- 用户随口一提、还没最终确认的想法。
- 过程性的中间输出,除非对后续排错有明确价值。
- 敏感信息,必须脱敏之后再入库。
写入策略直接决定后面的记忆质量。写的时候多想一想“三个月后这条数据还有没有用”,很多问题就能避免。
4.2 读取策略:先过滤,再排序,最后按预算截断
写入是入口,读取是出口。很多 Agent 记忆模块做不好,是因为读取时只写了“把最近 N 条历史拉出来”,这又回到了上下文自残的老路。正确的读取顺序应该是:先过滤,再排序,最后按 token 预算截断。
先看最简单的版本,从 SQLite 里读取最近的有效记忆。
python复制import sqlite3
DB_PATH = "agent_memory.db"
def recall_recent(agent_id: str, memory_type: str | None = None, limit: int = 6):
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
sql = """
SELECT id, content, memory_type, created_at
FROM agent_memories
WHERE agent_id = ? AND valid_to IS NULL
"""
params = [agent_id]
if memory_type:
sql += " AND memory_type = ?"
params.append(memory_type)
sql += " ORDER BY created_at DESC LIMIT ?"
params.append(limit)
rows = conn.execute(sql, params).fetchall()
conn.close()
return [dict(row) for row in rows]
这个函数适合读取事实和最近事件,因为它按类型过滤、按时间倒序。但如果用户问了一个语义上很接近的问题,比如“上次那个去重方案后来怎么定的”,靠时间倒序就找不准了。这时候需要引入向量检索。伪代码如下:
python复制# 伪代码:结合 sqlite-vec / pgvector 等向量插件
def recall_semantic(agent_id: str, query_embedding: list[float], top_k: int = 5):
sql = """
SELECT content, memory_type,
1 - vector_distance_cos(embedding, :query_vec) AS score
FROM agent_memories
WHERE agent_id = :agent_id
AND valid_to IS NULL
AND embedding IS NOT NULL
ORDER BY score DESC
LIMIT :top_k
"""
# 具体 SQL 语法按向量插件文档调整
注意,任何向量召回都不能只靠 ORDER BY score DESC。你还需要加 memory_type 过滤、时间范围过滤、相似度分数阈值。否则一个关于“上个月项目进展”的问题,可能把三个月前另一条相似表述也捞出来,然后被模型错误地当成当前事实。
最后一步是预算截断。假设你给记忆层分配的 token 预算是 1200 token,那么召回结果超过预算时,要按优先级砍掉。我常用的优先级是:硬事实高于软观点,决策高于猜测,近期事件高于早期事件。先把记忆拼进 prompt,再根据剩余空间决定放多少。
python复制def assemble_context(agent_id: str, query_embedding: list[float], max_tokens: int = 1200):
memories = []
memories += recall_recent(agent_id, "fact", limit=5)
memories += recall_recent(agent_id, "event", limit=8)
memories += recall_semantic(agent_id, query_embedding, top_k=5)
# 这时不要全量拼进去,按 token 估算截断
context_parts = []
used_tokens = 0
for m in memories:
tokens = estimate_tokens(m["content"])
if used_tokens + tokens > max_tokens:
break
context_parts.append(m["content"])
used_tokens += tokens
return "\n".join(context_parts)
这套流程的核心是:先让系统有“可查询的记忆”,再决定哪些记忆进入上下文。这一步走通之后,Agent 不再需要依赖超长历史才能记住事情。
5. 别人踩过的坑:脏记忆比没记忆更可怕
5.1 用户改主意了,旧记忆会变成回旋镖
我踩过最痛的一个坑,是用户记忆冲突。对话记录里用户先说“先用 MySQL 跑着”,三个小时后又改口“还是上 PostgreSQL”。如果你检索时只按时间倒序取最近 N 条,大概率会取到旧消息,Agent 就会把已经废弃的决定当成仍然有效的约束。
解决办法就是利用表里的 valid_to 和 supersedes_id。当检测到新记录会覆盖旧记录时,先更新旧记录的 valid_to,再插入新记录。检索时统一加一个 valid_to IS NULL 条件。这样从逻辑上保证:当前读到的永远是最近一次有效决策。
sql复制-- 伪代码:让旧记忆失效
UPDATE agent_memories
SET valid_to = datetime('now')
WHERE agent_id = ?
AND memory_type = 'fact'
AND valid_to IS NULL
AND content LIKE '%MySQL%';
-- 然后插入新的有效记录
INSERT INTO agent_memories(agent_id, memory_type, content)
VALUES (?, 'fact', '数据库选型最终确定为 PostgreSQL');
如果你在团队协作或多实例场景使用记忆表,写入时还需要加事务,避免并发导致同时插入老版本和新版本。单机本地 Agent 可以暂时不管并发问题,但事务习惯要早点养成。
5.2 向量相似不等于事实正确
很多刚上手向量数据库的开发者会犯一个错觉:只要向量检索分数够高,这条记忆就是对的。实际上,向量相似度只表示“语义接近”,不代表“逻辑成立”。
举个例子。用户问“这个月的发布计划是什么”,向量库可能找到一个上个月的发布计划,语义上非常接近,所以分数很高。但如果 Agent 没有把“时间范围”作为硬过滤条件,就会把上个月已经过期的计划当作本月计划回复给用户。
因此,在做语义检索时,必须在召回阶段就加入结构化条件。比如 created_at >= 本月月初、valid_to IS NULL、memory_type = 'decision'。向量模型负责粗筛,结构化字段负责精排。两者结合,而不是互相替代。
5.3 上下文用量满了怎么办(压缩命令只是止痛药)
最近经常看到有人问:workbuddy 上下文用量满了怎么办?Cursor 的压缩上下文命令是什么?Claude Desktop 的压缩上下文设置在哪里?这些问题的共同本质,其实还是“上下文承载太多”。
我并不是说压缩功能不能用。相反,压缩写得好,能把 10000 token 的历史变成 500 token 的摘要,非常适合用于跨会话快速恢复。但关键在于:压缩出来的摘要要写回数据库,而不是留在上下文里继续占位置。下一次对话开始时,从数据库加载摘要和关键事实,然后开启一个干净的上下文窗口。这样压缩命令就变成了“记忆持久化工具”,而不是“临时代餐”。
如果你发现自己每天都在折腾上下文压缩设置,那大概率不是压缩命令不够强,而是整个记忆层还没接。接了数据库之后,压缩就只是辅助手段,不再担惊受怕。
6. 实测效果:从 5 轮健忘到 25 轮不跑偏
6.1 一个周报 Agent 的 30 天实测
为了验证这套方案,我用一个周报 Agent 跑了差不多一个月。这个 Agent 需要记住项目代号、关键干系人、三个里程碑时间点、每期周报的输出格式,以及用户在不同周的反馈偏好。
改造前,这个 Agent 聊到 5 轮左右就开始忘项目代号,有时会把上一个项目的里程碑套到当前项目上。改造后,我把项目信息作为事实型记忆写进 SQLite,把每周周报作为事件型记忆存下来,再用向量检索召回过去几周的进展描述。实测跑到 25 轮,Agent 依然能准确说出“当前里程碑是哪条、下一期重点写什么”。中间有一次我把上下文窗口手动截断,只保留最近两轮消息,它也能靠数据库里的摘要恢复对话状态。
从“5 轮健忘”到“25 轮不跑偏”,提升并不是因为换了大模型,而是因为记忆不再放在上下文里等截断,而是放在数据库里等检索。模型还是同一个模型,系统行为完全不同。
6.2 给记忆层做日常运维
接了数据库不等于一劳永逸。跑得越久,记忆层越需要维护。
我目前会做三件常规事。第一,每晚把当天新增的对话事件做一次摘要,生成阶段性记忆写入 summary 类型,避免事件表无限膨胀。第二,定时清理 valid_to 不为空的旧记录,防止历史垃圾越积越多。第三,如果换了 embedding 模型,要重新为所有记忆生成向量索引。不同 embedding 模型输出的向量空间很可能不兼容,混着用会导致语义检索结果一团糟。
最后再分享一个我踩过三次的坑:刚开始做记忆层时,我恨不得把每条消息都塞进数据库,结果第二天 Agent 回忆出来的内容全是噪音,甚至会把上一轮随口一句“我可能改用 MySQL”当成既定事实。后来我只保留三种东西:已经确认的事实、已经做出的决策、已经完成或正在进行的任务状态。记住一句话:数据库不是对话录音机,是决策笔记。这个取舍,比选哪个向量库重要得多。
