接口不是 URL,是系统契约
古董级程序员,大厂出来后一直在创业公司,现在仍在一线写代码、做产品、和 AI 编程工具较劲。更完整的更新会放在微信公众号「字与码」:工作经历、新技术观察、AI 开发实践,以及这些年踩过的坑,都会陆续写在那里。
《AI时代的程序员修养》前四篇讲了程序、数据结构、执行模型和模块通信。从这一篇开始,进入“把功能做成能扛流量的服务”这一卷。第一件事是接口。
AI 写接口很快。给它一句“实现用户查询接口”,它能马上生成路由、参数、ORM 查询和 JSON 返回。问题是,接口最重要的部分不是 URL,也不是 handler 里那几行代码,而是系统对调用方做出的承诺:字段长什么样,错误怎么算,能不能重试,权限怎么判,版本怎么演进,旧客户端会不会被新字段打坏。
接口不是 URL,是契约。契约没写清楚,代码越快生成,后面越难收拾。
URL 只是入口
很多接口设计讨论会停在这一层:
GET /api/users/{id}
POST /api/orders
GET /api/orders?page=1&page_size=20
这些当然要设计,但它们只是入口。真正的契约至少包括:
- 请求参数和字段类型。
- 字段是否必填,默认值是什么。
- 返回结构是否稳定。
- 错误码和错误语义。
- 权限要求。
- 幂等规则。
- 分页和排序规则。
- 限流和超时边界。
- 版本兼容策略。
- 示例参数和示例响应。
- 可观测字段,如
request_id、trace_id。
AI 生成接口时,最常见的问题是“看起来能调”。能调不代表能用,更不代表能长期维护。
比如一个查询工具调用历史的接口,如果只写:
GET /tool-history?user_id=xxx
它没有说明很多关键问题:
- 只能查自己的历史,还是管理员可以查所有人?
- 时间范围有没有上限?
- 默认排序是什么?
- 返回字段里是否包含敏感参数?
- 分页是 page/page_size,还是 cursor?
tool_id搜索是精确匹配还是模糊匹配?- 没有结果时返回空列表,还是 404?
- 查询太慢时返回什么错误?
这些不明确,前端、后端、测试和数据分析都会各自猜一套。
先写 schema,再写 handler
接口契约里最基础的是 schema。schema 不是“文档装饰”,它应该约束代码。
以创建订单为例,一个随手写的请求可能是:
{
"user_id": "u_123",
"items": [
{"sku": "book_001", "count": 2}
],
"coupon": "NEWUSER"
}
但契约必须更明确:
{
"user_id": "string, required",
"items": "array, required, 1..100",
"items[].sku": "string, required, max 64",
"items[].quantity": "integer, required, 1..999",
"coupon_code": "string, optional, max 64",
"client_order_id": "string, required, max 128"
}
这里有几个小变化很关键。
count 改成 quantity,语义更清楚。items 有长度上限,避免一次请求塞进几万行。quantity 有范围,避免 0、负数或离谱数字。client_order_id 用于幂等,避免客户端重试创建重复订单。
如果用 Python,可以把 schema 直接写成模型:
from pydantic import BaseModel, Field
class OrderItemIn(BaseModel):
sku: str = Field(min_length=1, max_length=64)
quantity: int = Field(ge=1, le=999)
class CreateOrderRequest(BaseModel):
user_id: str = Field(min_length=1, max_length=64)
items: list[OrderItemIn] = Field(min_length=1, max_length=100)
coupon_code: str | None = Field(default=None, max_length=64)
client_order_id: str = Field(min_length=1, max_length=128)
这段代码不复杂,AI 也能写。重点是你要在 prompt 里要求它先定义契约:
先不要写 handler。
请先定义请求和响应 schema,说明每个字段的类型、必填、默认值、长度范围、枚举范围和业务含义。
schema 通过后再写接口实现。
没有 schema 的接口,就像没有类型的系统边界。小项目可以靠记忆,大系统只能靠约束。
响应结构要稳定
接口返回值最怕“今天想起来加一个字段,明天顺手改一个结构”。
一个比较稳的列表响应通常长这样:
{
"items": [
{
"id": "order_123",
"status": "paid",
"total_amount": 12900,
"created_at": "2026-07-03T10:00:00+08:00"
}
],
"page_info": {
"next_cursor": "eyJpZCI6...",
"has_more": true
},
"request_id": "req_abc"
}
几个细节值得保留:
- 列表永远放在
items,不要有时叫data,有时叫list。 - 分页信息单独放
page_info,不要混在每一项里。 - 金额用整数最小货币单位,避免浮点误差。
- 时间用明确时区的 ISO 8601 字符串。
request_id返回给调用方,方便排障。
AI 经常会根据 handler 内部对象直接返回 ORM:
return order
这很危险。ORM 里可能有内部字段、敏感字段、调试字段,也可能因为模型变化导致接口悄悄变化。响应模型应该单独定义:
class OrderOut(BaseModel):
id: str
status: str
total_amount: int
created_at: datetime
class ListOrdersResponse(BaseModel):
items: list[OrderOut]
page_info: PageInfo
request_id: str
让 AI 写接口时,可以直接要求:
禁止直接返回 ORM 对象。
必须定义独立 response schema。
内部字段、密钥、成本字段、调试字段不能出现在响应里。
这类约束很具体,AI 才容易遵守。
错误码比成功返回更重要
成功路径通常很简单。接口真正难的是失败。
很多 AI 生成的接口会这样处理错误:
raise HTTPException(status_code=400, detail="invalid request")
或者更糟:
return {"success": False, "message": "error"}
调用方拿到这种错误,很难判断下一步该做什么。应该让错误有稳定结构:
{
"error": {
"code": "ORDER_ITEM_OUT_OF_STOCK",
"message": "部分商品库存不足",
"retryable": false,
"details": {
"sku": "book_001"
}
},
"request_id": "req_abc"
}
错误至少要回答:
- 机器可识别的错误码是什么?
- 人能读的提示是什么?
- 能不能重试?
- 是参数错误、权限错误、资源不存在、状态冲突,还是系统错误?
- 有没有可排查的
request_id? - details 里有没有必要上下文?
HTTP 状态码和业务错误码要配合使用:
| HTTP 状态 | 适合场景 | 示例业务码 |
|---|---|---|
| 400 | 请求格式错误、字段不合法 | INVALID_ARGUMENT |
| 401 | 未登录或 token 无效 | UNAUTHENTICATED |
| 403 | 已登录但无权限 | PERMISSION_DENIED |
| 404 | 资源不存在或不可见 | RESOURCE_NOT_FOUND |
| 409 | 状态冲突、重复操作 | ORDER_ALREADY_PAID |
| 422 | 语义校验失败 | ORDER_ITEM_OUT_OF_STOCK |
| 429 | 超过频率或额度 | RATE_LIMITED |
| 500 | 服务内部错误 | INTERNAL_ERROR |
| 503 | 下游不可用、临时过载 | SERVICE_UNAVAILABLE |
有了错误码,客户端才能写稳定逻辑。比如 RATE_LIMITED 可以提示稍后再试,ORDER_ALREADY_PAID 可以刷新订单状态,ORDER_ITEM_OUT_OF_STOCK 可以让用户修改购物车。
让 AI 写错误处理时,可以要求:
请定义错误响应统一结构。
每个错误码必须说明:
- HTTP status
- code
- 是否 retryable
- 调用方应该怎么处理
- 是否需要记录安全审计日志
错误契约比成功契约更能看出接口是否成熟。
幂等不是支付接口才需要
幂等的意思是,同一个操作重复执行多次,最终效果和执行一次一样。
很多人只在支付接口想到幂等。实际上,只要存在客户端重试、网关重试、网络超时、队列重复投递,就要考虑幂等。
创建订单、提交表单、触发任务、发送验证码、执行工具调用、导入文件,都可能需要幂等。
一个典型创建接口可以要求客户端传 Idempotency-Key:
POST /api/orders
Idempotency-Key: create-order:user_123:client_order_789
服务端记录这个 key 的处理结果:
create table idempotency_keys (
key text primary key,
request_hash text not null,
status text not null,
response_body jsonb,
created_at timestamptz not null,
expires_at timestamptz not null
);
关键点有两个。
同一个 key、同一个 request hash,可以返回第一次处理结果。同一个 key、不同 request hash,应该拒绝,因为这代表客户端复用了幂等键但请求内容变了。
伪代码可以这样写:
def create_order(request: CreateOrderRequest, idempotency_key: str):
request_hash = hash_request(request)
existing = idempotency_store.get(idempotency_key)
if existing:
if existing.request_hash != request_hash:
raise Conflict("IDEMPOTENCY_KEY_REUSED")
return existing.response_body
with transaction():
idempotency_store.reserve(idempotency_key, request_hash)
order = order_service.create(request)
response = to_response(order)
idempotency_store.mark_done(idempotency_key, response)
return response
这段逻辑还需要处理并发抢同一个 key、处理中状态超时、结果保留时间等细节,但方向是对的。
让 AI 实现创建类接口时,可以直接写:
该接口必须支持幂等。
客户端通过 Idempotency-Key 传入幂等键。
同一 key + 同一请求体返回首次结果。
同一 key + 不同请求体返回 409。
请写并发测试:两个相同 key 的请求同时到达,只能创建一条业务记录。
如果没有这几句,AI 很可能只写一个普通 insert。
分页不是 page_size 那么简单
列表接口非常常见,也非常容易被写坏。
最简单的分页是:
GET /orders?page=10&page_size=20
它适合数据量不大、排序稳定性要求不高的后台页面。但在高频接口或大表上,offset 分页会越来越慢,还容易漏数据或重复数据。
比如按创建时间倒序翻页。用户看第一页时有新订单插入,再看第二页,原本第一页末尾的数据可能被挤到第二页,出现重复。或者某些数据被跳过。
更稳定的是 cursor 分页:
GET /orders?limit=20&cursor=eyJjcmVhdGVkX2F0Ijoi...IiwiaWQiOiI...In0=
cursor 里通常包含排序字段和唯一 ID:
{
"created_at": "2026-07-03T10:00:00+08:00",
"id": "order_123"
}
查询条件类似:
select *
from orders
where user_id = :user_id
and (
created_at < :cursor_created_at
or (created_at = :cursor_created_at and id < :cursor_id)
)
order by created_at desc, id desc
limit :limit;
这里 created_at, id 共同保证排序稳定。对应索引也要跟上:
create index idx_orders_user_created_id
on orders (user_id, created_at desc, id desc);
让 AI 写列表接口时,prompt 里要明确:
列表接口使用 cursor 分页,不使用 offset。
排序字段为 created_at desc, id desc,必须稳定。
limit 默认 20,最大 100。
响应返回 next_cursor 和 has_more。
请给出推荐索引。
分页是接口契约,也是数据库访问契约。只写 page_size 不够。
权限要和数据过滤绑定
权限错误很常见,尤其是 AI 生成 CRUD 接口时。
一个危险写法:
def get_order(order_id: str, current_user: User):
order = order_repository.get(order_id)
if not order:
raise NotFound()
return order
这段代码没有判断订单是否属于当前用户。更好的做法是查询时就绑定权限范围:
def get_order(order_id: str, current_user: User):
order = order_repository.get_visible_order(
order_id=order_id,
user_id=current_user.id,
)
if not order:
raise NotFound()
return order
为什么无权限也返回 404,而不是 403?这取决于业务。很多用户资源接口会用 404 避免暴露资源是否存在。后台管理接口则可能返回 403,并记录审计日志。
权限契约要写清楚:
- 谁能调用这个接口?
- 用户只能看自己的数据,还是可以按组织、角色、项目范围查看?
- 管理员是否能跨用户查询?
- 无权限返回 403 还是 404?
- 是否记录审计日志?
- 响应字段是否按权限脱敏?
字段级权限也很重要。同一个接口,普通用户和管理员看到的字段可能不同:
{
"id": "tool_call_123",
"tool_id": "fin_cap_001",
"status": "success",
"created_at": "2026-07-03T10:00:00+08:00"
}
管理员可能还需要看到成本、供应商、错误栈;普通用户不应该看到这些内部信息。
让 AI 写接口时,可以要求:
权限必须在查询条件中体现,不能先查全量再在内存过滤。
普通用户只能访问自己的资源;管理员可以按 user_id 查询。
无权限访问普通用户资源时返回 404。
管理员查询行为需要记录审计日志。
响应字段按角色过滤,普通用户不能看到内部成本和供应商错误详情。
这几条会直接影响 repository、service 和 response schema。
版本兼容要从第一天开始
接口一旦有人用,就会变成公共承诺。哪怕只是内部接口,也要考虑版本兼容。
最常见的兼容问题:
- 删除字段。
- 改字段类型。
- 改枚举含义。
- 把原本可选字段改成必填。
- 改错误码。
- 改分页顺序。
- 改金额单位。
- 把空列表改成 null。
这些改动都可能让调用方出问题。
兼容演进通常遵循几条规则:
- 新增字段一般安全,调用方应忽略未知字段。
- 新增枚举不一定安全,旧客户端可能不认识。
- 字段废弃要先标记 deprecated,再观察调用方。
- 老字段保留一段迁移期。
- 语义变化最好使用新字段,而不是复用旧字段。
- 大版本不兼容时,用版本路径或 header 明确区分。
比如接口原来返回:
{
"status": "running"
}
后来想增加 timeout。如果旧客户端只认 pending/running/success/failed,新增枚举可能导致显示异常。可以同时返回更稳定的展示状态:
{
"status": "timeout",
"display_status": "failed",
"can_retry": true
}
或者在契约里要求客户端对未知状态按 unknown 处理。
让 AI 修改接口时,不要只说“加个字段”。可以写:
这是已上线接口,必须保持向后兼容。
不能删除字段,不能修改已有字段类型。
新增枚举值前要说明旧客户端处理策略。
新增字段必须可选。
请列出兼容性风险和迁移步骤。
AI 写新功能很快,破坏兼容也很快。版本规则必须提前写进指令。
示例参数不是装饰
很多接口文档有字段表,却没有好样例。没有样例,AI 写调用方、测试用例和遥测脚本时就容易乱猜。
一个好的示例应该覆盖真实场景,不只是最短 happy path。
以搜索接口为例,弱样例可能是:
{
"query": "test"
}
这几乎没有价值。更好的样例应该贴近真实用户:
{
"query": "查询贵州茅台最近一年的分红和股息率",
"language": "zh",
"filters": {
"provider_type": "finance",
"market": "cn"
},
"limit": 10
}
它能帮助调用方理解接口用途,也能帮助测试覆盖真实路径。
示例还应该包括错误样例:
{
"query": "",
"limit": 1000
}
预期错误:
{
"error": {
"code": "INVALID_ARGUMENT",
"message": "query 不能为空,limit 不能超过 100",
"retryable": false
},
"request_id": "req_abc"
}
让 AI 生成接口文档时,可以要求:
每个接口至少给出:
- 一个真实成功样例。
- 一个边界成功样例。
- 一个参数错误样例。
- 一个权限错误样例。
- 一个可重试系统错误样例。
示例必须使用真实业务语义,不能用 foo/bar/test。
示例越真实,接口越不容易长歪。
契约测试把承诺钉住
接口契约最终要靠测试固定下来。
普通单测检查实现细节,契约测试检查对外承诺。它不关心内部怎么查库,只关心输入输出是否符合协议。
一个契约测试可以这样写:
def test_create_order_validation(client):
response = client.post("/api/orders", json={
"user_id": "u_123",
"items": [],
"client_order_id": "c_001",
})
assert response.status_code == 400
body = response.json()
assert body["error"]["code"] == "INVALID_ARGUMENT"
assert body["error"]["retryable"] is False
assert "request_id" in body
幂等测试更重要:
def test_create_order_idempotency(client):
payload = {
"user_id": "u_123",
"items": [{"sku": "book_001", "quantity": 1}],
"client_order_id": "c_001",
}
headers = {"Idempotency-Key": "create-order:u_123:c_001"}
first = client.post("/api/orders", json=payload, headers=headers)
second = client.post("/api/orders", json=payload, headers=headers)
assert first.status_code == 200
assert second.status_code == 200
assert first.json()["id"] == second.json()["id"]
分页测试也不能少:
def test_list_orders_cursor_is_stable(client, seed_orders):
first = client.get("/api/orders?limit=20")
next_cursor = first.json()["page_info"]["next_cursor"]
create_new_order()
second = client.get(f"/api/orders?limit=20&cursor={next_cursor}")
first_ids = {item["id"] for item in first.json()["items"]}
second_ids = {item["id"] for item in second.json()["items"]}
assert first_ids.isdisjoint(second_ids)
这些测试不是为了追求覆盖率好看,而是为了防止后来 AI 或人类重构时破坏契约。
一份给 AI 的接口契约提示词
以后让 AI 写接口,可以先用这段 prompt:
先不要写接口实现。
请为这个 API 设计契约:
1. URL、HTTP method、认证方式和权限范围。
2. 请求 schema:字段类型、必填、默认值、长度范围、枚举、业务含义。
3. 响应 schema:成功结构、字段脱敏、金额和时间格式。
4. 错误结构:HTTP status、业务 code、retryable、调用方处理建议。
5. 幂等规则:哪些操作需要 Idempotency-Key,重复请求如何处理。
6. 分页和排序:是否使用 cursor,排序字段是否稳定,limit 上限。
7. 版本兼容:新增字段、废弃字段、新增枚举、旧客户端策略。
8. 可观测性:request_id、trace_id、审计日志、关键指标。
9. 示例:真实成功样例、边界样例、参数错误、权限错误、可重试错误。
10. 契约测试:至少 5 个测试用例。
契约确认后,再写 handler、service、repository 和测试代码。
这段 prompt 看起来比“帮我写接口”麻烦,但它能省掉后面大量返工。
写接口前,先问这些问题
接口上线以后,调用方会把它当承诺。写代码前,至少问一遍:
- 这个字段以后能不能改名?
- 这个枚举以后能不能扩展?
- 这个错误调用方能不能自动处理?
- 这个创建接口能不能安全重试?
- 这个列表翻页会不会漏数据或重复数据?
- 这个资源是否可能被越权访问?
- 这个响应有没有泄漏内部字段?
- 老客户端看到新字段、新枚举、新错误码会怎样?
- 出问题时,能不能用
request_id查到完整路径?
AI 能快速写出能跑的接口。程序员要做的是把“能跑”推进到“能被别人长期依赖”。
这就是接口契约的价值。它不是文档洁癖,而是系统之间最基本的信任。
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。