1. 为什么Agent离了记忆就是"高级聊天机器人"
很多人在搭自己的Agent时,第一版通常长这样:一个System Prompt加上调大模型接口,能正常问答就欢呼胜利。可一旦拿给身边人用几天,问题马上暴露——用户昨天在对话里明确说了"我用的是Linux环境",今天换个新会话再问一遍部署步骤,Agent照样把Windows命令甩上来。这不是模型不够聪明,是Agent根本没有记忆。
我把Agent的最小闭环拆成四件事:感知、思考、行动、记忆。前三个大家比较熟:感知是接收用户的输入和外部信号,思考是让大模型推导下一步,行动是调用工具或返回结果。唯独"记忆"是最容易被忽略,却是把Agent从"一次性的问答工具"变成"可持续协作的工作伙伴"的关键。记忆解决的是连续性问题:它让Agent记得用户是谁、之前聊过什么、做过哪些决定,而不是每次对话都像一台刚恢复出厂设置的机器。
什么时候你需要自己动手写记忆?我的判断标准很简单:只要你的Agent需要跨会话提供一致体验,就需要。比如做个人助手,用户偏好要长期记住;做学习辅导,学习进度要连续跟踪;做客服,客户的历史订单和投诉记录最好能在开场就被"回忆"出来。如果只是单轮问答,比如用一次就走的翻译工具,那确实不需要记忆,把全部内容塞进上下文窗口就行。
说到"全塞进上下文窗口",这是很多人的第一反应,我也这么干过。但实测下来有几个硬伤:一是成本,每轮调用都把全部历史发给大模型,token消耗随对话次数线性增长;二是长度限制,对话超过上下文窗口后只能粗暴截断,截断掉的往往是最早但也可能最关键的约定;三是无法跨会话,用户关掉页面重开,一切归零。所以从工程角度,记忆不是可选项,是Agent里必须单独立出来的一块基础设施。
2. 先做减法:一个能跑的记忆系统只需要三类数据
在动代码之前,我建议先忘掉"记忆库""向量数据库"这些大词。回到认知科学的基础分法,人类的记忆至少可以分三类:工作记忆、情景记忆、语义记忆。工程实现没必要一比一复刻,但这三类的划分实操性很强,能直接引导数据表怎么建、检索怎么做。
| 记忆类型 | 对应现实 | 工程形态 | 典型检索方式 | 遗忘策略 |
|---|---|---|---|---|
| 工作记忆 | 当前对话里刚说过的内容 | 最近N条消息列表 | 按顺序拼接 | 超出窗口后移入长期区 |
| 情景记忆 | "上周三我们讨论过这个需求" | 带时间戳的会话流水 | 时间倒序/关键词/向量召回 | 过期归档或保留原始库 |
| 语义记忆 | "用户偏好Python""项目名是X" | 事实键值对/列表 | 向量相似度 | 去重合并,低相关淘汰 |
2.1 工作记忆:当前会话的滑动窗口
工作记忆最容易实现,本质上就是一个数组——当前会话的消息列表。我在代码里维护一个history列表,每次对话往里面追加user和assistant消息,超过一定条数(比如20条)就把最早的一部分移交给长期记忆,然后从history里裁剪掉。这里有个容易被忽略的细节:裁剪时不要从会话开始就切,要保留最近至少5到10条作为推理所需的新鲜上下文,切出去的部分才做提炼和沉淀。
2.2 情景记忆:完整事件的流水账
情景记忆存的是"发生过什么",它的特点是带时间、带事件边界。我最早踩过的坑是只记录文本摘要,不记时间和会话关系,结果检索出来"我们之前聊过部署方案"这句话,却不知道是哪天聊的、后来结论有没有变。所以在设计上,情景记忆至少要包含:事件文本、时间戳、会话ID、事件类型。检索时既可以按时间线回溯,也可以把事件文本向量化后用语义查找。
2.3 语义记忆:从对话里提炼出的硬事实
语义记忆是整个系统里信息密度最高的部分,它不存原对话,而存"事实"。比如用户说"我更喜欢简洁的回答",这是偏好;"我部署环境是Ubuntu 22.04",这是环境信息。这类硬事实提取之后可以结构化存储,以支持更精确的匹配和更新。我在第一版就用了一张简单的表,后面讲Agent主循环时会详细说怎么用大模型自动提炼这些事实。
用SQLite起步,两张表足够:一张存消息与情景记忆,一张存语义事实。我不建议第一版就上重型存储,文件和SQLite的差别在数据量小的时候几乎无感,但SQLite天然支持索引和并发,后续扩展也不会推翻重来。
CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE facts ( id INTEGER PRIMARY KEY AUTOINCREMENT, subject TEXT, predicate TEXT, object TEXT, source_message_ids TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP );messages表先解决工作记忆和情景记忆的统一存储:按会话维度拉取最近N条就是工作记忆,按时间范围查询就是情景记忆。facts表存提炼后的硬事实,比如"用户 / 偏好 / 简洁回答",这让后续可以做到"只更新一条事实,而不是把整段历史重读一遍"。
这样的设计权衡很清楚:第一版不要做复杂的记忆图谱,三元组比图谱容易维护得多;不要先搞分块和子代理,两张表加上后面要讲的向量检索,已经能覆盖80%的单Agent场景。
3. 极简语义检索:在不上向量库的前提下,用余弦相似度搞定召回
上一部分存储还停留在结构化表,第三类语义记忆如果只用SQL的like来检索,几乎等于没用。比如用户在新会话里说"我之前问过在天冷的地方部署要注意什么",如果原话是"服务器放机房要注意温度",关键词都对不上,like检索直接失效。这就是要用语义检索的原因:我们需要按"意思相近"来召回,而不是按"字面相同"。
语义检索的基础是嵌入(Embedding)。简单说,嵌入就是把一段文本转换成一串浮点数向量,而这个转换过程经过专门训练,语义相近的文本在向量空间里方向也接近。"苹果"和"水果"的向量会比"苹果"和"汽车"靠得更近。这个概念不需要深究数学细节,你只要把它当成"给文本拍了一张定位照"就行。
3.1 极简向量存储与召回实现
不依赖任何向量数据库组件,我用一个Python类就能跑起来。前提是你需要一个统一的向量化函数embed_fn,它接收字符串、返回list[float]。我用的是常见的文本向量化模型,在本地或云上跑都行,关键是所有文本必须用同一个模型、同一个版本,这个坑后面单独说。
import math def cos_sim(a, b): dot = sum(x * y for x, y in zip(a, b)) norm_a = math.sqrt(sum(x * x for x in a)) norm_b = math.sqrt(sum(x * x for x in b)) if norm_a == 0 or norm_b == 0: return 0.0 return dot / (norm_a * norm_b) class SimpleVectorStore: def __init__(self, embed_fn): self.embed_fn = embed_fn self.items = [] def add(self, text, metadata=None): vec = self.embed_fn(text) self.items.append({ "text": text, "vector": vec, "metadata": metadata or {}, }) def search(self, query, top_k=3, threshold=0.0): q_vec = self.embed_fn(query) scored = [] for item in self.items: score = cos_sim(q_vec, item["vector"]) if score >= threshold: scored.append((score, item)) scored.sort(key=lambda x: x[0], reverse=True) return scored[:top_k]这个实现是线上可用的最简形态:add时算好向量存内存,search时把查询向量和每条记录的向量做余弦相似度,排序后取前top_k。数据量在几千条以内,内存版的性能完全够,我实测一次全量扫描也就十几毫秒级。数据量再大,把向量落盘或换专门的向量索引是后面的优化方向,第一版不必背这个包袱。
3.2 为什么选余弦相似度
向量相似度有很多算法:欧氏距离适合比较"绝对长度"差异,但在文本场景里,同一语义的文本可能因为详略程度不同,向量长度差异很大,这时候用欧氏距离会误判。余弦相似度只看两个向量的方向夹角,对长度不敏感,"这句话写得很全"和"这句话写得很简"只要语义相同,向量方向就会接近。这也是文本检索里默认用余弦的原因。
3.3 嵌入模型的使用注意
用嵌入检索第一原则:全流程只用同一个模型。我见过有人前期用API向量化,后期换成本地模型,结果新写入的向量和老向量完全不在一个向量空间,检索出来的结果驴唇不对马嘴。第二原则:保存记忆文本时,最好连同一个版本的模型标识一起存,方便以后模型升级时能识别老数据、做批量重新向量化。第三原则:嵌入之前做简单的文本清理,去掉多余换行和语气词,中文尤其明显,清理后召回效果会稳定不少。
4. 把记忆接进Agent主循环:先回忆、再推理、最后沉淀
这是整个Agent架构的核心环节。很多人写Agent是直线逻辑:收到消息、调模型、返回结果。但接了记忆之后,主循环应该变成带回忆和沉淀的闭环。
完整流程我拆成六步:
- 收到用户新消息。
- 用这条消息做查询,去长期记忆里检索相关事实和历史事件(对应第3章的SimpleVectorStore)。
- 组装提示词:系统身份 + 检索到的长期记忆 + 当前会话上下文。
- 调用大模型获得回复。
- 把"用户消息 + Agent回复"交给独立的提炼步骤,让大模型抽取值得长期记住的事实。
- 将新事实写入长期记忆,同时做去重和过期淘汰。
4.1 代码骨架
下面是一个最小可跑的Agent类,它把第3章的SimpleVectorStore接进对话循环。
class MemAgent: def __init__(self, llm, embed_fn, store): self.llm = llm self.embed_fn = embed_fn self.store = store self.history = [] def _recall(self, query, k=3): return self.store.search(query, top_k=k) def _build_prompt(self, user_msg): memories = self._recall(user_msg) memory_block = "\n".join(m["text"] for _, m in memories) system_prompt = f"""你是一位用户助理。 以下是从用户历史交互中检索到的长期记忆,可能与当前问题相关: {memory_block} 如果长期记忆中有用,请结合它回答;如果没有,请明确说明你不知道。""" messages = self.history + [{"role": "user", "content": user_msg}] return system_prompt, messages def chat(self, user_msg): system_prompt, messages = self._build_prompt(user_msg) reply = self.llm(system=system_prompt, messages=messages) self.history.append({"role": "user", "content": user_msg}) self.history.append({"role": "assistant", "content": reply}) if len(self.history) > 20: self._move_to_long_term() return reply def _move_to_long_term(self): old_messages = self.history[: len(self.history) - 10] text = "\n".join(m["content"] for m in old_messages) facts = self._extract_facts(text) for fact in facts: self.store.add(fact, metadata={"source": "conversation"}) self.history = self.history[-10:]这里的llm是任意支持system和messages两个参数的大模型封装,embed_fn就是第3章说的向量化函数。核心逻辑在move_to_long_term里:当history超过20条,把最旧的10条交出去,调用extract_facts提炼事实,写入长期记忆,再把history裁剪到最近10条。这样工作记忆和长期记忆就通过一个阈值联动起来了。
4.2 用大模型做记忆提炼
extract_facts是我单独封装的函数:把一段对话文本交给大模型,让它输出值得保留的事实。提炼和回答最好用同一个模型,但用不同的提示词任务。我给个模板:
请从下面的对话内容中,提取出值得长期记住的事实。 值得长期记住的意思包括:用户偏好、身份信息、明确给出的结论、环境配置、任务约束。 忽略一次性闲聊和情绪化表达。 每行输出一条事实,不要编号,不要解释。 如果没有任何值得记录的,只输出一行:无 对话内容: {text}为什么要在对话变旧后才提炼而不是每轮都提炼?因为单轮对话里大量的是"今天天气不错"这类瞬时内容,提炼成本和噪声都高。攒到20条后统一提炼一次,信息密度更大,提炼结果也更可靠。这也意味着记忆写入是批量、异步的,不会拖慢每轮对话的响应速度。
4.3 上下文组装顺序与预算
长期记忆检索回来后,放在提示词里的顺序其实有讲究。我实测发现,大模型对提示词开头和结尾的内容关注度更高。所以我的组装顺序是:系统身份开头,紧跟长期记忆块,当前对话的最近几条放结尾。这既保证"你是谁"和"你记得什么"这种全局信息优先被模型吸收,又保证"用户在最后说了一句什么"这种即时信息不被稀释。
组装时还要做预算。长期记忆块如果无限堆,最后还是会顶爆上下文窗口。我给memory_block设了一个硬上限,比如800 token:检索结果先按分数排序,依次塞入,快接近上限就截断,宁可少召回,也不能让主提示词超长。价格敏感的项目还可以把这个数字压得更小。
4.4 遗忘策略
记忆系统不设计遗忘,迟早被自己写满。我在项目里用三种方式:第一条是上文说的滑动窗口,工作记忆自动淘汰;第二条是事实去重,写入新事实时先和最接近的已有记忆算相似度,超过0.92就认为重复,保留更新版本;第三条是低相关淘汰,检索时设相似度阈值,低于阈值的直接不返回,检索不到的陈旧记忆虽然没有显式删除,但实际已经"用不到"了。
5. 实测中反复踩的坑:从检索失灵到上下文爆炸
这套系统跑起来之后,问题不会马上暴露,而是要等对话量积累到一定规模才会集中爆发。我把几个反复踩的坑列在下面,每一条都对应真实调试场景。
5.1 嵌入模型中途更换,向量空间直接错位
第一次踩坑是在一个周末:我发现前一天的记忆还检索得好好的,第二天换了一个"效果更好"的本地小模型,结果所有历史记忆的召回结果完全变了。原因不复杂:不同模型的向量空间不是对齐的,旧记录在新模型里没有可比性。解法有两个,要么永远锁死一个模型版本,要么在给向量建索引时同时保存模型ID,切换时统一触发历史重向量化。我建议一开始就在metadata里存上model版本,后面升级能省很多功夫。
5.2 长期记忆被一次性垃圾塞满
第二个坑是过度提炼。早期的提炼提示词只写了"提取值得记住的事实",结果模型把"用户今天问过某某接口怎么调用"这种一次性问题也写成了长期事实。这些条目既占存储,还会在检索时频繁命中,干扰真正有用的记忆。修正方法是在提炼提示词里明确排除规则:忽略一次性闲聊、忽略单次操作、保留可复用的偏好和约束。我再加了过滤层,事实里出现"今天""现在"这类强时间词且没有后续价值时,降级成短期缓存而不是长期事实。
5.3 检索结果"相关但过时"
语义检索只看相似度,不管时间。用户三周前说"我还在用老版本的SDK",上周已经说"我升级到新版本了",但新会话里检索"当前SDK版本"时,两条记忆都命中了,老版本那条甚至可能因为表述更接近查询而排得更前。我给排序加了时间折扣因子:
final_score = 0.7 * semantic_score + 0.3 * recency_bonus # recency_bonus 由时间衰减计算,如最近1天为1.0,之后逐日递减这不算复杂,但显著改善了"总给用户翻旧账"的体验。时间因子放多少要结合场景,可以自己调,我的经验是语义分占七成比较稳,时间分超过五成反而会把近期的无关内容顶到前面。
5.4 上下文预算失控,长对话直接超限
接入检索块后,最大变化是system prompt变长了。我在一个带工具的Agent里遇到过:系统身份、工具描述、长期记忆块、当前上下文四段叠加,某一轮检索一下命中十几条记忆,直接让一次调用超了模型上下文窗口。解法在第4.3节已经提过,这里再强调一遍:所有动态拼装的内容都必须有预算上限,不仅记忆块要限,检索条数也要限。我后来把top_k从5调到3,再配合上限截断,再没出现过超限。
5.5 中文文本的嵌入效果需要额外处理
最后一个是中文场景特有的问题。中文没有天然空格分词,一些通用向量化模型对中文的效果不稳定。我实测下来,直接喂原始中文句子往往不如先做轻度清洗:把用户内容里的多余标点、口语语气词、重复字去掉,再交给嵌入函数。另一个细节是不要用逐字字符级向量化,尽量用按句或按语义段向量化,一个片段至少包含一个完整的"主谓宾"结构,这样向量语义才稳定。清洗并不复杂,但能让中文语义检索的效果明显上一个台阶。
最后再分享一个我的体会:第一版记忆系统,不要上来就追求尽善尽美。先跑通"存储-检索-组装"这个最小闭环,哪怕先用JSON文件存、内存里扫相似度,也比画一个永远不落地的架构强。等对话规模长大了,再平滑迁移到专门的向量索引和数据库,这时你已经知道真正的瓶颈在哪,而不是靠猜。