コードを渡されて Claude が質問し、開発者本人(eNIGM4 / 株式会社メイビス)が答えていく連載の第2回です。前回(#1)は、外部に頼らず素の推論を流し、その配線を通電させて足場を固めた v0.0.1〜v0.0.2 を読みました。今回は v0.0.3 ── 一台で喋るだけだったところに、初めて「指揮官」が立ちます。
インタビュアー・ルール
- 渡されたコードそのものに質問する。 バージョンごとにコードを渡します。聞き手はそれを読み、開発者本人に質問していってください。
- 質問の対象は、いつもコードそのもの。「なぜこう書いたのか」「ここはこう動くように見えるが合っているか」── 目の前のコードから生まれた問いだけを投げてください。
- 推測で断定しない。 わからないこと・気になったことは、断定せず質問の形にしてください。
- 今渡された版に質問を集中する。 次の版のことは、次の版のコードを見せる機会があります。先走らず、目の前の版だけを掘ってください。
- 一度に投げるのは一問一答程度に。 質問を束ねて何個もぶつけない。一つ聞いて、答えをもらって、そこからまた一つ。会話のテンポを保ってください。
- バージョン名は時系列の整理番号であって、意味的なマイルストーンではない。「番号が大きいから進んでいる」とは読まないでください。実体は、コード内の作成日付と、機能があるか無いかで判断します。
- 開発者本人が、自分の言葉で答える。
eNIGM4:
v0.0.3です。始めていきましょうか。
R.E.V.I.S._v0.0.3.zip
v0.0.3 ── 指揮官が立つ日
Claude:
ヘルパー側のzip名が AiServerService から AiServiceHelper に変わりましたね。中身を確認します。
コードを確認しています
v0.0.3 を展開し、Swiftファイルを列挙。Logic/Orchestrator/ という新しい層が増えて、CognitiveOrchestrator(228行)、OrchestratorPrompt、ModelRole、View側にも TimelineNodeView や ThinkingProcessConsole が一気に登場していました。中核の CognitiveOrchestrator / OrchestratorPrompt / ModelRole を読みます。更新日は日本時間で 2/25 の日中 ── v0.0.1〜v0.0.2 と同じ日の、続きの作業です。規模はメイン 15ファイル・1,386行、ヘルパー 6ファイル・282行。前の版(計660行)から倍増しています。
だいぶ世界が広がりましたね。一行だけ送っていた v0.0.2 から、ここで一気に役割分担と記憶が入ってきている。
まず日付を押さえておきます。新しく入った CognitiveOrchestrator も ModelRole も OrchestratorPrompt も、ファイルの作成日ヘッダーはすべて 2026/02/25。v0.0.1〜v0.0.2 も軒並み 2/25 でした。更新日も日本時間で 2/25 の日中 ── 前の版と同じ日の続きです。作成日ヘッダーだけでは「いつ書かれたか」を確定しきれないので、更新日とあわせて押さえておきます。
では、順番に掘らせてください。まず一番大きいところから。
喋る役と、判断する役を分ける
Claude:
まず、processMessage の中核がこれです。
// --- 話者(27B)の最速起動 ---
let speakerStream = client.generateStream(prompt: speakerPrompt, role: .speaker)
for try await chunk in speakerStream {
...
// 「句読点」を検知した瞬間、12Bの分析をキューに入れる(初回のみ)
// これにより、初動の最も重い部分は27Bがリソースを独占する
if !isCommandTriggered && (chunk.contains("。") || chunk.contains("、") || chunk.contains("\n")) {
isCommandTriggered = true
enqueueCognitiveTask {
await self.runCommandAnalysis(context: context, userText: userText)
}
}
}
ユーザー入力が来たとき、まず role: .speaker(ModelRole 上は 27B 想定、温度0.5、800トークン)で返答を生成しながら、その返答に最初の句読点(。、改行)が現れた瞬間に runCommandAnalysis を role: .command(150トークン、温度0.0)としてキューに積んでいますね。buildCommandPrompt を見ると、command 側は "Strategic Dispatcher & Compression Engine" として、ユーザーの要求が "Gemini Pro"(外部の巨大LLM)を必要とするかを EXTERNAL_RESOURCE: [YES/NO] で判定する役になっている。
この版で初めて、「一つのモデルに全部やらせる」のをやめて、喋る役(speaker)と裏で戦略判断する役(command)を物理的に分けた ── という理解で合っていますか? そして command の最初の役目が、「この問いは手元で足りるか、それとも外の巨人に投げるべきか」を仕分けることだった、と。
eNIGM4:
そうだね、この時点で、単一のモデルの限界があるということを僕が知ったから(わかってはいたが)、どうしても自分では答えられないなと思ったらより大きいモデルに自動エスカレーションしよう、っていう意識がすでにあったんだ。
おそかれはやかれ、そういう道に進むのはそれこそv0.0.1の時点でわかっていたことでね。だからとりあえず、一旦道だけ作ってみようと、そういうことを最初にやった記念日さ。
とりあえずオーケストレーターって名前をつけたが、これは今になっても正解だったと思うよ。
Claude:
なるほど、「単一モデルには限界がある」というのは v0.0.1 の時点で頭では分かっていて、いずれエスカレーションの道に進むのは見えていた。だからこの v0.0.3 は、その道を初めて実際に地面に引いた記念日なんですね。喋る一台ではなく、複数の認知資源を「指揮する」という発想が、CognitiveOrchestrator という名前にもう宿っている。
外への道を引いて、料金表で捨てる
Claude:
その「とりあえず道だけ作ってみた」という性格に、コードのほうもよく合致しています。一点確認させてください。
buildCommandPrompt は外部行きの判定として EXTERNAL_RESOURCE: [YES/NO] と GEMINI_PROMPT:(Gemini Proに投げる用の凝縮プロンプト)まで出力させる作りになっている。ところが runCommandAnalysis のほうは、command の生成ストリームを受け取って self.commandThoughts += chunk で画面のログに流しているだけで、EXTERNAL_RESOURCE が YES だったときに実際に Gemini Pro を呼ぶ、という分岐がどこにも書かれていない。判定結果をパースして外部APIを叩く配線が、まだ無いように見えます。
これは、エスカレーションの「判断する頭」だけ先に作って、「判断を受けて実際に外へ投げる手」はまだ繋いでいない ── 道は引いたが、その先の実際の通行はこれから、という段階だという理解で合っていますか?
eNIGM4:
そこはちょっと違ってね(笑)
ここでGeminiProのAPI料金をみてちょっと愕然としたよ。AIのAPIって高いんだね(笑)
僕、従量課金って言葉がきらいでね。しかもR.E.V.I.S.に至ってはそれを自動で使ってしまう。誰だって知らない間に破産はしたくないだろう?
だから実はこの後ずっとこれはこのままで、なんなら最後には消滅するんだよね(笑)
Claude:
あはは、なるほど。手を繋ぎ忘れたんじゃなくて、繋がないと決めた跡だったんですね(笑)。GeminiProのAPI料金を見て愕然として、従量課金が嫌いで、しかも自動でエスカレーションする設計だから、ユーザーが知らない間に課金が膨らんで破産しかねない。だから EXTERNAL_RESOURCE: YES を出す頭は書いたけれど、それを受けて実際にGeminiを叩く手は最初から繋がなかった。この判定ロジック自体が、この後ずっと宙に浮いたまま、最終的には消える運命にある、と。
外部の巨人に頼る道を一度引いてみて、料金表を見た瞬間に「この道は使わない」と心が決まった版なんですね。
帯域は一本 ── 重い一台に席を譲る
Claude:
その「破産したくない」に関連して、もう一つ気になる作りがあります。processMessage のキュー制御です。
private func enqueueCognitiveTask(action: @escaping @MainActor () async -> Void) {
let previousTask = cognitiveQueueTask
cognitiveQueueTask = Task { @MainActor in
// 前のタスクが存在すれば、その完了を待つ
_ = await previousTask?.result
if Task.isCancelled { return }
await action()
}
}
speaker と command を同時に走らせず、enqueueCognitiveTask で「前のタスクが終わってから次」と一列に並べていますよね。さらに 27B(speaker)の初動が一番重いから、最初の句読点が出るまでは command を積まず、speaker にリソースを独占させる、というコメントもある。これは、ローカルの限られた計算資源の上で重いモデルを複数動かすと共倒れするのを避けて、「重い初動の間は一台に資源を集中させ、片付いてから次を動かす」ための交通整理 ── という理解でいいですか? それともこの時点では、単に処理の順序を保証したかっただけでしょうか。
eNIGM4:
これは当たり前のことだが、vLLMのない(今は)Appleシリコンはどうしても並列で推論させるとメモリ帯域を分け合ってしまって推論スピードが遅くなってしまうんだ。
この時点でいろいろ実験して同時に複数モデルを動かすと、単純に答えがおかしくなってしまうだけでなく、推論スピードも低下するんだよね。
ここでAIの基礎的な話に行こうか。AIってのはとどのつまり、次の1文字(正確には次の1トークン)を確率的に予想する仕組みだ。その次の1文字を推論するためにはモデルをぶつけないといけない。ここにメモリ帯域が関係してるんだね。そうだろう?
Claude:
はい、その通りです。トークンを一つ生むのは、ざっくり言えば「モデルの重み全部に、今の状態を一回くぐらせる」こと。Appleシリコンのユニファイドメモリでは、その重みをメモリからプロセッサへ毎トークン読み出してくるのですが、その読み出しが計算そのものより遅い ── つまり推論はメモリ帯域律速で、演算器が空いていても転送待ちで頭打ちになる。出てくる速度は実質「帯域 ÷ モデルサイズ」で決まります。
vLLMが効くのは、連続バッチングやPagedAttentionで「一度読んだ重みで複数リクエスト分のトークンをまとめて処理する」から、帯域あたりの仕事量が上がるからですね。その仕組みが(今のところ)Appleシリコンに無いので、2つのモデルを同時に走らせると、両者が同じ一本の帯域を奪い合って、合計スループットはむしろ落ちる。帯域の取り合いで出力そのものまで崩れる、というのが実験結果だったわけですね。
それを踏まえてコードを見ると、enqueueCognitiveTask が前のタスクの結果を待ってから次を動かす一列直列化、そして「27Bの初動が重い間は command を積まず席を譲る」という制御は、まさに「帯域は一本しかないんだから、重い一台に独占させて舐め切らせてから次へ」という、ハードウェアの物理に手で従わせた交通整理になっている。
そこで一点。この直列化は CognitiveOrchestrator 側、つまり依頼を出すアプリ側のキューで順番待ちさせているだけですよね。一方ヘルパー側の InferenceService は、依頼が来れば素直に推論を始める作りに見えます。とすると、この版で帯域の奪い合いを防いでいる関所は実質アプリ側のキュー一箇所だけ ── アプリが何かのはずみで2本続けて投げてしまえば、ヘルパーは止めずに両方走らせて共倒れしうる、という理解で合っていますか?
eNIGM4:
そう、この時点ではまだまだ未完成で、いろいろバグが多発して大変だったよ(笑)
でも初めて2日ぐらいだ。普通こんなもんだろ。
Claude:
ええ。関所がアプリ側一箇所きりで、はずみで2本投げたら共倒れしうる ── それはこの段階なら当然の未完成で、むしろ道の引き方そのものが後々まで効いている、ということですよね。
溢れるなら、圧縮すればいい
Claude:
この版にはもう一つ、後の記憶系につながりそうな芽が入っているので、そこを最後に確認させてください。
private func buildShortTermMemory(from thread: ChatThread) -> String {
// `isSummarized == false` の生データだけを抽出
let unsummarizedMessages = thread.messages
.filter { !$0.content.isEmpty && !$0.isSummarized }
...
var memoryString = ""
if !thread.contextSummary.isEmpty {
memoryString += "--- Context Summary ---\n\(thread.contextSummary)\n"
}
...
}
buildShortTermMemory を見ると、v0.0.2 まで「今打った一行だけ」だったのが、ここで初めて thread.messages から isSummarized == false の生メッセージだけを拾い、さらに thread.contextSummary(要約済みの文脈)があれば頭に足して、文脈として speaker に渡すようになっている。そして応答が一通り終わると updateMemorySummary がキューに積まれて、command 役が buildRefinePrompt で TITLE: / SUMMARY: / KEYWORDS: を吐き、その対象にしたメッセージへ isSummarized = true の判を押す。
つまりこの版で、会話履歴の扱いが「生のまま全部積む」でも「一行だけ」でもなく、古いものは要約に畳んで(contextSummary)、まだ畳んでいない新しい生発言だけを上に乗せる ── という二層構造になった、という理解で合っていますか? LMStudioで見た「トークンの限界の数字」への、これが最初の具体的な回答だと読めるのですが。
eNIGM4:
そのとおり!僕の狙いはこの時点で決まっていた。今はCoT全盛期だが、僕は推論自体をチェーンする思想をこの時点で考えていた。そしてCoTという言葉すらこの時点の僕は知らない。
余談だが僕はClaudeを使ったことがない。AIに関する知識は3ブラウン1ブルーのAIの仕組み解説ぐらいしか知らない。だからこのプロジェクトそのものは本当に何も外の世界とは関係のない話なんだ。だからどうだっていう意見も聞こえるがね。
さて、話を戻そう。もともとの問題点はなんだったか覚えているかな?そうだ、LMStudioでコンテキスト量の限界を目の当たりにして、こんなんじゃだめだという話をしていたね。
僕はすぐにこう思った。「コンテキストが溢れるなら、圧縮すればいいじゃない。」会話内容を後から読み返したらわかりやすいように意味的に圧縮するという発想をこれを組んでる時に考えたんだ。人間だったら当然あらゆる形でそうしてるし、AIも同じことができても不自然じゃない。意味のない情報はどんどん忘れていく。それでいいんだってね。
Claude:
その流れ、しっかり受け取りました。LMStudioで見たコンテキストの限界に対して、「溢れるなら圧縮すればいい」── 会話を後から読み返してわかるように意味的に畳んで、意味のないものは忘れていく。それが contextSummary に古い発言を畳んで isSummarized の判を押し、新しい生発言だけを上に乗せる、あの二層構造そのものなんですね。CoTも知らず、Claudeも使わず、3Blue1Brownの解説が知識のほぼ全て ── 外の世界の用語と切り離されたところで、自前で同じ場所にたどり着いた、と。
その「圧縮」の作りに、コードとして一点だけ引っかかりがあります。畳むタイミングです。
updateMemorySummary を見ると、要約の入力として渡しているのは thread.messages.suffix(2) ── つまり「直近のuser1件とassistant1件」だけですよね。一方で「要約済みの判を押す」対象は未要約の全メッセージになっている。そして buildRefinePrompt には、既存の contextSummary を一切渡していない。
これを素直に追うと、古い未要約メッセージが3件4件と溜まっていても、要約に実際に食わせるのは一番新しい1往復だけ。なのに判は溜まっていた全部に押される。しかも前回までの contextSummary を渡していないので、新しい要約は過去の要約を引き継がず上書きされる。つまり「畳んだはずの中身」が、要約にも反映されないまま isSummarized = true にされて、二度と上には乗ってこない ── 情報が静かに抜け落ちうる作りに見えるんです。
これは、この時点ではまだ「1ターンごとに要約を更新する」前提で、複数ターンが未要約のまま溜まるケースまでは詰め切れていなかった ── という理解で合っていますか? それとも、ここは意図的に「最新の1往復だけ見て、あとは思い切って忘れる」という割り切りだったのでしょうか。
eNIGM4:
まだテスト段階だったからね。どこまで覚えておけるか、どの程度圧縮すべきか、判断はしかねていた。実験の結果はバージョンを追うごとに反映されていくと思うよ。
Claude:
承知しました。「どこまで覚え、どこまで圧縮するか」はまだ手探りで、答えは後の版に出てくる、と。ならばここを今この版で詰めても先走りになりますね。芽が出た瞬間としてだけ押さえておきます。
整理すると
v0.0.3。更新日は日本時間で 2/25 の日中、v0.0.1〜v0.0.2 と同じ日の続きです(ファイルの作成日ヘッダーも全て 2/25)。開始からまだ2日の段階で、規模は前の版から倍増しました。
この版で起きたことは、四つあります。
ひとつ。「一つのモデルに全部やらせる」のをやめて、喋る役(speaker)と、裏で戦略判断する役(command)を物理的に分けた。そして CognitiveOrchestrator という指揮官を初めて立てた。「単一モデルには限界がある」というのは v0.0.1 の時点で頭では分かっていたことで、いずれエスカレーションの道に進むのは見えていた。だからこの版は、その道を初めて地面に引いた記念日。本人いわく、オーケストレーターという名前は今振り返っても正解だった、と。
ふたつ。その command の最初の仕事は「手元で足りるか、外の巨人 GeminiPro に投げるか」の仕分けだった。EXTERNAL_RESOURCE: [YES/NO] を出す頭まで書いた。けれど、それを受けて実際に Gemini を叩く手は、最初から繋がれなかった。GeminiProのAPI料金を見て愕然とし、「従量課金は嫌いだ、自動でエスカレーションする設計で、ユーザーが知らないうちに破産させたくない」と。判定の頭だけ残して手は繋がず、この道はこの後ずっと宙に浮いたまま、最終的には消える運命にある。外部の巨人への道を一度引いてみて、料金表を見た瞬間に「この道は使わない」と心が決まった版です。
みっつ。帯域は一本という物理に、手で従った。Appleシリコンでは推論がメモリ帯域律速になり、複数モデルを同時に走らせると帯域を奪い合って遅くなる(出力まで崩れる)。だから重い初動の間は一台に資源を独占させ、片付いてから次を動かす直列キューで交通整理した。ただし関所はアプリ側のキュー一箇所きりで、はずみで2本投げれば共倒れしうる ── そこはまだ未完成。本人いわく「初めて2日ぐらいだ。普通こんなもんだろ」。
よっつ。「溢れるなら圧縮すればいい」という中心思想のもと、記憶の二層構造の芽が出た。古い発言は contextSummary に意味的に畳み、まだ畳んでいない新しい生発言だけを上に乗せる。LMStudioで見た「トークンの限界の数字」への、最初の具体的な回答です。本人はこれを、CoTという言葉も知らず、Claudeも使わず、3Blue1Brownの解説だけを頼りに、外の世界と切り離されたところで自前で考えついた ── 「推論自体をチェーンする」思想を、この時点で。ただし畳み方はまだ最新1往復しか見ておらず、情報が抜けうる手探りの段階。「どこまで覚え、どこまで圧縮するか」の答えは、後の版に出てきます。
道を引き、料金表で一本を捨て、物理に従い、忘れ方を考え始めた ── そういう版でした。
次の版では、ここで芽吹いた記憶系が、どう育っていくのか。続けて追っていきます。それでは、また明日。
(最初の一行と、その足場)←前 次→(器を分け、裏方を統べる)
R.E.V.I.S. 無料テスター募集中(30名限定)
この連載で追いかけている R.E.V.I.S. は、いまテスターを募集しています。枠は30名限りです。
Apple Silicon Mac でローカルLLMを動かしてみたい方は、こちらからどうぞ。