五份文档里对不上的契约
「回声」的五个 AI 能力最初各有各的文档。私信回复写在产品需求里,搜索重排写在算法说明里,长文摘要写在运营手册里,内容审核写在安全规范里,客服辅助则只有一段演示脚本。
第一次做整体评审时,问题集中暴露:
- 摘要能力的文档写了「模型不可用时降级」,但没有写降级到哪里。
- 审核能力的文档写了「失败即拦截」,后面又补了一句「可回退模板」,两句话互相矛盾。
- 搜索重排的质量下限是 0.9,延迟预算只有 300 毫秒,却没有人确认这两个数字能不能同时成立。
- 五个能力的成本单位不一致,有的写「分」,有的写「元」,有的只有一个百分比。
单看每份文档都像模像样,放在一起却无法回答一个最基本的问题:这五项能力,各自到底承诺了什么,哪些承诺是共通的,哪些是这一项独有的。
这一节把这些承诺收敛成一张矩阵。矩阵不是把文档并排贴在一起,而是用同一组列去逼问每个能力,让缺失和矛盾无处可藏。
矩阵要回答的四个问题
一张能用于评审的能力矩阵,至少回答四个问题。
| 问题 | 矩阵里的对应列 | 没有它会怎样 |
|---|---|---|
| 每个面是否都有值 | 输入、输出、质量、延迟、成本、失败 | 演示能跑,上线才发现缺降级 |
| 哪些是共性,哪些是差异 | 同一列横向比较 | 每个能力各写一套,无法复用 |
| 哪些属于契约,哪些属于配置 | 字段归属 | 租户配额被写进能力定义,换客户就要改代码 |
| 先看哪个能力 | 风险分数与排序 | 评审平均用力,高风险项被淹没 |
四个问题对应四种视图:逐项填值、横向对比、字段分层、风险排序。后面的实现会把它们收进同一个返回结构,避免四份表格各改各的。
六面逐项填值
六个面不是六个形容词,而是六组可以被追问的值。下面按「回声」当前的能力清单逐项填满,数字都是案例假设值。
| 能力 | 输入 | 输出 | 质量下限 | 延迟预算 | 成本上限 | 失败姿态 |
|---|---|---|---|---|---|---|
| chat.reply | thread.messages + user.tone-profile | reply.draft | 0.82 | 2500ms | 1200 微元 | degrade → quick-replies |
| search.rerank | query.text + candidates.top50 | ranking.ids | 0.90 | 300ms | 400 微元 | degrade → bm25-order |
| content.summarize | article.body + summary.length | summary.text | 0.80 | 6000ms | 3000 微元 | degrade → first-paragraph |
| moderation.classify | content.text + content.images | labels + score | 0.95 | 400ms | 100 微元 | fail-fast |
| support.assist | ticket.text + faq.corpus | answer.draft + citations | 0.85 | 1800ms | 4500 微元 | degrade → chat.reply / faq-snippet |
横向看这张表,共性立刻出现:五个能力都有明确的输入输出,都用通过率而不是「感觉不错」定义质量,都用整数微元表示成本。差异也同样清楚:审核要求最高的通过率、最低的延迟和最低的成本,并且是唯一不允许降级的能力;客服辅助的成本上限最高,因为它的输入最长、引用要求最严。
矩阵的价值在于,这些共性和差异是被同一组列逼出来的,而不是各写各的之后再做人工归纳。
质量指标与失败姿态
质量列不能只写一个「高」。可复核的质量指标至少包含三件事:评测集、通过率下限、切片要求。
「回声」给私信回复定的下限是 0.82,同时单独要求「不得编造订单状态」达到 0.98;给审核定的下限是 0.95,因为漏放的成本高于误拦。两个数字都写进矩阵,评审时争论的是「0.82 够不够」,而不是「我觉得还行」。
失败姿态列更容易被写歪。它必须回答三件事:
- 失败后产品是明确报错、降级、排队,还是静默跳过。
- 降级目标是什么,是另一个能力还是模板。
- 降级目标本身是否在能力清单里。
「回声」最后只允许三种失败姿态: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又声明降级路径。两句话互相矛盾,演练时一定有一边是假的。- 把租户配额写进能力定义。每接一个客户就改代码,契约也不再稳定。
- 把质量下限做成租户可调。客户可以调低承诺,事故后却无法说明产品承诺过什么。
- 风险排序凭直觉。没有权重与缺失面加分,评审会平均用力,高风险项被淹没。
- 矩阵只生成不门禁。缺失面在评审时被看见,却仍然能发布,等于没有检查。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。