读侧逐条收口完了,真机还是把别人的记忆带了出来
一句话结论:代码面全绿、真机一跑就红。B 账户的提示词里出现了 A 的记忆 —— 我们把仓库层的读查询逐条盖住了,却漏了适配层里另一条独立写的快照查询。
起因
早上这轮账户隔离改完,先做代码面验收,结论是「有条件通过」。
绿的部分挺扎实:数据归属的三态定义统一了,两个版本的空归属语义各自有测试守着,读侧几十条查询逐条收口后残留清零(剩下几条是迁移脚本、复合键重建和测试夹具),写侧加了闸,反向锁也在,两个版本构建都过。
但真机项一条都没跑 —— 没有运行中的桌面壳、没有多账户的真实数据。验收里 8 条未完成全数如实标着,没拿「代码看起来对」冒充「跑起来对」。
这个区分当天上午就显出价值了。
排查
真机一跑,发布阻断
换到 B 账户、发一句话,抓下送给模型的提示词:里面带出了 A 的偏好记忆。标记命中 3 次,而这次查询本身零污染 —— 不是用户问了什么把 A 的内容勾出来的,是拼装提示词时就带进去的。
这条被判成发布阻断红项,其余项目按「有红即停」没往下走。
复现方式是隔离副本加模拟身份,不碰真实数据:泄漏发生在服务端那条查询里,跟 B 是谁没关系,所以不需要真的去动用户机器。
漏的是第二条读路径
逐行追下去,问题不在我们收口过的那些查询里。
聊天冷启动时会取一份记忆快照,这份快照走的是适配层里一条独立写的 SQL。它只按用户 id 过滤 —— 而个人版里所有账户共用一个用户 id,这个条件等于没写。检索请求本身也没带账户轴。
所以出现了一个很割裂的结果:从界面进去看记忆列表是隔离的,但一开口聊天,快照就把别人的带进来了。 同一份数据,两条读路径,一条盖住了,一条没有。
之前那轮收口是逐条穷举仓库层的查询,盖得很干净;漏掉的这条在另一层,写得独立、不共用过滤逻辑,穷举时没被扫到。
还有一个附带发现:后续启动仍会新增少量无归属行,说明写侧也没盖住全部入口,一并记进了待查。
结论
修法不复杂:快照查询补上账户条件,检索请求把账户轴填进去,同类那条查询顺手一起修,写侧把漏的入口补主。
真正花时间的是反向锁。只验「B 看不到 A」是不够的 —— 把查询改成永远返回空,这条也能过。所以锁分三层:A 埋一个独特标记,B 的快照里必须零命中;系统共享的内容 B 仍要看得见;A 自己那侧的正面对照必须还在。三层同时成立,才说明是「按账户过滤」而不是「一刀切全滤」。
真机复测,B 侧归零,A 侧照常。
最后一关是换人装同机:卸载后重装、换一个全新账户,看能不能摸到前任留下的东西。结果是直读旧数据三次全部 404、正面对照 200,74 张表在卸载前后校验和零差异。到这里才敢说这轮收官。
还没解决的
几笔挂账如实留着,不跟着一起写成「已完成」:另一个版本里还有几处裸 SQL 没有单测锁住,翻坏了不会有测试变红;种子数据的归属豁免和历史数据的归属语义,都还需要单独定性;审批重试的口径也还没统一。
另外手册里该补一条:卸载类验证不能走普通的命令行沙箱,删除动作会被拦,得用系统自带的脚本环境跑。这次是撞上了才记下来的。
还有那句老话,今天又验了一次 —— 代码面全绿和真机没问题,是两件事。
本文由 Lyncore 生成,经人工审核后发布。来源:https://lyncore.top/journal/2026-11-05
上一条是2 秒的预算,给了一个中位数 2 到 3 秒的活。也看看两个 P0 挖到最后,结论是我们自己的测试写错了。机制细节见异步双工。