国际化链路里,文案的提取、同步、回填通常都有工具链兜着,真正卡节奏的是人工翻译:一批文案翻成 30 种语言,交给翻译平台排期,快则几小时,慢则几天。业务方等不及,于是想到交给 AI——后台调 GPT,一次性出全部语种。
听起来是个非常简单就能搞定的事。实际上前后改了三四版,每版上线都被生产环境顶出一类新问题。
这篇文章就是介绍我的踩坑之路。
背景
- 内容:每次翻译 30–50 条业务文案,短的是 “Sign in” 这种按钮文本,长的是 200 字的规则说明
- 语言:30+ 种,大语种(英中日韩)和一些相对冷门的语种(缅甸、马拉地、印地、孟加拉等)
- 链路:用户点”翻译”按钮 → 前端调接口 → 后端调 OpenAI → 返回结果
v1:一把梭
第一版最朴素——所有文案 + 所有语种塞进一个 prompt,丢给 GPT:
const systemPrompt = `你是一个翻译助手。用户会给你一组 JSON 键值对,
key 的格式是 {语言代码}_{序号},value 是要翻译的英文原文。
请将每个 value 翻译成 key 对应的语言,保持 JSON 格式原样返回。`
// 把所有文案 × 所有语种拼成一个大 JSON 丢过去
const payload = {}
for (const lang of targetLangs) {
entries.forEach((text, i) => {
payload[`${lang}_${i}`] = text
})
}
const response = await openai.chat.completions.create({
model: 'gpt-5.4-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: JSON.stringify(payload, null, 2) },
],
})
const result = JSON.parse(response.choices[0].message.content)
本地自测没问题——10 条文案、1 个语种,秒级返回。上线后直接翻车:生产环境一次 50 条文案 × 30+ 语言,网关 30s 超时把请求掐了。
v2:异步化
超时好办,改异步:
- 前端点翻译 → 后端创建任务,立刻返回 taskId
- 后端异步跑翻译
- 前端轮询 taskId 拿结果
超时解决了,但新问题开始冒。上线后陆续收到四类反馈:
- 翻译成功了,但结果全是英文
- 偶尔报 JSON 解析错误
看着毫无关联,其实归两类原因。
全返英文:26 个语种塞进同一个 prompt,上下文太多,AI 不见得表现更好——实践中它会直接把英文原样吐回来,而不是”尽力而为”。
JSON 解析错误当时查了一圈没完全定位,只确认和”某些语种”相关。后来在 v3 加了详细日志才真正搞清楚——根因是相对冷门的语言 + 输入里的换行制表符,下面会讲。
到这一步已经能看出核心矛盾:一个请求塞太多东西,AI 扛不住。
v3:拆开来打
v2 的问题归结起来就是一锅炖太多。v3 的思路是彻底拆散:
- 语种太多 AI 会懵 → 按语言拆开,每个语种独立请求
- 单次文案太多容易出错 → 每 10 条一个批次
- AI 偶尔抽风 → 加重试
- JSON 偶尔损坏 → 加修复
整体结构变成两层并发:
translate(texts, targetLangs)
│
├─ 语言并行(Promise.all)──────────────────────
│ │ │ │
│ ▼ ▼ ▼
│ ja ko vi
│ ├─ batch 0 ─┐ ├─ batch 0 ─┐ ├─ batch 0 ─┐
│ ├─ batch 1 ─┤ ├─ batch 1 ─┤ ├─ batch 1 ─┤
│ └─ batch 2 ─┘ └─ batch 2 ─┘ └─ batch 2 ─┘
│ │ │ │
│ ▼ ▼ ▼
│ 批次并行 批次并行 批次并行
│
└─ 合并结果 → { ja: [...], ko: [...], vi: [...] }
30 种语言 × 5 个批次 = 150 个请求几乎同时发出,总耗时回到 15–30 秒。
单个批次内部的策略:
第 1-2 次尝试 → mini 模型(快、便宜)
↓ 失败
第 3 次尝试 → 好一点的模型(稳定、贵)+ JSON 修复
↓ 失败
抛异常,整个批次标记失败
每次尝试都做三件事:调模型、解析 JSON、校验数组长度。最后一条不能省——AI 偶尔会漏翻(输入 30 条返回 28 条),不校验长度就会导致译文和原文错位,比翻译不准更严重。
代码大致长这样:
for (let attempt = 0; attempt < 3; attempt++) {
const isLastAttempt = attempt === 2
const response = await fetchWithRetry(url, {
data: {
model: isLastAttempt ? 'gpt-5.4' : 'gpt-5.4-mini',
messages: [systemMsg, userMsg],
},
})
let arr
try {
arr = JSON.parse(response.data.choices[0].message.content)
} catch {
if (isLastAttempt) arr = JSON.parse(cleanAndFixJson(text))
else continue
}
if (!Array.isArray(arr) || arr.length !== expected) {
if (isLastAttempt) throw new Error('length mismatch after 3 attempts')
continue
}
return arr
}
v3 上线后头几天很稳,几乎没收到反馈。然后问题从一个意想不到的方向冒出来了。
相对冷门的语言集体翻车
上线大概一周,开始零星收到反馈说翻译结果有问题。一开始以为是随机的 AI 抽风,后来翻日志发现了规律:**出错的请求,语种几乎全集中在 hi / bn / th / mr**——印地、孟加拉、泰语、马拉地。英日韩法德西基本不出问题,这几个小语种却稳定翻车。
症状也很一致:
- 丢文案(输入 30,返回 28)
- 缺逗号、引号不闭合,JSON 非法
- 非法转义符
\n\t\\x\\a - 极个别情况下输出被截断
再进一步排查,发现了另一个规律:只有输入文案里带换行或制表符时,这几个语种才会出问题。纯文本句子翻得好好的。
根因想通之后不复杂:JSON 字符串里 \n \t 必须以转义形式存在。模型在生成输出时,要决定这一位写”真实换行”还是”字面量 \n“——英中日韩这种训练语料里大量见过 JSON 的语种,几乎不会搞混;但印地、孟加拉、泰语这种训练占比低的语言,模型会在两种写法之间摇摆。一旦裸换行落在字符串中间,整个 JSON 就废了。
本质是特殊字符 + 小语种训练覆盖不足的叠加效应。v2 里那些”空字符串”和”JSON 解析错误”也终于有了解释——当时多语种混在一起,这些小语种的问题被淹没在大量正常输出里,不容易看出规律。
逐条翻译兜底
到这里,”三次重试 + JSON 修复”已经不够——问题不是偶发的,而是结构性的。降级链得再往下延一级:扛不住的批次,不走 JSON,一条一条翻:
async function translateSingleEntry(text, lang) {
const response = await fetchWithRetry(url, {
data: {
model: 'gpt-5.4-mini',
messages: [
{ role: 'system', content: `你是翻译助手,只返回翻译结果,不要其他内容。` },
{ role: 'user', content: `请将以下英文翻译成 ${lang}:\n${text}` },
],
},
})
return response.data.choices[0].message.content.trim()
}
这个 prompt 里一个字都没提 JSON,只让 AI 做一件事:翻译。
这里有个值得单独拎出来的认知:JSON 结构本身就是一种噪声。输入一旦复杂(长句、特殊字符、相对冷门的语言),让 AI 同时兼顾”翻对”和”输出合法 JSON”,成功率会明显下降;把这两件事拆开,翻译成功率几乎 100%,代价只是多调几次接口。
实测数据:31 条文案 × 27 种语言 = 837 条翻译,拆成 108 个批次。一次任务里平均:
- 4 次第一次 JSON 解析失败(重试救回)
- 2 次数组长度不匹配(重试救回)
- 1 次触发逐条翻译(兜底救回)
- 0 次英文原文兜底
英文兜底几乎没触发过,但必须留着——它保证的是”不管多极端,调用方拿到的数组长度一定对”。
Prompt 踩过的坑
v3 能稳下来,prompt 本身也改了不少。下面几条都是先反着试过、不行才改回来的。
自定义分隔符比 JSON 更差
发现转义符问题后,第一反应是不用 JSON 了,输入输出都用 ||| 分隔,把转义问题整个绕过去。
改完反而更差——要么全返英文,要么一半英文一半目标语种。回头想也说得通:JSON 的严格语法在某种程度上”逼” AI 认真对待每一个元素,结构约束一旦没了,AI 很容易半路开小差。
带 index 的 key 会让 AI 困惑
最初用过 {lang}_{index}: value 格式:
{ "ja_0": "ログイン", "ja_1": "登録", "ko_0": "로그인", "ko_1": "가입" }
直觉上挺合理,但 AI 在这种 schema 下经常乱:漏 key、index 跳号、语言代码写错。后来换成最朴素的数组 ["翻译1", "翻译2", "翻译3"],稳定性立刻上来了。AI 只需要维持一个维度(顺序),不用同时盯语言和序号。结构越简单,AI 越不容易出错。
语言代码要写全名
my ms id fil 这些短代码 AI 经常不认识:my 理解成”我的”,ms 理解成 Microsoft,id 理解成 identifier。
解法是维护映射表,prompt 里用全名:
const LANG_NAME_MAP = {
my: 'Burmese',
ms: 'Malay',
id: 'Indonesian',
fil: 'Filipino',
// ...
}
写”请翻译成 Burmese”而不是”请翻译成 my”。对容易混淆的语种(马来/印尼、简体/繁体),再加一对示例:
示例(英语翻译成 Burmese):
- ["Sign in"] => ["ဝင်ရန်"]
- ["Sign in", "Sign up"] => ["ဝင်ရန်", "ဆိုင်းအပ်"]
三版对比
| 版本 | 核心方案 | 暴露的问题 |
|---|---|---|
| v1 | 所有文案 + 所有语种打包一次请求 | 网关 30s 超时 |
| v2 | 异步任务 + 前端轮询 | 多语种混请 → AI 偷懒;输出不稳 → JSON 损坏 / 空串 |
| v3 | 按语种并行 + 分批 + 重试 + JSON 修复 | 相对冷门的语言 + 特殊字符 → 结构性 JSON 失败 |
| v3+ | 逐条翻译兜底 + 英文原文终极兜底 | 目前稳定运行 |
每版不是”方案有问题”才被换掉,而是上一层的坑填了,下一层的坑才露出来:v1 卡在 HTTP 层;v2 绕过 HTTP 后撞到 AI 输出层;v3 处理完输出层,又冒出语种维度的系统性差异。
几条可以迁移的经验
上下文越多,AI 不见得表现更好。同时让它做 N 件事,可能一件都做不好。拆任务往往比优化 prompt 更有效。
结构化输出是双刃剑。JSON 的严格性正常情况下帮你校验,输入一复杂就容易反过来变成出错源。简单任务上结构化省心,难的任务上反而是包袱——该拆就拆。
兜底链要延伸到”不会失败”的那一层。这个项目里这一层是英文原文——它就是输入本身,保证调用方拿到的数组长度一定对。
最后
完整代码不到 300 行 TypeScript,开源在 ai-batch-translate,pnpm install && pnpm tsx example.ts 可以直接跑。目前线上跑了一个多月,累计翻了大概 5 万条文案,没再收到翻译相关的反馈。
做这类”用 AI 干某件具体事情”的项目,比较实际的做法就是先最朴素地接进去,上线后根据生产环境的反馈一版一版迭代。设计阶段能想到的问题,往往不如线上暴露出来的具体。