【開発ルポ】#2 LLMシステムを作ると、実装の大半がパースとリトライになった

「LLMシステムを作る」と言うと「Claude APIを呼ぶコードを書く」と思われやすい。でも実際に作ってみると、API呼び出し自体は全体の2割程度で、残りの8割はパースとリトライと排他制御に費やされる。Cruxnoteの実装でそれを体感した。

記事生成の核心はSYSTEM_PROMPTの設計にある

記事の品質を決めるのは、LLMに渡すSYSTEM_PROMPTの設計だ。試行錯誤の末、4層構成に落ち着いた。

  1. メディア方針(mission.md)——このメディアが何を目指すか
  2. 品質基準(editorial.md)——記事の構造ルールと禁止事項
  3. 記事構成(structures/{type}.md)——論の組み立て方の設計図
  4. ペルソナ(persona/{name}.md)——このライターの読み方・書き方

順序が重要だ。「何のためのメディアか」を先に示し、「品質基準」で形を定め、「記事構成」で論の設計図を与え、最後に「誰として書くか」を指示する。この順序が逆だと、ペルソナの声がメディア方針を上書きしてしまう。

詰まり①:日本語のFTS5検索

同じ論点を繰り返し発議しないよう、SQLiteのFTS5(全文検索機能)で重複チェックを行っている。しかし日本語テキストは形態素解析なしでは適切にトークナイズできず、単純なFTS5検索では精度が落ちる。

回避策として採ったのは、英語固有名詞(GPT-5やAnthropicなどの製品名・企業名)を優先して検索することだ。固有名詞は言語を問わず一致するため、FTS5が機能しやすい。ヒットした場合はさらにLLMで意味類似判定を行う二段構えにした。FTS5のスコアが閾値以上であれば、LLM呼び出しをスキップして即除外する。根本解決ではなく回避策だが、運用上は機能している。

詰まり②:LLM出力のパース

LLMにJSONを返すよう指示しても、コードブロックでラップされたり、前後に説明文が付いたりする。json.loads() が失敗するたびにリトライを重ね、最終的にブレース位置を直接検索して抽出する方式に落ち着いた。

start = raw.find('{')
end   = raw.rfind('}')
raw   = raw[start:end+1]

シンプルだが、これが一番安定した。コードブロックの除去やJSONの開始・終了位置を検索するという発想は、LLM出力のパース問題を扱うすべての実装者がたどり着く場所だと思う。

詰まり③:バックグラウンドプロセスの管理

記事生成には数分かかるため、nohup(ターミナルを閉じてもプロセスを継続するコマンド)でバックグラウンド実行している。しかしSlackでのダブルクリック等で同一テーマへの二重起動が発生した。

ロックファイル(/tmp/media-write-e2e-${THEME_ID}.lock)と処理中マーカーを組み合わせた排他制御で対処した。シンプルなバックグラウンドジョブでも、本番運用に耐えるには相応の実装が必要だ。

「実装の大半はパースとリトライ」という現実

LLMを使ったシステムを作るとき、LLM呼び出し自体は数行で書ける。しかしその出力を安定して使えるようにするための防御的なコードが積み重なる。この経験は、LLMを使った自動化を組んでいる人に広く伝わる話だと感じている。「APIが動いた」と「安定して動く」の間には、パースとリトライの実装が詰まっている。

この経験が有効な規模感

単一LLMが1回の推論でJSONを返すシンプルな構成での話だ。複数ステップにまたがるエージェント型の設計では、パースの問題より状態管理の問題が先に来る。Cruxnoteで直面した課題は、シンプルなLLM呼び出しを本番品質で動かす段階の話だと捉えてほしい。

コメント

タイトルとURLをコピーしました