基于大模型的 i18n 方案:把中文原文当 Key,轻松搞定多语言维护

0 阅读15分钟

基于大模型的 i18n 方案:把中文原文当 Key,轻松搞定多语言维护

做前端国际化,你一般怎么写文案?

一种是大家最熟悉的语义 Key:

t('features.localeFiles.title')
t('features.localeFiles.description')

另一种是我现在的写法——直接把中文原文传给 t():

t('语言资源文件')
t('翻译存放在 src/locales/*.json 中,键名就是中文原文。')

前者叫语义 Key,后者直接拿中文原文当 Key。

这两种方式没有绝对优劣。但在一类场景里——源语言是中文、要持续用大模型翻译、前端技术栈还不统一——后者帮我解决了很多实际维护问题。

这篇文章就聊聊这套方案的运行时契约、CLI 工作流,以及我在 @10coding/i18n-trans 里做的一些取舍。


传统语义 Key 的几个痛点

语义 Key 的典型结构长这样:

{
  "features.localeFiles.title": "语言资源文件",
  "features.localeFiles.description": "翻译存放在 src/locales/*.json 中。"
}

它不是不能用,而是维护成本会随着项目变大而上升。几个很现实的痛点:

  1. 代码和文案分离:读到 features.localeFiles.title,必须跳到语言文件才知道实际写的是什么;
  2. 大模型翻译缺上下文:模型拿到的是一个 Key 和一句源文案,根本判断不出这个「删除」是删除数据,还是把用户移出分组;
  3. 一词多译不好表达:中文「删除」在英文里可能是 Delete,也可能是 Remove,一个语义 Key 只能绑一个译文;
  4. 缺 Key、多 Key 难发现:代码里用了但 JSON 没有,或者 JSON 里有但代码早就不用了,全靠人肉对;
  5. 跨框架复用困难:React 用 i18next,Vue 用 vue-i18n,Key 规范、插值语法都不一样。

语义 Key 适合大型团队、翻译团队并行、文案和代码严格分离的流程。但如果你的项目是「中文文案就是产品语言、开发者自己维护翻译、希望 AI 参与进来」,它的负担会越来越明显。


核心思路:源语言原文即 Key

这套方案的数据格式非常简单:

  • 源语言文件是一份「原文 → 原文」的映射;
  • 目标语言文件用同一批 Key,值是译文;
  • Key 全部是扁平字符串,不做嵌套。

举个例子:

// src/locales/zh-CN.json
{
  "你好,{name}!": "你好,{name}!",
  "删除": "删除"
}
// src/locales/en-US.json
{
  "你好,{name}!": "Hello, {name}!",
  "删除": "Delete",
  "删除__TRANS_ID_remove": "Remove"
}

代码里这样调用:

t('你好,{name}!', { name: 'Ada' })
t('删除')                    // Delete
t('删除', 'remove')          // Remove

它带来几个直接的好处:

  • 代码即文案,读代码不用来回跳文件;
  • 源文案天然就是大模型的输入,不用额外维护一份「Key → 源文案」映射;
  • 扁平 JSON,增删 Key、Diff 对比都很直接;
  • 同一个中文词,可以通过 __TRANS_ID 表达不同场景下的不同译文。

代价也很明确:修改原文等于修改 Key。原文一变,目标语言里旧的译文就对不上了。这个取舍后面会专门展开。


运行时契约:不提供 runtime 包,只发 CLI

这套方案没有发布 React / Vue 的运行时包,只发了一个 CLI。

原因很简单:运行时环境差异太大。Vite、Webpack、SSR、微前端、多实例、i18next 版本、vue-i18n 配置……封装一个通用 runtime 既难覆盖所有情况,又容易和框架已有的能力打架。

所以 i18n-trans 只约束两件事:

  1. 语言 JSON 的格式;
  2. t() 的调用形态和查找顺序。

t() 的签名大致是:

type Translate = (
  source: string,
  options?: { __TRANS_ID?: string | number; [param: string]: unknown } | string | number,
) => string

查找顺序:

目标语言 <原文>__TRANS_ID_<id>
  → 目标语言 <原文>
  → 源语言 <原文>
  → 原文本身

当当前语言等于源语言时,直接返回原文。

应用侧可以继续用 i18next、vue-i18n,甚至自己写一个几十行的实现——只要遵守这套契约就行。


CLI 到底解决了什么

@10coding/i18n-trans 的核心命令只有三个:

i18n-trans check    # 检查源语言 / 目标语言
i18n-trans check --add --add-target --remove   # 也就是 fix
i18n-trans trans    # 用大模型翻译待翻译项

1. 静态扫描 t() 调用

CLI 用 Babel 静态分析代码里的 t() / translate() 调用,支持:

  • TypeScript / TSX;
  • Vue 单文件组件的 <script> / <script setup>;
  • Vue 模板表达式和指令属性,比如 {{ t('保存') }}、:title="t('删除', 'remove')"。

不依赖运行时,也不需要启动应用,直接放进本地脚本或 CI 都可以。

2. 对比语言 JSON

i18n-trans check

它会报告四类问题:

  • 源语言缺失:代码里用了,但 zh-CN.json 没有;
  • 源语言未使用:JSON 里有,但代码里找不到静态调用或字符串字面量;
  • 目标语言缺失:源码里的 Key 在目标 JSON 里没有对应译文;
  • 目标语言多余:目标 JSON 里有、但源语言已经没有的 Key。

加上参数就能自动修复:

i18n-trans check --add --add-target --remove
  • --add:源语言补 Key,值就是原文本身;
  • --add-target:目标语言补 ⟦TODO⟧ 原文 占位;
  • --remove:删除未使用 / 多余的 Key。

目标语言文件不存在或内容为空也没关系,CLI 会按空 locale 处理,fix 的时候自动创建。

3. 调用大模型翻译

i18n-trans trans

它会把待翻译的 Key 分批发给 OpenAI-compatible 接口,默认每批 20 个,串行执行。

配置放在应用目录的 .env:

I18N_LLM_BASE_URL=https://api.deepseek.com/v1
I18N_LLM_API_KEY=sk-...
I18N_LLM_MODEL=deepseek-chat

OpenAI、DeepSeek 等兼容 /chat/completions 的服务都能直接接。


完整演示:check → fix → check → trans

下面用一个只有 3 个 Key 的小项目,把整套工作流完整走一遍。

src/App.tsx:

export const greeting = t('你好')
export const save = t('保存')
export const remove = t('删除', 'remove')

初始语言文件:

// src/locales/zh-CN.json
{
  "你好": "你好"
}
// src/locales/en-US.json
{
  "你好": "Hello",
  "过期文案": "Old"
}

我故意在这里埋了三个问题:

  • 源语言缺 保存、删除 两个 Key;
  • 目标语言缺 保存、删除 和 删除__TRANS_ID_remove(注意:第一次 check 时源语言还缺前两个,所以目标侧先只报变体;等 fix 把源语言补完,缺失会变成 3 个);
  • 目标语言多了一个 过期文案。

第一步:check

$ i18n-trans check

输出:

i18n check · 1 files scanned · 3 static t() keys

zh-CN (source) · src/locales/zh-CN.json
  ✖ missing 2 key(s)
    - 保存  (src/App.tsx:2)
    - 删除  (src/App.tsx:3)

en-US (target) · src/locales/en-US.json
  ✖ missing 1 translation(s)
    - 删除__TRANS_ID_remove
  ⚠ extra 1 key(s) (not in the source locale)
    - 过期文案

✖ check failed: missing keys remain

几个值得注意的细节:

  • missing 会带上调用位置 src/App.tsx:2,方便直接跳过去改;
  • 删除__TRANS_ID_remove 是从代码里 t('删除', 'remove') 推导出来的变体 Key;
  • 过期文案 在源语言里已经不存在,所以被归为 extra;
  • 有缺失时退出码是 1,正好可以放进 CI 做门禁。

第二步:fix

$ i18n-trans check --add --add-target --remove

输出:

zh-CN (source) · src/locales/zh-CN.json
  ✖ missing 2 key(s)
    - 保存  (src/App.tsx:2)
    - 删除  (src/App.tsx:3)

en-US (target) · src/locales/en-US.json
  ✖ missing 3 translation(s)
    - 保存
    - 删除
    - 删除__TRANS_ID_remove
  ⚠ extra 1 key(s) (not in the source locale)
    - 过期文案

+ zh-CN: added 2 key(s) in src/locales/zh-CN.json
+ en-US: added 3 key(s) in src/locales/en-US.json
- en-US: removed 1 key(s) in src/locales/en-US.json

✔ check passed

注意它是先报告检查结果,再输出实际动作。因为 --add 先把 保存、删除 加进了源语言,目标语言的缺失数量会从 1 变成 3。

文件变化长这样:

// src/locales/zh-CN.json
 {
   "你好": "你好",
+  "保存": "保存",
+  "删除": "删除"
 }
// src/locales/en-US.json
 {
   "你好": "Hello",
-  "过期文案": "Old"
+  "保存": "⟦TODO⟧ 保存",
+  "删除": "⟦TODO⟧ 删除",
+  "删除__TRANS_ID_remove": "⟦TODO⟧ 删除"
 }

这一步做了三件事:

  • --add:源语言补两个 Key,值就是原文;
  • --add-target:目标语言补三个 ⟦TODO⟧ 原文 占位;
  • --remove:删掉目标语言里源语言已经不存在的 过期文案。

其中 ⟦TODO⟧ 表示「Key 已经存在,但还没有译文」——它不是翻译结果,只是一个占位标记。

再跑一次 check,此时两边都干净了:

zh-CN (source) · src/locales/zh-CN.json   ✔ no issues
en-US (target) · src/locales/en-US.json   ✔ no issues
✔ check passed

但要注意:check 能通过,不代表目标语言里有译文。文件里还是 ⟦TODO⟧ 占位,下一步才是翻译。

第三步:trans

先 dry-run,只列出待翻译项,不真的调模型:

$ i18n-trans trans --dry-run
i18n trans · zh-CN → en-US
dry run: 3 unit(s) to translate
[en-US] 3 unit(s)
  - 保存
  - 删除
  - 删除__TRANS_ID_remove

你好 已经有译文 Hello,被自动跳过;剩下三个占位项。

真实执行:

$ i18n-trans trans
i18n trans · zh-CN → en-US
[en-US] 3 unit(s)
[en-US]   ✔ 3 translated

✔ check passed
translated 3, failed 0, skipped 1

目标语言文件变成:

{
  "你好": "Hello",
  "保存": "⟦REVIEW⟧ Save",
  "删除": "⟦REVIEW⟧ Delete",
  "删除__TRANS_ID_remove": "⟦REVIEW⟧ Remove"
}

skipped 1 就是已经翻译过的 你好。新译文默认带上 ⟦REVIEW⟧ 前缀,方便全局搜索、人工复核;不想带标记的话加 --ignore 即可。

上面这次演示为了输出可复现,用的是本地 mock 模型返回固定译文;实际接 DeepSeek / OpenAI 时,请求结构完全一样。


trans 内部:一次翻译请求到底发了什么

trans 不是把整个语言文件丢给模型,而是先做一轮筛选,只为「真正需要翻译的 Key」构造翻译单元。

完整流程:

扫描 t() 调用
  → 推导期望 Key(源语言 Key + __TRANS_ID 变体)
  → 读取目标语言 JSON
  → 找出未翻译项
  → 构造 unit(key / source / transId / context / hint)
  → 按 batchSize 分批
  → POST /chat/completions
  → 解析 result
  → 校验 key、非空、占位符
  → 写回 ⟦REVIEW⟧ 译文
  → 再跑一次 check

哪些 Key 会被送去翻译

满足以下任一条件,就算「未翻译」:

  • 目标语言 JSON 里没有这个 Key;
  • 值是 ⟦TODO⟧ 原文 占位;
  • 值等于源语言原文(兼容旧数据)。

以下情况会被跳过:

  • 已有正常译文;
  • 已经带 ⟦REVIEW⟧ 的 AI 译文(避免重复翻译);
  • translation.ignoreKeys 里显式排除的 Key。

unit 的结构

每个待翻译项会构造成这样:

interface TranslationUnit {
  key: string       // "删除__TRANS_ID_remove"
  source: string   // "删除"
  transId?: string  // "remove"
  context?: string  // 调用处代码片段
  hint?: string     // 手工补充的语义说明
}
  • key:最终写回目标 JSON 的 Key,可能带变体后缀;
  • source:真正交给模型翻译的原文;
  • transId:一词多译的区分标记;
  • context:自动读取的 t() 调用处上下文;
  • hint:i18n.config.json 里针对这个 Key 的手工提示。

system prompt 约束了什么

system 消息主要约束这几件事:

  • 只翻译 source,不要翻译 key;
  • 返回 JSON 对象,格式为 {"results":[{"key":"...","value":"..."}]};
  • Key 必须和输入完全一致,不能增删;
  • 保留 {name} / {{name}} 插值,花括号样式不能变;
  • 保留 HTML 标签、数字、产品名、代码标识符;
  • 结合 context 和 hint 判断词义;
  • 只输出 JSON,不要 markdown 代码围栏或注释。

模型应该返回:

{
  "results": [
    { "key": "保存", "value": "Save" },
    { "key": "删除", "value": "Delete" },
    { "key": "删除__TRANS_ID_remove", "value": "Remove" }
  ]
}

CLI 也兼容直接返回数组、或者普通 key/value map 的写法,但推荐用 results 结构。

响应校验和重试

拿到响应后,CLI 会做三件校验:

  1. Key 集合完全一致,不能少、不能多;
  2. 每个 value 都是非空字符串;
  3. 源文和译文的占位符 token 完全一致,包括 {name} / {{name}} 的花括号样式和出现次数。

任何一项失败都会按 maxRetries 重试;重试仍失败就记录错误,不会把坏数据写进语言文件。

批处理和上下文

  • 默认 batchSize: 20,一次请求最多 20 个 unit;
  • 串行执行,不并发;
  • contextLines 控制自动上下文包含调用行前后多少行;
  • 配置项 hints 可以针对某个 Key 覆盖 / 补充上下文;
  • glossary 用于统一术语。

这样设计是因为:模型一次只处理一小批、带上下文的文本,比直接丢整个 JSON 更稳定,失败时也更容易定位和重试。


一词多译:__TRANS_ID

「删除」这个词很典型:

  • 删除用户数据 → Delete
  • 把用户从角色组里移出 → Remove

在语义 Key 方案里,你通常要造两个 Key。现在可以直接写:

t('删除', 'delete')
t('删除', { __TRANS_ID: 'remove' })

目标语言文件里对应:

{
  "删除": "Delete",
  "删除__TRANS_ID_delete": "Delete",
  "删除__TRANS_ID_remove": "Remove"
}

CLI 会扫描代码里的第二个参数或 __TRANS_ID 配置,自动推导出变体 Key。

更关键的是,翻译时可以把上下文一起带给模型:

  • 自动读取 t() 调用处的 file:line 和附近代码;
  • 通过 i18n.config.json 的 translation.hints 手工补充说明。

比如:

{
  "translation": {
    "hints": {
      "删除__TRANS_ID_remove": "从角色组移出用户,对象仍然存在,不是销毁数据"
    }
  }
}

这样模型翻译「删除」时,就不会统一输出 Delete 了。


用标记做人工复核

大模型翻译最怕的就是「看起来都对,但不敢直接上线」。

这套流程用三种标记区分状态:

标记写入者含义
⟦TODO⟧ 原文check --add-targetKey 已存在,但还没有译文
⟦REVIEW⟧ 译文transAI 已翻译,等待人工复核
无标记人工 / trans --ignore已确认译文

trans 默认写 ⟦REVIEW⟧,而不是直接写干净译文。你可以在编辑器里全局搜索 ⟦REVIEW⟧,逐条检查,确认后把标记删掉就行。

它不会:

  • 重复翻译已经带 ⟦REVIEW⟧ 的值;
  • 覆盖已有译文;
  • 把占位符或插值 token 弄丢(会校验 {name} / {{name}} 的样式和数量)。

如果某些场景不需要复核,加 --ignore 直接写干净译文即可。


插值:单花括号和双花括号都认

不同框架的插值语法不一样:

  • vue-i18n 默认是 {name};
  • i18next 默认是 {{name}}。

CLI 会自动识别源文里用的是哪种写法,并要求译文保留相同的花括号样式:

{
  "你好,{name}!": "Hello, {name}!",
  "Hello, {{name}}!": "你好,{{name}}!"
}

如果模型把 {{name}} 改成了 {name},校验会直接失败并触发重试,避免上线后插值失效。

运行时侧也提供了对应配置示例:

// i18next:如果想让插值风格和 vue-i18n 一致
i18n.init({
  fallbackLng: 'zh-CN',
  keySeparator: false,
  nsSeparator: false,
  interpolation: { prefix: '{', suffix: '}' },
})
// vue-i18n:Key 是原文,可能包含 . 或 /,需要用 messageResolver
const i18n = createI18n({
  legacy: false,
  messageResolver: (obj, path) => (obj as Record<string, unknown>)[path] ?? null,
})

快速开始

安装:

pnpm add -D @10coding/i18n-trans

创建 i18n.config.json:

{
  "include": ["src/**/*.{ts,tsx,vue}"],
  "localesDir": "src/locales",
  "sourceLocale": "zh-CN",
  "targetLocales": ["en-US"],
  "callers": ["t", "translate"],
  "transId": {
    "option": "__TRANS_ID",
    "separator": "__TRANS_ID_"
  }
}

准备源语言文件:

// src/locales/zh-CN.json
{
  "你好,{name}!": "你好,{name}!",
  "删除": "删除"
}

配置翻译模型(只有 trans 需要)

trans 会读取项目根目录(也就是 i18n.config.json 所在目录)的 .env:

# .env
I18N_LLM_BASE_URL=https://api.deepseek.com/v1
I18N_LLM_API_KEY=sk-...
I18N_LLM_MODEL=deepseek-chat

三个必填项:

变量说明
I18N_LLM_BASE_URLOpenAI-compatible 接口地址,需要支持 POST /chat/completions
I18N_LLM_API_KEYAPI Key,发送时放在 Authorization: Bearer <key>
I18N_LLM_MODEL模型名,比如 deepseek-chat、gpt-4o-mini

还有三个可选变量:

变量默认值说明
I18N_LLM_TIMEOUT_MS60000单次请求超时时间(毫秒)
I18N_LLM_MAX_RETRIES1单批翻译失败后的重试次数
I18N_LLM_JSON_MODEtrue请求 response_format: { "type": "json_object" };服务商不支持时设为 false

几个注意事项:

  • .env 必须加进 .gitignore,不要提交真实密钥;
  • 进程环境变量优先于 .env,也可以用 --env-file <path> 指定其他文件;
  • 只跑 check / --dry-run 时不需要 .env,真正调模型才需要;
  • 项目里可以放一份 .env.example 当模板,仓库里的 React / Vue playground 都提供了示例。

跑起来:

# 1. 检查缺 Key
i18n-trans check

# 2. 补源语言 Key + 目标语言占位
i18n-trans check --add --add-target

# 3. 配好 .env 之后翻译
i18n-trans trans

# 4. 再检查一遍
i18n-trans check

如果目标语言文件不存在,第 2 步会自动创建它并写入 ⟦TODO⟧ 占位。


适合谁,不适合谁

比较适合:

  • 源语言是中文,目标语言由开发者自己维护;
  • 项目有一定量级的 UI 文案,缺 Key / 多 Key 已经开始影响维护;
  • 希望大模型参与翻译,但需要人工复核环节兜底;
  • 前后端、多个框架、多个仓库共享同一套翻译流程;
  • 能接受「修改原文 = 修改 Key」这个取舍。

不太适合:

  • 文案和代码需要严格分离,翻译团队需要独立的 Key 目录;
  • 复杂的 ICU 复数、性别、选择规则(当前契约刻意保持简单);
  • 原文会频繁大改,并且不希望触发大量翻译更新;
  • 已经有一套成熟的多语言平台,不需要大模型参与。

几个设计取舍

为什么不用语义 Key

语义 Key 不是错的,只是它更适合「文案和翻译分离」的组织流程。对于开发者自己维护翻译、用大模型辅助的场景,源文案本身就是最好的上下文,语义 Key 反而多了一层映射成本。

为什么不提供 runtime 包

因为运行时差异太大。CLI 只依赖 JSON 格式和 t() 调用形态;应用侧按契约实现 t(),可以继续用 i18next、vue-i18n,甚至自己写。仓库里提供了 React 和 Vue 两套参考实现,但它们只是示例,不作为 npm 包发布。

为什么保留人工复核标记

大模型翻译质量不稳定,直接覆盖线上文案风险太高。⟦REVIEW⟧ 让「AI 翻译」和「人工确认」变成两个明确的步骤——既享受效率,也控制风险。


写在最后

这套方案的核心不是「又一个 i18n 库」,而是把 i18n 拆成三件事:

  1. 数据格式:源语言原文即 Key,扁平 JSON;
  2. 运行时契约:t() 的查找顺序和 __TRANS_ID;
  3. 工作流:CLI 负责扫描、补齐、翻译,人工负责复核。

运行时留给你自己的框架,翻译交给大模型,质量由标记和校验兜底。

项目地址:

如果你也在维护多语言项目,欢迎试试这套流程,或者把它改成适合你团队的样子。评论区也聊聊:你们现在的 i18n 是怎么搞的?