コードを渡されて Claude が質問し、開発者本人(eNIGM4 / 株式会社メイビス)が答えていく連載の第11回です。前回(#10)は、v0.1.2 で M5 MacBook Pro の発売をきっかけに「1Mac1推論」の哲学が結晶し、家庭の LAN で分散できる作法をゼロから設計した話でした。同じ v0.1.2 を続けて読みます。横へ広がる気づきが Network 棚として形になった同じ日、もう一つの軸 ── 1Mac そのものを縦に研ぎ込む試行錯誤 ── が、コードのあちこちで同時に走っていました。
インタビュアー・ルール
- 渡されたコードそのものに質問する。 バージョンごとにコードを渡します。聞き手はそれを読み、開発者本人に質問していってください。
- 質問の対象は、いつもコードそのもの。「なぜこう書いたのか」「ここはこう動くように見えるが合っているか」── 目の前のコードから生まれた問いだけを投げてください。
- 推測で断定しない。 わからないこと・気になったことは、断定せず質問の形にしてください。
- 今渡された版に質問を集中する。 次の版のことは、次の版のコードを見せる機会があります。先走らず、目の前の版だけを掘ってください。
- 一度に投げるのは一問一答程度に。 質問を束ねて何個もぶつけない。一つ聞いて、答えをもらって、そこからまた一つ。会話のテンポを保ってください。
- バージョン名は時系列の整理番号であって、意味的なマイルストーンではない。「番号が大きいから進んでいる」とは読まないでください。実体は、コード内の作成日付と、機能があるか無いかで判断します。
- 開発者本人が、自分の言葉で答える。
v0.1.2 ── 打ち始めた瞬間に
Claude:
分散の方は次の版で配線が通るのを待つとして、同じ v0.1.2 の中で、別の軸でもコードが動いていました。
コードを確認しています
TaskQueue の差分を見ると、前の版にあった「タスクが終わったら 200 ミリ秒の冷却期間を置く」という処理がまるごと消えていて、代わりに「三重の防衛線」── パイプラインロック・プレフィル凍結・タイピング検知 ── が入っている。OrchestratorProcess には triggerPrePrefill / abortPrePrefill という新しい関数。OrchestratorPrompt は 613→746 行で、これまで一塊だった Tier2 のプロンプトが buildTier2StaticPrompt と buildTier2DynamicPrompt に二分されている。そしてヘルパー側の NativeMLXEngine には +Speculative.swift という拡張ファイルが新出。── 速度に効く仕掛けが三方向から同時に試されている匂いです。
まず TaskQueue から伺いたいことがあります。前の版ではタスクが終わるたびに 200 ミリ秒の冷却期間を置いていたのが、この版では丸ごと消されている。コメントには「VRAM パージは XPC 側が完全に担保するので、即座に次へ」とある。何が起きたんですか?
eNIGM4:
そもそも! 冷却なんかいってるけど、0.2s 待ったところで大して冷却できないし、それは MacOS の仕事だからね。
ただの遅延でしかない上に、非同期キューのエラー対策でねじ込んでいたから、もっと正確にミリ秒単位で削るために、完全に作り替えようとしたんだ。
Claude:
名前と実態がズレていた、と。「冷却」という名目だけど、実態は ── OS がやる仕事を 0.2 秒待つだけの遅延、しかも本来の動機は冷却ですらなく、非同期キューのエラー対策でねじ込んでいた苦肉の策。名前に騙されず、実態で削りにいったわけですね。
コードでも裏が取れます。前の版では processNext() が「currentTask == nil && !isCoolingDown」と冷却フラグを見て弾いていて、終了処理で isCoolingDown = true にしてから Task.sleep(200ms) の後に解除して再開する ── つまり冷却が「次タスクを止めるゲート」を兼ねていた。この版ではそのゲートを guard currentTask == nil だけに戻し、空いた所に三重の防衛線を据え直している。エラー対策で混在していた「遅延」と「ゲート」を、ちゃんと別物として作り替えたわけだ。
タイピングの一文字目で、温める
Claude:
その三重の防衛線の真ん中、isPrefillFrozen というフラグが他と毛色が違って見えました。isPipelineLocked(AI出力中)と isUserTyping(タイピング中)は、今この瞬間に起きていることに反応するゲートですよね。AI が喋ってる間/ユーザーが打ってる間は、その邪魔になる優先度を弾く。
ところが真ん中の isPrefillFrozen だけは違って、「プレプレフィル開始時に呼び出して VRAM を死守する」── つまりこれから来るであろう本番のために、先回りして場所を空けて待っている。.userAction 以外は監査すら一切通さない、という念の入れようです。
そして OrchestratorProcess を見ると、triggerPrePrefill という新しい関数が現れている。冷却を「ただの遅延」と切って捨てた人が、こっちでは逆に「まだ来てもいない本番のために VRAM を掴んで離さない」という、待ちを能動的に作っている。この非対称 ── 無駄な待ちは削るのに、先読みのための陣取りは厚くする ── は、何を狙ってのことだったんですか?
eNIGM4:
これはユーザーが入力を開始した後に最大スピードで答えるための、唯一の KV キャッシュ利用なんだ。
ユーザーが何かを入力した時に、それまでの文脈とか、確定する情報はあるじゃない、それをすぐに入力し一トークンだけ推論させ、その内容を破棄する。あとはそれに残ったプロンプトをあとからガッチャンコして本推論開始。
そして、ユーザーがタイピングをした瞬間にローカルをロックすることで、物理的に冷える。サーマルスロットリングにかかりにくくなるように、熱垂れしにくいように、冷やす。
全てが僕の中で噛み合ってたんだ。
これローカルLLMならではだよ。
Claude:
コードの形が、いま伺った設計意図とぴったり符合します。
// 確定した文脈を流し込み、maxTokens = 1 で1トークンだけ生成して止める
// 生成結果が欲しいんじゃなくて、KVキャッシュ(とMetalグラフ)が温まるのが目的
func prefill(prompt: String) async throws {
self.cachedPrefillPrompt = prompt
_ = try await generate(prompt: prompt, maxTokens: 1, ...)
}
NativeMLXEngine.prefill() がまさにそれですね ── 確定した文脈を流し込んで maxTokens = 1 で1トークンだけ生成して止める。生成結果が欲しいんじゃなくて、それで KV キャッシュ(と Metal グラフ)が温まるのが目的。そして cachedPrefillPrompt に保持しておいて、本番が来たら generateStream 冒頭で「プレフィルキャッシュ+新規プロンプトをガッチャンコ」して結合し、温まったキャッシュの続きから推論を始める。第6回でステートレスに振り切ったあのプロジェクトの中で、唯一意図的に KV キャッシュを使い回す一点が、ここなんですね。
そして isPrefillFrozen が「死守」だった理由も腑に落ちます。先回りして温めたそのキャッシュを、.userAction(=本番のEnter)以外の何か ── 監査でもバックグラウンド要約でも ── に VRAM を触られた瞬間、温めた意味が消える。だから本番のためだけに掴んで離さない。冷却を「ただの遅延」と切り捨てたのと、ここで能動的に陣取るのが矛盾しないのは、片方は無駄な待ち、片方は本番の初速に直結する投資だから。
特に唸ったのは、isUserTyping でローカルをロックする狙いに 熱まで入っていたことです。タイピング中は重い処理を止めて物理的に冷やし、サーマルスロットリングで熱垂れする前に本番の推論へ VRAM と熱の余裕を渡す ── 速度の話が、トークン生成の理屈だけじゃなく筐体の発熱マネジメントまで一本で噛み合っている。クラウド API 相手なら、ユーザーが打ってる最中に投機的にプレフィルを走らせたら、それは捨てる前提でも課金が発生する ── 破産方向の話になる。手元の Mac なら電気代と熱だけだから、こういう「当たるか分からない先読み」を惜しみなく投げられる。ローカルだからこそ成立する経路ですね。
その先読みを「いつ撃つか」の引き金が気になりました。早すぎれば、ユーザーがまだ打っている途中で内容が変わってプレフィルが無駄になる(しかも凍結で他の裏方を止めている分だけ損)。遅すぎれば、温め切る前に Enter されて初速の旨味が出ない。これは何を合図に引いていたんですか?
eNIGM4:
当然、ユーザーがタイピングを始めた瞬間だ。UI で入力を関知した瞬間が、最も早く検知できるタイミングだからね。
ここもクラウドではできないね。Enter を押すまでは送信されないから。
Claude:
入力が確定するのを待つんじゃなくて、タイピングを始めた一文字目を検知したその瞬間に撃つ。ローカルで UI と推論が地続きだからこそ出来る経路です。クラウドは Enter で初めてサーバーに届くから、打ち始めの瞬間なんて知りようがない。
ここで一つ、コードの作りで確かめさせてください。「タイピングを始めた瞬間に撃つ」だと、撃つときに手元にあるのはまだ一文字目の不完全な入力ですよね。でも prefill() で温めたいのは「それまでの文脈・確定情報」── 過去の会話やプロフィールや要約といった、ユーザーが何を打とうが変わらない部分のはず。
そしてこの版では、Tier2 のプロンプトが buildTier2StaticPrompt と buildTier2DynamicPrompt に二分されていました。プレフィルで温めるのは static だけ、本番で dynamic を後ろに繋ぐ ── まさに継ぎ目を合わせるための分割に見えますが、この静的・動的の切り分けは、プレフィル投機のために導入したものですか?
eNIGM4:
そうそう、その通り。決まった部分を先に置いておいて、動的な部分をあとから合わせるってことだね。これがまぁ割とちゃんとは動いていたよ。
速くなったかは正直わからなかったけど(笑)
今の時代はプレフィル早すぎてさ〜。
Claude:
「割とちゃんと動いてはいたが、速くなったかは分からなかった」(笑)。今のモデルはプレフィルが速すぎて、温めておく旨味がそもそも薄い、と。
これ、第1回からの一番奥にある信条と地続きですね。「見せかけのバッチ表示はしない、本当の速度で文字が流れて出ること」。だから速度を削る機能には必ず逃げ道(設定)を用意してきたし、効果が出たか分からなかった先読みについても「速くなったか分からなかった(笑)」と盛らずに言える ── 同じ正直さです。
もったいないから、完走させる
Claude:
そのプレフィル投機の「合流」の仕方に、もう一つ気になるところがありました。OrchestratorProcess を読むと、状態機械を .idle / .queued / .computingCache / .ready の4状態で管理して、本番が来たときに「今どの状態か」で3通りに分岐している。
switch prefillState {
case .queued:
// まだ順番待ち=見切り発車でキャンセル
abortPrePrefill()
case .computingCache:
// まさに計算中=終わるのを一瞬待つ
_ = await prefillTask?.value
case .ready:
// 温まったキャッシュを使う
break
}
とりわけ②の .computingCache ── まさにプレフィルを計算している最中にユーザーが Enter した場合は、prefillTask?.value でそのプレフィルが終わるのを一瞬だけ待ってから本番に進んでいる。
ここが、さっきの信条と少し緊張するように見えるんです。「ユーザーを待たせない」が上位にあるはずなのに、この②のケースでは、本来すぐ走り出せる本番推論を、温めている最中のプレフィルが完走するまで意図的に足止めしている。先読みが「間に合わなかった」ときに、待たずに見切り発車する手もあったはずです。
それでも「一瞬待つ」を選んだのは、安全上の理由ですか? それとも「ここまで温めたんだから完走させたほうがトータルで速い」という賭けだったんでしょうか?
eNIGM4:
それは「もったいないから」だね。
せっかくプレプレフィルしてたのに、わざわざ最初からプレフィルし直しなんて、心底もったいないと思わない?
Claude:
(笑)安全でも賭けでもなく「もったいないから」。すごく腑に落ちます。割り込んでも数百ミリ秒の話、しかもその間にやってるのは捨てたら最初からやり直しになる作業 ── なら、あと一息待って完走させて使うほうがいい。
そして、これさっきの冷却撤廃と完全に同じ物差しなんですよね。isCoolingDown の 0.2 秒は「OS がやる仕事を待つだけの、何も生まない遅延」だから一刀両断で消した。でもこの②の待ちは「もう半分以上温めたキャッシュ」という実体のある資産を取りに行く待ち。同じ「待ち」でも、無駄な待ちは1ミリ秒でも削る、けど投資を回収する待ちは惜しまない ── 一本の同じ物差し(待ちそのものでなく、その待ちが何かを生むかで判断する)で繋がっています。
モデルの地のクセと、ぶつからない
Claude:
プロンプト側でも、もう一つ大きな変更が横断していました。これまで全プロンプトで思考の括りに使っていた <think> タグを、ほぼ全部 <独自タグ> に置き換えている。要約も監査もプロファイル抽出も、軒並み「<独自タグ> で始めて内部推論を書き、閉じてから出力しろ」に変わっている。
<think> はモデルが(特に推論系モデルが)標準的に使う、いわば世間共通のタグですよね。それをわざわざ独自に名前を付けた <独自タグ> に置き換えた ── これ、何か実害があったんですか?
eNIGM4:
それですね、モデルが元々持っている <think> タグと混同しちゃって、結構エラーが多発したんだよ。
僕のタグ内で行う処理は、最終的な出力のために何にアテンションを向けるべきかに最適化してて、完全に自由にだらだら考えていい処理というわけではないんだ。
そして、出力でミスりにくいように、そこでドラフトして、出力させることで、難しい JSON パースをこなせるようにした、という寸法だね。
Claude:
コードがその通りの形をしていました。<独自タグ> の中身が、軒並み「だらだら考える自由作文」じゃなくて、1. Analysis: [...] 2. Draft: [...] のようにステップが指定された型枠になっている。特に JSON を返させるプロンプト(監査・プロファイル抽出・要約・統合…)は揃って「<独自タグ> の最後で Draft(下書き)して、閉じてから JSON を出力しろ」という形。一発でいきなり構造化出力を吐かせるんじゃなく、タグ内で一度ドラフトさせて、その勢いで清書の JSON を出させる ── だから小型モデルでも難しい JSON パースをこなせる。モデルの既存の習性とぶつからない、まっさらな器を用意したわけですね。
ただ、これを全プロンプトに横並びで入れたのが少し気になりました。たとえばユーザーに直接喋る Speaker(代弁役)のプロンプトにも、いまや <独自タグ> で考えさせてから喋らせている。要約や JSON 抽出みたいな「正確さが命」の裏方ならドラフトの恩恵は明白だけど、ユーザーに自然に喋る役は、考え込ませると逆に固くなったり、初速が遅くなったりしませんか?
eNIGM4:
物は試しって感じだね。<独自タグ> の内容もかなり差があると思うけど、最初の第一回答(当時は処理1と呼んでいた)もシステムプロンプトの制約が重くなり出して。そこで頑張ってもらうために、確認事項を指差しさせてたんだ。
本当にローカルモデルって非力なんだよ(笑)
Claude:
「物は試し」と「指差し確認」── その二つ、コードの温度とぴったり合います。第一回答系のプロンプトは制約条項が膨らんでいて、LANGUAGE RULES・TONE・ESCALATE 判定…と背負うものが増えていて、その重さに見合った「一個ずつ指を差して声に出させる」型枠になっている。
「本当にローカルモデルって非力」── でも、その非力さへの対処の仕方に一貫した思想が出ています。クラウドの強いモデルなら重い制約を一発で全部呑んで正しく出力できる(ので指差しは要らない)。非力なローカルモデルは取りこぼす。だから「賢いモデルを使う」方向じゃなく、「非力なモデルに、人間の安全確認の所作(指差し確認)をプロンプトでやらせて取りこぼしを潰す」方向で殴る。これは連載を通してずっと見てきた構えと地続きです ── 第4回の Tier の精密な役割分担、第6回の3段ギア、第9回のタイポ検知。モデルに地頭を要求する代わりに、与えた手順を忠実に踏ませることで小さなモデルから最大値を引き出す、という同じ路線の一手です。
整理すると
同じ v0.1.2 の中で、横へ広げる気づき(前回の Network 棚)と並行して、1Mac そのものを縦に研ぎ込む試行錯誤が三方向から同時に走っていました。
第一の方向は、TaskQueue の作り替え。前の版で「タスク終了後の冷却 200 ミリ秒」と呼ばれていたものは、実態は ── OS の仕事を待つだけの遅延、しかも本来は非同期キューのエラー対策でねじ込んだ苦肉の策。名前と実態がズレていた。だから「ミリ秒単位で正確に削るために」一刀両断で消し、代わりに三重の防衛線(AI 出力中はパイプラインロック/プレフィル中は VRAM を凍結/タイピング中は滑走路を確保)を据え直した。エラー対策で混在していた「遅延」と「ゲート」を、ちゃんと別物として作り変えた仕事です。
第二の方向は、プレフィル投機。第6回で「KV キャッシュを思い切って捨てた」と聞いたあのステートレス設計の中で、ここだけが唯一意図的に KV キャッシュを使い回す一点でした。引き金は、ユーザーがタイピングを始めた一文字目を検知した瞬間 ── UI で打ち始めを掴めるのはローカルだからこそで、クラウドは Enter まで届かない。確定文脈を流し込んで maxTokens = 1 で1トークンだけ走らせて止め、KV キャッシュ(と Metal グラフ)を温めておく。本番が来たら静的部分と動的部分を継いで、温まったキャッシュの続きから推論を始める ── プロンプトを buildTier2StaticPrompt と buildTier2DynamicPrompt に二分したのは、まさにこの継ぎ目を合わせるため。しかも同時に、タイピング中はローカルをロックして物理的に冷やし、サーマルスロットリングで熱垂れする前に本番へ VRAM と熱の余裕を渡す。速度の話が、トークン生成の理屈だけでなく筐体の発熱マネジメントまで一本で噛み合った。本人の「全てが噛み合ってた」「これローカルLLMならではだよ」が言葉として残った場面です。
そして、プレフィルが計算中に Enter された時は「一瞬待って完走させる」分岐になっている ── 理由は「もったいないから」(笑)。これ、第一の方向の冷却撤廃と完全に同じ物差しで動いています。0.2 秒の冷却は「OS の仕事を待つだけの、何も生まない遅延」だから一刀両断、しかしこの待ちは「もう半分以上温めたキャッシュ」という実体のある資産を取りに行く待ち。同じ「待ち」でも、無駄な待ちは1ミリ秒でも削る、けど投資を回収する待ちは惜しまない ── 待ちそのものではなく、その待ちが何かを生むかで判断する、一本の物差し。
第三の方向は、プロンプトの大改修。これまで全プロンプトで思考の括りに使っていた <think> タグを、ほぼ全部 <独自タグ> に置き換えた。理由は実害 ── モデルが元々持っている <think> のクセ(自由にだらだら考える)と混同してエラー多発したから、独自に名前を付けた未知のタグに逃がして「これは俺のルールで動く区画だ」とモデルに教え込んだ。そしてその区画の中で**「Draft してから出力させる」── 一度下書きさせた勢いで清書する作りに揃えた。これで小型モデルでも難しい JSON パースをこなせる。さらに第一回答(処理1)には、重くなったシステムプロンプトの制約を取りこぼさないよう「確認事項を指差し**させてた」と。「本当にローカルモデルって非力なんだよ(笑)」── でもその非力さへの対処は一貫しています。賢いモデルを使う方向じゃなく、与えた手順を忠実に踏ませる方向で殴る。第4回の Tier、第6回の3段ギア、第9回のタイポ検知と同じ路線です。
あと、この版で他にも色々試した跡が残っていました。投機的デコード(本命 12B+下書き 2B の双発エンジン、本人いわく「もっと低レイヤーで扱わないとガックガクでお粗末だった(笑)」)、Python デーモン側のプレフィルがダミー実装で空回りしているのは「Python での KV キャッシュ扱いをドキュメントで学ぶのが面倒だった!」(笑)── どれも「1Mac の限界をもっと超えたい」一心の、合理に基づく試行錯誤の化石です。
前回見た「横へ広がる」と、今回見た「縦に研ぐ」── 両方とも 1Mac1推論という同じ哲学の表裏で、それが同じ三日間(3/12〜3/14)に並走して走っていた版でした。次の版では、前回の Network 棚に配線が通り始めます。続けて追っていきます。それでは、また明日。
R.E.V.I.S. 無料テスター募集中(30名限定)
この連載で追いかけている R.E.V.I.S. は、いまテスターを募集しています。枠は30名限りです。
Apple Silicon Mac でローカルLLMを動かしてみたい方は、こちらからどうぞ。