批量调用豆包处理一批评论(情感分类),做好三件事:限制并发数、遇到 429 或 5xx 时按 Retry-After 退避重试、逐条记录 tokens 用量。费用只有在你自己提供单价(
PRICE_IN/PRICE_OUT,单位是每百万 tokens)时才计算,脚本里不写死价格。
先看清成品
- 测试: 用模拟限流服务器测试(前 4 个请求返回 429),6 条评论全部处理完。
- 重试情况: 程序记录到 4 次重试,服务器一共收到 10 个请求,两边对得上。
- 分类错误: 本地 qwen2.5 1.5B 把“一般般吧,和图片差不多”分成了差评(应该是中评)。
没有在 box 上跑的部分: box 上没有方舟 API Key,没有调用过豆包 / 火山方舟的真实接口。代码按官方文档的 Chat API(OpenAI 兼容,base_url
https://ark.cn-beijing.volces.com/api/v3)编写,测试时把ARK_BASE_URL指向两种替身:
- 本地 Ollama。
- 我自己写的模拟服务器:它只照着文档检查了请求格式,返回的内容是固定的假数据。
合规提醒:方舟 API Key(
ARK_API_KEY)只放在环境变量里,不要写进网页前端。商品图、用户评论和聊天记录都可能含有个人信息,上传前要确认有权使用、做好脱敏。AI 生成的商品描述和客服回答必须经过人工审核才能发布,不能写夸大或无法证实的功效(《广告法》)。对公众提供生成式 AI 服务,需要按《生成式人工智能服务管理暂行办法》《人工智能生成合成内容标识办法》等规定履行相应义务。价格以方舟控制台的“模型定价”为准,本文不列价格。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
ark_batch.py |
线程池(并发数 CONCURRENCY);关闭 SDK 自带的重试,由程序自己退避并计数;逐条记录 usage;写入结果 CSV |
reviews.csv |
6 条虚构的评论 |
fake_ratelimit.py |
模拟服务器(127.0.0.1:18201):前 N 个请求返回 429(带 Retry-After: 1),之后转发给本地 Ollama;/stats 返回收到的请求数 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Python | 3.9 以上 | pip install openai flask requests |
| 方舟 | API Key | 限流额度(RPM / TPM)以控制台为准,按额度设置 CONCURRENCY |
文件结构
43-doubao-C-src/
├── ark_batch.py
├── reviews.csv
└── fake_ratelimit.py
第 1 步:代码
ark_batch.py:
"""批量处理(例:商品评论情感分类),带并发上限、429/5xx 退避重试、逐条记录 tokens 用量。
费用只在你提供单价时计算:环境变量 PRICE_IN / PRICE_OUT = 每百万 tokens 的价格(以方舟控制台“模型定价”为准,本脚本不内置价格)。
环境变量:ARK_API_KEY、ARK_BASE_URL(默认 https://ark.cn-beijing.volces.com/api/v3)、ARK_MODEL(默认 doubao-seed-2-1-lite-260915)、CONCURRENCY(默认 4)
用法:python ark_batch.py reviews.csv 结果.csv"""
import csv, json, os, random, sys, time
from concurrent.futures import ThreadPoolExecutor
import openai
from openai import OpenAI
BASE = os.environ.get("ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3")
# max_retries=0:关掉 SDK 自带的静默重试,由下面的代码统一重试并记录,日志里才看得到真实的 429 次数
client = OpenAI(base_url=BASE, api_key=os.environ.get("ARK_API_KEY") or "missing-key", max_retries=0, timeout=120)
MODEL = os.environ.get("ARK_MODEL", "doubao-seed-2-1-lite-260915")
LABELS = ["好评", "中评", "差评"]
def call(text, tries=5):
extra = {} if "11434" in BASE else {"thinking": {"type": "disabled"}}
for i in range(tries):
try:
r = client.chat.completions.create(model=MODEL, temperature=0, max_tokens=50, extra_body=extra, messages=[
{"role": "system", "content": f"判断商品评论的情感,只输出 {'/'.join(LABELS)} 中的一个词。"},
{"role": "user", "content": text}])
return r.choices[0].message.content.strip(), r.usage, i
except (openai.RateLimitError, openai.InternalServerError, openai.APIConnectionError) as e:
ra = getattr(getattr(e, "response", None), "headers", {}).get("retry-after")
wait = float(ra) if ra else min(2 ** i, 30) + random.random() # 优先听服务端的 Retry-After
print(f" {type(e).__name__},{wait:.1f}s 后重试(第 {i+1} 次)", file=sys.stderr); time.sleep(wait)
raise RuntimeError("重试次数用完")
def work(row):
t0 = time.time()
try:
label, u, retries = call(row["review"])
if label not in LABELS: label = f"无法识别:{label}"
return {**row, "label": label, "in_tokens": u.prompt_tokens, "out_tokens": u.completion_tokens, "retries": retries, "seconds": round(time.time() - t0, 2)}
except Exception as e:
return {**row, "label": f"失败:{e}", "in_tokens": 0, "out_tokens": 0, "retries": "-", "seconds": round(time.time() - t0, 2)}
if __name__ == "__main__":
rows = list(csv.DictReader(open(sys.argv[1], encoding="utf-8-sig")))
with ThreadPoolExecutor(int(os.environ.get("CONCURRENCY", 4))) as ex: out = list(ex.map(work, rows))
with open(sys.argv[2], "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=list(out[0])); w.writeheader(); w.writerows(out)
for o in out: print(f"{o['id']:>3} {o['label']:6} 重试 {o['retries']} {o['review']}")
tin, tout = sum(o["in_tokens"] for o in out), sum(o["out_tokens"] for o in out)
print(f"共 {len(out)} 条,输入 {tin} tokens,输出 {tout} tokens,429/5xx 重试 {sum(o['retries'] for o in out if o['retries'] != '-')} 次")
if os.environ.get("PRICE_IN") and os.environ.get("PRICE_OUT"):
print(f"按你提供的单价估算费用:{tin/1e6*float(os.environ['PRICE_IN']) + tout/1e6*float(os.environ['PRICE_OUT']):.4f}")
fake_ratelimit.py(仅用于离线测试):
"""模拟限流的服务:前 N 个请求返回 429(带 Retry-After: 1),之后转发给本地 Ollama。统计收到的请求总数。
用法:FAIL_FIRST=4 python fake_ratelimit.py (监听 127.0.0.1:18201)"""
import os, threading, requests
from flask import Flask, jsonify, request
app, lock, n = Flask(__name__), threading.Lock(), {"total": 0}
FAIL = int(os.environ.get("FAIL_FIRST", 4))
@app.post("/v1/chat/completions")
def chat():
with lock: n["total"] += 1; k = n["total"]
if k <= FAIL: return jsonify(error={"message": "fake rate limit"}), 429, {"Retry-After": "1"}
b = request.get_json(); b["model"] = os.environ.get("FAKE_MODEL", "qwen2.5-local:1.5b")
return requests.post("http://127.0.0.1:11434/v1/chat/completions", json=b, timeout=300).json()
@app.get("/stats")
def stats(): return jsonify(n)
if __name__ == "__main__":
app.run(port=18201, threaded=True)
第 2 步:运行
$ FAIL_FIRST=4 python fake_ratelimit.py &
$ ARK_BASE_URL=http://127.0.0.1:18201/v1 ARK_MODEL=qwen2.5-local:1.5b python ark_batch.py reviews.csv 结果.csv
RateLimitError,1.0s 后重试(第 1 次)
(同样的提示共 4 行)
1 好评 重试 1 物流很快,包装也很好,下次还来
2 差评 重试 1 杯子有裂缝,客服态度还行但是退货麻烦
3 差评 重试 1 一般般吧,和图片差不多
4 差评 重试 1 颜色比图片暗很多,失望
5 好评 重试 0 性价比很高,推荐
6 差评 重试 0 发错货了,催了三天才补发
共 6 条,输入 254 tokens,输出 18 tokens,429/5xx 重试 4 次
$ curl 127.0.0.1:18201/stats
{"total":10}
检查点:6 条 + 4 次重试 = 10 个请求,和服务器的统计一致。真实使用时,设置 PRICE_IN / PRICE_OUT 后会多输出一行估算费用。
调试示例:重试“消失”了
第一版使用 SDK 的默认设置。跑完之后日志里显示 0 次重试,但服务器同样收到了 10 个请求。原因是 openai SDK 默认会自动重试 429(max_retries 默认为 2),重试发生在 SDK 内部,程序完全不知道。这样一来,统计不准,也没法控制退避策略。改成 max_retries=0,由程序自己按 Retry-After 退避并计数之后,两边的数字对上了。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 大量 429 | 并发数超过了额度 | 降低 CONCURRENCY |
| 中评被分成差评 | 小模型的判断能力有限 | 换更强的模型;抽样人工核对 |
| 费用一栏为空 | 没有设置单价 | 从控制台查到单价后,设置 PRICE_IN / PRICE_OUT |
模板规则
- 重试由自己控制,并且要计数。
- 用量逐条记录,单价由用户提供。
- 批量结果抽样核对后再使用。
项目资源
| 用途 | 地址 |
|---|---|
| 方舟文档首页 | https://ark.volcengine.com/region:cn-beijing/docs/ |
| openai-python:重试设置 | https://github.com/openai/openai-python#retries |
| MDN:Retry-After | https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Retry-After |
批量任务能不能放心交出去,看的是重试和用量这两本账记得清不清楚。