《互联网产品的 AI 架构·模型接入与能力边界》13.1 能力清单与契约矩阵

「回声」的五个 AI 能力散落在五份文档里,评审时才发现摘要没有降级路径、审核却声明了模板兜底。本节把能力契约收敛成一张六面矩阵:输入、输出、质量、延迟、成本与失败姿态逐项填值,区分契约字段与租户配置,按风险分数排序,并给出可运行的 TypeScript 矩阵视图与缺失面门禁。

五份文档里对不上的契约

「回声」的五个 AI 能力最初各有各的文档。私信回复写在产品需求里,搜索重排写在算法说明里,长文摘要写在运营手册里,内容审核写在安全规范里,客服辅助则只有一段演示脚本。

第一次做整体评审时,问题集中暴露:

  • 摘要能力的文档写了「模型不可用时降级」,但没有写降级到哪里。
  • 审核能力的文档写了「失败即拦截」,后面又补了一句「可回退模板」,两句话互相矛盾。
  • 搜索重排的质量下限是 0.9,延迟预算只有 300 毫秒,却没有人确认这两个数字能不能同时成立。
  • 五个能力的成本单位不一致,有的写「分」,有的写「元」,有的只有一个百分比。

单看每份文档都像模像样,放在一起却无法回答一个最基本的问题:这五项能力,各自到底承诺了什么,哪些承诺是共通的,哪些是这一项独有的。

这一节把这些承诺收敛成一张矩阵。矩阵不是把文档并排贴在一起,而是用同一组列去逼问每个能力,让缺失和矛盾无处可藏。

矩阵要回答的四个问题

一张能用于评审的能力矩阵,至少回答四个问题。

问题矩阵里的对应列没有它会怎样
每个面是否都有值输入、输出、质量、延迟、成本、失败演示能跑,上线才发现缺降级
哪些是共性,哪些是差异同一列横向比较每个能力各写一套,无法复用
哪些属于契约,哪些属于配置字段归属租户配额被写进能力定义,换客户就要改代码
先看哪个能力风险分数与排序评审平均用力,高风险项被淹没

四个问题对应四种视图:逐项填值、横向对比、字段分层、风险排序。后面的实现会把它们收进同一个返回结构,避免四份表格各改各的。

六面逐项填值

六个面不是六个形容词,而是六组可以被追问的值。下面按「回声」当前的能力清单逐项填满,数字都是案例假设值。

能力输入输出质量下限延迟预算成本上限失败姿态
chat.replythread.messages + user.tone-profilereply.draft0.822500ms1200 微元degrade → quick-replies
search.rerankquery.text + candidates.top50ranking.ids0.90300ms400 微元degrade → bm25-order
content.summarizearticle.body + summary.lengthsummary.text0.806000ms3000 微元degrade → first-paragraph
moderation.classifycontent.text + content.imageslabels + score0.95400ms100 微元fail-fast
support.assistticket.text + faq.corpusanswer.draft + citations0.851800ms4500 微元degrade → chat.reply / faq-snippet

横向看这张表,共性立刻出现:五个能力都有明确的输入输出,都用通过率而不是「感觉不错」定义质量,都用整数微元表示成本。差异也同样清楚:审核要求最高的通过率、最低的延迟和最低的成本,并且是唯一不允许降级的能力;客服辅助的成本上限最高,因为它的输入最长、引用要求最严。

矩阵的价值在于,这些共性和差异是被同一组列逼出来的,而不是各写各的之后再做人工归纳。

质量指标与失败姿态

质量列不能只写一个「高」。可复核的质量指标至少包含三件事:评测集、通过率下限、切片要求。

「回声」给私信回复定的下限是 0.82,同时单独要求「不得编造订单状态」达到 0.98;给审核定的下限是 0.95,因为漏放的成本高于误拦。两个数字都写进矩阵,评审时争论的是「0.82 够不够」,而不是「我觉得还行」。

失败姿态列更容易被写歪。它必须回答三件事:

  1. 失败后产品是明确报错、降级、排队,还是静默跳过。
  2. 降级目标是什么,是另一个能力还是模板。
  3. 降级目标本身是否在能力清单里。

「回声」最后只允许三种失败姿态:fail-fast、degrade、queue。silent-skip 被明确禁止,因为它会让调用方拿到空结果,却以为请求成功。degrade 必须给出至少一条降级路径,fail-fast 不允许再声明降级路径,否则矩阵自相矛盾。

审核能力选择 fail-fast,不是因为它不重要,而是因为审核结果不能猜。模型不可用时,产品宁可拦住等人工,也不能放过一条可能违规的内容。私信回复则相反,模型不可用时返回三条预设快捷回复,并标注「模板建议」,用户仍然可以继续操作。

同一个网关里,两项能力的失败姿态相反。这正是矩阵必须逐个能力填写、不能用「统一降级」一笔带过的原因。

哪些字段进矩阵,哪些属于租户配置

矩阵只放能力本身的承诺。凡是「换个租户就该变」的值,都不应该写成能力定义。

归属字段理由
能力契约id、title、inputs、outputs、qualityTier描述能力是什么,所有租户一致
能力契约minPassRate、latencyBudgetMs、costCeilingMicros产品对外的质量、延迟、成本承诺
能力契约onFailure、fallbacks失败时产品行为的默认约定
租户配置quotaPerDay、costBudgetMicros每个客户的额度与预算不同
租户配置degradeCopy、fallbackEnabled文案与是否允许降级可由客户策略决定
租户配置region、modelAllowlist数据驻留与可用模型随部署环境变化

这条分界的实际作用是防止两类事故。第一类是把租户配额写进能力定义,结果每接一个客户就要改一次代码。第二类是反过来,把质量下限做成租户可调,结果某个客户把下限调到 0.5,事故发生后却说不清产品到底承诺了什么。

矩阵实现里,MATRIX_FIELDS 与 TENANT_CONFIG_FIELDS 是两份显式清单,测试会断言两者没有交集。清单不是装饰,它决定了评审时哪些值必须所有人同意,哪些值可以按租户覆盖。

风险排序不是拍脑袋

矩阵填满之后,评审仍然需要知道先看哪一行。风险分数由四类因素构成:

  • 失败姿态:fail-fast 的成本最高,queue 次之,degrade 再次。
  • 质量下限:越接近 1,越难稳定达到。
  • 延迟预算:越紧,越容易在高峰或跨区时超时。
  • 成本与模型档位:单次上限越高、越依赖前沿模型,波动风险越大。

缺失面会额外加分,因为缺一项就意味着这项能力还没有完成定义。分数被限制在 0 到 100,并按高、中、低三档标注。

「回声」当前矩阵跑出来的顺序是:审核 65 分(高)、搜索重排 47 分(中)、客服辅助 32 分(中)、长文摘要 27 分(低)、私信回复 22 分(低)。审核排第一不是因为它最重要,而是因为它同时具备高通过率、低延迟、低成本与失败即拦截四个高风险因素。评审从它开始,投入产出比最高。

排序还有一个副作用:它让「所有能力都重要」这句话失效。分数低的能力不是不重要,而是当前契约的风险因素较少;一旦它的延迟预算收紧或质量下限提高,排序会立刻变化。

实现

下面的模块只做四件事:把能力规格展开成六面矩阵、检测缺失面、按风险排序、提供发布门禁。它不读取配置文件,也不依赖任何第三方库;输入就是第一章定义的能力规格数组。

// file: src/core/capability-matrix.ts
// tier: L1
// verify: node --test test/core/capability-matrix.test.ts

import type { CapabilitySpec, FailureMode } from "./capability-spec.ts";

/** 能力契约必须逐项填满的六个面,顺序就是评审时的阅读顺序。 */
export const MATRIX_FACETS = ["input", "output", "quality", "latency", "cost", "failure"] as const;
export type MatrixFacet = (typeof MATRIX_FACETS)[number];

/** 进入矩阵的字段。它们描述能力本身,不随单个租户变化。 */
export const MATRIX_FIELDS = [
  "id",
  "title",
  "inputs",
  "outputs",
  "qualityTier",
  "minPassRate",
  "latencyBudgetMs",
  "costCeilingMicros",
  "onFailure",
  "fallbacks",
] as const;
export type MatrixField = (typeof MATRIX_FIELDS)[number];

/** 这些字段属于租户配置,不能写进能力契约矩阵。 */
export const TENANT_CONFIG_FIELDS = [
  "quotaPerDay",
  "costBudgetMicros",
  "degradeCopy",
  "fallbackEnabled",
  "region",
  "modelAllowlist",
] as const;
export type TenantConfigField = (typeof TENANT_CONFIG_FIELDS)[number];

export type RiskLevel = "high" | "medium" | "low";

export interface MatrixCell {
  facet: MatrixFacet;
  /** 给评审直接看的摘要,不做机器解析。 */
  value: string;
  declared: boolean;
}

export interface MatrixRisk {
  score: number;
  level: RiskLevel;
  reasons: string[];
}

export interface MatrixRow {
  capability: string;
  title: string;
  cells: MatrixCell[];
  missing: MatrixFacet[];
  risk: MatrixRisk;
}

export interface CapabilityMatrix {
  rows: MatrixRow[];
  missingByCapability: Record<string, MatrixFacet[]>;
  /** 每个面在多少个能力上已声明,用于发现系统性缺口。 */
  facetCoverage: Record<MatrixFacet, number>;
}

const FAILURE_WEIGHT: Record<FailureMode, number> = {
  "fail-fast": 35,
  degrade: 12,
  queue: 20,
  "silent-skip": 25,
};

function failureDeclared(spec: CapabilitySpec): boolean {
  if (spec.onFailure === "degrade") return spec.fallbacks.length > 0;
  if (spec.onFailure === "fail-fast") return spec.fallbacks.length === 0;
  if (spec.onFailure === "queue") return spec.latencyBudgetMs >= 3_000;
  return false;
}

function failureText(spec: CapabilitySpec): string {
  if (spec.fallbacks.length === 0) return `${spec.onFailure}(无降级路径)`;
  return `${spec.onFailure} → ${spec.fallbacks.join(" / ")}`;
}

function assessRisk(spec: CapabilitySpec, missing: MatrixFacet[]): MatrixRisk {
  const reasons: string[] = [];
  let score = 0;

  score += FAILURE_WEIGHT[spec.onFailure];
  reasons.push(`失败姿态 ${spec.onFailure}`);

  if (spec.minPassRate >= 0.9) {
    score += 15;
    reasons.push(`质量下限 ${spec.minPassRate}`);
  }
  if (spec.latencyBudgetMs <= 500) {
    score += 15;
    reasons.push(`延迟预算 ${spec.latencyBudgetMs}ms`);
  } else if (spec.latencyBudgetMs <= 1_500) {
    score += 8;
    reasons.push(`延迟预算 ${spec.latencyBudgetMs}ms`);
  }
  if (spec.costCeilingMicros >= 3_000) {
    score += 10;
    reasons.push(`单次成本上限 ${spec.costCeilingMicros} 微元`);
  } else if (spec.costCeilingMicros >= 1_000) {
    score += 5;
    reasons.push(`单次成本上限 ${spec.costCeilingMicros} 微元`);
  }
  if (spec.qualityTier === "frontier") {
    score += 10;
    reasons.push("依赖前沿档模型");
  } else if (spec.qualityTier === "standard") {
    score += 5;
  }
  if (missing.length > 0) {
    score += missing.length * 20;
    reasons.push(`缺失面 ${missing.join(", ")}`);
  }

  score = Math.min(100, score);
  const level: RiskLevel = score >= 60 ? "high" : score >= 30 ? "medium" : "low";
  return { score, level, reasons };
}

function buildRow(spec: CapabilitySpec): MatrixRow {
  const cells: MatrixCell[] = MATRIX_FACETS.map((facet) => {
    if (facet === "input") {
      return { facet, value: spec.inputs.join(" + "), declared: spec.inputs.length > 0 };
    }
    if (facet === "output") {
      return { facet, value: spec.outputs.join(" + "), declared: spec.outputs.length > 0 };
    }
    if (facet === "quality") {
      return {
        facet,
        value: `通过率 ≥ ${spec.minPassRate}(${spec.qualityTier})`,
        declared: spec.minPassRate > 0 && spec.minPassRate <= 1,
      };
    }
    if (facet === "latency") {
      return {
        facet,
        value: `P95 ≤ ${spec.latencyBudgetMs}ms`,
        declared: spec.latencyBudgetMs > 0,
      };
    }
    if (facet === "cost") {
      return {
        facet,
        value: `≤ ${spec.costCeilingMicros} 微元/次`,
        declared: spec.costCeilingMicros > 0,
      };
    }
    return { facet, value: failureText(spec), declared: failureDeclared(spec) };
  });

  const missing = cells.filter((cell) => !cell.declared).map((cell) => cell.facet);
  return {
    capability: spec.id,
    title: spec.title,
    cells,
    missing,
    risk: assessRisk(spec, missing),
  };
}

/** 输入能力清单,输出按风险从高到低排列的矩阵视图。 */
export function buildCapabilityMatrix(specs: readonly CapabilitySpec[]): CapabilityMatrix {
  const rows = specs
    .map(buildRow)
    .sort(
      (left, right) =>
        right.risk.score - left.risk.score ||
        right.missing.length - left.missing.length ||
        left.capability.localeCompare(right.capability),
    );

  const missingByCapability: Record<string, MatrixFacet[]> = {};
  const facetCoverage = Object.fromEntries(
    MATRIX_FACETS.map((facet) => [facet, 0]),
  ) as Record<MatrixFacet, number>;

  for (const row of rows) {
    missingByCapability[row.capability] = row.missing;
    for (const cell of row.cells) {
      if (cell.declared) facetCoverage[cell.facet] += 1;
    }
  }

  return { rows, missingByCapability, facetCoverage };
}

/** 发布门禁:任何能力缺面都不允许悄悄上线。 */
export function assertMatrixComplete(matrix: CapabilityMatrix): void {
  const gaps = matrix.rows.filter((row) => row.missing.length > 0);
  if (gaps.length > 0) {
    const detail = gaps.map((row) => `${row.capability}: ${row.missing.join(",")}`).join("; ");
    throw new Error(`能力矩阵存在缺失面: ${detail}`);
  }
}

buildCapabilityMatrix 返回三样东西:按风险排序的行、每个能力缺失的面、每个面在多少个能力上已声明。第三项用于发现系统性缺口:如果 failure 的覆盖率只有 2/5,说明有三个能力根本没想清楚失败后怎么办。

assertMatrixComplete 是发布门禁。它不修数据,只在存在缺失面时抛错,并把能力与缺失面拼进错误信息。门禁放在 CI 里,比放在评审人的记忆里可靠。

风险分数里的权重是案例值,不是行业标准。团队可以调整权重,但不应该取消排序:没有排序,矩阵就会退化成一张需要逐行阅读的宽表。

测试与运行结果

测试覆盖六种情况:五能力矩阵的完整性、风险排序、缺失面检测、失败姿态矛盾、字段归属不重叠、同输入结果稳定。在实验工程根目录执行:

node --test test/core/capability-matrix.test.ts

本机一次运行的真实输出摘要如下,耗时随机器变化:

ℹ tests 6
ℹ pass 6
ℹ fail 0

「缺失面会被逐项标出」这条测试把摘要能力的延迟预算改成 0,并清空降级路径,矩阵随即在 missing 里返回 latency 与 failure,发布门禁也会失败。它验证的不是某个函数,而是「缺面不能上线」这条规则。

「失败姿态矛盾」这条测试给审核能力补了一条模板降级路径。矩阵没有修改输入,而是把 failure 面标成未声明。这个设计很重要:矩阵只做视图与判断,不替业务方做决定。

小结

能力清单回答「有哪些能力」,契约矩阵回答「每项能力承诺了什么」。六个面必须逐项填值;质量与失败姿态要能复述、能测试;契约字段与租户配置必须分开;风险排序决定评审顺序。

「回声」的五份文档并没有消失,它们被收敛成同一组列。以后新增能力,先填矩阵,再写文档;矩阵缺面,文档写得再漂亮也不允许发布。

下一节把矩阵里的六个面放进一条真实流水线,讨论认证、缓存、路由、调用、校验与降级如何组合,以及预算怎样逐段扣减。

阅读导航:上一节:12.3 质量信号的线上采集 · 下一节:13.2 网关的组合:路由、缓存、降级 。

常见错误与边界

  • 把能力矩阵做成五份文档的目录。没有统一列,就无法横向比较,也无法发现共性。
  • 质量列只写「高」「中」「低」。没有评测集与通过率,质量无法复核。
  • 失败姿态写「统一降级」。阻断型能力与可缺能力的失败语义本来就相反,不能一刀切。
  • degrade 不写降级目标。降级到哪、目标是否存在,必须在矩阵里逐条列出。
  • fail-fast 又声明降级路径。两句话互相矛盾,演练时一定有一边是假的。
  • 把租户配额写进能力定义。每接一个客户就改代码,契约也不再稳定。
  • 把质量下限做成租户可调。客户可以调低承诺,事故后却无法说明产品承诺过什么。
  • 风险排序凭直觉。没有权重与缺失面加分,评审会平均用力,高风险项被淹没。
  • 矩阵只生成不门禁。缺失面在评审时被看见,却仍然能发布,等于没有检查。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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