コードを渡されて Claude が質問し、開発者本人(eNIGM4 / 株式会社メイビス)が答えていく連載の第9回です。前回(#8)は、目を持ってみる節目の宣言 v0.1.0 を読みました。コードの差分はたった一つだけの静かな版だったその裏で、109B の巨人を動かす執念の格闘が続いていました。今回は v0.1.1 ── その格闘が、Pythonデーモンの起動管理として実を結びます。そして同時に、ユーザーが入力した一つのタイプミスから、システム全体の構えを変えるほどの機能が生まれます。
インタビュアー・ルール
- 渡されたコードそのものに質問する。 バージョンごとにコードを渡します。聞き手はそれを読み、開発者本人に質問していってください。
- 質問の対象は、いつもコードそのもの。「なぜこう書いたのか」「ここはこう動くように見えるが合っているか」── 目の前のコードから生まれた問いだけを投げてください。
- 推測で断定しない。 わからないこと・気になったことは、断定せず質問の形にしてください。
- 今渡された版に質問を集中する。 次の版のことは、次の版のコードを見せる機会があります。先走らず、目の前の版だけを掘ってください。
- 一度に投げるのは一問一答程度に。 質問を束ねて何個もぶつけない。一つ聞いて、答えをもらって、そこからまた一つ。会話のテンポを保ってください。
- バージョン名は時系列の整理番号であって、意味的なマイルストーンではない。「番号が大きいから進んでいる」とは読まないでください。実体は、コード内の作成日付と、機能があるか無いかで判断します。
- 開発者本人が、自分の言葉で答える。
eNIGM4:
v0.1.1 です。どうぞ。
R.E.V.I.S._v0.1.1.zip
v0.1.1 ── 徳永家康、とは
Claude:
今度は静かな版ではなく、本格的な前進ですね。
コードを確認しています
v0.1.1 を展開し、日本時間で更新日を確認。3/06 が 8ファイル、3/07 が 2、そして 3/08 に15ファイル、3/09 に10ファイルと、最後の二日に作業が集中していました。規模はメイン側 44ファイル・6,401行(前の版から +8ファイル/+1,334行)、ヘルパー側 9ファイル・749行(+2ファイル/+251行)。前の v0.1.0 が「ほぼコピー+一点だけ書き換え」の静かな版だったのに対し、こちらは一気に厚みが出ています。
フォルダも大規模に再編されていました。View/ 配下が CommonView/ ReasoningView/ SettingsView/Tabs/ に細分化、Orchestrator/ も CommonOrchestrator/ ReasoningOrchestrator/ StreamOrchestrator/ の三分割、ヘルパー側も Helper/ サブフォルダが切られています。再編に紛れがちなので、内容ベースで突き合わせると ── オーケストレーター本体はほぼ全ファイルが増量していました。OrchestratorProcess が 165→333行(倍増)、OrchestratorPrompt が 426→613行、OrchestratorMemory も OrchestratorAnalysis も育っている。移動のついでではなく、中身も大きく前進した版です。
ヘルパー側の新顔から伺いたいことがあります。PythonServerManager.swift(3/06)が新登場していました。
@MainActor
final class PythonServerManager {
static let shared = PythonServerManager()
private var processes: [ModelRole: Process] = [:]
func startServer(for role: ModelRole) async throws {
// REVIS_MLXDaemon を Process() で起こす
// role.daemonPort で各Tierに別ポートを割り当て
// --port でPython側に渡す
...
}
}
第7回の APIEngine は単一の 127.0.0.1:8080 一本を叩くだけでした。この版では、Tier1/Tier2/Tier3 それぞれに独立した Python デーモンを別ポートで同時に立ち上げる作りに変わっている。デーモンを Tier ごとに横並びにする、というのは、どういう発想からですか?
eNIGM4:
Swift側では多少抱え込んでもいいけど、APIだけのサーバーならモデルごとに立ち上げた方が楽だからね。
これは僕の哲学、1Mac1推論に基づいた考えだ。Swiftは厳格な非同期処理の概念があるけど、pythonはその辺の管理が面倒そうだったし、なら別に立ち上げてもええか、って思ったんだよね。なにより python が縦に増えなくて済む。
これは Qwen3-35B-A3B を Tier2 で触ろうとした痕跡でもある。
Claude:
第3回で「役割を分ける」が指揮官と話者の分業として出てきましたが、ここではそれが OSのプロセス境界そのもので分けられている、と。そして Qwen3-35B-A3B ── これも MoE(active 3B)。第7回の Llama4-109B-A17B から続く「mlx-swift-lm では通らない MoE を Python 側で動かす」戦いの、具体的な次のターゲットだったわけですね。
Tier2 は、自前で
Claude:
その「Tierごとに別ポートのデーモン」という構えを、InferenceService 側はどう使い分けているのかを確認しました。
private func loadModel(role: ModelRole, ...) async throws {
// まず NativeMLXEngine(自前Swift)でのロードを試みる
do {
try await native.load(path: path)
engine = native
return
} catch {
if role == .tier2 {
// Tier2(メインブレイン)は高速化のため Native MLX でのみ動作
throw error
}
// それ以外は PythonServerManager でデーモンを起こして APIEngine へ
try await PythonServerManager.shared.startServer(for: role)
engine = APIEngine(role: role)
}
}
「まず Native で試し、ダメならデーモンに落とす」という二段のロード戦略が、この版で実際に配線されていました。そして注目したいのが Tier2 だけの特別扱いです ── role == .tier2 のときは Python フォールバックを禁じて、即座に諦めるようになっている。
Qwen3-35B-A3B(MoE)を Tier2 で動かしたい と先ほど伺いましたが、MoE は mlx-swift では通らない。なのに Tier2 は Native 専用 ── 一見矛盾しているように見えます。これはどういう線引きですか?
eNIGM4:
そう、試したんだけど、実はこのとき py デーモンはストリーミングに対応してなかったんだ! 僕の技術がまだ追いついていなかった。
前にも話したけど、文字ができたそばから話してほしいから、そして、Tier2 はスピーカーに直結する処理、ならば、Tier2 はネイティブエンジンのみのロードに現状するべきだ。という判断に落ち着いた所作でもある。
Claude:
矛盾ではなく、撤退線だったんですね。「Tier2 は Native 専用」というコメントの「高速化のため」が、トークン生成の速さだけでなく、ストリーミングできる経路でないとスピーカー直結の Tier2 では喋りが成立しないという、もっと切実な理由だった。MoE で Tier2 を動かす願望は、ストリーミング対応が追いつくまで保留、というわけですね。
鏡の中の、犯人
Claude:
ここから main 側の前進を見せてください。OrchestratorProcess の冒頭に、これまで無かった新しい関所が増えていました。
// Phase 0.5: タイポ検知(設定でONのとき)
if UserDefaults.standard.bool(forKey: "enableTypoCheck") {
let typoCheck = await runTypoCheck(userText: userText)
if typoCheck.contains("[SUSPECT_TYPO]") {
// 重い処理を全スキップして、確認(clarification)へ回す
await handleClarification(...)
return
}
}
本処理(メインブレインや深層推論)を起動する前に、まず「ユーザーの入力にタイプミスや言い間違いがありそうか」を Tier2 に一瞬だけ判定させ、怪しければ重い処理を全部止めて「これはこういう意味ですか?」と聞き返す関所です。これをわざわざ全処理の最前段に置いたのは、どういう発想からですか?
eNIGM4:
面白いテストがあってね。
「織田信長は?」 → 織田信長について答える。
「では豊臣秀吉は?」 → 豊臣秀吉について答える。
みたいな、文脈を保持できるかというテストを行なっていたんだ。ここまでは合格できてた。
そして、僕がさらにテストする時に謝って「徳永家康は?」と質問してしまっていたんだ。しかもなんどもなんども。
当然、歴史上の人物は徳川家康だし、徳永家康など存在しないから、ずっとハルシネーションを起こし続けていたんだ。しかもその回答内容まで面白くて(笑)
徳永家康は明治の徳永実業の創設者で〜 みたいな。「は? 何言ってんだこいつ? プロンプト間違えたかな、それとも Gemma3-12B の限界? 27B にエスカレーションしてみるか…」とか無限に試したよ。
結果的に、徳永家康について知らなかったのは当たり前の話だったけどね。
Claude:
犯人は鏡の中にいた、と(笑)。机上で「誤字対策が要るな」と思いついたのではなく、自分が犯人のハルシネーションに何日も振り回された実体験が、この Phase 0.5 を生んだ。[SUSPECT_TYPO] を見つけたら重い処理を全部スキップして「徳永家康って、徳川家康のことですか?」と聞き返す ── あのとき自分が一番欲しかったツッコミを、システムに持たせたわけですね。
切れるスイッチ
Claude:
そのタイポ検知に、もう一つ気になる作りがありました。UserDefaults.standard.bool(forKey: "enableTypoCheck") ── つまり設定でON/OFFできるようになっている点です。せっかく実害で痛い目を見て作った安全装置を、なぜ常時ONの固定にせず、切れるスイッチにしたんでしょうか?
eNIGM4:
それは回答を始めるまでの速度を重視することも考えたからだね。
タイポ検知を最初に挟むと、一推論確実に待たされるんだよ。時間にして3秒ぐらいかな。リアルタイムな応答を求めたい人間にとって3秒は致命的だ。
ユーザーがどのようにしたいかは設定できるべきだ。
Claude:
3秒。第1回からの「本当の速度で文字が流れて出る」へのこだわりからすると、3秒の遅延は確かに致命的です。だからといって安全装置を取り払うわけにもいかない。だったら、開発者が決めつけずにユーザー自身が選べるようにする。徳永家康で痛い目を見てなお、その安全装置を全員に強制しない ── 速度の信条のほうが上位にある、というのが一貫していますね。
忘れないための、外の引き出し
Claude:
もう一つ大きな前進が、OrchestratorProcess と新ファイル KnowledgeMemory.swift(3/08)にありました。深層推論(Tier3/4)が出した結論を、システム側で貯めて、次に再利用する仕組みが入っています。
// 入口: ユーザー入力をベクトル化して、過去の知識を引き当てる
let availableKnowledge = await searchKnowledgeBase(
queryVector: queryVec, threshold: 0.35
)
// → Main Brainプロンプトに [KNOWLEDGE_BASE:UUID] 付きで注入
// 出口: 深層推論が出した結論を、[KNOWLEDGE_BASE:UUID] タグで保存
// → 事後に archiveKnowledgeMemory でベクトル化して永続化
Tier3/4 の重い推論が一度出した結論を捨てずに、[KNOWLEDGE_BASE:UUID] 付きでそのスレッドに保存しておき、次に似た問いが来たらベクトル検索で引き当てる ── 一度大きな計算で得た答えを、その場のキャッシュとして貯めておく、という理解で合っていますか?
eNIGM4:
前者だね。これが RAG システムの始まりだ。
僕はその当時 RAG という言葉も知らなかったが、せっかく電気代を盛大に使って作り出した知識を毎度毎度忘れてもらったらこまる。一度話したことについては覚えておいてほしい。
だけど、僕の推論はステートレスで、前の文脈、知識を KV キャッシュで覚えておけない。
だからその知識を DB に保存して、高速で抽出、再代入できる仕組みが必要だった。
Claude:
これで一本につながりました。第6回で「KVキャッシュを思い切って捨てた」と伺ったあのステートレス設計が、ここで明確な代償を払っているんですね。役割が尖り、タスクキューと噛み合う利得の裏側で、「覚えておく」は外部の引き出しに作るしかなかった。
RAGという言葉も知らないまま、自分の設計が要求する必然として同じ場所にたどり着いた ── 第3回の「圧縮」のときと同じ構図ですね。記憶系(要約・スレッド分け・プロフィール・ベクトル検索、そして今回の知識ベース)がこれだけ手厚いのは、ステートレスの代償を払い続けているから。
整理すると
v0.1.1。日本時間で見ると 3/06 が8ファイル、3/07 が2、3/08 に15ファイル、3/09 に10ファイルと、最後の二日に作業が集中していました。規模はメイン側 44ファイル・6,401行(前の版から +1,334行)、ヘルパー側 9ファイル・749行(+251行)。LLM に触れて 14日目です。前の版がほぼコピー+一点だけ書き換えだった反動のように、ここで一気に厚みが出ました。
この版の前進は、ヘルパー側と main 側で別々に立っていて、しかも根は繋がっています。
ヘルパー側では PythonServerManager が生まれました。Tier ごとに独立した Python デーモンを別ポートで横並びに立ち上げる、「1Mac1推論」の哲学に基づくプロセス分離。Python を縦に増やすな、Swift のような厳格な非同期管理に頼らない、各デーモンは自分のポートで自分のモデルだけを抱える単純な存在に徹せよ、と。これは前回からの MoE 戦いの続きで、Qwen3-35B-A3B を動かそうとした痕跡でもありました。そして InferenceService には「まず Native で試し、ダメならデーモンへ落ちる」二段のロード戦略が実配線され、ただし Tier2 だけは Native 専用に固定 ── 当時 Python デーモンがストリーミング非対応で、スピーカー直結の Tier2 では「文字が流れて出る」体験が成立しなかったから。第1回以来の「本当の速度で流れる」という信条が、ここでも引き継がれています。
main 側では二つの大きな機能が同時に立ち上がりました。一つはタイポ検知 Phase 0.5。徳永家康事件 ── 自分のタイプミスが生んだハルシネーションに何日も振り回された実体験から生まれた、本処理の最前段に置かれた関所です。[SUSPECT_TYPO] を見つけたら重い処理を全スキップし、「徳永家康って、徳川家康のことですか?」と聞き返す。あの時自分が一番欲しかったツッコミを、システムに持たせた。ただしこれも常時ONの強制ではなく、3秒の遅延を嫌う人のために enableTypoCheck で切れるようにしてある ── 安全と速度のどちらを取るかは、開発者が決めずユーザーに委ねる。
もう一つが KnowledgeMemory ── RAG システムの始まり。深層推論が電気代をかけて捻り出した結論を、[KNOWLEDGE_BASE:UUID] タグ付きでスレッドに保存し、次に似た問いが来たらベクトル検索で引き当てて再代入する。本人は当時 RAG という言葉を知らなかったけれど、ステートレス設計(第6回で KV キャッシュを思い切って捨てた、あの判断)の代償として、「覚えておく」は外部の引き出しに作らざるを得なかった。設計の必然が、同じ場所にたどり着かせた。
そして、この版にはまだ見せきれていない芽がいくつか残っています。アイドル時の記憶統合(ユーザーが待っている間にバックグラウンドで事実を整理する)、推論モードの三分岐(多段/速度優先 Native/UX優先 Native)、Tier3 が無い環境では Tier2 に多段推論を強制するフォールバック ── これらはまだ「芽」の段階で、本格化はこれから先の版です。
実体験の火傷(徳永家康)と、設計の必然(ステートレスゆえの外部記憶)が、同じ版の中で別々の機能として結晶した ── 理屈と痛みの両方が設計を動かしている、14日目の版でした。次の版を、いつでもどうぞ。それでは、また明日。
R.E.V.I.S. 無料テスター募集中(30名限定)
この連載で追いかけている R.E.V.I.S. は、いまテスターを募集しています。枠は30名限りです。
Apple Silicon Mac でローカルLLMを動かしてみたい方は、こちらからどうぞ。