背景问题:批量 API 便宜,但不是”上传文件后等结果”这么简单
大模型批量推理非常适合离线抽取、数据标注、评测、内容分类、Embedding 生成和历史数据回填。它通常以 JSONL 文件或内联请求提交,在数小时内异步完成,成本往往低于实时接口。
但真正进入生产后,风险不在模型调用本身,而在作业生命周期中那些容易被忽视的角落:
- 客户端提交请求超时,但服务端其实已经创建作业,程序重试后产生第二份费用。
- 输出文件顺序与输入顺序不同,按行号回填导致结果写错业务记录。
- 作业显示完成,但部分记录失败、过期或根本没有输出。
- 取消作业后仍存在已经完成的部分结果,系统却把整个批次视为失败。
- 为了补少量失败记录而重跑全部输入,既重复计费,又覆盖已经验收的结果。
- 供应商结果文件有保留期限,下载和归档不及时后无法重新对账。
因此,生产级批量推理的核心不是”轮询状态”,而是建立可证明的一次提交、逐条可追踪的结果集合,以及只重试必要记录的补偿闭环。
核心原理:把批量推理拆成两层幂等
作业层幂等
作业层回答一个问题:这份输入是否已经成功提交过?
不要把供应商返回的 Batch ID 当作唯一依据,因为 Batch ID 只有在创建请求成功返回后才存在。更稳妥的做法是在提交前生成本地 job_key:
job_key = sha256(
tenant_id + dataset_version + input_manifest_hash +
model_id + prompt_version + generation_config_hash
)
job_key 对应一条本地作业记录,状态机至少包括:
| 状态 | 含义 |
|---|---|
CREATED | 作业已创建,等待校验 |
VALIDATED | 输入校验通过 |
SUBMITTING | 正在提交至供应商 |
SUBMITTED | 供应商已确认接收 |
RUNNING | 供应商正在处理 |
RECONCILING | 正在结果对账 |
PARTIAL | 部分记录待补偿 |
COMPLETED | 全部对账通过 |
FAILED | 作业级失败 |
CANCELLED | 已取消 |
EXPIRED | 结果已过期 |
程序必须先持久化 SUBMITTING 状态和输入文件哈希,再调用供应商创建接口。若网络超时,恢复程序应先查本地账本和远端作业,而不是直接重新提交。
注意:Google Gemini 当前文档明确提示,批量作业创建不是幂等操作;相同创建请求提交两次会生成两个独立作业。这意味着”HTTP 重试”不能直接等价为”业务重试”。
记录层幂等
记录层回答另一个问题:每一条业务输入是否已经得到唯一、可验收的结果?
记录编号应来自稳定业务字段,而不是临时行号:
record_id = sha256(
tenant_id + business_primary_key + source_version +
prompt_version + model_config_version
)
这样,同一业务记录在同一输入和配置下会得到相同编号;Prompt、模型参数或源数据变化后,编号也会随之变化。
OpenAI Batch 使用唯一 custom_id 将输出映射回输入;Amazon Bedrock 使用 recordId,且官方文档明确指出输出 JSONL 的记录顺序不保证与输入一致。无论供应商是否承诺保持顺序,生产系统都应始终按记录编号 Join,不能按行号 Join。
Manifest:批量作业的事实来源
每次提交都应生成不可变的 Manifest。它既是输入快照,也是后续对账、审计和重试的依据。
{
"job_key": "sha256:...",
"manifest_version": 1,
"tenant_id": "tenant-a",
"provider": "openai",
"model": "model-version",
"endpoint": "/v1/responses",
"prompt_version": "summary-v7",
"generation_config_hash": "sha256:...",
"input_file_sha256": "sha256:...",
"expected_record_count": 50000,
"shards": [
{
"shard_id": "part-00017",
"file_sha256": "sha256:...",
"record_count": 2000
}
],
"created_at": "2026-07-28T02:59:08-04:00"
}
Manifest 至少要冻结以下信息:
- 输入记录集合及其哈希。
- 模型的精确版本,而不是浮动别名。
- Prompt、System Instruction、Tool Schema 和生成参数版本。
- 分片编号、记录数、字节数和文件哈希。
- 提交方、租户、成本中心和数据保留策略。
- 预期输出 Schema 及其版本。
输入文件、Manifest 和结果文件应采用追加写或内容寻址方式保存,不应原地覆盖。
提交流程:先落账,再调用供应商
推荐使用 Outbox 或数据库唯一约束保护提交过程:
def submit_batch(job_key: str) -> str:
job = load_job_for_update(job_key)
if job.provider_job_id:
return job.provider_job_id
assert job.input_validated
mark_submitting(job_key)
remote = provider.find_by_metadata(job_key)
if remote:
bind_provider_job(job_key, remote.id)
return remote.id
created = provider.create_batch(
input_file_id=job.input_file_id,
metadata={"job_key": job_key},
)
bind_provider_job(job_key, created.id)
return created.id
关键约束不是这段代码本身,而是以下保障:
job_key在本地数据库中必须唯一。- 同一作业只能有一个提交者持有租约。
- 提交前已完成 JSONL Schema、记录编号唯一性和文件哈希校验。
- 请求超时后先查远端,不能立即重发。
- 供应商支持 Metadata 时,将
job_key写入远端作业,便于反查。
结果对账:终态不是完成,集合闭合才是完成
设 Manifest 中的预期记录集合为 E,成功结果为 S,终态失败为 F:
missing = E − (S ∪ F)
unknown = (S ∪ F) − E
duplicate = 出现次数大于 1 的 record_id
只有同时满足以下条件,作业才能进入业务完成状态:
| 条件 | 含义 |
|---|---|
missing = ∅ | 无遗漏记录 |
unknown = ∅ | 无未知记录 |
duplicate = ∅ | 无重复记录 |
| ` | E |
供应商的作业状态只能作为”可以开始最终对账”的信号,不能直接替代这些集合条件。
对账器必须同时解析成功与失败通道:
def reconcile(expected_ids, output_rows, error_rows):
success = index_unique(output_rows, key="record_id")
failed = index_unique(error_rows, key="record_id")
seen = set(success) | set(failed)
missing = expected_ids - seen
unknown = seen - expected_ids
duplicate = find_duplicates(output_rows + error_rows)
return {
"success": success,
"failed": failed,
"missing": missing,
"unknown": unknown,
"duplicate": duplicate,
}
| 供应商 | 成功通道 | 失败通道 |
|---|---|---|
| OpenAI | 输出文件 + 成功计数 | 错误文件 + 失败计数 |
| Amazon Bedrock | modelOutput + manifest.json.out | error 字段 |
| Gemini | 正常响应 | 错误响应或过期无结果 |
分片重试:重试记录,不重放作业
失败记录应先分类:
| 类别 | 说明 | 处理方式 |
|---|---|---|
| 永久失败 | Schema 错误、内容超限、权限拒绝 | 人工处置 |
| 可重试失败 | 超时、临时错误、容量不足 | 纳入重试分片 |
| 结果不合格 | 响应成功但不满足业务质量门禁 | 纳入重试分片 |
| 缺失记录 | 输入存在但输出通道均无记录 | 纳入重试分片 |
新的重试分片只包含可重试失败、结果不合格和缺失记录,并保留关联字段:
{
"record_id": "stable-business-id",
"attempt": 2,
"parent_job_key": "sha256:...",
"parent_shard_id": "part-00017",
"retry_reason": "request_timeout"
}
不要为每次重试生成完全无关的随机记录编号,否则系统无法判断这是同一业务记录的再次执行。
重试还应设置:
- 最大尝试次数:防止无限重试
- 指数退避或下一批次窗口
- 错误码白名单:只重试已知可恢复的错误
- 单记录累计 Token 和费用上限
- 人工处置队列:超出上限后转入人工
- 跨供应商回退时的输出契约校验
取消、过期与部分结果
取消不等于”没有结果”。
| 供应商 | 取消/过期行为 |
|---|---|
| OpenAI | 已完成部分仍可能出现在输出文件中 |
| Amazon Bedrock | 已处理 Token 仍会计费 |
| Gemini | 区分取消和过期,过期作业可能无结果可取 |
取消后的正确动作:
- 冻结新的结果写入窗口。
- 下载现有输出和错误文件。
- 运行完整对账。
- 已成功记录继续验收,不重复提交。
- 仅将缺失或可重试失败放入补偿批次。
- 记录取消时刻、已处理数量和费用快照。
成本与审计:费用必须落到记录和尝试代次
批量接口的折扣不能替代成本治理。每条结果应记录:
| 字段 | 说明 |
|---|---|
record_id | 稳定业务编号 |
attempt | 第几次尝试 |
provider_job_id | 供应商作业 ID |
provider_request_id | 供应商请求 ID |
| 输入/输出/缓存 Token | 用量明细 |
| 供应商返回的模型版本 | 精确版本号 |
| 状态 | 成功、失败、取消或过期 |
| 单条估算费用 | 用于成本归因 |
| 结果文件与原始响应的哈希 | 审计溯源 |
成本报表至少要区分:
- 首次成功成本
- 可重试失败消耗
- 重复提交成本
- 质量门禁失败成本
- 人工补偿成本
只有能识别”同一记录被执行了几次”,才能真正发现批量作业中的重复计费。
适用场景
该方案适用于:
- 大规模离线内容抽取和分类
- 夜间评测与安全回归
- 历史数据摘要和结构化回填
- Embedding 或多模态特征批量生成
- 数据清洗、合成数据和训练集构建
不适用于需要秒级响应、强交互或必须立即失败反馈的在线请求。
常见误区
用 Batch ID 作为唯一幂等键
Batch ID 由供应商创建成功后才返回,无法保护”服务端创建成功但客户端没有收到响应”的窗口。
默认输出顺序等于输入顺序
部分平台明确不保证顺序。即使某个平台当前保持顺序,也应按稳定记录编号关联,以便未来迁移。
作业成功就直接覆盖业务表
必须先完成记录级集合对账、Schema 校验和质量门禁,再发布结果。
一条失败,整批重跑
这会重复消费已经成功的记录。正确方式是生成失败子集的重试分片。
只保存最终文本,不保存原始响应
没有原始响应、请求编号、用量和错误码,就无法审计、申诉费用或复现问题。
使用随机 UUID,无法识别业务重复
随机 UUID 可以保证唯一,却不能表达同一业务输入的稳定身份。应使用确定性 Record ID,并另设 Attempt ID。
上线检查清单
- Manifest 已持久化并包含输入文件哈希
- Record ID 在作业内唯一,并由稳定业务字段派生
- 本地
job_key有数据库唯一约束 - 提交过程有租约、Outbox 或分布式锁
- 创建超时后会先查询远端作业
- 成功、错误、取消和过期结果都会进入对账器
- 对账按 Record ID,不依赖文件行号
- 缺失、未知和重复集合都有告警
- 重试只包含失败子集,并保留父作业与 Attempt
- 结果文件在供应商过期前自动归档
- 每条记录可追踪 Token、费用和供应商请求 ID
- 取消作业后仍会下载并验收部分结果
- 灰度环境演练过提交超时、重复回调、输出乱序和结果文件缺失