系列「企业级 AI Agent 实现拆解」E64 篇,Part 14 记忆篇第二章。上一篇留了个尾巴:「我叫张伟」和「帮我查报销截止时间」混在同一个数组里,同生同灭------要么一起留着浪费 token,要么一起删掉丢失用户画像。
这篇讲怎么把它们分开。因为 Eino 没有官方 memory 组件(上一篇讲过),这篇的源码取材于 DeepFlux 的 memory 限界上下文。
看点 :设计文档说 session 层的命名空间是
["session", sid],但实际代码写的是["user", uid, "session", sid]四元组------中间隔着一条外键约束。这个妥协过程,比设计本身更有教学价值。
读完这篇你会知道
- 三层不是三个枚举值,是变长的层级 tuple,理由是「无需改 schema 即可扩展」
- 分层不靠用户选,靠记忆类型自动决定 :
fact可能进项目层,preference一定在用户层- 9 种记忆类型里有三个是特殊公民:
identity不走相似度、constraint恒注入永不淘汰、task过期即消失- 一条
NOT NULL + FK约束怎么把三元组逼成了四元组,源码注释里连 PostgreSQL 错误码都写了- 层级匹配实测三种写法:精确相等 、前缀切片 、包含运算符------其中一种会造成跨层越权
- SQL 里的
namespace[1]和 Go 里的ns[0]是同一个东西(数组下标 1-based 的坑)
一、为什么非要分层
上一篇那个最简版,所有消息都在一个数组里。问题在于这些内容的生命周期完全不同:
| 用户说的话 | 该记多久 | 谁能看到 |
|---|---|---|
| 「我叫张伟,在财务部」 | 一直记着 | 这个用户的所有会话 |
| 「回答我的时候用中文」 | 一直记着 | 这个用户的所有会话 |
| 「我们这个项目用的是 Go 1.26.5」 | 项目存续期间 | 只在这个项目里 |
| 「帮我查一下报销截止时间」 | 这次会话结束就没用了 | 只在这次会话 |
混在一起存,就只有两个选择:全留着 (每轮对话都把三个月前的闲聊塞进 prompt),或者全删掉(用户下次来又得重新自我介绍)。
分层就是给每条记忆挂一个「作用域」,让它们各自过期、各自可见。
二、不用枚举,用层级 tuple
第一个设计决定藏在包注释里:
go
// Package model · Memory 聚合 · 层级命名空间记忆
// namespace 约定(非 enum,层级 tuple,无需改 schema 即可扩展):
//
// ["user", userID] --- 全局用户偏好/事实
// ["user", userID, "project", projectID] --- 项目级事实(仅当前项目可见)
// ["agent", agentConfigKey] --- Agent 专属知识
// ["session", sessionID] --- 会话摘要(短期)
关键是那句「非 enum,层级 tuple,无需改 schema 即可扩展」。
如果用枚举------比如加一列 scope varchar CHECK (scope IN ('user','agent','session'))------那明天产品说「我们要按部门共享记忆」,你就得改 CHECK 约束、改枚举类型、改所有 switch 语句、跑一次迁移。
用变长数组就不用:["dept", deptID] 直接写进去,表结构一个字不改。
对应的列类型就是一个 PostgreSQL 数组:
sql
namespace text[]
这个取舍很典型:枚举给你编译期检查,数组给你扩展性 。这里选了后者,代价是「层名写错了没人拦你」------["usr", uid] 和 ["user", uid] 在数据库看来都是合法值,但它们永远查不到对方。所以这类设计必须配一个统一的构造函数,不能让调用方手拼数组。下一节那个函数就是干这个的。
三、分层不是用户选的,是记忆类型决定的
这是我读这套代码时觉得最值得学的一点。
很多人设计记忆系统会做成「写入时传一个 scope 参数」。但实际上用户和上层业务都不知道该选哪层------「我住北京」该存哪?「这个项目用 Go」该存哪?让调用方决定,就等着不一致。
这里的做法是一个函数说了算:
go
// namespaceForKind · 按记忆类型 + 是否有 project_id 决定 namespace 路径
// - fact / experience → 有 project_id 则项目隔离 ["user",uid,"project",pid],否则 ["user",uid]
// - preference/skill/goal/constraint/task → 跨项目有效 ["user", userID]
func namespaceForKind(in ExtractSessionInput, kind model.MemoryKind) []string {
类型决定作用域,逻辑是这样的:
fact(客观事实)/experience(过往做法) :可能是项目相关的。「这个项目用 Go 1.26.5」只在那个项目里成立,所以有project_id就往项目层放preference(偏好)/skill(方法)/goal(目标)/constraint(红线)/task(待办):跨项目有效。「回答用中文」这件事不会因为换了项目就变,所以恒定在用户层
这样一来,调用方只需要说清楚「这是一条什么类型的记忆」,作用域是推导出来的。判断标准从「你想存哪」变成了「这句话的性质是什么」------后者客观得多。
9 种类型里,三个特殊公民
记忆类型一共 9 种,源码注释里有三个的处理方式跟其他都不一样:
go
KindIdentity MemoryKind = "identity" // 用户是谁(走 Profile,不经 Memory 相似度)
KindConstraint MemoryKind = "constraint" // 红线/禁止事项(恒注入,永不自动 supersede)
KindTask MemoryKind = "task" // 未完成事项(短生命周期,valid_until 过期即不召回)
identity不走相似度检索。 「用户叫张伟」这种事实不该靠语义匹配去捞------用户问「帮我订个会议室」时,「张伟」和这句话相似度很低,但你恰恰需要知道他是谁。所以它走独立的 Profile 通道,每次都直接读出来,不参与召回排序constraint恒注入,永不自动淘汰。「不要给我推荐含酒精的餐厅」这类红线,一旦漏掉一次就是事故。所以它不参与后面几篇讲的衰减淘汰task有valid_until,过期即不召回。「下周三前把报表交了」,过了下周三这条记忆就是噪声
**这三类的存在说明:「记忆」不是一种东西。**把它们塞进同一套召回逻辑(都按相似度排序、都按时间衰减),必然有一类会被搞坏。
四、一条外键约束,把三元组逼成了四元组
现在讲开头说的那个反差。
设计文档里 session 层写的是 ["session", sessionID]。但实际代码是这样的:
go
case model.KindSummary:
// Phase C:四元组而非 memory-engine-v2 §3 原文的 ["session",sid]------memories.user_id
// 由 namespace 推导(userIDFromNamespace 仅认 ns[0]=="user")且 NOT NULL + FK→users(id),
// ["session",x] 会得 uuid.Nil 而违例(23503)。"user" 前缀保住 user_id,session 段仍
// 使召回域收窄到本会话;召回侧 planRecall 对称构造同一 tuple(精确匹配,必须逐元素一致)。
if in.SessionID != "" && in.UserID != "" {
return []string{"user", in.UserID, "session", in.SessionID}
}
return []string{"user", in.UserID}
把这段注释翻译成人话:
memories表有个user_id列,NOT NULL且外键指向users(id)- 这个列的值不是单独传进来的,而是从 namespace 推导 的------推导函数只认
ns[0] == "user"这种形态 - 所以如果 namespace 写成
["session", sid],推导出来的 user_id 是uuid.Nil(全零 UUID) - 全零 UUID 在
users表里不存在 → 外键违例,PostgreSQL 错误码 23503
于是设计里干净的三层,落地时被改成了:前面挂一个 ["user", uid] 保住外键,后面再跟 "session", sid 收窄范围。
这个妥协其实相当漂亮:
- FK 约束保住了:user_id 有真实值,删用户时能级联,RLS 也能按 user_id 过滤
- 会话隔离也保住了 :四元组和
["user", uid]不相等,所以查用户级记忆时不会捞到会话摘要
**但它是个妥协,不是原设计。**如果你只读设计文档,会以为 session 记忆是 ["session", sid];只有读代码才知道是四元组。这类「文档和实现的偏差」在真实项目里非常常见,而这里做对的一件事是:把偏差的原因写在了紧挨着代码的注释里,连错误码都留了。三个月后有人想「简化」成三元组,看到这段注释就会停手。
写代码时留下「为什么不那样做」,比留下「这样做了什么」更值钱。 后者代码本身就能读出来,前者只存在于当时那个人的脑子里。
顺带说,那个 userScope 函数也印证了同一件事:
go
// 非 user 根(["agent",x] / ["session",x] / 残缺)返回 nil,调用方据此跳过------这些 namespace
// 既无稳定的 user 归属,写入也会撞 memories.user_id FK。
所以严格说,这套设计里 agent 层和 session 层都不能独立存在,必须挂在 user 下面。四层设计文档,落地成了「user 一棵树」。
五、层级匹配的三种写法,有一种会越权
注释末尾那句「精确匹配,必须逐元素一致」值得单独讲,因为它决定了你怎么查。
我建了张临时表实测三种写法。数据:
ini
id=1 {user,u1}
id=2 {user,u1,session,s1}
id=3 {user,u1,project,p1}
id=4 {session,s1}
写法一:精确相等(这套代码实际用的)
sql
SELECT id, ns FROM m WHERE ns = ARRAY['user','u1'];
sql
id | ns
----+-----------
1 | {user,u1}
只命中 1 条。 查用户级记忆时,会话摘要(id=2)和项目事实(id=3)都不会被捞出来------这正是隔离想要的效果。
代价是:写入侧和召回侧必须构造出一模一样的数组 。差一个元素、顺序不同、多一个空字符串,就查不到。所以源码里写入用 namespaceForKind、召回用 planRecall,两边必须对称------这也是注释里专门点出「召回侧 planRecall 对称构造同一 tuple」的原因。
写法二:前缀切片(想要层级语义时)
sql
SELECT id, ns FROM m WHERE ns[1:2] = ARRAY['user','u1'];
sql
id | ns
----+----------------------
1 | {user,u1}
2 | {user,u1,session,s1}
3 | {user,u1,project,p1}
3 条全中。 这才是「这个用户名下的所有记忆」------包括他各个项目、各次会话的。想做「用户注销时删除全部记忆」这种操作,就得用这个写法。
写法三:包含运算符------有坑
sql
SELECT id, ns FROM m WHERE ns @> ARRAY['user','u1'];
在上面那份数据上,@> 的结果和写法二完全一样,看起来可以互换。但加一条数据就露馅了:
ini
id=5 {agent,a9,user,u1} -- agent 层的记忆,只是恰好带了这个用户的 ID
sql
--- 前缀切片:id=5 不命中(user 不在开头)---
1 | {user,u1}
2 | {user,u1,session,s1}
--- @> 包含:id=5 也命中了 ------ 语义是无序集合 ---
1 | {user,u1}
2 | {user,u1,session,s1}
5 | {agent,a9,user,u1} ← 越权了
@> 的语义是「无序包含」,不是「前缀匹配」。 它只问「这两个元素在不在里面」,不管在什么位置。于是 agent 层的记忆被捞进了 user 层的查询结果。
这个坑很阴:
- 小数据量下两种写法结果一致,测不出来
@>还能走 GIN 索引,性能更好,很容易被当成「优化」换上去- 出问题时表现为「偶尔多出来几条不相关的记忆」,而不是报错
记住:数组的 @> 是集合语义,层级前缀要用 ns[1:n] 显式切片。
六、一个下标陷阱:namespace[1] 到底是第几个
统计代码里有这么一行:
go
ByScope map[string]int `json:"by_scope"` // namespace[1] 聚合:user/agent/session
对应的 SQL:
sql
GROUP BY namespace[1]
我第一眼以为这是个 bug------Go 里 ns[0] 才是层名(["user", uid] 的 "user"),怎么 SQL 里聚合 [1]?
实测:
sql
id | sql_idx1 | sql_idx2
----+----------+----------
1 | user | u1
4 | session | s1
PostgreSQL 数组下标从 1 开始。 SQL 里的 namespace[1] 就是 Go 里的 ns[0],注释没写错,是我差点误判。
这个细节值得记,因为它天天在同一个项目里制造混乱:同一个字段,Go 代码里叫 ns[0],SQL 里叫 namespace[1]。写迁移脚本或者对着日志排查时,很容易差一位。
七、演进史:这套设计不是一次到位的
看迁移 000019 就知道,最早根本不是这样:
sql
-- 数据迁移:从现有 tags + user_id 重建 namespace
CASE
WHEN tags @> '["__scope:agent"]'::jsonb THEN ARRAY['agent', user_id::text]
WHEN tags @> '["__scope:session"]'::jsonb THEN ARRAY['session', user_id::text]
WHEN tags @> '["__scope:project"]'::jsonb THEN ARRAY['project', user_id::text]
ELSE ARRAY['user', user_id::text]
END
第一版是用标签模拟作用域的 :往 tags 数组里塞一个 __scope:agent 这样的魔法字符串。
这是个很常见的起步姿势------不想改表结构,先用现有的字段凑合。双下划线前缀是在说「这不是用户的标签,是系统的」。
它能跑,但问题也明显:标签是无序集合,没法表达层级;魔法字符串靠约定,写错了不报错;想查「某用户所有记忆」得做字符串匹配。所以后来单独立了一个 namespace text[] 列。
同一个迁移里还有一句更值得玩味的注释:
sql
-- ScopeProject 记忆的 user_id 列存的是 project_id(Phase 1 行为)
user_id 这一列,在项目级记忆里存的其实是 project_id。 一列两用,靠 tags 里的标记区分。这是典型的「先跑起来再说」留下的债。
这条演进线很有代表性 :tags 魔法字符串 → 独立 namespace 数组 → 撞上 user_id 的历史包袱 → 妥协成四元组。真实系统的设计很少是想清楚了一次写对的,多数是这样一层层长出来的。读代码时看到别扭的地方,先查 git 历史和迁移,往往能找到它别扭的理由。
小结
- 三层用变长 tuple 不用枚举,理由写在包注释里:无需改 schema 即可扩展。代价是层名写错没人拦,必须配统一的构造函数
- 作用域不是调用方选的,是记忆类型推导的 :
fact/experience可能进项目层,preference/skill/goal/constraint/task恒在用户层。判断标准从「你想存哪」变成「这句话是什么性质」 - 9 种类型里三个特殊 :
identity不走相似度(走 Profile 直读)、constraint恒注入永不淘汰、task过期即不召回。「记忆」不是一种东西 ["session",sid]落地成了["user",uid,"session",sid]:因为memories.user_id是NOT NULL + FK,且由ns[0]=="user"推导,三元组会撞 23503。前缀保外键,后缀保隔离- 注释里写清「为什么不那样做」(连错误码都留了),比写「这样做了什么」值钱------它能拦住三个月后想来「简化」的人
- 层级匹配三种写法 :精确相等(隔离最强,两侧必须对称构造)、
ns[1:2]前缀切片(真层级语义)、@>包含(无序集合语义,会跨层越权,小数据量测不出来) - PostgreSQL 数组下标从 1 开始 :SQL 的
namespace[1]= Go 的ns[0] - 演进史 :tags 里塞
__scope:agent魔法字符串 → 独立namespace text[]列 → 撞上user_id一列两用的历史债 → 妥协成四元组
下一篇讲记忆多了怎么办:衰减公式怎么设计、什么样的记忆该被淘汰、以及为什么 constraint 类型要被排除在淘汰之外。
代码状态说明:
**本篇是源码解读,没有跑完整 demo。**引用的所有 Go 代码、SQL 迁移和注释均来自 DeepFlux 仓库既有实现(
server/internal/memory/、迁移000019),是项目原有产物,不是我为本文写的。第五、六节的 SQL 是真跑的 :在本地 PostgreSQL 18.4 上建临时 schema 实测了数组下标、三种匹配写法及那个越权反例,输出原样粘贴,跑完已
DROP SCHEMA CASCADE,未触碰业务表。没有验证的部分 :第四节那个外键违例(23503)我没有实际触发过,是根据源码注释和表约束的转述;
identity/constraint/task三种类型的运行时行为也只做了源码阅读,没有跑通召回链路。