本地模型无法取代译者。不过对于简短的界面字符串,它能免费给出像样的草稿,而且你的文本永远不会离开本机。大部分工作在模型之外:提供上下文、保护占位符、审阅结果。
安装模型
使用 Ollama 时,一条命令即可下载模型,之后本地服务器会监听 http://localhost:11434:
ollama pull gemma3:12b
一个 120 亿参数的模型,使用默认的量化版本,可以在 16 GB 内存中运行。在配置较低的电脑上,gemma3:4b 能处理简单的字符串,但长句的错误会更多。
使用 LM Studio 时,在应用中选择模型并启动本地服务器:它提供兼容 OpenAI 的 API,默认地址为 http://localhost:1234/v1。
模型需要知道什么
单独发送 “Share list”,模型只能去猜它是按钮、标题还是动词。有三样东西能带来明显的改善:
- 键的描述,前提是你在 ARB 文件中填写了
@key.description,这也是填写它的最好理由。 - 应用的上下文:“家庭购物清单应用,语气平实,法语中用非正式的 tu 称呼”。
- 格式规则:保持每个
{placeholder}和 ICU 语法不变,只回复译文。
同时把 temperature 设为 0,让模型给出最可能的译法。
一个入门脚本
这个 Dart 脚本使用 Ollama,逐个翻译 app_fr.arb 中缺失(或为空)的键。它不会改动你的 ARB 文件:草稿写入单独的文件,占位符发生变化的回复会被搁置。
import 'dart:convert';
import 'dart:io';
const model = 'gemma3:12b';
final placeholder = RegExp(r'\{[A-Za-z_]\w*\}');
Future<void> main() async {
final source = _read('lib/l10n/app_en.arb');
final target = _read('lib/l10n/app_fr.arb');
final drafts = <String, String>{};
for (final key in source.keys.where((k) => !k.startsWith('@'))) {
if ((target[key] as String?)?.isNotEmpty ?? false) continue;
final text = source[key] as String;
final description = (source['@$key'] as Map?)?['description'] ?? '';
final draft = await _translate(text, description as String);
if (_placeholders(draft) != _placeholders(text)) {
stderr.writeln('$key: placeholders changed, redo by hand');
continue;
}
drafts[key] = draft;
}
File('app_fr.drafts.json').writeAsStringSync(const JsonEncoder.withIndent(' ').convert(drafts));
print('${drafts.length} drafts in app_fr.drafts.json');
}
Map<String, dynamic> _read(String path) => jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
String _placeholders(String text) => (placeholder.allMatches(text).map((m) => m[0]!).toList()..sort()).join(',');
Future<String> _translate(String text, String description) async {
final client = HttpClient();
final request = await client.postUrl(Uri.parse('http://localhost:11434/api/generate'));
request.headers.contentType = ContentType.json;
request.write(jsonEncode({
'model': model,
'stream': false,
'options': {'temperature': 0},
'prompt': 'Translate this mobile app string from English to French. '
'Keep every {placeholder} and the ICU plural/select syntax unchanged. '
'Context: $description\n'
'Reply with the translation only.\n\n$text',
}));
final response = await request.close();
final body = jsonDecode(await response.transform(utf8.decoder).join()) as Map<String, dynamic>;
client.close();
return (body['response'] as String).trim();
}
在提示词中加入你的应用上下文;如果目标语言不是法语(例如简体中文),也请相应修改文件名和提示词。
容易出错的地方
ICU 复数。这是薄弱环节。模型可能会翻译 other 关键字、丢掉某个分支,或者只在一个分支中重命名 {count}。脚本的占位符比较能发现其中一部分;其余的需要你逐个审阅每条复数消息。还要留意 =1 分支,在法语中它也适用于零(详见 ICU 复数指南)。
专有名称。你的应用名、付费套餐名、功能名:模型很喜欢把它们也翻译掉。在提示词中把它们列为需要保留的术语,事后再检查一遍。
一致性。每条字符串都是单独翻译的,所以 “item” 可能这里译成 “article”,那里译成 “élément”。在提示词中加一个简短的术语表,就能解决大部分问题。
长度。法语和德语通常比英语长。在英语中放得下的按钮可能会溢出;只有在屏幕上看了才知道。
使用 LM Studio
只需修改请求部分。其 API 遵循 OpenAI 格式:把消息列表发送到 /v1/chat/completions,然后从响应中读取 choices[0].message.content。脚本的其余部分保持不变。
先审阅,再合并
把草稿文件和 app_en.arb 并排打开,逐个键检查。把好的译文复制到 app_fr.arb,重新运行 flutter gen-l10n,然后查看相关的界面。LocalePilot 把这个流程自动化了:来自 Ollama 或 LM Studio 的建议会以“待审阅的 AI 草稿”出现在工作区中,质量检查会比对占位符,在导出之前不会向你的文件写入任何内容。