🔬 不器用パパの休日

14倍遅くなった日、Qwen3 32Bで安定した日

前回の記事で、LLMによるOCR誤字修正は「前処理の順番」を整えることで安定した。しかし、もう一つ大きな問題が残っていた——LLMモデルそのものの選定だ。

ローカルLLMはOllamaで動かしている。Ollamaにはたくさんのモデルがある。どれを使うかで、処理速度も品質も全然違う。そして「大きいモデル=良い結果」とは限らなかった。

504ページの小説を29チャンクに分割し、各チャンクをLLMに通す。1チャンクに100秒かかるなら、全体で約44分。でもモデル選びを間違えると、これが何時間にも膨れ上がる。

やったこと

最初の候補:Llama 3.3 70B(OOMで即退場)

最初に試したのは Llama 3.3 70Bllama3.3:70b-instruct-q4_K_M)だった。パラメータ数が多いほど賢いだろう、という単純な発想だ。

結果:OOM(Out of Memory)で起動すらしなかった

model requires more system memory (20.7 GiB) than is available (14.9 GiB)

モデルサイズは42GB。RTX 5090のVRAM 32GBに載り切らない分がホストRAMにオフロードされるが、それでも足りない。

学び:VRAM 32GBでも70Bモデルは厳しい。 量子化(q4_K_M)しても42GBある。Docker環境でOllamaを動かしているので、ホストRAMの割り当ても制限がある。

デフォルト:Llama 3.2 3B(速いが雑)

次に使ったのが Llama 3.2 3B。Ollamaのデフォルトモデルとして設定していた。軽量で高速——だが品質に問題があった。

  • 修正すべきでない箇所を「修正」してしまう
  • 文脈を理解しきれず、OCR誤字と正常な表現の区別がつかない
  • 出力が不安定(同じ入力で異なる結果が出ることがある)

3Bパラメータでは、日本語の文脈理解が浅い。「力ード」が「カード」なのか本当に「力」+「ード」なのか、前後の文脈から判断する能力が足りなかった。

本命:Qwen3 32B(精度は高いが……)

そして Qwen3 32B。20GBでVRAM 32GBに余裕で収まる。日本語の理解力は3Bとは段違いだった。

A/Bテストの結果:

サンプル入力文字数出力文字数比率処理時間
head(冒頭)5,2864,7910.91104.9秒
mid(中盤)4,7094,6320.9869.5秒
tail(終盤)4,3434,2760.98145.5秒

入出力の比率が0.91〜0.98。「95%以上保持」の制約をほぼ守っている。意味の変更なし、説明の混入なし。Qwen3 32Bを正式採用した。

地獄の始まり:thinking機能の暴走

Qwen3 32Bで本番ジョブ(504ページ全量)を回した。最初は順調だった。しかし途中からタイムアウトが頻発し始めた

原因を調べると、Qwen3のthinking機能が犯人だった。

Qwen3には推論過程を <think>...</think> タグで出力する「thinking モード」がある。これが有効だと、LLMは回答の前に「考える」フェーズに入る。数学や論理問題には有用だが、OCR校正には完全に不要だ。

何が起きていたか:

  1. LLMが <think> に入る
  2. OCRテキストについて延々と「考える」(数千トークンを消費)
  3. 600秒のタイムアウトに到達
  4. 空レスポンスが返る
  5. bisect retry(チャンクを半分に分割して再試行)が走る
  6. 分割した片方でまた <think> 暴走……

本番ジョブのTop10遅いチャンクを分析したら、全てが「timeout/空レス → bisect retry」に起因していた

速度測定:thinking ONとOFF

単発テスト(Qwen3 32B、同一入力):

設定処理時間倍率
think=true(デフォルト)11.7秒×14.6
think=false0.8秒×1(基準)
think=false + /no_think1.4秒×1.75

thinking OFFで14.6倍速。 しかも /no_think をプロンプトに追加すると逆に遅くなる。think: false だけでOKだった。

本番ジョブでの効果

504ページ全量処理(Qwen3 32B):

ステップbefore(think=true)after(think=false)削減率
llm_fix(29チャンク)6,008秒(100分)2,024秒(34分)-66%
llm_prep(28チャンク)3,160秒(53分)1,970秒(33分)-38%
Read timed out多数0完全解消
bisect retry多数0完全解消

タイムアウトとリトライが完全にゼロになった。処理時間はllm_fixで66%削減、llm_prepで38%削減。合計で約90分の短縮。

タイムアウトも短縮

thinking抑制でタイムアウト自体が発生しなくなったので、タイムアウト設定も600秒から180秒に短縮した。通常のチャンク処理は70秒前後(Qwen3 32B、think=false)なので、180秒は十分な余裕がある。万が一ハングしても、600秒待たずに3分で検出→bisect再試行できる。

結果

最終構成

LLMモデル     : qwen3:32b(Ollama経由)
thinking     : false(デフォルト)
temperature  : 0.1(低めに設定して安定性重視)
top_p        : 1.0
repeat_penalty: 1.05
chunk_chars  : 6,000文字
num_predict  : 16,384トークン
timeout      : 180秒

モデル選定の結論

モデルサイズ結果
Llama 3.3 70B42GBOOM。VRAM 32GBでは動かない
Llama 3.2 3B2GB速いが品質不足。日本語の文脈理解が浅い
Qwen3 32B20GB採用。精度・速度・VRAM消費のバランスが最良

runtime.allowed.jsonには他にも calm3-22b-chatgemma3:27bqwen2.5-coder:32b などを登録している。用途に応じて切り替えられる設計にしたが、OCR校正にはQwen3 32Bが圧倒的に安定している。

うまくいった点

  • thinking抑制の効果が劇的だった — APIパラメータに think: false を1つ追加するだけで、処理時間が1/14になった。コードの変更は llm_chunk_process.py の1ファイルのみ。最小の変更で最大の効果
  • 4xx fallbackの設計 — 古いバージョンのOllamaは think パラメータを認識しない可能性がある。4xxエラーが返った場合は think パラメータなしで自動リトライする仕組みを入れた。後方互換性を失わずに新機能を使える
  • runtime.allowed.json によるモデル管理 — 使用可能なモデルをホワイトリスト化した。未登録のモデルを指定するとジョブ開始前にブロックされる。「間違ったモデルで100分走ってから気づく」事故を構造的に防いでいる
  • bisect retry の自動化 — タイムアウトしたチャンクを自動的に半分に分割して再試行する仕組みは、thinking暴走への対症療法として機能した。根本対策(think=false)を見つけるまでの間、パイプラインの完全停止を防いでくれた

失敗・課題

  • 70Bモデルへの憧れ — 「大きいモデルほど賢い」は直感的には正しいが、ローカル環境のリソース制約を無視していた。VRAM 32GBで70Bモデルを動かすのは無理がある。環境のスペックシートを先に確認すべきだった
  • thinkingモードの存在を知らなかった — Qwen3にthinking機能があることを、タイムアウトが頻発するまで知らなかった。モデルのドキュメントを事前に読んでいれば、最初からOFFにできた。100分以上の無駄な処理時間を防げたはず
  • /no_think の罠 — プロンプトに /no_think を入れればthinkingが抑制されると思ったが、think: false と併用すると逆に遅くなった。APIパラメータとプロンプト指示は別の仕組みで、両方使うと干渉する
  • デフォルトモデルの危険性llama3.2:3b をデフォルトにしていた時期、モデル指定を忘れたジョブがデフォルトで走り、品質の低い出力を生成していた。現在は「モデル未指定=ジョブ実行拒否」に変更している

次にやること

テキスト処理は安定した。LLMも決まった。次はいよいよだ。

テキストを音声に変換するTTS(Text-to-Speech)エンジンは、実は3つ試した。VOICEVOX、Style-Bert-VITS2、Qwen3-TTS。それぞれに長所と短所があり、「どれが正解」とは言い切れない結果になった。

次回は、3つのTTSエンジンを聴き比べた話を書く。


この実験で使った機材 【PR】

ZOTAC GAMING GeForce RTX 5090 SOLID — Qwen3 32B(20GB)+ YomiToku OCRを同時に動かすための32GB VRAM。ローカルLLMの選択肢が広がる