《互联网产品的 AI 架构·平台化与治理》2.1 一条能力条目的最小字段

卷三 11.3 给出了能力目录的七个字段,但没有回答字段如何被机器校验。本节把条目扩展为十一个最小字段,定义标识、契约、默认档位、可覆盖参数、失败语义、计费单位、SLA、下游依赖与退役日期的 schema,用确定性代码检查缺字段、契约错配、依赖环和退役顺序,并逐条说明缺字段在调用、计费、迁移中的后果。

从一张表到一份可校验的 schema

卷三 11.3 已经说明能力目录为什么需要七个字段:能力名、版本、契约、SLO、成本、所有者与变更流程。那一节解决的是「目录该记什么」,这一节解决「记下来的东西如何被机器拒绝」。

差别很具体。七个字段写在文档里,仍然要靠人检查;写成 schema 之后,一条缺提供方、契约引用对不上、依赖成环或退役日期早于依赖的条目,会在提交时直接失败。多团队共享能力时,后者才是目录真正起作用的方式。

本节在 11.3 的基础上把字段扩到十一个:标识、提供方、版本、契约、默认档位、可覆盖参数、失败语义、计费单位、SLA、下游依赖、退役日期。新增的四个字段不是装饰,它们分别回答「谁能改」「失败怎么办」「按什么单位记账」「什么时候必须迁走」。成本不再作为条目字段,而是由计费单位与调用量在计费系统里结算;这样同一能力可以按次、按输入 token 或按输出 token 计价,不会把价格写死在目录里。卷三 11.3 的示例里三能力成本合计 4,240 微元、P95 合计 1,200ms;本节保留这两个合计作为对照,但不把单价塞回条目。

十一个字段分别约束什么

字段形态校验点缺失或写错后的表现
idcontent.summarize小写点分段,至少两段调用方按文档猜名字,指标无法按能力聚合
provider团队标识非空事故、升级与退役找不到责任团队
version语义化版本可解析、同版本唯一范围解析与回滚没有锚点
contractref / inputs / outputs / cacheKeyref 必须等于 contract:id@version;缓存键必须是输入子集同一版本被换契约,调用方运行时才报错
defaultTierfrontier / standard / economy枚举内路由落到一个没人评审过的隐式默认
overridable参数名、允许值、责任归属允许值非空、不重复、责任归属合法调用方不知道改了参数谁负责质量与成本
failureretry-safe / retry-idempotent / no-retry / human-escalation枚举内重试与转人工策略无法从目录推导
billingUnit按次 / 按千输入 token / 按千输出 token枚举内配额与分摊使用互不兼容的单位
sla可用率、P95、统计窗口可用率在 (0,1],P95 为正,窗口写明分母两个 SLA 数字口径不同却被放在一张表里比较
dependsOn能力标识列表存在、非自依赖、无环、不晚于被依赖方退役退役顺序算不出来,迁移当天才发现依赖仍在
retireAtISO 日期或 none真实日期下游无法排期,能力会无声地「一直可用」

契约字段值得单独说。它不是一个文档链接,而是三个可比对的部分:ref 锁定版本,inputs 与 outputs 定义边界,cacheKey 规定调用方做缓存时必须覆盖哪些字段。cacheKey 必须是 inputs 的子集,否则缓存键里会出现能力根本没有承诺的字段,命中率看似很高,实际把不同输入混在一起。

SLA 的窗口也不是注释。availability 的分母必须写清楚:是「该版本的全部调用」,还是「排除调用方输入错误后的调用」。分母不同,99.9% 与 99.9% 可能差出一个数量级。目录不接受一个没有窗口的可用率。

缺字段的后果要能被复述

只告诉团队「必须填」没有用,团队会复制一条最像的旧条目。有效做法是给每个校验码配一句后果,让提交失败时同时看到「为什么」。

  • 缺 provider:事故发生时找不到能改配置的人,升级审批也没有发起者。
  • 缺 contract.inputs:调用方只能照文档猜字段,错了要等到运行时。
  • contract.ref 与 id@version 不一致:版本锁定的语义被破坏,回滚也无法复现。
  • cacheKey 含非输入字段:缓存会把不同租户或不同提示版本的结果混用。
  • 缺 failure:调用方各自决定重试,动作节点可能被重复执行。
  • 缺 billingUnit:租户总额用微元、团队份额用次数,配额无法相加。
  • 缺 retireAt:下游永远不知道什么时候必须迁走,旧版本会无限期滞留。
  • 依赖成环:没有任何安全的发布或退役顺序,两个团队互相等待。

这八条不是理论风险。在示例目录里,一条 chat.reply@9.9.9 同时写错档位、失败语义、SLA 与退役日期,校验一次返回六个问题;如果只在发布后人工检查,通常只会发现第一个。

把校验写成确定性模块

catalog.ts 提供五组能力:最小字段检查、单条目 schema 校验、整目录校验、ISO 日期判断与后果表。整目录校验在单条目之外补三类规则:同一 id@version 不能重复;依赖必须存在且不能指向自己;依赖图不能成环,调用方也不能比它依赖的能力更晚退役。

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

import { compareSemver, parseSemver } from "../orch/platform.ts";

export type QualityTier = "frontier" | "standard" | "economy";
export type FailureMode = "retry-safe" | "retry-idempotent" | "no-retry" | "human-escalation";
export type BillingUnit =
  | "micros-per-call"
  | "micros-per-1k-input-tokens"
  | "micros-per-1k-output-tokens";
export type Responsibility = "provider" | "consumer" | "shared";

export interface Sla {
  /** Success rate in (0, 1]; the denominator is written into window. */
  availability: number;
  p95LatencyMs: number;
  /** Human-readable denominator, for example "per version per rolling 28d". */
  window: string;
}

export interface OverridableParam {
  name: string;
  allowed: readonly (string | number)[];
  responsibility: Responsibility;
}

export interface CapabilityContract {
  /** Must equal contract:<id>@<version>; a version can never swap contracts. */
  ref: string;
  inputs: readonly string[];
  outputs: readonly string[];
  /** Fields that must appear in a consumer cache key. */
  cacheKey: readonly string[];
}

export interface CapabilityEntry {
  id: string;
  provider: string;
  version: string;
  contract: CapabilityContract;
  defaultTier: QualityTier;
  overridable: readonly OverridableParam[];
  failure: FailureMode;
  billingUnit: BillingUnit;
  sla: Sla;
  dependsOn: readonly string[];
  /** "none" or an ISO date (YYYY-MM-DD). */
  retireAt: string;
}

export type EntryIssueCode =
  | "invalid-id"
  | "invalid-version"
  | "missing-provider"
  | "contract-ref-mismatch"
  | "empty-contract"
  | "cache-key-not-input"
  | "invalid-tier"
  | "invalid-override"
  | "duplicate-override"
  | "invalid-failure"
  | "invalid-billing-unit"
  | "invalid-sla"
  | "invalid-retirement-date"
  | "self-dependency"
  | "unknown-dependency"
  | "dependency-cycle"
  | "dependency-retired"
  | "duplicate-version";

export interface EntryIssue {
  id: string;
  version: string;
  code: EntryIssueCode;
  detail: string;
}

export const MINIMUM_FIELDS = [
  "id",
  "provider",
  "version",
  "contract",
  "defaultTier",
  "overridable",
  "failure",
  "billingUnit",
  "sla",
  "dependsOn",
  "retireAt",
] as const;

const ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/;
const DATE = /^\d{4}-\d{2}-\d{2}$/;
const TIERS: readonly QualityTier[] = ["frontier", "standard", "economy"];
const FAILURES: readonly FailureMode[] = [
  "retry-safe",
  "retry-idempotent",
  "no-retry",
  "human-escalation",
];
const BILLING: readonly BillingUnit[] = [
  "micros-per-call",
  "micros-per-1k-input-tokens",
  "micros-per-1k-output-tokens",
];
const RESPONSIBILITIES: readonly Responsibility[] = ["provider", "consumer", "shared"];

export function isIsoDate(value: string): boolean {
  if (!DATE.test(value)) return false;
  const parsed = new Date(`${value}T00:00:00Z`);
  return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
}

/** Report fields a caller forgot, before type-level validation runs. */
export function missingFields(raw: Record<string, unknown>): string[] {
  const missing: string[] = [];
  for (const field of MINIMUM_FIELDS) {
    const value = raw[field];
    if (value === undefined || value === null) {
      missing.push(field);
    } else if (typeof value === "string" && value.trim() === "") {
      missing.push(field);
    }
  }
  return missing;
}

/** One entry's own schema. Cross-entry rules live in validateCatalog. */
export function validateEntry(entry: CapabilityEntry): EntryIssue[] {
  const issues: EntryIssue[] = [];
  const add = (code: EntryIssueCode, detail: string): void => {
    issues.push({ id: entry.id, version: entry.version, code, detail });
  };
  if (!ID.test(entry.id)) add("invalid-id", `bad capability id ${entry.id}`);
  if (parseSemver(entry.version) === null) add("invalid-version", `bad version ${entry.version}`);
  if (entry.provider.trim() === "") add("missing-provider", "provider is empty");
  if (entry.contract.ref !== `contract:${entry.id}@${entry.version}`) {
    add("contract-ref-mismatch", `expected contract:${entry.id}@${entry.version}`);
  }
  if (entry.contract.inputs.length === 0 || entry.contract.outputs.length === 0) {
    add("empty-contract", "inputs and outputs are required");
  }
  const cacheKey = entry.contract.cacheKey;
  const unknownKey = cacheKey.find((field) => !entry.contract.inputs.includes(field));
  if (unknownKey !== undefined || new Set(cacheKey).size !== cacheKey.length) {
    add("cache-key-not-input", `cache key ${unknownKey ?? "has duplicates"} is not an input`);
  }
  if (!TIERS.includes(entry.defaultTier)) add("invalid-tier", `unknown tier ${entry.defaultTier}`);
  const names = new Set<string>();
  for (const param of entry.overridable) {
    if (param.name.trim() === "" || param.allowed.length === 0
      || !RESPONSIBILITIES.includes(param.responsibility)) {
      add("invalid-override", `bad override ${param.name}`);
    }
    if (names.has(param.name)) add("duplicate-override", `duplicate override ${param.name}`);
    names.add(param.name);
  }
  if (!FAILURES.includes(entry.failure)) add("invalid-failure", `unknown failure ${entry.failure}`);
  if (!BILLING.includes(entry.billingUnit)) {
    add("invalid-billing-unit", `unknown billing unit ${entry.billingUnit}`);
  }
  if (!(entry.sla.availability > 0 && entry.sla.availability <= 1)) {
    add("invalid-sla", `availability ${entry.sla.availability}`);
  }
  if (!(Number.isFinite(entry.sla.p95LatencyMs) && entry.sla.p95LatencyMs > 0)) {
    add("invalid-sla", `p95 ${entry.sla.p95LatencyMs}`);
  }
  if (entry.sla.window.trim() === "") add("invalid-sla", "SLA window has no denominator");
  if (entry.retireAt !== "none" && !isIsoDate(entry.retireAt)) {
    add("invalid-retirement-date", `bad retirement date ${entry.retireAt}`);
  }
  return issues;
}

function latest(entries: readonly CapabilityEntry[], id: string): CapabilityEntry | undefined {
  return entries
    .filter((entry) => entry.id === id && parseSemver(entry.version) !== null)
    .sort((a, b) => compareSemver(b.version, a.version))[0];
}

/** Validate the whole catalog, including dependencies and duplicate versions. */
export function validateCatalog(entries: readonly CapabilityEntry[]): EntryIssue[] {
  const issues: EntryIssue[] = [];
  const ids = new Set(entries.map((entry) => entry.id));
  const counts = new Map<string, number>();
  for (const entry of entries) {
    issues.push(...validateEntry(entry));
    const key = `${entry.id}@${entry.version}`;
    counts.set(key, (counts.get(key) ?? 0) + 1);
  }
  for (const [key, count] of counts) {
    if (count > 1) {
      const at = key.lastIndexOf("@");
      issues.push({
        id: key.slice(0, at),
        version: key.slice(at + 1),
        code: "duplicate-version",
        detail: `${count} entries for ${key}`,
      });
    }
  }
  for (const entry of entries) {
    for (const dep of entry.dependsOn) {
      if (dep === entry.id) {
        issues.push({ id: entry.id, version: entry.version, code: "self-dependency", detail: dep });
        continue;
      }
      if (!ID.test(dep) || !ids.has(dep)) {
        issues.push({
          id: entry.id, version: entry.version, code: "unknown-dependency", detail: dep,
        });
        continue;
      }
      const target = latest(entries, dep);
      if (target !== undefined && isIsoDate(target.retireAt)
        && (entry.retireAt === "none" || entry.retireAt > target.retireAt)) {
        issues.push({
          id: entry.id, version: entry.version, code: "dependency-retired",
          detail: `${dep} retires at ${target.retireAt}`,
        });
      }
    }
  }
  const graph = new Map<string, string[]>();
  for (const entry of entries) {
    graph.set(entry.id, entry.dependsOn.filter((dep) => ids.has(dep) && dep !== entry.id));
  }
  const state = new Map<string, 1 | 2>();
  const reported = new Set<string>();
  const visit = (id: string): void => {
    state.set(id, 1);
    for (const dep of graph.get(id) ?? []) {
      if (state.get(dep) === 1 && !reported.has(id)) {
        const entry = entries.find((item) => item.id === id)!;
        issues.push({
          id, version: entry.version, code: "dependency-cycle", detail: `cycle via ${dep}`,
        });
        reported.add(id);
      } else if (state.get(dep) === undefined) {
        visit(dep);
      }
    }
    state.set(id, 2);
  };
  for (const entry of entries) {
    if (state.get(entry.id) === undefined) visit(entry.id);
  }
  return issues;
}

/** Why a missing field hurts, used by the catalog review checklist. */
export const FIELD_CONSEQUENCES: Record<EntryIssueCode, string> = {
  "invalid-id": "callers cannot address the capability or join metrics by name",
  "invalid-version": "range resolution and rollback have no anchor",
  "missing-provider": "no team owns incidents, upgrades or retirement",
  "contract-ref-mismatch": "a version may silently change its payload",
  "empty-contract": "callers guess fields and only fail at runtime",
  "cache-key-not-input": "cache hits mix tenants or reuse stale inputs",
  "invalid-tier": "routing falls back to an implicit default nobody reviewed",
  "invalid-override": "an allowed value cannot be explained or audited",
  "duplicate-override": "two owners claim the same parameter",
  "invalid-failure": "retry and escalation policy cannot be derived",
  "invalid-billing-unit": "quota and chargeback use incompatible units",
  "invalid-sla": "capacity planning compares numbers with different denominators",
  "invalid-retirement-date": "downstream plans cannot schedule a migration",
  "self-dependency": "the dependency graph never reaches a leaf",
  "unknown-dependency": "retirement order cannot be computed",
  "dependency-cycle": "no safe retirement or rollout order exists",
  "dependency-retired": "the caller outlives a capability it still needs",
  "duplicate-version": "two payloads claim one immutable version",
};

代码里有两个刻意的取舍。第一,missingFields 把空数组视为「显式声明没有」,只有字段不存在、为 null 或空白字符串才算缺失;dependsOn: [] 是一条合法声明,不应该被当成漏填。第二,依赖退役检查只比较 ISO 日期字符串,不引入时区换算;目录日期是日历日,不是时刻,比较字符串已经足够且更稳定。

后果表用 Record<EntryIssueCode, string> 声明,新增一个校验码却忘记写后果时,TypeScript 会在编译期报错。这比在评审会上提醒「记得补文档」可靠。

示例样本上的运行结果

示例目录包含回声的五个能力:content.summarize、moderation.classify、chat.reply、support.assist、search.rerank,依赖关系为摘要依赖审核、回复依赖摘要与审核、客服辅助依赖回复。以下是示例样本上的运行结果。

操作结果
五条完整条目0 个问题
只填 id、provider、version缺 8 个字段:contract、defaultTier、overridable、failure、billingUnit、sla、dependsOn、retireAt
三条问题条目11 个问题:search 4 个(版本、提供方、契约引用、空契约)、chat 6 个(档位、失败语义、SLA 三处、退役日期)、自依赖 1 个
重复 id@version1 个 duplicate-version
两个能力互相依赖1 个 dependency-cycle
调用方退役晚于被依赖能力1 个 dependency-retired

五条完整条目里,chat.reply 声明了 99.9% 可用率、420ms P95、按次计费与 retry-idempotent 失败语义;search.rerank 只有输入输出与缓存键,没有可覆盖参数。两者都能通过校验,因为「没有可覆盖参数」是显式声明,不是缺字段。

三条问题条目共 11 个问题,说明校验的价值不是找到一个错,而是一次把同一份提交里的错全部列出来。团队修完一轮即可重新提交,不用在「发布—报错—再发布」之间来回。

目录条目的三条硬规则

第一条,版本与契约绑定。contract:content.summarize@3.1.0 只能对应 3.1.0 的输入输出;字段变化必须发新版本,不能原地改契约。

第二条,依赖必须可排序。dependsOn 不只是展示关系,它决定发布与退役顺序。调用方的 retireAt 不能晚于它依赖的能力,否则迁移计划会在最后一步失败。

第三条,责任写进参数。可覆盖参数不是「调用方随便调」,每个允许值都带一个责任归属:provider 表示提供方仍对 SLA 负责,consumer 表示覆盖后的质量与成本由调用方承担,shared 表示双方按约定分摊。没有责任归属的覆盖,就是一次没有签字的变更。

小结

卷三 11.3 的七个字段回答「目录记什么」,本节把它们落成可校验 schema,并补上默认档位、可覆盖参数、失败语义、计费单位、下游依赖与退役日期。十一个字段分别对应标识、责任、兼容、边界、路由、变更权、失败策略、计价口径、容量承诺、发布顺序与生命周期。

校验模块在示例样本上对五条完整条目返回 0 个问题,对三条问题条目一次返回 11 个问题;只填三个字段的条目会被指出缺 8 项。目录只有能拒绝不合格条目,才配叫治理工具;否则它只是一张更长的 README。

阅读导航:上一节:1.3 平台与业务的职责边界 · 下一节:2.2 提供方与调用方的契约 。

常见错误与边界

  • 把目录条目写成介绍卡。没有版本、契约与提供方,任何自动校验都无从谈起。
  • 把 cacheKey 当注释。缓存键不覆盖租户与提示版本,命中率越高,串数据越隐蔽。
  • 用浮点金额记录价格。计费单位与调用量相乘会出现精度问题,统一用整数微元结算。
  • 允许任意参数覆盖。没有允许值列表和责任归属,覆盖就是绕过提供方的 SLA。
  • 把 dependsOn 当展示关系。它决定发布与退役顺序,必须参与环检测。
  • 调用方退役日期晚于依赖。迁移计划会在最后一步发现依赖已经下线。
  • 只校验单条目,不校验整目录。重复版本、依赖环与退役顺序都跨条目。
  • 把空数组当成漏填。dependsOn: [] 与「没有这个字段」是两种语义。
  • 一次只报一个错。团队会在发布与修字段之间来回多轮,成本远高于一次列全。
  • 目录校验通过就认为能力可共享。SLA、配额与调用方契约仍要单独评审。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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