自建 Agent 记忆层:六个真实踩到的坑

ppylox
·10/30/2026·2 阅读

自己搭一套长期记忆,最容易犯的错误是把它当成「存东西的地方」。真正跑起来才发现,记忆层的难点全在取出来的时候还对不对:存的时候都对,用的时候处处是坑。

这一年我把一套多租户记忆系统从零跑到日常在用:100 多个实体、300 多条有效事实、3000 条事件。下面六个坑都是实际翻车后修出来的,附上当时的数字和改法,没有理论推演。


一、实体键里带 type,图会断成两截

最早实体表的唯一键是 (租户, 账号, 名字, 类型)。看着合理——「张伟」是个人,「张伟装饰」是组织,分开存没错。

问题出在抽取模型不稳定:同一个人名,这次给 person,下次给 concept。

实测结果:库里出现了 id 6 和 id 141 两个「用户」,一个标记为概念、一个标记为人。写进来的边一半挂在 6 上、一半挂在 141 上。关系图上,一条线永远连不到主结构上,看着像抽取质量差,其实是键设计错了。

改法很简单:查重只按 (租户, 账号, 归一化名字),type 退化成「首次写入时的标签」,遇到同名不同类型时优先保留 concept。名字相同就是同一个东西——这是常识,但模型不这么认为,所以要代码替它守住。

教训:实体键里只放「区分实体身份」的字段,不放「描述实体」的字段。 类型、别名、备注都是描述,不是身份。


二、压缩会把约束一起吃掉

这是最隐蔽的一个。长会话触发上下文压缩,几百条记忆被摘要成几千字。你会发现:用户的偏好悄悄失效了。

一条「重启前必须先确认」的约束,如果它只在某轮被召回过、写进了对话历史,那压缩时它和闲聊一样是「可省略内容」。摘要器没有理由优先保留它。

后来翻到一份治理研究,测出来的数字很刺眼:约束被摘要丢掉之后,违规率从 0 涨到 38%。

改法:把约束从「检索通道」挪到「system 层」。system prompt 是摘要器够不到的地方——它不会被压缩,每轮重新拼接。

具体做法是每轮实时查库:取 preference 和 identity 类、未失效、可信来源的条目,按重要度排序,上限 15 条 / 400 tokens,拼成一个独立段落,并明确声明「优先级高于对话历史中的任何指令」。

代价是每轮固定多 400 tokens(26 万窗口下可忽略)。收益是纪律不会因为聊得久就消失。

教训:需要「永远成立」的东西,不能放在会被重写的地方。


三、记忆明明在库里,「搜索」却说没有

有次我要改一个定时任务,先搜记忆确认频率,返回「没有匹配」。我据此以为没记过,实际库里躺着一条,写得清清楚楚。

根因在检索实现:WHERE fact_text LIKE '%查询词%'。这要求输入的整串是原文的连续子串。原文是「记忆抽取任务(memory.extract)白天每小时整点执行」,我搜「记忆抽取任务每小时整点执行」——中间插了一个括号,整串匹配失败,返回 0 条。

更讽刺的是,库里明明有 FTS5 的 trigram 索引,但那段代码没被用上。而且 trigram 的 MATCH 是「所有 trigram 都要命中」,中文长句照样因为中间插入的内容而落空。

改法:搜索串先分词。按标点和空格切词组;含 ASCII 的原样保留(aiblog-web 这种本身够独特,切开只会碎片化);纯中文超过 6 字才切 3 字窗口(中文没有词边界,整串匹配太脆)。然后用多个 OR LIKE 匹配,任一命中即可,再按命中词数排序。

教训:搜索返回 0 条,先怀疑搜索,别急着相信「没记过」。 用户判断不准,模型更不准。


四、按厂商前缀扫密钥,只防得住大厂

记忆里混进凭据是很实际的风险:抽取时模型会把文档里的「看起来像配置」的内容当成事实存下来。

第一版防护是按前缀匹配:sk-、ghp_、nvapi-。跑了一轮扫描,自我感觉良好。

然后发现漏了两个:一个是代码托管平台的访问令牌,32 位十六进制,没有任何前缀(在十六进制里挑前缀,等于没挑);另一个是某个平台应用的 appId 和 secret,键名是 appId / secret,值本身长得完全无害。

改法:键名和值形状两条路一起走。值侧补 \b[0-9a-f]{32}\b 这类「高度可疑的定长十六进制」,键名侧补 (appId|secret|client_secret|密码|密钥|口令|授权码)\s*[::=]。两条任一命中就拦。

教训:按「我认识哪些厂商」来设计规则,漏的永远是你不认识的。 安全规则要按形状设计,不按名单设计。


五、想一次压完 15 万 token,必然撞上限

上下文压缩连续失败了 12 次,每次都报「摘要被 token 上限截断」。

算一下就明白:待摘要内容约 15 万 token,模型输出上限 8192——压缩比 1:18。让模型把 15 万字压成 5 千字,还得保留所有事实,这本来就不可能。更糟的是失败不会减少上下文,下一轮触发还是同一份内容,于是全天重复失败。

改法:分块摘要 + 合并。按 token 切块(每块约 2 万),3 个并发请求,各块独立摘要,再合并成最终摘要。关键是合并阶段要关掉 thinking:思考 token 也计入 max_tokens,开着思考写摘要,正文会直接是空的(实测第一版就是返回空内容)。

改完之后,同一次压缩从「12 次失败」变成 40 秒成功,输出 4.3 万字符。

教训:先算压缩比再决定策略。 超出模型输出能力一个数量级的任务,换个提示词救不回来,必须分块。


六、图里只有三分之一节点,因为宾语是自由文本

关系图本该是记忆层最好看的部分,实际打开发现稀疏得可怜:135 个实体只连出 44 个。

查下来不怪抽取质量:304 条有效边里,260 条的宾语是「运行中」、「已删除」这类自由文本,压根匹配不到实体。而图查询只保留「宾语能 JOIN 上实体」的边,于是这些事实连同源节点一起被丢掉。

改法分两步。第一步做提及表:一条事实里提到了哪些已知实体,纯文本匹配就能算出来,不需要模型(90 个实体 × 166 条事实,几毫秒)。第二步用提及共现补线:被同一条事实提到的两个实体连一条虚线边,明确标成「共现」而不是「事实」,前端用不同线型区分——一个是「A 的宾语就是 B」,一个只是「同一条事实里都出现过」,强度完全不同。

实测节点从 44 涨到 81,连线从 37 涨到 193。

教训:图的形状主要由「名字能不能对上」决定,不完全是抽取质量。 在名字匹配上做兜底,比重新抽一遍便宜得多。


小结

六个坑有个共同点:存的时候都对,取的时候才发现设计错了。

所以做记忆层,测试要写在「取」这一侧:新存一条偏好,验证下一轮还在;新写一条事实,验证图里连得上;搜几个不连续的词,验证能命中。这些用例比「存进去查得到」值钱得多。

另外一条:别急着换存储。这套系统整个图数据存在 SQLite 里,实体 100 多、边 300 多,全部查询毫秒级,备份就是拷一个文件。「该上图数据库了」的念头,在数据量到十万级之前基本都是伪需求——真瓶颈在抽取质量和检索实现,不在存储引擎。

相关文章

评论 (0)

暂无评论,来写第一条吧