このインターフェースは、私の「単語一括翻訳システム」が安定して稼働していることを示しています。
- 翻訳ジョブが2件実行中
- 翻訳ジョブが546件スケジュール待ち
- 翻訳ジョブが1.6k件完了済み
- 青色の棒グラフはジョブが正常に完了したことを示し、黄色の棒グラフは失敗したが最大リトライ回数に達していないため、後ほど再試行されることを意味します
安定
今の私の気持ちはまさにこれです。残りの546件も最終的にすべてうまく完了することがわかっているので、夜も安心して眠れます。
25万語の日本語単語を翻訳待ち
前回のブログ「17751部の日本文学作品には何語あるのか?」で、114,010語の日本語単語を抽出しました。
さらに他のソースから14万語を追加し、現在25万語の翻訳待ち単語があります。
テキスト翻訳については、もはや機械翻訳ではなくLLMを使うべきです。最近のLLMの翻訳品質は非常に高く、機械翻訳を大きく上回っています。
しかし、私はこれに費用をかけたくありません。
そこで、無料で使えるLLM APIを利用することにしました。調査の結果、Mistral AI のサービス(モデル:mistral-medium-3-1-25-08)を採用しました。すべてのモデルを無料で利用できますが、いくつかの制限があります。
- 1秒あたり最大リクエスト数:1
- 1分あたり最大トークン数:500,000
- 1か月あたり最大トークン数:1,000,000,000
実際に使用すると、これらの制限のほかに「3505 error(Service tier capacity exceeded for this model.)」が発生することがあります。これは、無料ユーザーに割り当てられているGPUリソースが限られているためと思われます。
これは「APIを呼び出せば終わり」という単純な話ではない
「単語をグループ化してAPIを順番に呼び出すだけでは?」と思う人もいるでしょう。
実際には、そう簡単ではありません。
理想的にはそうですが、現実はもっと複雑です。
私は以下の課題に直面しました。
- LLM APIがエラー(Code 500、Code 429など)を返す場合があるため、失敗したジョブを再実行するリトライ機構が必要
- LLM APIが成功(Code 200)を返しても、出力が期待する形式(有効なJSON)でない場合がある。こうした不正データ対策が必要
- 翻訳効率を上げるための並列処理
- 単語数が多く、無料APIの処理速度も低いため、長時間の実行が必要。その間にプログラムが中断する可能性(PCシャットダウンやWi-Fi切断など)があるため、データと状態の永続化・中断復旧が必要
これらの課題をすべて考慮して解決したプログラムこそが「堅牢なシステム」と言えます。
単なるスクリプトではなく、もはや完全なシステムです。
適切なツールを選べば、仕事はシンプルになる
上記の課題は、ほとんど「ジョブ管理システム」の領域に属します。
したがって、適切なジョブ管理ツールを選べばよいのです。たとえば、Sidekiq(Ruby)やCelery(Python)などです。
Elixirの場合、間違いなく選ぶべきは Oban です。
Obanには上記4つ以外にも多数の機能があります。
SidekiqやCeleryも強力ですが、Obanの絶対的な強みは PostgreSQL(MySQL、SQLite3も対応)を利用して動作する点 です。
Redisを前提とするSidekiqやCeleryとは異なり、データベースをそのまま活用できます。
私はすでにPhoenixアプリとPostgreSQLを使っています。
そこに「単語一括翻訳システム」を追加するのは簡単で、mix.exs に依存関係を1行追加するだけです。Redisを新たに導入する必要はありません。
Obanのインストール手順には他にも設定事項があるので、使用前に必ずドキュメントを確認しましょう。
{:oban, "~> 2.20"}
次に、Workerモジュールを追加し、翻訳ロジックを実装します。
defmodule Dokuya.Jobs.TranslateJob do
use Oban.Worker, queue: :translate, max_attempts: 100, unique: true
@impl Oban.Worker
def perform(%Oban.Job{
args: %{
"words" => words
}
}) do
case chat(words) do
{:ok,
%{
choices: [
%{
"finish_reason" => "stop",
"message" => %{
"content" => content
}
}
]
}} ->
JSON.decode!(content)
File.write!(
"./output/#{DateTime.utc_now() |> DateTime.to_string()}.json",
content
)
{:error, reason} ->
{:error, reason}
end
end
@impl Worker
def backoff(_) do
1
end
defp chat(input_json) do
MistralClient.chat(
model: "mistral-medium-2508",
temperature: 0,
prompt_mode: nil,
messages: [
%{
"role" => "user",
"content" => """
你是一名精确的日语到中文术语翻译助手。只返回符合给定 JSON Schema 的内容,不要输出任何额外文字或解释。
将下列日语词语精准翻译成简体中文,务必遵守:
1. meaning 字段 ≤6 个汉字,可用常见缩写或固定搭配。
2. 译文需自然、易懂,保留原义;不确定时写“待确认”。
3. 保持顺序与输入一致,text 字段原样照抄。
4. 仅输出 JSON,禁止附加说明、代码块或额外文本。
5. 不要添加和删除原始日文词语,保持数量相同。
输入(JSON 数组,每项为待翻译文本):
#{input_json}
"""
}
],
response_format: %{
type: "json_schema",
json_schema: %{
name: "translation",
description: "日语词语与中文含义(<=6个字符)的对应数组",
strict: true,
schema: %{
title: "translation_list",
type: "array",
items: %{
type: "object",
properties: %{
text: %{
title: "text",
type: "string"
},
meaning: %{
title: "meaning",
type: "string"
}
},
required: ["text", "meaning"],
additionalProperties: false
}
}
}
}
)
end
end
このコードはシンプルですが、実際にはLLM APIを呼び出しているだけです。
その背後での「堅牢性」を担保しているのはObanです。👏
データの妥当性確認は、保存前に JSON.decode!(content) を1行追加するだけ。
もし返却データが不正なJSONなら例外が発生し、ジョブは失敗として扱われ、Obanが自動的に再試行します。
それでも失敗しても大丈夫。最大リトライ回数を100に設定してあります。😂
ジョブの登録コードも非常に簡潔で、Elixirのパイプ演算子によって可読性も抜群です。
alias Dokuya.Jobs.TranslateJob
alias Dokuya.Repo
"words.txt"
|> File.read!
|> String.split(",", trim: true)
|> Enum.chunk_every(100)
|> Enum.map(fn words ->
%{
words: words
}
|> TranslateJob.new
|> Repo.insert
end)
適切なツールを選べば、仕事は本当にシンプルになる。
