从一次「智能回复」事故说起
「回声」是一个有内容流、私信和站内下单的互联网产品。某个版本里,产品经理提了一个听起来很直接的需求:给私信加一个「智能回复」,用户点一下就能生成一段得体的回复。
团队用一周做出了演示:选一个前沿模型,把最近十条消息拼进提示词,生成一段文字填进输入框。演示会上效果很好,评审给的评价是「像人写的」。于是功能按 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 里随便返回空串。
- 认为契约写完就一劳永逸。模型、价格、区域、用户分布都会变,契约要随版本复核。
- 把能力标识当装饰。标识不稳定,指标、配额、降级和账单就无法按能力归因。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。