文章

LLM 批量推理作业生产实战:用 Manifest、幂等 Record ID 与分片重试避免重复计费和漏结果

面向异步大批量调用,系统讲解如何以作业清单、确定性记录编号、逐条结果对账和失败分片重试,构建可恢复、可审计、不会因超时重提而重复计费或遗漏结果的批量推理流水线。

背景问题:批量 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

关键约束不是这段代码本身,而是以下保障:

  1. job_key 在本地数据库中必须唯一
  2. 同一作业只能有一个提交者持有租约
  3. 提交前已完成 JSONL Schema、记录编号唯一性和文件哈希校验
  4. 请求超时后先查远端,不能立即重发。
  5. 供应商支持 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 BedrockmodelOutput + manifest.json.outerror 字段
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区分取消和过期,过期作业可能无结果可取

取消后的正确动作:

  1. 冻结新的结果写入窗口。
  2. 下载现有输出和错误文件。
  3. 运行完整对账。
  4. 已成功记录继续验收,不重复提交。
  5. 仅将缺失或可重试失败放入补偿批次。
  6. 记录取消时刻、已处理数量和费用快照。

成本与审计:费用必须落到记录和尝试代次

批量接口的折扣不能替代成本治理。每条结果应记录:

字段说明
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
  • 取消作业后仍会下载并验收部分结果
  • 灰度环境演练过提交超时、重复回调、输出乱序和结果文件缺失

参考资料

常见问题

批量作业状态为成功,是否代表每条记录都成功?
不代表。作业级终态只能说明调度流程结束,仍需逐条核对成功、失败、缺失和重复记录,并与输入 Manifest 的预期集合对账。
批量作业提交超时后,可以直接重新提交吗?
不应直接重提。应先依据本地提交账本、输入文件哈希和供应商作业列表确认是否已经创建成功,否则可能生成第二个作业并产生重复费用。
失败重试应该重新提交整个批次吗?
通常不应该。应从对账结果中只提取可重试失败和缺失记录,生成新的重试分片,并保留父记录编号和重试代次。
为什么不能把输入行号直接作为 Record ID?
分片、合并、过滤和重试都会改变行号。稳定编号应来自业务主键、源数据版本、Prompt 版本和模型配置,确保跨文件和跨尝试仍能关联。