开发指南
- 从原型到上线的工程实践
- 覆盖重试、超时、幂等、流式解析与成本控制
- 示例以 Python 与 JavaScript 为主,其他语言思路一致
选接口:同步还是异步
| 类型 | 典型接口 | 返回方式 | 适合 |
|---|---|---|---|
| 同步 | 文本对话、embedding | 一次返回或流式返回 | 交互式问答、批处理 |
| 异步 | 图像、视频、音乐 | 提交拿 task_id,再轮询 | 生成类长任务 |
判断标准很简单:结果能不能在一分钟内出来。能就用同步,不能就用异步。
| Type | Typical endpoints | How results come back | Best for |
|---|---|---|---|
| Sync | Chat, embeddings | One response or a stream | Interactive Q&A, batch jobs |
| Async | Images, video, music | Submit, get a task_id, then poll | Long-running generation |
The rule is simple: can the result come back within a minute? If so, go sync. If not, go async.
超时与重试
同步接口建议的超时:
| 场景 | 超时 | 重试 |
|---|---|---|
| 非流式对话 | 60 秒 | 最多 2 次 |
| 流式对话 | 首 token 30 秒,总时长 5 分钟 | 首 token 超时才重试 |
| embedding | 30 秒 | 最多 3 次 |
重试只对这几类错误有意义:
408、429、500、502、503- 网络层超时
不要重试 400、401、402、403,这些重试多少次都是一样的结果,只会白花时间。
import time, random, requests
RETRYABLE = {408, 429, 500, 502, 503}
def call_with_retry(url, headers, payload, tries=3):
for attempt in range(tries):
try:
res = requests.post(url, headers=headers, json=payload, timeout=60)
if res.status_code in RETRYABLE and attempt < tries - 1:
time.sleep((2 ** attempt) + random.random())
continue
return res
except (requests.Timeout, requests.ConnectionError):
if attempt == tries - 1:
raise
time.sleep((2 ** attempt) + random.random())退避用指数加随机抖动,避免多个客户端在同一时刻一起重试,把上游再打垮一次。
Recommended timeouts for sync endpoints:
| Scenario | Timeout | Retries |
|---|---|---|
| Non-streaming chat | 60 s | Up to 2 |
| Streaming chat | 30 s to first token, 5 min total | Retry only on first-token timeout |
| Embeddings | 30 s | Up to 3 |
Retries only make sense for these errors:
408,429,500,502,503- Network-level timeouts
Don't retry 400, 401, 402, 403. Retrying those gives the same result every time and just wastes time.
import time, random, requests
RETRYABLE = {408, 429, 500, 502, 503}
def call_with_retry(url, headers, payload, tries=3):
for attempt in range(tries):
try:
res = requests.post(url, headers=headers, json=payload, timeout=60)
if res.status_code in RETRYABLE and attempt < tries - 1:
time.sleep((2 ** attempt) + random.random())
continue
return res
except (requests.Timeout, requests.ConnectionError):
if attempt == tries - 1:
raise
time.sleep((2 ** attempt) + random.random())Use exponential backoff with jitter, so multiple clients don't all retry at the same instant and knock the upstream over again.
异步任务的正确轮询
import time
def wait_task(task_id, interval=5, timeout=600):
deadline = time.time() + timeout
while time.time() < deadline:
r = requests.get(
f"https://api.oairoute.com/v1/tasks/{task_id}",
headers={"Authorization": "Bearer YOUR_OAIRoute_KEY"},
timeout=30,
)
body = r.json()
data = body.get("data") or {}
status = data.get("status")
if status == "completed":
return data["result"]
if status in ("failed", "cancelled"):
raise RuntimeError(data.get("error", {}).get("message"))
time.sleep(interval)
raise TimeoutError(f"任务 {task_id} 超时")几个要点:
- 间隔不要小于 5 秒,太密没有意义且浪费额度
- 设一个总超时,避免无限等待
- 终态有两个:成功和失败,都要处理
import time
def wait_task(task_id, interval=5, timeout=600):
deadline = time.time() + timeout
while time.time() < deadline:
r = requests.get(
f"https://api.oairoute.com/v1/tasks/{task_id}",
headers={"Authorization": "Bearer YOUR_OAIRoute_KEY"},
timeout=30,
)
body = r.json()
data = body.get("data") or {}
status = data.get("status")
if status == "completed":
return data["result"]
if status in ("failed", "cancelled"):
raise RuntimeError(data.get("error", {}).get("message"))
time.sleep(interval)
raise TimeoutError(f"Task {task_id} timed out")A few points:
- Keep the interval at 5 seconds or more; polling faster is pointless and burns balance
- Set an overall timeout so you never wait forever
- There are two terminal states, success and failure; handle both
幂等
同一个请求重复发送,不应该产生两次扣费或两个任务。
服务端已有的幂等能力:
| 场景 | 机制 |
|---|---|
| 创建任务 | 传入 Idempotency-Key 头,相同值在 24 小时内只建一个任务 |
| 回调接收 | 按 task_id 去重 |
客户端也要做一层:记录已经提交过的业务主键,重试前先查。
Sending the same request twice shouldn't charge you twice or create two tasks.
Idempotency the server already provides:
| Scenario | Mechanism |
|---|---|
| Creating a task | Send an Idempotency-Key header; the same value creates only one task within 24 hours |
| Receiving callbacks | Deduplicate by task_id |
Do it on the client too: record business keys you've already submitted and check before retrying.
流式解析
SSE 的解析坑主要集中在「一次读到的数据不完整」:
const res = await fetch(url, { method: "POST", headers, body });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// 按空行切分完整的 SSE 事件
const parts = buf.split("\n\n");
buf = parts.pop(); // 最后一段可能不完整,留到下次
for (const part of parts) {
for (const line of part.split("\n")) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6);
if (payload === "[DONE]") return;
const chunk = JSON.parse(payload);
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
}
}关键是把剩余的不完整片段留在 buf 里,等下一次数据补齐再解析。
SSE parsing pitfalls mostly come down to "the data you read in one go is incomplete":
const res = await fetch(url, { method: "POST", headers, body });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// Split on blank lines to get whole SSE events
const parts = buf.split("\n\n");
buf = parts.pop(); // the last part may be incomplete; keep it for next time
for (const part of parts) {
for (const line of part.split("\n")) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6);
if (payload === "[DONE]") return;
const chunk = JSON.parse(payload);
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
}
}The key is to keep the leftover incomplete fragment in buf and parse it once the next chunk arrives.
成本控制
用 max_tokens 卡住上限。 不设的话,偶发的超长输出会显著推高成本。
把固定内容前置以命中缓存。 系统提示、产品手册放在最前面且保持不变,见「上下文缓存」相关页面。
按任务选模型。 分类、抽取、改写用不了旗舰模型,小模型便宜一个数量级。
批量时限制并发。 并发过高会触发 429,重试又增加成本。用信号量把并发压在 5 到 10。
Cap output with max_tokens. Without it, an occasional overlong response pushes costs up noticeably.
Put fixed content first to hit the cache. Keep system prompts and product manuals at the front and unchanged; see the "Context caching" pages.
Match the model to the job. Classification, extraction, and rewriting don't need a flagship model; small models cost an order of magnitude less.
Limit concurrency in batch jobs. Too much concurrency triggers 429s, and retries add cost. Use a semaphore to keep concurrency between 5 and 10.
上线前检查清单
- 密钥从环境变量读取,不在代码里硬编码
- 所有请求都设了超时
- 只对可重试的错误重试,且带退避
- 异步任务有总超时,不会永久阻塞
- 结果文件下载后转存到自己的存储
- 错误结构按
error.code与error.type分别处理 - 成本有监控或告警
- Keys are read from environment variables, not hardcoded
- Every request has a timeout
- Only retryable errors are retried, with backoff
- Async tasks have an overall timeout and can't block forever
- Result files are downloaded and copied to your own storage
- Errors are handled separately by
error.codeanderror.type - Costs are monitored or alerted on