site logo

Marico's space

用 4 个并行 agent 将你的应用本地化为 4 种语言,然后脚本化关键检查

前端技术 2026-11-09 11:28:03 8

最近折腾多语言本地化,踩了几个坑:这篇把 4 个并行 agent(智能体)怎么分工,以及关键检查怎么脚本化讲清楚。

  • 书籍:AI That Answers
  • 系列:AI in TypeScript——5 本书,从第一次调用 LLM(大型语言模型)一直讲到生产环境中的 agent——5 本都在这里
  • 我的项目:Hermes IDE | GitHub——一款面向使用 Claude Code 等 AI(人工智能)编程工具的开发者、帮助他们交付代码的 IDE(集成开发环境)
  • 我:xgabriel.com | GitHub

翻译本来就是一件很适合交给编程 agent 的事。Claude Code 概览页就拿它当脚本示例:claude -p "translate new strings into French and raise a PR for review"。

PR(拉取请求)返回后,你打开差异视图。迎面而来的是一大片法语,每一行看起来都没问题,但你无法确认 {count} 是否全都保留了下来,因为你的法语还没好到能一眼看出哪一条字符串里的占位符名称也被翻译了。接下来还要对德语、日语和波兰语重复同样的检查。

与其在差异视图里逐行找问题,不如用一个短脚本把隐藏的坑揪出来。这篇会搭起 4 个 agent,每个 agent 负责一种语言,各自在独立分支上工作,再给它们配上一项检查,精准拦截那些容易漏掉的错误。

为什么翻译任务特别适合并行拆分

大多数交给并行 agent 的任务都会在某个地方产生重叠:共享类型、配置文件,或者测试辅助函数。翻译是个例外,前提是每种语言只对应一个文件:

locales/ en.json <- source, written by you de.json es.json ja.json pl.json

德语 agent 只写 de.json,波兰语 agent 只写 pl.json。4 个分支各改各的文件,无论按什么顺序合并都不会冲突。唯一的共享文件是 en.json,除了你没人会改它。

源文件使用 ICU(Unicode 国际化组件)MessageFormat,这是 FormatJS 系列库所读取的语法。文件结构尽量扁平,每条字符串对应一个 key:

{ "cart.title": "Your cart", "cart.greeting": "Welcome back, {name}", "cart.items": "{count, plural, one {# item} other {# items}}", "checkout.total": "Total: {total, number}"
}

{name} 和 {count} 是代码传入的参数。FormatJS 文档说得很直接:“消息模式中的每个参数都必须提供值……”,缺少参数值可能导致运行时格式化错误。

如果波兰语 agent 把 {count} 改名成 {liczba},代码仍然只会传 count。结果是英语页面正常,波兰语购物车直接坏掉。

Four parallel rails leaving one station and rejoining at another, each carrying one sealed envelope, with no crossings
一个源文件,四条互不交叉的分支,最后在一个位置汇合。

第 1 步:一份任务说明,拆成四个任务

给每个 agent 相同的任务说明,只替换 locale(语言区域)代码:

Translate locales/en.json into <LOCALE>.
Write only locales/<LOCALE>.json. Edit no other file.
Keep every key. Add no keys.
Keep every {placeholder} name exactly as in English.
Keep plural, select and number arguments the same type.
Give each plural every category Intl.PluralRules has
for <LOCALE>.
Done when `node scripts/check-locales.mjs <LOCALE>` exits 0.
Commit with the message "i18n: <LOCALE>".

任务说明里直接写明检查命令,agent 就不能只靠一句“翻译完了”交差,它得亲自把检查跑通。

如果直接使用原生 Git(分布式版本控制系统),每个 agent 都需要独立的 worktree(工作树):基于同一个仓库创建不同分支和不同工作目录。

for lc in de es ja pl; do git worktree add -b "i18n/$lc" "../app-$lc"
done

在每个目录里启动一个 agent,粘贴任务说明,然后把 4 个终端同时摆在眼前。

我在开发 Hermes IDE,所以讲这一段时请先带着这个前提。Hermes 里,按 ⌘N(Windows 和 Linux 上是 Ctrl+Shift+N)可以新建任务。每个任务都会获得独立的 hermes/<name> 分支和独立 worktree,任务说明则作为 agent 的第一条提示词。你可以为每个任务分别选择 agent、模型和推理强度:德语、西班牙语可以交给 Claude Code,日语、波兰语可以交给 Codex。4 个会话各自显示状态,⌘I 可以直接跳到等待你最久的那个 agent。

The Hermes New task sheet with a task typed, chips for Claude Code, the project, a new hermes/ worktree, Accept edits, model and effort, and the command preview
⌘N 新任务面板:Claude Code、项目、新建的 hermes/ worktree、接受编辑、模型、推理强度,以及 claude 命令的预览。

Hermes 新建的 worktree 几秒钟内就能装好依赖,因此每个工作树里都能直接运行检查脚本。每个仓库的具体配置写在 worktree recipes 文档里。

The Hermes Review Desk Worktrees tab listing one Hermes worktree each for three projects, with branch, task, age and disk size
Review Desk 的 Worktrees 标签页:orders-api、web-app 和 orders-types 三个项目各有一个 Hermes worktree,并展示分支、任务、运行时长、磁盘占用和剩余磁盘空间。

第 2 步:编写检查脚本

这个脚本会将每种语言与 en.json 对比,并拦截四类错误:key 缺失或内容为空、出现多余 key、占位符被改名、删除或改变类型,以及复数形式缺少当前语言必须使用的类别。

脚本直接使用 FormatJS 官方解析器 @formatjs/icu-messageformat-parser,目前版本为 3.5.22:

npm install --save-dev @formatjs/icu-messageformat-parser
// scripts/check-locales.mjs
// usage: node scripts/check-locales.mjs [locale ...]
import { readFileSync, readdirSync } from "node:fs";
import { parse, TYPE } from "@formatjs/icu-messageformat-parser";
import { isStructurallySame } from "@formatjs/icu-messageformat-parser/manipulator.js"; const DIR = "locales";
const SOURCE = "en";
const load = (lc) => JSON.parse(readFileSync(`${DIR}/${lc}.json`, "utf8")); const source = load(SOURCE);
const args = process.argv.slice(2);
const locales = args.length ? args : readdirSync(DIR) .filter((f) => f.endsWith(".json")) .map((f) => f.slice(0, -".json".length)) .filter((lc) => lc !== SOURCE); let problems = 0;
const fail = (lc, key, msg) => { problems++; console.error(`${lc} ${key}: ${msg}`);
}; // every plural, including ones nested in select or tags
function plurals(ast, found = []) { for (const el of ast) { if (el.type === TYPE.plural) found.push(el); for (const opt of Object.values(el.options ?? {})) { plurals(opt.value, found); } if (el.children) plurals(el.children, found); } return found;
} for (const lc of locales) { const target = load(lc); for (const key of Object.keys(target)) { if (!(key in source)) fail(lc, key, "not in en.json"); } for (const [key, text] of Object.entries(source)) { const t = target[key]; if (typeof t !== "string" || !t.trim()) { fail(lc, key, "missing or empty"); continue; } try { const ast = parse(t); const same = isStructurallySame(parse(text), ast); if (!same.success) fail(lc, key, same.error?.message); for (const p of plurals(ast)) { const type = p.pluralType; const { pluralCategories } = new Intl.PluralRules(lc, { type }).resolvedOptions(); for (const cat of pluralCategories) { if (!(cat in p.options)) { fail(lc, key, `{${p.value}} lacks "${cat}"`); } } } } catch (err) { fail(lc, key, err.message); } }
} console.error(problems ? `${problems} problem(s)` : "locales OK");
process.exit(problems ? 1 : 0);

整套逻辑建立在三个库函数上,每个函数的实现都有文档可查:

  • parse() 遇到损坏的 ICU 语法会抛出 SyntaxError,例如大括号没有闭合;默认情况下,它也会拒绝缺少 other 分支的复数表达式。
  • isStructurallySame() 会收集两条消息中的变量,在变量数量不同、名称缺失或类型改变时判定失败。如果 agent 把英语里的 {total, number} 写成 {total},这就属于类型变化,脚本会直接报错。
  • Intl.PluralRules(...).resolvedOptions().pluralCategories 会直接从运行时返回某种语言所使用的复数类别。

最后一项几乎不可能靠肉眼完成。CLDR(Unicode 通用语言环境资料库)的复数规则显示,英语和德语都有 one 与 other,日语只有 other。波兰语有 one、few、many 和 other。西班牙语有 one、many 和 other,其中 many 覆盖整百万这样的数量。

所以,下面这条波兰语内容在差异视图里看着没问题,却会同时触发两个错误:

"cart.items": "{liczba, plural, one {# produkt} other {# produktów}}"

变量被改名了,因此 isStructurallySame 会报告缺少 count。同时,这个复数表达式没有 few 和 many 分支,界面会显示“2 produktów”,而波兰语在这里应该使用“2 produkty”。

正确写法如下。为了便于阅读,这里拆成了几行,实际放在 pl.json 中是一条完整字符串:

{count, plural, one {# produkt} few {# produkty} many {# produktów} other {# produktu}}

复数规则故意设得很严格,它会逼着西班牙语 agent 补上 many 分支。如果业务里的数量永远到不了一百万,可以取消对 es 的这项要求,但要把例外直接改在脚本里,让规则留在代码中,而不是只存在于某个人的记忆里。

如果语言文件不存在,readFileSync 会直接抛错,Node.js 也会以非零状态码退出,因此这种情况同样会被判定为失败。

第 3 步:让脚本充当“完成”标准

任务说明要求 agent 运行检查,但更好的做法是:无论 agent 记不记得,检查都应该自动执行。

在 Hermes 中,可以为每个任务配置自己的完成检查。把对应语言的参数填进去:

node scripts/check-locales.mjs pl

记得传入 locale。如果不传参数,脚本会检查目录里的全部语言文件。但当时位于波兰语 worktree 中,德语、西班牙语和日语文件尚未翻译,此时全量检查会因为错误的原因失败。

检查失败后,把输出直接贴回给 agent。每一行都包含语言、key 和具体问题,已经足够作为一轮精确的返工说明。

不用 Hermes 也没关系。同一个脚本可以放进 pre-commit hook(提交前钩子),或者交给 CI(持续集成)任务执行。无论采用哪种方式,都应该让检查结果为红时阻止合并。

第 4 步:依次合入四个分支

合并前先确认 agent 是否只改了分配给它的文件。使用三点式命令,它只会列出分支从 main 分叉之后修改过的内容:

git diff --name-only main...i18n/pl

理想结果只有一行:locales/pl.json。如果还有别的文件,就说明 agent 动了共享文件,也意味着这里可能产生合并冲突。

接下来按顺序合入:

git switch main
for lc in de es ja pl; do git merge --no-edit "i18n/$lc" || break
done
node scripts/check-locales.mjs

第一次合并可能是 fast-forward(快进合并),后面三次会生成 merge commit(合并提交)。由于每个分支只带进一个无人修改过的文件,所以不会冲突。|| break 会在任意一次合并失败时中止循环。最后一行会检查 main 上的全部语言。

这次全量检查很有必要:agent 工作期间,en.json 可能新增了 key。每个语言分支都只针对自己拿到的旧版本通过了检查,只有 main 上才包含最新 key。

在 Hermes 中,可以选择 Land commits、创建 PR,或者把修改直接合并回任务启动时所在的分支,而且操作可以撤销。4 个任务逐个落地,最后再在 main 上运行一次完整检查即可。

脚本查不出来的那些问题

它检查的是结构,不是语义。一条德语内容可以完全通过所有规则,却依然生硬、正式得不像你的产品,或者长度是按钮容量的两倍。占位符相同的日语译文是好是坏,脚本也判断不了。

翻译平台的 QA(质量保证)机制处理的也是同一层问题。Crowdin 的 QA checks 会自动运行“Placeholders mismatch”和“ICU syntax”检查,并把有问题的字符串标记给翻译人员。让脚本接管机械检查,你或母语者就能把注意力集中在措辞上。

基础版本跑通后,还有两个扩展值得加:一是翻译结果与英语完全相同时直接失败,但可以为“OK”和品牌名维护一份允许列表;二是当译文长度超过英语原文两倍时发出提示,交给 UI(用户界面)人员复核。

如果这篇内容对你有用

这里的检查对待 agent 输出的方式,其实也适用于任何模型输出:先解析,再与预期结果比较,失败时明确报错。AI That Answers 是我的 AI in TypeScript 系列第一本书,从第一次调用 LLM 开始,就讲这种习惯:如何写提示词、如何验证结构化输出,以及每次调用要付出多少成本。

如果下一步是在自己的 Node.js 应用里实现翻译接口,而不是在终端里运行 agent,可以先从这本书入手。

AI That Answers — AI in TypeScript

整个系列从第一次调用 LLM 一直讲到生产环境中的 agent,都可以到 xgabriel.com/ai-in-typescript 阅读。

如果想在一个窗口里同时管理这 4 个 agent,Hermes IDE 可以免费下载,源码可用,采用 BSL 1.1(Business Source License 1.1)授权,支持 macOS、Windows 和 Linux。它与 Nous Research 或其 Hermes Agent 没有从属关系。代码托管在 GitHub。