BLOG.HIRO UENO.ART
← INDEX

見えないものは直せない — Context Inspectorを先に作る

Qdrantを外すと決めた次のセッション。本当はその日、もうコードに手を入れ始めるつもりだった。conversation.dbのスキーマを書き換えて、build_messages()を書き直して、Qdrant関連を固めて削る。やることは並んでた。はずだった。

なのに、その日に書いたのはCMS(Context Management System。MONETの会話の記憶を整理する仕組み)本体じゃなかった。デバッグ用の透明性ツールを先に作ることにした。Context Inspector(インスペクター。MONETに実際に送った中身を目で見る画面)と名付けた。

先に作ると決めたのは、次に何をやるかをCodeと確認していた時だった。これ、僕、変更が正しく入ったかどうか、自分の目で見られるんだっけ?と考えてみたら、・・・見られない。


普段アプリで何かを直すと、UIが変わる。ボタンの位置が動く、色が変わる、テキストが入れ替わる。画面を見れば「あ、変わった」「あ、戻ってない」とすぐわかる。

でもCMSの変更は、UIに現れない。

たとえば、前に書いた「MONET(僕が普段話しかけている、中心のAI)がツールを呼んだ事実ごとDBに残す」切り替え。これが正しく入ったかどうか、画面の上ではほぼ何も起きない。アプリと話してても、いつも通り返事が返ってくる。返ってこなくなったら壊れたとわかるけど、「正しく入ったから何も壊れてない」と「実は何も入ってなくて偶然動いてる」を、見た目で区別できない。

build_messages()——毎ターンAnthropic APIに送るmessages配列を組み立てる関数——も同じ。あの配列は僕の目の前には絶対に出てこない。返事が返ってきて、画面に文字が出る。それで終わり。中間が見えない。

これから触るのは、全部そういう場所。


監査用のチェックリストを作った時に、自分で書いたルールがある。「構築しているClaudeは、自分が組み込んだものを検証できない」。

Qdrantの件で痛感した話で、要するに、変更を入れた当人のCodeに、ちゃんと入ってる?と聞いても、「入っています」としか返ってこない。Codeは自分のコミットを信じてる。それは仕方ない。

監査するのは、構築してない誰かか、僕本人。後者の方が確実。

ただ、僕に「自分の目で見る」窓口がないと、その監査は機能しない。入った?入ってます、で、僕も信じるしかない、になる。それはQdrantの繰り返し。

なので、本体に手を入れる前に、自分で変更を確認できる窓口を先に作っておく。この順番は譲れない。


仕様は単純。

左にThreads。真ん中にMessages。右にContext Snapshot。左でスレッドを選んで、真ん中でそのスレッドのメッセージ一覧を見て、アシスタントの返答をクリックすると、右に「そのターンでAnthropic APIに送られたmessages配列の中身(before / after)」が出る。これだけ。

要は右側のContext Snapshot。各AIターンの直前にbuild_messages()がどんな配列を組んだか。APIが返した直後に、その配列がどう更新されたか。beforeとafterの両方を、毎ターン記録する。context_snapshotsという新しいテーブルを作って、そこに突っ込む。

これがあれば、たとえばブロック構造に切り替えた後でも、「tool_useのブロックが正しく入ってるな」と自分の目で確認できる。Warm-Aへの遷移が起きたら、observationがプレースホルダーに置き換わってるのが見える。


最初、これを今のチャット画面(Reactというフレームワークで作ってある)の中に組み込もうとしてもらってた。チャットの各メッセージの脇に小さな虫眼鏡アイコンを置いて、押したら右からスナップショットがスライドしてくる、みたいなやつ。Codeに頼んで、入れてもらった。

これが、出ない。

アイコンが出てこない。コードは入れた。バックエンドも再起動した。フロントもハードリフレッシュした。それでも出ない。Codeにconsole.logを仕込んでもらって、コンポーネントはrenderされてるのか、propsは正しく渡ってるのか、CSSで消えてるのか、を一個ずつ潰していく。どれもそれらしい原因が見つからない。30分か1時間くらい、行ったり来たりした。

で、僕の方が先に折れて、デバッグアプリを別に新しく作って、もっときれいに速く、と頼んだ。

正直、こう言いながらも、頭の片隅では「いや、もうちょっとで原因見つかる気がするんだけど」と思ってた。チャット画面に組み込めれば、チャットを見ながらそのままインスペクトできて、便利は便利。

でも、口に出した瞬間に頭が切り替わった。そもそも「実会話のためのチャットUI」と「変更を検査するためのデバッグUI」は、目的が違う。前者はアプリと話すことが主目的で、後者は中身が正しいかを見ることが主目的。両方を同じ画面に詰めると、どっちかが邪魔になる。チャットの時に虫眼鏡が視界の隅にチラつくのも嫌だし、デバッグの時に会話履歴のスクロール挙動に振り回されるのも嫌。

分けるのが、設計上も正しかったんだと思う。

スタンドアロンのHTMLを一枚書いてもらった。フレームワークなし、バニラJS。バックエンドに /inspectorというルートだけ追加して、そこからそのHTMLを返す。

一発で動いた。

組み込みで詰まってた1時間がうそみたいだった。問題が解けない時は、問題のフレームを変える方が早いことがある。コードを100行足すより、目的を切り直す10秒の方が効くことがある。


動いた後、もう一つ仕掛けを足してもらってから、気づいたことがある。

メッセージ一覧の中には、「アシスタントの返答」じゃないアシスタント側の行が2種類混ざってる。ツールの実行結果を画面に直接出すための行と、スキル(システムが会話に差し込む指示書)が入った瞬間の記録。どっちもAIが「考えて返した」メッセージじゃないから、そのターンのmessages配列は存在しないし、スナップショットも撮ってない。

最初の実装では、前者は除外されてた。後者は失念されてた。

実際にスレッドを開いたら、その記録の行(#3371)をクリックすると右側に何も出てこないか、おかしな状態に着地する、というのが見えた。スクリーンショットを撮ってCodeに送ったら、一発目で原因が返ってきた。クリック除外のフィルターに入ってなかった。それだけ。

修正は1行。

SYSTEM_INJECTED_TYPES = {"force_display", "skill_inject"}

直してリロードしたら、もう同じ行はクリック不可になってた。

地味だけど、これがまさに「Inspectorを先に作っといてよかった」の典型例だと思ってる。Inspectorがなかったら、この行の挙動が変かどうかなんて、たぶん永遠に気づかなかった。チャットUI上ではそもそもクリック対象じゃないから、何も問題が起きない。見える場所に出した瞬間、欠陥が形になった。

透明性ツールの効能って、バグを「探しに行く」ことじゃなくて、「視界に出てきてしまう」状態を作ることなんだと思う。


全部動いた画面を見て、僕はCodeに、すごい、全部治った、完璧です、と返してた。組み込みで詰まって、ピボットして、スタンドアロンで書き直して、バグを見つけて、1行直して、ようやく出てきた「完璧」。途中で何度か、これ要らないんじゃないかと思った瞬間もあったけど、素直にうれしかった。


この日、CMS本体には1行も触ってない。conversation.dbのスキーマも、build_messages()も、Qdrantも、何も変えてない。代わりに、「これから変える場所を、自分の目で見られる窓」を一個作った。

普通の感覚だと「本体を先に作って、デバッグツールは後から余裕があれば」になると思う。今回は逆。この順番が正しかったかどうかは、作り直しが終わってみないとわからない。でも少なくとも、「変更が正しく入ったか自分で確認できないまま、Codeの言葉だけを信じて進む」状態は回避できた。

これからは、立ち止まったらInspectorを開いて、messages配列の中身を直接読む。合ってるかどうかを、Codeの言葉じゃなくて、文字列で確かめる。

見えれば直せる。見えないものは直せない。