供应商提前 14 天说要换模型
周五下午,模型供应商发来邮件:下个月 1 号起,cheng 系列模型升级到新版本,旧版本保留 14 天后下线。邮件抄送了采购、客服和技术,但没有说明新版本在哪些区域可用、是否改变训练策略、价格是否变化。
回声的 B 链路依赖这个模型生成客服回复。14 天够做什么?够跑一次回归,不够完成一次跨团队迁移评审;够切换默认版本,不够验证敏感数据的区域约束是否仍然成立。更麻烦的是,合同里只写了「供应商应提前通知」,没有写提前多少天、通知必须包含什么、谁来评估。
供应商合规不是一份签字文件,而是一组可比较的字段加一条可执行的变更流程。本节把清单、时限与判定写成代码,让「这家供应商能不能接这个级别的数据」「这次变更能不能接受」变成可以复现的结论。
所有天数与要求都是示例口径,不是法律意见;真实合同条款要由法务与合规确认。
六类清单字段
| 类别 | 字段 | 要回答的问题 |
|---|---|---|
| 协议 | dpaSigned | 数据处理协议是否签署,版本是否覆盖当前用途 |
| 区域 | regions | 数据在哪些区域处理与存储,是否覆盖请求区域 |
| 留存 | retentionDays | 供应商侧最长保留多久,是否超过数据级别上限 |
| 训练 | trainingUse | 是否用客户数据训练,聚合训练是否可关闭 |
| 通知 | changeNoticeDays | 模型、价格、退役等变更提前多少天通知 |
| 安全 | breachNoticeHours | 安全事件多少小时内通报 |
| 资质 | certifications | SOC 2、ISO 27001、ISO 42001 等是否在有效期 |
| 转委托 | subprocessors | 有哪些子处理者,是否逐一评审 |
八个字段里,最容易被当成「有就行」的是 dpaSigned。实际要问的是协议版本与用途是否匹配:一份只覆盖「技术支持」的协议,不能拿来支撑「用客户数据生成回复」。示例用布尔值简化,生产系统应保存协议 id 与适用场景。
subprocessors 也常被忽略。模型供应商可能把日志、内容审核或向量存储转委托给第三方;主合同签了,子处理者没评审,数据仍然多经过一手。字段里除了列表,还要有一个「已评审」标记,否则列表只是装饰。
按数据级别收紧
同一家供应商,接公开数据和接受限数据的结论可以完全不同。示例按 8.1 的四级给出四套要求。
| 要求 | 公开 | 内部 | 敏感 | 受限 |
|---|---|---|---|---|
| 区域 | 不限 | 不限 | cn、sg | cn |
| 留存上限 | 365 天 | 180 天 | 30 天 | 7 天 |
| 训练用途 | 不限 | 允许聚合 | 禁止训练 | 禁止训练 |
| 变更通知 | 15 天 | 30 天 | 30 天 | 60 天 |
| 安全通报 | 72 小时 | 48 小时 | 24 小时 | 12 小时 |
| 必需资质 | 无 | SOC 2 | SOC 2、ISO 27001 | SOC 2、ISO 27001、ISO 42001 |
这张表的作用不是评分,而是准入。assessProvider 对每个字段独立判定,任何一项不满足就产生一个问题码,返回 ok=false。它不做加权,因为「区域不对但价格便宜」不能抵消区域约束。
级别越高,能通过的供应商越少。示例里两家供应商,敏感级别只有一家通过,受限级别一家都没有。这个结果不是坏消息,而是采购前的真实约束:如果业务要求接受限数据,就必须先找到满足全部条件的供应商,而不是先上线再补协议。
通知时限怎么算
通知时限不是「发了邮件就算」。要同时看三个量:实际通知期、政策要求的最短通知期、以及内部缓冲。
实际通知期等于生效时间减公告时间,按天向下取整。政策最短通知期按变更类型区分:模型版本 30 天、价格 30 天、退役 90 天、区域 60 天、训练策略 60 天、安全事件 7 天。内部缓冲是在最短通知期之外再留的时间,用于回归、评审与灰度:模型版本 14 天、退役 30 天、区域 14 天、训练策略 30 天。
判定分三档。
- 接受:通知期达到最短要求加缓冲,非破坏性变更,或破坏性变更已备好迁移计划。
- 有条件接受:通知期达到最短要求,但破坏性变更缺迁移计划,或落在缓冲窗口内,或受影响能力列表为空。
- 拒绝:通知期低于最短要求(安全变更除外),或变更已经生效且不属于安全事件。
安全变更是唯一允许短通知的类型,但不能静默生效:通知期不足时转为有条件接受,并要求 48 小时内补一次事后评审。这个例外不是放水,而是承认安全事件无法提前 30 天预告;它用事后评审换取响应速度。
可运行的供应商判定
下面的模块把清单评估、变更时限与合并决策放在一起。所有时间都由调用方传入,不读系统时钟,因此测试可以精确断言边界。
// file: src/gov/provider-compliance.ts
// tier: L2
// verify: node --test test/gov/compliance.test.ts
import { DATA_CLASS_RANK } from "./compliance.ts";
import type { DataClass, TrainingUse } from "./compliance.ts";
import { DAY_MS } from "./retention.ts";
export type ProviderChangeKind = "model-version" | "pricing" | "deprecation" | "region" | "training-policy" | "security";
export type Certification = "soc2-type2" | "iso27001" | "iso42001" | "pci-dss";
export interface ProviderRecord {
id: string;
dpaSigned: boolean;
regions: readonly string[];
retentionDays: number;
trainingUse: TrainingUse;
changeNoticeDays: number;
breachNoticeHours: number;
certifications: readonly Certification[];
subprocessors: readonly string[];
subprocessorsReviewed: boolean;
}
export interface ProviderRequirement {
maxRetentionDays: number;
allowedRegions: readonly string[];
requireNoTrain: boolean;
minChangeNoticeDays: number;
minBreachNoticeHours: number;
requiredCertifications: readonly Certification[];
requireDpa: boolean;
}
/** 示例要求,按数据级别收紧;生产值由采购与合规共同确认。 */
export const PROVIDER_REQUIREMENTS: Record<DataClass, ProviderRequirement> = {
public: { maxRetentionDays: 365, allowedRegions: ["*"], requireNoTrain: false, minChangeNoticeDays: 15, minBreachNoticeHours: 72, requiredCertifications: [], requireDpa: true },
internal: { maxRetentionDays: 180, allowedRegions: ["*"], requireNoTrain: false, minChangeNoticeDays: 30, minBreachNoticeHours: 48, requiredCertifications: ["soc2-type2"], requireDpa: true },
sensitive: { maxRetentionDays: 30, allowedRegions: ["cn", "sg"], requireNoTrain: true, minChangeNoticeDays: 30, minBreachNoticeHours: 24, requiredCertifications: ["soc2-type2", "iso27001"], requireDpa: true },
restricted: { maxRetentionDays: 7, allowedRegions: ["cn"], requireNoTrain: true, minChangeNoticeDays: 60, minBreachNoticeHours: 12, requiredCertifications: ["soc2-type2", "iso27001", "iso42001"], requireDpa: true },
};
export type ProviderIssueCode =
| "dpa-missing" | "region-missing" | "retention-too-long" | "training-not-allowed"
| "change-notice-too-short" | "breach-notice-too-slow" | "certification-missing" | "subprocessor-unreviewed";
export interface ProviderIssue { providerId: string; code: ProviderIssueCode; detail: string }
export interface ProviderAssessment {
providerId: string;
dataClass: DataClass;
ok: boolean;
issues: ProviderIssue[];
missingCertifications: Certification[];
}
export function assessProvider(provider: ProviderRecord, dataClass: DataClass): ProviderAssessment {
const requirement = PROVIDER_REQUIREMENTS[dataClass];
const issues: ProviderIssue[] = [];
const push = (code: ProviderIssueCode, detail: string): void => { issues.push({ providerId: provider.id, code, detail }); };
if (requirement.requireDpa && !provider.dpaSigned) push("dpa-missing", "data processing agreement is not signed");
const regionsCovered = requirement.allowedRegions.includes("*")
? provider.regions.length > 0
: requirement.allowedRegions.every((region) => provider.regions.includes(region));
if (!regionsCovered) push("region-missing", `provider regions ${provider.regions.join(",")} do not cover ${requirement.allowedRegions.join(",")}`);
if (provider.retentionDays > requirement.maxRetentionDays) push("retention-too-long", `${provider.retentionDays}d exceeds ${requirement.maxRetentionDays}d`);
if (requirement.requireNoTrain && provider.trainingUse !== "no-train") push("training-not-allowed", `${provider.trainingUse} is not allowed for ${dataClass}`);
if (provider.changeNoticeDays < requirement.minChangeNoticeDays) push("change-notice-too-short", `${provider.changeNoticeDays}d is below ${requirement.minChangeNoticeDays}d`);
if (provider.breachNoticeHours > requirement.minBreachNoticeHours) push("breach-notice-too-slow", `${provider.breachNoticeHours}h exceeds ${requirement.minBreachNoticeHours}h`);
const missingCertifications = requirement.requiredCertifications.filter((certification) => !provider.certifications.includes(certification));
for (const certification of missingCertifications) push("certification-missing", `${certification} is required for ${dataClass}`);
if (provider.subprocessors.length > 0 && !provider.subprocessorsReviewed) push("subprocessor-unreviewed", `${provider.subprocessors.length} subprocessors are unreviewed`);
return { providerId: provider.id, dataClass, ok: issues.length === 0, issues, missingCertifications };
}
export interface ChangeRule { minNoticeDays: number; requiresReview: boolean; bufferDays: number }
export const CHANGE_RULES: Record<ProviderChangeKind, ChangeRule> = {
"model-version": { minNoticeDays: 30, requiresReview: true, bufferDays: 14 },
pricing: { minNoticeDays: 30, requiresReview: false, bufferDays: 7 },
deprecation: { minNoticeDays: 90, requiresReview: true, bufferDays: 30 },
region: { minNoticeDays: 60, requiresReview: true, bufferDays: 14 },
"training-policy": { minNoticeDays: 60, requiresReview: true, bufferDays: 30 },
security: { minNoticeDays: 7, requiresReview: true, bufferDays: 0 },
};
export interface ProviderChange {
providerId: string;
kind: ProviderChangeKind;
breaking: boolean;
announcedAt: number;
effectiveAt: number;
affects: readonly string[];
migrationPlanReady: boolean;
}
export type ChangeVerdict = "accept" | "conditional" | "reject";
export interface ChangeAssessment {
providerId: string;
kind: ProviderChangeKind;
verdict: ChangeVerdict;
noticeDays: number;
requiredDays: number;
remainingDays: number;
issues: string[];
actions: string[];
}
/**
* 通知时限按 announcedAt 到 effectiveAt 计算;安全变更允许短通知,
* 但必须转为事后评审,不能静默生效。
*/
export function assessChange(change: ProviderChange, now: number): ChangeAssessment {
const rule = CHANGE_RULES[change.kind];
const noticeDays = Math.floor((change.effectiveAt - change.announcedAt) / DAY_MS);
const remainingDays = Math.floor((change.effectiveAt - now) / DAY_MS);
const base = { providerId: change.providerId, kind: change.kind, noticeDays, requiredDays: rule.minNoticeDays, remainingDays };
const issues: string[] = [];
const actions: string[] = [];
if (change.effectiveAt <= now && change.kind !== "security") {
issues.push("already-effective");
actions.push("回滚到已评估版本,或切换到备用供应商");
return { ...base, verdict: "reject", issues, actions };
}
if (noticeDays < rule.minNoticeDays) {
if (change.kind !== "security") {
issues.push("late-notice");
actions.push(`通知期 ${noticeDays}d 少于要求的 ${rule.minNoticeDays}d,拒绝本次变更`);
return { ...base, verdict: "reject", issues, actions };
}
issues.push("security-notice-short");
actions.push("按安全例外处理,48 小时内补一次事后评审");
}
if (rule.requiresReview && change.breaking && !change.migrationPlanReady) {
issues.push("migration-plan-missing");
actions.push("提交受影响能力清单、替代版本与回滚开关");
}
if (change.kind !== "security" && change.breaking && noticeDays < rule.minNoticeDays + rule.bufferDays) {
issues.push("no-buffer");
actions.push("推迟生效日到缓冲窗口之后,或准备双版本并行");
}
if (change.affects.length === 0) {
issues.push("no-affected-capabilities");
actions.push("补齐受影响能力列表后再放行");
}
return { ...base, verdict: issues.length === 0 ? "accept" : "conditional", issues, actions };
}
export interface ProviderDecision {
providerId: string;
dataClass: DataClass;
status: "approved" | "approved-with-conditions" | "blocked";
blockers: string[];
assessment: ProviderAssessment;
change: ChangeAssessment;
}
/** 供应商准入与变更评估合并成一个可判定结果,阻塞项必须显式列出。 */
export function decideProviderChange(provider: ProviderRecord, change: ProviderChange, dataClass: DataClass, now: number): ProviderDecision {
const assessment = assessProvider(provider, dataClass);
const changeResult = assessChange(change, now);
const blockers = [
...assessment.issues.map((issue) => `provider:${issue.code}`),
...(changeResult.verdict === "reject" ? changeResult.issues.map((issue) => `change:${issue}`) : []),
];
const status = blockers.length > 0 ? "blocked" : changeResult.verdict === "conditional" ? "approved-with-conditions" : "approved";
return { providerId: provider.id, dataClass, status, blockers, assessment, change: changeResult };
}
/** 最晚可接受的通知日:生效日减去监管要求与内部缓冲。 */
export function latestNoticeDate(change: ProviderChange): number {
const rule = CHANGE_RULES[change.kind];
return change.effectiveAt - (rule.minNoticeDays + rule.bufferDays) * DAY_MS;
}
/** 数据级别越高,允许的供应商集合越小;用于采购前的快速筛选。 */
export function providersForClass(providers: readonly ProviderRecord[], dataClass: DataClass): ProviderRecord[] {
return providers.filter((provider) => assessProvider(provider, dataClass).ok);
}
export function compareProviderStrictness(a: DataClass, b: DataClass): number {
return DATA_CLASS_RANK[a] - DATA_CLASS_RANK[b];
}
assessChange 里有一个容易写错的顺序:先判断「是否已经生效」,再判断通知期。一个 45 天通知期、但生效日已经过去的变更,通知期看起来达标,实际已经没有评估窗口,必须拒绝。如果先判通知期,这类变更会被误放行。
另一个细节是 latestNoticeDate。它返回的是「最晚可接受的通知日」,等于生效日减去最短通知期与缓冲之和。把它写进供应商门户,比在合同里写「应提前通知」更有约束力:超过这个日期公告的变更,系统直接标记为拒绝,不需要人工再算一遍。
示例样本上的运行结果
示例有两家供应商。A 签了数据处理协议,覆盖 cn 与 sg,留存 30 天,禁止训练,变更通知 60 天,安全通报 12 小时,具备 SOC 2 与 ISO 27001,子处理者已评审。B 未签协议,只覆盖 us,留存 90 天,允许聚合训练,通知 10 天,安全通报 72 小时,只有 SOC 2,两个子处理者未评审。
| 检查 | 结果 |
|---|---|
| A 在敏感级别 | 通过,0 个问题 |
| B 在敏感级别 | 不通过,8 个问题 |
| B 缺少的资质 | ISO 27001 |
| 敏感级别可用供应商 | 1 家(A) |
| 受限级别可用供应商 | 0 家 |
B 的 8 个问题分别是:未签协议、区域不覆盖、留存超限、训练用途不允许、通知期不足、安全通报过慢、缺资质、子处理者未评审。八个问题一次性返回,而不是遇到第一个就退出;采购需要完整差距清单,才能判断是补协议还是换供应商。
变更样本以 45 天通知期的模型版本变更为基线。非破坏性变更、迁移计划已备,判定为接受,通知期 45 天、要求 30 天。把 breaking 改成 true 且迁移计划未备,判定为有条件接受,问题为「缺迁移计划」。把生效日提前到 35 天,仍在最短要求之上但落在 14 天缓冲内,判定为有条件接受,问题为「无缓冲」。把通知期压到 10 天,判定为拒绝,问题为「通知过短」。把检查时间移到生效日之后,判定为拒绝,问题为「已经生效」。
安全变更样本只有 2 天通知,最短要求 7 天。它没有被直接拒绝,而是转为有条件接受,问题为「安全通知过短」,动作是 48 小时内补事后评审。这个分支是刻意保留的:安全事件不能等 30 天,但也不能因为「是安全事件」就跳过评估。
latestNoticeDate 在基线上返回生效日前 44 天,即公告后第 1 天。超过这一天公告,变更就进入拒绝区。合并判定把供应商准入与变更评估放在一起:A 加合规变更得到 approved;A 加缺计划的破坏性变更得到 approved-with-conditions;B 加合规变更得到 blocked,阻塞项里包含 provider:dpa-missing。
一次变更评估流程
第一步,登记。把供应商、变更类型、公告时间、生效时间、受影响能力、是否破坏性写入变更台账。没有台账,通知期无法计算。
第二步,算窗口。用 latestNoticeDate 判断公告是否及时;已经生效的变更直接进入拒绝分支,安全事件走例外。
第三步,查准入。用 assessProvider 按受影响数据的最高级别重跑一次清单,确认区域、留存、训练用途与资质没有变化。
第四步,列影响。把受影响能力映射到链路与租户,形成迁移清单;清单为空不放行,因为「不知道影响谁」不等于「没有影响」。
第五步,定方案。破坏性变更必须有迁移计划与回滚开关;落在缓冲窗口内的变更要么推迟生效日,要么准备双版本并行。
第六步,记录与复核。把判定、问题、动作、负责人与复查日期写入台账;有条件接受的变更在条件关闭前不得标记完成。
小结
供应商合规是一组字段,不是一份签字文件。示例用协议、区域、留存、训练用途、变更通知、安全通报、资质与转委托八类字段构成清单,按公开、内部、敏感、受限四级收紧要求。assessProvider 独立判定每个字段并返回完整差距,示例中 A 在敏感级别通过,B 返回 8 个问题,受限级别可用供应商为 0。
变更通知要同时看实际通知期、政策最短要求与内部缓冲。assessChange 先判是否已生效,再判通知期,最后检查迁移计划、缓冲与受影响能力列表,输出接受、有条件接受或拒绝。示例的 45 天模型变更被接受,10 天通知被拒绝,2 天安全通知转为有条件接受并要求事后评审。latestNoticeDate 把最晚公告日写死,让供应商门户可以直接判定。
阅读导航:上一节:8.2 留存、删除与审计 · 下一节:9.1 提示与工具的风险登记 。
常见错误与边界
- 合同只写「应提前通知」。没有天数、没有内容要求,通知无法判定是否合规。
- 把签了协议当成合规。协议版本与用途不匹配,签字也不覆盖当前场景。
- 只看主供应商,不看子处理者。数据可能经第三方日志或审核服务再转一手。
- 用一套要求接所有数据级别。公开与受限用同一份清单,要么过度采购,要么放行越级。
- 通知期从邮件打开时间算。必须从公告时间到生效时间,且按天向下取整。
- 先判通知期再判是否生效。已生效的变更会被误认为通知充足。
- 安全变更直接豁免。短通知可以,但必须转事后评审,不能静默生效。
- 受影响能力列表为空也放行。不知道影响谁,不等于没有影响。
- 变更台账只记结果。没有公告时间、生效时间与问题码,无法复核判定。
- 把示例天数写进合同。真实条款要由法务与合规确认,并按数据级别分别约定。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。