コードを渡されて Claude が質問し、開発者本人(eNIGM4 / 株式会社メイビス)が答えていく連載の第4回です。前回(#3)は、記憶がスレッドを越え、裏方を統べる土台が据えられた v0.0.4 を読みました。今回は v0.0.5 ── ここでプロジェクトは「構造を組む」段階から、「どのモデルで何ができるのか」を実機で探る基礎研究へと移ります。
インタビュアー・ルール
- 渡されたコードそのものに質問する。 バージョンごとにコードを渡します。聞き手はそれを読み、開発者本人に質問していってください。
- 質問の対象は、いつもコードそのもの。「なぜこう書いたのか」「ここはこう動くように見えるが合っているか」── 目の前のコードから生まれた問いだけを投げてください。
- 推測で断定しない。 わからないこと・気になったことは、断定せず質問の形にしてください。
- 今渡された版に質問を集中する。 次の版のことは、次の版のコードを見せる機会があります。先走らず、目の前の版だけを掘ってください。
- 一度に投げるのは一問一答程度に。 質問を束ねて何個もぶつけない。一つ聞いて、答えをもらって、そこからまた一つ。会話のテンポを保ってください。
- バージョン名は時系列の整理番号であって、意味的なマイルストーンではない。「番号が大きいから進んでいる」とは読まないでください。実体は、コード内の作成日付と、機能があるか無いかで判断します。
- 開発者本人が、自分の言葉で答える。
eNIGM4:
v0.0.5、いってみましょう。
R.E.V.I.S._v0.0.5.zip
v0.0.5 ── 空が青いのはなぜか
Claude:
日付がさらに進みましたね。新顔は OrchestratorJSON、OrchestratorExternal、Audio/Input/VoiceInputManager。それと TaskQueue が大きく育っています。
コードを確認しています
v0.0.5 を展開し、新顔と差分を確認。規模はメイン側で 26ファイル・2,949行(前の版から約620行増)。更新日は日本時間で、外部系の OrchestratorExternal が 2/27、Orchestrator 中核の5ファイルがまとめて 2/28 に書き換わっていました。音声入力の VoiceInputManager は作成日の欄が空。TaskQueue は前の版から大きく育ち、OrchestratorJSON という新しいファイルに ModelRole の組み替えが入っています。
日付の物証から押さえます。外部系統の OrchestratorExternal が 2/27、Orchestrator 中核の5ファイルが 2/28 にまとめて書き換わった。前の版(v0.0.4)が 2/25〜26 だったので、ここで日付がさらに一歩進んでいます。
そして、この版はコードの見た目以上に、性格が変わった版に見えます。OrchestratorJSON に残るのは、何度も叩いて調整した跡のようなプロンプト群。まず、そこから聞かせてください。
OrchestratorJSON を見ると、ModelRole が今までの speaker / command から、.tier1 / .tier2 / .tier3 に組み替わっていて、コメントで Tier1=4B、Tier2=12B、Tier3=27B と、モデルの大きさの段階に結びついている。そして各処理が重さに応じて段を選んでいる ── 複雑な論理思考(処理3)は一番賢い Tier3(27B)、事後検証やルーティングやプロフィール抽出は中堅の Tier2(12B)、チャット圧縮やスレッド要約は一番軽い Tier1(4B)で速度優先、というふうに。
// 【処理3】複雑な論理思考 (CoT) -> Tier 3 (27B)
let response = await runCommandStream(prompt: prompt, preferredRole: .tier3, temperature: 0.2)
...
// 【処理6】事後検証 (Audit) -> Tier 2 (12B)
let response = await runCommandStream(prompt: prompt, preferredRole: .tier2, temperature: 0.0)
...
// 【処理11】チャット局所圧縮 -> Tier 1 (4B) 速度優先
let response = await runCommandStream(prompt: prompt, preferredRole: .tier1, temperature: 0.1)
この「どのサイズで足りるか」を決めるまでに、実際どんな実験をしたんですか? コードはサイズ別に仕事を割り当てているけれど、その裏には「このサイズなら担える/担えない」を測った何かがあったはずで。
eNIGM4:
実は最初の、自分のシステムへの質問は「空が青いのはなぜですか?」だった。一般的でわかりやすいやつだね。レイリー散乱ぐらいはモデルなら答えられて当然だと思った。
面白かったのは、モデルのそのパラメーター数ごとに回答の質が全く違ったんだよ。
とくに4Bは酷かった。いろんなモデルサイズ、量子化を試したけど、正直システムでロールを担わせながら質問するにはまったく耐えられていない。12Bぐらいが最低ラインだったね。
Claude:
「空が青いのはなぜか」── レイリー散乱という、知っていて当然のはずの問いを物差しにして、サイズごとの答えの質を測ったんですね。そして4Bは、その当然のはずの問いにすら耐えられなかった。
それはコードの割り当てとぴたり符合します。OrchestratorJSON で 4B(Tier1)に振られているのは、チャット圧縮やスレッド要約という、決まった文章を縮める軽い裏方だけ。ユーザーと向き合って論理を組む処理(CoT)は 27B、事後検証やルーティングは 12B。「4Bにはロールを担わせられない、12Bが最低ライン」というその実測が、そのまま「4Bは軽い裏方、対話は12B以上」というコードの段割りになっている。
その「いろんなサイズと量子化を試した」のところ、もう少し聞かせてください。Tier はパラメータ数(4B / 12B / 27B)だけで段を切っていて、コード上は量子化の軸は出てこない。でも実機でローカルLLMを回すと、同じパラメータ数でも量子化(Q4、Q8)で、メモリ消費も速度も、たぶん賢さまで変わってきますよね。量子化は、実際どれくらい効きましたか?
eNIGM4:
量子化も結構面白くてね。
今だからわかることもあるけど、当時はQ4、Q8以外の選択肢や発想がなかったから、それでしか調査してないね。
ただ速度に関しては明確で、流石に重みデータが軽いQ4の方が速度は速かった。当然だよね!メモリ帯域を使うんだから、データは軽い方がいい。
でも問題もあって、Q4クラスになると、そもそもの知識量がグッと減ってしまって、めちゃくちゃ嘘つくんだよ(笑)
27Bクラスだとそもそもパラメータが多いから影響が少なかったけど、12Bクラスだとかなり深刻な形で露出してしまって、かなり使い物にならなかったんだ。
Claude:
なるほど、ここに綱引きがあるんですね。Q4は重みが軽いぶん速い ── これは前に伺った「推論はメモリ帯域律速、速度は帯域÷モデルサイズ」のまさにその通りで、重みが軽ければ毎トークンの読み出しが速くなる。でもその軽さの代償が、知識量の目減りと「嘘をつく」こと。しかもその劣化の出方がサイズで違って、27Bはパラメータの厚みで吸収できるが、12BをQ4まで削ると一気に露呈して使い物にならなくなる。
速さを取れば嘘が増える、賢さを取れば重くなる ── その綱引きの中で、どのサイズをどの量子化で使えば「ロールを担える最低ライン」に乗るか、を実機で探っていた、と。
eNIGM4:
そう、その仕事を全うさせるのに、どういうサイズのモデルを使うのがいいのか、足りてるのかたりてないのか、どこまでできるのか、できないのか。
ここが基礎研究だね。ちなみに大きいものだとLlama3-70Bまで扱ったよ。僕は当時M4MaxのMBPしかなかったから、それでどこまでやれるかを本当にいろいろ実験した。
言うことを聞かせる ── プロンプトの泥仕事
Claude:
その「叩いて調整した」跡が、OrchestratorJSON にもはっきり出ています。各処理 ── 推論、監査、ルーティング、タイトル生成、プロフィール抽出、要約 ── がどれも、モデルの応答をそのまま使うのではなく、extractJSON でいったん JSON 部分を取り出してから try? でデコードを試みる形になっている。try? で受けているということは、デコードが失敗することもある前提ですよね。
ローカルの、しかも小さめのモデルに「決まった形式の JSON で返せ」と守らせるのは、相当手こずるところだと思います。プロンプトを叩いていて一番てこずったのは、このあたりだったんですか?
eNIGM4:
そう、そこでもモデルのIQが関係してくるんだよ。
Gemma3-12B-Q4は知識量こそ少ないけど、JSONパースはある程度できたんだ。
ただプロンプトがほんとに難しくてね(笑)書きまくったらJSONパースエラー頻発するし、ルールが薄いと意味のないJSONを出しちゃう。どの量のタスク、JSON、命令まで守れるか、ほんとに何回も試行錯誤したよ。
この後のバージョンでもずっとその部分は書き直してるから、この後も確認してくれ。
Claude:
なるほど、ここでも効いてくるのが「IQ」なんですね。知識量(何を知っているか)とは別に、「指示された形式をどれだけ守れるか」という賢さがあって、Gemma3-12B-Q4 は知識は乏しくても、JSONを返すという約束はある程度守れた。でもそれも綱渡りで ── プロンプトを盛りすぎると守りきれずにパースエラーを連発し、逆に薄すぎると形式は守るが中身のない JSON を出す。「どれだけの量のタスクと命令まで一度に守らせられるか」の上限を、何度も叩いて探っていた、と。
承知しました。その書き直しはこの先の版でも続くとのことなので、各版で OrchestratorJSON やプロンプト周りがどう変わっていくか、追いながら確認していきます。
帯域は一本、は分かっていた
Claude:
70Bまで手元のM4 Max一台で。その総当たりの中で、複数のモデルを同時に動かす実験もされていましたよね。前に「Appleシリコンで並列にすると帯域を食い合って遅くなる」と伺いましたが、実測としてはどう見えていたんですか?
eNIGM4:
モデルごとにキャッシュが存在するということはわかっていたから、12Bと27Bを同時に走らせる実験も通じてしてはいた。
でも、前にも触れたけど、t/sがあきらかに遅くなるのはわかっていたからね。メモリ帯域を使い切ってるんだ。GPUのグラフを見ても振り切ってたしね。
Claude:
やはり、ですね。Q4が速いのも、並列で遅くなるのも、根っこは同じ一本の帯域。重みを毎トークン読み出す以上、軽くすれば速いし、二つ同時に走らせれば一本の帯域を奪い合って両方遅くなる ── GPUのグラフが振り切る、というのはその帯域を使い切った姿そのものですね。
その実測があるから、TaskQueue が重い推論は直列を基本にしつつ、ベクトル化のような軽い処理だけ並列キューに逃がす、という線引きにも実体が宿る。「帯域は一本」という前提が、コードの交通整理の根拠になっている。
賢さの棚と、まだ通電していないフォールバック
Claude:
その「仕事ごとに賢さを割り当てる」設計で、コードに一つ確認したいことがあります。OrchestratorJSON の resolveTier です。
// ※ 実際にロードされているTierのセット。SettingsView等でロード成功時に更新する想定
var availableTiers: Set<ModelRole> {
return [.tier1, .tier2, .tier3]
}
/// 要求されたTierが不在の場合、ロード済みの最適なモデルに自動フォールバックする
func resolveTier(preferred: ModelRole) -> ModelRole {
if availableTiers.contains(preferred) { return preferred }
// 読めていない場合:賢い順(Tier3 -> Tier2 -> Tier1)にフォールバック先を探す
let fallbackOrder: [ModelRole] = [.tier3, .tier2, .tier1]
for tier in fallbackOrder {
if availableTiers.contains(tier) { return tier }
}
return .tier1 // 最後の砦(通常はここには来ない)
}
resolveTier は「欲しい Tier がロードされていなければ、賢い順に繰り下げる」フォールバックを持っている。ところがそれが参照する availableTiers(いまロード済みの Tier 集合)が、return [.tier1, .tier2, .tier3] と固定で返すだけになっていて、コメントにも「仮置きとして、現在はすべてロードされている前提」とある。つまり本当のロード状態とは繋がっていない。
とすると、繰り下げのロジックは書かれているけれど、availableTiers が常に「全部ある」を返す以上、この版では一度も繰り下げが発動しない ── 機構だけ先に置いて、実際のモデルロード状態との配線は後回し、という段階に見えます。これは合っていますか?
eNIGM4:
繰り下げはこの後の話なのかな。ちょっと先走りすぎたね。
とにかく、どれで何ができるのかをこの辺りでずっと探っていたという認識でいいと思う。
Claude:
承知しました、繰り下げの実際の発動は先の版の話ですね。先走らず、ここでは「枠だけ置かれた」事実だけ記録しておきます。この版の本質は、あなたの言う通り「どのサイズのモデルで何ができるのか・足りるのか」を探っていた基礎研究の時期で、コードに残る Tier 分けは、その探索の中間スナップショットだ、と。
もう一つ、この版に芽として置かれているものに気づきました。CognitiveOrchestrator を見ると、resolveTier(ロード状態での繰り下げ)とは別に、Tier1 がまず応答を試みて、自分では手に負えないと判断したら上位の Tier へ「上告」する、という仕掛けの最初の形があります。
先ほど伺った「小さいモデルは嘘をつく」── その嘘への一つの答えが、「一台に全部を背負わせず、手に負えなければより賢い段へ引き継ぐ」というこの上告なのかな、と読めました。仕組みの中身はこの先の核心に関わりそうなので深くは立ち入りませんが、この芽を置いたのは、まさにその「嘘をつくモデル」への対処としてだった、という理解で合っていますか?
eNIGM4:
ほんとによく気がつくね!それは次の回で本格的に話すとしようか。
開かないと決めた蛇口
Claude:
もう一つの新顔、OrchestratorExternal(2/27付)も見させてください。前に「GeminiProの料金表を見て、外部行きの手は繋がず、いずれ消える運命」と伺った、あの外部エスカレーションの系統が、ファイルとして独立した形ですよね。
handleExternalKnowledgeFetch を見ると、まず 12B に Gemini 用の最適化クエリを生成させるところまでは実際に動く。ところがその先、本来 Gemini を叩くはずの場所は「実際のGemini API通信(現在はモック)」とコメントされていて、1.5秒の擬似的な遅延を挟んだ後、"API is offline" として返答を偽装している。しかもその偽装の中身が、「外部AIには今アクセスできない、と日本語でユーザーに丁寧に伝えろ」という、speaker への指示文になっている。
// 2. 実際のGemini API通信(現在はモック)
// 擬似的なネットワーク遅延(1.5秒)
try? await Task.sleep(nanoseconds: 1_500_000_000)
print("[EXTERNAL] API is offline. Returning mock error response.")
// 3. Geminiからの返答を偽装(英語で返す)
// 27B/12Bに「外部通信ができない旨をユーザーに伝えろ」と英語で指示する
return """
[SYSTEM NOTICE FROM EXTERNAL API]
Connection to the external AI (Gemini Pro) is currently unavailable due to offline mode.
Please politely inform the user in Japanese that you cannot access external real-time data or the internet at this moment.
"""
つまりこの版の外部エスカレーションは、「クエリを作る頭」までは生きているけれど、その先は最初から繋がず、代わりに『今は外部に繋げません』とユーザーに言わせるための、わざと失敗するダミーとして固められている ── 外部の蛇口は付けたけれど、栓を閉めた状態にしてある、という理解で合っていますか?
eNIGM4:
当然さっきも話したように、エラー回避のために適当に書いていたが、外部APIは最後まで繋がらず消滅する。
Claude:
承知しました。エラー回避のために置いたダミーで、外部APIはこの先も最後まで繋がることなく、いずれ消滅する。前に伺った筋 ── 料金表を見た瞬間に「自動で従量課金の蛇口はひねらない」と決めた、その後始末の姿がこれなんですね。外側の配管だけは一時的に伸びるけれど、栓は一度も開かないまま、最後には配管ごと撤去される運命だと。深掘りは無用な領域として、この版に「閉じた蛇口」が残っていた事実だけ記録しておきます。
この版、全体を通してどう振り返りますか。
eNIGM4:
とにかくこの版はまとめると、どこで何が必要なのかを探り、今後の設計方針を固めていたという状態だね。
整理すると
v0.0.5。更新日は日本時間で、外部系の OrchestratorExternal が 2/27、Orchestrator 中核の5ファイルが 2/28。音声入力 VoiceInputManager は作成日の欄が空でした。前の版(2/25〜26)から、日付がさらに一歩進んでいます。規模はメイン側で 26ファイル・2,949行、前の版(23ファイル・2,330行)から約620行増えました。
この版で起きた一番大きなことは、機能が増えたことではありません。プロジェクトの段階そのものが、「構造を組む」から「どのモデルで何ができるのかを実機で探る基礎研究」へ移ったことです。本人いわく、ここで初めて、テストをしながらプロンプトを何度も叩いて調整する作業に入った ── プロンプトエンジニアリングの基礎も知らないまま、モデルとプロンプトの組み合わせを総当たりして。
その基礎研究の中身が、今回いくつも語られました。
最初のシステムへの質問は「空が青いのはなぜか」。レイリー散乱という、知っていて当然のはずの問いを物差しに、サイズごとの答えの質を測った。すると4Bは、その当然の問いにすら耐えられない。12Bが最低ライン。量子化も総当たりした ── Q4は重みが軽いぶん速いが(メモリ帯域を使うのだから、軽い方が速いのは道理)、知識量が目減りして「嘘をつく」。しかもその劣化はサイズで出方が違い、27Bはパラメータの厚みで吸収するが、12BをQ4まで削ると一気に露呈して使い物にならない。大きいものは Llama3-70B まで、手元のM4 Max一台で。
そしてもう一つの軸が、プロンプトの泥仕事でした。モデルに決まった形式の JSON を返させる ── ここでも効いてくるのが「IQ」。知識量とは別に「指示された形式をどれだけ守れるか」という賢さがあり、Gemma3-12B-Q4 は知識は乏しくてもJSONはある程度守れた。だが綱渡りで、プロンプトを盛りすぎればパースエラーを連発し、薄すぎれば中身のない JSON を出す。「どれだけの量のタスクと命令を一度に守らせられるか」の上限を、何度も叩いて探っていた。本人いわく、この書き直しはこの先の版でもずっと続く。OrchestratorJSON が extractJSON でいったん JSON を取り出して try? でデコードを試みているのは、その「守りきれないことがある」前提への備えです。
そして「帯域は一本」は、もう分かっていたことでした。Q4が速いのも、12Bと27Bを同時に走らせると t/s が落ちるのも、根は同じメモリ帯域。GPUのグラフが振り切るのは、その帯域を使い切った姿。だから TaskQueue が重い推論を直列にしつつ軽い処理だけ並列へ逃がす線引きにも、実体が宿っている。
コードに残ったのは、その探索の中間スナップショットです。ModelRole が機能名(speaker / command)から能力の階層(Tier1=4B / Tier2=12B / Tier3=27B)へ組み替わり、裏方の各処理に「この賢さで足りる/最上位が要る」と資源を割り当てた。resolveTier には「足りなければ賢い順に繰り下げる」フォールバックの枠が置かれた(ただし参照先の availableTiers はまだ固定値のハリボテで、繰り下げは一度も発動しない)。外部エスカレーションは OrchestratorExternal として独立したが、その先は「わざと失敗するダミー」で栓をされ、最後まで開かず消える運命のまま固められた。
そしてもう一つ、芽が置かれました。下位の Tier がまず応答を試み、手に負えなければ上位へ「上告」する、という仕掛けの最初の形です。「小さいモデルは嘘をつく」という実測への、一つの答え ── 一台に全部を背負わせるのではなく、手に負えない時だけ賢い段へ引き継ぐ。本人いわく、これは次の回で本格的に話すことになる。芽が置かれた事実だけ、ここでは押さえておきます。
本人の総括が、この版の芯をいちばんよく捉えています ── 「どこで何が必要なのかを探り、今後の設計方針を固めていた」。新しい能力を足したのではなく、Tier という賢さの軸と、優先度という資源配分の軸を、実機で確かめながら定めていた。ここから先の版は、この地図の上に建っていきます。
次の版では、その地図の上に最初に何が建つのか ── そして、ここで芽だけ置かれた「上告」が、どう動き出すのか。続けて追っていきます。それでは、また明日。
(器を分け、裏方を統べる)←前 次→(小さく分けて、何度も考える)
R.E.V.I.S. 無料テスター募集中(30名限定)
この連載で追いかけている R.E.V.I.S. は、いまテスターを募集しています。枠は30名限りです。
Apple Silicon Mac でローカルLLMを動かしてみたい方は、こちらからどうぞ。