用 Ollama 或 LM Studio 翻译 Flutter 应用

更新于 · LocalePilot

在你自己电脑上运行的模型可以为 ARB 文件起草译文,无需订阅,也不会把文本发送给在线服务。本文介绍安装配置、一个入门 Dart 脚本,以及模型容易出错的地方。

本地模型无法取代译者。不过对于简短的界面字符串,它能免费给出像样的草稿,而且你的文本永远不会离开本机。大部分工作在模型之外:提供上下文、保护占位符、审阅结果。

安装模型

使用 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 草稿”出现在工作区中,质量检查会比对占位符,在导出之前不会向你的文件写入任何内容。

常见问题

翻译应用字符串应该用哪个模型?

像 gemma3:12b 这样较新的 12B 通用模型,翻译短字符串能给出不错的草稿。在配置较低的电脑上,4B 版本也能用,只是错误更多。

我的文本会离开我的电脑吗?

不会。Ollama 和 LM Studio 在本地运行模型;请求发往 localhost。

可以直接写入 ARB 文件吗?

最好先写入一个草稿文件,审阅后再合并:模型可能会破坏 ICU 复数,或者把品牌名也翻译了。