アフェリエイト広告

サブスクのまま4つのAIを統率する ― APIを使わないマルチエージェント基盤を作った

複数のAIモデルを扱えるツールは増えた。しかしそれらの多くは、チャットやターミナルの単位でモデルを切り替える仕組みであって、モデル同士を協働させる仕組みではない。前の回答をコピーし、文脈を補い、次のモデルへ渡す。統合しているのは結局のところ人間である。

一方で、複数エージェントを協働させる基盤も存在する。ただしその多くはAPI呼び出しを前提としている。APIキーを用意し、トークン単位で課金され、その基盤の作法に合わせて実装することになる。すでにClaude、Codex、Geminiにサブスクリプションを払っているのに、協働させようとした瞬間に別建てで課金し直すことになる。しかも一度組み上げれば、その基盤から離れにくくなる。

この2つを避けたかった。そこで採った方針は単純である。

APIを呼ぶのではなく、すでに契約しているCLIをプロセスとして起動し、統率する。

作ったのは gc(group chat)というシステムだ。Discordに依頼を書くと、指定した順にAIエージェントが作業を引き継ぐ。本記事では、その設計思想とアーキテクチャを扱う。実装中に踏んだCLI固有の罠については、分量の都合で別記事にまとめる。


アフェリエイト広告

1. 何ができるか

Discordの専用チャンネルに、担当と指示を書く。

@gemini SQLiteのWALモードの利点を artifacts/notes.md に3行で書いて
@gpt それを読んで1行に要約して artifacts/summary.md に書いて

返ってくるのはこうだ。

AIgc:   ジョブ 20260829-170009-baa38f を受け付けました(経路: gemini → gpt)

gemini: hop1 / gemini
        SQLiteのWALモードの利点を調査し、notes.md に3行でまとめました。
        次の処理: notes.md を読んで1行に要約し、summary.md に書いてください。
        成果物: artifacts/notes.md   [notes.md 607 bytes]

gpt:    hop2 / gpt
        要約を summary.md に書きました。
        成果物: artifacts/summary.md [summary.md 196 bytes]

エージェントごとに別の名前とアイコンで発言し、作成したファイルはその場に添付される。Discordが動く環境であれば場所を選ばない。実際、最初の受け入れテストはiPhoneから行った。

現在の構成は4体である。

Discord上の名前実体役割
opusClaude Opus 5計画、難しい判断、最終統合
sonnetClaude Sonnet 5実装、編集、日常作業
geminiGemini 3.1 Pro調査、長文資料、別視点でのレビュー
gptCodex gpt 5.6コード実装、テスト、技術レビュー

重要なのは、モデルを並べたことではなく、引き継ぎがシステムの一級の操作になっていることだ。誰が何を完了し、何を残作業として渡したかが記録され、成果物の実在が検証される。人間が手で運んでいたときには、そのどれも残っていなかった。


2. 設計の核 ― APIではなくCLIを統率する

この選択は、単なる実装上の都合ではない。以降のほぼすべての設計がここから導出される。

APIではなくCLIを起動することで得られるものは3つある。

  • サブスクリプションのまま使える。 追加のAPIキーもトークン課金も発生しない
  • ツールに縛られない。 各ベンダーのCLIは独立して更新され、こちらは呼び出し方を合わせるだけでよい
  • ベンダーが用意した実行環境をそのまま使える。 サンドボックス、権限モデル、セッション管理を自前で作らずに済む

代償もはっきりしている。相手はHTTPリクエストではなく、プロセスである。以下は、この方式を選んだことで必要になった設計と、その根拠である。

設計上の決定その根拠
使用量を回数と実行時間で管理するサブスクには「1回いくら」という価格が存在しないため
アダプタ層を設けるCLIごとにフラグ、セッションの扱い、出力形式が異なるため
プロセスグループ単位で起動し、タイムアウトで終了させる応答しないプロセスを外から確実に止める手段が他にないため
環境変数を絞ってから起動する親の環境を継承すると、CLIが作業場所を取り違えるため
成果物の実在を毎回確認する終了コードが0でも、期待した場所にファイルがあるとは限らないため

APIを叩く実装なら、これらの多くは考えなくてよい。CLIを選んだ時点で、プロセス管理と検証はこちらの責任になる。 それを引き受ける代わりに、課金とロックインから自由になる、という取引だ。


3. オーケストレーションの4層と、いまの位置

この種のシステムを作ると、「AIが次の担当を自分で決める」方向に進みたくなる。マルチエージェントらしい絵になるからだ。

gcの初期版では、それを意図的に実装していない

理由は、暴走したときに止められないからである。3体が互いを呼び合えば、こちらが寝ている間にサブスクの枠を使い切り、作業ディレクトリが荒れる。そういう朝を迎えたくない。

代わりに置いたのが、上の4層という整理だ。

役割いまの担当
意図理解層「何がしたいか」を解釈する人間
計画層役割分担と実行順序を決める人間(メンションの並びとして表現)
実行層経路どおりに起動し、文脈と成果物を引き継ぐgc
保護層上限・タイムアウト・停止・分離・監査gc

つまりgcは、オーケストレーションの実行基盤であって、指揮者ではない。 指揮者はいまのところ人間が務めている。

この順序で作ったのには理由がある。

マルチエージェントで難しいのは「誰に何をやらせるか決めること」ではない。決めたとおりに確実に実行し、失敗を検知し、確実に止めることである。

計画層を先に作れば、動くデモは早くできる。しかし実行層と保護層が薄いままでは、動くけれど任せられないものになる。止められないものは、結局のところ使えない。


4. アーキテクチャ

処理の流れは一本道である。

gateway.py はDiscordの入出力だけを担当し、モデルの知識を持たない。受信側では6つの条件(サーバー、チャンネル、投稿者、Bot判定、Webhook判定、添付の有無)をすべて満たすメッセージだけを受理する。送信側では2000字での分割、コードブロックをまたぐ場合の再オープン、レート制限時の待機、再試行による重複投稿の防止を扱う。

router.py が中核だ。メンションを出現順に解析して経路を確定し、ホップごとにエージェントを起動する。このとき、依頼の原文をそのまま全員に渡すのではなく、メンションの位置で指示を分割する(後述)。実行後は構造化出力を検証し、申告された成果物が実在するかを確認する。

adapters/ が各CLIの差異を吸収する。フラグの綴りも、セッションの継続方法も、構造化出力の返し方も、3つのCLIですべて異なる。ここを厚くしたおかげで、router以降はCLIの違いを意識しない。

guards.py はパスとコマンドを検査する。ディレクトリからの脱出、シンボリックリンク経由の回避、禁止領域への到達、そして危険なコマンドの実行を止める。

db.py はSQLiteでジョブ、キュー、セッション、成果物、イベント、日次の使用量を永続化する。

ジョブDBは、実質的にワークフローエンジンである

作ってから気づいたのではなく、ジョブを永続化して状態機械で管理すると決めた時点で、それはワークフローエンジンだった。

いまDBが持っているものを並べると、その性格がはっきりする。

  • 状態機械(queued → running → waiting_model → completed / failed / timed_out / cancelled / failed_recovery
  • ホップ単位の進捗と担当
  • 成果物の系譜(どのホップが何を作ったか)
  • タイムスタンプ付きのイベントログ
  • 使用量の集計

これは進捗ダッシュボードにも、事後監査にも、そのまま使えるデータモデルだ。

規模

実装3,746行(Python)
テスト2,048行 / 200件
外部ライブラリDiscord接続用の1つのみ。他はすべて標準ライブラリ
制作期間1日

外部依存を増やさなかったのは意図的である。エージェントにパッケージ導入を禁止している以上、システム自身の依存も最小にしておきたかった。JSON Schemaの検証も手書きした。


5. 統制の設計

保護層に何を入れたか。4つある。

5.1 経路と上限は人間が握る

  • 実行するのは、利用者が明示した順番だけ
  • 最大3ホップ、1モデル10分、ジョブ全体35分で必ず止まる
  • AIが出力の中で「次は◯◯へ」と提案しても、記録と表示はするが実行経路には採用しない

将来的に自律引き継ぎを入れる場合も、エージェント単位で1体ずつ有効化し、上限とタイムアウトは維持する方針である。

5.2 指示をホップ単位に分ける

複数エージェントへの依頼を、原文のまま全員に渡すと分業が壊れる。実際に起きた例を挙げる。

@sonnet plan.md を作って @gpt それをレビューして

これを投げたところ、sonnetが plan.mdreview.md の両方を作って返してきた。 gptの担当分まで終わらせていたのである。

当然だった。全ホップに原文を渡していたので、sonnetからは「レビューして」という指示も見えていた。真面目なエージェントほど全部やってしまう。

そこでメンションの位置を境界として指示を切り出し、各エージェントには自分の担当分を「あなたへの指示」として渡す。原文全体は文脈として別枠で添え、「他の担当の分は実行しないこと」と明記する。

5.3 AIの完了報告を、そのままは信じない

これが設計上もっとも重要な判断だった。

あるエージェントに「artifacts/smoke.md を作って」と指示し、「作成しました」という応答を得た。ジョブディレクトリを見ると、ファイルがない。探すと、まったく別の場所にあった。

/private/tmp/claude-501/-Users-hiro-Documents-cloude-4c7d3abd-...-claude_smoke2/artifacts/smoke.md

原因は環境変数だった。親プロセスから継承した変数を見て、CLIが自前の作業ディレクトリを作り、相対パスをそちら基準で解決していた。エージェントは嘘をついていない。指定した場所とは違う場所に、確実に作っていた。

以来、成否の判定はこの4条件の組み合わせで行っている。

  • 構造化出力に必要な内容が揃っていること
  • 申告された成果物が実在すること
  • その成果物がジョブの artifacts/ 配下にあること
  • タイムアウトしていないこと

「AIがそう報告した」ことと「システム上で確認できた」ことを分ける。 これが原則になった。

5.4 既存システムから分離する

同じMac上では、別のDiscordゲートウェイが24時間稼働している。gcの導入や障害がそちらへ波及してはならない。

プロセス、launchd設定、ログ、作業ディレクトリ、Bot、チャンネルをすべて分けた。作業の前後には、既存システムのPID、launchdの登録状態、ログ更新時刻を読み取り専用で記録し、変化がないことを確認する。

この確認が効いた場面がある。調べると、既存ゲートウェイの起動コマンドには --replace が付いていた。つまり別のプロセスが同じコマンドを実行した瞬間、稼働中のゲートウェイが置き換えられて停止する。 エージェントがバージョン確認のつもりで叩くだけで事故になる。そのコマンド名を、システム全体の禁止リストに入れた。

テストが見つけたもの

200件のテストが見つけたのは、他人のバグではなく自分で書いたバグ5つだった。

  • 禁止コマンド検査の抜け穴 3件(パイプ経由、バッククォート内、改行区切り)
  • DBの並行性の問題 1件(同一接続をスレッド間で共有していた)
  • 停止処理の競合 1件(「停止した」と返しながらプロセスが走り続ける)

いずれもコードを読んでいて気づいたものではない。実際にプロセスを起動し、実際に強制終了シグナルを送って初めて出てきた。外部CLIを組み合わせるシステムでは、正常系の応答だけを試しても意味がない。


6. これから

6.1 並行実行・条件分岐・ループ

いまの経路は一次元の列である。将来はこれを有向グラフにしたい。ただし「経路をDAGにするだけ」では済まない。

拡張必要になるものいまの設計との衝突
並行実行ノード単位の同時実行、全親の完了待ち同時実行数1の撤廃。停止対象が複数のプロセスグループになる
条件分岐分岐条件の評価器判定にAIの出力を使えば「報告を信じない」原則と正面衝突する
ループ終了条件と最大反復回数暴走防止の要。ホップ上限の考え方を引き継ぐ必要がある

特に条件分岐が難しい。AIの申告を信じないと決めたシステムが、分岐の判断ではAIを信じざるを得ない。ここをどう設計するかは、まだ答えが出ていない。

並行実行にも別の問題がある。並行度がそのまま使用量のリスクになる。 いまの上限(1日60回・合計3時間)は逐次実行を前提とした数字であり、並行化すれば見直しが要る。

6.2 ダッシュボード

前述のとおり、ジョブDBはワークフローエンジンのデータモデルを持っている。ならば可視化は自然な発展だ。

ただし価値の中心は進捗管理ではなく、事後監査にあると考えている。どのジョブがどのファイルを書き換えたか、どのコマンドが検査で弾かれたか、どの権限拒否が記録されたか。エージェントに読み書きさせる範囲を広げるほど、「何をされたかを後から追えること」の重みが増す。必要な情報はイベントテーブルにすでに入っている。

6.3 そして、蓄積

これが一番大きな課題である。

いまのgcは、セッションをジョブ単位・エージェント単位で切っている。同じジョブ内で同じ担当が二度登場すれば記憶は続くが、ジョブをまたげば切れる。エージェント同士も、引き継ぎ文と成果物のパス以外は共有しない。別案件の文脈が混ざるのを防ぐための設計だ。

裏返せば、毎回ゼロから始めている。

蓄積の材料はある。全ジョブの引き継ぎ内容も、成果物も、検証で見つかった問題点も、すべてDBとファイルに残っている。欠けているのは取り出す仕組みだけだ。

しかし蓄積を入れると、この記事の主題と正面から衝突する。検証されていない知見を貯めることは、間違いを固着させることだからだ。 一度誤った知見が入り込むと、以後のすべてのジョブがそれを前提に動く。しかもAIは、書いてあることを自信を持って引用する。

現時点で有力だと考えているのは、AI専用の記憶を作らないという方針だ。蓄積先を人間が読み書きできる場所(自分の場合はObsidianのノート)に置き、残すかどうかを人間が決める。ノートを残す・消すという行為が、そのまま承認の仕組みになる。 ベクトルDBに放り込んで検索させる方式より地味だが、この設計の原則とは整合する。

オーケストレーションの次の課題は、並行実行ではなく蓄積である。そう考えている。


7. まとめ

gcは、Discordを入口として複数のAIエージェントを順に動かし、成果物を引き継ぐシステムだ。設計の中心に置いたのは3点である。

  1. APIではなく、すでに契約しているCLIをプロセスとして統率する。 サブスクのまま使え、ツールに縛られない。代わりにプロセス管理と検証は自分の仕事になる
  2. 実行経路と上限は人間が握る。 指揮者を自動化する前に、確実に実行し確実に止める基盤を作る
  3. AIの報告ではなく、構造化出力と成果物の実在で完了を判定する。 「そう報告した」と「確認できた」を分ける

1日で書いた5,794行のうち、3分の1はテストである。そのテストが見つけたのは、自分で書いたバグ5つだった。外部のCLIを組み合わせて何かを作るなら、正常系ではなく、空応答・誤った保存先・認証待ち・通知失敗・停止競合まで含めて試すことを勧める。

実装中に踏んだCLI固有の罠は、それ自体が分量のある話になった。別記事にまとめる。


関連記事: 「CLIオーケストレーションで踏んだ罠カタログ」(執筆予定)

コメント

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