做内容平台的朋友最近问得最多的一个问题是:AI 检测接口怎么接。看起来只是「发个请求拿个结果」,但真正上线之后,90% 的坑都集中在三件事上:提交是扣费动作、结果是异步的、失败分两层。
这篇把这三个问题拆开讲,代码可以直接跑。
一、为什么这类接口一定是异步的
一次文本检测的实际耗时在 1--3 秒,长文本更久。同步接口意味着你的 HTTP 连接要挂在那里等上游推理,连接池很快被打满,上游一抖动你的服务跟着雪崩。
所以标准形态是:
bash
POST /v1/text/classify → { task_id } # 立即返回,此时已扣费
GET /v1/tasks/{task_id} → { status, result } # 轮询直到终态
status 的取值:pending / running 是中间态,succeeded / failed 是终态。
这个设计本身没问题,问题在于很多人把它当同步接口写:while True: 查一下; sleep(0.1)。这会撞上查询接口的限流(一般 10 次/秒),而且密集轮询并不会让上游算得更快。
二、完整实现
下面是最小可用版本,包含限流重试、幂等和超时处理。
python
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}') 继续查询,勿重新提交")
调用:
python
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 | 含义 | 处理 |
|---|---|---|---|
| 400 | invalid_param |
参数非法,如 text 为空或超长 | 修代码,别重试 |
| 400 | missing_idempotency_key |
缺幂等键 | 补上 |
| 401 | invalid_api_key |
密钥无效 | 检查配置 |
| 402 | insufficient_balance |
余额不足 | 充值后重提 |
| 429 | rate_limited |
超出每秒上限 | 按 Retry-After 等待 |
| 429 | quota_exceeded |
每日配额用尽 | 等次日重置,等待无用 |
| 413 | payload_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,安全。
如果单账号不够用,优先考虑多账号分片或申请提额,而不是加线程。
六、小结
接入这类接口,真正要写对的只有四行逻辑:
- 幂等键跟着「逻辑请求」走,不跟着「HTTP 请求」走;
- 429 按
Retry-After退避,且复用幂等键; - 轮询超时继续查,永不重新提交;
- 失败分 HTTP 层和任务层两套处理,退款只看流水。
其余都是工程细节。
示例基于文识云(sbnew.top)的 AIGC 检测接口,底层为朱雀模型。接口形态与错误分层设计在同类的异步检测服务里比较通用,换成别家也能对上大半。