返回 AICAP-180
B8 · Day 71真 API + 流式 + Docker

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/123DELETE /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:

codeHTTP status含义重试?
invalid_request400客户端请求错(参数/格式)
rate_limited429被限流退避后重试
upstream_error502上游模型挂了可重试
timeout504整体超时可重试

这 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. 今日实战

  1. 在仓库根新增 openapi.yaml(OpenAPI 3.1,对齐 JSON Schema)。
  2. 定义 POST /chat:request body 含 messages(数组)、model(默认 deepseek-v4-flash);response 为 assistant 消息 + usage。
  3. 定义 4 类 typed error schema:invalid_request(400)、rate_limited(429)、upstream_error(502)、timeout(504),每类一个稳定 error.code 枚举值 + 可读 message
  4. 在每个 error 的 description 里标注重试语义(4xx 不重试 / 429+5xx+504 可重试),对齐 src/agent/runtime/resilience.tsshouldRetry 设计。
  5. 可选:在 request header 加 Idempotency-Key,注明用于去重防重复扣费。

4. 今日实测 / 产出

  • 产出openapi.yaml 草案(含 4 类 typed error schema)。
  • 诚实标注:独立的 POST /chat SSE app route 并未单独构建——真实 MCP server(src/agent/mcp/server.ts)已覆盖「真实网络服务」部分,本日 openapi.yaml契约设计产出,非已部署端点
  • src/agent/runtime/resilience.ts:已构建+已测(backoffDelay/retry/CircuitBreaker/withFallback tested),本日仅引用其语义做 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 增量流)。