基于大模型的 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 中。"
}
它不是不能用,而是维护成本会随着项目变大而上升。几个很现实的痛点:
- 代码和文案分离:读到
features.localeFiles.title,必须跳到语言文件才知道实际写的是什么; - 大模型翻译缺上下文:模型拿到的是一个 Key 和一句源文案,根本判断不出这个「删除」是删除数据,还是把用户移出分组;
- 一词多译不好表达:中文「删除」在英文里可能是
Delete,也可能是Remove,一个语义 Key 只能绑一个译文; - 缺 Key、多 Key 难发现:代码里用了但 JSON 没有,或者 JSON 里有但代码早就不用了,全靠人肉对;
- 跨框架复用困难: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 只约束两件事:
- 语言 JSON 的格式;
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 会做三件校验:
- Key 集合完全一致,不能少、不能多;
- 每个 value 都是非空字符串;
- 源文和译文的占位符 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-target | Key 已存在,但还没有译文 |
⟦REVIEW⟧ 译文 | trans | AI 已翻译,等待人工复核 |
| 无标记 | 人工 / 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_URL | OpenAI-compatible 接口地址,需要支持 POST /chat/completions |
I18N_LLM_API_KEY | API Key,发送时放在 Authorization: Bearer <key> |
I18N_LLM_MODEL | 模型名,比如 deepseek-chat、gpt-4o-mini |
还有三个可选变量:
| 变量 | 默认值 | 说明 |
|---|---|---|
I18N_LLM_TIMEOUT_MS | 60000 | 单次请求超时时间(毫秒) |
I18N_LLM_MAX_RETRIES | 1 | 单批翻译失败后的重试次数 |
I18N_LLM_JSON_MODE | true | 请求 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 拆成三件事:
- 数据格式:源语言原文即 Key,扁平 JSON;
- 运行时契约:
t()的查找顺序和__TRANS_ID; - 工作流:CLI 负责扫描、补齐、翻译,人工负责复核。
运行时留给你自己的框架,翻译交给大模型,质量由标记和校验兜底。
项目地址:
- GitHub:github.com/ALiuYiLin/i…
- npm:www.npmjs.com/package/@10…
- 契约文档:github.com/ALiuYiLin/i…
如果你也在维护多语言项目,欢迎试试这套流程,或者把它改成适合你团队的样子。评论区也聊聊:你们现在的 i18n 是怎么搞的?