《互联网产品的 AI 架构·模型接入与能力边界》1.3 ai-lab:从需求到能力契约

ai-lab 把「回声」的五个能力写成一份可校验清单:智能回复、搜索重排、长文摘要、审核分类、客服辅助,每个能力都固定输入输出、质量下限、延迟与成本预算和失败语义;本节给出清单校验器的可运行 TypeScript 实现与测试,并说明后面十二章如何围绕这份清单展开。

为什么先写清单

1.1 说明了能力契约的六个面,1.2 说明了哪些任务该进入模型。到了这一节,要把前两节的结论落成一份工程上可执行的清单。

「回声」的候选任务有七个,最终进入 AI 能力清单的是五个:私信智能回复、搜索重排、长文摘要、内容审核分类、客服辅助回答。手机号脱敏走规则,退款审批走人工,不进入模型能力清单,但它们的判断结果保留在 1.2 的判据函数里。

清单不是文档附件,而是后面十二章的共同坐标系。选型要按能力查预算,路由要按能力查降级,成本要按能力拆账,评测要按能力设门槛。如果清单本身可以随手改、拼写不一致、降级指向不存在的目标,后面每一章都会建立在流沙上。

「回声」的五个能力

先看全貌。表中的延迟、成本、通过率都是案例假设值,用来演示契约怎么定,不代表任何真实产品的实测。

能力标识产品场景质量档P95 预算单次成本上限质量下限失败语义
chat.reply私信智能回复草稿standard2500ms1200 微元0.82降级到快捷回复模板
search.rerank内容搜索重排standard300ms400 微元0.90降级到原始排序
content.summarize长文摘要standard6000ms3000 微元0.80降级到首段截取
moderation.classify内容审核分类economy400ms100 微元0.95快速失败转人工
support.assist客服辅助回答frontier1800ms4500 微元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 可能意味着成本翻倍。
  • 认为清单只服务工程。产品、运营、财务都需要按能力讨论预算和失败策略。
  • 清单一次定稿就不再复核。模型、价格、监管和用户分布变化后,预算与失败语义都要重算。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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