11.3 记忆治理:TTL、隐私与按用户删除
前两节把记忆做成了一个能写、能用的服务,但服务跑起来才暴露真正棘手的问题:记忆是用户数据。用户随口说过的一句话,可能包含卡号、密钥、健康状况;一年前的一条任务态记忆,今天已经毫无意义却还在占检索位;用户行使「删除我的数据」的权利时,如果只删了主表却漏了向量索引和会话,等于没删。这一节把治理补上——它不是锦上添花,而是能不能合规上线的分水岭。
本节把 ResearchCopilot 推进到「可治理的记忆服务」:TTL 自动清理、写入侧隐私拒绝清单、按用户级联删除 + 审计,并真连本机 Postgres 跑一次完整的删除请求。
11.3.1 记忆治理的三个问题
治理不是抽象口号,它落在三个具体问题上,每个都有明确的失败模式:
| 问题 | 不治理的后果 | 机制 |
|---|---|---|
| 记忆会过时 | 过期信息污染检索,模型答错 | TTL:到期自动失效并清理 |
| 记忆会越界 | 密钥、卡号、健康信息被长期留存 | 写入侧拒绝清单 + 隐私分级 |
| 记忆要能删 | 「删除我的数据」删不干净,合规事故 | 按用户级联删除 + 审计留痕 |
三者有一条共同的设计原则:治理必须发生在写入侧和存储侧,而不是指望事后人工发现。 敏感内容在写入那一刻就该被挡下,而不是等它进了库再扫描;过期在存储层用 TTL 表达,而不是靠定时任务去猜哪些该删。防线前移,代价最小。
为什么强调「不是事后」?因为事后补救的每条路都更贵:进了库再扫,你要在成千上万条记忆里跑分类器,还得保证不漏;靠人工巡检,迟早漏掉一条卡号;靠「用户自己发现并要求删除」,用户的信任已经受损了。相反,写入侧一行正则、存储侧一个 TTL 字段,把成本压到接近零。治理的性价比完全取决于它在链路的哪一端——越靠前越便宜。这也是为什么本节把三件事都放在「记忆服务」这一层,而不是留给下游的合规团队。
11.3.2 TTL:什么会过期,过期后怎么办
不是所有记忆都该永久留存。按生命周期给记忆分档,TTL 才有依据:
| 记忆类型 | 例子 | TTL 建议 |
|---|---|---|
| 长期偏好 | 「偏好用 Python」「希望简体中文回复」 | 不过期(0) |
| 项目背景 | 「在做本地知识库问答项目」 | 长(季度级),项目结束手动清 |
| 任务态 | 「这次要查 2024 年财报」 | 短(小时~天级),任务结束即失效 |
| 会话态 | 「上一条我指的是 Q3」 | 最短,随会话结束 |
用 ttl_expires_at 字段表达:0 表示永不过期,其余是绝对时间戳。清理有两种时机——读时惰性过滤(查询时加 ttl_expires_at = 0 or ttl_expires_at > now)与后台批量清理(定时 DELETE ... WHERE ttl_expires_at <= now)。前者保证过期记忆永远读不到,后者回收磁盘。两者要一起用:只惰性过滤,磁盘会涨;只批量清理,两次清理之间的窗口里过期记忆仍可能被召回。
-- 读时惰性过滤:11.2 的检索查询永远带上这一条
select id, content from memories
where workspace_id = %s
and (ttl_expires_at = 0 or ttl_expires_at > %s) -- now_ts
and status = 'active';
-- 后台清理:过期即删,向量随外键级联
delete from mem13_memories
where ttl_expires_at <> 0 and ttl_expires_at <= now_ts
returning id;
两段 SQL 各管一头:上面那段让过期的读不到,下面那段让过期的被回收。只做上面,过期行永远占着磁盘;只做下面,两次清理之间的窗口里,一条本该过期的记忆仍可能被检索注入进模型。所以「读时过滤」是正确性保障,「后台清理」是成本保障,缺一不可。
11.3.3 隐私分级与写入侧拒绝清单
比 TTL 更硬的是根本不该记。有一类内容,一旦写进长期记忆就是数据泄露风险:支付卡号、密钥凭据、证件号、健康信息。它们的特点是用户可能无意识地提供——「帮我记一下我的卡号是……」——所以不能指望用户自觉,必须在写入侧拦。
设计上分两层:
- 隐私分级:给每条记忆打一个敏感度标签(
public/internal/sensitive),sensitive级不参与跨用户检索、不进日志、默认短 TTL。 - 写入侧拒绝清单:一批正则模式,命中即拒绝写入,并记录拒绝原因(不记录被拒内容本身)。
# memory/governance.py
import re
REJECT_RULES = [
("支付卡号", re.compile(r"\b(?:\d[ -]?){15}\d\b")),
("密钥凭据", re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[:=]\s*\S+")),
("证件号", re.compile(r"\b\d{17}[\dXx]\b")),
("健康信息", re.compile(r"血糖|病历|诊断|处方|HIV|核酸")),
]
def screen(text: str) -> str | None:
for label, pat in REJECT_RULES:
if pat.search(text):
return label
return None
几个实现要点:拒绝的是「写入」,不是「整条对话」——命中的那条候选记忆被丢弃,其余正常写入;返回的是类别而非内容,日志里写「拒绝了 1 条支付卡号」,不写卡号本身,否则拒绝清单自己成了泄露源;清单要能配——不同行业(金融、医疗)的敏感项不同,硬编码进代码不如放进配置。
敏感度分级则决定了「放行之后怎么对待」。三档的处理方式不同:
| 敏感度 | 典型内容 | 检索 | 日志 | TTL |
|---|---|---|---|---|
public | 用户偏好、公开项目名 | 可跨会话 | 可记 | 长 |
internal | 内部项目、团队信息 | 限本 workspace | 可记(脱敏) | 中 |
sensitive | 联系方式、财务线索 | 仅本人、限当次 | 不记 | 短 |
分级的价值在于给「放行但需谨慎」的内容留一个中间态:有些内容不该硬拒(用户明确想让它记住),但也不该像普通偏好一样到处检索、长期留存。sensitive 档就承担这个角色——放行,但限制它的可见范围、日志与生命周期。这套分级和拒绝清单是互补的:拒绝清单管「绝对不记」,分级管「记了怎么用」。
11.3.4 按用户删除:级联清理 + 审计
「删除我的数据」是合规硬要求(GDPR 等),落到工程上是一次跨表级联清理。用户的数据散在至少三类存储里:
| 存储 | 内容 | 删除方式 |
|---|---|---|
memories | 记忆条目 | delete where user_id = ? |
sessions | 会话与消息 | delete where user_id = ? |
| 向量索引 | 记忆的嵌入向量 | 随记忆外键 on delete cascade |
用户视角的「删除我的数据」是一句话,工程视角是一组跨表的动作。只要有一张表漏了,就等于没删——尤其是向量索引,它最容易在最初设计时被当成「派生数据」而忽略,结果成了合规检查时最难解释的残留。
关键在于级联:删主表时,依赖它的向量必须一起走,否则留下「孤儿向量」——查不到记忆文本,却仍能被检索命中,既不干净也可能泄露。用外键 references ... on delete cascade 让数据库保证这件事,比应用层手写两条 delete 可靠:
create table mem13_vectors (
memory_id bigint references mem13_memories(id) on delete cascade,
vec text);
删除还必须留审计:不能悄悄删掉就当没发生。审计行记录「谁、什么时候、删了什么类别、多少条」,但不记录被删的内容本身。审计是删除操作的反向证据——合规审计要看的正是「用户请求了,我们确实删了」。删除与审计必须包在同一个事务里,用 psycopg 的 conn.transaction() 表达:
with conn.transaction(): # 要么全成,要么全不生效
n_mem = conn.execute(
"delete from memories where workspace_id=%s and user_id=%s returning id",
(ws, user)).fetchall()
n_ses = conn.execute(
"delete from sessions where workspace_id=%s and user_id=%s returning id",
(ws, user)).fetchall()
conn.execute(
"insert into audit (workspace_id, user_id, action, detail, ts) "
"values (%s, %s, 'gdpr_delete', %s, %s)",
(ws, user, f"memories={len(n_mem)} sessions={len(n_ses)}", now_ts))
注意审计 detail 里写的是条数(memories=2),不是被删的内容。这一点和拒绝清单同理:审计记录本身也可能被读取、被导出,绝不能让它变成一个新的泄露面。把「删了什么」压缩成「删了几条」,合规上足够,隐私上安全。
11.3.5 真跑:一次完整的删除请求
真连本机 Postgres 跑一遍。若本机没有该容器,先起一个:
docker run -d --name ab-pg -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=research -p 15435:5432 postgres:17-alpine
连不上先 docker ps | grep ab-pg 确认容器在跑。先看写入侧筛查(全部是假样例,非真实个人信息):
== 写入侧隐私筛查 ==
ACCEPT | 用户偏好用 Python 写 Agent
REJECT(支付卡号) | 我的卡号是 4111-1111-1111-1111 帮我记一下
REJECT(密钥凭据) | API_KEY=FAKE0000abcd1234efgh 存起来
REJECT(证件号) | 我的身份证号 111111111111111111
REJECT(健康信息) | 用户的血检报告显示血糖偏高
ACCEPT | 用户在做本地知识库问答项目
6 条候选里 4 条被拒、2 条放行。被拒的四条分别命中四个类别,放行的两条是正常的偏好与项目背景。再看 TTL 检查与清理:
== TTL 检查(now=2000)==
mem#1 用户偏好用 Python 写 Agent ttl=0 -> 永久
mem#2 用户在做本地知识库问答项目 ttl=0 -> 永久
mem#3 用户这次要查 2024 年财报 ttl=1200 -> 已过期
过期清理: 删除 [3](向量随外键级联)
任务态记忆 mem#3(TTL=1200,已过期)被清理,两条长期偏好(TTL=0)保留。最后是核心——用户 u_lin 请求删除全部数据:
== 用户 u_lin 请求删除全部数据 ==
级联删除: memories=2 sessions=2
== 校验 ==
u_lin 剩余 memories=0 sessions=0 vectors=0
其它用户 u_other 剩余 memories=1(不受影响)
审计行: [('gdpr_delete', 'memories=2 sessions=2')]
四行读完,一次合规的删除闭环就成立了:级联删除了 2 条记忆、2 个会话;校验阶段 u_lin 的 memories / sessions / vectors 全是 0(向量经外键级联清掉,没有孤儿);其它用户 u_other 的记忆纹丝不动(删除只作用于请求者,不误伤);最后写入了一行审计,记下动作 gdpr_delete 和条数。注意删除动作包在一个事务里——要么全删干净并留审计,要么什么都不发生,绝不出现「删了一半、审计没写」的中间态。
值得单独说的是 vectors=0 这一行是怎么来的。演示里 mem13_vectors 的外键是 references mem13_memories(id) on delete cascade,所以删 mem13_memories 时,数据库自动把指向它们的向量行也删了——应用代码里根本没写「删向量」这一步。这正是把级联交给数据库的价值:如果换成应用层手写两条 delete,一旦某次重构只改了其中一条,就会留下查不到文本却仍能被检索命中的孤儿向量,而这种 bug 往往要等到合规审计才被发现。让数据库的约束兜底,比依赖人的记性可靠。
11.3.6 软删除还是硬删除
删除有两种实现,选错会在合规上留下把柄:
| 方式 | 做法 | 优点 | 风险 |
|---|---|---|---|
| 软删除 | 置 status='deleted',行保留 | 可恢复、审计链完整 | 数据其实还在,合规上不算「已删除」 |
| 硬删除 | 真 DELETE 掉行 | 真正不可恢复 | 误删无法挽回 |
| 分阶段 | 先软删(停止检索),延迟后硬删 | 兼顾误删宽限与最终删除 | 实现复杂,需定时任务 |
对「用户主动请求删除」这类明确的合规删除,必须硬删除——软删除把行留在库里,等于数据还在,审计一句「已删除」是站不住的。但直接硬删又有误删风险(用户手滑、脚本 bug)。工程上的折中是分阶段:用户请求后立刻把记忆标记为 pending_delete 并停止一切检索(这一步保证「删」在功能上立即生效),给一个短宽限期后由后台任务真删,再写审计。宽限期内若用户反悔可撤销,超过期限则不可逆地清掉。
本节演示用的是直接硬删除,因为它最直白地展示了「删干净」这件事:删完校验查不到、向量无孤儿、审计留痕。真实系统在此之上加宽限期即可,但最终一定是硬删除——这是「按用户删除」和「软删除做样子」的分界线。
11.3.7 治理检查清单
把三件事收成一张可勾选的清单,上线前逐条过:
| 检查项 | 通过标准 |
|---|---|
| TTL 覆盖 | 每条记忆都有 TTL 策略,任务态默认短 |
| 过期清理 | 惰性过滤 + 后台批量清理,两者都在跑 |
| 拒绝清单 | 卡号/密钥/证件号/健康信息在写入侧拦截 |
| 拒绝留痕 | 只记类别不记内容,避免清单自身泄露 |
| 隐私分级 | sensitive 记忆不进日志、不跨用户检索 |
| 级联删除 | 记忆、会话、向量一起删,无孤儿 |
| 删除审计 | 每次删除留一行审计,含条数与动作 |
| 事务性 | 删除 + 审计在同一事务,无中间态 |
这张表里,「拒绝留痕」和「删除审计」两项最容易被做反——团队往往记得「要记录」,却忘了记录的内容本身也可能敏感。记住一条:治理日志记录的是「发生了什么」,不是「内容是什么」。卡号不该出现在拒绝日志里,被删的记忆内容也不该出现在审计行里。
11.3.8 真实环境与验收清单
真实系统里,治理还要往前压:拒绝清单接一个独立的内容分类模型(正则只能挡格式化的敏感项,挡不住「我妈的名字是……」这类自由文本);删除请求要接异步工作流(删得慢时先标记 pending_delete 并停止检索,再后台清,避免用户等);审计要进不可篡改的日志(append-only 或外部审计服务),否则删除方自己改审计就等于没审计。数据驻留、跨境传输等更细的合规项,属于第 15.3 节合规审计的范畴。
还有一条容易被忽略的删除传播:如果记忆曾经被嵌入到向量库、被缓存进 Redis(第 6 章)、被写进某次 run 的报告快照,那么「按用户删除」就不止删三张表,还要通知并清理这些派生物。真实系统的做法是给删除请求挂一个清理工作流,逐类派生物发删除指令,并把每一步的结果记进审计——审计因此不只是「删了几条记忆」,而是「记忆、会话、向量、缓存、快照各自的清理结果」。本节演示了最核心的级联(记忆 + 会话 + 向量),把缓存与快照的清理作为清单项留给读者按自己的存储拓扑补齐。
验收清单:
- 每条记忆都有明确的 TTL 策略,任务态与偏好分开。
- 过期记忆读不到,且后台确实回收。
- 写入侧拒绝清单生效,被拒内容不落库、不进日志正文。
- 删除按用户级联清记忆、会话、向量,无孤儿残留。
- 删除动作与审计在同一事务,审计不可篡改。
- 删除只作用于请求者,其它用户数据不受影响。
记忆服务到这里闭环了:能写(11.1)、能用(11.2)、能管(11.3)。这三节分别对应记忆服务的三个面——写入、读取、治理,缺任何一个都不是一个能上线的服务:只写不治是隐私炸弹,只治不注是死数据,只注不写是无源之水。
但「能管」要能被证明——你怎么知道改了检索策略后记忆注入的收益没退步?靠的是一套可复现的评测与质量门禁。下一章把评测集、评分器和 CI 拦截线建起来,让「记忆有用」从一句感觉变成一个可回归的数字。
阅读导航:上一节:11.2 检索注入:长期记忆接入 run 并量化收益 · 下一节:12.1 评测集与评分器:成功率/引用准确率/成本 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。