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 呼び出しのベストプラクティス](#)