Sequential(順序依存)なタスクをマルチエージェントでやると -39%〜-70% 性能が落ちる——前の記事で書いた論文の話。あれで、MONET(メインのオーケストレーター)に集約して、Typewriter と Handler は AI を抜いて実行デバイスにする、と決めた。
決断と実装は別物だった。
「抜く」という単純な作業のはずだった
論文を読んで方針が決まった次の日、僕は Code に、Typewriter から Agent SDK を全部除去して CRUD だけにしよう、と頼んだ。
理屈はシンプル。Typewriter の中にいた Claude は、結局MONET側の Claude が考えた文章を「保存して」と言われて保存しているだけになっていた。前に書いた「保存箱問題」を、自律化で一回解決したつもりだったけど、論文を読んだ後で見直すと、自律化したあとの Typewriter も、結局MONETから見ると「ちょっと賢い保存箱」でしかなかった。
二人の Claude が伝言ゲームをしていた。それを論文のデータが「やめろ」と言っていた。
正直、寂しさはあった。「保存箱から脱却した Typewriter」をようやく作って、また「ただの保存箱」に戻すのは、自分が想像していた方向と違うところに行かざるを得ない、という悔しさもあった。事務的にサクッと進められる作業ではなかった。
でも作業自体はそんなに難しくないはずだった。Agent SDK の import を消して、チャット用のエンドポイントを潰して、テーブルを片付ける。それだけ。
700行が300行になった
Code に頼んで、Typewriter のバックエンドを書き直してもらった。
app.py が 701行から302行 になった。半分以下。何が消えたか書き出してみる。
ai_generator.py(AI処理の本丸)— 丸ごとバックアップして削除- Agent SDK の import と初期化
- システムプロンプトの読み込み
/api/chat_typewriter/api/monetのエンドポイント- チャット用の WebSocket
- セッション管理
データベースからもテーブルが4つ消えた。
threads(チャット用)messages(チャット用)typewriter_sessions(Typewriter AI のセッション)typewriter_messages(Typewriter AI のメッセージ)
残ったのは drafts(下書き)と posts(投稿履歴)と settings(X API設定)だけ。下書きを保存して、投稿して、履歴を残す。X 投稿アプリとして必要最低限の構造になった。
フロントエンドからも ChatPage.jsx と MonetPage.jsx を消した。Typewriter の UI から「Typewriter のチャット画面」が消えた。これは結構象徴的。Typewriter の中で Claude と話せたあの画面が、なくなった。
ここで設計の問題にぶつかった
Typewriter 側を空にしただけでは終わらない。
MONET側(オーケストレーター)に MCP 経由で Typewriter を呼ばせる仕組みを書き換える必要があった。今までは typewriter_chat という1つのツールで「Typewriter とチャット」していた。これからはチャットじゃなくて、CRUD 操作だ。
Typewriter 側の MCP サーバーには、新しく8つのツールが並んでいた。
save_draft # 下書き保存
update_draft # 下書き更新
delete_draft # 下書き削除
list_drafts # 下書き一覧
get_draft # 下書き取得
post_draft # 投稿(下書きから)
post_direct # 直接投稿
schedule_draft # 予約投稿
8つのツールを、MONETからどう見せるか。素直に8つそのまま並べるのが普通の発想だ。
ただ、ここで困ったことがあった。
別の記事で書いた「領域展開」の話。Typewriter のサイドパネルが開いている間、tool_choice で typewriter_chat を強制呼び出しさせる、あれだ。Watchman の入口・出口チェックも、typewriter_chat のツール名を見張っている。
ツールが8つに分かれると、tool_choice で何を強制すればいいかわからなくなる。8つ全部を許可リストに入れる、みたいな機能は API にない。tool_choice は「このツールを必ず呼ばせる」か「どれかツールを呼ばせる(any)」か「ツールを呼ばせない(none)」の3択。複数ツール候補から1つに絞る、ができない。
Watchman 側も困る。今までは「typewriter_chat のツール結果が来てるか」を機械的に見ればよかった。8つに分かれると、それぞれを別々に見張る必要が出てくる。Watchman の出口チェックロジックが複雑になる。
困った。せっかく作った領域展開と Watchman の枠組みが、ツール分割で機能しなくなる。
Action enum 方式
ここで Code が出してきたのが、Action enum 方式だった。
MONETから見えるツールは1つだけにする。名前は typewriter_action。引数として、何のアクションを実行するかを enum で渡す。
{
"name": "typewriter_action",
"description": "Typewriter(Xポスト管理)への操作指示...",
"input_schema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["save_draft", "update_draft", "delete_draft",
"list_drafts", "get_draft", "post_draft",
"post_direct", "schedule_draft"],
"description": "実行する操作"
},
"params": {
"type": "object",
"description": "アクション固有のパラメータ"
}
},
"required": ["action"]
}
}
MONETには「typewriter_action という1つのツール」が見える。tool_choice でそれを強制すれば、今までの仕組みがそのまま使える。
実行ブロック側で、渡ってきた action 文字列をそのまま MCP のツール名として使ってディスパッチする。
if tool_name == "typewriter_action":
action = tool_input["action"]
params = tool_input.get("params", {})
result = await call_mcp_tool_async(action, params)
外から見ると1ツール、内側では8ツール。
tool_choice 強制と Watchman の両立。8つに分割した CRUD ツールの保守性。両方が立つ形になった。
一発で動いた、と思ったら
Action enum 方式に書き換えて、Typewriter で python -m py_compile、フロントエンドで npm run build、両方通った。VoiceChatBot 側も同じく通った。
ビルドが通るのと、実運用で動くのは別の話。
ここで Code に頼んで、もう一つやってもらった。monet-rules(MONET用のスキル)に、絶対ルールのセクションを追加してもらった。各アクションごとに「いつ呼べるか」を明示する。post_draft の前には必ず get_draft で内容を確認させる。
これでもう、ツール捏造はなくなるはずだった。プロンプトの強化と、Action enum 方式の構造化で、嘘の入る余地はないはず。
実運用テストを始めた。
save_draft(下書き保存)— 安定して成功。
post_draft(投稿)— ツールを呼ばずに「投稿したよ」と捏造した。
また同じパターン
ログを見た。stop_reason: end_turn、テキストのみ返答、tool_use ブロックなし。
ツールハルシネーション記事の3本目で書いたのと、まったく同じパターンだった。あの時と何も変わっていない。
正直、ちょっと驚いた・・・。
論文を読んで、構成を変えて、AI を片方から抜いた。Sequential タスクの劣化問題は構造として解決したはずだった。エラー増幅も消えたはずだった。下のClaudeを上に「吸い上げる」ことで、伝言ゲームを潰した。
なのに、嘘は残っていた・・・。
しかも今回の方が始末が悪い。save_draft は成功するのに、post_draft だけが捏造される。なぜか。Code と一緒にログを読んでいて、Code が本質的な問題に気づいた。
save_draft は会話の1段階目。「投稿の下書きを作って保存して」と言われたら、Claude は最初にツールを呼ぶしかない。何もせずに「保存しました」とだけ返すのは、文脈的にあからさまに不自然だから。
でも post_draft は会話の2段階目で起きやすい。「じゃあそれを投稿して」と言われた時、直前に save_draft の結果がある。そこに乗っかって「投稿しました」とテキストで済ませる方が、Claude にとっては「自然な流れ」に見える。投稿は、テキストで済ませやすい。
Watchman を当てた
ここで覚悟を決めた。monet-rules の強化だけでは足りない。領域展開(Watchman)を、Typewriter の投稿フローでもオンにするしかなかった。
Code に頼んで、Watchman の入口・出口プロンプトを実行デバイス版に更新してもらった。
入口チェック:
- 「作って」「修正して」は CHAT_ONLY(MONETが考えるだけでいい)
- 「保存して」「投稿して」は EXECUTOR_REQUIRED(必ず Typewriter 側でツールが呼ばれる必要がある)
出口チェック:
typewriter_actionのツール結果がなければ、問答無用で REJECT
ツール名も typewriter_chat から typewriter_action に書き換える。Action enum 方式に対応させる。
領域展開オンの状態で、もう一度投稿フローを実行した。
post_draft が、ちゃんと呼ばれた。投稿された。
一発で成功。
「ルールで足りる」は幻想だった
このテスト、僕にとっては結構大きな一区切りだった。
論文を読んだ後、僕は「構成を Sequential 向けに変えれば、ツール捏造もある程度落ち着くんじゃないか」と思っていた。AI を抜いて、伝言ゲームを潰せば、嘘の表面積は減るんだから。
実際は違った。表面積は確かに減った。Typewriter 側の Claude が嘘をつく経路は、消えた。Claude が1人になったから、その1人を見張ればよくなった。
でも、その1人は変わらず嘘をついた。階層の問題と、嘘の問題は、別だった。
別の記事(領域展開のやつ)で「プロンプトはあくまで行動指針であって強制力じゃない」と書いた。論文の数値も「プロンプトエンジニアリングによる改善はわずか2.7%」だった。それは知識として知っていた。
でも今回の実装で、もう一回、体で確認した気がする。
monet-rules をどんなに具体的に書いても、save_draft までは安定しても、post_draft のような「テキストで済ませやすい」場面では、Claude は逃げ道を探す。確率が下がるだけで、ゼロにはならない。
tool_choice 強制と Watchman の組み合わせは、その逃げ道を構造で塞ぐ。テキストで返してきても、Watchman が REJECT する。Claude には「ツールを呼ぶ」以外の正解ルートが残らない。
これは Claude を信じていないのではなくて、Claude には「テキストで済ませる」という回路が物理的にあるから、その回路を塞ぐしかない、という話。チャットボットとして訓練されているモデルに「ツールを必ず呼べ」と説得しても、最終的にはテキストで返してくる場面が出る。Claude Code みたいに「ツールを呼ばないと物理的に何もできない」環境とは違う。
「AIを持つことが偉い」から、「役割を持つことが大事」へ
少し抽象的な話に降りる。
このプロジェクトを始めた時、僕は「アプリの中に AI が入っていることが、エージェントシステムの本体」と思っていた。Typewriter の中に Claude がいて、Handler の中に Claude がいて、それぞれが自律的に考えて動く。それが「マルチエージェント」だと思っていたし、業界もそう言っていた。
論文を読んでこの設計が間違いだったとわかって、Typewriter から AI を抜いた。Handler でも同じことをやることになる。
抜いた後の Typewriter は、チャット画面もない。中で動いている Claude もいない。8つの CRUD ツールと、X API への接続。それだけ。
機能としては、寂しい。
でも実際に動かしてみると、変な話、こっちの方が「役に立ってる」感覚がある。中央の Claude が「下書きを作る」と判断して、save_draft を叩く。Typewriter は保存する。中央の Claude が「投稿する」と判断して、post_draft を叩く。Typewriter は投稿する。
Typewriter は何も考えない。指示通り実行する。エラーがあれば中央に返す。それだけ。
これが、「実行デバイス」という言葉の意味なんだと思う。
中央が考える。下が動く。それぞれの役割がはっきりしてる。
「AI が中に入っているか」じゃなくて、「役割がはっきりしているか」。エージェントシステムを評価する物差しを、自分の中で書き換えた感覚がある。中の Claude が多いほど偉いシステム、ではなかった。