《AI Agent 工程实战》3.3 幂等、分页与统一错误契约

本节补齐 ResearchCopilot 接口的三个边角:用 Idempotency-Key 让同一 key 重复 POST 只创建一个 run、设计 code / message / details / request_id 四字段统一错误体并给出错误码表、用游标分页取代 offset 分页,三处都用 TestClient 真跑并贴出真实输出。

3.3 幂等、分页与统一错误契约

3.1 立了资源,3.2 立了流式,但一个「能用的 API」和「能扛住真实流量的 API」之间,还差三件小事:重试不会重复建任务、错误有统一形状、列表能稳定翻页。这三件事都不显眼,却决定了客户端敢不敢重试、运维能不能排障。这一节把它们一次补齐。

本节把 ResearchCopilot 推进到「客户端可安全重试」:新增 Idempotency-Key 幂等控制、code / message / details / request_id 四字段统一错误体、以及游标分页,并用 TestClient 真跑三组请求。

3.3.1 重试是常态,幂等是刚需

网络请求会超时、会断线,客户端的标准应对是重试。但一个朴素的问题立刻出现:客户端发了 POST /sessions/{id}/runs,服务端其实收到了、run 也建好了,只是响应在回来的路上丢了。客户端超时后重试——于是同一个提问建出了两个 run,用户看到两份重复报告,后台白跑一次检索。

这就是幂等要解决的:同一个逻辑请求,无论发多少次,效果与发一次相同。GET / PUT / DELETE 天生幂等,麻烦的是 POST——它被设计成「每次调用都创建新资源」。解法是让客户端带一个 Idempotency-Key 头,服务端记住「这个 key 已经处理过」,重复请求直接返回首次的结果。

3.3.2 Idempotency-Key 的落地

核心是一张 key -> (请求体指纹, run_id) 的映射表。客户端生成一个唯一的 key(通常是 UUID),随请求发出;服务端收到后先查表:

import hashlib
from fastapi import Header
from fastapi.responses import JSONResponse

_idem: dict[str, tuple[str, str]] = {}   # Idempotency-Key -> (body_hash, run_id)


@app.post("/sessions/{sid}/runs", status_code=201)
def create_run(sid: str, body: RunCreate, idempotency_key: str | None = Header(default=None)):
    if sid not in _sessions:
        raise ApiError(404, "session_not_found", "会话不存在", {"session_id": sid})

    if idempotency_key:
        body_hash = hashlib.sha256(body.model_dump_json().encode()).hexdigest()[:12]
        if idempotency_key in _idem:
            old_hash, old_rid = _idem[idempotency_key]
            if old_hash != body_hash:
                raise ApiError(409, "idempotency_conflict",
                               "同一 Idempotency-Key 携带了不同请求体",
                               {"idempotency_key": idempotency_key})
            return JSONResponse(status_code=200, content=_runs[old_rid])   # 重放
        rid = "r_" + uuid.uuid4().hex[:8]
        _idem[idempotency_key] = (body_hash, rid)
    else:
        rid = "r_" + uuid.uuid4().hex[:8]

    _runs[rid] = {"id": rid, "session_id": sid, "status": "queued",
                  "input": body.input, "created_at": now()}
    return _runs[rid]

三个设计点值得留意:

  • 记的是「请求体指纹」不是「请求体」。把 body 的 SHA-256 存下来,重复请求比对指纹即可;不存原文,既省空间又避免把用户输入留在幂等表里。
  • 指纹不同就报 409。同一个 key 配不同 body 是客户端 bug(key 复用错了),必须显式冲突,不能「以第一次为准」静默吞掉。
  • 重放返回 200 而非 201。首次创建是 201(资源诞生),重放是 200(资源早已存在)——状态码本身就在告诉客户端「这次没新建」。

3.3.3 真跑:重复 POST 只创建一个 run

用 TestClient 连发两次同样的请求,带上同一个 Idempotency-Key:

key = "idem-abc-001"
r1 = client.post(f"/sessions/{sid}/runs", json={"input": "对比 pgvector 与 Qdrant"},
                 headers={"Idempotency-Key": key})
r2 = client.post(f"/sessions/{sid}/runs", json={"input": "对比 pgvector 与 Qdrant"},
                 headers={"Idempotency-Key": key})
print("first :", r1.status_code, r1.json()["id"])
print("second:", r2.status_code, r2.json()["id"])
print("same id?", r1.json()["id"] == r2.json()["id"], " run 总数:", len(_runs))

# 同一 key 换 body
r3 = client.post(f"/sessions/{sid}/runs", json={"input": "换个问题"},
                 headers={"Idempotency-Key": key})
print("status:", r3.status_code, r3.json())

真实输出:

first : 201 r_d5b4e816
second: 200 r_d5b4e816
same id? True  run 总数: 1
status: 409 {'code': 'idempotency_conflict', 'message': '同一 Idempotency-Key 携带了不同请求体', 'details': {'idempotency_key': 'idem-abc-001'}, 'request_id': 'req_8c8d11aa5a12'}

两次请求返回同一个 run id r_d5b4e816,且 run 总数 是 1——重复请求没有创建第二个 run。状态码从 201 变 200,如实反映「这次是重放」。换 body 后变成 409,客户端能立刻发现自己的 key 用错了。

3.3.4 统一错误响应体

错误最怕「每种错长得都不一样」。客户端要 if 一堆分支才能判断,日志里也拼不出模式。我们的解法是所有错误响应体统一四字段:

class ApiError(Exception):
    def __init__(self, status: int, code: str, message: str, details: dict | None = None):
        self.status = status
        self.code = code
        self.message = message
        self.details = details or {}


@app.exception_handler(ApiError)
async def api_error_handler(request: Request, exc: ApiError) -> JSONResponse:
    return JSONResponse(status_code=exc.status, content={
        "code": exc.code,               # 机器可判别的稳定标识
        "message": exc.message,         # 给人读的一句话
        "details": exc.details,         # 结构化补充,便于前端定位
        "request_id": request.state.request_id,   # 排障时的关联 id
    })
字段给谁看说明
code机器稳定不变的枚举,客户端按它分支,绝不改
message人可读的一句话,允许随时润色
details前端结构化上下文(哪个字段错了、哪个资源不存在)
request_id运维与日志关联,报障时用户一贴就能定位

request_id 由一段中间件在请求进入时生成,并回写进响应头,这样同一串 id 同时出现在响应体、响应头和日志里:

@app.middleware("http")
async def attach_request_id(request: Request, call_next):
    request.state.request_id = "req_" + uuid.uuid4().hex[:12]
    resp = await call_next(request)
    resp.headers["X-Request-Id"] = request.state.request_id
    return resp

真跑一个不存在的会话,看错误体长什么样:

status: 404 {'code': 'session_not_found', 'message': '会话不存在', 'details': {'session_id': 's_nope'}, 'request_id': 'req_e4deddd821ba'}

注意 code 是 session_not_found 这种稳定 snake_case,而不是把 HTTP 短语直接抄过来。客户端判断「会话没了」就比对 code == "session_not_found",永远不用去解析 message 的中文。

3.3.5 错误码表

把 ResearchCopilot 会用到的错误码一次列清,作为全卷的统一口径:

HTTPcode场景引入章节
400invalid_request参数格式错误3.1
401unauthorized缺少或无效凭证第 4 章
403forbidden越权访问他人 workspace第 4 章
404session_not_found会话不存在3.1
404run_not_foundrun 不存在3.1
409idempotency_conflict同 key 配了不同请求体3.3
429rate_limited超过 token 配额第 6 章
500internal_error未捕获异常全卷
503model_unavailable供应商不可用,降级中第 2 章

说明:FastAPI 对 pydantic 校验失败会自动返回 422,其体形状与本表不同(是 detail 数组)。要做到「全站错误一个形状」,需要注册一个 RequestValidationError 处理器把它改写成四字段体——这属于第 4 章中间件的活,本节先记下这个坑。

3.3.6 为什么用游标分页而不是 offset

列表接口迟早要分页。最直觉的写法是 ?offset=20&limit=20,但它在数据会增删的场景下会出问题:

维度offset 分页游标分页
翻页参数offset + limitcursor + limit
数据插入时后续页漏项或重项从游标位置继续,不重不漏
深翻性能OFFSET 100000 要扫过前面全部行走索引定位,与深度无关
能否跳到第 N 页能不能(只能顺序翻)
适合后台管理、数据基本静止时间线、事件流、会话列表

Agent 的会话列表正是「边用边增」——你翻页时别人可能正建新会话。offset 分页下,新插入的行会把后续内容整体后移,导致第二页出现第一页看过的项,或者漏掉一项。游标分页把「下一页从哪个稳定锚点开始」编码进 cursor,插入多少都不影响已翻过的部分。代价是不能随机跳页,但对「无限滚动」这种交互来说,顺序翻页本来就够用。

3.3.7 真跑:游标翻页

游标用「上一页最后一条的 id」base64 编码而成,下一页从该 id 之后取:

import base64


def encode_cursor(sid: str) -> str:
    return base64.urlsafe_b64encode(sid.encode()).decode()


@app.get("/sessions")
def list_sessions(limit: int = 2, cursor: str | None = None) -> dict:
    ids = list(_sessions.keys())
    start = 0
    if cursor:
        after = base64.urlsafe_b64decode(cursor.encode()).decode()
        if after in ids:
            start = ids.index(after) + 1
    page = ids[start:start + limit]
    has_more = len(ids) > start + limit
    return {
        "items": [_sessions[i] for i in page],
        "next_cursor": encode_cursor(page[-1]) if has_more and page else None,
    }

建 5 个会话后,limit=2 连翻三页的真实输出:

page1: ['研究课题 0', '研究课题 1']  next_cursor=c18zNzllYjQy...
page2: ['研究课题 2', '研究课题 3']  next_cursor=c19jZmNkODFi...
page3: ['研究课题 4']  next_cursor=None

三页正好覆盖 5 条,无重无漏;next_cursor 在最后一页变成 null,客户端据此停止翻页。「有没有下一页」由 next_cursor 是否为 null 决定,而不是去猜「本页是否满 limit」——后者在数据量恰好是 limit 整数倍时会多打一次空请求。

3.3.8 真实环境 / 真实模型下会怎样

三件事在真实部署里都会变重:

  • 幂等表要有 TTL 且要落库。内存字典重启即丢,重试窗口一过就没意义;生产里 Idempotency-Key 通常存 Redis(第 6 章)并设 24 小时过期,避免无限增长。
  • 游标要编码复合键。单靠 id 排序在「同一秒创建多条」时不稳定,真实实现用 (created_at, id) 复合游标,保证全序。
  • 错误体要覆盖框架默认错误。pydantic 的 422、Starlette 的 405 都得改写进四字段体,否则「统一」只是口号。

本节验收清单:

  • 同一 Idempotency-Key 重复 POST 返回同一 run id,且 run 总数不增
  • 同一 key 换 body 返回 409,code == "idempotency_conflict"
  • 错误体恒为 code / message / details / request_id 四字段
  • 响应头带 X-Request-Id,与错误体里的 request_id 一致
  • 游标翻页无重无漏,末页 next_cursor 为 null

3.3.9 小结

  • POST 默认不幂等,用 Idempotency-Key + 请求体指纹实现「重试只建一次」。
  • 指纹不同报 409;重放返回 200 而非 201,用状态码区分「新建」与「命中已有」。
  • 错误体统一四字段:code(机器)/ message(人)/ details(前端)/ request_id(运维)。
  • 游标分页在「边用边增」的列表上不重不漏,且深翻性能恒定;代价是不能跳页。
  • 幂等表要 TTL、游标要复合键、框架默认错误要改写——真实化时逐条补齐。

第 3 章到此收束:ResearchCopilot 有了资源模型、流式接口和可重试的契约。但这一切对所有人开放——任何人都能调别人的会话。下一节 4.1 认证:JWT 与 API Key 中间件 会给这张脸加上身份。想回看流式接口,翻 3.2 流式接口:SSE 推送事件 。

阅读导航:上一节:3.2 流式接口:SSE 推送事件 · 下一节:4.1 认证:JWT 与 API Key 中间件 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距