AI 工具

Together AI Batch API:批量提交 JSONL 并下载结果

大量不着急的请求(比如几千条用户反馈的摘要)适合用 Batch API:上传一个 JSONL 文件,异步处理,完成后下载结果文件。

BINBHO · 图文教程

大量不着急的请求(比如几千条用户反馈的摘要)适合用 Batch API:上传一个 JSONL 文件,异步处理,完成后下载结果文件。 这篇做 batch_tool.py 三个子命令:prepare 把 CSV 变成符合官方格式的 JSONL,check 在上传前查出重复 id、超长 id、超大行和坏 JSON,reconcile 按 custom_id 把输出文件和错误文件对回原始顺序,生成 CSV。

先看清成品

  • prepare 和 check 在 box 上真实运行;故意做坏的文件被查出 2 个问题。
  • 用无效 Key 真实调用官方上传接口,返回 401。
  • reconcile 用我手写的模拟输出/错误文件(标明了是模拟的,格式按官方示例)演示:只看输出文件会漏掉 1 条失败。5 项 pytest 通过。

合规提醒:只用 Together AI 官方 API 和你自己的 API Key,Key 只放环境变量或服务端,不共享账号。按 token 计费,价格以 together.ai/pricing 为准,本文不写价格。每个开源模型有自己的许可证,商用前看模型页面;处理用户消息时注意个人信息保护,不把别人的隐私数据发给第三方服务而不告知。

运行说明:我没有 Together API Key,没有对 Together 发出过成功的请求(也没产生费用)。真实跑过的是:用无效 Key 调用官方接口(2026-10-21 21:44–21:46,UTC+8,返回原样贴在文中),以及全部本地代码。Batch 的输出和错误文件是我按官方示例格式手写的模拟数据,放在 mock/ 目录并有说明。

做完之后你会得到什么?

成果 说明
batch_tool.py prepare / check / reconcile
submit.sh 检查后上传
feedback.csv 4 条示例反馈(我编的)
mock/ 模拟输出和错误文件
test_batch_tool.py 5 项测试

需要准备哪些环境?

项目 说明
Together AI 账号 在 https://api.together.ai/settings/api-keys 创建 API Key
Python 3.9 以上
环境变量 export TOGETHER_API_KEY=...
依赖 只用标准库;测试需 pip install pytest

关键事实

事实 来源
每行 {"custom_id": …, "body": {…}};custom_id 每个文件内唯一,最长 64 字符 官方 Batch 教程
每行要小于 10MB;超大行在校验阶段发现不了,会在处理时报 "error reading input file" 同上
上传 purpose=batch-api;REST 直传时 multipart 里要带 file_name 同上
创建批次时 endpoint 填 /v1/chat/completions 同上
状态 VALIDATING、IN_PROGRESS、COMPLETED、FAILED、EXPIRED(教程页另列 CANCELLED);建议 30–60 秒轮询一次 官方 管理批次
COMPLETED 的批次也可能有失败行,要同时下载 output_file_id 和 error_file_id;输出行顺序与输入不同,按 custom_id 对应 同上
VALIDATING 或 IN_PROGRESS 时可以取消;已完成的请求仍然计费 同上

文件结构

86-together-ai-B-src/
├── batch_tool.py
├── submit.sh
├── feedback.csv
├── batch.jsonl          # prepare 生成,测试会用到
├── mock/
│   ├── README.md
│   ├── output.jsonl
│   └── errors.jsonl
└── test_batch_tool.py

第 1 步:示例 CSV

id,text
1,物流太慢了,等了一周才到
2,客服回复很快,问题当场解决
3,包装还行,东西和描述一致
4,第二次买了,质量稳定

第 2 步:工具脚本

"""Together Batch API 的本地三件套:prepare(生成 JSONL)、check(提交前校验)、reconcile(按 custom_id 对账)。
规则来自 docs.together.ai 的 Batch 文档:custom_id 唯一且 ≤64 字符;每行 < 10MB(超大行在校验阶段不会被发现,
会在处理时报 "error reading input file");输出顺序与输入不同,必须按 custom_id 对;COMPLETED 也可能有失败行,
要同时下载 output_file_id 和 error_file_id。"""
import argparse, csv, json, sys

MAX_LINE = 10 * 1024 * 1024


def prepare(csv_path, out_path, model, prompt_tpl):
    n = 0
    with open(csv_path, encoding="utf-8") as f, open(out_path, "w", encoding="utf-8") as o:
        for row in csv.DictReader(f):
            body = {"model": model, "max_tokens": 200, "temperature": 0,
                    "messages": [{"role": "user", "content": prompt_tpl.format(**row)}]}
            o.write(json.dumps({"custom_id": f"row-{row['id']}", "body": body}, ensure_ascii=False) + "\n")
            n += 1
    return n


def check(path):
    errs, seen = [], set()
    with open(path, "rb") as f:
        for i, raw in enumerate(f, 1):
            if len(raw) >= MAX_LINE:
                errs.append(f"第{i}行 {len(raw)} 字节,超过 10MB 上限"); continue
            try:
                obj = json.loads(raw)
            except json.JSONDecodeError as e:
                errs.append(f"第{i}行不是合法 JSON:{e.msg}"); continue
            cid = obj.get("custom_id")
            if not isinstance(cid, str) or not cid:
                errs.append(f"第{i}行缺 custom_id")
            elif len(cid) > 64:
                errs.append(f"第{i}行 custom_id 超过 64 字符")
            elif cid in seen:
                errs.append(f"第{i}行 custom_id 重复:{cid}")
            else:
                seen.add(cid)
            body = obj.get("body")
            if not isinstance(body, dict) or "model" not in body or not body.get("messages"):
                errs.append(f"第{i}行 body 缺 model 或 messages")
    return len(seen), errs


def reconcile(input_path, output_path, error_path=None):
    wanted = [json.loads(l)["custom_id"] for l in open(input_path, encoding="utf-8") if l.strip()]
    ok, failed = {}, {}
    for l in open(output_path, encoding="utf-8"):
        if l.strip():
            o = json.loads(l)
            ok[o["custom_id"]] = o["response"]["body"]["choices"][0]["message"]["content"]
    if error_path:
        for l in open(error_path, encoding="utf-8"):
            if l.strip():
                o = json.loads(l)
                failed[o["custom_id"]] = f'{o["error"].get("code")}: {o["error"].get("message")}'
    missing = [c for c in wanted if c not in ok and c not in failed]
    return [(c, ok.get(c), failed.get(c)) for c in wanted], missing


def main(argv=None):
    ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd", required=True)
    p = sub.add_parser("prepare"); p.add_argument("csv"); p.add_argument("out")
    p.add_argument("--model", default="Qwen/Qwen3.5-9B")
    p.add_argument("--prompt", default="用一句话总结这条用户反馈,并判断情绪(正面/负面/中性):{text}")
    c = sub.add_parser("check"); c.add_argument("jsonl")
    r = sub.add_parser("reconcile"); r.add_argument("input"); r.add_argument("output"); r.add_argument("--errors")
    r.add_argument("--csv", default="results.csv")
    a = ap.parse_args(argv)
    if a.cmd == "prepare":
        print(f"写入 {prepare(a.csv, a.out, a.model, a.prompt)} 行 → {a.out}"); return 0
    if a.cmd == "check":
        n, errs = check(a.jsonl)
        for e in errs: print("✗", e)
        print(f"{n} 个有效 custom_id,{len(errs)} 个问题"); return 1 if errs else 0
    rows, missing = reconcile(a.input, a.output, a.errors)
    with open(a.csv, "w", encoding="utf-8-sig", newline="") as f:
        w = csv.writer(f); w.writerow(["custom_id", "result", "error"]); w.writerows(rows)
    nok = sum(1 for _, o, _ in rows if o); nerr = sum(1 for _, _, e in rows if e)
    print(f"成功 {nok},失败 {nerr},缺失 {len(missing)} → {a.csv}")
    for m in missing: print("  缺失:", m)
    return 1 if (nerr or missing) else 0


if __name__ == "__main__":
    sys.exit(main())

第 3 步:上传脚本

#!/usr/bin/env bash
# 先本地 check 通过再上传;字段按 docs.together.ai Batch 教程的 cURL 示例(REST 直传需带 file_name)
set -euo pipefail
F=${1:-batch.jsonl}
: "${TOGETHER_API_KEY:?先 export TOGETHER_API_KEY}"
python batch_tool.py check "$F"
curl -sS -X POST https://api.together.ai/v1/files/upload \
  -H "Authorization: Bearer $TOGETHER_API_KEY" \
  -F purpose=batch-api -F "file_name=$(basename "$F")" -F "file=@$F"
echo
echo "拿到 file id 后:curl -X POST https://api.together.ai/v1/batches -H 'Authorization: Bearer …' -H 'Content-Type: application/json' -d '{\"input_file_id\":\"<id>\",\"endpoint\":\"/v1/chat/completions\"}'"

第 4 步:模拟输出(我写的)

{"id":"mock-1","custom_id":"row-3","response":{"status_code":200,"body":{"choices":[{"message":{"role":"assistant","content":"包装尚可、与描述一致;中性"}}]}}}
{"id":"mock-2","custom_id":"row-1","response":{"status_code":200,"body":{"choices":[{"message":{"role":"assistant","content":"抱怨物流慢;负面"}}]}}}
{"id":"mock-3","custom_id":"row-2","response":{"status_code":200,"body":{"choices":[{"message":{"role":"assistant","content":"客服快速解决问题;正面"}}]}}}{"custom_id":"row-4","error":{"message":"Invalid model specified","code":"invalid_model"}}

第 5 步:测试

import json, batch_tool as bt

def w(p, lines): p.write_text("".join(l + "\n" for l in lines), encoding="utf-8"); return str(p)

def test_prepare_and_check(tmp_path):
    c = tmp_path / "a.csv"; c.write_text("id,text\n1,好\n2,差\n", encoding="utf-8")
    out = tmp_path / "b.jsonl"
    assert bt.prepare(str(c), str(out), "m", "{text}") == 2
    n, errs = bt.check(str(out)); assert n == 2 and errs == []
    first = json.loads(out.read_text(encoding="utf-8").splitlines()[0])
    assert first["custom_id"] == "row-1" and first["body"]["messages"][0]["content"] == "好"

def test_check_flags(tmp_path):
    good = '{"custom_id":"a","body":{"model":"m","messages":[{"role":"user","content":"x"}]}}'
    p = w(tmp_path / "x.jsonl", [good, good, '{"custom_id":"' + "z"*65 + '","body":{"model":"m","messages":[1]}}',
                                  '{"custom_id":"b","body":{}}', "{oops"])
    _, errs = bt.check(p); s = " ".join(errs)
    assert "重复" in s and "64" in s and "缺 model" in s and "合法 JSON" in s

def test_oversized_line(tmp_path):
    p = tmp_path / "big.jsonl"; p.write_bytes(b'{"custom_id":"a","body":"' + b"x" * bt.MAX_LINE + b'"}\n')
    _, errs = bt.check(str(p)); assert "10MB" in errs[0]

def test_reconcile_order_and_errors():
    rows, missing = bt.reconcile("batch.jsonl", "mock/output.jsonl", "mock/errors.jsonl")
    assert [r[0] for r in rows] == ["row-1", "row-2", "row-3", "row-4"]
    assert rows[0][1].endswith("负面") and rows[3][2].startswith("invalid_model")
    assert missing == []

def test_reconcile_without_error_file_reports_missing():
    _, missing = bt.reconcile("batch.jsonl", "mock/output.jsonl")
    assert missing == ["row-4"]

第 6 步:运行

$ python batch_tool.py prepare feedback.csv batch.jsonl
写入 4 行 → batch.jsonl
$ python batch_tool.py check batch.jsonl
4 个有效 custom_id,0 个问题

# 故意做一个坏文件:重复一行 + 截断的 JSON
$ python batch_tool.py check bad.jsonl
✗ 第2行 custom_id 重复:row-1
✗ 第3行不是合法 JSON:Expecting ',' delimiter
1 个有效 custom_id,2 个问题

# 真实上传,无效 Key(2026-10-21 21:46 UTC+8)
$ curl -s -H 'Authorization: Bearer invalid-demo' -F purpose=batch-api -F file=@batch.jsonl https://api.together.ai/v1/files/upload
{"error":{"message":"Invalid API key provided. You can find your API key at https://api.together.xyz/settings/api-keys.","type":"invalid_request_error"}}   ← HTTP 401

# 对账(模拟文件)
$ python batch_tool.py reconcile batch.jsonl mock/output.jsonl
成功 3,失败 0,缺失 1 → results.csv
  缺失: row-4
$ python batch_tool.py reconcile batch.jsonl mock/output.jsonl --errors mock/errors.jsonl
成功 3,失败 1,缺失 0 → results.csv
$ cat results.csv
custom_id,result,error
row-1,抱怨物流慢;负面,
row-2,客服快速解决问题;正面,
row-3,包装尚可、与描述一致;中性,
row-4,,invalid_model: Invalid model specified
$ python -m pytest -q
5 passed

怎么检查结果?

  • results.csv 按原始输入顺序排列(模拟输出里是 3、1、2 的乱序)。
  • 没给 --errors 时,失败的那条显示为“缺失”,退出码 1,提醒你还有一个文件没下载。
  • 真实使用时,先用 5–10 行的小文件走一遍上传→创建→下载,再放大。

调试示例

1. 只看 output 文件会漏掉失败。 上面第一次 reconcile 没带 --errors:输出文件里只有 3 行,如果代码只遍历输出文件,得到的就是 3 条“成功”,row-4 的失败悄无声息。官方管理页明确说 COMPLETED 的批次也可能有失败行。所以 reconcile 以输入文件的 custom_id 为准逐个找,既不在输出也不在错误文件里的列为“缺失”,退出码为 1——运行结果里那行“缺失:row-4”就是它起的作用。

2. 两个接口的错误信息指向不同域名。 真实的 401:chat/completions 返回的提示是 api.together.ai/settings/api-keys,带 code: invalid_api_key;files/upload 返回的是 api.together.xyz/settings/api-keys,而且没有 code 字段。所以代码只看 HTTP 状态码,不依赖 code 字段判断错误类型。

3. 文档里的状态列表不一致。 管理页的状态表没有 CANCELLED,教程页有。轮询代码要把 CANCELLED 也当作终态,否则取消后会一直轮询。

常见问题

现象 原因 处理
401 Key 错误 重新创建 Key
error reading input file 某行超过 10MB 上传前跑 check
结果条数对不上 没下载错误文件 reconcile 带 --errors
批次 EXPIRED 未在时限内完成 拆小批次重提

模板规则

  • 上传前本地校验。
  • custom_id 用业务 id 生成,保证唯一。
  • 同时下载输出和错误文件。
  • 按 custom_id 对账,不按行号。

项目资源

用途 地址
Together AI 快速入门(官方) https://docs.together.ai/docs/quickstart
Together AI OpenAI 兼容说明(官方) https://docs.together.ai/docs/inference/openai-compatibility
Together AI API Keys 页面 https://api.together.ai/settings/api-keys
Together AI 定价 https://www.together.ai/pricing
Together AI Batch 概览(官方) https://docs.together.ai/docs/inference/batch/overview
Together AI Batch 教程(官方) https://docs.together.ai/docs/inference/batch/tutorial
Together AI 管理批次(官方) https://docs.together.ai/docs/inference/batch/manage

批处理的关键不在提交,在对账:每一条都要有着落。