AI 内容检测 API 接入实战——异步任务、幂等键与轮询的正确写法

3 阅读5分钟

做内容平台的朋友最近问得最多的一个问题是:AI 检测接口怎么接。看起来只是「发个请求拿个结果」,但真正上线之后,90% 的坑都集中在三件事上:提交是扣费动作、结果是异步的、失败分两层。

这篇把这三个问题拆开讲,代码可以直接跑。

一、为什么这类接口一定是异步的

一次文本检测的实际耗时在 1–3 秒,长文本更久。同步接口意味着你的 HTTP 连接要挂在那里等上游推理,连接池很快被打满,上游一抖动你的服务跟着雪崩。

所以标准形态是:

POST /v1/text/classify   →  { task_id }        # 立即返回,此时已扣费
GET  /v1/tasks/{task_id} →  { status, result } # 轮询直到终态

status 的取值:pending / running 是中间态,succeeded / failed 是终态。

这个设计本身没问题,问题在于很多人把它当同步接口写:while True: 查一下; sleep(0.1)。这会撞上查询接口的限流(一般 10 次/秒),而且密集轮询并不会让上游算得更快。

二、完整实现

下面是最小可用版本,包含限流重试、幂等和超时处理。

import os
import time
import uuid

import requests

BASE = "https://sbnew.top"
KEY = os.environ["AIGC_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}"}


def submit_text(text: str, is_merge: bool = False, idem: str | None = None) -> str:
    """提交检测任务,返回 task_id。提交即扣费。"""
    idem = idem or str(uuid.uuid4())

    while True:
        resp = requests.post(
            f"{BASE}/v1/text/classify",
            headers={**HEADERS, "Idempotency-Key": idem},
            json={"text": text, "is_merge": is_merge},
            timeout=30,
        )

        if resp.status_code == 429:
            # 未被受理,不产生任务也不扣费;复用同一个幂等键重试是安全的
            wait = int(resp.headers.get("Retry-After", "1"))
            time.sleep(wait)
            continue

        resp.raise_for_status()
        return resp.json()["data"]["task_id"]


def wait_for(task_id: str, timeout: int = 180) -> dict:
    """轮询到终态。超时不代表任务失败,任务仍在服务端。"""
    deadline = time.time() + timeout
    interval = 1.5

    while time.time() < deadline:
        resp = requests.get(f"{BASE}/v1/tasks/{task_id}", headers=HEADERS, timeout=30)
        resp.raise_for_status()
        task = resp.json()["data"]

        if task["status"] in ("succeeded", "failed"):
            return task

        time.sleep(interval)
        interval = min(interval * 1.5, 5.0)   # 连续 pending 时退避,避免空转

    raise TimeoutError(f"任务仍在执行,请稍后用 get_task('{task_id}') 继续查询,勿重新提交")

调用:

task_id = submit_text("把待检测的文本放在这里", is_merge=False)
task = wait_for(task_id)

if task["status"] == "failed":
    print("任务失败:", task["error_code"], task["error_msg"])   # 是否退款看 error_code
else:
    result = task["result"]
    print("整体置信度:", result["softmax_confidence"])
    for seg in result["segment_labels"]:
        start, length = seg["position"]          # 注意:第二项是段长,不是结束下标
        print(seg["label"], seg["conf"], seg["text"])

三、幂等键的三个陷阱

陷阱一:以为重试就是重新提交。 提交接口一被调用就扣费。网络抖动导致客户端超时,服务端可能已经建了任务——这时如果你换一个新幂等键重试,就是实打实的重复扣费。正确做法是:同一个逻辑请求,自始至终用同一个键,把键和业务 ID 一起落库。

陷阱二:改了参数还复用旧键。 幂等是按「账号 + 键」去重的,服务端不校验请求内容。你改了 is_merge 或换了文本,却复用旧键,拿回来的是上一次的任务——结果对不上,还很难排查。规则很简单:

场景幂等键
网络超时、429 重试复用同一个
改了文本 / 改了 is_merge必须换新的
用户手动点「重新检测」必须换新的

陷阱三:键不落库。 进程崩了、消息重投,键就丢了。建议直接用业务主键派生:f"{article_id}:{revision}:{attempt}",可复现、可追溯。

四、失败分两层,别混在一起处理

这是最容易写错的地方。HTTP 层的错误和任务层的错误语义完全不同:

HTTP 层(请求没被受理,不产生任务,不扣费)

状态code含义处理
400invalid_param参数非法,如 text 为空或超长修代码,别重试
400missing_idempotency_key缺幂等键补上
401invalid_api_key密钥无效检查配置
402insufficient_balance余额不足充值后重提
429rate_limited超出每秒上限按 Retry-After 等待
429quota_exceeded每日配额用尽等次日重置,等待无用
413payload_too_large请求体超限压缩或分片

任务层(HTTP 200,看 data.status 和 data.error_code)

error_code含义退款
bad_request输入未被受理全额退
rate_limited检测服务繁忙全额退
service_unavailable服务暂不可用全额退
service_error检测服务异常不退
detect_failed结果未取回不退
timeout检测超时不退

实践建议:不要穷举 error_code 做分支。这个枚举会随上游演进,认不出的码一律按失败处理,退款以资金流水为准——这是唯一权威的账。

五、并发怎么设

两个限流是独立的:提交 2 次/秒,查询 10 次/秒,另有每日提交配额。

这意味着提交侧的 2 QPS 就是你的吞吐上限,开 20 个线程也不会更快,只会在提交口排队撞 429。合理的配置是:4–6 个 worker,每个 worker 提交后以 1.5–2 秒间隔轮询,整体查询压力约 3 QPS,安全。

如果单账号不够用,优先考虑多账号分片或申请提额,而不是加线程。

六、小结

接入这类接口,真正要写对的只有四行逻辑:

  1. 幂等键跟着「逻辑请求」走,不跟着「HTTP 请求」走;
  2. 429 按 Retry-After 退避,且复用幂等键;
  3. 轮询超时继续查,永不重新提交;
  4. 失败分 HTTP 层和任务层两套处理,退款只看流水。

其余都是工程细节。


示例基于文识云(sbnew.top)的 AIGC 检测接口,底层为朱雀模型。接口形态与错误分层设计在同类的异步检测服务里比较通用,换成别家也能对上大半。