为什么先写清单
1.1 说明了能力契约的六个面,1.2 说明了哪些任务该进入模型。到了这一节,要把前两节的结论落成一份工程上可执行的清单。
「回声」的候选任务有七个,最终进入 AI 能力清单的是五个:私信智能回复、搜索重排、长文摘要、内容审核分类、客服辅助回答。手机号脱敏走规则,退款审批走人工,不进入模型能力清单,但它们的判断结果保留在 1.2 的判据函数里。
清单不是文档附件,而是后面十二章的共同坐标系。选型要按能力查预算,路由要按能力查降级,成本要按能力拆账,评测要按能力设门槛。如果清单本身可以随手改、拼写不一致、降级指向不存在的目标,后面每一章都会建立在流沙上。
「回声」的五个能力
先看全貌。表中的延迟、成本、通过率都是案例假设值,用来演示契约怎么定,不代表任何真实产品的实测。
| 能力标识 | 产品场景 | 质量档 | P95 预算 | 单次成本上限 | 质量下限 | 失败语义 |
|---|---|---|---|---|---|---|
chat.reply | 私信智能回复草稿 | standard | 2500ms | 1200 微元 | 0.82 | 降级到快捷回复模板 |
search.rerank | 内容搜索重排 | standard | 300ms | 400 微元 | 0.90 | 降级到原始排序 |
content.summarize | 长文摘要 | standard | 6000ms | 3000 微元 | 0.80 | 降级到首段截取 |
moderation.classify | 内容审核分类 | economy | 400ms | 100 微元 | 0.95 | 快速失败转人工 |
support.assist | 客服辅助回答 | frontier | 1800ms | 4500 微元 | 0.85 | 降级到回复能力或 FAQ 片段 |
五个能力覆盖了三种质量档和四种失败语义,正好把后面要讲的选型、路由、成本、可靠性都拉进来。
chat.reply 是用户体验最敏感的能力。它的输出会直接出现在输入框里,用户可能一字不改地发出去,所以质量下限不能太低,降级也必须无缝:模型不可用时给三条快捷回复,而不是弹一个错误框。
search.rerank 的预算只有 300 毫秒,因为搜索页等不起。它不需要生成文字,只需要给出候选内容的顺序,因此可以用更便宜的档位,并且失败时退回原始排序几乎无感。这个能力是「低延迟、可降级」的典型。
content.summarize 的预算最宽,6000 毫秒,因为摘要通常在后台或展开时触发,用户可以等。它的降级是首段截取,体验会变差但不会中断。宽预算不意味着可以浪费,单次成本仍然有上限。
moderation.classify 是唯一选择快速失败的能力。审核结果不能猜:模型不可用时,宁可把内容拦住转人工,也不能默认放行。它的质量下限也最高,因为漏放的代价远大于误拦。这个能力说明了「失败语义由业务代价决定,不由技术偏好决定」。
support.assist 用前沿档,因为客服回答要引用政策、订单和 FAQ,错误代价高。它的降级链有两级:先退回 chat.reply 的通用回复能力,再退回 FAQ 片段。降级链不是随便列的,每一级都要比上一级更保守、更确定。
契约字段为什么是这些
CapabilitySpec 的字段不多,但每个都对应一个后面会反复用到的问题。
id:稳定标识,发布后不复用。指标、配额、账单、降级都按它归因。title:面向产品的名字,让非工程角色能参与评审。inputs/outputs:能力边界。没有输出契约的能力无法校验,也无法降级。qualityTier:选型的起点,不是模型名。具体用哪个模型由第二章决定。latencyBudgetMs:端到端预算,不是提供方超时。costCeilingMicros:单次成本上限,用整数微元,避免浮点误差。minPassRate:评测集通过率下限,让「质量够不够」可以争论。onFailure:失败时产品做什么,必须是显式选择。fallbacks:降级路径,按优先级排列,可以是其他能力或模板。
失败语义有四种,每种都有适用边界:
fail-fast:立刻明确失败。适合不能猜结果的能力,比如审核。degrade:退回更保守的实现。适合用户可感知但可接受的场景,比如回复、摘要。queue:排队稍后处理。适合可以延迟但不能丢的任务,且需要足够的等待预算。silent-skip:静默跳过。本书直接禁止,因为调用方会拿到空结果却无法区分「没有内容」和「能力坏了」。
校验器把这些边界变成可执行规则:degrade 必须有降级路径,fail-fast 不能声明降级路径,queue 至少要有 3000 毫秒预算,silent-skip 一律拒绝。规则不是为了限制表达,而是为了让评审时的隐含假设显式化。
校验器代码
下面的文件同时承担三件事:定义契约类型、给出「回声」的五个能力、提供只读校验函数。它不依赖任何模型 SDK,也不读取环境变量,因此可以在 CI 里对同一份清单反复运行。
// file: src/core/capability-spec.ts
// tier: L1
// verify: node --test test/core/capability-spec.test.ts
import type { QualityTier } from "./types.ts";
/** 能力失败时产品的处理方式,必须在契约里写死,不能留给调用方猜。 */
export type FailureMode = "fail-fast" | "degrade" | "queue" | "silent-skip";
export interface CapabilitySpec {
/** 形如 "chat.reply" 的稳定标识,发布后不得复用。 */
id: string;
title: string;
inputs: string[];
outputs: string[];
qualityTier: QualityTier;
/** 端到端 P95 预算,毫秒(案例假设值)。 */
latencyBudgetMs: number;
/** 单次请求成本上限,微元(案例假设值)。 */
costCeilingMicros: number;
/** 评测集通过率下限,0..1(案例假设值)。 */
minPassRate: number;
onFailure: FailureMode;
/** 降级路径;能力标识或 "template:xxx" 模板,按优先级排列。 */
fallbacks: string[];
}
export interface SpecIssue {
capability: string;
field: string;
message: string;
}
/** 「回声」本卷的五个能力,后续章节都围绕这份清单展开。 */
export const echoCapabilities: CapabilitySpec[] = [
{
id: "chat.reply",
title: "私信智能回复草稿",
inputs: ["thread.messages", "user.tone-profile"],
outputs: ["reply.draft"],
qualityTier: "standard",
latencyBudgetMs: 2_500,
costCeilingMicros: 1_200,
minPassRate: 0.82,
onFailure: "degrade",
fallbacks: ["template:quick-replies"],
},
{
id: "search.rerank",
title: "内容搜索重排",
inputs: ["query.text", "candidates.top50"],
outputs: ["ranking.ids"],
qualityTier: "standard",
latencyBudgetMs: 300,
costCeilingMicros: 400,
minPassRate: 0.9,
onFailure: "degrade",
fallbacks: ["template:bm25-order"],
},
{
id: "content.summarize",
title: "长文摘要",
inputs: ["article.body", "summary.length"],
outputs: ["summary.text"],
qualityTier: "standard",
latencyBudgetMs: 6_000,
costCeilingMicros: 3_000,
minPassRate: 0.8,
onFailure: "degrade",
fallbacks: ["template:first-paragraph"],
},
{
id: "moderation.classify",
title: "内容审核分类",
inputs: ["content.text", "content.images"],
outputs: ["moderation.labels", "moderation.score"],
qualityTier: "economy",
latencyBudgetMs: 400,
costCeilingMicros: 100,
minPassRate: 0.95,
onFailure: "fail-fast",
fallbacks: [],
},
{
id: "support.assist",
title: "客服辅助回答",
inputs: ["ticket.text", "faq.corpus"],
outputs: ["answer.draft", "citations"],
qualityTier: "frontier",
latencyBudgetMs: 1_800,
costCeilingMicros: 4_500,
minPassRate: 0.85,
onFailure: "degrade",
fallbacks: ["chat.reply", "template:faq-snippet"],
},
];
const CAPABILITY_ID = /^[a-z][a-z0-9]*(\.[a-z][a-z0-9-]*)+$/;
function issue(capability: string, field: string, message: string): SpecIssue {
return { capability, field, message };
}
function hasDuplicates(values: string[]): boolean {
return new Set(values).size !== values.length;
}
/**
* 校验能力清单。返回空数组表示可以发布。
* 校验只读,不修改输入,便于在 CI 里对同一份清单反复运行。
*/
export function validateCapabilitySpec(specs: CapabilitySpec[]): SpecIssue[] {
const issues: SpecIssue[] = [];
const ids = new Set<string>();
if (specs.length === 0) {
issues.push(issue("*", "spec", "能力清单不能为空"));
return issues;
}
for (const spec of specs) {
if (!CAPABILITY_ID.test(spec.id)) {
issues.push(issue(spec.id || "*", "id", "能力标识必须形如 domain.action"));
}
if (ids.has(spec.id)) {
issues.push(issue(spec.id, "id", "能力标识重复,发布后不得复用"));
}
ids.add(spec.id);
if (spec.title.trim() === "") {
issues.push(issue(spec.id, "title", "能力必须有面向产品的标题"));
}
if (spec.inputs.length === 0) {
issues.push(issue(spec.id, "inputs", "输入契约不能为空"));
}
if (spec.outputs.length === 0) {
issues.push(issue(spec.id, "outputs", "输出契约不能为空"));
}
if (hasDuplicates(spec.inputs) || hasDuplicates(spec.outputs)) {
issues.push(issue(spec.id, "io", "输入或输出存在重复字段"));
}
if (spec.latencyBudgetMs <= 0) {
issues.push(issue(spec.id, "latencyBudgetMs", "延迟预算必须为正"));
}
if (spec.costCeilingMicros <= 0) {
issues.push(issue(spec.id, "costCeilingMicros", "成本上限必须为正"));
}
if (spec.minPassRate <= 0 || spec.minPassRate > 1) {
issues.push(issue(spec.id, "minPassRate", "通过率下限必须落在 (0, 1]"));
}
if (spec.onFailure === "degrade" && spec.fallbacks.length === 0) {
issues.push(issue(spec.id, "fallbacks", "degrade 必须给出至少一条降级路径"));
}
if (spec.onFailure === "fail-fast" && spec.fallbacks.length > 0) {
issues.push(issue(spec.id, "fallbacks", "fail-fast 不应声明降级路径"));
}
if (spec.onFailure === "queue" && spec.latencyBudgetMs < 3_000) {
issues.push(issue(spec.id, "latencyBudgetMs", "queue 模式需要至少 3000ms 的等待预算"));
}
if (spec.onFailure === "silent-skip") {
issues.push(issue(spec.id, "onFailure", "silent-skip 会让调用方拿到空结果,不能作为能力失败模式"));
}
if (spec.fallbacks.includes(spec.id)) {
issues.push(issue(spec.id, "fallbacks", "降级路径不能指向自身"));
}
}
for (const spec of specs) {
for (const fallback of spec.fallbacks) {
if (fallback.startsWith("template:")) {
continue;
}
if (!ids.has(fallback)) {
issues.push(issue(spec.id, "fallbacks", `降级目标 ${fallback} 不在能力清单内`));
}
}
}
return issues;
}
/** 按标识取能力;调用方拿不到时应视为配置错误,而不是静默跳过。 */
export function findCapability(specs: CapabilitySpec[], id: string): CapabilitySpec | undefined {
return specs.find((spec) => spec.id === id);
}
几个实现细节值得说明。
第一,validateCapabilitySpec 返回问题数组而不是抛异常。清单校验通常要一次看到全部问题,抛异常只能看到第一个,修完再跑很浪费。返回数组也让测试可以直接比对。
第二,能力标识用正则约束成 domain.action。这不是为了好看,而是为了让指标和日志可以按前缀聚合。chat.reply 和 chat.rewrite 天然属于 chat 域,账单和告警可以按域拆开。
第三,降级目标分两类:能力标识和 template: 前缀。前者表示复用另一个 AI 能力,后者表示退回确定性模板。两类都允许,但能力标识必须真实存在,否则运行到降级时才发现目标不存在,就太晚了。
第四,自引用被单独拦截。一个能力降级到自己,看起来像「重试」,实际会在提供方故障时形成循环。校验器不允许这种写法,重试应该由网关的重试策略处理,而不是写进降级链。
第五,silent-skip 被完全禁止。空结果和失败结果必须可区分,否则上层无法决定是展示空态、重试还是告警。
校验器测什么
测试覆盖九类情况:正常清单、标识格式与重复、降级缺失、快速失败带降级、降级目标不存在、数值区间、排队预算、静默跳过、按标识查找。
在实验工程根目录执行:
node --test test/core/capability-spec.test.ts
✔ 回声的五个能力通过契约校验 (0.951833ms)
✔ 能力标识必须符合 domain.action 且不可重复 (0.259625ms)
✔ degrade 必须给出降级路径 (0.474833ms)
✔ fail-fast 不允许声明降级路径 (0.117583ms)
✔ 降级目标必须存在或使用 template 前缀 (0.105584ms)
✔ 通过率、延迟与成本必须落在合法区间 (0.103417ms)
✔ queue 模式需要足够的等待预算 (0.089041ms)
✔ silent-skip 不能作为能力失败模式 (0.073041ms)
✔ findCapability 按标识查找,未知标识返回 undefined (0.082458ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 179.688292
这些测试里最重要的是第一个:它断言「回声」的五个能力零问题。后面任何一章如果给能力加了字段、改了失败语义,这个测试会先失败,提醒作者同步更新清单和文档。
一次降级链自引用的复盘
「回声」最初的客服能力把降级写成这样:模型不可用时,降级到 support.assist 自己,期望网关「换个模型再试一次」。
这个写法在演示阶段没有暴露问题,因为演示时模型一直可用。上线后遇到提供方限流,网关按照降级链重新调用同一个能力,又打到同一个提供方,再次失败,再降级,形成短循环。监控上看到的是一串重复请求,用户看到的是长时间等待后仍然失败。
根因是把「重试」和「降级」混成了一件事:
- 重试是同一能力、同一语义,换实例或换时间再试,由网关的重试策略控制。
- 降级是换一个更保守的实现,输出语义可以变,但必须保证可用。
校验器的自引用规则就是这次事故之后加的。它不能自动设计出正确的降级链,但能在清单提交时拦住「降级到自己」这种明显错误。修正后的链路是 support.assist → chat.reply → template:faq-snippet,每一级都比上一级更简单、更确定。
这次复盘还留下一个教训:清单里的每个字段都要能被机器检查,否则它就只是注释。 如果降级链只写在文档里,自引用会一直存在到故障发生。
后面十二章怎么围绕这份清单展开
这份清单是第二到第十三章的共同输入。可以按问题分成四组来看。
模型与选型(第 2 至 3 章)。 第二章讲模型能力的五个维度,第三章讲选型与路由。它们要回答的是:给定 qualityTier、latencyBudgetMs、costCeilingMicros,应该选哪个模型档案、在哪个区域、按什么顺序尝试。清单里的预算字段就是选型的约束条件。
协议与输出(第 4 至 6 章)。 流式、结构化输出、工具调用分别对应不同能力。chat.reply 需要首字预算,search.rerank 和 moderation.classify 需要严格结构,support.assist 需要引用与工具。协议章节会用这五个能力做例子,而不是另起一套玩具场景。
成本与可靠性(第 7 至 9 章)。 成本章节按 costCeilingMicros 拆账,可靠性章节按 onFailure 设计降级,可观测性章节按能力标识打点和告警。清单里的失败语义直接决定这些章节的代码路径。
安全与评测(第 10 至 13 章)。 审核能力对应安全章节,minPassRate 对应评测章节,客服与摘要对应人工复核和反馈闭环。评测不是最后补一张报表,而是从清单里的质量下限倒推评测集怎么建。
十二个章节不是十二个孤立话题,而是同一份清单的不同切面。读者可以把每一章的实现挂回这五个能力,看它改变了哪个字段、哪条路径。
清单之外的东西
清单故意不包含模型名、提示词、提供方 SDK、重试次数。这些都属于实现,应该随模型和供应商变化,而清单要相对稳定。
一个常见的反例是把 model: "mock-frontier" 写进能力契约。这样做的直接后果是:换模型要改契约,灰度要改契约,降级到另一个模型也要改契约。契约一旦频繁变动,就失去了约束力。
正确的分层是:能力契约描述「要什么」,模型档案描述「谁能提供」,路由策略描述「怎么选」。三份东西各自演化,通过标识关联,而不是互相嵌套。
小结
ai-lab 的第一份产物不是模型调用代码,而是一份能力清单。它把「回声」的五个能力固定成标识、输入输出、质量下限、延迟与成本预算、失败语义和降级链,并用校验器把明显错误拦在提交阶段。
清单的价值不在于写得多全,而在于它是后面十二章的共同坐标系。选型、路由、流式、结构化、成本、可靠性、安全、评测都能挂回这五个能力,读者不会学到一堆互不相干的技巧。
下一章开始进入模型本身:同一个能力,不同模型到底差在哪五个维度上。
阅读导航:上一节:1.2 哪些事该用模型,哪些不该 · 下一节:2.1 模型能力的五个维度 。
常见错误与边界
- 把清单写成文档附件。没有校验器的清单会在第一次改动后失效。
- 允许降级指向自身。自引用会在提供方故障时变成循环,重试应由网关策略处理。
- 用
silent-skip处理失败。调用方无法区分空结果与能力故障,问题会被推迟到更晚。 - 在契约里写死模型名。模型是实现,换型不该改能力边界。
- 只校验字段存在,不校验语义。
degrade没有降级路径、queue没有等待预算,都是语义错误。 - 让不同能力共用一个模糊标识。指标、配额和账单会混在一起,事后无法归因。
- 把质量下限定成「越高越好」。下限要结合成本和用户体验,0.99 可能意味着成本翻倍。
- 认为清单只服务工程。产品、运营、财务都需要按能力讨论预算和失败策略。
- 清单一次定稿就不再复核。模型、价格、监管和用户分布变化后,预算与失败语义都要重算。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。