3つのAIに異なる役割を与えてブログ記事をレビューするパイプラインを作った

ブログ記事の品質判断で「公開価値」「技術正確性」「読みやすさ」を一人で担保するのは限界がある。

3つのLLMにそれぞれ異なる役割を与えて並列レビューするパイプラインを作った。

前提知識: この記事では OpenRouter API を使用して複数のLLMにアクセスしています。OpenRouter は統一APIで異なるLLMプロバイダーにアクセスできるサービスです。編集長・技術査読者・読者代表という3人格が同じ記事を読み、投票で公開可否を決める仕組みだ。

この記事では設計上の判断とつまずいた点を残しておく。

なぜ1つのLLMではなく3つか

最初は1つのLLMに「編集長として判断してください」と投げることを考えた。ただ同じモデルに複数の視点を持たせると、視点同士が干渉して「中庸な判断」に収束しやすい。厳しい技術査読と「面白いかどうか」という感覚的な評価は、同じセッション内では互いを薄め合う。

別モデルに別役割を与えることで、モデルのクセごと活かした評価ができる。

構成

3つのペルソナに異なるモデルを割り当てている。

| ペルソナ | モデル | weight | 役割 | |---------|--------|--------|------| | editor(編集長) | claude-sonnet-4-6 | 0.40 | 公開価値の最終判断、veto権あり | | tech_reviewer(技術査読) | deepseek/deepseek-chat | 0.35 | 再現性・正確性・セキュリティ | | reader(読者代表) | google/gemini-2.5-flash | 0.25 | 面白さ・読みやすさ・共感 |

モデルの選定理由:

  • Claude: 文章評価と論理的な判断の精度が高い。編集長の判断軸は「価値があるか」なので、ここに一番信頼できるモデルを置く
  • DeepSeek: コード・技術系の知識が厚く、コストも低い。技術査読の大量処理に向いている
  • Gemini: 速い。読者視点の「パッと読んで面白いか」という判断は、重厚な推論より反応速度の方が実態に近い

パイプラインの流れ

記事ファイル (content/posts/**/*.md)
  ↓
3ペルソナが ThreadPoolExecutor で並列レビュー
  ↓
集計: スコア加重平均 (min_score: 70) + 承認数 (min_approvals: 2)
  ↓
editor が reject → 最終判定 reject(veto)
  ↓
approve の場合: 修正適用 → 文体調整 → 整合性チェック
  ↓
レビューレポート (review_report.json) 出力

並列実行のコア部分: 以下は並列実行の具体的な実装例です。エラーハンドリングやタイムアウト設定を含め、実運用に耐える形にしています:

from concurrent.futures import ThreadPoolExecutor, as_completed

def run_review(article: Article, personas: list[PersonaConfig]) -> list[PersonaResult]:
    results = []
    with ThreadPoolExecutor(max_workers=len(personas)) as executor:
        futures = {
            executor.submit(persona.review, article): persona
            for persona in personas
        }
        for future in as_completed(futures):
            results.append(future.result())
    return results

注意: 実際の実装では各 persona.review() にタイムアウト設定(30秒)とリトライロジック(最大3回)を含めています。LLM APIの応答が不安定な場合があるためです。

editor veto を入れた理由

当初は単純な多数決(2/3 approve で公開)を考えていた。ただ実際に動かしてみると、reader と tech_reviewer が「面白い」「技術は合ってる」と判断しても、editor から見ると「これは薄い、公開する価値がない」というケースが出てきた。

多数決だとこのパターンで公開されてしまう。価値のない記事を出すことは、価値ある記事を出さないことより害が大きいという判断から、editor には veto 権を与えた。

# review/config.yaml
consensus:
  min_score: 70
  min_approvals: 2

editor:
  model: claude-sonnet-4-6
  weight: 0.40
  veto: true          # reject なら全体も reject
  can_modify: true

thin_content の自動検出

「内容は正確だが、何も新しいことを言っていない」という記事は多数決では検出できない。技術的に正確で文章も読みやすければ、tech_reviewer も reader も approve を出す。

editor プロンプトに thin_content の定義を明示し、検出した場合は critical issue として記録する設定にした。

thin_content_detection:
  enabled: true
  auto_reject: true

これを入れてから、「公式ドキュメントをなぞっただけ」の記事が自動で reject されるようになった。

テスト

tests/fixtures/ にペルソナ別のサンプル記事を用意し、期待する verdict(approve/request_changes/reject)と一致するかを検証している。

tests/fixtures/
├── good_article.md       # 実体験あり、技術詳細あり → approve 期待
├── thin_article.md       # 内容が薄い → editor reject 期待
├── code_heavy_article.md # コード中心 → tech_reviewer approve 期待
├── formal_article.md     # 文体が硬い → voice_transform 対象
└── casual_article.md     # カジュアルすぎ → voice_transform 対象

現在 45 テストが通っている。ペルソナプロンプトを変更しても、コードロジックには影響しないため引き続き全パスを維持できた。

運用してわかった制約

LLMのレスポンスは JSON で返ってくるとは限らない。 例:期待する JSON レスポンス

{"verdict": "approve", "score": 85, "issues": []}

実際に返ってくるレスポンス

以下が評価結果です:
{"verdict": "approve", "score": 85, "issues": []}

総合的に良い記事だと思います。
```プロンプトに `return valid JSON only` と書いても、前後に説明文を付けて返してくることがある。レスポンスパーサーに正規表現でのJSON抽出を入れておかないと、パイプラインが止まる。

**モデルによってレスポンス速度が大きく違う**。Gemini が最速、DeepSeek が中程度、Claude が最も遅い。並列実行しているため全体のボトルネックは Claude になる。タイムアウト設定は Claude 基準にしないと落ちる。

**同じプロンプトへの応答がブレる**。特にスコアリングで顕著で、同じ記事を2回投げると異なるスコアが返ることがある。閾値をギリギリに設定すると不安定になるので、min_score は余裕を持たせた方がいい。

## 次にやること

- GitHub Actions / GitLab CI への組み込み(現在はローカル実行のみ)
> **参考**: CI/CD 統合の際は、LLM API キーの安全な管理とレート制限への対応が重要になります。特に複数記事の一括レビュー時は API 呼び出し数が急増するため注意が必要です。
- reject された記事に対する具体的な改善提案の生成
- ペルソナごとのスコア履歴を記録して、記事の傾向を分析する

3つのLLMを使い分けるアーキテクチャは、単一LLMで複数役割を担わせるより評価の分離が明確でデバッグしやすい。どのペルソナがなぜ reject したかがログに残るので、記事の改善点が具体的にわかる。
**関連記事**: 
- [OpenRouter API の使い方とモデル選択のコツ](#)
- [LLM を使った文章評価システムの設計パターン](#)
- [Python での並列 API 呼び出しのベストプラクティス](#)