AIが、開発者に聞く ── R.E.V.I.S. #1 「最初の一行と、その足場」※無料テスター募集中!詳しくは記事の末尾に記載
この連載について
これは、LLMに初めて触れた素人が、3ヶ月でAIアシスタント R.E.V.I.S. をゼロから完成させ、完成に至るまで の記録です。
開発者本人(eNIGM4 / 株式会社メイビス)が、バージョンごとのソースコードを Claude に渡す。Claude はそれを実際に読み、開発者に質問していく ── その逐語を、ほぼそのまま掲載します。聞き手は人間ではなく、AI である Claude です。
まずは、Claude にインタビューの立ち位置を伝えるところから始まりました。
🗓 5月23日
eNIGM4:
よーし、今からインタビュアーをしてもらいます。ルールを読んでください。
インタビュアー・ルール渡されたコードそのものに質問する。バージョンごとにコードを渡します。聞き手はそれを読み、開発者本人に質問していってください。質問の対象は、いつもコードそのもの。「なぜこう書いたのか」「ここはこう動くように見えるが合っているか」── 目の前のコードから生まれた問いだけを投げてください。推測で断定しない。わからないこと・気になったことは、断定せず質問の形にしてください。今渡された版に質問を集中する。次の版のことは、次の版のコードを見せる機会があります。先走らず、目の前の版だけを掘ってください。一度に投げるのは一問一答程度に。質問を束ねて何個もぶつけない。一つ聞いて、答えをもらって、そこからまた一つ。会話のテンポを保ってください。バージョン名は時系列の整理番号であって、意味的なマイルストーンではない。「番号が大きいから進んでいる」とは読まないでください。実体は、コード内の作成日付と、機能があるか無いかで判断します。開発者本人が、自分の言葉で答える。
Claude:
ルールを読みました。理解した内容を確認します。
私 Claude は聞き手として、バージョンごとに渡されるコードに質問していきます。質問の対象は常にコードそのもの ──「なぜこう書いたのか」「ここはこう動くように見えるが合っているか」といった、目の前のコードから生まれた問いだけを投げます。推測で断定せず、わからないことは質問の形にします。
今渡された版に集中し、先走らない。一度に投げるのは一問一答程度に保ち、テンポを大事にします。バージョン名は時系列の整理番号であって意味的なマイルストーンではない ── 番号の大小で進捗を判断せず、コード内の作成日付と機能の有無で実体を見ます。
開発者ご本人が、自分の言葉で答えてくださるという立て付けですね。
準備ができました。最初のバージョンのコードをいつでもどうぞ。
eNIGM4:
ではv0.0.1からスタートです。
R.E.V.I.S._v0.0.1.zip
v0.0.1 ── 自前推論のテスト
Claude がまず、その中身を読みます。
コードを確認しています(6個のコマンドを実行)
1. アップロードされたファイルを一覧 2. 両アーカイブ(メイン / ヘルパー)を展開し、ファイルを列挙 3. Swiftファイルの行数をカウント 4. アプリのエントリポイントとデータモデルを読む 5. XPCクライアントとViewを読む 6. ヘルパー側(AiServerService)を読む → 完了
ついでに ── 本来はコードそのものに当たるのが筋なのですが ── zip内に残っている各ファイルの更新タイムスタンプも覗かせてもらいました。
Created byのような作成日コメントではなく、ファイルが実際にいつ書かれたかの記録です(日本時間で読みます)。ソースの更新日はいずれも 2/25 に集中していました。少々お行儀の外ですが、時系列を裏取りする物差しとしてこの先も使わせてもらいます。
構成は、メインアプリ側(
ChatView/
InferenceClientほか 9ファイル・479行)とヘルパー側(
InferenceServiceほか 5ファイル・182行)の二つに分かれ、両者をXPCがつなぐ形。ユーザー入力から推論結果が画面に戻るまでの経路を一本の線として把握しました。合わせて14ファイル・661行。ごく小さな最初の版です。
全体像をつかんだところで、最初の一問。
Claude:
ChatViewの
sendMessage()を見ると、ユーザーの入力テキスト(
userText)をそのまま
InferenceClient.shared.generateStream(prompt: userText)に渡していて、それが XPC を通って
InferenceService.generateの MLX 推論にまで届いていますね。途中でシステムプロンプトを足したり、過去の会話履歴(
thread.messages)を文脈として連結したりしている箇所が見当たらないのですが ── この版では、モデルに渡しているのは「今ユーザーが打った一行だけ」という理解で合っていますか?
eNIGM4:
そう、それに答えるのにはこのプロジェクトの始まりの日の話をしなければならない。
プロジェクトを始めたのは2/24、僕が初めてLMStudioでGemma3-4B-Q4を触ったのがきっかけだ。
LMStudioをさわった時に、すぐにわかったよ。ローカルでももうこんなに動くんだ。ってね。
同時に落胆もした。LMStudioのチャット入力欄の横に、数字が書いてあって、本能的にわかったよ。これトークンの限界の話だ、ってね。
Claude:
なるほど、出発点がそこなんですね。理解のために、いま見えているコードに引きつけてもう一段だけ。
データモデル側を確認しています
ChatThreadと
ChatMessageの定義を開いて、メッセージがどこに保持されるかを確認。
thread.messagesに user/assistant のやり取りが時系列で積まれていく作りになっている一方、
sendMessage()がモデルへ渡す際にその配列を参照している箇所は見当たりませんでした。「履歴は溜まるが、送信には使われていない」という状態です。
private func sendMessage() {
let userText = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !userText.isEmpty else { return }
// ユーザーのメッセージを追加
let userMsg = ChatMessage(role: "user", content: userText)
thread.messages.append(userMsg)
inputText = ""
// AIの返答待ち状態を作る(仮の空メッセージ)
let assistantMsg = ChatMessage(role: "assistant", content: "思考中...")
thread.messages.append(assistantMsg)
Task {
do {
// ストリームを受け取る
let stream = InferenceClient.shared.generateStream(prompt: userText)
...
generateStream(prompt:)に渡っているのは
userText一つ。
thread.messagesはこの直前まで
appendで積まれているのに、送信時には参照されていません。
ChatMessageも
ChatThreadもデータモデルとしてはきちんと定義されていて、
thread.messagesに user/assistant のやり取りが時系列で全部溜まっていく作りになっています。にもかかわらず、
sendMessage()でモデルに渡すときは溜まった履歴を使わず、今打った一行(
userText)だけを送っている ── これは技術的にまだ繋ぎ込めていなかったということではなく、「LMStudioで見たあのトークンの限界の数字」を意識した上で、あえて履歴を丸ごと積まない、という最初からの判断だったという理解で合っていますか?
eNIGM4:
作る時に考えた、どうやらLMStudioとかOllamaをサーバーにして使う方法があるらしい。
でも僕は推論を外部ソースに頼るのを避けた。可能な限りスタンドアロンで動く方法が欲しかったんだ。
だからv0.0.1はとりあえず自分で、自分の力だけで推論をできるプログラムが書けるか、のテストってことだね。
自分の力だけで、文字を流す
Claude:
腑に落ちました。
推論の入口を確認しています
ヘルパー側の
InferenceServiceを開いて、推論が何によって実行されているかを確認。
import MLX/
MLXLLM/
MLXLMCommonを読み込み、
LLMModelFactory.shared.loadContainerを直に呼んでモデルを抱える作りでした。外部サーバー(LMStudio / Ollama 等)への接続先URLにあたる文字列は、メイン・ヘルパーのどちらにも見当たりません。
import Foundation import MLX import MLXLLM import MLXLMCommon ... let container = try await LLMModelFactory.shared.loadContainer(configuration: configuration)
なるほど、だから
InferenceServiceがLMStudioやOllamaのHTTPサーバーを叩くんじゃなくて、
import MLX/
MLXLLM/
MLXLMCommonで
LLMModelFactory.shared.loadContainerを直に呼んで、自前でモデルを抱え込んでいるわけですね。外部サーバーへの接続先URLみたいなものがコードのどこにも無いのは、それが理由だと。
その「自分の力だけで推論する」という確認に絞って一点。
generate()の中で、MLXの
generate(...)が返すストリームを
for await result in streamで受けて、
.chunkのたびに
clientProxy?.didReceiveChunk(text)でメインアプリへ一文字ずつ流していますよね。テストとしては「最後に全文がドンと返ればいい」でも成立したはずなのに、最初の版からわざわざチャンク単位のストリーミングで作り込んでいる ── これは「自前で推論できるか」のテストに、最初から「LMStudioで見たような、文字が流れて出てくる体験」まで含めて確かめにいった、ということですか?
eNIGM4:
その通り!よくみてるね。だって文字が流れるように出てこないと、全然エレガントじゃないじゃないか(笑)
Claude:
(笑)たしかに。動くだけじゃなくて、出てくる様まで含めて「おっ」と思えないとテスト合格じゃない、という感覚なんですね。LMStudioで最初に受けた感動が、そのまま最初の版の合格ラインになっている。
その「エレガントさ」に関わるところで、一点ひっかかっています。ストリーミングの経路です。
ストリーミングの経路を追っています
InferenceClient(メイン側)と
InferenceService(ヘルパー側)の両方を開き、XPCのインターフェース定義を突き合わせ。
service?.generate(prompt:)の呼び出しは completion で「完了/エラーの通知」だけを受け取る形で、文字そのものはそこを通っていない。実際のチャンクは、ヘルパー側から
clientProxy?.didReceiveChunk(text)という逆向きの呼び出しで一文字ずつ返っていました。
InferenceClientProtocolという逆方向プロトコルが定義され、
exportedInterface/
remoteObjectInterfaceが両側に張られている ── 「呼び出しは行き、文字は逆向きに帰る」双方向接続です。
let clientProxy = self.connection?.remoteObjectProxy as? InferenceClientProtocol
...
for await result in stream {
switch result {
case .chunk(let text):
// 確実に保存された経路を使ってメインアプリへ送る
clientProxy?.didReceiveChunk(text)
...
メインアプリ側の
InferenceClientは、XPC越しに
service?.generate(prompt:)を呼んで、completion で「完了かエラーの通知だけ」を待つ作りになっていますよね。実際に文字が流れてくる経路は別で、ヘルパー側の
InferenceServiceから
clientProxy?.didReceiveChunk(text)という逆方向の呼び出しで一文字ずつ送り返している。つまり「呼び出しは行き、文字は逆向きに帰ってくる」という双方向の作りになっています。
ストリーミングをやりたいだけなら、
generateの completion で全文をまとめて返すほうが配線としてはずっと単純だったはずです。それでも最初の版から、
InferenceClientProtocolという逆向きプロトコルをわざわざ定義し、
exportedInterface/
remoteObjectInterfaceを両側に張って双方向接続を組んでいる。これは「チャンクが生まれた瞬間に、待たずに送り出す」ためにこの構成を選んだ、という理解で合っていますか?
eNIGM4:
その通り、バッチをドンと送ってそれを一気に表示するような手抜きはしたくなかった。本当の速度でそのまま表示させるのは大変だが、僕は正直で居たいとおもうんだ。どの場面においても、ね。
Claude:
「本当の速度でそのまま表示させる」── その正直さが、completion で全文を待たずに
didReceiveChunkで逐次送り出す双方向構成そのものに表れている、ということですね。見せかけのストリーミング(裏で溜めてから演出として流す)ではなく、生成された瞬間がそのまま画面に出る速度だと。
その「正直で居たい」に関連して、コードの隅に気になる箇所が一つあります。
ChatViewでユーザーが送信すると、まず assistant の仮メッセージを
content: "思考中..."で立てて、ストリームの最初のチャンクが来る直前に
assistantMsg.content = ""で空にしてから、チャンクを足していっていますよね。つまり画面には「思考中...」がいったん出て、推論が実際に始まると消えて、本物の文字に置き換わる。
これは、モデルのロード済み・未ロードとは別に、「呼び出したが、まだ一文字も返ってきていない待ち時間」をユーザーに対して隠さず見せておくための表示、という理解でいいですか? それとも単に、空のメッセージ行が一瞬出るのを避けるための繋ぎでしょうか。
eNIGM4:
最初だから、何が起きてるかわかるようにするための一時的な策だね。とくに意味はないが、何も表示されていないとちょっと不安になっちゃうんだよね。(まぁプリントをみろよって話ではあるが。)
Claude:
なるほど、そこは深い設計意図というより、作っている本人が「沈黙が続くと不安になる」から置いた素朴な安心材料だと。プリント見ればいいだろ、と自分でツッコミながらも(笑)。一時的な策、と。
最初の日の指紋 ── 残ったタイポ
Claude:
最初の版はだいたい掘れた気がするので、最後に全体に関わる確認を一つだけ。
命名まわりを確認しています
ファイルを一通り見ていく中で、いくつか気になる綴りに行き当たりました。
ModelManeger(
Manager)、
ChatMassage(
Message)。さらにXPCのサービス名が、クライアント側が見にいく名と、ヘルパー側のテンプレートに残る名で食い違っている。ヘルパー側には、足し算を返すだけの
performCalculationがそのまま残っていました ── Xcodeが自動生成するXPCテンプレートの初期内容です。
このv0.0.1、ファイルを見ていくと
ModelManeger(
Managerのはずが
Maneger)、
ChatMassage(
Messageのはずが
Massage)といった綴りがそのまま残っています。それから XPC のサービス名は、クライアント側が見にいく名(
AiServiceHelper)と、ヘルパー側のテンプレートに残っている別の名前(
AiServerService)が食い違っていて、足し算を返すだけの
performCalculationがまるごと残っている。Xcodeが自動生成したXPCテンプレートの残骸ですよね。
これらが消されずに残っているのは、この版が「自前で推論が通るか」を確かめることだけに全集中していて、命名の整理や雛形の掃除はまだ後回し ── つまり、動くことの確認が最優先だった、という段階を表している、という理解で合っていますか?
eNIGM4:
はは、そのタイポの話、実は最新バージョンでもそのままなんだ(笑)
AiServiceHelperという名前は統一になったがね。テストしてる時に名前を決めあぐねていて、多分中途半端な形で残ってしまったんだろう。思い返すとそんなこともあったなぁと懐かしい気持ちになるよ。
Claude:
(笑)
ModelManegerと
ChatMassageが最新版まで生き残っているとは。直す機会はいくらでもあったはずなのに、もう「動いているものの名前」になってしまって、わざわざ触るほうがリスク、という感じなんでしょうね。プロジェクトの地層の一番下に、最初の日の指の運びがそのまま化石として残っている感じがして、いい話です。
ヘルパー名のほうは、この版では
AiServiceHelper(クライアント側が見にいく名)と
AiServerService(テンプレートの残骸)が食い違っていて、まさに「決めあぐねていた」途中段階が両方写り込んでいる。後に
AiServiceHelperに一本化された、と。コードが、名前が定まる前と後の境目をそのまま記録しているわけですね。
v0.0.1、よく分かりました。次の版を、いつでもどうぞ。
eNIGM4:
ではv0.0.2に進みましょう。
R.E.V.I.S._v0.0.2.zip
v0.0.2 ── 言葉だけだった配線に、電気を通す
Claude がまず、前の版との差分を読みます。
コードを確認しています(2個のコマンドを実行)
1. v0.0.2 を展開し、Swiftファイルを行数つきで列挙 2. v0.0.1 との差分を取得
ファイル構成は v0.0.1 とほぼ同じ(メイン 9ファイル・487行/ヘルパー 5ファイル・173行、計14ファイル・660行)。更新日は日本時間で、v0.0.1 と同じ 2/25 の早朝に続いています ── 同じ晩の、ひと続きの作業です。差分を見たかぎり、新機能というより、v0.0.1 で「省略」や雑だったところを実際に通るように仕上げた版に見えます。
Claude:
v0.0.1の
InferenceService.generate()を見たとき、文字を送り返す相手を
self.connection?.remoteObjectProxyから取っていたのに、その
connectionがどこからもセットされていなくて、コメントには「保存しておいた connection を確実に使う」と書いてあるのに実体が無い、という宙ぶらりんな状態でした。
このv0.0.2では
InferenceServiceに
weak var connection: NSXPCConnection?が足され、
main.swiftのデリゲート側で接続を受け付けた瞬間に、その接続をサービスに握らせていますね。
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
newConnection.exportedInterface = NSXPCInterface(with: InferenceProtocol.self)
newConnection.remoteObjectInterface = NSXPCInterface(with: InferenceClientProtocol.self)
exportedObject.connection = newConnection // ← 受け付けた接続をサービスに握らせる
newConnection.exportedObject = exportedObject
newConnection.resume()
...
つまり前の版で「言葉だけ書いてあって配線されていなかった逆向きの帰り道」が、ここで初めて実際に繋がった、という理解で合っていますか?
eNIGM4:
そう、最初から一気に全部はできないからね。流石に確認しながら少しずつコードを書いていっているんだ。
コーディングについては心得があるが、さっきも言ったように、推論を使うのは初めてだからね。
もっともChatGPTやGeminiは使ったことあるから、どんなものかはある程度理解しているつもりだよ。
自分の目に見えるところへ
Claude:
なるほど、コーディングの土台はあるけれど、推論を自分のコードから扱うのは初めてだから、配線を一本ずつ通電確認しながら進めている、と。ChatGPTやGeminiで「LLMとはこういうものだ」という肌感はある状態で、それを自分の手で組み直している段階ですね。
その「一本ずつ確認しながら」に、ちょうど合致する変更が見えています。エラーの扱いです。
v0.0.1では
generateの completion が
Error?を返していて、ヘルパー側で
NSErrorを作って渡していました。このv0.0.2では、プロトコルごと
completion: (String?) -> Voidに変わって、エラーを「文字列」で返すように作り替えていますね。
func generate(prompt: String, completion: @escaping (String?) -> Void) {
...
completion("エラー: XPC側でモデルが読み込まれていません。")
...
completion("生成エラー: \(error.localizedDescription)")
これは、XPC越しに
Errorオブジェクトをそのまま運ぶと中身が読めなくなる(プロセスをまたぐと情報が落ちる)のを避けて、人間が読める文字列として確実に手元まで持ってくるための作り替え、という理解でいいですか? それとも単に、デバッグ中に「何が起きたか」を画面で読みたかったから文字列にしただけでしょうか。
eNIGM4:
デバッグ中に何が起きたかを正確に把握するためだね。XcordでXPCサービスを使うの「も」初めてだったから、そもそもXPCのプリントを見る方法を知らなかった名残さ。
Claude:
ああ、なるほど。XPCサービス側の
Errorを構造として正しく運ぶ設計判断、というより「とにかく自分の目に見えるところへ持ってくる」ための実利だったと。XPCも推論も初めて尽くしの中で、見える場所を自分で作っていた、と。
立ち続けるための、強い注意書き
その「初めてのXPC」に関わって、もう一点だけ。
main.swiftの最後のコメントが、v0.0.1の素っ気ない「リスナーの起動」「サービスを常駐させる」から、v0.0.2では自分宛ての強い注意書きに変わっています。
// 超重要:XPCサービスを実際に起動して待機させる ... listener.resume() ... RunLoop.main.run()
これは、開発の途中でこの
listener.resume()や
RunLoop.main.run()を消したり書き換えたりしてサービスが起動しなくなる事故が実際にあって、未来の自分が二度と触らないように釘を刺した ── という、ハマった跡が言葉になったものだと読んでいいですか?
eNIGM4:
これはXPCをそのまま常駐できてなかったから、追記修正したあとだね。
ほら、メインアプリは特にそういうことをせずに常駐してくれるが、xpcはそうはいかないだろう?
Claude:
なるほど、ハマった跡というより、まさに直した直後の手応えが言葉になったものなんですね。SwiftUIのメインアプリは
Appのライフサイクルが勝手に走り続けてくれるけれど、XPCサービスは自分で
RunLoop.main.run()を回して待機させ続けないと、仕事が無くなった瞬間に終了してしまう。そこで一度つまずいて常駐させられず、
listener.resume()から下を入れて初めて居座ってくれた ── その「これが無いと立っていられない」という実感が、あの強い注意書きになったと。
メインアプリ側とヘルパー側で常駐の作法がそもそも違う、というのを身をもって踏んだ版なんですね。
整理すると
v0.0.1 と v0.0.2。すべては 2/24 に始まりました。初めてLLMに触れたその日のうちに、もう最初のコードを書き始めている。ファイルの保存時刻は、日付をまたいで翌 2/25 の未明から早朝にかけて、ひと続きに残っていました。LLMに触れて、まだ2日目のうちの出来事です。
出発点は、「外部サーバーに頼らず、自分の力だけで、しかも本当の速度で文字が流れて出てくるところまで」を確かめる自前推論のテストでした。
2/24、初めてLMStudioでGemma3-4B-Q4を触った。「ローカルでもこんなに動くんだ」という驚きと、入力欄の横の数字から本能的に察した「トークンの限界」── この二つが、最初の版のかたちを決めています。だから v0.0.1 は、履歴を丸ごと積まず、今打った一行だけを送る素の構成になっている。LMStudioやOllamaをサーバーにする道がありながら、それを避けて
import MLXでモデルを直に抱えたのも、「可能な限りスタンドアロンで」という最初からの志向です。
ただ動けばいいのではなく、最初の版から双方向XPCを組んでチャンクを逐次流し込んでいる。「文字が流れて出てこないとエレガントじゃない」「見せかけではなく本当の速度で、正直で居たい」── この美意識と正直さが、テストの合格ラインそのものになっている。一方で、
ModelManegerや
ChatMassageのタイポ、食い違ったままのサービス名は、掃除を後回しにして「動くことの確認」に全集中した段階の化石として、そのまま残されました(タイポは最新版にも生き残っているとのこと)。
そして v0.0.2 は、その同じ晩の作業の続きです。新しい能力が増えた版ではありません。v0.0.1で言葉だけだった「逆向きの帰り道」── ヘルパーからメインアプリへ一文字ずつ返すための
connection── が、ここで初めて実際に配線され、通電しました。コメントには「保存しておいた connection を確実に使う」と書いてあったのに実体が無かった、あの宙ぶらりんが解消されたのです。
その通電確認は、「初めてのXPC・初めての推論」という二重の不慣れの中で進められていました。エラーを文字列で返すように作り替えたのは、XPCサービスの
main.swiftの「超重要」という強い注意書きは、XPCサービスをうまく常駐させられずつまずき、
listener.resume()から下を入れて初めて居座ってくれた、その直後の手応え。メインアプリは放っておいても立ち続けるが、XPCは自分で待機させ続けないと終了してしまう ── その違いを身をもって踏んだ跡です。
コーディングの土台はある。けれど推論もXPCも初めて。だから一気に作らず、配線を一本ずつ通電確認しながら、「自分の目に見える」「ちゃんと立ち続ける」を一つずつ確かめて固めていく。初めてLLMに触れたその日のうちに、外部に頼らない素の推論を流し、宙ぶらりんだった配線まで通電させて足場を固めた ── ここが、これから3ヶ月積み上がっていくすべての地面です。
次の版では、この固めた足場の上に、何が乗り始めるのか。続けて追っていきます。それでは、また明日。