群体记忆端到端设计:agent-memory 的多用户多代理记忆隔离、归属判定与跨空间检索实战指南
群体记忆端到端设计:agent-memory 的多用户多代理记忆隔离、归属判定与跨空间检索实战指南
群体记忆(Collective Memory)是 agent-memory 面向「多用户、多代理、多项目协作」场景的核心特性:它把原先「一次调用一个 scope、权限全靠调用方自报」的简单形态,升级为以归属判定自动决定记忆落哪个空间、以空间级两轴权限隔离可见边界、以跨空间检索聚合多空间召回结果的完整体系。读完本文,你将掌握 agent-memory 中记忆归属坐标(coords)、内容轴与治理轴两轴角色、归属主体档、检索两族谓词、跨空间检索编排与存量回填工具的全部设计细节与可运行配置。
从单用户单代理到协作:现状模型暴露的六项缺口
agent-memory 原有的记忆隔离建立在「一次调用一个 scope、scope 由调用方给定」之上。进入多用户、多代理、多项目协作后,该形态暴露六项缺口:
| 能力 | 现状 | 缺口 |
|---|---|---|
| 空间内权限分档 | 成员记录只有一个 role 字符串,条目读写与成员管理共用一档 |
表达不出「能管成员但看不到条目内容」这一档 |
| 空间归属 | 空间只有成员表,不记录它属于谁 | 没有成员记录的个体空间判定看来无人可放行,本人访问自己的记忆要靠额外发放授权 |
| 代理调用与本人调用的区分 | 条目上没有作者位,判定拿不到「这条是谁写的」 | 「谁在写」与「写给谁」不可区分 |
| 条目落哪个空间 | 完全由调用方传入的 scope 决定,内核不判断 |
对话里既有个人偏好又有团队约定时,两者只能落进同一个空间 |
| 跨空间检索 | 一次调用只查一个空间 | 调用方自行循环,逐次判定与结果合并的规则散落在接入层 |
| 主体可访问哪些空间 | list_spaces 走全 keyspace 遍历后逐个过滤 |
成本随全库规模增长,且没有可复用的「主体到空间」查询 |
其中第三项有一条更基础的成因:接入层现状用同一个对象同时表达「谁在调用」与「读写哪个位置」,两者合一时,「代理替用户写」与「用户自己写」在判定看来完全相同,归属对比一步退化为无条件成立。身份与目标的分离是其余能力的前提,不是可选项。
目标模型:归属维与收窄维
设计的核心判据是「换掉哪个坐标这条记忆就不成立,那个坐标即它的归属维」:
| 记忆 | 换 user | 换 agent | 换 project | 归属维 | 收窄维 |
|---|---|---|---|---|---|
| 「User1 的部署环境是 K8s」 | 不成立 | 成立 | 成立 | user | 无 |
| 「User1 让我用简洁风格回复」 | 不成立 | 不成立 | 成立 | user | agent |
| 「project1 的部署环境是集群 A」 | 成立 | 成立 | 不成立 | project | 无 |
| 「所有项目代码必须过 lint」 | 成立 | 成立 | 成立 | 组织,判定不可写入 | 无 |
三条配套规则:恰好一个坐标不成立时该坐标即归属维;多个坐标不成立时取隔离性最强的作归属维、其余降为收窄维;全部成立时归属最外层,但组织空间不接受判定写入,实际落 fallback——一个用户的一句话不足以确立组织级规范,允许写入等于任何人说句话即可影响全组织。
第二条规则的依据是判错后果不对称:落 user 空间加代理收窄时,谓词遗漏只是同一用户的另一个代理看到其偏好,不构成泄露;落 agent 空间加用户收窄时,谓词遗漏会让一个用户的偏好出现在另一个用户的会话中,属跨用户泄露。
归属维落空间,收窄维落标签
越界后果是「内容被另一个主体看到」用空间;只是「上下文混入无关内容」用标签:
| 概念 | 越界后果 | 载体 |
|---|---|---|
| user | 另一个人看到本人的信息 | 空间 |
| project | 成员之外的人看到项目内容,跨用户可见是设计意图 | 空间,兼收窄维标签 |
| agent-team | team 被多用户共用,本身不是可见性边界;做成空间会导致换 team 失忆 | 记录维标签 |
| agent | 同一用户的多个代理服务同一个人,互见不构成泄露 | 收窄维标签 |
| session | 跨会话互见即「记住上次」,是记忆的目的 | 收窄维标签 |
空间与标签两套机制不可合并:空间由授权判定器在调用入口、读数据之前裁决,默认拒绝,判错后果不可逆;标签由检索谓词过滤、在 top-k 截断之前下推到索引侧,默认放行,判错可调整。全用空间的代价是换代理换 team 即失忆、删 user 要遍历所有空间;全用谓词则跨用户的硬边界降级为可选参数,遗漏即泄露。
场景与空间形态
六类典型场景被映射为「个体空间 / 共享空间」两种空间形态:
| 场景 | 空间形态 | 谁可达 |
|---|---|---|
| 用户名下某代理专属记忆 | 个体空间 + 代理收窄维 | 本人及本人经该代理发起的调用 |
| 单用户使用一个 agent-team | 共享空间 | 成员表中的该用户与各代理 |
| 多用户共用一个 agent-team | 共享空间 | 成员表中的多用户与各代理 |
| 关于本人、名下任一代理均可读写的记忆 | 个体空间,不带收窄维标签 | 本人及名下任一代理 |
| 组织内全体共享 | 共享空间 + 组织通配成员记录 | 组织通配命中的全体,接入方只读 |
| 某代理跨用户复用的技能 | 共享空间,代理级配置 | 该代理与组织通配命中的用户 |
设计明确不设用户私密空间、也不设条目级可见性声明:现有覆盖判定是前缀语义,父 scope 某一维为空时不约束该维,因此「用户私密空间」对用户名下的代理同样覆盖成立;「让一部分内容对代理不可见」的诉求由「把内容放进另一个空间」承担。
两个互不相关的开关与四种部署组合
特性由两个开关独立控制,默认全关,这正是灰度上线的起点:
| 开关 | 判据 | 开启后生效的部分 |
|---|---|---|
| 空间治理 | permission.default.target = space_aware |
两轴角色判定、归属对比、归属主体档、检索谓词第一族、空间状态校验扩展至全部入口 |
| 归属判定 | 声明 router 命名空间且判定表非空 |
写入侧 scope.space 可为空、归属坐标生效、候选空间集合、显式 scope 归一、检索谓词第二族、写入边界校验 |
| 组合 | 行为 | 是否合法 |
|---|---|---|
| 两个都不开 | 与改造前一致 | 合法,灰度起点 |
| 只开空间治理 | 空间之间有权限边界,space 为空照旧直写 |
合法 |
| 只开归属判定 | 内容按坐标分流进协作空间,但协作空间没有任何权限边界 | 装配期拒绝 |
| 两个都开 | 完整特性 | 合法 |
第三种组合等于把内容写进一个组织内任意主体可读的位置,方向为放行且从调用侧看不出异常,由 _reject_routing_without_space_authorization 在 build_kernel 末尾主动拒绝,不靠配置注释提示。示例配置 examples/config.yml 对两种开关的启用形态有完整注释说明。
十步判定链与两轴角色
判定实现与判据主体的分离
决策 4 是本方案的骨架:判定拆成两部分——判据主体是不访问存储的纯函数,落在 space_decision.py;宿主负责取空间事实、调用判据、把结论折算成调用方要的形态,落控制层 PermissionManager 的实现 space_aware,与 sqlite / allow_all / routing 平级注册。
从源码可见这一分工的落实方式:宿主 space_aware_permission_manager.py 的模块文档明确写着「本模块只做适配,不含判据」,它通过 PermissionProducer.register("space_aware") 注册,decide 方法把入口名、轴、动作、作者主体、主体次序、空间事实与显式授权结果装配好后,逐轴调用 common.security.space_decision.decide。判据主体的内部返回 (allowed, rule, reason) 三元组(DecisionOutcome),由 check 外层折算为布尔值;步骤按「组织边界在前」的目标次序编排而非照抄既有实现,使跨组织请求得到准确的拒绝原因。
共十步判定,三步为本特性新增,七步沿用上游判据:
| 步 | 来源 | 判据 | 结果 |
|---|---|---|---|
| 1 上下文时效 | 上游 | 身份凭据对 environment.now 是否有效 |
无效即拒绝 |
| 2 主体非空 | 上游 | auth.actor 是否为空 Scope() |
为空即拒绝 |
| 3 管理面角色闸门 | 上游 | 管理面动作按最低角色要求比对 | 终局 |
4 ROOT 放行 |
上游 | 平台级角色 | 放行 |
5 组织级 ADMIN 治理放行 |
本规约 | 组织管理员 + 同组织 + 治理轴 | 放行 |
| 6 组织边界 | 上游 | 调用方组织与目标组织不同 | 直接拒绝 |
| 7 归属对比 | 本规约 | 个体空间、调用方覆盖归属登记、作者标记比对通过 | 放行,仅限内容轴 |
| 8 主体覆盖 | 上游 | scope_covers(actor, resource.scope, principal_path=) |
覆盖即放行 |
| 9 代操作委托 | 上游 | 按委托标识回查 DelegationStore |
命中即放行 |
| 10 归属主体档 + 两轴求值 | 本规约 | 先查归属主体档,未命中再按轴合成动作集合 | 任一命中即放行 |
十步中有五步(1、3、4、5、9)在本特性内无判据来源(组织级角色与委托记录不属本特性范围),除第 3 步外均为「命中即放行」分支,缺失方向是更严格而非更宽松。
内容轴与治理轴:两套正交的角色
权限拆成两条独立的轴,定义在 space_roles.py:
| 轴 | 类型 | 档位 | 作用对象 |
|---|---|---|---|
| 内容轴 | SpaceContentRole |
none / viewer / contributor / editor |
空间内的条目 |
| 治理轴 | SpaceGovernanceRole |
none / manager / owner |
空间本身(成员、策略、状态) |
三张动作矩阵写成代码常量而非配置或存储记录——它是判定的基础规则,不随部署变化:
CONTENT_ACTIONS:viewer→ READ;contributor→ READ / WRITE;editor→ READ / WRITE / UPDATE / DELETECONTENT_ACTIONS_OWN:contributor另有限「本人所写」的 UPDATE / DELETE(editor不在内)GOVERNANCE_ACTIONS:manager→ READ / UPDATE / SHARE / REVOKE_SHARE;owner另加 DELETE(指删空间)
治理轴中间档取名 manager 而非 admin,与组织级 Role.ADMIN 区分——两者作用范围、数据来源与判定位置都不同,同名会在读代码时被当成一回事。两轴角色是空间级的,与组织级 Role(服务端注册表、随身份一次性带入、由角色闸门终局裁决)不是一回事。
入口到轴与动作的映射
全部入口的「判哪条轴、要哪个动作」集中为一张代码常量表 ENTRY_RULES(见 space_roles.py),不散在各方法里——漏配一个入口的后果是它绕开某条轴的判定,散落形态无法一眼核对完整性。典型映射:add/check_write → 内容轴 WRITE;search/list/get/export_space → 内容轴 READ;update → 内容轴 UPDATE;delete → 内容轴 DELETE;get_space/list_spaces/space_usage → 两轴任一 READ;add_space_member/grant → 治理轴 SHARE;remove_space_member/revoke → 治理轴 REVOKE_SHARE;delete_space → 治理轴 DELETE。去重与遗忘两种演进模式取 UPDATE,由鉴权点按模式覆盖默认动作。
鉴权点的职责与两段鉴权
PEP(LocalMemoryAPI)在上游既有职责上增加三件事:空间事实读取、事实投影与属性编排、空间状态校验。条目级入口分两段鉴权,两段目标不同:
| 段 | 目标 scope | 额外属性 | 判什么 |
|---|---|---|---|
| 第一段 | 入参 scope 归一后的空间级 scope | 无 | 调用方对该空间有无该动作的权限 |
| 第二段 | 条目真源 scope(引擎从条目取回) | author_principal |
归属对比的作者标记比对与「本人所写」附加集合 |
search 与 export_space 只有第一段(目标不落到具体条目);list 是两段但第二段不携带作者标记——逐条鉴权的失败形态是抛异常而非过滤,携带后个体空间内只要有一条作者不是调用方的条目整次调用即失败。
归属主体档:个体空间的治理通道
成员表为空的个体空间(如预建的用户主空间),归属主体在第 10 步的第一段按入口清单放行,不折算成动作集合。分两级:
| 级别 | 判据 | 入口 |
|---|---|---|
| 第一级 | 主体维逐维相同,且归属登记只有一项 | update_space、archive_space、set_space_policy、delete_space、list_space_members、add_space_member、remove_space_member、export_space |
| 第二级 | 覆盖即可(如本人经其代理调用),登记项数不限 | get_space、list_spaces、space_usage、get_space_policy |
归属主体档必须排在「主维无记录即拒绝」之前:预建的主空间恒不写成员记录,排在其后则该档整段不可达——用户改不了、删不了自己的主空间,也读不到它的策略与成员表,而条目读写照常通过,症状是「部分能用」且不产生任何用例失败。多归属空间(回填产物)与预建主空间事实形态相同,唯一区别是登记项数,因此第一级另加项数判据:登记多于一项时一律不放行。
归属判定:坐标、类别与空间名
内核三项坐标与接入方声明项
归属坐标(coords)的键集合由内核自带三项 user / agent / session 加部署声明项(coord_entities,如 project / team)构成。内核三项以调用方身份为准、不接受调用方覆盖——reject_kernel_coords 在入口拒绝调用方赋值,kernel_coords(见 principal.py)在装配判定上下文之前以身份覆盖内核三项,全链路只有一份坐标。
判定表:类别、收窄维与空间名模板
判定表落 router 命名空间,解析产物四样:classes / narrow_dims(进判定上下文与检索折算)、tag_keys(供写入边界拒绝调用方占用)、naming(供候选空间集合与 fallback 空间)。未声明该命名空间即不装配判定算子,四样均为空、写入侧 scope 必填、行为与未启用一致。
MemoryClass 一个类别 = 一个归属维 + 一个空间名模板,字段包括 name / owner / space_template / fallback / cross_user / record_only / tag_key 等;NarrowDim 是独立的收窄维,与类别正交(并入类别枚举时类别数为 2^n,拆开后为 n + m)。解析与校验实现于 router.py 的 parse_route_table,加载期十四条校验任一不过即装配失败,典型几条:owner=user 的类别不得 cross_user;cross_user 类别必须有成员来源或已声明脱敏;fallback 类别有且仅有一个且不得 cross_user;空间名模板必须引用该类别的 owner、且各模板不得重复;coord_entities 不得声明内核自带三项;判定标签键不得以归属未决后缀 _unresolved 结尾。
Router 与 Classifier / LayerAnnotator 同构:契约在接口层、实现自注册、不注入即整段跳过。核心落点约束是判定只在候选空间集合内选择(RouteContext.candidates 由 API 层判权后给出),四种降级情形(未装配判定器、判定抛异常、返回条数与输入不符、落点不在候选集内)一律全批落 fallback、不阻断写入,各自的 reason 取值互不相同以保证可区分。每批一次模型调用,不逐条调用——一次抽取常产出 3 到 8 条派生单元,逐条调用会把写入时延放大同等倍数。
判定插入点早于去重:去重按候选自身的 scope 取数并按同 scope 过滤,判定晚于去重会导致在源空间比对、在目标空间落盘。
两条判定路径与降级记录通道
- 派生单元路径(
infer=true同步抽取、procedural=true):判定在构建层、抽取之后;判为丢弃即剔除。 - 结论直写路径(
infer=false且scope.space为空):没有抽取环节、构建层没有插入点,判定由 routing.py 调用构建层Router完成,API 层只把入参内容整体作为一条候选送进RouteContext;判为丢弃时取 fallback——落盘的是调用方给的内容,不能替调用方丢弃。
降级记录按消费者分通道:结论直写路径落审计事件 entry=routing_degraded(含降级条数、本批总数与逐原因计数),派生单元路径落 WARNING 日志——前者是调用方动作的直接结果须可回放,后者是进程级健康信号。
写入候选空间集合与多空间写入
plan_write_targets(见 write_targets.py)算出本次写入的候选空间集合,全程机械计算:候选 = 归属坐标按类别空间名模板渲染出的相关空间 ∩ 调用方有写权的空间。关键规定:
- fallback 必须可写:不在候选集内时返回
fallback=None,鉴权点据此整体拒绝写入——判不准时无处可落; - 上限
SPACE_FANOUT_LIMIT = 8,与检索侧空间数上限同值(同一处取值,分叉即出现「写得进去但检索不到」);达到上限后其余空间不再参与判权(判权要读空间事实并走判定链,是候选集计算唯一的外部调用),截断按渲染顺序而非按写权结果,fallback 排在首位不会被截掉,截断记 WARNING; - 组织空间不进写入候选;
- 原文与消息维护落 fallback 空间,判定只改派生单元的 scope;
- 逐目标空间独立提交,失败项单独返回,不做跨空间回滚;
- 批量入口的判定上下文与判定本身每批各一次——
coords与身份批内恒定、候选集是同一份,逐项各判一次会使Router「每批一次模型调用」的契约整段失效。
middle=true 写入不判定:它的原文按 tier=WORKING 直接建索引、参与检索,交给判定则它落 fallback 且收窄维标签键一个都不落,随后在任何带 coords 的检索里被第二族谓词静默排除。因此按「没请求判定」处置,落点取入参 scope,本次坐标不生效并记 WARNING。
检索两族谓词与跨空间编排
两族谓词分开生成、一次下推
第一族(个体空间系统谓词)由空间事实与调用方身份生成,调用方不可干预;第二族(收窄维谓词)由调用方声明的坐标生成,允许调用方控制不构成越权——空间级读权已由判定裁决过,改写坐标只能在本来就有读权的空间里换一批结果。实现见 space_predicates.py:
- 第一族生成规则(仅个体空间生效):代理自主运行追加
metadata.author_principal == "agent:<id>";用户本人直接调用或经名下代理调用不追加。多归属空间(回填产物)恒追加「作者主体等于调用方」; - 第二族:每个有取值的收窄维生成
system_metadata.<tag_key> IN ["", <value>],坐标缺项不生成谓词(失效方向是放宽,不是越权)。
两族谓词与调用方过滤表达式合成一个 AND 一次下推,在 top-k 截断之前生效,不做召回后二次过滤——二次过滤会让被筛掉的条目白占召回名额,最终返回条数少于请求条数。两族都依赖「键恒存在」:EQ 与 IN ("", value) 两个算子对缺失键都判为不匹配,靠「不写键表示默认值」会静默收窄,因此全部收窄维标签键由判定应用处恒写入(取值为空也不省略),with_all_tag_keys 与 fill_missing_tag_keys 两个落盘不变量由构建层公共函数承担,不放进任何 Router 实现内部。
list 必须注入第一族——不注入则自主运行的代理读得到空间内其他主体所写的条目,且逐条鉴权不能替代谓词(抛异常而不过滤)。
六步跨空间编排:读 identity 的留 API 层,不读的下沉控制层
跨空间检索是六步编排,底层仍是既有单空间召回,落点判据取 S02「不做业务编排逻辑」原文——「移出本层是否还能按 identity 裁决」:
| 步 | 做什么 | 落点 |
|---|---|---|
| 1 定候选空间 | extensions["spaces"] 非空即用它,为空取反查索引结果,截到 space.fanout_limit(缺省 8) |
API |
| 2 逐空间判权与状态校验 | 每个候选取一次空间事实、走一次读权判定 | API |
| 2.5 逐空间谓词 | 授权路由值回注 + 两族系统谓词,逐空间各算一份 | API |
| 3 定取数上界 | 每个通过判权的空间一个取数上界 | 控制层 |
| 4 逐空间召回 | 每空间一次召回,带自己的上界与本空间谓词 | 控制层 |
| 5 合并 | 各空间轮流出一条,按内容去重,凑够 top_k 即止 |
控制层 |
| 6 审计与错误汇总 | 扇出失败逐空间落审计,判权剔除与扇出失败一并进 merged.errors |
API |
第 3—5 步由 cross_space_recall.py 承担,控制层只 import retrieval/cross_space.py 的三个纯函数(allocate_quota / merge / space_error),不 import 引擎实现与检索算子,依赖方向 control → retrieval 无环。它收 recall 回调(引擎门面)与写入侧 write_targets 收 can_write 回调同形;不收 identity、引擎实例与检索算子。
取数上界、轮转合并与并发现状
allocate_quota 给每个空间一个取数上界而非把 top_k 切成定额——空间按归属切分规模天然不均,定额分配的前提系统性不成立,缺口(空间条目少于定额、跨空间重复内容)由 merge 的轮转在合并阶段回收。取数成本由两道上限封住:候选空间数上限(8)与取数总量上限 TOTAL_FETCH_CAP = 400,各空间上界取 min(top_k, 总量上限 ÷ 空间数)。
merge 的轮转在「多样性」与「相关性」之间显式选择多样性:rrf / weighted_rrf(默认 rrf)的分数是纯名次函数、score_max 把每空间最高分归一到同一基准,跨空间不可比,因此某空间的第 1 条会排在另一空间的第 2 条之前。要按相关性跨空间排序,唯一干净的路径是对合并集做一次统一 rerank——merge 是不访问存储、不调模型的纯函数,rerank 需要模型调用,并入会破坏该定位。相关测试见 tests/unit/retrieval/test_cross_space.py 与 tests/unit/control/collective/test_cross_space_recall.py。
单个空间失败不使整次调用失败但必须可见:失败空间产出一条 ChannelError 进 RetrievalResult.errors,channel 取 RecallChannel.SPACE、source 取空间名。空间级扇出失败与判权剔除分两路返回:控制层只交回 (merged, space_failures),不把 space_failures 并进 merged.errors——否则 API 层要为「整个空间挂了」写审计就只能按 channel is RecallChannel.SPACE 过滤,把审计判据绑在控制层的 channel 编码上。
并发现状须如实声明:第 4 步用 asyncio.gather 编排,但当前两个引擎实现(CloudEngine、InMemoryEngine)的 recall 都是不含 await 的 async def,gather 因而顺序执行,实测时延是空间数乘以单空间时延。取得真并发的两条路径(asyncio.to_thread 或引擎层提供真正异步的 recall)都属引擎层改动,在其落地之前不声称并发收益。
成员表、归属登记与主体反查
单键成员表与三类反查索引
成员表由逐成员键改单键存储(决策 11):判定链每次调用都要读成员表,逐成员键的遍历落在全部数据面调用的前置路径上、在部分后端上是全 keyspace 扫描;改单键后成员读取由遍历变一次点读,成本与后端规模、空间内条目数无关。读取侧不做「新键读不到就回落遍历旧键」的兼容——保留回落等于那个遍历永久留在鉴权路径上,必须靠回填把老键合并成新键。
反查索引(SpaceManager.spaces_for,实现见 kv_space_manager.py)只有三类桶键:具名 user、具名 agent、组织通配,不设 user 与 agent 双维桶(双维记录在写入处拒绝)——两轴求值按两维各取最具体记录再取交,一条双维记录会在两维同时命中,取交退化为与自身相交。索引语义按「超集」定义:允许多给(候选集虚大,逐空间判定裁决),不允许遗漏(漏给表现为空间不可见);写入次序主数据先、索引后,删除次序相反。membership 组件(MembershipResolver.facts / spaces_for / invalidate)是正查与反查的统一入口,消费方只有一处:跨空间检索不传 spaces 时的候选空间集合;list_spaces 不用它——靠 grant 显式授权取得读权的主体不在索引里。
缓存失效由 API 层的治理入口下发
空间事实按 (org, space) 缓存整份,TTL 决定授权变更的最大延迟。「空间不存在」同样装填一份,因此建空间也须下发失效(接入层的先查后建会在建之前装填该形态,不清则新空间在一个 TTL 内判定无归属、无成员)。失效不放在空间管理器内部——后者与事实解析模块构成构造期环,且匿名构造会产生两份实例使失效静默无效;代价是绕过 API 层直接操作空间管理器时不触发失效,滞后至多一个 TTL,该形态只应用于装配期与运维脚本。
配置契约:判定实现装配与判定表示例
判定实现经 authorizer 顶层段选择,与 standard 互斥:
authorizer:
default:
target: space_aware # 默认实现由 standard 改为 space_aware
params:
grant_store: default # 两个 Store 都无默认实现,与 standard 一致
delegation_store: default
membership:
default:
target: kv
membership 与 space / policy / permission 平级、一律装配,不按判定实现的 requires_space_facts() 按需装配——判定实现声明不需要空间事实时鉴权点确实不调用它,但跨空间检索第 1 步的候选空间反查不经判定实现,按能力声明跳过装配会让未开空间治理的部署静默拿到空候选集。
判定表完整可运行示例见 examples/config.yml 末节(启用时须一并换成 engine.target: cloud,因为默认的 in_memory 引擎只接受 scope.space == ""):
router:
default:
target: keyword_demo # quickstart 演示桩;生产改 llm
params:
coord_entities: [project, team]
memory_classes:
- name: user_memory
description: 关于用户本人的事实、偏好与习惯,换一个用户就不成立
owner: user
space_template: "u_{user}"
fallback: true # 兜底落点,有且仅有一个
- name: project_memory
description: 关于某个项目的事实、决定与约定,换一个项目就不成立
owner: project
space_template: "p_{project}"
cross_user: true
members: 项目参与者
- name: team_memory
description: 关于某个 agent-team 的协作方式,换一个 team 就不成立
owner: team
record_only: true
tag_key: team_id
narrow_dims:
- entity: agent
tag_key: agent_id
question: 这条记忆是否只对这一个代理成立?
- entity: session
tag_key: session_id
question: 这条记忆是否只在这一次会话内有意义?
两个配套策略(加进 globals.policies 段):space.auto_create_fallback(默认 true,fallback 空间不存在时按调用方身份自动创建并登记归属;关闭即回到硬前置,接入方须在用户开通时预建主空间,否则新主体首次写入失败)与 space.fanout_limit(缺省 8,一次调用参与的空间数上限,写入侧与检索侧同取)。
空间开通契约与自动创建边界
调用方指定的空间必须先经 create_space 注册才能往里写记忆——这不是新增的存在性校验,而是判定生效后的结果:鉴权的两个判据(归属登记与成员记录)都存放在注册记录里,未注册时两者皆空,四条放行路径全部落空。scope.space 为空时的 fallback 空间是唯一例外:不存在即按调用方身份自动创建并登记归属。判据是「空间名由谁决定」——fallback 空间名由内核用调用方自己的身份渲染,别的主体渲染不出它、没有抢占面;而调用方传入的空间名是任意字符串,内核只有「坐标 → 空间名」的渲染方向、反解不出它归谁,登记给写入者即先写入者占有该空间名。
自动创建四条边界:只建 fallback(协作空间不自动建,共享形态成立的唯一条件是成员表非空,而内核不产生成员记录);只处理「不存在」(空间存在但调用方无写权时不创建,照常拒绝,否则任何一次写入都能把归档撤销);走空间管理器而非 create_space 入口(内核代行的动作,不该按调用方身份过一次建空间的闸门,仍落一条审计事件);空间策略取默认值。
五类空间的开通形态定为契约,执行方是接入方服务侧:
| 空间 | 建立时机 | 归属主体登记 | 开通时写入的成员记录 |
|---|---|---|---|
| 用户主空间 | 用户开通 | 该用户 | 无,保持个体形态 |
| project 协作空间 | project 创建 | 不登记 | 逐参与者一条具名 user 记录,项目负责人治理轴取管理员 |
| team 跨用户空间 | agent-team 创建 | 不登记 | 逐参与用户一条具名 user 记录,内容轴可贡献 |
| agent 跨用户空间 | agent 注册为可跨用户复用 | 不登记 | 一条组织通配记录内容轴可贡献 + 该 agent 一条具名 agent 记录 |
| 组织空间 | org 开通 | 不登记 | 一条组织通配记录内容轴只读,写入只走运维通道 |
五类空间的建立都要求组织管理员角色,该角色只发给服务身份,不配给任何最终用户——发了即等于该用户对本组织全部空间持治理权,治理轴整体失效。SpaceSpec.owner 有三种形态:None 由 API 层填入调用方身份的推导值、主体维全空的 Scope() 显式不登记(四类共享空间)、具名主体替该主体登记(开通服务预建用户主空间);API 层须校验同组织、单维非空与「非组织 ADMIN 不得填与调用方身份不符的主体」。
破坏性变更与存量回填
三项破坏性变更:
| # | 变更 | 现状 | 改后 | 破坏性表现 |
|---|---|---|---|---|
| 1 | 成员表存储形态 | 每条成员记录各占一个键,按前缀遍历读取 | 整张表存进一个键,一次点读 | 回填前写入的成员记录读不到,共享空间退化为个体空间 |
| 2 | 条目存放位置的键 | 条目 scope 带主体维 | 条目一律存在空间级的键下 | 回填前条目还在老键下,新查询找不到 |
| 3 | 写入不存在的空间不再自动建空间 | 任何调用方写任意空间名都成功 | 视同一次建空间,须持组织管理员角色 | 新用户开通后没预建主空间,首次写入被拒 |
回填须在装配空间感知判定实现之前完成,逐空间一次遍历完成四项动作:按条目 scope 主体维推导归属登记、由条目 scope 主体维推导作者标记写入 metadata、清空条目 scope 的主体维与会话维、逐成员键合并为单键。第三项不只是「搜不到」的可用性问题,而是判定正确性的前提——其失效方向为放行:条目按新查询形态搜不到,且判定第 8 步按各维相等放行,作者对自己写的条目取得全部动作权、不经内容轴矩阵,症状「部分能用」比一律拒绝难发现得多。第三项也不是字段更新而是物理键搬迁:条目 scope 同时是存储的命名空间键,须逐条「写新 → 校验 → 删旧」,删旧作为最后一遍独立扫描执行以保留回滚窗口。
交付物为 backfill.py,五个子命令:
python -m deploy.migration.backfill backfill --org acme [--space coding] [--dry-run]
python -m deploy.migration.backfill provision-main-space --org acme --principals p.txt
python -m deploy.migration.backfill rebuild-registry --org acme
python -m deploy.migration.backfill rebuild-index --org acme
python -m deploy.migration.backfill audit-shape --org acme
全部子命令支持 --dry-run(只报不写)与 --config <yaml>(与服务同一份装配配置);audit-shape 的 unmigrated_entry_scopes 为空即该 org 回填完成,可作装配前的放行判据。回填脚本以具名的运维账号加平台级角色运行,不用空身份——空身份在上游是直接拒绝,且具名主体使回填动作在审计里可归因到执行人。停写窗口的长度是本方案最大的上线风险,须在实施前于生产同构环境实测定量、按 P99 估算并在预发环境演练一次回滚。
验证:主链路与分能力用例
验证分两层:主链路是一条从头走到尾的完整场景,验「群体记忆能不能用」;分能力用例逐项覆盖边界与异常。主链路九个步骤(M1—M9)覆盖:开通建主空间与归属登记、一次写入按内容分流(个人偏好落主空间、团队约定落团队空间)、团队内跨用户共享、个人空间不因同团队而外泄、换代理不改变可达性、坐标缺项的放宽失效、退出即失效(撤销即时生效)、跨组织硬拒绝、归档空间的读写受限与运维通道保留。
最容易漏掉的用例集中在「失效不产生异常」与「不报错的少给 / 静默的多给」两类:归属登记确实落盘(不落盘时症状与回填未完成完全一样)、跨组织硬拒绝(须先配一条本来会命中的授权记录)、坐标缺项(只多召回,须对比条数发现)、可见范围不扩大(须主动比对升级前后结果集)、用户所写内容名下代理可读、归属主体管得了自己的空间、列举入口的隔离、条目 scope 未迁移不放行——这些都需要正面断言才能捕获。环境前提是测试环境要能按需构造不同形态的调用身份(用户本人 / 经代理 / 组织管理员 / 代理自主运行 / 平台方运维 / 另一组织用户),否则验不出隔离。
已知遗留与边界
设计明确列出本期不做的项:条目级可见性(随安全横切契约可信代理身份一并引入)、跨空间流动与溯源、轨迹分段作者位、后台演进通道的判定(判定上下文经瞬态 metadata 键传递、落盘时按瞬态键清单过滤,后台任务从存储重新读取原文后该键已不存在)、授权记录存取的契约化(GrantStore)。实体删除后的清理不由内核编排,只交付 DeleteSelector.filters 谓词能力——唯一有合规驱动的场景(删 user 的数据主体删除权)的谓词语义是「谁写的」而非「关于谁的」,且内核的反查索引是当前成员关系的派生而非历史足迹,业务系统保有的历史参与记录反而更全,因此级联删除由接入方按业务实体关系逐个调 delete 完成。
从源码结构看,本文涉及的核心实现均已落地并可追踪:判据主体在 space_decision.py、两轴角色矩阵在 space_roles.py、身份推导与比较在 principal.py、归属判定算子与判定表在 router.py、写入候选集在 write_targets.py、跨空间扇出在 cross_space_recall.py 与 retrieval/cross_space.py。相应的单测覆盖集中在 tests/unit/common/security/test_space_decision.py、tests/unit/construction/test_router_table.py、tests/unit/control/collective/test_cross_space_recall.py、tests/unit/retrieval/test_cross_space.py 与 tests/unit/api/test_space_aware_authorization.py,可供读者按模块深入。