统一 API 错误:缺一份文档,决定了移动端会不会坏

在同一个小型 API 上跑了 9 次 Claude Code,要求让错误响应保持一致。我们改变了两个条件:提示词是一句话还是一份规格,以及仓库里是否有那份一页纸的移动端契约。每次运行都由一套隐藏的 HTTP 测试套件评分。

无文档,模糊提示词3 次中有 2 次破坏了已上线的移动端 App
有文档,模糊提示词3 次都没有破坏;但全部未通过“同一形状”检查
规格3 次全部通过 8 项检查

实验设置

仓库

一个基于 node:http 的三端点任务 API。它的错误响应乱得很真实:{ error }、{ message }、{ errors: [...] }、一个纯文本 404、一个在任务不存在时崩溃的 PATCH,以及一个返回 err.stack 的 500。

仓库里还有 docs/mobile-contract.md:已上线的 iOS 和 Android v3.x App 把 body.error 当作字符串读取,并且会发送 X-Request-Id,客服会向用户索要这个值。

三种变体,各跑三次

  • 无文档(N1 到 N3):模糊提示词,删除契约文档。大多数真实仓库就是这样:约束存在,但只在某个人的脑子里。
  • 有文档(V1 到 V3):模糊提示词,保留契约文档。
  • 规格(S1 到 S3):规格提示词,保留契约文档。

Claude Code 2.1.284,claude-opus-5-5,headless 模式,每次使用全新副本,每次耗时 37 到 69 秒。录制于 2026 年 9 月 28 日。运行记录包。

两份提示词

模糊版(N 和 V 运行),原文

Our API error responses are all over the place. Clean them up so they're consistent.

规格版(S 运行),节选

Envelope
{ "error": "Human-readable message", "code": "project_not_found",
  "requestId": "…", "details": [] }
- error stays a non-empty string (mobile contract).
- requestId echoes X-Request-Id; also set as a header.
- details is always an array.

Acceptance criteria
- AC-2 Unknown project or task → 404.
- AC-3 Invalid fields → 422 validation_failed.
- AC-4 Archived project → 409 project_archived.
- AC-5 Malformed JSON → 400 invalid_json.
- AC-6 Unexpected exceptions → 500, no stack or path.

隐藏测试套件评分表

测试套件只通过 HTTP 与每个构建交互,探测 8 条错误路径。它不检查模糊提示词不可能知道的错误码或状态码,只检查未知资源返回 404、畸形 JSON 返回 400,以及“永远不出现 500”。

检查项无文档有文档规格
A1 每个错误响应都是 JSON3/33/33/3
A2 移动端契约:body.error 是非空字符串1/33/33/3
A3 每个错误响应体的顶层键都相同2/30/33/3
A4 未知资源返回 404,畸形 JSON 返回 400,没有 5003/33/33/3
A5 不泄露堆栈信息或内部路径3/33/33/3
A6 App 发送的 X-Request-Id 会被原样返回1/33/33/3
A7 校验错误会指出具体字段3/33/33/3
A8 成功响应保持原有形状3/33/33/3

每次运行都修复了任务不存在时的崩溃、纯文本 404 和堆栈泄露,每次运行自己写的测试也都通过了。对于这个任务中在代码里看得见的部分,模型做得很好。

故障:一个合理的 envelope,上线后却是空白横幅

N2 和 N3,没有契约文档

// All error responses share one shape: { error: { code, message, details? } }

GET /projects/nope/tasks  → 404
{ "error": { "code": "not_found", "message": "Project not found" } }

这是一个常见且设计得不错的 envelope。但它恰好是 v3.x App 读不了的格式:body.error 现在是一个对象,于是用户会看到一个空的红色横幅,以及一个点了没反应的重试按钮。代码里没有任何东西告诉 Agent 这些 App 的存在。

V1,同样的提示词,有契约文档

// Every error response has the shape:
//   { error: string, code: string, requestId: string, details?: [...] }
// `error` must stay a human-readable string: mobile v3.x displays it verbatim
// (see docs/mobile-contract.md). Clients should branch on `code`, not `error`.

同样是一行提示词。Agent 找到了这份文档,保持 error 为字符串,在旁边新增了 code,并回传了请求 ID,而文档对请求 ID 只是顺带一提。

各变体实际返回什么

用同一组请求调用每个构建实测得出。

请求N1N2、N3V1 到 V3S1 到 S3
已归档项目400400400409
缺少 title400400400422
404 响应中的键code, error, requestIderror(对象)code, error, requestIdcode, details, error, requestId
校验错误响应中的键+ detailserror(对象)+ details同样的四个键

“一致”之后仍有两种形状

3 次 V 运行都只在校验错误里加了 details,所以客户端会看到两种形状。这是件小事,但类型化的 SDK 会在这里出错。规格写明了“details is always an array”,3 次 S 运行都照做了。

Agent 是有意保留状态码的

V1 让已归档项目继续返回 400:“409 会更准确,而且契约只要求非 2xx,但我不想在没问过你的情况下改状态码。” 这是好的判断,但这也是工单留给 Agent 自己做的决定。

规格大约多花 20 秒

规格运行耗时 60 到 69 秒,其他运行是 37 到 49 秒,并且写了更多测试。成本在于写规格,而不在于运行。

从 9 次运行中得到什么

规格可以放在仓库里

影响最大的单一因素是一个 13 行的 markdown 文件,而不是提示词。写在 Agent 会去看的地方的约束,就会被遵守。把这类契约说明放在代码旁边,并在 AGENTS.md 里指向它们。

剩下的由提示词决定

文档消除了破坏性变更;只有规格消除了差异:统一的一组键,以及团队想要的 409 和 422。这些是产品决策,仓库里没有任何文件写明。

测试契约,而不是测试代码

像 A2 这样的 HTTP 层检查,本可以在合并前拦下 N2 和 N3。我们的 API 契约指南介绍了如何在 CI 中保留这类检查。

这次测试的局限

  • 每个变体跑 3 次只能展示差异,不能说明比率。“3 次中有 1 次”表示这种情况发生过,不表示它有三分之一的概率发生。
  • 契约文档很短,也很显眼。在更大的仓库里,Agent 可能会漏掉一份在小仓库里能找到的文档。
  • 规格、文档和隐藏测试套件都由我们编写。测试套件在第一次运行前就已冻结,从未展示给 Agent。
  • 只用了一个模型和一个工具。运行记录包包含了用其他模型和工具重复这次实验所需的全部内容。

同一个案例在 Sonnet 5.5、Haiku 4.5 和 Fable 5.1 上

有 spec 时,四个模型(包括 Haiku 4.5)都通过了全部 8 项检查。没有 spec 时,没有任何模型的任何一次运行全部通过:每次要么破坏了移动端契约,要么留下了两种错误格式;Haiku 4.5 的 6 次非 spec 运行全部没通过状态码检查(其中一次对格式错误的 JSON 仍然返回 500)。

查看跨模型结果

相关内容

API 错误规格包

可直接交给评审的版本:错误分类、兼容性说明和 SDK fixture。

打开 API 错误案例

拆分姓名迁移运行记录

9 次 schema 变更运行,其中一次删掉了其他服务仍在读取的列。

打开迁移运行记录

在 Agent 重新设计契约之前,先把它写下来

API 规格生成器会产出错误 envelope、状态码映射和兼容性说明,你可以把它们提交到代码旁边。

编辑说明

本页的每个数字都来自运行记录包中录制的运行。评分前我们没有修改 Agent 输出中的任何内容。