BLOG.HIRO UENO.ART
← index
2026.02.07

実行デバイスへの降格 — TypewriterからAIを抜く

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.py701行から302行 になった。半分以下。何が消えたか書き出してみる。

データベースからもテーブルが4つ消えた。

残ったのは drafts(下書き)と posts(投稿履歴)と settings(X API設定)だけ。下書きを保存して、投稿して、履歴を残す。X 投稿アプリとして必要最低限の構造になった。

フロントエンドからも ChatPage.jsxMonetPage.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_choicetypewriter_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 の入口・出口プロンプトを実行デバイス版に更新してもらった。

入口チェック:

出口チェック:

ツール名も 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 が多いほど偉いシステム、ではなかった。