《互联网产品的 AI 架构·平台化与治理》10.2 SDK、CLI 与配置分发

同一个能力,三个团队跑出三种行为。本节把问题拆成 SDK 与 CLI 的职责分界、六层配置覆盖、灰度下发与向后兼容四件事,用可运行代码解析默认值、平台、能力、租户、团队与请求六层配置,并在 200 个租户样本上跑出 5%、25%、50%、100% 四档灰度的实际分桶结果。

同一个能力,三个团队跑出三种行为

平台把 chat.reply 的默认档位从 economy 调到 standard,理由是标准档的回复质量更稳定。上线第二天,三个团队报来三种结果:客服团队的回复变好了,内容团队的摘要成本涨了 18%,交易团队的下单咨询延迟从 793ms 涨到 900ms。

查配置发现,三个团队都在自己的仓库里写了一份「默认值」:客服团队读平台配置,内容团队把档位写死在代码里,交易团队在请求里覆盖了档位。平台改的是第一层,另外两层根本没动。

这不是模型问题,是配置分发问题。同一个能力,如果默认值、覆盖优先级与下发方式没有统一定义,平台改一次配置,就等于在三个团队里做三次不同的变更。

SDK 与 CLI 各管什么

先分清两个交付物的职责。它们共用同一份 schema,但服务的时刻不同。

职责SDKCLI
类型与默认值编译期类型、运行时默认值展示当前生效值与来源
失败处理重试、超时、幂等不自动重试,失败即报错
配置解析六层叠加、来源标注校验、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.tierfrontierteam团队层覆盖了租户与平台
safety.threshold0.7platform租户与请求层的覆盖被拒绝
retry.maxAttempts2request请求层显式覆盖
prompt.versionv3tenant团队层的 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%10190
第 3 小时25%25%51149
第 27 小时50%50%10397
第 50 小时100%100%2000

实际比例与目标比例不完全相等,这是哈希分桶的正常现象: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 不会提供该字段,服务端会判空。
  • 破坏性变更不留迁移窗口。调用方会在发布当天集体失效。
  • 全量后立刻删除基线版本。回滚时无版本可切,只能重新构造旧配置。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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