《互联网产品的 AI 架构·模型接入与能力边界》1.1 产品里的 AI 能力到底是什么

「回声」把智能回复当成模型演示上线后连续踩坑,本节由此区分模型调用与产品能力,把输入、输出、质量、延迟、成本、失败语义写成六面契约,并用可运行的 TypeScript 类型与测试固定下来,说明为什么能力标识、微元成本、剩余预算和降级原因必须在编码前谈清楚。

从一次「智能回复」事故说起

「回声」是一个有内容流、私信和站内下单的互联网产品。某个版本里,产品经理提了一个听起来很直接的需求:给私信加一个「智能回复」,用户点一下就能生成一段得体的回复。

团队用一周做出了演示:选一个前沿模型,把最近十条消息拼进提示词,生成一段文字填进输入框。演示会上效果很好,评审给的评价是「像人写的」。于是功能按 5% 流量上线。

上线第三天,问题集中出现:

  • 高峰期一部分请求超过四秒,用户以为按钮没反应,连续点击,重复生成。
  • 有用户收到了一段回复「您的订单已发货」,但对话里根本没有订单,模型把商品名当成了订单号。
  • 提供方限流时网关直接抛错,输入框上方只显示「服务异常」,用户不知道还能不能手动回复。
  • 财务月底对账,发现这个功能的成本比预估高出一倍,因为重试和重复点击都被计费。

复盘时团队发现,演示阶段只验证了一件事:输出看起来不错。没有人回答「输入里应该有什么、不该有什么」「输出怎样算合格」「多慢算失败」「失败后产品怎么办」。他们把一次模型调用当成了产品能力。

这一节要解决的就是这个区分。它不是概念游戏,而是决定后面十二章怎么写的底座。

模型调用与产品能力不是一回事

模型调用是「把一段文本发给提供方,拿回一段文本」。产品能力是「在约定条件下,向用户交付一个可预期结果,并在条件不满足时给出可理解的替代」。

把两者放在一张表里:

问题模型调用产品能力
谁定义边界提供方的 API 文档产品、工程、算法共同约定
输入一段提示词结构化上下文、裁剪规则、脱敏规则
输出文本可校验结构加失败语义
质量主观觉得写得好评测集、通过率、分场景切片
延迟单次请求耗时端到端预算与首字预算
成本月底账单单次上限、租户配额、失败也计量
故障抛一个异常用户能理解的降级或明确失败

这张表最重要的不是「产品能力更复杂」,而是「模型调用只是能力的一种实现」。同一个能力可以换模型、换提供方、换成模板,甚至临时换成人。只要契约不变,业务代码就不该跟着改。

反过来,如果先选模型再想产品,契约就会被模型的能力牵着走:模型上下文只有 8K,产品就砍掉历史消息;模型不支持结构化输出,前端就自己去解析自由文本。这些决定看起来是技术限制,实际上是把提供方的实现细节泄漏成了产品规则。

能力契约的六个面

一个可交付的 AI 能力,至少要把六件事写清楚。下面用「回声」的私信智能回复举例,数字都是案例假设值,用来演示判断过程。

输入:谁负责裁剪与脱敏

输入不是「一段提示词」,而是一组有名字、有来源、有上限的字段。智能回复需要最近若干条消息、当前用户的语言与语气偏好、以及产品允许引用的订单事实。契约要说明:

  • 哪些字段必填,缺失时是拒绝还是降级。
  • 上下文上限按 token 还是按消息条数,超出后保留头部还是尾部。
  • 手机号、地址、身份证号在进入模型前由谁脱敏。

「回声」最后把裁剪放在业务侧,把脱敏放在网关侧。业务侧最了解「哪段对话重要」,网关侧最了解「哪些字段不能出域」。两边各做一半,比全部塞给提示词工程可靠。

输出:结构先于文采

如果输出只是一段字符串,产品就只能「展示」它,不能校验、不能统计、不能局部替换。智能回复的契约最终定为三个字段:回复正文、语气标签、是否引用了订单事实。前端根据语气标签决定气泡样式,审核根据引用标记做二次检查。

传输层的 ChatResponse.output 仍然是字符串,因为网关不该理解每个业务的 schema。结构化发生在能力层:能力层负责把字符串解析成对象,解析失败就归为 invalid-output,而不是把半截 JSON 直接展示给用户。

质量:通过率而不是「感觉不错」

演示会上「像人写的」不是质量指标。可用的质量指标必须能重复测量:给定一批评测样本,按评分表判断通过与否,得到通过率;再按场景切片,看是不是某一类输入特别差。

「回声」给智能回复定的下限是通过率 0.82,并且单独要求「不得编造订单状态」这一项达到 0.98。前者是整体体验,后者是事故红线。两个数字都写进契约,评审时才能争论「0.82 够不够」,而不是争论「我觉得还行」。

延迟:预算不是超时

超时是「等到某个时刻就放弃」,预算是「从用户点击到看到结果,总共只有这么多时间」。两者差很多:如果网关先排队 300 毫秒、再重试一次、再等提供方,单次超时设置得再合理,端到端也可能超出用户耐心。

所以请求里带的是 deadlineMs,表示剩余预算,而不是某个提供方的 timeout。网关必须在预算内给出结果或明确失败。流式场景还要拆成首字预算和总预算:首字超过 800 毫秒,用户就会觉得卡;总时长超过 2.5 秒,草稿的价值已经下降。

成本:用整数微元说话

成本如果用浮点数元表示,累计几百万次请求后会出现对不上的分位。契约里统一用整数微元:一百万 token 多少微元,单次请求向上取整。向上取整不是抠门,而是避免大量小请求被算成零成本,最后账对不上。

「回声」给智能回复定的单次上限是 1200 微元,约合 0.0012 元。这个数字决定了它能用哪个档位的模型、上下文能带多长,也决定了限流时先砍哪个能力。成本不是财务事后才知道的结果,而是设计时就要分配的预算。

失败语义:降级是一等公民

失败不是只有「抛异常」一种。产品需要知道:是明确告诉用户失败,还是退回模板,还是排队稍后处理,还是静默跳过。这四种选择对用户体验的影响完全不同,不能留给调用方临时决定。

「回声」的智能回复选择降级:模型不可用时返回三条预设快捷回复,并标注「模板建议」。内容审核则选择快速失败:审核结果不能猜,宁可拦住等人工,也不能放过。两个能力在同一个网关里,失败语义却相反,这正是契约要逐个写清楚的原因。

用类型把契约钉住

下面的类型文件是本书实验工程的第一块地基。它不依赖任何模型 SDK,只描述网关与业务之间的约定。

// file: src/core/types.ts
// tier: L1
// verify: node --test test/core/foundation.test.ts

/** 产品对外承诺的 AI 能力标识,例如 "chat.reply"、"search.rerank"。 */
export type CapabilityId = string;
export type ProviderId = string;
export type TenantId = string;

export type QualityTier = "frontier" | "standard" | "economy";

/** 模型档案是选型与路由的唯一事实来源,成本一律用整数微元表示。 */
export interface ModelProfile {
  id: string;
  provider: ProviderId;
  capabilities: CapabilityId[];
  qualityTier: QualityTier;
  contextWindow: number;
  maxOutputTokens: number;
  /** 输入价格:微元 / 百万 token,避免浮点误差。 */
  inputMicrosPerMTok: number;
  /** 输出价格:微元 / 百万 token。 */
  outputMicrosPerMTok: number;
  /** 该模型在目标区域的 P95 延迟,用于预算分配,不是 SLA。 */
  p95LatencyMs: number;
  regions: string[];
  healthy: boolean;
}

export type Role = "system" | "user" | "assistant" | "tool";

export interface ChatMessage {
  role: Role;
  content: string;
  name?: string;
}

/** 网关收到的统一请求。业务方只描述能力与预算,不指定具体模型。 */
export interface ChatRequest {
  requestId: string;
  capability: CapabilityId;
  tenantId: TenantId;
  region?: string;
  messages: ChatMessage[];
  maxOutputTokens: number;
  /** 端到端剩余预算;网关必须在预算内给出结果或明确失败。 */
  deadlineMs: number;
  stream?: boolean;
  /** 业务幂等键:同一键在窗口内允许复用结果。 */
  idempotencyKey?: string;
  metadata?: Record<string, string>;
}

export interface Usage {
  inputTokens: number;
  outputTokens: number;
  costMicros: number;
}

export type DegradeReason =
  | "model-unavailable"
  | "deadline-exceeded"
  | "budget-exceeded"
  | "content-blocked"
  | "fallback-template";

export interface ChatResponse {
  requestId: string;
  modelId: string;
  output: string;
  usage: Usage;
  finishReason: "stop" | "length" | "tool_call" | "content_filter";
  degraded?: DegradeReason;
}

/** 时钟注入让超时与预算逻辑可以被确定性测试。 */
export interface Clock {
  now(): number;
}

export const systemClock: Clock = {
  now: () => Date.now(),
};

几个设计选择值得展开。

第一,CapabilityId 是字符串别名而不是枚举。能力标识会随产品增长,枚举每加一个值都要改所有引用;字符串别名配合清单校验,能在运行时发现拼写错误,又不把编译期锁死。

第二,ModelProfile 是选型与路由的唯一事实来源。价格、上下文、区域、健康状态都放在这里,业务代码不读提供方文档,也不写死模型名。换模型是改档案,不是改调用点。

第三,价格用「微元 / 百万 token」而不是「元 / 千 token」。前者和账单单位一致,后者每个团队都要心算一次,迟早算错。

第四,ChatRequest 带 region、deadlineMs、idempotencyKey。区域决定数据去哪里,预算决定能等多久,幂等键决定重复点击会不会重复计费。这三个字段都是事故之后补上的,不是一开始就想到的。

第五,ChatResponse 有 degraded 字段。降级不是异常路径,而是正常返回的一部分。调用方必须显式处理它,不能假装没看见。

第六,Clock 是注入的。超时、预算、缓存过期都依赖时间,如果直接调 Date.now(),测试就只能靠 sleep。注入时钟让这些逻辑可以确定性重放。

可运行的契约测试

契约如果只写在文档里,第一次改动就会被绕过。本书的做法是:每个契约配一个能跑的类型测试或行为测试,测试失败就是契约被破坏。

// file: test/core/foundation.test.ts
// tier: L1
// verify: node --test test/core/foundation.test.ts

import assert from "node:assert/strict";
import test from "node:test";

import { AiError, isRetryable, normalizeError } from "../../src/core/errors.ts";
import { systemClock } from "../../src/core/types.ts";
import type { ChatRequest, ModelProfile } from "../../src/core/types.ts";
import { estimateCostMicros, validateRequest } from "../../src/protocol/chat.ts";
import { decodeStreamEvent, encodeStreamEvent } from "../../src/protocol/stream.ts";

const model: ModelProfile = {
  id: "mock-small",
  provider: "mock",
  capabilities: ["chat.reply"],
  qualityTier: "economy",
  contextWindow: 8_000,
  maxOutputTokens: 1_024,
  inputMicrosPerMTok: 250_000,
  outputMicrosPerMTok: 1_000_000,
  p95LatencyMs: 400,
  regions: ["cn"],
  healthy: true,
};

function request(overrides: Partial<ChatRequest> = {}): ChatRequest {
  return {
    requestId: "req-1",
    capability: "chat.reply",
    tenantId: "tenant-a",
    messages: [{ role: "user", content: "你好" }],
    maxOutputTokens: 128,
    deadlineMs: 2_000,
    ...overrides,
  };
}

test("合法请求通过校验", () => {
  assert.doesNotThrow(() => validateRequest(request()));
});

test("空消息被归为 invalid-request 且不可重试", () => {
  try {
    validateRequest(request({ messages: [] }));
    assert.fail("应当抛出错误");
  } catch (error) {
    const aiError = normalizeError(error);
    assert.equal(aiError.kind, "invalid-request");
    assert.equal(isRetryable(aiError), false);
  }
});

test("超时与限流可重试,鉴权错误不可重试", () => {
  assert.equal(isRetryable(new AiError("timeout", "slow")), true);
  assert.equal(isRetryable(new AiError("rate-limit", "busy")), true);
  assert.equal(isRetryable(new AiError("auth", "bad key")), false);
});

test("成本按微元向上取整", () => {
  assert.equal(estimateCostMicros(model, 1_000_000, 1_000_000), 1_250_000);
  assert.equal(estimateCostMicros(model, 1, 1), 2);
});

test("流式事件可往返编解码", () => {
  const frame = encodeStreamEvent({ type: "delta", text: "回声" });
  assert.deepEqual(decodeStreamEvent(frame), { type: "delta", text: "回声" });
  assert.equal(decodeStreamEvent("data: [DONE]\n\n"), null);
});

test("系统时钟返回毫秒时间戳", () => {
  assert.equal(typeof systemClock.now(), "number");
});

在实验工程根目录执行:

node --test test/core/foundation.test.ts

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

✔ 合法请求通过校验 (0.530459ms)
✔ 空消息被归为 invalid-request 且不可重试 (0.790084ms)
✔ 超时与限流可重试,鉴权错误不可重试 (0.06675ms)
✔ 成本按微元向上取整 (0.075083ms)
✔ 流式事件可往返编解码 (0.488958ms)
✔ 系统时钟返回毫秒时间戳 (0.065583ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 155.229

这些测试故意很小,但它们覆盖了四类容易退化的约定:结构错误不消耗预算、可重试与不可重试要分开、成本必须向上取整、流式协议必须能往返。后面加路由、重试、降级时,这些测试会先拦住「顺手改坏」的改动。

一次把 P95 当成 SLA 的复盘

「回声」智能回复第一次上线时,网关的截止时间是这样定的:提供方文档写 P95 是 1.8 秒,加 200 毫秒网络开销,于是 deadlineMs 设为 2000。看起来留了余量。

实际高峰期的表现是:一部分请求在 2 秒时被网关判定超时,但提供方其实还在生成,费用照收;用户看到「生成失败」,再点一次,又产生一笔费用。更糟的是,区域字段为空时请求被路由到了海外节点,P95 直接翻倍,而网关没有任何区域感知。

复盘后改了三件事:

  • 截止时间从「提供方 P95 加余量」改成「产品能接受多久」,智能回复定为 2.5 秒。
  • 增加首字预算 800 毫秒,首字超时就切模板,不再等完整结果。
  • 区域必填,缺失时按租户默认区域路由,并在指标里单独统计跨区请求。

改完之后,超时率下降,但更重要的是失败变得可解释:用户看到的是「先用快捷回复」,而不是「服务异常」。同一个故障,从不可用变成了可降级。

这个案例说明,契约里的延迟面不是「填一个数字」,而是要回答「这个数字从谁的角度算、超时之后产品做什么」。只写数字不写语义,等于没写。

契约先行带来的三个具体收益

第一,选型可以替换。业务代码只依赖能力标识和请求结构,模型档案换了,调用点不动。第二,失败可以讨论。降级原因被枚举出来,产品经理能在评审时选「模板」还是「排队」,而不是上线后看日志猜。第三,成本可以归因。失败也带 usage,重试也有幂等键,月底能按能力、租户、区域拆账。

这三个收益都不需要模型更聪明,只需要先把约定写清楚。反过来说,如果契约缺失,模型越强,事故越难定位,因为每次出问题都只能归因于「模型不稳定」。

与后面章节的关系

这一节只回答「能力是什么」。接下来 1.2 回答「哪些事根本不该用模型」,1.3 把「回声」的五个能力写成可校验的清单。第二章开始进入模型本身:能力维度、选型、路由、流式、结构化输出;第三章之后才是成本、可靠性、安全与评测。

顺序不能反。先选模型再补契约,最后一定会把模型限制写成产品规则;先写契约再选模型,模型只是可替换的实现。

小结

产品里的 AI 能力不是「调用一次模型」,而是输入、输出、质量、延迟、成本、失败语义六个面共同构成的承诺。模型调用可以换、可以降级、可以失败,能力契约必须稳定到业务代码可以依赖。

「回声」的智能回复事故不是因为模型不够好,而是因为团队只验证了输出,没有验证边界。把边界写成类型和测试之后,同样的模型可以表现得更可靠,因为系统知道什么时候该停、该退、该告诉用户。

下一节会把问题往前推一步:在写任何契约之前,先判断这件事到底该不该用模型。

阅读导航:上一节:目录与学习路径 · 下一节:1.2 哪些事该用模型,哪些不该 。

常见错误与边界

  • 把演示效果当成质量指标。演示只覆盖了顺境样本,契约要覆盖的是分布和失败。
  • 用提供方 P95 当产品截止时间。P95 是统计值,不是承诺,更不是端到端预算。
  • 只给成功路径写类型。失败、降级、超时同样是返回结果,必须进入契约。
  • 让业务代码直接拼提示词并指定模型名。模型名一旦散落,换型和灰度都无从下手。
  • 用浮点数记账。请求量一大,误差会变成对不上的账,成本必须用整数最小单位。
  • 把降级当成异常处理。降级是产品选择,应该由能力和产品共同决定,而不是 catch 里随便返回空串。
  • 认为契约写完就一劳永逸。模型、价格、区域、用户分布都会变,契约要随版本复核。
  • 把能力标识当装饰。标识不稳定,指标、配额、降级和账单就无法按能力归因。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查