Memory 三层设计:为什么 [“session“,id] 最后写成了四元组(第78篇-E64)

系列「企业级 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 恒注入,永不自动淘汰。「不要给我推荐含酒精的餐厅」这类红线,一旦漏掉一次就是事故。所以它不参与后面几篇讲的衰减淘汰
  • taskvalid_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}

把这段注释翻译成人话:

  1. memories 表有个 user_id 列,NOT NULL 且外键指向 users(id)
  2. 这个列的值不是单独传进来的,而是从 namespace 推导 的------推导函数只认 ns[0] == "user" 这种形态
  3. 所以如果 namespace 写成 ["session", sid],推导出来的 user_id 是 uuid.Nil(全零 UUID)
  4. 全零 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_idNOT 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 三种类型的运行时行为也只做了源码阅读,没有跑通召回链路。

相关推荐
aqi001 小时前
15天学会AI应用开发(二十)使用LangChain实现RAG检索功能
人工智能·python·ai编程
为你学会写情书2 小时前
Agent Skills 完全指南:从目录规范到渐进式加载的工程实践
agent
Ai拆代码的曹操2 小时前
Agent 做错了怎么办?Self-Critique 机制拆解
后端·agent·ai编程
9i编程2 小时前
16G 内存带不动 Milvus:用 opencode 重写私文简搜聊天版(上篇)
人工智能·openai·ai编程
ckjoker2 小时前
我把Java多模态链路从0跑通了,结果先被4个坑狠狠干了一顿
后端·agent
奈斯先生Vector2 小时前
2026 开发者效能革命:基于创源AIGC 与开源生态的工程化实践、安全沙箱与代码智能体协同
人工智能·算法·架构·prompt·aigc
寒蝉1282 小时前
我要的不是更强的 AI,而是更懂我的 AI
ai编程
王烁鑫3 小时前
把 Gemini 写的角色 Prompt 接进小游戏之前:先做一份可回放的语音 NPC 契约
aigc