一次升级打穿了三条链路
「回声」的摘要能力由内容团队维护。某个周二,他们发布了 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-search | 1 条:search.rerank |
| 查询成本上限 1,000 微元 | 2 条:moderation.classify(420)、search.rerank(900) |
| 查询可用率下限 0.999 | 2 条:moderation.classify、search.rerank |
解析 content.summarize ^2.0.0 | 2.3.0 |
解析 content.summarize 3.1.1 | 3.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 和发布门禁就没有约束力。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。