《互联网产品的 AI 架构·平台化与治理》2.2 提供方与调用方的契约

能力条目只写了提供方的一半承诺。本节把契约拆成提供方承诺与调用方义务:输入约束、配额、重试、缓存键与反馈渠道,并规定可覆盖参数的责任归属。用确定性代码校验三个月度配额共 1 亿微元、单次上限 6,200/4,200/3,000 的调用方契约,给出 11 个违约码与两个覆盖参数的归属判定。

条目只写了契约的一半

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。这类错误在手工表格里很常见:团队复制了别人的单次上限,却忘了同步月度总额。倒挂的配额会让调用方在第一次请求就失败,而不是在月底结算时才发现。

契约评审的五个问题

平台在接收一份新契约时,可以按顺序问五个问题。任何一个答不上来,契约不应该进入目录。

  1. 提供方版本是否在调用方声明的范围内,且不是已弃用版本?
  2. 输入约束是否只引用契约声明过的字段,单位是否统一为字节?
  3. 月度配额与单次上限是否与租户总额、团队份额闭合?
  4. 重试次数与幂等键是否匹配提供方的失败语义,是否仍在 13.02% 的单步失败预算内?
  5. 缓存键是否覆盖租户与提供方声明的全部字段,反馈渠道是否可路由?

五个问题都能由代码回答,评审只需要看结果与例外说明。契约评审不应该变成一次阅读理解。

小结

契约有两半:提供方承诺 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 能力的承诺被绕过。
  • 缓存键不含租户。命中率越高,跨租户串数据越隐蔽。
  • 缓存键漏掉提供方字段。不同提示版本的结果会被混用。
  • 允许覆盖却没有责任归属。质量下降时,提供方与调用方各执一词。
  • 把固定参数当成可覆盖。调用方以为改的是配置,实际改的是提供方的实现。
  • 反馈渠道写成个人邮箱。人员变动后,反馈通道静默失效。
  • 契约通过校验就认为可以上线。配额闭合与链路基线仍要单独验证。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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