同一个能力,三个团队跑出三种行为
平台把 chat.reply 的默认档位从 economy 调到 standard,理由是标准档的回复质量更稳定。上线第二天,三个团队报来三种结果:客服团队的回复变好了,内容团队的摘要成本涨了 18%,交易团队的下单咨询延迟从 793ms 涨到 900ms。
查配置发现,三个团队都在自己的仓库里写了一份「默认值」:客服团队读平台配置,内容团队把档位写死在代码里,交易团队在请求里覆盖了档位。平台改的是第一层,另外两层根本没动。
这不是模型问题,是配置分发问题。同一个能力,如果默认值、覆盖优先级与下发方式没有统一定义,平台改一次配置,就等于在三个团队里做三次不同的变更。
SDK 与 CLI 各管什么
先分清两个交付物的职责。它们共用同一份 schema,但服务的时刻不同。
| 职责 | SDK | CLI |
|---|---|---|
| 类型与默认值 | 编译期类型、运行时默认值 | 展示当前生效值与来源 |
| 失败处理 | 重试、超时、幂等 | 不自动重试,失败即报错 |
| 配置解析 | 六层叠加、来源标注 | 校验、schema diff、冲突诊断 |
| 灰度 | 按主体分桶、上报指标 | 查看灰度状态、放量、回滚 |
| 审计 | 调用级埋点 | 变更记录导出 |
SDK 的目标是「运行时正确」:业务方不写配置也能跑,写了配置也知道最终生效的是哪一层。CLI 的目标是「变更时可见」:上线前能校验,上线后能查状态,出问题能回滚。
两者最容易混的地方是重试。SDK 需要重试,因为网络抖动是常态;CLI 不能重试,因为一条「发布配置」的命令重试两次,可能把两次变更叠在一起。CLI 的幂等应该由服务端的变更单号保证,而不是客户端自动重发。
另一个容易混的地方是默认值。SDK 里的默认值必须来自平台 schema,不能在 SDK 里另写一份。否则平台改默认值,SDK 用户不升级就永远看不到。正确做法是 SDK 启动时拉取 schema 版本,本地只保留「拉不到时的最小安全默认值」,并在指标里标记「使用了离线默认值」。
六层覆盖:从默认值到请求
配置分六层,优先级从低到高:default、platform、capability、tenant、team、request。高层覆盖低层,但每一层能覆盖哪些字段,由 schema 决定,不由调用方决定。
| 层 | 谁维护 | 典型内容 | 变更频率 |
|---|---|---|---|
| default | 平台 | 安全默认值、类型 | 随 SDK 版本 |
| platform | 平台 | 全局档位、超时、重试上限 | 周 |
| capability | 能力提供方 | 该能力的提示版本、输出约束 | 天 |
| tenant | 租户管理员 | 租户级档位、预算策略 | 天 |
| team | 团队负责人 | 团队档位、配额内偏好 | 小时 |
| request | 调用方代码 | 单次请求的显式覆盖 | 每次调用 |
优先级高不代表权限大。safety.threshold 这类安全字段可以锁在 platform 层,租户与团队写了也会被拒绝,并产生一条 not-overridable 记录。反过来,retry.maxAttempts 可以开放到 request 层,让调用方按自己的 deadline 调整。
这里有一个设计取舍:锁定字段会让部分团队觉得「不灵活」,但它避免了「每个团队一套安全阈值」。安全阈值的差异不应该由业务团队各自解释,否则事故复盘时无法判断「是平台默认值错了,还是某个团队调松了」。
解析规则:非法值不静默生效
解析器按 source 从低到高叠加,遇到四类问题时记录 issue 并保留上一层结果:
第一,字段不在 schema 里,记为 unknown-key。这通常意味着 SDK 与服务端版本不一致,或者调用方拼错了字段名。
第二,类型不匹配,记为 type-mismatch。数字字段收到字符串是最常见的情况,尤其是从环境变量读取配置时。
第三,数值越界或枚举不合法,记为 out-of-range 或 invalid-enum。例如重试次数上限是 3,团队写了 4。
第四,覆盖层超过了字段允许的最高层,记为 not-overridable。例如安全阈值只允许平台设置,租户写了也会被忽略。
四类问题都只影响当前字段,不影响其他字段。一次配置下发要能报告全部问题,而不是遇到第一个错误就中断;否则用户要改一轮、跑一轮,来回很多次。
灰度下发:分桶稳定,放量单调
配置不能一次全量。平台需要按主体分桶,先放 5%,观察 2 小时,再放 25%、50%、100%。
分桶的输入只有两个:主体标识与配置版本。用 FNV-1a 哈希取模 100,得到 0 到 99 的桶号。桶号小于当前百分比的主体使用新配置,其余使用基线配置。
这个设计有两个性质。第一,稳定:同一个主体在同一个配置版本下,无论查多少次,桶号都一样。第二,单调:百分比从 5% 涨到 25%,原来在 5% 里的主体仍然在新配置里,不会退回基线。单调性很重要,否则一个租户会在灰度期间反复切换配置,指标无法归因。
灰度期间要同时看两类指标:新配置组的成功率与单次成本,以及基线组的同一组指标。只看新配置组的绝对值没有意义,因为流量结构可能不同;必须与同期基线比较。
实现
下面的模块只做三件事:六层配置解析、灰度分桶与快照、schema 兼容判定。它不读远端配置中心,也不发变更请求。
// file: src/gov/config-dist.ts
// tier: L2
// verify: node --test test/gov/platform-eng.test.ts
import { AiError } from "../core/errors.ts";
/** 配置从默认值到请求级覆盖共六层,数字越大优先级越高。 */
export type ConfigSource = "default" | "platform" | "capability" | "tenant" | "team" | "request";
export type ConfigValue = string | number | boolean;
export const SOURCE_RANK: Record<ConfigSource, number> = {
default: 0, platform: 1, capability: 2, tenant: 3, team: 4, request: 5,
};
export interface ConfigField {
type: "string" | "number" | "boolean";
/** 允许覆盖到哪一层;更高的层会被拒绝,而不是静默生效。 */
overridableTo: ConfigSource;
min?: number; max?: number; enum?: readonly string[];
}
export type ConfigSchema = Record<string, ConfigField>;
export interface ConfigLayer { source: ConfigSource; values: Record<string, ConfigValue> }
export type ConfigIssueCode =
| "unknown-key" | "type-mismatch" | "out-of-range"
| "invalid-enum" | "not-overridable" | "unknown-source";
export interface ConfigIssue { key: string; source: string; code: ConfigIssueCode; detail: string }
export interface ResolvedConfig {
values: Record<string, ConfigValue>;
provenance: Record<string, ConfigSource>;
issues: ConfigIssue[];
}
function invalidValue(field: ConfigField, value: ConfigValue): ConfigIssue | null {
if (field.type === "number") {
if (typeof value !== "number" || !Number.isFinite(value)) {
return { key: "", source: "", code: "type-mismatch", detail: "expected number" };
}
if ((field.min !== undefined && value < field.min) || (field.max !== undefined && value > field.max)) {
return { key: "", source: "", code: "out-of-range", detail: `expected [${field.min ?? "-inf"}, ${field.max ?? "inf"}]` };
}
return null;
}
if (field.type === "boolean") {
return typeof value === "boolean" ? null : { key: "", source: "", code: "type-mismatch", detail: "expected boolean" };
}
if (typeof value !== "string") {
return { key: "", source: "", code: "type-mismatch", detail: "expected string" };
}
return field.enum !== undefined && !field.enum.includes(value)
? { key: "", source: "", code: "invalid-enum", detail: `expected one of ${field.enum.join("|")}` }
: null;
}
/**
* 按 source 从低到高叠加。非法值只记 issue 并保留上一层结果,
* 一次下发要能报告全部问题,而不是遇到第一个错误就中断。
*/
export function resolveConfig(schema: ConfigSchema, layers: readonly ConfigLayer[]): ResolvedConfig {
const values: Record<string, ConfigValue> = {};
const provenance: Record<string, ConfigSource> = {};
const issues: ConfigIssue[] = [];
const ordered = layers
.map((layer, index) => ({ layer, index, rank: SOURCE_RANK[layer.source] }))
.sort((left, right) => left.rank - right.rank || left.index - right.index);
for (const { layer, rank } of ordered) {
if (rank === undefined) {
issues.push({ key: "*", source: String(layer.source), code: "unknown-source", detail: "unknown config source" });
continue;
}
for (const [key, value] of Object.entries(layer.values)) {
const field = schema[key];
if (field === undefined) {
issues.push({ key, source: layer.source, code: "unknown-key", detail: "not in schema" });
continue;
}
if (layer.source !== "default" && rank > SOURCE_RANK[field.overridableTo]) {
issues.push({ key, source: layer.source, code: "not-overridable", detail: `highest allowed source is ${field.overridableTo}` });
continue;
}
const invalid = invalidValue(field, value);
if (invalid !== null) {
issues.push({ ...invalid, key, source: layer.source });
continue;
}
values[key] = value;
provenance[key] = layer.source;
}
}
return { values, provenance, issues };
}
// ---- 灰度下发:同一主体在同一配置版本下的分桶必须稳定 ----
export interface CanaryStage { percent: number; hours: number }
export interface CanaryPlan {
configVersion: string;
baselineVersion: string;
stages: readonly CanaryStage[];
}
export const DEFAULT_CANARY: readonly CanaryStage[] = [
{ percent: 5, hours: 2 }, { percent: 25, hours: 24 },
{ percent: 50, hours: 24 }, { percent: 100, hours: 0 },
];
export function validateCanary(plan: CanaryPlan): string[] {
const issues: string[] = [];
if (plan.configVersion.trim() === "") issues.push("configVersion is empty");
if (plan.stages.length === 0) issues.push("stages is empty");
let previous = 0;
plan.stages.forEach((stage, index) => {
if (!Number.isInteger(stage.percent) || stage.percent <= previous || stage.percent > 100) {
issues.push(`stage ${index}: percent must increase within 1..100`);
}
if (!Number.isFinite(stage.hours) || stage.hours < 0) {
issues.push(`stage ${index}: hours must be non-negative`);
}
previous = stage.percent;
});
if (plan.stages.length > 0 && plan.stages[plan.stages.length - 1].percent !== 100) {
issues.push("last stage must be 100");
}
return issues;
}
/** 32 位 FNV-1a 只做稳定分桶,不作为安全摘要。 */
export function canaryBucket(subjectId: string, configVersion: string): number {
let hash = 0x811c9dc5;
const input = `${configVersion}:${subjectId}`;
for (let index = 0; index < input.length; index += 1) {
hash ^= input.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash % 100;
}
export interface CanarySnapshot {
stageIndex: number; percent: number; elapsedHours: number; complete: boolean;
canarySubjects: string[]; baselineSubjects: string[]; buckets: Record<string, number>;
}
/** 分桶只看主体与配置版本;百分比变大时,已进入灰度的主体不会退回基线。 */
export function canarySnapshot(
plan: CanaryPlan, startMs: number, nowMs: number, subjects: readonly string[],
): CanarySnapshot {
const problems = validateCanary(plan);
if (problems.length > 0) throw new AiError("invalid-request", `config-dist: ${problems.join("; ")}`);
let remaining = Math.max(0, nowMs - startMs) / 3_600_000;
let stageIndex = 0;
let percent = plan.stages[0].percent;
let complete = false;
for (let index = 0; index < plan.stages.length; index += 1) {
const stage = plan.stages[index];
if (index === plan.stages.length - 1 || remaining < stage.hours) {
stageIndex = index;
percent = stage.percent;
complete = index === plan.stages.length - 1 && remaining >= stage.hours;
break;
}
remaining -= stage.hours;
}
const buckets: Record<string, number> = {};
const canarySubjects: string[] = [];
const baselineSubjects: string[] = [];
for (const subject of subjects) {
const bucket = canaryBucket(subject, plan.configVersion);
buckets[subject] = bucket;
if (bucket < percent) canarySubjects.push(subject);
else baselineSubjects.push(subject);
}
return {
stageIndex, percent, elapsedHours: Math.max(0, nowMs - startMs) / 3_600_000,
complete, canarySubjects, baselineSubjects, buckets,
};
}
// ---- 向后兼容:只允许新增字段与放宽约束 ----
/** 删除字段、改类型、收窄枚举或数值范围都会破坏已在用的配置。 */
export function schemaCompatibility(
before: ConfigSchema, after: ConfigSchema,
): { breaking: boolean; reasons: string[] } {
const reasons: string[] = [];
for (const key of Object.keys(before)) {
const left = before[key];
const right = after[key];
if (right === undefined) {
reasons.push(`${key}:removed`);
continue;
}
if (left.type !== right.type) reasons.push(`${key}:type-changed`);
if (right.enum !== undefined) {
const beforeEnum = left.enum;
if (beforeEnum === undefined || beforeEnum.some((value) => !right.enum!.includes(value))) {
reasons.push(`${key}:enum-narrowed`);
}
}
if (right.min !== undefined && (left.min === undefined || right.min > left.min)) {
reasons.push(`${key}:range-narrowed`);
}
if (right.max !== undefined && (left.max === undefined || right.max < left.max)) {
reasons.push(`${key}:range-narrowed`);
}
}
return { breaking: reasons.length > 0, reasons };
}
/** SDK 负责运行时正确,CLI 负责变更时可见;两者共用同一份 schema。 */
export const SURFACE_DUTY = {
sdk: ["类型与默认值", "重试、超时与幂等", "配置解析与来源标注", "调用指标埋点"],
cli: ["目录与版本查询", "配置校验与 schema diff", "灰度状态与放量", "回滚与审计导出"],
} as const;
resolveConfig 的排序是稳定的:先按 source 等级,再按传入顺序。同一层内出现重复字段时,后传入的覆盖先传入的;但更常见的错误是同一层被拆成两个对象,调用方以为它们会合并,实际后一个整体覆盖前一个。要避免这种歧义,配置中心应该保证「一个 source 一个对象」。
canarySnapshot 的时间计算以小时为单位,阶段时长是累计的。示例计划是 2 小时、24 小时、24 小时、0 小时:第 0 小时在 5%,第 3 小时进入 25%,第 27 小时进入 50%,第 50 小时进入 100%。最后一档的 0 小时表示「到达即完成」,不是「立即全量」。
schemaCompatibility 只检查删除、改类型、收窄枚举与收窄范围。新增字段被认为是兼容的,但它有一个前提:SDK 必须忽略未知字段。如果 SDK 遇到未知字段就报错,新增字段也会变成破坏性变更。
示例样本上的运行结果
用回声的四字段 schema 与六层配置跑一遍,结果如下。所有数字都是示例样本上的运行结果。
| 字段 | 生效值 | 来源 | 说明 |
|---|---|---|---|
model.tier | frontier | team | 团队层覆盖了租户与平台 |
safety.threshold | 0.7 | platform | 租户与请求层的覆盖被拒绝 |
retry.maxAttempts | 2 | request | 请求层显式覆盖 |
prompt.version | v3 | tenant | 团队层的 v4 越权被拒绝 |
同一份输入产生 5 条 issue:团队层写 prompt.version 是 not-overridable,团队层写重试 4 次是 out-of-range,请求层写安全阈值是 not-overridable,租户层多了一个未知字段是 unknown-key,租户层把提示版本写成数字是 type-mismatch。解析器没有因为第一条错误停下,最终值仍是各层合法覆盖的结果。
灰度样本用 200 个租户、配置版本 cfg-2026-11-a,四档的实际分桶如下。
| 时间 | 阶段 | 目标比例 | 实际进入新配置 | 基线 |
|---|---|---|---|---|
| 第 0 小时 | 5% | 5% | 10 | 190 |
| 第 3 小时 | 25% | 25% | 51 | 149 |
| 第 27 小时 | 50% | 50% | 103 | 97 |
| 第 50 小时 | 100% | 100% | 200 | 0 |
实际比例与目标比例不完全相等,这是哈希分桶的正常现象:5% 对应 10/200,恰好相等;25% 对应 51/200,是 25.5%;50% 对应 103/200,是 51.5%。样本越大越接近目标,但永远不要假设「5% 就是精确的 10 个租户」。
向后兼容:只加不减,只宽不窄
配置 schema 的变更分两类。可加的变更包括新增字段、放宽数值范围、增加枚举值;破坏性变更包括删除字段、改类型、收窄范围、收窄枚举。
| 变更 | 是否兼容 | 原因 |
|---|---|---|
| 新增可选字段 | 是 | 旧 SDK 忽略未知字段即可 |
| 新增必填字段 | 否 | 旧 SDK 不会提供,服务端会判空 |
| 删除字段 | 否 | 仍在读该字段的调用方会拿到 undefined |
| 数字范围 0..3 改为 0..5 | 是 | 旧值仍然合法 |
| 数字范围 0..3 改为 0..2 | 否 | 旧配置里的 3 会变成非法 |
| 枚举增加一个值 | 是 | 旧值仍然合法 |
| 枚举删除一个值 | 否 | 使用旧值的配置会失效 |
兼容性判定只回答「旧配置还能不能用」,不回答「旧行为是否还正确」。新增一个字段可能兼容 schema,但如果它改变了默认行为,仍然需要灰度与回归。把 schema 兼容当成行为兼容,是配置发布最常见的误判。
破坏性变更需要一次主版本升级,并给调用方一个迁移窗口。窗口的长度可以参考能力目录的弃用策略:至少覆盖一个发布周期,并且要在 CLI 的 diff 输出里明确列出「哪些字段会失效、哪些配置需要改」。
一次配置发布的标准流程
把上面的规则串起来,一次配置发布应该有六步。
第一步,本地校验。CLI 读取 schema 与所有层,跑一遍 resolveConfig,确认没有 unknown-key、type-mismatch 与 out-of-range。
第二步,schema diff。如果是破坏性变更,停止发布,先走主版本升级与迁移窗口。
第三步,生成灰度计划。默认 5% / 2 小时、25% / 24 小时、50% / 24 小时、100%。高风险字段可以缩短第一档时间,但不能跳过。
第四步,发布 5%。观察新配置组与基线组的成功率、单次成本与 P95。任何一项超出预设容忍度,回滚。
第五步,逐档放量。每一档都要重新比较,不能因为 5% 没事就默认 25% 也没事;流量结构在不同比例下可能不同。
第六步,全量后保留基线版本至少一个窗口。回滚不是「再发一次旧配置」,而是「把指针切回基线版本」,两者的审计记录不同。
小结
配置分发的核心不是「把值传到客户端」,而是三件事:谁有权覆盖、覆盖后以谁为准、变更如何逐步生效。SDK 负责运行时正确,CLI 负责变更时可见,两者共用一份 schema。
六层覆盖里,锁定字段与可覆盖字段要显式声明;非法值只记 issue、不静默生效。灰度分桶要稳定且单调,示例样本的 200 个租户在 5%、25%、50%、100% 四档分别进入 10、51、103、200 个。向后兼容只允许新增与放宽,删除、改类型与收窄都必须走主版本迁移。
阅读导航:上一节:10.1 平台的自助服务边界 · 下一节:10.3 平台自身的 SLO 与容量 。
常见错误与边界
- 在 SDK 里另写一份默认值。平台改默认值后,不升级 SDK 的用户永远看不到。
- 让 CLI 自动重试发布命令。两次重试可能叠加成两次变更。
- 把优先级当成权限。高层能覆盖,不代表高层能覆盖所有字段。
- 锁定字段不报错。静默忽略会让用户以为配置生效了。
- 配置解析遇到第一个错误就中断。用户要来回很多轮才能改完。
- 灰度分桶用随机数。同一主体每次请求可能切换配置,指标无法归因。
- 灰度比例不单调。已进入灰度的主体退回基线,等于人为制造抖动。
- 只看新配置组的绝对值。不与同期基线比较,流量结构差异会被误读。
- 把 schema 兼容当成行为兼容。新增字段可能改变默认行为,仍需回归。
- 新增必填字段却宣称兼容。旧 SDK 不会提供该字段,服务端会判空。
- 破坏性变更不留迁移窗口。调用方会在发布当天集体失效。
- 全量后立刻删除基线版本。回滚时无版本可切,只能重新构造旧配置。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。