国际化链路里,文案的提取、同步、回填通常都有工具链兜着,真正卡节奏的是人工翻译:一批文案翻成 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:异步化

超时好办,改异步:

  1. 前端点翻译 → 后端创建任务,立刻返回 taskId
  2. 后端异步跑翻译
  3. 前端轮询 taskId 拿结果

超时解决了,但新问题开始冒。上线后陆续收到四类反馈:

  • 翻译成功了,但结果全是英文
  • 偶尔报 JSON 解析错误

看着毫无关联,其实归两类原因。

全返英文:26 个语种塞进同一个 prompt,上下文太多,AI 不见得表现更好——实践中它会直接把英文原样吐回来,而不是”尽力而为”。

JSON 解析错误当时查了一圈没完全定位,只确认和”某些语种”相关。后来在 v3 加了详细日志才真正搞清楚——根因是相对冷门的语言 + 输入里的换行制表符,下面会讲。

到这一步已经能看出核心矛盾:一个请求塞太多东西,AI 扛不住

v3:拆开来打

v2 的问题归结起来就是一锅炖太多。v3 的思路是彻底拆散:

  1. 语种太多 AI 会懵 → 按语言拆开,每个语种独立请求
  2. 单次文案太多容易出错 → 每 10 条一个批次
  3. AI 偶尔抽风 → 加重试
  4. 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**——印地、孟加拉、泰语、马拉地。英日韩法德西基本不出问题,这几个小语种却稳定翻车。

症状也很一致:

  1. 丢文案(输入 30,返回 28)
  2. 缺逗号、引号不闭合,JSON 非法
  3. 非法转义符 \n \t \\x \\a
  4. 极个别情况下输出被截断

再进一步排查,发现了另一个规律:只有输入文案里带换行或制表符时,这几个语种才会出问题。纯文本句子翻得好好的。

根因想通之后不复杂: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 处理完输出层,又冒出语种维度的系统性差异。

几条可以迁移的经验

  1. 上下文越多,AI 不见得表现更好。同时让它做 N 件事,可能一件都做不好。拆任务往往比优化 prompt 更有效。

  2. 结构化输出是双刃剑。JSON 的严格性正常情况下帮你校验,输入一复杂就容易反过来变成出错源。简单任务上结构化省心,难的任务上反而是包袱——该拆就拆。

  3. 兜底链要延伸到”不会失败”的那一层。这个项目里这一层是英文原文——它就是输入本身,保证调用方拿到的数组长度一定对。

最后

完整代码不到 300 行 TypeScript,开源在 ai-batch-translatepnpm install && pnpm tsx example.ts 可以直接跑。目前线上跑了一个多月,累计翻了大概 5 万条文案,没再收到翻译相关的反馈。

做这类”用 AI 干某件具体事情”的项目,比较实际的做法就是先最朴素地接进去,上线后根据生产环境的反馈一版一版迭代。设计阶段能想到的问题,往往不如线上暴露出来的具体。