这份附录解决什么重复劳动
卷一的每一章都在做同一件事:把模糊的模型调用,改写成有边界的约定。
重复出现的产物只有两类:能力契约与错误模型。前者写清“承诺什么”,后者写清“做不到时怎么表达”。附录 B 把它们做成可直接抄用的模板。
模板不是标准答案。照抄能保证不漏项,但不能保证填得对。填不对的地方,恰恰是需要团队自己决策的地方。
一、能力契约模板
一份契约至少包含六面承诺:
能力标识: chat.reply
版本: v2
所有者: 消息团队
模型无关性: 业务只依赖本契约,不依赖模型 ID
输入: 会话摘要 + 最近 N 条消息 + 用户语气偏好
输出: 单段回复文本,长度上限 400 字
质量: 人工抽检通过率 >= 92%,安全拦截率 <= 0.5%
延迟: 首字 P95 <= 800ms,完整 P95 <= 4s
成本: 单次 <= 1200 微元,租户月度上限见配额服务
失败: 不可用时降级为“建议回复”候选,不阻塞输入框
六个字段里最容易空着的是质量与失败。延迟和成本通常有人负责,质量往往只写“效果好”,失败往往只写“报错”。
质量要落到可验收的指标上:抽检样本量、判定人、通过标准、复核周期。没有这些,上线后没人能回答“它到底行不行”。
失败要落到用户可见的行为上:入口是否隐藏、是否换成较弱结果、是否明确提示稍后再试。三者对应完全不同的产品体验。
二、请求与响应形状
契约的传输形状建议保持稳定,不随供应商变化:
请求:
capability 能力标识,必填
tenant 租户标识,由网关注入,业务不得伪造
input 结构化输入,字段随能力而定
constraints 可选,覆盖默认预算
deadlineMs 本次完整时限
maxOutputTokens
idempotencyKey 可选,写操作与计费相关时必填
trace 可选,请求链路标识
响应:
output 结构化输出,字段随能力而定
usage 输入/输出 token、金额微元
model 实际使用的模型标识
degraded 是否发生降级,以及降级等级
requestId 用于对账与排障
三个字段值得单独说明。
tenant 由网关注入:业务请求里带租户只是一种声明,网关按凭证重新确定。否则客户端可以伪装成别的租户,配额与数据边界同时失效。
constraints 是覆盖不是放权:业务可以要求更短的超时,但不能超过平台上限。反过来,平台可以在业务不知情时收紧预算,响应里的 degraded 要如实反映。
usage 必须回传:没有用量的响应无法计量。计量缺失时,成本只能按调用次数估算,误差会随着模型切换迅速放大。
三、五种能力的契约对照
以「回声」的五项能力为例,对照六面承诺的填写差异:
| 能力 | 输入重点 | 输出约束 | 质量指标 | 失败姿态 |
|---|---|---|---|---|
| chat.reply | 会话摘要 + 最近消息 | 单段文本、400 字内 | 人工抽检通过率 | 降级为候选建议 |
| search.rerank | 查询 + 候选列表 | 只重排,不新增条目 | 前 3 位命中率 | 保留原顺序 |
| content.summarize | 正文、目标长度 | 要点列表,条数固定 | 要点覆盖率 | 返回截断原文 |
| moderation.classify | 待审文本 + 策略版本 | 类别 + 置信度 + 理由 | 误杀率与漏放率 | 转人工队列 |
| support.assist | 工单上下文 + 知识片段 | 建议回复 + 引用编号 | 采纳率、引用准确率 | 只展示知识片段 |
这张表能暴露一个常见错误:把“失败姿态”统一写成“报错”。五项能力的失败代价完全不同。重排失败保留原顺序几乎无感;审核失败转人工只是慢一点;回复生成失败如果直接报错,用户会以为整个输入框坏了。
四、九类错误
错误模型的价值在于分类,而不是码值本身。九类错误覆盖了卷一中出现的全部失败:
| 类别 | 含义 | 可重试 | HTTP | 典型原因 |
|---|---|---|---|---|
| auth | 凭证无效或过期 | 否 | 401 | 密钥轮换、租户禁用 |
| quota | 配额耗尽 | 否 | 402 | 月度额度用完、单次超限 |
| rate-limit | 触发限流 | 是,带退避 | 429 | 突发流量、共享池争用 |
| timeout | 超过预算未完成 | 是,有剩余预算时 | 504 | 长输入、模型拥堵 |
| unavailable | 依赖不可用 | 是 | 503 | 供应商故障、区域中断 |
| invalid-request | 请求本身不合法 | 否 | 400 | 字段缺失、超长、参数冲突 |
| invalid-output | 模型输出不合约定 | 是,有限次修复 | 502 | 结构漂移、截断 |
| content-blocked | 内容被策略拦截 | 否 | 422 | 安全策略、合规要求 |
| provider | 供应商返回未归类错误 | 视情况 | 502 | 私有错误码、协议异常 |
分类的关键是可重试与用户可解释两列。第一列决定代码路径,第二列决定产品文案。两者不一致时,故障处理一定混乱:例如把配额耗尽标成可重试,重试只会把额度耗得更快。
五、可重试判定
可重试不是“看起来像临时故障”,而是同时满足三个条件:
- 错误类别在可重试集合内;
- 剩余预算足够完成一次尝试;
- 请求是幂等的,或带幂等键且服务端能去重。
三个条件缺一不可。缺第一条会重试无效错误;缺第二条会让重试把延迟拖爆;缺第三条会在写操作上产生重复副作用。
退避策略建议:
| 场景 | 初始间隔 | 上限 | 抖动 |
|---|---|---|---|
| 限流 | 200ms | 2s | 全抖动 |
| 超时 | 100ms | 1s | 等抖动 |
| 不可用 | 300ms | 3s | 全抖动 |
上限要小于剩余预算。预算 3 秒、退避上限 3 秒,等于只允许一次重试,这个关系应该在代码里显式表达,而不是靠调参碰运气。
六、HTTP 映射与用户文案
对外 API 的 HTTP 状态码与内部错误类别是两层。映射保持稳定,但文案按场景调整:
| 内部类别 | HTTP | 面向开发者的文案 | 面向终端用户的文案 |
|---|---|---|---|
| auth | 401 | 凭证无效,请检查密钥 | 登录状态异常,请重新登录 |
| quota | 402 | 配额已用尽 | 本月额度已用完 |
| rate-limit | 429 | 请求过于频繁,请稍后重试 | 当前使用人数较多,请稍后再试 |
| timeout | 504 | 生成超时 | 生成时间有点久,请重试 |
| unavailable | 503 | 依赖服务不可用 | 该功能暂时不可用 |
| invalid-request | 400 | 请求参数不合法 | 输入内容有误,请检查 |
| invalid-output | 502 | 输出不符合约定 | 生成结果异常,请重试 |
| content-blocked | 422 | 内容被策略拦截 | 内容不符合规范,请修改后重试 |
| provider | 502 | 供应商标识错误 | 该功能暂时不可用 |
两列文案都要写。只写开发者文案,前端会临时想词;只写用户文案,排障时看不出真实原因。
七、响应中的降级字段
降级不是布尔值,而是分级:
| 等级 | 含义 | 用户感知 | 记录字段 |
|---|---|---|---|
| none | 正常 | 无 | degraded=false |
| weaker | 换了较弱模型或更短输出 | 结果质量下降 | degraded=true, level=weaker |
| cached | 返回缓存结果 | 可能略旧 | degraded=true, level=cached |
| fallback | 走非模型兜底 | 功能变简 | degraded=true, level=fallback |
| blocked | 明确不可用 | 看到提示 | degraded=true, level=blocked |
分级的意义在于复盘。只记录“降级过”,无法区分是换了模型还是返回了缓存,成本与质量的变化也就无法归因。
八、流式事件速查
流式响应建议只暴露五类事件:
| 事件 | 时机 | 必带字段 | 客户端动作 |
|---|---|---|---|
| start | 首帧 | requestId, model | 建立占位 |
| delta | 每个增量 | text | 追加渲染 |
| usage | 结束前 | 输入/输出 token、金额 | 记录用量 |
| done | 正常结束 | finishReason | 收尾、解除禁用 |
| error | 任意时刻失败 | kind, retryable | 按类别提示或重试 |
两条硬规则:
- 首帧之后不自动重试。已经渲染的文本无法撤回,重试会产生重复内容。正确做法是把错误交给客户端,由用户决定是否重新生成。
- 取消要向上游传播。客户端断开只是断开连接,上游可能仍在生成并计费。取消事件必须让网关通知供应商停止。
如果供应商的流式协议与这五类事件不同,在网关做转换,不要把供应商的私有事件名透给客户端。透传一次,以后换供应商就要改所有客户端。
九、结构化输出失败表
结构化输出的失败不是一种,处理方式也不同:
| 失败形态 | 例子 | 处理 |
|---|---|---|
| 非法 JSON | 缺少引号、尾逗号 | 尝试修复一次,失败则重新生成 |
| 结构不符 | 缺必填字段、类型错误 | 带校验错误重新生成,最多一次 |
| 枚举越界 | 类别不在允许集合内 | 映射到 unknown,不重试 |
| 截断 | 输出达到上限 | 缩短输入或提高上限后重试一次 |
| 语义冲突 | 字段之间自相矛盾 | 放弃自动修复,转兜底 |
| 空结果 | 结构合法但内容为空 | 按能力定义处理,通常视作失败 |
修复预算要与总预算共享。常见错误是给修复单独设三次重试,结果一次请求变成四次调用,成本和延迟都翻倍。
十、预算计算速查
三个预算的常用算式:
输入 token 上限 = 模型上下文上限 - 输出上限 - 预留余量
单次成本上限 = 输入 token × 输入单价 + 输出 token × 输出单价
端到端预算 = 排队 + 首字 + 生成 + 后处理 + 网络余量
金额统一用整数微元,避免浮点误差。单价按百万 token 计,换算时先乘后除,不要先除后乘。
预算分配建议给后处理留固定余量,而不是按比例。后处理耗时与输出长度弱相关,按比例留会在短输出时不够用。
十一、契约评审的五个检查点
拿到一份契约,按顺序检查:
- 六面是否齐全:缺质量或失败的那一面,基本等于没写。
- 是否混入模型标识:契约里出现具体模型名,说明还没抽象干净。
- 错误是否分类:只有“失败”一个类别,无法决定重试与文案。
- 预算是否可拆:只有一个总超时,排障时无法定位卡在哪一段。
- 降级是否有等级:只有布尔值,复盘时无法归因。
五个检查点都能过,契约就可以进入实现;过不了的,先补齐再写代码。
十二、契约落地时的三个常见冲突
模板填完不等于能落地。评审中最常出现的冲突有三类:
| 冲突 | 表现 | 处理方式 |
|---|---|---|
| 质量与成本 | 业务要更高质量,平台要压成本 | 按能力分级:核心路径用标准档,边缘路径用经济档 |
| 延迟与安全 | 审核要求先审后发,产品要求首字即出 | 拆成两段:先出草稿,审核结果回来后再允许发送 |
| 契约与供应商 | 供应商新特性无法通过现有契约表达 | 先加可选字段并保持旧字段语义,验证后再升主版本 |
三类冲突的共同点:都不是技术问题,而是取舍问题。契约只能记录取舍结果,不能替团队做取舍。
落笔时把取舍写进字段,比写在会议纪要里可靠。例如“核心路径用标准档”应体现为能力标识或租户配置里的档位字段,而不是一句口头约定。
还有一类冲突容易被忽略:契约与现有客户端的冲突。新增可选字段通常安全,但改变默认值会悄悄影响老客户端。默认值一旦发布,就应当视为契约的一部分,修改它等同于破坏性变更。
因此版本演进的原则是:加字段不升主版本,改语义必须升主版本,删字段要经过弃用期。三条规则写进契约首页,比写在发布规范里更容易被看到。
小结
契约与错误模型是同一件事的两面:前者定义成功,后者定义失败。只写成功路径的契约,上线后会在第一次故障时暴露全部缺口。
模板可以直接抄,但六个承诺的取值必须自己填。填空的过程就是架构决策的过程:质量谁来验收、失败时用户看到什么、预算超了谁负责。这些问题的答案,比模板本身重要得多。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。