《互联网产品的 AI 架构·模型接入与能力边界》9.2 失效、隐私与多租户隔离

提示改版后旧缓存继续返回旧口径,用户注销后摘要仍能被相似问题命中。本节把失效拆成提示换版、模型切换、权限调整与用户删除四类传播范围,说明作用域纪元如何挡住迟到的旧写回,并给出可运行的 TypeScript 失效器、删除权传播与完整隔离测试。

从提示改版后旧口径继续返回说起

「回声」的搜索摘要提示在周三下午改了一版。旧版要求「三条以内,不超过八十个字」,新版改成「五条以内,先结论后依据」。发布流程没有问题:模板版本从 p3 升到 p4,评测集通过,灰度十分钟,全量。

周四上午,运营发现一部分搜索结果还是三条。排查后确认模型调用正常、新提示已经生效,问题在缓存。缓存的键里只有租户、能力和查询文本,没有提示版本。旧版写入的结果仍然躺在缓存里,TTL 还有十几个小时。用户看到的不是新提示的输出,而是昨天那版提示的残留。

这不是孤例。同一个季度里还有两次:

  • 模型从 mock-standard 切到 mock-frontier,部分租户的摘要仍由旧模型生成,因为键里没有模型。
  • 一位用户注销账号两周后,另一位同租户用户搜索了相似的句子,语义缓存命中并返回了包含前一位用户订单信息的摘要。

第一次是版本失效问题,第二次是删除权传播问题。两者表面上都属于「缓存脏了」,但传播范围完全不同:提示换版影响同一租户同一能力的全部结果;用户删除只影响包含该用户数据的那部分结果,而且必须跨能力传播。

如果失效逻辑只会「清空全部缓存」,它确实能解决这两个问题,但代价是所有租户、所有能力同时冷启动。真实系统需要的是按范围失效,并且要挡住失效之后迟到的旧写回。

失效不是删除,而是版本推进

把失效理解成「删掉旧键」,会漏掉一个关键场景:删除发生的那一刻,可能还有请求在处理中。它读的是旧版本,写回时用的是旧作用域。删除完成后,这个迟到的写回会把刚清掉的数据重新放回去。

所以失效要做两件事,顺序不能反:

  1. 推进作用域纪元,让旧作用域的写入从此刻起被拒绝。
  2. 清理已经存在的旧结果。

纪元是「当前有效的版本组合」,至少包含提示版本、模型标识与权限分区。写入前先检查请求携带的作用域是否与当前纪元一致;不一致就丢弃写入。这样即使旧请求晚到,也无法污染新一轮缓存。

清理则是按范围遍历并删除。删除必须同时清理精确索引与语义索引,否则精确键没了,语义桶里还留着一个指向已删除条目的键,下一次相似查询仍可能命中。

四类失效与它们的传播范围

触发源传播范围是否推进纪元典型错误
提示换版同租户、同能力的全部结果是,提示版本只改模板不换键,旧结果继续命中
模型切换同租户、同能力的全部结果是,模型标识只对新请求生效,旧结果留在缓存
权限调整同租户、同能力的全部分区是,权限分区只清当前分区,其他分区仍按旧边界返回
用户删除包含该用户数据的结果,跨能力否,改用墓碑只清当前能力,其他能力仍有残留

提示换版与模型切换的范围相同,但语义不同。提示换版改变的是「怎么回答」,模型切换改变的是「谁来回答」。两者都要求同租户同能力的结果全部重算,因为无法判断哪一条旧结果仍然适用。

权限调整的范围更大。权限边界变化后,同一个租户内不同分区的可见内容可能重新划分,旧分区里的结果不能简单按新标签继续使用。保守做法是把该能力的全部分区一起清掉,让新边界下的第一次请求重新计算。

用户删除的范围最特殊。它不按能力划分,而按「结果里是否包含这个用户的数据」划分。一条摘要可能同时包含多个用户的信息,删除其中一个人,整条结果都必须作废,不能只改其中一段。

提示换版:让旧作用域写不回来

提示换版的实现可以很短:把新版本号写进作用域纪元,然后删除该租户该能力下所有提示版本不等于新版本的条目。

这里有一个容易写错的细节:清理条件不能用「旧版本号」,而要用「不等于新版本号」。因为线上可能同时存在多个历史版本,灰度期间也可能有多个版本并行。用「不等于」可以一次清掉所有非当前版本,避免逐个枚举。

清理之后,旧请求仍可能带着旧版本号到达。写入前的纪元检查会拒绝它。拒绝应当是静默的:请求本身可以正常返回模型结果,只是不写缓存。对一个正在服务的请求来说,缓存写不进去不是错误,只是一次成本损失。

换模型:只影响该能力

模型切换常常被当成全局事件,实际影响范围是「租户 × 能力」。同一个租户的搜索摘要换了模型,不代表私信回复也要换。失效逻辑应当按能力切割,否则一次模型灰度会让全站缓存冷启动。

模型标识要进作用域键,这一点在 9.1 已经说过。失效时则要按「不等于新模型」清理,理由与提示版本相同:可能同时存在多个旧模型。

如果新模型只是候选,尚未全量,正确的做法不是失效,而是给新模型一个独立的作用域。灰度流量写新作用域,存量流量继续读旧作用域,两者互不干扰。等全量后再统一失效旧作用域。

权限调整:同能力全分区重算

权限调整的难点在于「哪些结果仍然有效」无法从缓存条目本身判断。一条摘要可能引用了某个文档,而该文档的可见范围刚刚改变。缓存只知道结果属于哪个权限分区,不知道结果内部引用了哪些资源。

因此保守策略是:权限边界一变,该租户该能力的全部分区一起作废。这会产生一次冷启动,但避免了「按旧边界生成、按新边界展示」的越权风险。

如果权限系统能提供「受影响资源列表」,可以做得更细:只清理引用了这些资源的结果。但这要求缓存记录结果与资源的依赖关系,复杂度显著上升。多数团队应当先做保守策略,把越权风险降到零,再考虑优化。

用户删除权:从结果传到索引

用户删除权要求「用户数据被删除后,不再出现在任何派生结果里」。缓存是派生结果的一种,而且是最容易被忽略的一种,因为它不在数据库里,也不在备份里,看起来像临时数据。

传播路径至少有三段:

  1. 精确缓存:删除所有 subjectIds 包含该用户的结果。
  2. 语义索引:删除条目时必须同步删除索引键,否则相似查询仍会命中。
  3. 派生记录:日志、评测样本、离线特征、导出文件。缓存代码只能负责前两段,第三段要靠数据地图与删除流程。

subjectIds 是写入时记录的「这条结果里出现过谁的数据」。它不参与缓存键,因为一条结果可能包含多个用户;它只参与删除传播。记录粒度可以是用户 ID,也可以是更粗的主体标识,取决于产品的数据模型。

删除还要防「迟到写回」。用户删除后,一个正在处理的请求可能带着该用户的数据写回缓存。解决办法是维护墓碑集合:被删除的用户 ID 进入集合,写入时检查 subjectIds 是否与墓碑相交,相交就拒绝。

墓碑不需要永久保存。它只需要覆盖「最长请求处理时间 + 安全余量」的窗口。超过窗口后,不可能还有合法请求携带该用户的数据。窗口长度应当由请求超时预算决定,而不是拍一个「七天」。

删除报告只应记录数量与作用域,不应记录查询文本与缓存值。查询文本本身可能就是个人信息,把「删除了哪条查询」写进日志,等于把刚删掉的数据又复制了一份。

租户隔离的三种破法

多租户隔离的漏洞通常不在数据库,而在缓存。三种常见破法值得单独列出来。

第一种是键漏租户。查询文本相同就复用,单租户测试完全正常,多租户上线后串数据。修复不是加一个 if,而是把租户放进作用域键,让「跨租户命中」在结构上不可能发生。

第二种是语义索引全局共享。精确键带了租户,语义桶却是全局的,相似比较时仍然会跨租户。修复是让语义桶的键与精确键的前半段完全一致,任何一次比较都先经过作用域过滤。

第三种是前缀拼接歧义。用 : 拼接字段,租户 a、能力 b:c 与租户 a:b、能力 c 会拼成同一个字符串。修复是使用不会出现在字段里的分隔符,或者在拼接前对字段做转义。前者更简单,后者更容易出错。

隔离的验证不能只靠代码评审。测试里应当显式构造「同文本、不同租户」「同文本、不同权限分区」「同文本、不同提示版本」三组用例,并断言它们全部未命中。这些用例很便宜,但能挡住最贵的一类事故。

代码:失效器与作用域纪元

下面的实现把「推进纪元」与「按范围清理」放在同一个类里,并提供带守卫的写入。缓存本体来自 9.1 的 ResultCache,这里不重复实现存储。

// file: src/cache/invalidation.ts
// tier: L1
// verify: node --test test/cache/invalidation.test.ts

import type { CapabilityId, TenantId } from "../core/types.ts";
import type { CacheEntry, CacheScope, CacheWriteOptions, ResultCache } from "./cache.ts";

export type InvalidationReason =
  | "prompt-version"
  | "model-version"
  | "permission-change"
  | "user-deletion"
  | "tenant-purge"
  | "manual";

/** 作用域当前有效的版本组合,旧版本写入会被拒绝。 */
export interface ScopeEpoch {
  promptVersion: string;
  modelId: string;
  permissionScope: string;
}

export interface InvalidationReport {
  reason: InvalidationReason;
  removed: number;
  remaining: number;
  /** 只记录作用域标签,不记录键原文与缓存值。 */
  scopes: string[];
  at: number;
}

export interface InvalidatorOptions {
  now?: () => number;
}

function epochKey(tenantId: TenantId, capability: CapabilityId): string {
  return `${tenantId}\u0000${capability}`;
}

export class ScopeGuard {
  private epochs = new Map<string, ScopeEpoch>();

  seed(scope: CacheScope): ScopeEpoch {
    const key = epochKey(scope.tenantId, scope.capability);
    const existing = this.epochs.get(key);
    if (existing) {
      return existing;
    }
    const epoch: ScopeEpoch = {
      promptVersion: scope.promptVersion,
      modelId: scope.modelId,
      permissionScope: scope.permissionScope,
    };
    this.epochs.set(key, epoch);
    return epoch;
  }

  current(tenantId: TenantId, capability: CapabilityId): ScopeEpoch | undefined {
    return this.epochs.get(epochKey(tenantId, capability));
  }

  /** 没有登记过的作用域放行,登记过则三项必须完全一致。 */
  admit(scope: CacheScope): boolean {
    const epoch = this.current(scope.tenantId, scope.capability);
    if (!epoch) {
      return true;
    }
    return (
      epoch.promptVersion === scope.promptVersion &&
      epoch.modelId === scope.modelId &&
      epoch.permissionScope === scope.permissionScope
    );
  }

  advance(scope: CacheScope, patch: Partial<ScopeEpoch>): ScopeEpoch {
    const next: ScopeEpoch = { ...this.seed(scope), ...patch };
    this.epochs.set(epochKey(scope.tenantId, scope.capability), next);
    return next;
  }
}

export class CacheInvalidator<T> {
  private cache: ResultCache<T>;
  private guard: ScopeGuard;
  private now: () => number;
  private reports: InvalidationReport[] = [];
  /** 删除过的用户不能通过迟到的请求把数据写回缓存。 */
  private deletedUsers = new Map<TenantId, Set<string>>();

  constructor(cache: ResultCache<T>, options: InvalidatorOptions = {}) {
    this.cache = cache;
    this.guard = new ScopeGuard();
    this.now = options.now ?? (() => Date.now());
  }

  write(
    scope: CacheScope,
    input: string,
    value: T,
    options: CacheWriteOptions = {},
  ): CacheEntry<T> | undefined {
    if (!this.guard.admit(scope)) {
      return undefined;
    }
    const deleted = this.deletedUsers.get(scope.tenantId);
    if (deleted && (options.subjectIds ?? []).some((id) => deleted.has(id))) {
      return undefined;
    }
    return this.cache.set(scope, input, value, options);
  }

  /** 提示换版:同一租户同一能力下,旧提示版本的结果全部作废。 */
  invalidatePrompt(scope: CacheScope, nextPromptVersion: string): InvalidationReport {
    this.guard.advance(scope, { promptVersion: nextPromptVersion });
    return this.purge(
      (entry) =>
        entry.scope.tenantId === scope.tenantId &&
        entry.scope.capability === scope.capability &&
        entry.scope.promptVersion !== nextPromptVersion,
      "prompt-version",
    );
  }

  /** 换模型:只作废该能力的结果,不牵连其他能力。 */
  invalidateModel(scope: CacheScope, nextModelId: string): InvalidationReport {
    this.guard.advance(scope, { modelId: nextModelId });
    return this.purge(
      (entry) =>
        entry.scope.tenantId === scope.tenantId &&
        entry.scope.capability === scope.capability &&
        entry.scope.modelId !== nextModelId,
      "model-version",
    );
  }

  /** 权限边界调整后,该能力的全部分区都要重算,不能只换一个标签。 */
  invalidatePermission(scope: CacheScope, nextPermissionScope: string): InvalidationReport {
    this.guard.advance(scope, { permissionScope: nextPermissionScope });
    return this.purge(
      (entry) =>
        entry.scope.tenantId === scope.tenantId &&
        entry.scope.capability === scope.capability,
      "permission-change",
    );
  }

  /** 用户删除权:按结果里出现过的 subjectIds 传播,跨能力生效。 */
  invalidateUser(tenantId: TenantId, userId: string): InvalidationReport {
    let deleted = this.deletedUsers.get(tenantId);
    if (!deleted) {
      deleted = new Set();
      this.deletedUsers.set(tenantId, deleted);
    }
    deleted.add(userId);
    return this.purge(
      (entry) => entry.scope.tenantId === tenantId && entry.subjectIds.includes(userId),
      "user-deletion",
    );
  }

  invalidateTenant(tenantId: TenantId): InvalidationReport {
    return this.purge((entry) => entry.scope.tenantId === tenantId, "tenant-purge");
  }

  invalidateCapability(tenantId: TenantId, capability: CapabilityId): InvalidationReport {
    return this.purge(
      (entry) =>
        entry.scope.tenantId === tenantId && entry.scope.capability === capability,
      "manual",
    );
  }

  history(): InvalidationReport[] {
    return [...this.reports];
  }

  private purge(
    predicate: (entry: CacheEntry<T>) => boolean,
    reason: InvalidationReason,
  ): InvalidationReport {
    const matched = this.cache.list().filter(predicate);
    for (const entry of matched) {
      this.cache.remove(entry.key);
    }
    const scopes = [
      ...new Set(matched.map((entry) => `${entry.scope.tenantId}/${entry.scope.capability}`)),
    ];
    const report: InvalidationReport = {
      reason,
      removed: matched.length,
      remaining: this.cache.size,
      scopes,
      at: this.now(),
    };
    this.reports.push(report);
    return report;
  }
}

几个设计选择需要解释。

第一,纪元按「租户 × 能力」登记,而不是按精确作用域。提示版本与模型标识是租户级、能力级的配置,不是每个权限分区各有一份。按更细的粒度登记会让失效逻辑复杂,却不会带来额外收益。

第二,invalidatePrompt 的清理条件是 promptVersion !== next,而不是 === previous。这样能一次清掉所有历史版本,包括灰度期间并行存在的版本。

第三,用户删除的墓碑检查发生在写入前,而不是清理时。清理只能处理已经存在的结果,墓碑处理的是「删除之后还想写回来」的请求。两者缺一不可。

第四,报告里的 scopes 是「租户/能力」标签,不是缓存键。缓存键包含规范化后的查询文本,可能含有个人信息;把它写进失效报告,等于把删除做成了复制。

失效的顺序与可观测性

一次完整的失效应当按下面的顺序执行:

  1. 推进纪元,先挡住新写入。
  2. 执行清理,删除旧条目与语义索引。
  3. 发出失效事件,通知多实例与其他区域。
  4. 抽样验证,确认旧作用域不再命中。

顺序不能反。如果先清理再推进纪元,清理与推进之间到达的旧请求仍可能写回;如果只在本实例推进纪元,其他实例仍会接受旧写入,所以失效事件必须广播。

可观测性至少要回答三个问题:这次失效删了多少条、覆盖了哪些作用域、清理后是否还有旧版本命中。前两个问题由失效报告回答,第三个问题要靠命中日志里的版本字段。如果日志里没有版本字段,失效是否成功就只能靠猜。

失效也需要限流。一次权限调整可能触发几十万条删除,同步遍历会阻塞请求线程。生产实现应当把清理放进后台任务,按作用域分批删除,并在批次之间让出执行权。本节代码为了可测试,使用的是同步遍历;这个差别在接入真实存储时必须补上。

小结

失效不是「删掉旧键」,而是「先推进版本,再清理存量」。只清理不推进,迟到的旧请求会把数据写回来;只推进不清理,旧条目会继续占用内存,语义索引也可能继续命中。

四类失效的传播范围不同:提示换版与模型切换影响同租户同能力,权限调整影响同能力的全部分区,用户删除按结果里出现过的用户传播,并且要跨能力。

多租户隔离要写进键的结构,而不是靠调用方记得传租户。精确键、语义桶、拼接分隔符,任何一处漏掉租户,都会在真实流量里变成串数据事故。

阅读导航:上一节:9.1 精确缓存、语义缓存与结果复用 · 下一节:9.3 命中率的度量与陷阱 。

常见错误与边界

  • 只清精确缓存,不清语义索引。精确键没了,相似查询仍会命中已删除的条目。
  • 失效只在本实例执行。多实例部署下,其他实例继续用旧版本提供服务。
  • 先清理再推进纪元。清理与推进之间到达的旧请求会把数据写回。
  • 用旧版本号做清理条件。灰度期间存在多个历史版本时,只能清掉其中一个。
  • 用户删除只清当前能力。同一条用户数据可能出现在搜索、私信、推荐等多个能力里。
  • 删除报告记录查询原文。刚删掉的数据又被写进日志,等于没有删除。
  • 墓碑永久保存。用户 ID 可能被回收,永久墓碑会误伤新用户。
  • 权限调整只清当前分区。其他分区仍按旧边界生成的结果继续被使用。
  • 把缓存当事实来源。缓存不可用时请求应当继续走模型,而不是直接失败。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查