首页
/ agent-skills 中的 API 与接口设计技能:契约优先、Hyrum's Law 与幂等键的正确打开方式

agent-skills 中的 API 与接口设计技能:契约优先、Hyrum's Law 与幂等键的正确打开方式

2026-09-05 16:32:42作者:廉皓灿Ida

agent-skills 仓库是一套面向 AI 编码智能体(AI coding agents)的工程技能包,其中 api-and-interface-design 是 Build(构建)阶段的核心技能之一,专门指导智能体设计"难以被误用"的稳定接口——覆盖 REST API、GraphQL schema、模块边界与前后端契约。本文以 skills/api-and-interface-design/SKILL.md 为主体,完整拆解其六条核心原则、REST 与 TypeScript 接口模式、反驳话术表与红旗清单,并结合仓库中的评测用例与测试夹具(fixtures),说明这套技能是如何被验证"真的能改变智能体行为"的。

技能在项目中的位置与触发机制

README.md 描述的六阶段生命周期(DEFINE → PLAN → BUILD → VERIFY → REVIEW → SHIP)中,api-and-interface-design 属于 Build 阶段,与 incremental-implementationtest-driven-developmentfrontend-ui-engineering 等技能并列。README 明确指出:"设计 API 会触发 api-and-interface-design,构建 UI 会触发 frontend-ui-engineering"——技能不是靠人手动调用,而是由智能体根据当前任务自动激活。

这种自动触发依赖 SKILL.md 的 YAML frontmatter。按 docs/skill-anatomy.md 的规范,frontmatter 的 name 必须与目录名一致(小写连字符),description 必须同时写清"技能做什么(what)"与"何时激活(when)",且最长 1024 字符。本技能的 frontmatter 正是范本:

---
name: api-and-interface-design
description: Guides stable API and interface design. Use when designing APIs, module boundaries, or any public interface. Use when creating REST or GraphQL endpoints, defining type contracts between modules, or establishing boundaries between frontend and backend.
---

description 中罗列的触发词——"designing APIs"、"module boundaries"、"public interface"、"REST or GraphQL endpoints"、"type contracts"、"boundaries between frontend and backend"——就是智能体做路由判断的依据。仓库的评测系统(见 evals/README.md)会专门用"正向/负向提示词"验证这套词表是否覆盖真实用户的说法,这一点在下文"技能如何被验证"一节展开。

适用场景(When to Use)

技能原文列出的五个激活场景,即这套方法论的边界:

  • 设计新的 API 端点
  • 定义模块边界或团队之间的契约
  • 创建组件 props 接口
  • 建立会影响 API 形态的数据库 schema
  • 修改已有的公共接口

注意第 5 条:修改现有公共接口同样是高风险操作,同样需要这套契约思维。原文的 Overview 给出的总纲是一句话:"设计稳定的、文档良好的、难以被误用的接口。好的接口让正确的事容易做,让错误的事难做。"这适用于 REST API、GraphQL schema、模块边界、组件 props,以及任何一处代码与代码对话的表面。

核心原则一:Hyrum's Law(海勒姆定律)

技能首先立起一面理论旗帜:

当 API 的用户数量足够多时,系统的所有可观测行为都会被某个人依赖,无论你在契约中承诺了什么。

由此推出四条设计含义:

  1. 有意识地控制暴露面——每一个可观测行为(包括未记录的怪癖、错误信息文案、时序与顺序)都是一个潜在的承诺;
  2. 不要泄漏实现细节——用户只要观察得到,就会依赖它;
  3. 在设计期就规划弃用——如何安全地移除用户依赖的东西,交给姊妹技能 skills/deprecation-and-migration/SKILL.md(该技能同样以 Hyrum's Law 为前提,强调"弃用规划从设计期开始");
  4. 测试是不够的——即便契约测试完美,Hyrum's Law 意味着"安全"的变更仍可能打破那些依赖未记录行为的真实用户。

这条定律是整个技能后续所有原则的底层动机:既然一切可观测行为都是承诺,那么契约必须先行、错误语义必须一致、变更必须只做加法。

核心原则二:One-Version Rule(单一版本规则)

避免迫使消费者在同一依赖或 API 的多个版本之间做选择。钻石依赖问题(diamond dependency problem)就产生于不同消费者需要同一事物的不同版本。设计目标是一个"同一时刻只存在一个版本"的世界——扩展而非分叉(extend rather than fork)。

核心原则三:契约先行(Contract First)

先定义接口,再实现。契约就是规格(spec),实现跟随契约。原文给出的 TypeScript 示例是一个完整的任务管理 API 契约,每个方法都带注释说明语义约定:

// Define the contract first
interface TaskAPI {
  // Creates a task and returns the created task with server-generated fields
  createTask(input: CreateTaskInput): Promise<Task>;

  // Returns paginated tasks matching filters
  listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;

  // Returns a single task or throws NotFoundError
  getTask(id: string): Promise<Task>;

  // Partial update — only provided fields change
  updateTask(id: string, input: UpdateTaskInput): Promise<Task>;

  // Idempotent delete — succeeds even if already deleted
  deleteTask(id: string): Promise<void>;
}

注意契约里写死的不只是签名,还有行为承诺listTasks 必须分页、getTask 找不到时抛 NotFoundError 而不是返回 null、deleteTask 幂等(已删除时再删也算成功)。这些注释就是规格的一部分——实现阶段若偏离了其中任何一条,契约测试就能抓住。

核心原则四:一致的错误语义(Consistent Error Semantics)

选定一种错误策略,并在全站使用。原文示例是 REST 场景:HTTP 状态码 + 结构化错误体,每个错误响应都遵循同一形状:

// REST: HTTP status codes + structured error body
// Every error response follows the same shape
interface APIError {
  error: {
    code: string;        // Machine-readable: "VALIDATION_ERROR"
    message: string;     // Human-readable: "Email is required"
    details?: unknown;   // Additional context when helpful
  };
}

// Status code mapping
// 400 → Client sent invalid data
// 401 → Not authenticated
// 403 → Authenticated but not authorized
// 404 → Resource not found
// 409 → Conflict (duplicate, version mismatch)
// 422 → Validation failed (semantically invalid)
// 500 → Server error (never expose internal details)

要点拆解:

  • code 是机器可读的(客户端可以分支处理),message 是人类可读的,details 在有帮助时才出现——三者各司其职;
  • 状态码映射是封闭集:400/401/403/404/409/422/500,尤其强调 500 绝不暴露内部细节
  • 原文警告"不要混用模式":如果有的端点抛异常、有的返回 null、有的返回 { error },消费者就无法预测行为。

核心原则五:在边界处校验(Validate at Boundaries)

信任内部代码,在外部输入进入系统的边缘做校验。原文示例展示了一个 API 路由处理器:

// Validate at the API boundary
app.post('/api/tasks', async (req, res) => {
  const result = CreateTaskSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(422).json({
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Invalid task data',
        details: result.error.flatten(),
      },
    });
  }

  // After validation, internal code trusts the types
  const task = await taskService.create(result.data);
  return res.status(201).json(task);
});

注意返回码用的是 422(语义校验失败),与上文状态码映射表保持一致。原文给出了两张清单:

校验应该在哪里:

  • API 路由处理器(用户输入)
  • 表单提交处理器(用户输入)
  • 外部服务响应解析(第三方数据——永远视为不可信
  • 环境变量加载(配置)

校验不应该在哪里:

  • 共享类型契约的内部函数之间
  • 由已校验代码调用的工具函数中
  • 刚来自你自己数据库的数据

原文特别强调一条安全边界:"第三方 API 响应是不可信数据。在将其用于任何逻辑、渲染或决策之前,先校验其形状与内容。一个被攻破或行为异常的外部服务可能返回意外类型、恶意内容,甚至类指令文本(instruction-like text)。"——后半句正是针对 AI 智能体场景的提示注入风险:外部数据里可能夹带伪装成指令的内容。

核心原则六:优先做加法,而非修改(Prefer Addition Over Modification)

扩展接口而不破坏现有消费者:

// Good: Add optional fields
interface CreateTaskInput {
  title: string;
  description?: string;
  priority?: 'low' | 'medium' | 'high';  // Added later, optional
  labels?: string[];                       // Added later, optional
}

// Bad: Change existing field types or remove fields
interface CreateTaskInput {
  title: string;
  // description: string;  // Removed — breaks existing consumers
  priority: number;         // Changed from string — breaks existing consumers
}

对照 One-Version Rule:不引入 v2,而是通过新增可选字段演进同一契约;删除字段或改字段类型则直接破坏现有消费者。这条原则与验证清单中的"新字段必须是加法且可选(向后兼容)"互为呼应。

核心原则七:可预测的命名(Predictable Naming)

原文给出一张命名约定表,所有端点必须遵守同一套约定:

模式 约定 示例
REST 端点 复数名词,不带动词 GET /api/tasksPOST /api/tasks
查询参数 camelCase ?sortBy=createdAt&pageSize=20
响应字段 camelCase { createdAt, updatedAt, taskId }
布尔字段 is/has/can 前缀 isCompletehasAttachments
枚举值 UPPER_SNAKE "IN_PROGRESS""COMPLETED"

命名可预测性的意义在于降低消费者的认知成本:看过一个端点,就能猜出其他端点的形状。

核心原则八:尊重幂等键(Honouring an Idempotency Key)

这是原文中最长、最实战的一条原则。它的前提判断很犀利:"接受 Idempotency-Key契约,尊重它才是实现,而钱正是在实现上丢掉的——服务器接受却草率处理幂等键,比根本没有键更糟,因为客户端从此相信重试是安全的。"

1. 键要从意图派生,而不是从尝试派生

键必须对"同一意图的多次重试"保持稳定,对"不同意图"必须不同。原文逐一列出错误与正确做法:

crypto.randomUUID()                    // ✗ 每次尝试生成新键——每次重试都是一笔新扣款
`${userId}:${amount}`                  // ✗ 两笔合法的 $50 扣款被合并成一笔
`${orderId}:${Date.now()}`             // ✗ 时间戳只是戴了帽子的 randomUUID()

req.headers['idempotency-key']         // ✓ 客户端生成一次,重试时复用
`charge:v1:${orderId}`                 // ✓ 从不可变标识符派生

结论:键来自客户端或发起事件,绝不能来自执行重试的那一层。

2. 原子地占用键——"先检查后执行"是竞态

// ✗ TOCTOU: 两个并发重试都读到"未见过",双双扣款
if (!(await db.exists(key))) {
  await chargeCard(amount);
  await db.insert(key);
}

// ✓ 让唯一约束来裁决胜者
try {
  await db.insert({ key, state: 'in_progress', requestHash });
} catch (e) {
  if (isUniqueViolation(e)) return replayOrReject(key);
  throw;
}
const result = await chargeCard(amount);
await db.update({ key, state: 'succeeded', response: result });

原文的判断标准是:"唯一约束本身就是机制。无法在单个操作中强制唯一性的存储,不能支撑幂等。"

3. 守卫请求体(payload)

同一个键配上不同的请求体是客户端 bug,必须响亮地失败,而不是把第一个请求的响应偷梁换柱给第二个请求:

if (existing.requestHash !== hash(req.body)) {
  return res.status(422).json({ error: 'idempotency key reused with a different payload' });
}

4. 决定"飞行中的重复请求"得到什么响应

第一个请求还在执行时第二个请求到达——这是重试风暴下的常见情形。原文给出三种策略:

策略 响应 适用场景
拒绝 409 Conflict 客户端可以稍后重试;最简单也最安全
等待 阻塞至结果(有界等待) 调用方需要同步拿到结果
返回 pending 202 + 状态 URL 长时运行的副作用

并附了一条硬规则:绝不能因为第一个请求"看起来卡住了"就放行第二个调用者——一个结果未知的停滞尝试,恰恰是重复执行代价最高的时刻

5. 每次调用有三种结果,而不是一种:成功、失败、未知

超时不告诉你副作用是否已经生效。原文要求在调用外部服务之前就记录意图,这样"调用与响应之间崩溃"会留下证据、提示后续必须对账——而不是静默地重试一笔扣款。

6. 保留期从最长的重试链推导

键的保留窗口由"能重新投递同一意图的最长路径"决定,而不是磁盘成本。这包括一周后才被重放的死信队列(DLQ),以及支付商的争议窗口(dispute window)。原文的原话是:"在 7 天 DLQ 背后放一个 24 小时 TTL 的幂等键,就是一枚等待发生的重复扣款。"

REST API 设计模式

资源设计

GET    /api/tasks              → List tasks (with query params for filtering)
POST   /api/tasks              → Create a task
GET    /api/tasks/:id          → Get a single task
PATCH  /api/tasks/:id          → Update a task (partial)
DELETE /api/tasks/:id          → Delete a task

GET    /api/tasks/:id/comments → List comments for a task (sub-resource)
POST   /api/tasks/:id/comments → Add a comment to a task

动词不出现在 URL 中,子资源通过嵌套路径表达(/api/tasks/:id/comments)。

分页

列表端点必须分页:

// Request
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc

// Response
{
  "data": [...],
  "pagination": {
    "page": 1,
    "pageSize": 20,
    "totalItems": 142,
    "totalPages": 8
  }
}

过滤

过滤用查询参数表达:

GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01

部分更新(PATCH)

接受部分对象——只更新提供的字段:

// Only title changes, everything else preserved
PATCH /api/tasks/123
{ "title": "Updated title" }

TypeScript 接口模式

用可辨识联合(Discriminated Unions)表达变体

// Good: Each variant is explicit
type TaskStatus =
  | { type: 'pending' }
  | { type: 'in_progress'; assignee: string; startedAt: Date }
  | { type: 'completed'; completedAt: Date; completedBy: string }
  | { type: 'cancelled'; reason: string; cancelledAt: Date };

// Consumer gets type narrowing
function getStatusLabel(status: TaskStatus): string {
  switch (status.type) {
    case 'pending': return 'Pending';
    case 'in_progress': return `In progress (${status.assignee})`;
    case 'completed': return `Done on ${status.completedAt}`;
    case 'cancelled': return `Cancelled: ${status.reason}`;
  }
}

type 判别字段让每个状态变体携带自己必须有的字段(in_progress 必须有 assigneecompleted 必须有 completedAt),消费者通过 switch (status.type) 获得类型收窄——这比"一个字符串枚举 + 一堆可选字段"的写法更能从类型层面拒绝非法状态。

输入/输出分离(Input/Output Separation)

// Input: what the caller provides
interface CreateTaskInput {
  title: string;
  description?: string;
}

// Output: what the system returns (includes server-generated fields)
interface Task {
  id: string;
  title: string;
  description: string | null;
  createdAt: Date;
  updatedAt: Date;
  createdBy: string;
}

输入契约描述调用方能给什么(description 可缺省),输出契约描述系统返回什么(包含服务器生成的 idcreatedAt 等字段,且 description 显式建模为 string | null)。两者分开定义,避免"入参即出参"造成的语义混淆。

用品牌类型(Branded Types)标识 ID

type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };

// Prevents accidentally passing a UserId where a TaskId is expected
function getTask(id: TaskId): Promise<Task> { ... }

string 让"把用户 ID 传进任务查询"这类事故畅通无阻;品牌类型让它在编译期就被拦截。

常见借口与反驳(Common Rationalizations)

技能的特色之一是"反合理化表"——把智能体(以及工程师)常用的跳过步骤的借口逐条钉死。原文完整列表如下:

借口 现实
"API 文档以后再补" 类型就是文档。先定义它们。
"现在不需要分页" 一旦有人有了 100+ 条数据就会需要。从第一天就加上。
"PATCH 太复杂,就用 PUT 吧" PUT 每次都要完整对象。PATCH 才是客户端真正想要的。
"需要时再给 API 加版本号" 没有版本控制的破坏性变更会打碎消费者。从一开始就为扩展而设计。
"没人用那个未文档化的行为" Hyrum's Law:只要可观测,就有人依赖它。把每个公共行为都当作承诺。
"我们可以维护两个版本" 多版本倍增维护成本,并制造钻石依赖问题。优先遵循单一版本规则。
"内部 API 不需要契约" 内部消费者仍然是消费者。契约防止耦合,并让并行开发成为可能。
"接受 Idempotency-Key 头就够了" 头是契约;把键与结果一起存储才是实现。接受却不兑现的键,会在重试并不安全时告诉客户端重试是安全的。
"我们的队列保证精确一次投递" 在消费者崩溃这一场景下,没有任何队列能做到——broker 的 ack 和你的副作用不在同一个事务里。按至少一次投递来设计,配合幂等处理。
"重复请求很少见" 它们是相关的——重试正是在依赖降级时激增的,那恰恰是重复最可能发生、也最昂贵的时刻。

这张表把前文所有原则都接了地:契约先行、分页、PATCH、扩展式设计、Hyrum's Law、One-Version Rule、内部契约、幂等键——每条原则都预设了它的反面借口。

红旗清单(Red Flags)

原文列出的 11 条红旗,是代码评审与自查时的检查项:

  • 端点在条件分支下返回不同形状
  • 各端点错误格式不一致
  • 校验散落在内部代码中,而不是集中在边界处
  • 对现有字段的破坏性变更(类型变更、字段删除)
  • 没有分页的列表端点
  • REST URL 里出现动词(/api/createTask/api/getUsers
  • 第三方 API 响应未经校验或净化就直接使用
  • 幂等键的 SELECT 后接 INSERT——那是竞态,不是守卫
  • 幂等键派生自 UUID、时间戳或任何每次尝试重新生成的东西
  • 同一个键带着不同的请求体被接受,却静默返回第一次的响应
  • 键的保留窗口短于能够重新投递该请求的最长路径

设计完成后的验证清单(Verification)

原文以 12 项 checklist 作为技能的出口条件(exit criteria)。注意其中一半以上都指向幂等性,与"红旗清单"逐条对应:

  • [ ] 每个端点都有带类型的输入与输出 schema
  • [ ] 错误响应遵循单一一致格式
  • [ ] 校验只发生在系统边界
  • [ ] 列表端点支持分页
  • [ ] 新字段是加法且可选(向后兼容)
  • [ ] 命名在所有端点间遵循一致约定
  • [ ] API 文档或类型与实现一起提交
  • [ ] 改变状态的端点要么尊重幂等键,要么被明确文档化为"不可安全重试"
  • [ ] 键在单个原子操作中被占用,由唯一约束守卫
  • [ ] 同一键配不同载荷时会响亮失败,而不是重放错误的响应
  • [ ] 飞行中重复请求的响应是刻意选择(409、等待或 202),而不是"恰好掉出来的东西"
  • [ ] 键的保留期长于最长重试路径,包括死信重放

技能如何被验证:评测用例与夹具

agent-skills 不只是"写下来",每个技能都配一个评测文件。evals/cases/api-and-interface-design.json 展示了这套技能的两层验证方式:

触发层(trigger):正向提示词必须能把本技能排进 top-k(top_k 为 3),例如:

  • "Design a REST endpoint for creating invoices, including error responses and versioning"
  • "What should the public interface of this payments module expose to other teams?"
  • "Help me define the contract between the frontend and the orders service"

负向提示词则必须路由到本技能,并声明正确归属者(owner):单测空指针错误归 debugging-and-error-recovery,落地页响应式布局归 frontend-ui-engineering。按 evals/README.md 的说明,声明 owner 后运行器会断言 owner 技能的排名高于本技能,使负向测试成为真实的成对路由测试,而非"什么也不匹配就空手通过"。

行为层(behavioral eval):该技能配置了一个执行型评测——"为 URL 短链服务设计公共 API:create、resolve、stats,产出端点契约。"评分依据的 expectations 有 4 条:

  1. 错误响应必须用状态码 + 一致的错误形状来规定,而不是只写快乐路径;
  2. 针对用户提供的 URL 处理边界输入校验;
  3. 说明版本化或兼容性策略;
  4. 响应不得偷偷发明未陈述的需求。

评测所依据的真实任务输入存放在夹具 evals/fixtures/api-and-interface-design/service-brief.md 中,是一份 URL 短链服务简报。对照技能原则可以发现,这份简报的每条约束几乎都映射到前文某个原则:

  • "客户端包含浏览器扩展与移动 App,契约必须保持向后兼容" → 优先做加法原则 + 验证清单第 5 条;
  • "目标 URL 由不可信用户提供" → 边界校验原则(第三方数据不可信);
  • "slug 不存在与 slug 已过期必须能被运维区分,但公共 API 不得暴露内部存储细节" → 一致的错误语义(机器可读 code 区分两种 404,而 message 不泄漏实现);
  • "自定义 slug 是否开放、链接是否默认过期、统计是否需要鉴权——仍未决定" → 对应评测期望第 4 条"不偷偷发明未陈述的需求",考验智能体能否在契约中显式标注开放问题而不是擅自拍板。

evals/README.md 描述的三层评测体系看,上述触发层属于 Tier 2(确定性、CI 中运行的词汇路由检查),行为层属于 Tier 3(真实跑一次无头智能体执行、按 expectations[] 逐条评分)。也就是说,"技能文本写得对"与"智能体按它执行得对"被分别验证——前者由 scripts/run-evals.js 在 CI 中保证描述词表不漂移,后者由行为评测保证智能体面对 service-brief 时真的产出带错误语义、带边界校验、带兼容性策略的端点契约。

小结:这套技能沉淀的决策框架

skills/api-and-interface-design/SKILL.md 的全部脉络串起来,它是一个环环相扣的决策框架:

  1. 以 Hyrum's Law 为世界观——一切可观测行为都是承诺,因此暴露面要最小化、实现细节不外泄、弃用从设计期规划(交接给 skills/deprecation-and-migration/SKILL.md);
  2. 以契约为规格——类型先于实现,错误语义全站统一,输入输出分离,ID 用品牌类型;
  3. 以边界为信任线——外部输入(用户、第三方服务、环境变量)在边界处校验,内部信任类型;
  4. 以加法为演进方式——新字段可选、单版本、扩展不分叉,直接服务于"契约保持向后兼容"的长期目标;
  5. 以幂等为安全网——键从意图派生、唯一约束原子占用、载荷守卫、飞行中重复请求的响应策略、保留期覆盖最长重试链,六个子规则堵住"接受键却不兑现键"的全部漏洞;
  6. 以借口表、红旗清单与 12 项验证 checklist 为质量门——前两者负责在评审时拦截偏差,checklist 负责在交付前逐项核销。

如果你正在给团队(或团队的 AI 智能体)立接口设计规范,可以直接从这份 SKILL.md 起步:它不是泛泛的最佳实践罗列,而是一份带代码示例、带反例、带反驳话术、带可勾选验证条件的可执行工作流,并且仓库内置的评测体系为它"是否真的能约束智能体行为"提供了可复现的证据路径。

登录后查看全文
热门项目推荐
相关项目推荐