《互联网产品的 AI 架构·多能力编排与任务闭环》11.3 多团队共享能力目录

能力目录不是一张 README 表,而是多团队复用的版本契约。本节定义能力名、版本、契约、SLO、成本、所有者与变更流程七个字段,用可运行代码解决版本解析、所有者校验与升级审批,并回答「谁负责升级」,给出三档变更流程、审批规则与灰度验收步骤。

一次升级打穿了三条链路

「回声」的摘要能力由内容团队维护。某个周二,他们发布了 content.summarize 3.1.1,把输出从纯文本改成了带引用列表的结构。调用方没有收到通知:链路 A 仍按旧格式解析,链路 B 的回复里混进了引用标记,链路 C 的评测集直接判为格式错误。

事故复盘时发现,摘要能力在文档里只有一个名字和一句描述。没有版本、没有契约、没有所有者、没有变更流程。调用方只能看到「摘要」两个字,不知道自己依赖的是哪个版本,也不知道升级该找谁。

能力目录要解决的就是这个问题。它不是给能力写介绍,而是给多团队复用建立一份可查询、可校验、可审批的版本契约。目录里的每一行都对应一个明确的责任主体和一条明确的升级路径。

七个字段

一个可用的能力目录条目至少包含七个字段。缺任何一个,都会在某个环节失效。

字段含义缺失后的表现
能力名稳定的逻辑标识,如 content.summarize调用方按文档猜名字,拼写不一致
版本语义化版本,同一版本不可复用无法表达兼容性,升级只能全量替换
契约与能力名和版本绑定的输入输出 schema上游改字段,下游运行时才报错
SLO可用率与 p95 延迟,注明分母无法判断某个版本能否承载新链路
成本每次调用的整数微元预算分配只能拍脑袋,无法归因
所有者负责发布、升级、弃用的团队出问题找不到人,升级无人审批
变更流程patch-auto / minor-review / major-migration小改动走大流程,或大改动绕过评审

七个字段里,「契约」与「版本」必须绑定。contract:content.summarize@3.1.1 表示这个契约只属于 3.1.1;同一个版本号不能换一份契约,否则调用方锁定的版本就失去意义。契约引用不是文档链接,而是可以被解析和比对的标识。

成本字段用整数微元,不用浮点金额。示例目录里 search.rerank 是 900 微元,content.summarize 是 2,180 微元,chat.reply 是 1,160 微元,三者合计 4,240 微元,与链路 A 独立调用成本一致。p95 延迟分别是 160ms、620ms、420ms,合计 1,200ms。这两个合计让目录可以直接参与预算与延迟核算,而不是只做展示。

谁负责升级

多团队复用最容易扯皮的问题是「谁负责升级」。目录给出的规则是:目标版本的所有者发起升级,原版本的所有者在跨所有者大版本上审批,平台负责流程评审。

具体分三档。补丁升级由目标版本所有者发起并自审,因为补丁不改变契约语义。次版本升级需要平台评审,确认新增字段或行为不会破坏现有调用方。主版本升级需要目标所有者、原所有者与平台三方审批;如果所有权已经转移,原所有者仍要在迁移窗口内确认旧调用方已迁出。

调用方的责任同样明确:默认使用版本范围而不是精确版本,例如 ^2.0.0;精确锁定只用于必须复现的场景。范围让补丁与次版本可以自动进入,但也要求调用方在 CI 里跑契约测试,而不是等到运行时才发现不兼容。

这套规则把「升级」从一次口头通知变成了一个可计算的计划:从哪个版本到哪个版本、需要哪些审批、按什么顺序灰度、何时标记弃用。目录不替团队做决定,但让决定有据可查。

查询、版本解析与所有者校验

catalog.ts 提供四组能力:目录校验、条件查询、版本解析、所有者与升级计划。校验一次返回全部字段问题;查询按能力名、所有者、成本上限和可用率过滤;版本解析区分精确锁定与范围选择;升级计划检查方向、所有权与变更流程是否足够强。

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

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

export type ChangeProcess = "patch-auto" | "minor-review" | "major-migration";

export interface CapabilitySlo {
  /** 成功返回可用结果的比例,分母是该版本的全部调用。 */
  availability: number; p95LatencyMs: number;
}

export interface CapabilityVersion {
  version: string;
  /** 契约必须与能力名和版本绑定,同一个版本不能换契约。 */
  contractRef: string;
  slo: CapabilitySlo; costMicrosPerCall: number;
  owner: string; changeProcess: ChangeProcess; deprecated?: boolean;
}

export interface CapabilityEntry { name: string; description: string; versions: readonly CapabilityVersion[] }
export interface CapabilityCatalog { entries: readonly CapabilityEntry[] }

export type CatalogIssueCode =
  | "invalid-name" | "duplicate-capability" | "no-versions" | "invalid-version"
  | "duplicate-version" | "missing-owner" | "invalid-slo" | "invalid-cost"
  | "invalid-change-process" | "contract-mismatch";

export interface CatalogIssue { capability: string; version?: string; code: CatalogIssueCode; detail: string }

const PROCESSES: readonly ChangeProcess[] = ["patch-auto", "minor-review", "major-migration"];
const PROCESS_RANK: Record<ChangeProcess, number> = {
  "patch-auto": 0, "minor-review": 1, "major-migration": 2,
};

function catalogIssue(
  capability: string,
  code: CatalogIssueCode,
  detail: string,
  version?: string,
): CatalogIssue {
  return version === undefined ? { capability, code, detail } : { capability, version, code, detail };
}

export function validateCatalog(catalog: CapabilityCatalog): CatalogIssue[] {
  const issues: CatalogIssue[] = [];
  const names = new Set<string>();
  for (const entry of catalog.entries) {
    if (entry.name.trim() === "") issues.push(catalogIssue(entry.name, "invalid-name", "name is empty"));
    if (names.has(entry.name)) issues.push(catalogIssue(entry.name, "duplicate-capability", "appears twice"));
    names.add(entry.name);
    if (entry.versions.length === 0) issues.push(catalogIssue(entry.name, "no-versions", "no versions"));
    const versions = new Set<string>();
    for (const version of entry.versions) {
      const label = `${entry.name}@${version.version}`;
      if (parseSemver(version.version) === null) {
        issues.push(catalogIssue(entry.name, "invalid-version", `bad ${version.version}`, version.version));
      }
      if (versions.has(version.version)) {
        issues.push(catalogIssue(entry.name, "duplicate-version", `duplicate ${version.version}`, version.version));
      }
      versions.add(version.version);
      if (version.owner.trim() === "") {
        issues.push(catalogIssue(entry.name, "missing-owner", `no owner for ${label}`, version.version));
      }
      if (!(version.slo.availability > 0 && version.slo.availability <= 1)) {
        issues.push(catalogIssue(entry.name, "invalid-slo", `availability ${version.slo.availability}`, version.version));
      }
      if (!(version.slo.p95LatencyMs > 0)) {
        issues.push(catalogIssue(entry.name, "invalid-slo", `p95 ${version.slo.p95LatencyMs}`, version.version));
      }
      if (!Number.isInteger(version.costMicrosPerCall) || version.costMicrosPerCall < 0) {
        issues.push(catalogIssue(entry.name, "invalid-cost", `cost ${version.costMicrosPerCall}`, version.version));
      }
      if (!PROCESSES.includes(version.changeProcess)) {
        issues.push(catalogIssue(entry.name, "invalid-change-process", `process ${version.changeProcess}`, version.version));
      }
      if (version.contractRef !== `contract:${entry.name}@${version.version}`) {
        issues.push(catalogIssue(entry.name, "contract-mismatch", `expected contract:${entry.name}@${version.version}`, version.version));
      }
    }
  }
  return issues;
}

function selectVersion(entry: CapabilityEntry, includeDeprecated: boolean): CapabilityVersion | undefined {
  return entry.versions
    .filter((version) => includeDeprecated || !version.deprecated)
    .slice()
    .sort((a, b) => compareSemver(b.version, a.version))[0];
}

export interface CatalogQuery {
  capability?: string; owner?: string; maxCostMicros?: number;
  minAvailability?: number; includeDeprecated?: boolean;
}

export interface CatalogHit {
  capability: string; version: string; owner: string; contractRef: string;
  costMicrosPerCall: number; p95LatencyMs: number; availability: number; deprecated: boolean;
}

export function queryCatalog(catalog: CapabilityCatalog, query: CatalogQuery = {}): CatalogHit[] {
  const hits: CatalogHit[] = [];
  for (const entry of catalog.entries) {
    if (query.capability !== undefined && entry.name !== query.capability) continue;
    const version = selectVersion(entry, query.includeDeprecated ?? false);
    if (version === undefined) continue;
    if (query.owner !== undefined && version.owner !== query.owner) continue;
    if (query.maxCostMicros !== undefined && version.costMicrosPerCall > query.maxCostMicros) continue;
    if (query.minAvailability !== undefined && version.slo.availability < query.minAvailability) continue;
    hits.push({
      capability: entry.name, version: version.version, owner: version.owner,
      contractRef: version.contractRef, costMicrosPerCall: version.costMicrosPerCall,
      p95LatencyMs: version.slo.p95LatencyMs, availability: version.slo.availability,
      deprecated: version.deprecated ?? false,
    });
  }
  return hits.sort((a, b) => (a.capability < b.capability ? -1 : a.capability > b.capability ? 1 : 0));
}

export type ResolveFailure = "unknown-capability" | "invalid-range" | "no-match" | "deprecated-only";

export type ResolveResult =
  | { ok: true; capability: string; requested: string; version: string; pinned: boolean }
  | { ok: false; capability: string; requested: string; reason: ResolveFailure };

/** 精确版本可以选中已弃用版本;范围选择默认跳过弃用版本。 */
export function resolveVersion(catalog: CapabilityCatalog, capability: string, constraint: string): ResolveResult {
  const entry = catalog.entries.find((item) => item.name === capability);
  if (entry === undefined) return { ok: false, capability, requested: constraint, reason: "unknown-capability" };
  if (!isValidRange(constraint)) return { ok: false, capability, requested: constraint, reason: "invalid-range" };
  const pinned = parseSemver(constraint) !== null;
  const matches = entry.versions
    .filter((version) => (pinned || !version.deprecated) && satisfiesRange(version.version, constraint))
    .sort((a, b) => compareSemver(b.version, a.version));
  if (matches.length === 0) {
    const deprecated = entry.versions.some(
      (version) => version.deprecated && satisfiesRange(version.version, constraint),
    );
    return { ok: false, capability, requested: constraint, reason: deprecated ? "deprecated-only" : "no-match" };
  }
  return { ok: true, capability, requested: constraint, version: matches[0].version, pinned };
}

export type CatalogAction = "publish" | "upgrade" | "deprecate";

export interface OwnerDecision {
  ok: boolean; capability: string; version: string; actor: string; owner: string;
  action: CatalogAction; reason?: "invalid-actor" | "unknown-capability" | "unknown-version" | "actor-not-owner";
}

export function checkOwner(
  catalog: CapabilityCatalog, capability: string, version: string, actor: string, action: CatalogAction,
): OwnerDecision {
  const entry = catalog.entries.find((item) => item.name === capability);
  if (entry === undefined) return { ok: false, capability, version, actor, owner: "", action, reason: "unknown-capability" };
  const target = entry.versions.find((item) => item.version === version);
  if (target === undefined) return { ok: false, capability, version, actor, owner: "", action, reason: "unknown-version" };
  if (actor.trim() === "") return { ok: false, capability, version, actor, owner: target.owner, action, reason: "invalid-actor" };
  if (actor !== target.owner) return { ok: false, capability, version, actor, owner: target.owner, action, reason: "actor-not-owner" };
  return { ok: true, capability, version, actor, owner: target.owner, action };
}

export type UpgradeFailure =
  | "unknown-capability" | "unknown-version" | "not-forward"
  | "actor-not-owner" | "process-too-weak";

export interface UpgradePlan {
  capability: string; from: string; to: string; fromOwner: string; toOwner: string;
  changeProcess: ChangeProcess; approvals: string[]; steps: string[];
}

export type UpgradeResult =
  | { ok: true; plan: UpgradePlan }
  | { ok: false; capability: string; from: string; to: string; reason: UpgradeFailure };

function requiredProcess(from: string, to: string): ChangeProcess {
  const a = parseSemver(from)!;
  const b = parseSemver(to)!;
  if (a.major !== b.major) return "major-migration";
  if (a.minor !== b.minor) return "minor-review";
  return "patch-auto";
}

/** 升级由目标版本的所有者发起;跨所有者大版本还需要原所有者与平台评审。 */
export function planUpgrade(
  catalog: CapabilityCatalog, capability: string, from: string, to: string, actor: string,
): UpgradeResult {
  const entry = catalog.entries.find((item) => item.name === capability);
  if (entry === undefined) return { ok: false, capability, from, to, reason: "unknown-capability" };
  const source = entry.versions.find((item) => item.version === from);
  const target = entry.versions.find((item) => item.version === to);
  if (source === undefined || target === undefined) {
    return { ok: false, capability, from, to, reason: "unknown-version" };
  }
  if (compareSemver(to, from) <= 0) return { ok: false, capability, from, to, reason: "not-forward" };
  if (actor !== target.owner) return { ok: false, capability, from, to, reason: "actor-not-owner" };
  const required = requiredProcess(from, to);
  if (PROCESS_RANK[target.changeProcess] < PROCESS_RANK[required]) {
    return { ok: false, capability, from, to, reason: "process-too-weak" };
  }
  const approvals = [...new Set([
    target.owner, source.owner, ...(required === "patch-auto" ? [] : ["platform-review"]),
  ])];
  return {
    ok: true,
    plan: {
      capability, from, to, fromOwner: source.owner, toOwner: target.owner,
      changeProcess: required, approvals,
      steps: [
        `发布 ${target.contractRef},冻结 ${source.contractRef}`,
        `通知钉在 ${from} 的调用方,给出迁移窗口`,
        `按 10%→50%→100% 灰度,逐档核对 SLO 与成本`,
        `把 ${from} 标记为 deprecated,保留窗口后下线`,
      ],
    },
  };
}

版本解析有一个容易忽略的细节:精确版本可以选中已弃用版本,范围选择默认跳过弃用版本。这是为了兼顾两种场景。复现历史问题时需要精确锁定某个旧版本;日常依赖升级时不应该被弃用版本吸引。pinned 字段把这两种语义显式区分开。

所有者校验绑定到具体版本,而不是能力名。content.summarize 的 2.3.0 属于 team-content-legacy,3.1.1 属于 team-content;查询 3.1.1 的发布权限时,只有 team-content 通过。所有权转移必须通过一次主版本升级完成,不能在原版本上直接改 owner。

示例样本上的运行结果

示例目录包含 5 个能力、6 个所有者值(摘要能力跨一次所有权转移)。在示例样本上运行查询与解析,结果如下。

操作结果
全量能力数5
查询 owner=team-search1 条:search.rerank
查询成本上限 1,000 微元2 条:moderation.classify(420)、search.rerank(900)
查询可用率下限 0.9992 条:moderation.classify、search.rerank
解析 content.summarize ^2.0.02.3.0
解析 content.summarize 3.1.13.1.1,pinned=true
三能力成本合计4,240 微元
三能力 p95 合计1,200ms
2.3.0 → 3.1.1 审批人3 个:team-content、team-content-legacy、platform-review

升级计划返回四步:发布新契约并冻结旧契约、通知钉在旧版本的调用方、按 10%→50%→100% 灰度并核对 SLO 与成本、把旧版本标记为 deprecated 并保留下线窗口。审批人与步骤都是确定性的,测试可以直接断言,不依赖人工记忆。

如果目标版本声明的变更流程弱于版本差异要求,计划会被拒绝为 process-too-weak。例如把 3.1.1 标成 minor-review 再发起主版本升级,会在计划阶段失败,而不是等到发布后才发现没人评审。

多团队复用的三条规则

第一条,版本范围优先。调用方默认写 ^2.0.0 或 >=2.3.0 <3.0.0,只在复现与审计场景使用精确版本。范围让兼容升级自动进入,精确锁定把升级责任转移给了调用方。

第二条,契约不可变。同一个 能力名@版本 只能对应一份契约;字段变更必须发新版本。目录校验会检查 contractRef 是否等于 contract:能力名@版本,不一致直接拒绝。

第三条,所有者显式。每个版本必须有 owner,跨所有者升级必须保留原所有者审批。没有 owner 的条目不允许发布,这比事后找人便宜得多。

三条规则合起来,把「多团队复用」从共享一个 API 变成了共享一套版本与责任协议。目录是协议的可查询副本,不是协议的替代品。

小结

能力目录的七个字段——能力名、版本、契约、SLO、成本、所有者、变更流程——分别解决标识、兼容、校验、容量、经济性、责任与流程问题。示例目录的 5 个能力中,三能力成本合计 4,240 微元、p95 合计 1,200ms,与链路 A 的独立调用口径一致。

「谁负责升级」的答案是:目标版本所有者发起,原所有者在大版本上审批,平台做流程评审。示例中 2.3.0 → 3.1.1 需要 3 个审批人、4 步灰度;流程强度不足会在计划阶段被拒绝。目录让升级从口头通知变成可计算、可审计的计划。

阅读导航:上一节:11.2 引擎扩展点与插件边界 · 下一节:12.1 从单能力到编排的重构 。

常见错误与边界

  • 把能力目录做成 README。没有版本、契约和所有者,目录无法参与任何自动校验。
  • 同一版本换契约。调用方锁定的版本语义被破坏,回滚也无法复现。
  • 只记录能力名,不记录版本。升级时只能全量替换,无法渐进迁移。
  • 所有者写在能力上而不是版本上。所有权转移时无法区分新旧责任。
  • 默认精确锁定版本。所有升级都变成调用方的负担,安全补丁无法自动进入。
  • 用范围选择却允许弃用版本。日常依赖会被已弃用版本吸引。
  • 成本用浮点金额。累计与预算比较会出现精度问题,统一用整数微元。
  • 变更流程写死为「都要评审」。小补丁走大流程,团队会绕过目录。
  • 升级计划不检查方向。回退被当成升级,审批人和灰度步骤全部错位。
  • 目录只服务查询,不服务决策。字段再全,不进入预算、SLO 和发布门禁就没有约束力。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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