誤字脱字チェックが記事のリンクを壊していた話 — ローカルLLM自動生成パイプラインのデバッグ記録

きっかけ:自動生成した記事のリンクが、いつの間にか切れていた

うちの検証ノート(もう一つのブログ)は、ローカルLLMで楽天商品の比較記事を自動生成して、そのまま公開キューに流すパイプラインで回してるんだけど、ある日ふと「前に公開した記事、アフィリエイトリンクが切れてない?」って気づいたんだよね。

最初は「まあ生成AIだしたまに誤字くらい出るか」くらいに思ってたんだけど、よく見たら誤字どころじゃなかった。見出しの中の商品スペックが「両耳用」→「両極用」になってたり、対応機種が「PS5」→「PS2」になってたり。極めつけは、アフィリエイトリンクのドメインが hb.afl.rakuten.co.jp から hb.agfl.rakuten.co.jp に変わってて、見事にリンク切れ。これはさすがに「たまたま」で片付けられないやつだった。

犯人は「誤字脱字チェック」そのものだった

パイプラインの流れとしては、こんな感じ。

  1. ローカルLLM(Gemma4-26B)で商品紹介文を生成
  2. コード側のテンプレートで見出し・画像タグ・アフィリエイトリンクを組み立ててMarkdownをレンダリング
  3. 「誤字脱字が残ってないか最終チェックして」と、レンダリング済みの全文をもう一度LLMに投げる
  4. LLMが返した「修正後の全文」をそのまま採用

犯人はステップ3・4だった。チェック前後のMarkdownを実際にdiffしてみたら、見出しの中の固有名詞は化けてるのに、その数行下にある画像altタグの同じ文字列は無事、みたいな状態がいくつも見つかったんだよね。

- ...装着タイプ:両耳用 接続方式:ワイヤード(有線)...
+ ...装着タイプ:両極用 接続方式:ワイヤード(有効)...

プロンプトには「見出し・リンクのURL・文言は一切変更しない」ってちゃんと書いてあるんだけど、量子化した26Bモデルに数千字のMarkdownを丸ごと「作文として書き直させる」と、この手の指示は確率的に無視されることがある、というのが今回学んだこと。長文を一字一句そのまま再生成させるタスクって、地味に難易度が高いんだなと。

直し方:そもそもLLMにリンクを見せない

プロンプトの書き方をどう工夫しても「絶対に守らせる」のは無理筋だなと判断して、方針を変えた。見出し・画像タグ・アフィリエイトリンクは元々コード側のテンプレートで組み立てていて、LLMが生成しているのは商品紹介文などの地の文だけ。だったら、チェックもレンダリング前の地の文だけに対してかければ、LLMはリンクの存在すら知らずに済む。

# Before: レンダリング済みの全文をまるごとチェックに通す
markdown_text = render_roundup_article(state, content)
markdown_text = apply_typo_check_local(markdown_text, model=CHECK_MODEL)

# After: 導入文・まとめ・各商品紹介文を個別にチェックしてから組み立てる
content["intro"] = apply_typo_check_local(content["intro"], model=CHECK_MODEL)
content["conclusion"] = apply_typo_check_local(content["conclusion"], model=CHECK_MODEL)
for product in content["products"]:
    product["write_up"] = apply_typo_check_local(product["write_up"], model=CHECK_MODEL)
markdown_text = render_roundup_article(state, content)  # ここで初めてリンク等を組み込む

地の文だけの短いチェックを何回かに分けて呼ぶ形に変えたので、LLMがURLや見出しの文字列を目にすること自体が無くなった。「プロンプトで頑張って指示する」から「そもそも壊せない構造にする」への転換、という話。ちなみに副産物として、1回の巨大なチェックが409〜748秒かかっていたのが、短い文章を複数回チェックする方式に変えたことで合計118秒まで縮んだ。

おまけ:画像生成を並走させたら今度はGPUがハングした

ついでの話だけど、このパイプラインは本文生成(GPU0/1をまたぐ26Bモデル)と、サムネイル画像生成(GPU1固定のStable Diffusion系)を同じマシンで動かしてる。せっかくだから両方を並走させて時間短縮したいよね、と思って組んだんだけど、これがまた沼だった。

Ollamaは一度使ったモデルを、既定で数分間VRAMに乗せっぱなしにする(keep_alive)。26Bモデルは18GB近くあって2枚のGPUにまたがってロードされるので、本文生成が終わった直後にサムネイル生成を始めると、GPU1がOllamaの26Bモデルでほぼ埋まったまま。この状態でPyTorch側(diffusers)がGPU1に新規メモリを確保しようとすると、メモリ不足エラーにすらならず、無期限にハングするという厄介な挙動を実機で確認した。torch/SYCL側とOllama/Vulkan側という、別々のGPUドライバスタックが同じデバイスの空き容量を取り合った時の相性問題っぽい。

対策は「Ollamaにモデルを明示的にアンロードさせてから画像生成を始める」なんだけど、ここにも罠があった。keep_alive: 0 を送ると done_reason: "unload" という応答は返ってくるものの、実際のVRAM解放は数秒〜数十秒遅れることがある。応答を信じてすぐ画像生成を投げると、また同じようにハングしてしまう。なので今は、アンロード要求を送った後に /api/ps をポーリングして、実際にモデルがリストから消えたことを確認してから画像生成ジョブを投げるようにしている。地味だけど、これでハングは再現しなくなった。

まとめ

  • 「プロンプトで禁止事項を書く」だけでは、長文の丸ごと再生成タスクにおいて量子化モデルの逸脱を防ぎきれない。壊されたくない部分は、そもそもLLMの入力に含めない構造にするのが一番確実
  • 複数のGPUドライバスタック(今回はOllama/VulkanとPyTorch/SYCL)を同じマシンで並走させる時は、「APIの応答」と「実際のリソース状態」がズレることがある。信じるのは状態そのものを確認した結果だけにする

自動化パイプラインを組んでると、こういう「一見ちゃんと動いてるように見えて、実は細かいところが少しずつ壊れている」系のバグに時々遭遇するんだけど、今回は実際にdiffを取って原因を特定できたのが収穫だった。同じような構成でハマってる人の参考になれば。