Part 4 记忆:agent 的”记得”与”忘记”

墨叔开场:老哥,前面咱们把”循环”和”工具”这两层扒明白了。这一 Part 聊一个你迟早会摔跟头的话题——记忆。你先别觉得这词儿玄,它本质上就是一句话:agent 什么时候该把东西摊在工作台上,什么时候该归档到档案室,以及,归档了怎么在需要时找回来还不添乱。


4.1 短期记忆(上下文窗口)与长期记忆(外部存储)的边界

老哥,你先回想一下 Part 1 里咱们给的那个画面:模型是个只有一张办公桌的会计,所有要算的账、要查的单子、老板的指令,都得摊在桌上。桌子就是上下文窗口,有限、昂贵、挤掉就没了。那一节咱们埋了个伏笔:目标、关键约束这些东西,挤掉了怎么办?答案是——外置。

这一节,墨叔要把”桌上”和”桌下”的分工,正式给你划一条线。

我先问你一个最朴素的问题:你希望 agent 记住的,到底是哪一类东西?是”这轮对话里刚读的那个文件的第 38 行”,还是”老哥是个 Go 后端、讨厌水字数”这种跨会话都该管用的偏好?

你马上能分出来:前者是这趟活儿进行到一半的临时现场,后者是长期有效、不随某次任务结束而消失的背景。在 agent 的世界里,这就是短期记忆和长期记忆的分野。

短期记忆,就是上下文窗口本身。它有几条铁律,你做后端的一听就懂:快、贵、易失、且是唯一真相源。快——模型读它零延迟;贵——按 token 计费,越多越烧钱;易失——满了就截断,先来的先丢;唯一真相源——模型决策时,只信桌上摊着的,桌下档案室里的东西它压根看不见,除非你主动递过去。

长期记忆,是窗口之外的任何存储:文件、数据库、向量库、KV 表、甚至一条写死的配置。它的铁律刚好反过来:慢、便宜、持久、但需要”被读回窗口才有用”。它永不丢失,但模型不会自动知道那里有东西——你得在合适的时机把它检索出来、拼进上下文、再让模型看见。

我给你一个能记一辈子的比喻。短期记忆是”工作台”,长期记忆是”档案室”。你(agent)干活时,手边工作台摆着当前正在用的图纸、正在拧的螺丝、刚量完的尺寸——这些是短期记忆,随手就拿,但台面就那么大。档案室在走廊尽头,里面是你公司十年的合同、图纸底稿、员工花名册——这些是长期记忆,容量无限、永久保存,但你干活时不会把整间档案室搬到工作台上,那台子得塌。你需要某份旧合同,就派个人去档案室调,调回来摊在台子一角,用完再放回去(或者不放回去,反正档案室还在)。

关键洞察来了:档案室里的东西,不调出来就等于不存在。模型这会计,眼睛只盯工作台。你三个月前在档案室写了”老哥讨厌水字数”,但这次开工没把这条调上工作台,模型就会当没这回事,照样啰嗦。所以”长期记忆”不是”自动生效的记忆”,而是”可被检索调回的记忆”——它解决的是持久性,不解决可见性。可见性还是要靠你(循环)在每次拼上下文时主动把该调的调上来。

这跟你后端那套”内存 vs 磁盘”的直觉,像,但有个坑。你习惯里,内存不够就落盘,数据永远在、随时能取,取回来也没成本。agent 这儿不是:长期记忆取回来是有代价的——它要占据宝贵的窗口位置(吞掉桌面的面积),取错了还会污染决策。所以后端你可以”无脑缓存全量”,agent 你绝对不能”无脑把所有长期记忆都塞进窗口”。取,要取对的;取回来,要摆在合适的位置;取多了,桌面反而乱。

给你一段真实的工程经验。很多新手做 agent,第一次遇到”它忘了我之前说的”,反应是把系统提示越写越长,把用户所有历史偏好全堆进去。结果窗口被塞爆,模型注意力被稀释,反而更傻(这就是 Part 1 讲的”lost in the middle”)。正确的做法是分层:真正每轮都该看的少量高频约束(比如”你是谁、能调什么工具”)放系统提示固定段;偶尔才需要的项目约定,放外部文件,按需检索;一次性的任务上下文,随任务结束自然丢弃,绝不外置污染长期记忆。

我再给你一个更具体的”怎么画这条边界”的启发式,免得你停留在比喻。墨叔教你用三个问题判断一样东西该住工作台还是档案室:第一,它跨不跨这次任务? 不跨(只是当前这趟活儿读到的一半文件),住工作台、用完即弃;跨(你下个月开新会话还想它管用),住档案室。第二,它变不变? 频繁变的现场状态(当前到第几步了)必须住工作台(因为每轮都重算);稳定的偏好(用 Go)才值得住档案室长期存。第三,它大不大? 体量小且每轮必看的(工具清单),直接常驻工作台固定段;体量大且偶发的(整个代码库的架构说明),住档案室按需调。这三个问题一问,绝大多数记忆该放哪就清楚了——你会发现,真正该住档案室的,远没有你直觉里那么多。

我讲个踩坑的反例帮你加深。有个团队做客服 agent,把”用户历史所有对话摘要”全量塞进每轮窗口,理由是”要让它记得用户之前说过啥”。结果单次会话上下文动辄几万 token,成本高到离谱,且模型常被三个月前一条无关对话带偏、答非所问。后来改成:当前这一轮对话住工作台(短期),用户级别的”稳定画像”(如”这位用户是付费企业客户、关心 SLA”)抽成精简 KV 常驻,历史的逐轮对话只在用户明确说”记得我上周提的那个问题”时才去档案室按时间检索调回。成本降了七成,答非所问几乎归零。这个案例把”该外置的才外置、外置了的按需调、调回来的要精简”三条全演了一遍。

顺带破除一个心理障碍:很多后端第一次做 agent,会觉得”还得额外存一份记忆”很别扭,好像在给模型打补丁。其实你换个角度就通了——人类自己就是这么干的。你不会把公司十年合同全记在脑子里,你存档案室、记笔记、写 wiki;你开会前翻一下上次的纪要,才接得上话。agent 把”该外置的存外部、需要时调回”,不是权宜之计,而是复刻了人类”有限脑力 + 外部辅助记忆”这套已经被验证了几千年的分工。区别只在:人类靠直觉决定”现在该翻哪份笔记”,agent 靠你写的循环逻辑决定”现在该调哪段记忆”。你想通这一层,就不会再纠结”要不要记忆”,只会纠结”怎么让调取得更准”——而这正是 4.2~4.4 要解决的。

我再点一个你极易忽略、但极其要命的细节:窗口满了对”谁先被丢”这件事,是有偏向的,而这个偏向恰好最坑人。模型处理超长上下文时,最常见的是”截断最早的”——也就是 FIFO(先进先出)。可你想想,一次 agent 任务里,“最早被写进窗口”的是什么?往往是用户的原始目标和最初的几条关键约束(“帮我重构这个模块,注意别动公共接口”)。而这些,恰恰是整趟任务最不该丢的东西。结果就是:转了十几圈、工具调了一堆之后,窗口满了,模型把”用户最初说的目标”挤掉了,后半程开始瞎跑——它忘了自己到底要干啥。这跟你后端熟悉的 LRU 缓存淘汰还不一样:LRU 淘汰的是”最久没访问”的,而 agent 窗口是”最先进来”的先没,偏偏目标这种东西是”进来最早、但全程都要用”的。所以工程上必须主动保护重要上下文不被淘汰——把目标/核心约束放进系统提示的固定段(那段通常不参与滚动淘汰),或者每轮重新把目标贴回醒目位置。这又回扣了 Part 1 的”目标要反复重申”。外置长期记忆,本质上也是给这个目标上双保险:窗口丢了,去档案室还能调回来。

我再点一句后面会展开的:这条”工作台 vs 档案室”的线,直接决定了 4.2 的 RAG 该不该用、4.3 的存储该选哪种、4.4 的记忆该分层怎么分。你把它吃透,后面三节就是顺水推舟。

【思考题】

  1. 既然长期记忆”不调出来就等于不存在”,那为什么不全都塞进短期记忆(窗口)里,省得麻烦?你后端”内存 vs 磁盘”的直觉在这里为什么不能直接套用?
  2. 如果agent要”跨会话记住用户偏好”,但窗口每轮都有限,你会怎么设计”什么时候去档案室调、调多少”的策略?给个朴素直觉,先别想复杂方案。
  3. 你代码里有没有类似”工作台 vs 档案室”的分工?举一个你最熟悉的例子(比如本地缓存和数据库的关系),对照一下哪里像、哪里不一样。

【参考答案】

  1. 不能全塞,因为窗口是稀缺且昂贵且会反噬的:一是按 token 计费、越多越烧钱;二是窗口越长模型越慢;三是最致命的——塞太多无关内容,模型注意力被稀释(lost in the middle),反而抓不住重点,决策质量下降。“内存 vs 磁盘”的直觉坑就在这:你那套里内存取回来零代价、不影响正确性,只是占点 RAM;agent 里”取回来”是要占窗口、且占多了会变傻的。所以长期记忆的价值不是”省得调”,恰恰相反——它的价值是”不占窗口”,需要时才精准调回。宁肯多一次检索 IO,也不肯多占一寸桌面。
  2. 朴素策略就两条:一是高频少取——把最常被用到的那几条偏好(比如”老哥用 Go、讨厌水字数”)放进每轮都加载的轻量固定段,相当于工作台上永远贴一张便签;二是低频按需取——只有任务明显涉及某类约定(比如要写运维脚本了)才去档案室翻对应的项目约定。核心原则是”默认不调、命中才调”,而不是”全量预载”。这其实就是缓存设计里”use case 驱动的懒加载”,agent 记忆管理是同一思路。
  3. 最像的是你后端本地缓存(如 Redis / 进程内 cache)vs 数据库。缓存是快、贵(内存)、易失(可能淘汰)、且是热点数据的工作台;数据库是慢、便宜、持久、海量。你不会把整库塞进缓存,而是按访问热点调。不一样的是:你的缓存取回来不影响业务逻辑正确性(取不到就回源库),而 agent 的”长期记忆取回来”会占据并影响窗口这个决策现场——取错一条,模型可能基于错误前提决策。所以 agent 的记忆检索比缓存更讲究”精准”,因为它既是数据源,又是决策上下文的一部分。

小结:短期记忆=工作台(窗口),快贵易失且是唯一真相源;长期记忆=档案室(外部存储),慢便宜持久但”不调出来就不存在”。别用”内存全量缓存”的直觉套 agent——长期记忆的价值恰是”不占窗口、按需精准调回”,可见性仍需循环主动赋予。


4.2 RAG 不是银弹:什么时候该检索、什么时候不该

老哥,我先给你一句话把 RAG 的原理交代了,免得你以为这是什么高深法术:RAG = Retrieval-Augmented Generation,检索增强生成。说人话就是——模型要答题前,你先去某个知识库(通常是向量库)里搜一段相关的资料,把资料拼到提示词里,再让模型基于”资料+问题”作答。它解决的本质问题是:模型训练时没见过你私有的、最新的、或小众的知识,你临时喂给它。

原理就这一句。但墨叔今天要讲的,是反直觉的那一半:RAG 经常是毒药,而不是补药。

我先抛个钩子:你觉得,往模型的工作台上多摊一份”相关的资料”,是让它更聪明,还是可能让它更糊涂?你后端直觉会告诉你”信息越多越好啊”。——错了。在 agent 这儿,多摊一份不对的、或过时的、或看似相关实则跑题的资料,比不摊更糟。

为什么?因为模型是”续写机器”,它极度容易被眼前摊着的最显眼的那段文字带偏。咱们 Part 1 讲过,它不”理解”、只”像”——当你喂给它一段检索来的资料,它会本能地顺着这段资料的语气和结论往下写,哪怕这段资料是错的、或过时的、或只是字面相关。这叫上下文污染(context pollution):你以为给它加了锚,其实给它递了副有色眼镜。

我给你拆三种 RAG 变毒药的典型场景。

第一种,检索噪声。向量检索是按”语义相似度”排的,但”语义相似”不等于”事实相关”。你问”我们项目的部署流程”,向量库可能召回一篇讲”另一个项目部署踩坑”的文章,字面全是”部署、K8s、回滚”这些词,相似度巨高,但对你当前项目是错的。模型拿到它,就照着另一个项目的做法给你编步骤。噪声进了窗口,模型不会自己说”这不对”,它只会把噪声当事实续写。

第二种,检索到的东西过时,反而压过正确记忆。更阴险的是这种情况:模型其实在训练里或当前上下文里已经有正确信息,但你又检索来一份半年前的旧文档,旧文档说”A 方案可行”,现在早改 B 了。模型看到新摊上来的旧文档,倾向于采信”刚给我的”,把正确的覆盖掉。你本想”增强”,结果”误导”。

第三种,无关资料稀释注意力。哪怕检索回来的都对,但量大、和当前具体问题关系不大,它们会挤占窗口、稀释模型对真正关键约束的注意力(又是 lost in the middle)。你为了一个问题塞了八篇文档,模型反而抓不住你要问的那一个点的重点。

那什么时候该用 RAG?墨叔给你一条清晰的分界线:RAG 该用在”模型原本绝对没有、且必须靠外部资料才能答对”的场景。典型就是:查你公司私有的内部知识库、查一个星期前才出的新API文档、查某个具体代码仓库里某函数的定义。一句话——资料是唯一信息源、且不在当前上下文里、且不靠它就会答错,才检索。

什么时候不该用、反而直接塞上下文更好?当资料就在手边、且范围可控时。比如 agent 正在改你这个项目的一个文件,它需要的”项目约定”其实就是当前目录下的 README 和几个配置——你直接 cat 读进来塞窗口,比建个向量库检索又快又准又没噪声。RAG 的有意义前提,是”资料量大到塞不进窗口、且需要语义筛选”。如果资料小、就在眼前,别检索,直接读,直接塞。检索是应对”海量且需筛选”的武器,不是应对”需要信息”的万金油。

我给你一段 Python 示意,把”检索→拼接→喂模型”这条链写出来,你重点看它被污染的那个点:

# RAG 示意:检索增强生成(非可运行)
def rag_answer(question: str, vector_store, model_chat, top_k: int = 3):
    # 1. 检索:按语义相似度召回 top_k 段
    hits = vector_store.search(question, top_k=top_k)  # 返回 [(文本, 相似度), ...]
    # 危险点:hits 可能"相似但不相关"、或"过时"、"或无关但占位置"
    context = "\n---\n".join([text for text, score in hits])
 
    # 2. 拼接:把检索到的资料硬塞进提示
    prompt = f"""请仅根据以下资料回答问题,不要凭空编造:
资料:
{context}
 
问题:{question}
"""
    # 3. 喂模型:模型倾向于顺着 context 续写,哪怕 context 有错
    return model_chat(prompt)

老哥,你看第 1 步那个 hits,它没有任何”相关性校验”——只是相似度排序。第 2 步那个”请仅根据以下资料回答”,更是把模型的命门交给了检索质量:资料错了,它就错;资料过期,它就旧;资料跑题,它就偏。这就是 RAG 反直觉的核心——检索的质量,直接且无条件地决定了生成的质量,而检索几乎没有”纠错机制”。你后面做工程,一定要在检索后面加一层”这坨资料到底跟问题相不相关、新不新”的过滤,否则就是给模型递毒药。

我再把”什么时候直接塞上下文、不检索”这件事,给你一个可操作的量化直觉,免得你每次都在纠结。你心里可以立一条线:单份资料如果小于你窗口的十分之一、且你能确定它在哪(知道文件路径/知道在哪张表),就直接读、直接塞,别检索。举例:你要改的项目就三个核心配置,你 Read 一下全读进来,加起来不到两千字,直接拼窗口,又快又零噪声——这时候上向量库是纯添乱。反过来,资料散落在几万份文档里、你不知道答案在哪份、且必须按”意思”捞,那才是向量检索的用武之地。一句话总结这条边界:检索解决的是”不知道在哪、按意思找”,不是”需要信息”。只要”你知道在哪”,读取永远优于检索——因为读取给的是零噪声的真相,检索给的是带噪的候选。把这句话刻脑子里,你就不会再有无脑 RAG 的冲动。

给你一个真实案例收口。某团队做”代码库问答 agent”,一开始全量 RAG:任何问题都先向量召回再答。结果发现问”这个函数干嘛的”,经常召回一堆同名但不同模块的函数的文档,答得驴唇不对马嘴。后来他们改成:先让模型判断”这个问题靠当前已读的文件能答吗”,能答就不检索;答不了才去精确检索(带上模块/文件路径做过滤)。幻觉率直接腰斩。教训就一句:RAG 是”补盲”,不是”必选项”,默认开检索比默认不开更危险。

我再往下挖一层,讲讲”为什么模型这么容易被检索来的资料带偏”的机理,这样你设计时才知根知底。模型生成答案时,是靠”当前整段上下文”预测下一个 token 的。检索资料被你拼在提示词里,它就成了上下文里最新鲜、最显眼、且被你用”请仅根据以下资料回答”盖章为权威的一段。模型没有”这条资料可能过期/跑题”的元认知——它只会把”刚看到的、被标注为权威的”文字当成最强先验,顺着写。这和你写代码时”后面读到的配置覆盖前面的默认值”一个道理:后写、显眼、带权威标记的输入,权重天然最高。RAG 恰恰把一份未经事实校验的资料,用最高权重喂了进去。所以防污染的本质,不是”不检索”,而是”检索回来的东西,在进窗口前必须被降级为’候选参考’而非’权威铁律’“——要么让模型保留”结合已有知识判断”的余地,要么在检索后加一道相关性/新鲜度过滤(4.3 会讲这层存储怎么配合)。

我补一个生产级的失败样本,让你看见代价。一个做”合同审查 agent”的团队,把公司十年合同全量进向量库,每次审查自动召回”相似合同”当参考。结果有次审查一份新框架的保密协议,召回的全是五年前的旧模板(旧模板里某些免责条款已因法律变更失效),模型照旧模板给了”此条款合规”的结论,没人复核,客户据此签了字,后来闹出合规事故。根因就是:RAG 把”相似但过时”的旧合同,以最高权重喂给了模型,而没有人/没有过滤层去拦”过时”这件事。这案例点出一个残酷事实:RAG 的失败是静默的——它不会报错,它会很自信地给你错误答案,因为资料”看起来相关”。所以凡是 RAG 输出要进生产决策的,必须有”资料时效性 + 相关性”的护栏,绝不能”检索到就信”。

【思考题】

  1. 为什么”语义相似”不等于”事实相关”?这给向量检索的质量设了一道什么坎?你后端有没有类似”字面匹配但语义不对”的坑(比如用字符串模糊匹配做路由)?
  2. 上面那段 Python 里,最脆弱的环节是哪个?如果要加一层”防污染”的过滤,你会怎么设计(给思路,不用写全)?
  3. 既然 RAG 这么容易污染,那”资料就在手边的当前项目”为什么反而应该直接读文件塞窗口、而不是检索?这里的分界线到底是什么?

【参考答案】

  1. 向量检索是按 embedding 的余弦相似度排的,它衡量的是”这段话和那句话在语义空间里挨得近不近”,但”挨得近”可能是因为共用了大量相同词汇(部署、K8s、回滚),并不代表”讲的是同一件事、适用于同一上下文”。这坎就是:相似度是软信号,不是事实相关性证明。后端里太像了:你用字符串模糊匹配做路由或去重,两个请求字面像(都含”order”),其实一个是下单一个是查订单状态,匹配上了就窜。区别是后端窜了顶多出 bug 可查日志,agent 窜了是模型拿着错误前提一本正经编答案,更难察觉。所以检索召回后必须有”相关性/新鲜度”的二次判断,不能只信相似度分。
  2. 最脆弱的是第 1 步 vector_store.search 的”无过滤召回”,以及第 2 步”无条件信任召回结果”。防污染过滤可以这么设计:一是相关性打分之外加一道 LLM/规则判定——拿召回片段和问题再过一道”这段是否真能回答此问题”,只留判通过的;二是新鲜度/来源权重——带时间戳的资料优先、明确过期的打低权;三是范围约束——检索时带上当前项目/模块的过滤条件(metadata filter),不让跨模块噪声进来;四是控制 top_k 和长度,宁可少召回几段精准的,不堆一堆稀释注意力。本质是把”检索”从”自动采信”降级为”候选+需校验”。
  3. 分界线是:资料量是否大到塞不进窗口、且是否真需要语义筛选。当前项目的 README、配置、几个核心文件,体量小、就在文件系统里、agent 用 Read 工具一秒就读到,且读到的就是”百分之百相关且无噪声的真实内容”——直接读最准最快,没有检索的噪声和延迟。RAG 的存在意义是应对”百万字文档、需要按语义挑出相关几句”这种你没法手读的体量。小资料上 RAG,是拿大炮打蚊子还附赠噪声。一句话:能直接读就不检索,检索只在”量大且需语义筛选”时才划算。

小结:RAG 原理只是一句”检索资料拼进提示再生成”,但反直觉处在于它常是毒药——噪声、过期资料、无关稀释都会污染模型(续写机器易被眼前文字带偏)。该用只在”模型原本没有、且必须靠外部资料才答对”时;资料就在手边就直接读塞窗口,别无脑检索。


4.3 结构化记忆 vs 非结构化

老哥,上一节咱们把 RAG 这把双刃剑收了。那长期记忆”档案室”里,东西到底该以什么形态存?这是这一节要你拍板的事。我先泼盆冷水:很多人一上来就把所有记忆全丢进向量库,觉得”反正语义检索万能”。这是最常见的存储选型错误。

为什么?因为记忆不是一种东西,是三类东西,每类该住的”档案格”都不一样。

第一类,非结构化记忆:笔记、文档、对话摘要、自由文本。特点是”人写给人看、模型读给模型用”,没有固定字段,语义松散。比如你记一条”今天和老板确认了,这个模块的下个迭代要支持多租户”。它是一段话,不是一张表。这种最适合存成文档 / 纯文本 / Markdown,检索时适合用向量(按语义找”相关笔记”)。

第二类,结构化记忆:KV、事实表、属性字段。特点是”键-值”或”实体-属性”明确、可被精确查询、不需要语义匹配。比如”老哥的语言偏好 = Go""本项目禁用的数据库 = SQLite""当前环境 = staging”。这些是精确事实,你要的是”查到就是这个值”,不是”找一段意思相近的文字”。这种该存 KV 或关系表,用精确 key 取,绝不走向量。

第三类,向量记忆:专门服务”语义检索”的形态。它本身不是一种”内容类型”,而是一种索引方式——把非结构化文本编码成向量,支持”按意思找相似”。它最适合”我记不清精确关键词,但大概记得讲的是什么”的场景,比如”帮我找找上次关于连接池打满的那次复盘笔记”。

我给你个比喻收这三类。非结构化文档像”抽屉里的手写便签”,内容自由、靠你翻找、适合语义联想;结构化 KV 像”通讯录的字段表”,姓名-电话一一对应、查谁就是谁、绝不含糊;向量索引像”便签的语义目录”,你忘了便签放哪、甚至忘了写啥关键词,但记得”大概讲报销的事”,它能帮你按意思捞回来。关键洞察:向量是索引,不是存储本身。很多人把”存向量库”当成”存记忆”,其实你存的是”文本的向量表示”,原文通常还得另外存(向量只是用来找回原文的路标)。

那怎么选?墨叔给你三条铁律。

铁律一:凡是”查到就必须是这个值”的事实,走结构化 KV/表,别走向量。因为向量检索是”相似度排序”,它永远可能返回”第二相似”把你带偏,而 KV 是”精确命中”。你记”禁用 SQLite”,用 KV 一查就是禁用;若走向量,哪天召回一条”SQLite 在测试环境可用”的旧笔记,模型就可能误判。精确事实容不得”相似”。

铁律二:凡是”长篇、自由、靠语义找”的内容,走非结构化文档 + 向量索引。项目复盘、设计决策、对话摘要,这些没有固定 schema,且你检索时往往只记得大意,向量语义检索正合适。但记住,向量是路标,原文另存。

铁律三:别为了”统一”而全丢向量库。统一存储看似省事,代价是:精确事实被语义检索污染(铁律一的反面),且向量库对你那些”按 key 精确取”的场景是杀鸡用牛刀、又慢又易错。混合存储才是正道——KV 管精确事实,文档+向量管语义内容,两者按记忆类型分流。

给你一个落到本行的连接。你后端设计存储时,早就在做同样的选型:配置项、用户属性用 MySQL/Redis 的精确字段;文章、日志、评论用文档或宽表;需要”相关推荐/相似图片”才上向量/ES 的向量能力。agent 的记忆存储,就是把你这套”按数据形态选存储”的功力,平移到”模型的记忆”这个新对象上。你不是不会选,你只是没意识到”模型要记的东西”也得分形态。

再给一段真实经验。有个团队做 coding agent,把用户的”项目约定”(比如”用 1Panel 运维""禁用 SQLite”)全塞进向量库,结果 agent 经常”忘了”禁用 SQLite——因为向量检索没召回那条(相似度被别的笔记盖过)。后来改成:项目约定是精确事实,抽到一份 PROJECT_RULES 的 KV/固定文件,每次必读;只有”设计决策复盘”这类自由文本才进向量库按需检索。约定被违反的次数直接归零。教训:精确事实别交给概率性的语义检索,那是让瞎子管红绿灯。

我再给你一个更实用的”选型决策树”,把三类的边界钉死,免得你临场犹豫。拿到一样要记的东西,先问:第一,它有没有固定 schema、能否用 key 精确取到? 能(“环境=staging""禁用=SQLite”)→ 结构化 KV/表,精确存精确取,绝不向量。第二,它是自由文本、且你以后大概率”只记得大意、靠意思找”? 是(复盘笔记、设计决策、对话摘要)→ 非结构化文档存原文 + 向量索引做语义找回。第三,它其实是”结构化事实,但偶尔要理解背景”? 那按 4.4 的思路拆成两份:事实走 KV,背景走文档+向量,用途分流。你会发现,真正”只能向量”的纯语义场景,远少于营销课吹的那么万能——大部分 agent 的记忆,是 KV 管规则、文档管知识,向量只是文档的检索加速器。

我补一个混合架构的反例帮你避坑。有个知识库 agent,一开始”全向量”,连”公司名→官网”这种纯 KV 映射也存向量。结果问”XX 公司官网”,向量召回了一条”XX 公司被收购的新闻”(语义更像”公司”话题),给了错误链接。后来他们做了分层:实体映射(公司名→官网、术语→定义)进 KV 精确查,文章/FAQ 进文档+向量语义搜,查询时先走 KV 命中就直接返回、未命中才进向量。准确率从七成飙到九成九。这把”精确事实 vs 语义内容”的分工用生产指标印证了——你后端那句”合适的存储放合适的数据”,在 agent 记忆这儿一字不差地成立。

还有一层你写后端时天天在用、但放到记忆选型里容易忘的:读写频率决定存储选型。精确事实(KV)是”高频读、低频写”——每次会话都读、难得改一次,所以你要它读取极快、零歧义,KV 正中下怀。自由文档(非结构化)是”低频读、偶发写”,且读的时候要”按意思捞”,所以配向量慢一点也忍了。如果你反过来把高频读的精确事实塞进向量,等于每次决策都额外付一次 embedding 检索的延迟和噪声风险,纯亏。所以选型时先问”这东西被读的频率和多高”,再定存储——又是你做缓存/存储选型时的老常识,平移即可。

我再补一个很多人算漏的账:向量库不是”存进去就完事”,它有实实在在的运维成本和一致性陷阱,而 KV 没有。你往向量库塞文本,得先过一道 embedding(调用模型把文字编成向量),这条链路有成本、有延迟;原文更新了(比如项目约定从”禁用 SQLite”改成”禁用 MySQL”),你得重新 embedding 并覆盖旧向量,否则库里还是旧的——这和你后端”改了主表忘了更新二级索引,查出来是脏数据”一模一样,只是这里的”二级索引”是向量,脏了更隐蔽,因为召回时你只看到”相似的一段文字”,不会想到它对应的是过期 embedding。更麻烦的是,向量库本身不是真相源:真相在你那份原文/主表,向量只是它的影子。一旦你养成”以向量库里的东西为准”的习惯,就等于拿影子当本人。所以工程上的正解是:精确事实的真相源是 KV/主存储,向量的角色永远是把”语义查询”翻译成”该取哪条原文”的路标,取回来还得回主存储核一下原文才是最新的。这套”索引与真相源分离、索引可重建”的纪律,你维护 ES 索引时早就在用,平移到 agent 记忆,毫无新意,只是别被”向量库能存”的错觉骗去把真相也交出去。

【思考题】

  1. 为什么说”向量是索引,不是存储本身”?如果只存向量、不存原文,召回时会出什么问题?
  2. 你代码里”配置项 vs 文章/日志 vs 相似推荐”的存储选型,和这里的三类记忆怎么一一对应?举你最熟的一个例子。
  3. 如果一个事实”既精确(禁用SQLite)又偶尔需要语义联想(‘为什么禁用’的复盘笔记)“,你会怎么存?两种形态要分开还是合并?

【参考答案】

  1. 向量只是文本的一段数学编码(embedding),它本身不包含可读原文,只代表”这段话在语义空间的位置”。如果只存向量不存原文,检索时你拿到的只是一堆向量坐标/ID,没法把”那段资料”摊回模型的工作台——你失去了要喂给模型的真正内容。正确做法是:原文存文档/数据库(带 ID),向量只是”ID 的语义路标”,召回时先捞向量拿到 ID,再用 ID 取回原文喂模型。只存向量等于只存了”目录”丢了”书”,检索到了也读不了。这也是为什么工程里向量库通常和原存储联用,向量负责”找”,原存储负责”给”。
  2. 一一对应:配置项/用户属性(精确 key-value)→ 结构化 KV 记忆,比如”语言偏好=Go”;文章/日志/评论(自由文本、按内容查)→ 非结构化文档 + 向量索引记忆,比如”连接池打满的复盘”;相似推荐(按语义找相近)→ 纯向量语义检索场景,比如”找和当前 bug 相似的过往 case”。你最熟的:Redis 存用户 session(KV,精确取)对应精确事实记忆;ES 存日志支持全文/语义搜对应非结构化+向量记忆。你早就在按数据形态选型,agent 记忆只是把”模型要记的”当成了新的一类数据。
  3. 分开存,且用途分流:“禁用 SQLite”这个精确事实存 KV/固定规则文件,每次必读、绝不走向量(保证铁律一);“为什么禁用”的复盘笔记存非结构化文档+向量,当你或模型问”为啥不能 SQLite”时语义检索调回。两者不矛盾,因为一个是”约束”(要强制执行),一个是”解释”(要理解背景),访问模式和可靠性要求完全不同。合并进向量库只会让约束被语义检索的概率性带偏。工程上就是:规则归规则、知识归知识,别让知识库的噪声污染规则的执行。

小结:记忆分三类——非结构化文档(自由文本,靠语义找)、结构化 KV/表(精确事实,按 key 取)、向量(语义检索的索引而非存储本身)。精确事实走 KV 别走向量(容不得”相似”),自由内容走文档+向量,最忌全丢向量库用概率检索管精确事实。


4.4 真实案例:让 agent 记住你的项目约定

老哥,前面三节把”桌面/档案室""RAG 的坑""存储选型”都铺好了。这一节咱们落到一个你马上用得上的真实场景:怎么让 agent 真正记住你的偏好和项目约定,而且记住得恰到好处——不漏、不烦、不污染。

先给两个分层,这是设计的核心。

user-level(用户级)记忆:跨所有项目都该管的、关于”你这个人”的偏好。比如”老哥用 Go、讨厌水字数""回答要直接给代码不要铺垫""术语第一次出现要给直觉解释”。这些是和你绑定的,不随项目切换而变。

project-level(项目级)记忆:只在这个项目里生效的约定。比如”本项目用 1Panel 运维、禁用 SQLite""API 统一走 gRPC""禁止直接向 main 分支推”。换个项目,这些全作废。

你品这分层的必要:如果把项目约定塞进 user-level,你开新项目时 agent 会拿旧项目的”禁用 SQLite”来约束新项目,闹笑话;如果把 user-level 偏好塞进某个项目文件,你换项目就丢了”讨厌水字数”这个习惯。分层本质是作用域(scope)——和你后端”全局配置 vs 项目配置 vs 请求级配置”三层覆盖,一模一样。

那”什么时候写、什么时候读”怎么定?墨叔给你一张操作表。

写(写入记忆)的时机:不是每次对话都写。该写的是——用户显式纠正了 agent(“以后别用 SQLite”)、或显式声明了偏好(“我是 Go 后端”)、或 agent 在任务中发现了一条可复用的项目事实(首次读到 docker-compose 里写了 1Panel,归纳出”本项目用 1Panel”)。反过来,任务过程里的临时上下文(某次读了哪个文件)、一次性的中间结论,绝不外置——那是短期记忆的命,写进去只会让档案室膨胀。

读(加载记忆)的时机:user-level 在每轮会话开始时轻量加载(相当于工作台永远贴一张用户便签);project-level 在任务明显涉及该项目约定时加载(比如要写代码了,就读”本项目禁用 SQLite”)。关键是按需、精简,绝不全量预载所有记忆——那又回到 4.1 说的窗口污染。

怎么避免记忆膨胀污染上下文?三招。一,分层 + 按需加载,上面讲了。二,记忆也要有”摘要/压缩”:user-level 别存十条例散偏好,归纳成”老哥:Go 后端、反水字数、要深度答案”一句话。三,定期修剪:过时项目约定(比如项目从 SQLite 迁到 Postgres 了)要能删,不能只增不删——很多 agent 的记忆文件越滚越大,最后把窗口吃了。

我给你一段最小 memory 读写示意,你重点看”分层”和”按需”两点:

# 最小记忆读写示意(非可运行)
class Memory:
    def __init__(self):
        self.user = {}      # user-level:跨项目,如 {"lang": "Go", "style": "反水字数"}
        self.projects = {}  # project-level:按项目名隔离,如 {"agent-book": {"no_sqlite": True}}
 
    def write(self, scope, key, val, project=None):
        if scope == "user":
            self.user[key] = val                      # 用户偏好,永久
        elif scope == "project":
            self.projects.setdefault(project, {})[key] = val  # 项目约定,隔离
 
    def load_for_prompt(self, project=None):
        # 按需组装:user 常驻,project 仅当涉及时加载
        mem = "用户偏好: " + str(self.user) + "\n"
        if project and project in self.projects:
            mem += f"项目[{project}]约定: " + str(self.projects[project]) + "\n"
        return mem   # 这段被拼进系统提示的固定段,但只含精简摘要

老哥,你看 write 里 user 和 project 是分开存、按 scope 隔离的,这就是分层;load_for_prompt 里 user 常驻、project 仅当传了 project 才加载,这就是按需 + 作用域。真实系统里它背后是两份存储(用户配置 + 项目 MEMORY.md),循环每轮拼上下文时调 load_for_prompt 拿精简摘要。既保证”记得”,又保证”不啰嗦”。

给你一段真实经验收口。Claude Code 这类工具实际上就是这么干的:它有一个项目级的 CLAUDE.md(或 memory 文件)放 project-level 约定,有一个用户级的全局配置放 user-level 偏好;每次会话把这两份精简后拼进系统提示。新人常犯的错是:把”这次调试的中间发现”也写进 CLAUDE.md,导致文件越来越长,下次 agent 一开门就被一大坨历史噪音糊脸,反而忽略真正重要的约定。正确纪律是:CLAUDE.md 只放”长期、稳定、跨会话有用”的约束;临时发现当场用完即弃,值得沉淀的才手动/显式地写进去。记忆不是越多越好,是越准越好。

我最后点一句连接全 Part 的话:4.1 的”工作台 vs 档案室”告诉你记忆要外置;4.2 的 RAG 教训告诉你外置了也别无脑检索污染;4.3 的存储选型告诉你外置的东西要按形态分家;4.4 这案例告诉你,落到”用户偏好 + 项目约定”这种具体记忆上,就是分层、按需、精简、可修剪八字。你把这四节串起来,agent 的”记得”与”忘记”,就不再是玄学,而是一套你能设计的存储与检索纪律。

我再补一层”写入纪律”的细节,因为这是新手最易翻车的地方。写入记忆有两个雷区。雷区一是静默写入:agent 自作主张把某次临时发现写进项目记忆,用户毫无感知,下次被噪音糊脸。正确做法是写入要走显式信号——要么用户明文说”记住以后禁用 SQLite”,要么 agent 提出”是否要把这条加入项目约定?“等用户确认才写。记忆是会影响后续所有决策的”潜规则”,绝不能偷偷改。雷区二是只增不删:记忆文件只会长大不会 shrink,半年后 CLAUDE.md 变成万字长文,窗口被历史碾压。所以要有修剪机制——项目约定变更时(从 SQLite 迁到 Postgres),新约定覆盖旧约定、旧的那条明确删掉;过时的临时归纳定期清。这和你在数据库里”配置要可更新可下线”是同一纪律,只是对象换成了模型的记忆。

给你一个和真实工具对照的收口,让你看见这章不是空谈。Claude Code 的 CLAUDE.md 就是 project-level 记忆的载体:它放在项目根目录、随项目走、每次会话被精简后拼进系统提示;它的全局用户配置(~/.claude/CLAUDE.md 或 settings)就是 user-level 记忆,跨项目生效。它从不在 CLAUDE.md 里塞”某次调试中间态”,因为那违反”只放长期稳定约束”的纪律——临时上下文活在当次会话的窗口里(短期记忆),会话结束自然消散。你理解了这个,就理解了一个生产级 agent 是怎么把”记忆”这团看似模糊的东西,驯化成一套可维护的存储与加载流程的。这也正好接 Part 5 的”上下文工程”和 Part 8 的”迷你 Claude Code 蓝图”——到时候你会看见,记忆的读写,就是那个循环里”拼上下文”这一步的核心子程序。

我把这套流程端到端走一遍,让你看见”分层、按需、精简、可修剪”在真实一次任务里怎么流动。假设老哥新开一个会话,说”帮我给这个项目加个用户注册接口”。循环第一步加载(read):从 user-level 取”老哥=Go 后端、反水字数”,从 project-level 取”本项目用 1Panel 运维、禁用 SQLite”,两者各压缩成一句话,拼进系统提示固定段——工作台贴上两张便签。第二步干活:模型基于这两张便签决策,写接口时自动避开 SQLite、用 Go 习惯写法。第三步途中发现:模型读到 docker-compose.yml,归纳出”这项目用 Postgres”——这是可复用的项目事实,但它不静默写,而是结束前问你”要把’本项目用 Postgres’加入项目约定吗?“,你确认,才 write 进 project-level。第四步会话结束:本次”读了哪个文件、写了哪些函数”这些临时上下文,随窗口自然消散,不外置——因为下个任务不一定需要。你看,一次任务里,记忆只在”开头加载”和”结尾经确认才写入”两个时刻被碰,过程中绝不滥用。这就是纪律落到时序上的样子。

我再用一个记忆膨胀的反例收尾,强调”可修剪”不是可选项。有个团队让 agent 自动把”每次会话的收获”追加进项目记忆文件,半年后那份文件攒到两万字,下次会话一加载,大半窗口被历史占满,真正的当前任务目标反而挤到中间被模型忽略(又是 lost in the middle),agent 开始答非所问、且频繁违反”禁用 SQLite”——因为那条关键规则被淹没在两万字噪音里。根因就是忘了”记忆要可修剪”:只增不删,等于给工作台无限堆旧报纸。正解是给记忆文件设体积上限和定期压实(把零散条目合并成精简摘要、删除已过时/被覆盖的条目),让它永远保持”小而准”。记忆的价值不在”量多”,在”每次加载都能立刻派上用场”——这和你在 Redis 里给热点 key 设 TTL、防止冷数据占满内存,是同一句老话。

最后给你一个”怎么判断记忆系统健不健康”的体检清单,作为这一节的落点:一,打开一个新会话,只靠记忆(不靠你当场叮嘱),agent 是否正确遵守了你的关键约定?不遵守,说明写入或加载断了。二,记忆文件体积是否稳定在一个小范围?只增不减,说明缺修剪。三,agent 答非所问的比例,是否随使用时间上升?上升,说明记忆噪音在累积污染窗口。这三项是你后面做 eval(Part 9)时,针对”记忆”这个子系统最该盯的指标。能对着这三项自查,你的 agent 记忆就脱离了”玄学”,进入了可观测、可迭代的工程范畴。

【思考题】

  1. user-level 和 project-level 的分层,和你后端哪套”配置作用域”最像?举一个你写过的配置三层覆盖的例子(全局/项目/请求)。
  2. “什么时候写记忆”最容易犯的错是什么?如果 agent 把”某次调试中间发现的临时结论”也写进项目记忆,会发生什么?
  3. 上面那段 Python 里,load_for_prompt 只返回 str(self.user) 这种”整块摘要”。如果 user 记忆膨胀到几十条,你还这么无脑全拼进窗口吗?怎么进化这一步?

【参考答案】

  1. 最像的就是你后端的配置优先级链:全局配置(如框架默认的 GIN_MODE=release)→ 项目/环境配置(如 config/staging.yaml 覆盖全局)→ 请求级上下文(如 context.Context 里挂的单次请求参数)。三层后者覆盖前者,作用域清晰。agent 的 user-level 就是”全局配置”(跟人走)、project-level 就是”项目/环境配置”(跟项目走)、当次任务的临时上下文就是”请求级”(用完即弃)。你早就懂”不同作用域的配置不能混装”,agent 记忆只是把这套作用域意识,用到了”模型该记什么”上。分层错的代价也类似:把项目约定塞进全局,就像把 staging 的库地址写进框架默认值,换个环境就炸。
  2. 最容易犯的错是**“过度写入”——把短期、临时、一次性的东西当长期记忆外置**。如果 agent 把”这次调试发现连接池是 200”写进项目记忆,后果是:第一,记忆文件膨胀,下次窗口被一堆过时噪音占满(4.1 的污染);第二,这条可能只是当时临时调参、并非真约定,agent 之后每次都当它铁律,反而束缚正确决策;第三,它和真正的稳定约定混在一起,真正的约定被稀释。纪律是:只有”稳定、跨会话有用、用户显式或反复印证”的才写;临时发现当场用完即弃,值得沉淀的再显式写入。记忆的”写”比”读”更需要克制。
  3. 几十条还无脑全拼,窗口会被用户偏好吃掉,又犯 4.1 的错。进化方向是给 user 记忆也加”按需 + 检索”:一是把 user 记忆也分”常驻精简摘要”(如”Go 后端、反水字数”一句话)和”详细档案”(按需检索);二是拼窗口前先做”相关性筛选”——根据当前任务只挑相关的几条 user 偏好(写运维脚本才加载”讨厌水字数”可能不相关,写教程才加载);三是设上限,超了就摘要压缩。本质上 user-level 也不能”全量预载”,要和 project-level 一样遵守”按需、精简、可修剪”。这把 4.1~4.3 的原则闭环了:无论哪层记忆,进窗口都得讲纪律。

小结:记忆分层 = user-level(跨项目偏好)与 project-level(项目约定),本质是你后端”全局/项目/请求”配置作用域的平移。写要克制(只存稳定可复用)、读要按需(user 常驻精简、project 涉及时加载)、记忆要可修剪防膨胀——八字:分层、按需、精简、可修剪。