《互联网产品的 AI 架构·平台化与治理》8.1 数据分级与出域

数据分级不是给整份文档贴一个标签,而是给字段定级、再取最高项。本节给出公开、内部、敏感、受限四级与每级允许的出域方式、区域、留存和训练用途,把它们落成能力目录的五个字段,并用可运行的路由校验回答一条客服消息能否发给某个指定模型,而不是靠文档约定。

一条客服消息该不该出域

回声的客服链路 B 收到一条私信:用户发来订单号、收货地址、手机号,外加一句「昨天买的杯子裂了,能退吗」。链路要把这段内容交给 chat.reply 生成回复。默认路由把它送到一个公有云模型,延迟 420ms,每次 1,160 微元。

从成本和延迟看,这个选择没有问题。从合规看,它一次踩了三条线:手机号属于敏感数据,公有云 API 不在允许的出域方式里,模型供应商的训练策略是「可用聚合数据训练」。三条线任何一条不成立,这次调用就不应该发生。

很多团队的处理方式是写一份《数据安全规范》,再让每个调用方自己判断。规范里写着「敏感数据不得出境」「重要数据不得使用第三方模型」,但没有人能回答「chat.reply 这个版本到底能不能接这条消息」。原因是规范描述的是原则,而路由需要的是字段。

本节把原则变成字段:先定义四级数据,再定义每级允许的出域方式、区域、留存与训练用途,最后把这些约束落到能力目录,并给出一个确定性的路由校验函数。所有数字来自示例样本上的运行结果,不是生产实测,也不构成法律意见。

四个级别与四条约束

示例采用四级:公开、内部、敏感、受限。级别越高,可选的出域方式越少、区域越窄、留存越短、训练用途越严。

级别典型内容允许出域允许区域留存上限训练用途
公开已发布文章、公开商品标题四种都允许不限365 天三种都允许
内部内部编号、运营备注私有端点、区域内、自托管不限180 天禁止训练、仅聚合
敏感手机号、地址、订单明细私有端点、区域内、自托管cn、sg30 天仅禁止训练
受限证件号、支付凭证、病例区域内、自托管cn7 天仅禁止训练

这张表是示例口径,不是法规结论。真正上线前,每一行都要由合规、安全和业务共同确认,并写进配置而不是文档。表里最容易被忽略的是「允许区域」与「允许出域」的组合:敏感数据允许私有端点,但私有端点如果落在境外,仍然不成立。两个约束必须同时检查。

还有一个常见误解:级别是给整份文档定的。实际系统里,一条消息往往混合多个级别。标题是公开的,订单号是敏感的,备注里可能有一句病历。按整份文档定级,要么全部拔高导致无法使用,要么按最低项放行导致泄露。

分级要落到字段

classifyPayload 的做法是逐字段标注,再取最高项。空载荷没有证据,按 internal 处理,而不是按 public 放行。

// file: src/gov/compliance.ts
// tier: L2
// verify: node --test test/gov/compliance.test.ts

export type DataClass = "public" | "internal" | "sensitive" | "restricted";
export type EgressMode = "public-api" | "private-endpoint" | "in-region-vpc" | "self-hosted";
export type TrainingUse = "no-train" | "aggregate-only" | "allowed";

export const DATA_CLASS_RANK: Record<DataClass, number> = { public: 0, internal: 1, sensitive: 2, restricted: 3 };
export const TRAINING_RANK: Record<TrainingUse, number> = { "no-train": 0, "aggregate-only": 1, allowed: 2 };

export interface EgressPolicy {
  allowedEgress: readonly EgressMode[];
  allowedRegions: readonly string[];
  maxRetentionDays: number;
  allowedTraining: readonly TrainingUse[];
}

/** 示例策略,不是法律意见;生产值由合规评审确认后写入配置。 */
export const EGRESS_POLICY: Record<DataClass, EgressPolicy> = {
  public: {
    allowedEgress: ["public-api", "private-endpoint", "in-region-vpc", "self-hosted"],
    allowedRegions: ["*"], maxRetentionDays: 365,
    allowedTraining: ["no-train", "aggregate-only", "allowed"],
  },
  internal: {
    allowedEgress: ["private-endpoint", "in-region-vpc", "self-hosted"],
    allowedRegions: ["*"], maxRetentionDays: 180,
    allowedTraining: ["no-train", "aggregate-only"],
  },
  sensitive: {
    allowedEgress: ["private-endpoint", "in-region-vpc", "self-hosted"],
    allowedRegions: ["cn", "sg"], maxRetentionDays: 30, allowedTraining: ["no-train"],
  },
  restricted: {
    allowedEgress: ["in-region-vpc", "self-hosted"],
    allowedRegions: ["cn"], maxRetentionDays: 7, allowedTraining: ["no-train"],
  },
};

export interface FieldClass { field: string; dataClass: DataClass }

export interface PayloadClass { dataClass: DataClass; highest: readonly FieldClass[] }

/** 载荷按字段逐项标注,整包级别取最高项;空载荷没有证据,按 internal 处理。 */
export function classifyPayload(fields: readonly FieldClass[]): PayloadClass {
  if (fields.length === 0) return { dataClass: "internal", highest: [] };
  const top = Math.max(...fields.map((item) => DATA_CLASS_RANK[item.dataClass]));
  const dataClass = (Object.keys(DATA_CLASS_RANK) as DataClass[]).find((item) => DATA_CLASS_RANK[item] === top)!;
  return { dataClass, highest: fields.filter((item) => DATA_CLASS_RANK[item.dataClass] === top) };
}

export interface CapabilityDataProfile {
  capability: string;
  maxDataClass: DataClass;
  declaredEgress: readonly EgressMode[];
  declaredRegions: readonly string[];
  retentionDays: number;
  trainingUse: TrainingUse;
}

export interface RouteRequest {
  capability: string;
  dataClass: DataClass;
  region: string;
  egress: EgressMode;
  providerRegion: string;
  retentionDays: number;
  trainingUse: TrainingUse;
}

export type RouteIssueCode =
  | "unknown-capability" | "data-class-exceeds-capability" | "egress-forbidden"
  | "egress-undeclared" | "region-forbidden" | "region-undeclared"
  | "provider-region-mismatch" | "retention-too-long" | "retention-exceeds-declaration"
  | "training-not-allowed" | "training-exceeds-declaration";

export interface RouteIssue { capability: string; code: RouteIssueCode; detail: string }

export interface RouteDecision {
  capability: string;
  dataClass: DataClass;
  allowed: boolean;
  requiredEgress: readonly EgressMode[];
  issues: RouteIssue[];
}

function coversRegion(regions: readonly string[], region: string): boolean {
  return regions.includes("*") || regions.includes(region);
}

/** 路由前校验:数据级别、出域方式、区域、留存与训练用途五项必须同时成立。 */
export function checkRoute(profiles: readonly CapabilityDataProfile[], request: RouteRequest): RouteDecision {
  const policy = EGRESS_POLICY[request.dataClass];
  const issues: RouteIssue[] = [];
  const push = (code: RouteIssueCode, detail: string): void => { issues.push({ capability: request.capability, code, detail }); };
  const profile = profiles.find((item) => item.capability === request.capability);
  if (profile === undefined) {
    push("unknown-capability", `${request.capability} is not declared in the catalog`);
    return { capability: request.capability, dataClass: request.dataClass, allowed: false, requiredEgress: policy.allowedEgress, issues };
  }
  if (DATA_CLASS_RANK[request.dataClass] > DATA_CLASS_RANK[profile.maxDataClass]) {
    push("data-class-exceeds-capability", `${request.dataClass} exceeds declared ${profile.maxDataClass}`);
  }
  if (!policy.allowedEgress.includes(request.egress)) push("egress-forbidden", `${request.egress} is not allowed for ${request.dataClass}`);
  if (!profile.declaredEgress.includes(request.egress)) push("egress-undeclared", `${request.egress} is not declared by ${request.capability}`);
  if (!coversRegion(policy.allowedRegions, request.region)) push("region-forbidden", `${request.region} is outside the ${request.dataClass} region set`);
  if (!coversRegion(profile.declaredRegions, request.region)) push("region-undeclared", `${request.region} is not declared by ${request.capability}`);
  if (request.providerRegion !== request.region) push("provider-region-mismatch", `provider serves ${request.providerRegion}, request requires ${request.region}`);
  if (request.retentionDays > policy.maxRetentionDays) push("retention-too-long", `${request.retentionDays}d exceeds the ${policy.maxRetentionDays}d cap`);
  if (request.retentionDays > profile.retentionDays) push("retention-exceeds-declaration", `${request.retentionDays}d exceeds the ${profile.retentionDays}d declaration`);
  if (!policy.allowedTraining.includes(request.trainingUse)) push("training-not-allowed", `${request.trainingUse} is not allowed for ${request.dataClass}`);
  if (TRAINING_RANK[request.trainingUse] > TRAINING_RANK[profile.trainingUse]) push("training-exceeds-declaration", `${request.trainingUse} is looser than ${profile.trainingUse}`);
  return { capability: request.capability, dataClass: request.dataClass, allowed: issues.length === 0, requiredEgress: policy.allowedEgress, issues };
}

/** 在给定数据级别下,哪些能力仍有资格承接。用于降级与替代路由。 */
export function eligibleCapabilities(profiles: readonly CapabilityDataProfile[], dataClass: DataClass): CapabilityDataProfile[] {
  return profiles.filter((item) => DATA_CLASS_RANK[item.maxDataClass] >= DATA_CLASS_RANK[dataClass]);
}

export interface RouteSummary { total: number; allowed: number; blocked: number; byCode: Record<string, number> }

export function summarizeRoutes(decisions: readonly RouteDecision[]): RouteSummary {
  const byCode: Record<string, number> = {};
  for (const decision of decisions) for (const issue of decision.issues) byCode[issue.code] = (byCode[issue.code] ?? 0) + 1;
  const allowed = decisions.filter((decision) => decision.allowed).length;
  return { total: decisions.length, allowed, blocked: decisions.length - allowed, byCode };
}

这段代码里有两个值得说明的选择。

第一,级别比较用整数秩,不用字符串比较。public、internal、sensitive、restricted 的字典序没有意义,DATA_CLASS_RANK 把顺序显式化,后续所有「是否超过上限」的判断都基于它。

第二,未知能力直接失败,不做默认放行。checkRoute 找不到能力条目时立即返回 unknown-capability。治理系统里最危险的不是拒绝,而是「没查到就当作允许」。默认拒绝会让接入变慢,但会让越权变得可见。

能力目录要新增五个字段

8.1 的约束不能停留在请求侧,否则每个调用方都要重新实现一遍。能力目录的条目要声明自己承接什么数据、从哪里出、留多久、能不能训练。

字段含义缺失后的表现
maxDataClass该能力允许承接的最高级别调用方只能靠文档猜,越级请求无人拦截
declaredEgress该能力实际支持哪些出域方式目录说有私有端点,运行时却没有
declaredRegions该能力部署在哪些区域请求发到境外节点才发现不合规
retentionDays该能力承诺的最长留存供应商留 90 天,业务以为只留 7 天
trainingUse该能力允许的训练用途敏感数据被聚合训练,事后无法撤回

这五个字段与 8.2 的留存、8.3 的供应商清单是同一组事实的不同视角。目录声明的是「能力承诺什么」,供应商记录声明的是「上游实际提供什么」,路由校验则是「这次请求是否同时满足两边」。三者缺一,承诺就会在某个环节断掉。

以回声为例:content.summarize 最高接敏感数据,支持私有端点与区域内,部署在 cn 与 sg,留存 30 天,禁止训练;chat.reply 最高接内部数据,支持公有 API 与私有端点,区域不限,留存 90 天,允许聚合训练;moderation.classify 最高接受限数据,只支持区域内,只在 cn,留存 7 天,禁止训练。

路由校验:五项同时成立

checkRoute 检查五组条件:级别不超过能力上限、出域方式同时被策略与能力允许、请求区域同时被策略与能力覆盖、供应商服务区域等于请求区域、留存与训练用途不超过策略与声明。

任何一项失败都会产生一个稳定的问题码,而不是一句自然语言。问题码的价值在于可以聚合、可以告警、可以写进测试断言。示例里共有十一种:未知能力、越级、出域被策略禁止、出域未被能力声明、区域被策略禁止、区域未被能力声明、供应商区域不匹配、留存超策略、留存超声明、训练用途被策略禁止、训练用途超声明。

有两个细节容易被忽略。

其一是「策略允许」与「能力声明」是两道独立的门。content.summarize 声明了私有端点,但敏感数据的策略也允许私有端点,两者都通过才放行。如果只查策略,能力可能实际没有部署私有端点;如果只查能力,能力可能声明了一个策略不允许的出域方式。两道门都必要。

其二是训练用途的偏序。no-train 最严,aggregate-only 次之,allowed 最松。请求可以比能力声明更严,但不能更松。一个声明 aggregate-only 的能力,收到 no-train 请求是合法的;收到 allowed 请求则不行。这条规则让调用方可以主动加严,而不需要提供方为每个调用方单独配置。

示例样本上的运行结果

示例目录有三个能力,构造了六条请求样本。在示例样本上运行校验,结果如下。

请求结果问题码
受限数据 → moderation.classify,区域内 cn放行无
敏感数据 → content.summarize,公有 API拒绝出域被策略禁止、出域未被能力声明
受限数据 → chat.reply,区域内拒绝越级、出域未被能力声明
敏感数据 → content.summarize,供应商在 us拒绝供应商区域不匹配
敏感数据 → content.summarize,留存 45 天拒绝留存超策略、留存超声明
公开数据 → chat.reply,训练用途 allowed拒绝训练用途超声明

把「受限放行」「敏感数据走公有 API」「受限数据越级到 chat.reply」与一条故意构造的未知能力请求放进同一次汇总:总数 4,放行 1,拒绝 3;问题码计数为出域未被声明 2 次、出域被策略禁止 1 次、越级 1 次、未知能力 1 次。未知能力那条用来确认默认拒绝生效。

同一份目录下,按级别筛选可承接能力:公开 3 个、敏感 2 个、受限 1 个。这个数字直接回答了「降级能做到什么程度」。当一条敏感请求被 chat.reply 拒绝时,平台可以自动改投 content.summarize 或 moderation.classify,而不是简单报错。降级不是换一个更便宜的模型,而是换一个级别上限更高的能力。

出域不是二选一

很多团队把出域理解成「境内还是境外」。实际决策至少有四个维度:出域方式、区域、留存、训练用途。四个维度里任何一个不满足,都不能靠另一个维度补偿。

举例来说,把敏感数据从公有 API 换成私有端点,解决了出域方式,但如果私有端点部署在 us,区域仍然不成立;把区域改回 cn,但供应商留存 90 天,留存仍然不成立;把留存压到 30 天,但供应商用聚合数据训练,训练用途仍然不成立。合规不是加权评分,而是一组与条件。

这也解释了为什么能力目录不能只写「支持私有化部署」。私有化部署是一个模糊说法,它没有回答部署在哪个区域、留存多久、是否训练。目录字段必须能被机器比较,否则它只是市场文案。

与后两节的衔接

本节定义的是「一次请求能不能发」。还有两个问题没有回答:发出去之后数据留多久、怎么删;以及上游供应商换了模型或改了策略,平台怎么知道。

前者是 8.2 留存、删除与审计 的主题。删除请求必须沿着缓存、日志、评测集与备份传播,并留下可复核的证明。后者是 8.3 供应商合规与模型变更通知 的主题。供应商清单里的区域、留存、训练用途与通知时限,正是本节路由校验的上游输入。

三节合起来构成一条证据链:目录声明能力边界,路由校验请求,留存与删除证明数据已经消失,供应商清单证明上游仍然守约。

小结

数据分级要落到字段,而不是停在文档。示例用四级——公开、内部、敏感、受限——为每级定义允许的出域方式、区域、留存上限与训练用途。classifyPayload 取字段最高项,空载荷按 internal 处理;能力目录新增 maxDataClass、declaredEgress、declaredRegions、retentionDays、trainingUse 五个字段。

checkRoute 把策略与能力声明作为两道独立的门,五项条件同时成立才放行,失败返回稳定问题码。示例样本上,汇总里的四条请求放行 1 条、拒绝 3 条;按级别筛选能力,公开 3 个、敏感 2 个、受限 1 个。出域不是境内境外的二选一,而是出域方式、区域、留存、训练用途四维与条件。

阅读导航:上一节:7.3 优化优先级与投资回报 · 下一节:8.2 留存、删除与审计 。

常见错误与边界

  • 按整份文档定级。混合级别的内容会要么被过度拔高,要么按最低项泄露。
  • 只检查境内境外,不检查出域方式。境内公有 API 仍然可能违反敏感数据的出域约束。
  • 能力目录只写「支持私有化」。没有区域、留存与训练用途,字段无法被机器比较。
  • 默认放行未知能力。查不到条目就当作允许,越权会静默发生。
  • 把策略检查与能力声明合并成一道门。少一道,就会出现「策略允许但能力没部署」或反之。
  • 训练用途只做布尔判断。no-train、aggregate-only、allowed 是偏序,不是开关。
  • 用加权评分判断合规。任何一项不满足都应拒绝,不能靠其他项补偿。
  • 把示例策略当成法律结论。表里的天数与区域必须由合规评审确认后写入配置。
  • 只做路由校验,不记录拒绝原因。没有稳定问题码,无法聚合、告警与回归。
  • 降级时只看成本。降级首先要换一个级别上限足够的能力,而不是换一个更便宜的模型。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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