HTTP API 边界设计
B7 把 agent runner 包进了一个真实网络服务(stateless MCP server + OAuth 2.1 + fail-closed eval gate),证明「这套东西能被远程、带授权地调用」。B8 这一周要补的是「这套东西如何被工业级地调用」:
阶段: B8 · 真 API + 流式 + Docker(Day 71-80) 标签: #rest-vs-rpc #typed-errors #openapi #idempotency
今日导引(由浅入深)
B7 把 agent runner 包进了一个真实网络服务(stateless MCP server + OAuth 2.1 + fail-closed eval gate),证明「这套东西能被远程、带授权地调用」。B8 这一周要补的是「这套东西如何被工业级地调用」:
- 从协议边界(今天 Day 71)
- 到流式传输(Day 72 SSE)
- 到真模型流式(Day 73 DeepSeek + TTFT)
- 到错误重试契约(Day 74 typed error + 退避)
- 到把 eval 当集成测试夹具(Day 75)
今天处在 B1→B18 能力曲线由「能跑」转向「能被别人当 API 依赖」的拐点:先把边界契约写清楚,后面才有东西可流、可重试、可测。
最小可判定产出:一份 openapi.yaml 草案,含 POST /chat 与 4 类稳定 typed error code(invalid_request/rate_limited/upstream_error/timeout)。
1. 机理精读
1.1 REST 面向资源,RPC 面向动作,agent runner 属于后者
REST 的世界观是「对资源做 CRUD」:GET /orders/123、DELETE /orders/123,URL 是名词,HTTP 动词是操作。但 agent runner 的本质是「执行一次对话推理」——这是一个动作而非一个可寻址的资源。
硬套 REST 会逼你发明一个 messages 资源、再 POST 进去,语义别扭:到底是创建了一条消息,还是触发了一次推理?更诚实的建模是 RPC 风格的 POST /chat:一个端点、一个动作、一次请求一次推理。这跟 OpenAI / Anthropic 的 Messages API 走的是同一条路——它们都不是 REST,而是 action-over-HTTP。判断标准很简单:当「这次调用」本身有副作用、有成本、不可天然寻址时,它是 RPC 动作而非 REST 资源。
1.2 typed 错误契约是 API 可被依赖的前提
裸 500 Internal Server Error 对调用方是黑盒:它不知道该重试还是该改请求。工业做法是给每一类失败一个稳定的 error code(字符串枚举),调用方按 code 分支,而不是去 parse 人类可读的 message。本日定义 4 类,每类一个 HTTP status + 一个稳定 code:
| code | HTTP status | 含义 | 重试? |
|---|---|---|---|
invalid_request | 400 | 客户端请求错(参数/格式) | 否 |
rate_limited | 429 | 被限流 | 退避后重试 |
upstream_error | 502 | 上游模型挂了 | 可重试 |
timeout | 504 | 整体超时 | 可重试 |
这 4 类不是随手挑的,而是直接映射到 Day 74 的重试决策树:4xx 不重试、429/5xx/超时可重试。契约层先把这个二分钉死,执行层才有据可依。
1.3 幂等键防止网络重试重复扣费
agent 调用是有成本的(每次 completion 烧 token、烧钱)。网络层重试在分布式系统里是常态:客户端发了请求、没收到响应、不知道服务端到底执行了没,于是重发。如果没有幂等保护,一次「超时但其实成功」的请求会被算两次费。
Idempotency-Key 头是标准解法(Stripe 的经典实践):服务端用这个 key 去重,同 key 的重复请求返回首次结果而非再跑一遍。这是「动作型 API」相对「资源型 API」额外要操心的事——GET 天然幂等,POST 一个动作不是。对烧钱的 agent 调用,幂等键不是锦上添花而是省钱护栏。
1.4 Anthropic Messages API 流式约定是 typed-event 边界的范本(2026-01)
Anthropic Messages API streaming(2026-01)的流不是裸 token,而是用 event 类型把流切成有语义的帧:
message_start:流开始content_block_delta:内容增量(一小段 token)message_stop:流结束
这给了我们一个边界设计的样板——不光请求 / 响应是 typed 的,连流里的每一帧也是 typed 的。Day 72 的 SSE 哨兵帧、Day 73 的 delta 帧节奏都会回头对照这个范本。它与 REST/RPC 的边界在于:Messages API 的「资源」其实是不存在的,它就是一个 typed-action + typed-event-stream,正是 agent runner 该长的样子。
2. 推导 / 手算 / 代码走读
openapi.yaml 不是凭空写的,error code 命名要对齐既有的 src/agent/runtime/resilience.ts(B9 已构建的韧性原语),否则契约层与实现层会两套词汇。走读 resilience.ts 的真实符号,确认命名锚点:
backoffDelay(attempt, opts):对 0-based 的 attempt 算min(base*factor^attempt, max),再叠加±jitter分数。这就是rate_limited(429) 该触发的退避公式来源——OpenAPI 里 429 的语义注释应指向「客户端按 backoff 重试」。retry(fn, opts):带shouldRetry(err)谓词,默认() => true(重试全部)。契约设计上,shouldRetry应只对rate_limited/upstream_error/timeout返回 true,对invalid_request返回 false——这正是 4 类 error code 的二分依据。CircuitBreaker:三态closed/open/half-open,连续失败threshold(默认 5)次后 open。当上游持续upstream_error时熔断器会 open,此时 API 应直接快速返回 502 而非继续打上游。withFallback(primary, fallback):primary 抛错时切 fallback(注释里是 DeepSeek-V3 → Qwen3),返回{ value, usedFallback }。这给 502 一个产品级出路:上游挂了可降级到备用模型。
一个最小 openapi.yaml 片段(OpenAPI 3.1,对齐 JSON Schema 的 nullable 写法):
openapi: 3.1.0
paths:
/chat:
post:
operationId: chat
parameters:
- in: header
name: Idempotency-Key
schema: { type: string }
requestBody:
content:
application/json:
schema:
type: object
required: [messages]
properties:
messages: { type: array, items: { $ref: '#/components/schemas/Message' } }
model: { type: string, default: deepseek-v4-flash }
responses:
'200': { description: ok }
'400': { $ref: '#/components/responses/InvalidRequest' }
'429': { $ref: '#/components/responses/RateLimited' }
'502': { $ref: '#/components/responses/UpstreamError' }
'504': { $ref: '#/components/responses/Timeout' }
每个 error response 的 schema 都含一个稳定 error.code 枚举(invalid_request/rate_limited/upstream_error/timeout)+ 可读 message。
结论:4 类 typed error code 全部能在 resilience.ts 里找到对应的处理路径,契约与实现自洽。注意 resilience.ts 是纯函数 + 可注入 clock/sleep/rng,无需 key 即可单测——这保证了契约测试可离线跑。
3. 今日实战
- 在仓库根新增
openapi.yaml(OpenAPI 3.1,对齐 JSON Schema)。 - 定义
POST /chat:request body 含messages(数组)、model(默认deepseek-v4-flash);response 为 assistant 消息 + usage。 - 定义 4 类 typed error schema:
invalid_request(400)、rate_limited(429)、upstream_error(502)、timeout(504),每类一个稳定error.code枚举值 + 可读message。 - 在每个 error 的 description 里标注重试语义(4xx 不重试 / 429+5xx+504 可重试),对齐
src/agent/runtime/resilience.ts的shouldRetry设计。 - 可选:在 request header 加
Idempotency-Key,注明用于去重防重复扣费。
4. 今日实测 / 产出
- 产出:
openapi.yaml草案(含 4 类 typed error schema)。 - 诚实标注:独立的
POST /chatSSE app route 并未单独构建——真实 MCP server(src/agent/mcp/server.ts)已覆盖「真实网络服务」部分,本日openapi.yaml为契约设计产出,非已部署端点。 src/agent/runtime/resilience.ts:已构建+已测(backoffDelay/retry/CircuitBreaker/withFallbacktested),本日仅引用其语义做 error code 命名,未改动。
5. 常见误区 / 陷阱
- 把 agent runner 硬塞进 REST:发明
messages资源会让语义别扭,动作型 API 用 RPC 风格POST /chat更诚实。 - 错误契约面向 message 而非 code:调用方若去 parse
error.message字符串,你改文案就破坏了契约。永远断言 / 分支在稳定的error.code上。 - OpenAPI 3.0 的 nullable 旧写法:3.1 已对齐 JSON Schema,应用
type: [string, "null"],不要用 3.0 遗留的nullable: true。 - 把契约设计误当成已部署端点:openapi.yaml 是设计产物,真实流式服务由 MCP server 承载,作品集里必须区分二者。
6. 学习资源(每条带 YYYY-MM)
- Anthropic, "Messages API streaming"(官方文档,2026-01)——typed-event 流式边界范本。
- OpenAPI Specification 3.1.0(OpenAPI Initiative,现行版,对齐 JSON Schema 2020-12)。
- Stripe Docs, "Idempotent requests"(官方文档,持续更新;幂等键经典实践)。
- Roy Fielding, "Architectural Styles and the Design of Network-based Software Architectures"(博士论文,2000;REST 原始定义,经典打底)。
- 本仓交叉引用:
docs/aipa/AI-native 参考架构网关 / 契约相关章节。
SOTA检查 (2026-06 更新)
- 当前主流:Anthropic Messages API 流式约定仍是 typed-event 边界范本,2026-01 版有效。OpenAPI 3.1(对齐 JSON Schema)为当前版本。
- 是否仍 SOTA:是。RPC 风格的 action-over-HTTP + typed error code + 幂等键是当下 LLM API 边界的事实标准(Anthropic / OpenAI / DeepSeek 均如此)。
- 过时黑名单:
- 避免用 legacy Text Completions API(已弃用,应用 Messages API)。
- 勿用 OpenAPI 3.0 的
nullable旧写法(3.1 改用 JSON Schema 风格的 nullable)。
- 下次复查点:Day 72 起接触 MCP 2026-07-28 最终规范的 Streamable HTTP transport,届时复验流式帧约定是否影响本日的 error-event 设计。
衔接
- 昨天:Day 70 — 修复回归 + 双 transcript 收口(B7 收口,证明「坏→红、好→绿」+ 归档 OAuth 双 transcript)。
- 今天:把 agent runner 的网络边界写成契约——RPC 风格
POST /chat+ 4 类 typed error code + 幂等键,对齐resilience.ts的重试语义。 - 明天:Day 72 — SSE 流式协议(把契约里的「响应」从一次性 body 变成 token 增量流)。