Agent 的记忆怎么做:从 20 条 localStorage 到向量库
LLM 是无状态的——这句话每个教程都说,但只有自己做对话产品时才真正体会到它的分量:模型不记得上一句话、不记得你是谁、不记得自己昨天答过什么。你看到的所有「记性」,都是工程补出来的。
这个博客助手的记忆分三层,各自的生命周期、容量、读写方式完全不同。这篇逐层拆开讲,以及每层背后那笔权衡账。
第一层:会话短期记忆——浏览器存 20 条,服务端存 30 分钟
对话历史有两个副本,存的地方和期限都不一样,这是故意的。
浏览器侧(useChatHistory.ts):localStorage 键 kalpa-chat,存 conversation_id + 消息流水,只留最近 20 条:
const KEY = "kalpa-chat";
const MAX_HISTORY = 20;
// 每次 push:messages.slice(-MAX_HISTORY) 再写回
20 条这个数字是两头挤出来的。往下挤的是成本:每次提问历史要发给后端再发给模型,历史每多一条,每次请求的输入 token 都在涨,DeepSeek 按量计费,长历史是纯开销。往上挤的是连贯性:存太少,用户刷新页面后「刚才聊到哪了」的上下文全丢。实测下来博客问答很少有超过十轮的对话,20 条足够覆盖真实使用,再往上是浪费。
服务端侧(core/history.py):HistoryManager 在内存里按 conversation_id 存问答轮次,带两个保险:
TTL_SECONDS = 1800 # 会话 30 分钟不活跃即过期
CLEANUP_INTERVAL = 300 # 后台任务每 5 分钟清扫一次
为什么是 30 分钟?服务端存历史的目的不是「记住用户」,是兜底:前端可能不发历史(旧版本客户端、历史被用户清空、或者干脆有人直接调 API),这时服务端靠 conversation_id 还能接上上下文。但这份兜底数据不该永久存在——2G 内存的小机器上,不清理的内存字典就是一个缓慢漏气的气球。TTL + 定时清扫,把「兜底」和「内存泄漏」隔开。
为什么 conversation_id 由前端生成?前端用 genUuid() 造好 ID 后连同历史一起存进 localStorage,这样刷新页面、关掉再打开,会话都能续上——ID 的生命周期跟着用户的浏览器走。后端只在请求没带 ID 时才自己生成一个(conv_ 前缀的 12 位 hex),属于保底路径。如果反过来让服务端主导发 ID,前端就得等第一次请求返回才能落盘,首条消息的持久化就多出一个竞态窗口。
发给模型的历史还要过一道加工(ChatWidget.tsx 的 buildHistory):消息流水被配成 {question, answer} 对,只取最近 5 对,不成对的直接丢弃——比如限流提示、降级文案这些「假 AI 消息」绝不能进模型的上下文,否则模型会把错误提示当成自己说过的话。
第二层:知识长期记忆——ChromaDB 是「读过就记住」的永久记忆
会话记忆会过期,知识记忆不会。所有博客文章经过分块、Embedding 后存在 ChromaDB 里,这是助手的长期记忆:写入一次,永久可查,重启不丢(前提是真落盘了——这块的血泪史在 ChromaDB 三座大山)。
这一层有个值得想清楚的定位问题:它是共享记忆,不是个人记忆。所有访客提问,检索的是同一份向量库——助手对「博主写过什么」的记忆是全站共享的,而它不知道也不该知道「你上次问过什么」(那是第一层的事)。这个切分带来一个隐私上的副产品:服务端不需要为知识记忆存任何用户标识,检索请求里没有「谁」这个概念,只有「问什么」。
长期记忆的更新是显式的:发新文章 → 同步文件到 data/docs/ → 跑 index_docs.py 重建索引,助手才算「读过」新文章。这一步目前是手动的,也是这套记忆系统最大的缺口——记忆不会自动生长,忘了重建索引,助手就会对新文章「失忆」,还在检查清单里留了一条「删文章要同步删向量,否则检索到幽灵文章」。
第三层:工作记忆——每次请求临时拼装的 SYSTEM_PROMPT
前两层是「存」,第三层是「取」。每次请求到达时,后端把检索结果、对话历史、行为约束现场拼成一份上下文,塞进 System Prompt:
SYSTEM_PROMPT = """你是 kalpacode 博客的 AI 助手。请基于以下检索到的博客内容回答用户问题。
...
【检索上下文】
{context}"""
messages = [SystemMessage(content=system_prompt)] + history + [question]
这对应认知科学里的「工作记忆」:容量有限(受上下文窗口和 token 预算双重限制)、生命周期只有一次请求、内容是从长期记忆里按需取出来的。人类回答问题前也是这么干的——从长期记忆里捞出相关片段放到工作台上,用完就清空。
这一层的设计要点是组装顺序和内容都要受控:检索结果按相关度过滤后才进上下文(距离超阈值的丢掉,宁可少给不给噪音),历史只带配好对的几轮,行为约束写死在最前面。工作记忆里放进什么,模型就「知道」什么——这是 RAG 系统里杠杆最大的一个环节,比换模型见效得多。
三层放在一起看
| 层 | 存哪 | 生命周期 | 容量策略 | 对应角色 |
|---|---|---|---|---|
| 会话短期记忆 | localStorage + 内存 | 20 条 / 30 分钟 TTL | 封顶截断 + 定时清扫 | 「刚才聊了什么」 |
| 知识长期记忆 | ChromaDB | 永久(手动更新) | 分块 + 相关度阈值 | 「博主写过什么」 |
| 工作记忆 | 请求的 Prompt | 单次请求 | 按需组装 | 「这次该想什么」 |
复盘
做完这三层我最大的体会是:「记忆」不是一个功能,是一组关于生命周期的决定。每一段信息都要回答三个问题——存多久、存多少、谁来读。教程里「给 Agent 加记忆」往往就是塞一个向量库完事,但真正决定体验的是那些看起来琐碎的参数:20 条、30 分钟、最近 5 对、相关度阈值。这些数字没有标准答案,全是拿真实使用场景去卡出来的。下次再做记忆系统,我会先画那张三层表,再动手写代码。