LLMを使った記事生成パイプラインの実装アーキテクチャ
この記事について
このスタジオでは2種類の記事生成パイプラインが本稼働している。一つは「まとめ記事」の自動生成パイプライン(とあるまとめ媒体向け)、もう一つは「オリジナル記事」の半自動生成パイプライン(the NTM向け)。
以前「公開ボタンを捨てる」(abandon-publish-button)という記事で「なぜ自動公開にしたか」という運用判断の話を書いた。この記事はその実装側——LLMをどう組み合わせて記事を作っているか——を記録する。
まとめ記事パイプライン(まとめ媒体向け)
おーぷん2chのスレを素材に、まとめ記事を自動生成するパイプラインの全体像は以下:
collect_threads → judge_local → promote → watch_threads → compose → wp_post
各ステップの役割:
| ステップ | スクリプト | 処理内容 |
|---|---|---|
| 収集 | matome/collect_threads.py | subject.txt取得・スコアリング |
| 振り分け | matome/judge_local.py | 媒体別の振り分け判定 |
| 昇格 | matome/promote.py | watchリストへ追加・R2に画像保存 |
| 監視 | matome/watch_threads.py | 画像追加・stale 引退(1時間ごと) |
| 生成 | matome/compose.py | LLMで編集・見出し挿入 |
| 投稿 | matome/wp_post.py | WordPress draft 投稿 |
LLMが介入するのは compose ステップだけ。それ以外は決定論的な処理(スコアリング・ファイル操作・API呼び出し)で、LLMに判断を任せない。
compose ステップの設計(引き算の原則)
まとめ生成の設計で最も重要な判断は「スレの構造をどう扱うか」だった。
初期案は「点数をつけてレスを採点し、上位を並べ替える」方法だった。しかしこれには問題がある。スレッドの会話は順序が意味を持つ。レス同士が参照し合い(アンカー)、前後の流れで文脈が成立している。並べ替えると「謎のレスの羅列」になる。
採用したのは引き算の原則:
- まず全スレを読んで方針とオチの目星をつける(framing)
- 元の順序のまま、各レスを keep / drop 判定(drop は明確なノイズだけ)
- 話題転換点に H2 見出しを挿す
「何を残すか」ではなく「何を消すか」を指示する。これにより元の会話の流れとアンカーの噛み合わせが保たれる。
長スレ(110レス超)はチャンク単位で処理し、チャンクごとに方針を持たせてからフル編集する。
LLMは llm_registry("matome_compose") 経由(Ollama gemma3:4b 優先、不達時は Gemini API にフォールバック)。
オリジナル記事パイプライン(the NTM向け)
こちらは「記者がゼロから書く」ワークフローを補助する半自動設計。
GitHub Issue (status:selected)
→ init: 雛形生成 + アセットディレクトリ作成
→ source-check: ソースURLの整合性チェック(Gemini)
→ [ドラフト執筆: 人間またはLLM]
→ validate: frontmatter・リンク・プレースホルダー検証
→ publish: WordPress / Astro デプロイ
source-check が重要なステップで、ここで「このソースでこの記事は書けるか」をGeminiで判定する。スコア閾値(40点未満で中断、40〜69で警告、70以上でGO)を設けることで、素材が薄いままドラフトを生成して手直しが大変になる問題を防いでいる。
執筆ステップ自体はパイプラインから外れている。LLMに「ゼロから記事全文を生成させる」ことはしていない——少なくとも現時点では。ソース素材の精査(source-check)と品質検証(validate)を機械に任せ、記事の判断と執筆は人間またはLLMが明示的にフックする設計。
共通の設計原則
LLMに任せるのは「判断が必要な部分だけ」
ファイルの移動・API呼び出し・ステータス更新は決定論的なコードで書く。LLMへのAPI呼び出しはコストがかかり、出力が確定的でない。「ここでLLMを使う必要があるか」を都度判断する。
全スクリプトのモデルは llm_registry で一元管理
モデル名の文字列がスクリプトに散らばると、世代更新のたびにgrepして書き換える必要が出る。llm_registry.py に purpose(用途)をキーにした定義を集約し、スクリプト側は get("matome_compose") と書くだけにしている。
draft 投稿がデフォルト
WordPress REST API への投稿は必ず status=draft をデフォルトにする。自動パイプラインが status=publish を指定するのは、validate 通過・スロット予約・公開後レビューの条件が揃った場合のみ。ワンコマンドで本番公開されるルートを閉じている。
やってわかったこと
「書く」より「選ぶ」のほうがLLMの得意領域と合う。 まとめパイプラインでLLMに「何を消すか」を判断させる設計は安定している。「何を書くか」(ゼロ生成)より「何を残すか」(フィルタリング)のほうが出力のブレが小さく、品質が安定しやすい。
パイプラインの中でLLMが介入する場所を1ステップにする。 複数ステップでLLMを連鎖させると、エラーの原因の特定が難しくなる。可能なら「LLMが1か所・その前後は決定論的」という構成にする。
validate ステップをスキップしない。 公開後に404リンクやプレースホルダーが残っているのを発見するのは、デプロイ前に発見するより何倍もコストがかかる。validate を省略できる状況はほぼない。
更新履歴
- 2026-07-23: 初版公開