条目只写了契约的一半
2.1 的能力条目里,提供方声明了 SLA、失败语义、计费单位与退役日期。这些是承诺,但不是完整契约。调用方要传什么、能重试几次、缓存键怎么拼、出问题往哪里报,条目里一个字都没有。
卷三的节点契约约束的是一条链路内部的上下游,双方通常属于同一个团队,出了问题在同一个群里解决。平台化之后,提供方与调用方是两个组织,契约必须把双方义务写在同一份可校验的结构里。否则「能力可用」只是一句祝福。
本节把契约拆成两半。提供方的一半是条目本身,签一次对所有调用方生效;调用方的一半是每个调用方各自的 ConsumerContract,按能力版本、输入约束、配额、重试、缓存与反馈渠道逐项声明。
提供方承诺什么
提供方的承诺必须能被引用,而不是被转述。调用方在自己的链路基线里引用同一个版本与同一个 SLA,平台才能判断「是能力退化」还是「调用方用错」。
| 承诺 | 示例(chat.reply@3.1.0) | 调用方如何使用 |
|---|---|---|
| 可用率 | 99.9%,分母是该版本全部调用、滚动 28 天 | 作为链路成功率的分母之一,不自行改口径 |
| P95 延迟 | 420ms | 进入链路 P95 预算,不按平均值估算 |
| 计费单位 | 每次调用 4,200 微元,整数 | 月度配额与分摊按同一单位累计 |
| 失败语义 | retry-idempotent | 只有带幂等键才允许自动重试 |
| 默认档位 | standard | 不覆盖时按此档位运行与计量 |
| 可覆盖参数 | 档位、最大输出 token | 只能在允许值内声明,并接受责任归属 |
| 退役日期 | 2027-12-31 | 提前排迁移,不等到下线通知 |
这张表的每一行都能在 2.1 的条目里找到对应字段。契约校验不需要另建一份「提供方承诺表」,否则两份数据迟早不一致。
失败语义尤其不能省。retry-safe 表示重复调用没有副作用,调用方可以自行重试;retry-idempotent 表示只有携带幂等键才能重试;no-retry 与 human-escalation 表示自动重试会制造更大的问题。三种语义对应三种调用方代码,不能靠口头约定。
调用方要承担什么
调用方的义务有五项,每一项都对应一个可判定的违约码。
第一项是输入约束。inputGuards 为每个输入字段声明最大字节数,字段必须是契约声明过的输入。传一个契约里没有的 orderId 不会让能力变强,只会让提供方在运行时收到未定义字段。
第二项是配额。月度总额与单次上限都必须是正整数微元,且单次上限不能超过月度总额。回声的示例口径是租户月度 1 亿微元,内容团队 4,000 万、客服团队 3,000 万、交易团队 3,000 万;单次上限分别是摘要 6,200、回复 4,200、下单 3,000 微元。三个团队之和正好是 1 亿,这不是巧合,而是配额表必须先闭合。
第三项是重试。attempts 限定在 0 到 3,退避时间为非负整数。当能力声明 retry-idempotent 时,只要 attempts > 0,调用方就必须生成幂等键;当能力声明 no-retry 或 human-escalation 时,任何自动重试都被拒绝。重试还要受卷三的单步失败预算约束:13.02% 是三步各一次重试、目标 95% 时的上限,不能因为「多试几次更稳」就突破。
第四项是缓存键。调用方的缓存键必须包含 tenantId,并且覆盖提供方声明的全部 cacheKey 字段。少一个字段,缓存命中就会把不同租户、不同会话或不同提示版本混在一起。缓存不是免费的优化,它把提供方的契约责任转移到了调用方的键设计上。
第五项是反馈渠道。feedbackChannel 必须是可路由的 issue:、mailto: 或 chat: 地址。没有反馈渠道,提供方只能通过线上指标猜调用方遇到了什么。
可覆盖参数的责任归属
多团队共享能力最容易被忽略的问题是:调用方改了参数,谁对结果负责。
示例里 chat.reply 声明了两个可覆盖参数。qualityTier 允许 economy 与 standard,责任归属是 provider:内容团队为了摘要场景切到经济档,提供方仍对 99.9% 可用率与 420ms P95 负责,但质量基线按经济档单独统计。maxOutputTokens 允许 256、512、1024,责任归属是 consumer:调用方把输出上限调到 1024,超出部分的成本与截断风险由调用方承担。
modelId 不在可覆盖列表里。调用方尝试覆盖时返回 unknown-override,提示「该参数由提供方固定」。这不是权限系统,而是契约边界:允许覆盖的集合必须显式声明,未声明的参数一律视为固定。
责任归属分三档。provider 表示覆盖不转移 SLA;consumer 表示覆盖后的质量与成本由调用方承担;shared 表示双方按事先约定的比例分摊。三档之外没有第四种「看情况」。
把契约校验写成模块
catalog-contract.ts 只做三件事:校验调用方契约、解析覆盖参数、把提供方承诺压缩成一份可引用的摘要。它不判断业务合理性,只回答「这份契约是否违反了条目声明的边界」。
// file: src/gov/catalog-contract.ts
// tier: L2
// verify: node --test test/gov/catalog.test.ts
import { isValidRange, satisfiesRange } from "../orch/platform.ts";
import type {
BillingUnit,
CapabilityEntry,
FailureMode,
Responsibility,
} from "./catalog.ts";
export interface Quota {
monthlyMicros: number;
perRequestMicros: number;
}
export interface RetryPolicy {
attempts: number;
idempotencyKey: boolean;
backoffMs: number;
}
export interface CachePolicy {
key: readonly string[];
ttlSeconds: number;
}
export interface InputGuard {
field: string;
maxBytes: number;
}
/** The consumer half of a capability contract. The entry is the provider half. */
export interface ConsumerContract {
consumer: string;
capability: string;
versionRange: string;
inputGuards: readonly InputGuard[];
quota: Quota;
retry: RetryPolicy;
cache: CachePolicy;
feedbackChannel: string;
}
export type ContractIssueCode =
| "missing-consumer"
| "unknown-capability"
| "invalid-range"
| "no-matching-version"
| "empty-guard"
| "guard-field-not-input"
| "duplicate-guard"
| "invalid-quota"
| "quota-inverted"
| "invalid-retry"
| "retry-needs-idempotency"
| "retry-forbidden"
| "cache-missing-key"
| "cache-missing-tenant"
| "invalid-cache-ttl"
| "invalid-feedback-channel";
export interface ContractIssue {
consumer: string;
capability: string;
code: ContractIssueCode;
detail: string;
}
const FEEDBACK = /^(issue|mailto|chat):\S+$/;
const RETRYABLE: readonly FailureMode[] = ["retry-safe", "retry-idempotent"];
export function validateConsumerContract(
entry: CapabilityEntry,
contract: ConsumerContract,
): ContractIssue[] {
const issues: ContractIssue[] = [];
const add = (code: ContractIssueCode, detail: string): void => {
issues.push({ consumer: contract.consumer, capability: contract.capability, code, detail });
};
if (contract.consumer.trim() === "") add("missing-consumer", "consumer is empty");
if (contract.capability !== entry.id) {
add("unknown-capability", `entry is ${entry.id}, contract names ${contract.capability}`);
}
if (!isValidRange(contract.versionRange)) {
add("invalid-range", `bad range ${contract.versionRange}`);
} else if (!satisfiesRange(entry.version, contract.versionRange)) {
add("no-matching-version", `${entry.version} is outside ${contract.versionRange}`);
}
const guardFields = new Set<string>();
for (const guard of contract.inputGuards) {
if (guard.field.trim() === "") add("empty-guard", "guard has no field");
if (!entry.contract.inputs.includes(guard.field)) {
add("guard-field-not-input", `${guard.field} is not a declared input`);
}
if (!Number.isInteger(guard.maxBytes) || guard.maxBytes <= 0) {
add("empty-guard", `bad maxBytes ${guard.maxBytes} for ${guard.field}`);
}
if (guardFields.has(guard.field)) add("duplicate-guard", guard.field);
guardFields.add(guard.field);
}
const quota = contract.quota;
if (!Number.isInteger(quota.monthlyMicros) || !Number.isInteger(quota.perRequestMicros)
|| quota.monthlyMicros <= 0 || quota.perRequestMicros <= 0) {
add("invalid-quota", `monthly=${quota.monthlyMicros} request=${quota.perRequestMicros}`);
} else if (quota.perRequestMicros > quota.monthlyMicros) {
add("quota-inverted", "per-request cap exceeds the monthly pool");
}
const retry = contract.retry;
if (!Number.isInteger(retry.attempts) || retry.attempts < 0 || retry.attempts > 3) {
add("invalid-retry", `attempts ${retry.attempts}`);
}
if (!Number.isInteger(retry.backoffMs) || retry.backoffMs < 0) {
add("invalid-retry", `backoff ${retry.backoffMs}`);
}
if (retry.attempts > 0 && !RETRYABLE.includes(entry.failure)) {
add("retry-forbidden", `${entry.failure} does not allow automatic retries`);
}
if (retry.attempts > 0 && entry.failure === "retry-idempotent" && !retry.idempotencyKey) {
add("retry-needs-idempotency", "retry requires a consumer-generated idempotency key");
}
const cache = contract.cache;
if (!Number.isInteger(cache.ttlSeconds) || cache.ttlSeconds < 0) {
add("invalid-cache-ttl", `ttl ${cache.ttlSeconds}`);
}
if (!cache.key.includes("tenantId")) {
add("cache-missing-tenant", "cache key must scope by tenantId");
}
if (new Set(cache.key).size !== cache.key.length) {
add("cache-missing-key", "cache key has duplicate fields");
}
for (const field of entry.contract.cacheKey) {
if (!cache.key.includes(field)) add("cache-missing-key", `cache key omits ${field}`);
}
if (!FEEDBACK.test(contract.feedbackChannel)) {
add("invalid-feedback-channel", `bad channel ${contract.feedbackChannel}`);
}
return issues;
}
export interface OverrideRequest {
param: string;
value: string | number;
}
export type OverrideIssueCode = "unknown-override" | "value-not-allowed" | "duplicate-override";
export interface OverrideIssue {
param: string;
code: OverrideIssueCode;
detail: string;
}
export interface OverrideDecision {
param: string;
value: string | number;
/** Who owns quality and cost after the override is applied. */
responsibility: Responsibility;
}
export interface OverrideResolution {
ok: boolean;
decisions: OverrideDecision[];
issues: OverrideIssue[];
}
/** Apply caller overrides and return the owner of each resulting value. */
export function resolveOverrides(
entry: CapabilityEntry,
requests: readonly OverrideRequest[],
): OverrideResolution {
const decisions: OverrideDecision[] = [];
const issues: OverrideIssue[] = [];
const seen = new Set<string>();
for (const request of requests) {
const declared = entry.overridable.find((param) => param.name === request.param);
if (declared === undefined) {
issues.push({
param: request.param, code: "unknown-override",
detail: `${request.param} is fixed by ${entry.provider}`,
});
continue;
}
if (seen.has(request.param)) {
issues.push({
param: request.param, code: "duplicate-override", detail: "declared twice",
});
continue;
}
seen.add(request.param);
if (!declared.allowed.includes(request.value)) {
issues.push({
param: request.param, code: "value-not-allowed",
detail: `${request.value} not in ${declared.allowed.join(",")}`,
});
continue;
}
decisions.push({
param: request.param, value: request.value, responsibility: declared.responsibility,
});
}
return { ok: issues.length === 0, decisions, issues };
}
export interface CommitmentSummary {
availabilityPct: number;
p95LatencyMs: number;
billingUnit: BillingUnit;
failure: FailureMode;
retryable: boolean;
}
/** Provider-side summary; consumers must not restate these numbers locally. */
export function describeCommitment(entry: CapabilityEntry): CommitmentSummary {
return {
availabilityPct: Math.round(entry.sla.availability * 10000) / 100,
p95LatencyMs: entry.sla.p95LatencyMs,
billingUnit: entry.billingUnit,
failure: entry.failure,
retryable: RETRYABLE.includes(entry.failure),
};
}
代码里有三个刻意选择。第一,版本范围先用 isValidRange 判断语法,再用 satisfiesRange 判断条目版本是否落在范围内;语法错误与版本不匹配是两个不同的修复动作。第二,重试规则从能力的失败语义推导,而不是让调用方自由填写;提供方改一次失败语义,所有调用方的违约判定同时更新。第三,缓存键同时检查 tenantId 与提供方字段,前者是隐私边界,后者是正确性边界,缺一不可。
示例样本上的运行结果
以 chat.reply@3.1.0 为提供方条目,客服团队的契约为月度 3,000 万微元、单次 4,200 微元、重试 1 次且带幂等键、缓存键含租户与会话、反馈到 issue:ai-platform。以下是示例样本上的运行结果。
| 操作 | 结果 |
|---|---|
| 完整调用方契约 | 0 个问题 |
| 问题契约 | 11 个违约码:未知能力、版本不匹配、空输入约束、字段不在输入内、配额倒挂、重试缺幂等键、缓存 TTL 非法、缺租户、缺两个提供方字段、反馈渠道非法 |
| 两个合法覆盖 | qualityTier=economy 归属 provider;maxOutputTokens=1024 归属 consumer |
| 两个非法覆盖 | modelId 为 unknown-override;重复声明 qualityTier 为 duplicate-override |
| 提供方承诺摘要 | 可用率 99.9%、P95 420ms、按次计费、retry-idempotent、可重试 |
问题契约一次返回 11 个违约码,说明校验不是「找到一个就退出」。如果调用方逐项修复,至少要经历 11 轮发布;一次列全只需要一轮。对平台团队来说,这是最直接的体验差异。
配额倒挂值得单独看。示例中把月度总额写成 3,000 微元、单次上限写成 4,200 微元,校验返回 quota-inverted。这类错误在手工表格里很常见:团队复制了别人的单次上限,却忘了同步月度总额。倒挂的配额会让调用方在第一次请求就失败,而不是在月底结算时才发现。
契约评审的五个问题
平台在接收一份新契约时,可以按顺序问五个问题。任何一个答不上来,契约不应该进入目录。
- 提供方版本是否在调用方声明的范围内,且不是已弃用版本?
- 输入约束是否只引用契约声明过的字段,单位是否统一为字节?
- 月度配额与单次上限是否与租户总额、团队份额闭合?
- 重试次数与幂等键是否匹配提供方的失败语义,是否仍在 13.02% 的单步失败预算内?
- 缓存键是否覆盖租户与提供方声明的全部字段,反馈渠道是否可路由?
五个问题都能由代码回答,评审只需要看结果与例外说明。契约评审不应该变成一次阅读理解。
小结
契约有两半:提供方承诺 SLA、计费单位、失败语义与可覆盖参数;调用方承诺输入约束、配额、重试方式、缓存键与反馈渠道。缺任何一半,能力共享都会退化成口头约定。
示例样本上,完整契约返回 0 个问题,问题契约一次返回 11 个违约码;两个合法覆盖分别归属 provider 与 consumer,两个非法覆盖被拒绝。配额口径与全书一致:租户月度 1 亿微元,三个团队 4,000 万、3,000 万、3,000 万,单次上限 6,200、4,200、3,000。
阅读导航:上一节:2.1 一条能力条目的最小字段 · 下一节:2.3 能力退役与迁移 。
常见错误与边界
- 只写提供方 SLA,不写调用方义务。契约缺一半,违约时无法归责。
- 把契约写成文档链接。无法解析的契约不能参与发布门禁与回归测试。
- 输入约束引用未声明字段。提供方收到未定义输入,只能忽略或报错。
- 月度总额小于单次上限。配额倒挂会让第一次请求就失败。
- 重试次数与失败语义脱钩。动作节点被重复执行,损失比失败本身更大。
- 重试不带幂等键。
retry-idempotent能力的承诺被绕过。 - 缓存键不含租户。命中率越高,跨租户串数据越隐蔽。
- 缓存键漏掉提供方字段。不同提示版本的结果会被混用。
- 允许覆盖却没有责任归属。质量下降时,提供方与调用方各执一词。
- 把固定参数当成可覆盖。调用方以为改的是配置,实际改的是提供方的实现。
- 反馈渠道写成个人邮箱。人员变动后,反馈通道静默失效。
- 契约通过校验就认为可以上线。配额闭合与链路基线仍要单独验证。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。