批量改写和即时问答混在同一条调用链上,出问题的往往不是模型本身,而是两类请求的节奏对不上:批量任务可以排队、可以重试、失败能重跑;即时问答不能等,重试代价高,结果断了就是一次交互失败。比较稳的做法是把它们拆成两条独立链路,批量改写交给 Ling-3.1-flash 作为离线任务跑,即时问答留在原来的交互链路里,各自的提交方式、并发限制和结果落盘都分开设计。
批量改写与即时问答拆成两条链路,要点是输入有稳定清单、输出有落盘记录、限流有独立配额。批量侧用文件清单驱动脚本逐条提交,成功写 done.jsonl、失败写 fail.jsonl,重跑按 id 跳过已完成条目;交互侧保持原有超时与并发不变。两侧各自设限,先动批量并发,再根据交互侧延迟和超时日志决定下一步。适用于能接受分钟级延迟的离线改写,不适合要求实时返回的场景。
把待改写内容整理成本地文件清单
批量任务要能重跑,输入就不能是聊天框里的临时粘贴。建议把待改写内容整理成一份本地清单文件,由脚本读取,清单本身当作可追溯的输入保存。
约定可以先定成下面这样,字段含义一旦确定就不要中途改:
- 文件名固定为 manifest.tsv,放在任务目录下,和脚本同级
- 文件编码 UTF-8 且不带 BOM,换行用 LF,避免从 Excel 另存后变成 GBK 或带 BOM
- 字段用制表符分隔,顺序为:id、源文件路径、目标语言、待改写文本
- id 要求唯一且稳定,用源文件名的哈希前缀或固定序号都行,同一批任务重跑时不能变
q7f3a1 article/0001.md zh 把下面这段改写得更简洁……
b21c08 article/0002.md en Rewrite the paragraph below……
示例里列与列之间是制表符,复制时注意编辑器不要把它替换成空格。整理完先校验一遍再跑,几条命令就够:
wc -l manifest.tsv # 行数是否等于预期条数
awk -F'\t' 'NF!=4 {print NR, NF}' manifest.tsv # 输出为空说明每条都是 4 列
cut -f1 manifest.tsv | sort | uniq -d # 输出为空说明 id 没有重复
file -i manifest.tsv # 期望是 utf-8
head -c 3 manifest.tsv | xxd # 前 3 字节不是 ef bb bf,说明没有 BOM
sha256sum manifest.tsv > manifest.sha256 # 留档,方便日后对齐结果版本
需要留意的是,清单一旦开始跑就只追加新行,不要改动已有行的 id 或列含义,否则断点续跑时的 id 映射会错位,已经跑完的结果也没法和输入对上。
写一个逐条提交并落盘的批量脚本
脚本的作用是把手工重复操作变成可重复执行的任务,位置建议放在任务目录里,和 manifest.tsv 同级。下面是一份通用骨架,接口地址、请求字段名、返回结果里的字段名都要以官方文档为准,替换掉占位部分再跑。
# bulk_rewrite.py
import os, json, time, pathlib, requests
ENDPOINT = os.environ['LING_API_ENDPOINT'] # 以官方文档为准
MODEL = 'Ling-3.1-flash'
SRC = pathlib.Path('manifest.tsv')
OUTDIR = pathlib.Path('out'); OUTDIR.mkdir(exist_ok=True)
DONE = pathlib.Path('done.jsonl')
FAIL = pathlib.Path('fail.jsonl')
def finished_ids():
if not DONE.exists():
return set()
return {json.loads(l)['id'] for l in DONE.read_text('utf-8').splitlines() if l.strip()}
def rewrite(text):
r = requests.post(
ENDPOINT,
headers={'Authorization': 'Bearer ' + os.environ['LING_API_KEY']},
json={'model': MODEL, 'input': text},
timeout=(5, 60),
)
r.raise_for_status()
return r.json()['output'] # 取哪个字段以官方文档为准
done = finished_ids()
with SRC.open(encoding='utf-8') as f, \
DONE.open('a', encoding='utf-8') as ok, \
FAIL.open('a', encoding='utf-8') as bad:
for line in f:
line = line.rstrip('\n')
if not line or line.startswith('#'):
continue
rid, src, lang, text = line.split('\t')
if rid in done:
continue
try:
out = rewrite(text)
(OUTDIR / (rid + '.txt')).write_text(out, encoding='utf-8')
ok.write(json.dumps({'id': rid, 'ts': int(time.time())}) + '\n')
except Exception as e:
bad.write(json.dumps({'id': rid, 'err': str(e)[:200]}) + '\n')
time.sleep(1)
执行方式可以是 python bulk_rewrite.py,也可以交给系统定时任务在夜间跑,取决于对延迟的要求。建议先拿清单里两三条跑通,确认返回结构和落盘路径都对,再放全量。结果按 id 命名写到 out/ 目录,好处是和清单一一对应,抽查或补跑时不用再翻日志。
给批量任务加上失败重投与断点续跑
中途断掉是常态,关键是别让中断之后从头再来。
- 成功记录写在 done.jsonl,一行一个 JSON 对象,只包含 id 和时间戳,追加写
- 失败记录写在 fail.jsonl,除 id 外带上错误摘要,同样追加写
- 两个文件都不覆盖、不清空;重跑时先读 done.jsonl 得到已完成 id 集合,脚本里命中就跳过
只重投失败条目时,从 fail.jsonl 抽出 id 生成一份新清单再跑:
jq -r .id fail.jsonl | sort -u > retry_ids.txt
grep -F -f retry_ids.txt manifest.tsv > retry.tsv
python bulk_rewrite.py `--manifest` retry.tsv # 脚本需支持 `--manifest`,默认读 manifest.tsv
重投前先看一眼失败原因,超时、限流、参数错误要区别处理:限流类失败直接重投通常还会失败,中间需要加等待间隔;参数类失败重投多少次都一样。结果文件按 id 命名是覆盖写,同一条重复跑不会留下多份结果,这点比按时间戳命名更好用。判断某条是否真的完成,以 out/ 目录里对应文件存在且非空为准,done.jsonl 只是加速跳过的索引。
把批量请求与交互请求分开限流
两条链路混在一起最直接的后果,是批量任务的并发把即时问答的配额吃掉。隔离可以从三个位置下手。
- 批量侧:并发上限设在脚本进程内,用固定数量的 worker 或信号量控制,值从环境变量 BULK_CONCURRENCY 读取,先设为 1 或 2,跑稳了再考虑往上加
- 交互侧:并发和超时设在服务端网关的路由级限制,或交互服务自己的连接池配置里,这两个位置都不要因为批量任务调整而改动
- 调用凭据:条件允许时给批量任务单独申请一个 key 或项目标识,让服务端配额能按调用方区分,出问题时也容易定位是谁打满的
调整之后要看两类信号:批量脚本日志里每条的起止时间,能看出实际并发有没有贴近上限;交互链路侧的超时数、429 计数和首响应延迟,能看出有没有被挤到。如果交互侧开始出现延迟上升或超时,先把批量并发降一半再观察一轮,而不是直接停批量。整个过程中失败重试要带等待间隔,连续失败时暂停本轮比硬打更划算。
对两边输出做一致性抽查
两条链路用的模型版本、提示词和参数可能不同,输出完全一致并不是目标,需要防的是明显偏差:实体丢失、被截断、语言跑偏、格式损坏。
- 抽样方式:从已完成记录里按固定间隔取,例如每 50 条抽 1 条并在每批至少保留 5 条,批次小的时候全看;具体比例按人工成本确认,不必追求统计意义
- 比对字段:长度差异(明显变短要怀疑截断)、关键实体是否保留(数字、链接、专有名词)、语言是否正确、结构是否闭合(Markdown 标签、JSON 括号)、是否出现不该有的词
- 记录方式:差异写进 review.tsv,字段为 id、批量结果片段、交互结果片段、差异类型、是否需人工处理,先记录再判断,不要边看边改输出
发现差异先归因,再决定动作:提示词或参数不同造成的措辞差异可以忽略;成规模出现同类问题时,先固定其中一条链路的输出作为基准,再调另一条,避免两边同时改动导致无法对比。抽查频率可以随批次稳定程度降低,但只要清单换了版本,就建议重新抽一轮。