一条客服消息该不该出域
回声的客服链路 B 收到一条私信:用户发来订单号、收货地址、手机号,外加一句「昨天买的杯子裂了,能退吗」。链路要把这段内容交给 chat.reply 生成回复。默认路由把它送到一个公有云模型,延迟 420ms,每次 1,160 微元。
从成本和延迟看,这个选择没有问题。从合规看,它一次踩了三条线:手机号属于敏感数据,公有云 API 不在允许的出域方式里,模型供应商的训练策略是「可用聚合数据训练」。三条线任何一条不成立,这次调用就不应该发生。
很多团队的处理方式是写一份《数据安全规范》,再让每个调用方自己判断。规范里写着「敏感数据不得出境」「重要数据不得使用第三方模型」,但没有人能回答「chat.reply 这个版本到底能不能接这条消息」。原因是规范描述的是原则,而路由需要的是字段。
本节把原则变成字段:先定义四级数据,再定义每级允许的出域方式、区域、留存与训练用途,最后把这些约束落到能力目录,并给出一个确定性的路由校验函数。所有数字来自示例样本上的运行结果,不是生产实测,也不构成法律意见。
四个级别与四条约束
示例采用四级:公开、内部、敏感、受限。级别越高,可选的出域方式越少、区域越窄、留存越短、训练用途越严。
| 级别 | 典型内容 | 允许出域 | 允许区域 | 留存上限 | 训练用途 |
|---|---|---|---|---|---|
| 公开 | 已发布文章、公开商品标题 | 四种都允许 | 不限 | 365 天 | 三种都允许 |
| 内部 | 内部编号、运营备注 | 私有端点、区域内、自托管 | 不限 | 180 天 | 禁止训练、仅聚合 |
| 敏感 | 手机号、地址、订单明细 | 私有端点、区域内、自托管 | cn、sg | 30 天 | 仅禁止训练 |
| 受限 | 证件号、支付凭证、病例 | 区域内、自托管 | cn | 7 天 | 仅禁止训练 |
这张表是示例口径,不是法规结论。真正上线前,每一行都要由合规、安全和业务共同确认,并写进配置而不是文档。表里最容易被忽略的是「允许区域」与「允许出域」的组合:敏感数据允许私有端点,但私有端点如果落在境外,仍然不成立。两个约束必须同时检查。
还有一个常见误解:级别是给整份文档定的。实际系统里,一条消息往往混合多个级别。标题是公开的,订单号是敏感的,备注里可能有一句病历。按整份文档定级,要么全部拔高导致无法使用,要么按最低项放行导致泄露。
分级要落到字段
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是偏序,不是开关。 - 用加权评分判断合规。任何一项不满足都应拒绝,不能靠其他项补偿。
- 把示例策略当成法律结论。表里的天数与区域必须由合规评审确认后写入配置。
- 只做路由校验,不记录拒绝原因。没有稳定问题码,无法聚合、告警与回归。
- 降级时只看成本。降级首先要换一个级别上限足够的能力,而不是换一个更便宜的模型。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。