決められた台詞ではなく、その場で考えて喋ってほしい

スポンサーリンク

ゲームの村人に話しかけると、いつも同じ台詞が返ってきます。3回話しかければ3回とも同じ。あれを「その場で考えた返事」にできないか——というのが今回の出発点です。

p933ではMacのデスクトップアプリにローカルLLMを組み込みました。今回はその組み込み先がゲームエンジンになります。アプリとゲームでは、決定的に違う制約がひとつあります。

ゲームは、待ってくれない
チャットアプリなら、返事が来るまで数秒固まっても「考え中なんだな」で済みます。ところがゲームで同じことをすると、画面がカクつき、操作を受け付けなくなり、BGMまで途切れます。1秒間に60回描き直すという約束を、LLMの都合で破るわけにはいきません。

つまり今回の本題は「LLMに喋らせること」そのものより、喋らせながらゲームを動かし続けることのほうです。あとは、NPCらしく振る舞わせるための人格と記憶をどう持たせるか。

Unityを使う検証なので実測はWindows機(RTX 3060)で行いましたが、その前段——LLMの呼び方そのものはMacで固めています。Unityを開く前に潰せる問題は、先に潰しておいたほうが早いからです。

用語集

今回は聞き慣れない略語がいくつか出てきます。先に片付けておきます。

用語平たく言うと
TTFT
Time To First Token
最初の1文字が出るまでの時間。会話では「全部できるまでの時間」より、こちらのほうが体感に直結します。返事が出始めさえすれば、人は待っている感じがしなくなるためです
トークンAIが文章を扱うときの最小単位。日本語だとおよそ1〜2文字ぶん。「入力トークン数」はAIに読ませた量、「出力トークン数」は書かせた量
プロンプトAIに渡す文章のかたまり全部。今回なら「NPCの人格設定+これまでの会話+今の話しかけ」をつないだもの
ゲームループ「入力を読む→状態を更新する→描く」を1秒に何十回も繰り返す、ゲームの心臓部。ここが1回でも長引くと、その瞬間カクつきます
メインスレッドゲームループが回っている作業ライン。Unityでは画面やオブジェクトに触れるのはここだけという決まりがあります。だから重い仕事を別ラインに逃がしても、結果を受け取るのはここに戻す必要があります
1% Low遅かったほうから1%のフレームだけを平均したfps。平均fpsが良くても1% Lowが低ければ、実際には引っかかって見えます。カクつきを数字にするための指標
SSE
Server-Sent Events
サーバーが「できたぶんから少しずつ」送ってくる通信のやり方。LLMが1文字ずつ返してくるのはこれ
VRAMグラフィックボードが持つ専用メモリ。ゲームの描画もAIモデルもここを使うので、今回いちばん取り合いになる資源です

どうやってUnityからLLMを呼ぶか

組み込み方には大きく2通りあります。

構成A:別プロセス+HTTP Unity ゲームループ 描画・入力 llama-server 別の実行ファイル モデルを保持 HTTP / SSE ⭕ 落ちてもゲームは生き残る ⭕ モデルの入れ替えが自由 ⭕ 実装が単純(ただのHTTP) △ 配布時に2つ起動する必要 構成B:プロセス内に組み込む Unity(1つのプロセス) llama.dll(同居) ゲームループ ⭕ 通信のぶんだけ速い ⭕ 配布が1本で済む ❌ AIが落ちるとゲームも落ちる ❌ 署名のないDLLは弾かれることがある

今回は構成A(別プロセス+HTTP)を本命にしました。理由は3つ目の「実装が単純」より、むしろ1つ目の落ちてもゲームは生き残るが大きいです。モデルの読み込みは数GBのファイルを触る作業で、失敗の仕方も派手です。それをゲーム本体と同じプロセスに置くのは、開発中はとくに割に合いません。

構成Bも試しましたが、そちらは後半にまとめます。

ゲームループを止めない——3つの呼び方

構成Aを選んだとして、その通信をどのスレッドで待つかで結果がまるで変わります。3通り書いて比べました。

メインスレッド(ゲームループ)の時間の流れ → ❌ 同期呼び出し 結果を待ってから次へ LLMの生成完了までフレームが1枚も進まない △ メインスレッドで受信 await の続きが戻ってくる トークンが届くたび、フレームの合間に割り込む ⭕ 別スレッドで受信 キューに置くだけ フレームは途切れない 別スレッド 受信してキューに積む(UnityのAPIには触らない) = 1フレーム

3つ目が正解なのですが、大事なのは「別スレッドではUnityのものに一切触らない」という一点です。Unityは画面やオブジェクトをメインスレッド以外から触ると落ちます。そこで別スレッドは受け取った文字をキューに積むだけにして、取り出して画面に出すのはメインスレッドの仕事にします。

// 別スレッド側:UnityEngine には触らない。積むだけ。
Events.Enqueue(new LlmEvent {
    Kind = LlmEvent.EventKind.Token, Text = content
});

// メインスレッド側(Update):ここで初めて Unity のものに触る
void Update()
{
    LlmEvent ev;
    while (_client.Events.TryDequeue(out ev))
    {
        if (ev.Kind == LlmEvent.EventKind.Token)
            _dialogueText.text += ev.Text;   // ← 画面に触るのはここだけ
    }
}

ConcurrentQueue を挟むだけの、拍子抜けするほど単純な形です。ただしこの単純さが効きます。別スレッドは「文字を積む」以外に何も知らなくてよく、メインスレッドは「積まれていたら取り出す」以外に何も待たなくてよい。

NPCに人格と記憶を持たせる

喋らせる中身のほうです。今回のNPCは、村の道具屋の主人「ガロン」にしました。

あなたは村の道具屋の主人「ガロン」です。無愛想だが情に厚い中年の男。
一人称は「オレ」。返事は必ず2文以内。ト書きや説明はせず、セリフだけを話す。

この文章をsystem プロンプトとして毎回いちばん先に置きます。そのあとに「これまでの会話」を並べ、最後に「今の話しかけ」を置く。組み上がった1本の文字列がAIへの入力になります。

毎ターン、この1本を組み立てて投げる 人格(system) ずっと変わらない これまでの会話(記憶) ターンごとに1往復ずつ伸びていく 今の話しかけ 毎回新しい この全部が「入力トークン」として毎回読み直される ★ 会話が進むほど真ん中が伸びる=毎ターンの読み直しが増える=待ち時間が伸びる …はずなのだが、そうならない仕組みがある(次章)

ここで素朴な疑問が出ます。会話が10往復も続いたら、毎回その全部を読み直すことになるのでは? 読む量が増えれば、返事が来るまでの時間もそのぶん伸びるはずです。

会話が続くほど遅くなる問題

実際に測りました。Mac(M4)でQwen2.5-3Bを動かし、会話を積み上げながらTTFT——最初の1文字が出るまでの時間——を記録します。

比べたのはプロンプトキャッシュの有無です。これは「前回と同じ文章の続きなら、読み直さずに使い回す」という仕組みで、cache_prompt という指定ひとつで切り替わります。

会話の往復数と、最初の1文字が出るまでの時間(Mac M4 / Qwen2.5-3B) 0 300 600 900 1200 ミリ秒 0 2 4 6 8 10 会話の往復数 キャッシュなし 10往復で 1252 ms キャッシュあり 10往復でも 258 ms(横ばい)
会話の往復キャッシュありキャッシュなし
082.7 ms195.0 ms
2250.9 ms371.9 ms
4253.4 ms606.9 ms
6253.1 ms786.9 ms
8250.7 ms966.3 ms
10257.8 ms1252.2 ms

心配は当たっていました。キャッシュなしでは10往復で6.4倍。会話を続けるほど、話しかけてから返事が始まるまでが目に見えて遅くなります。

そしてキャッシュありでは、10往復してもほぼ横ばいでした。理屈は単純で、前回投げた文章はそのまま今回の文章の先頭部分になっているので、新しく増えた1往復ぶんだけ読めば済むからです。実際、読み直したトークン数は毎ターン48前後で固定されていました。

NPCの会話では cache_prompt は事実上必須
指定ひとつで、10往復目の待ち時間が 1252ms から 258ms になります。書き換えるコードの量に対して、効きが大きすぎるくらいです。

記憶を切り詰めると、キャッシュが崩れる

ところが、ここに落とし穴があります。

NPCの記憶を無限に伸ばすわけにはいきません。入力が伸び続ければいずれ上限に当たりますし、そのぶんVRAMも食います。そこで普通は「直近3往復だけ覚えておく」ように古い会話を捨てます。

ここで思い出してほしいのが、キャッシュが効く条件です。「先頭からの共通部分」しか使い回せないのでした。古い往復を捨てると、人格の直後から中身が変わります。

3ターン目に 投げた文章 人格 往復1 往復2 往復3 4ターン目 (往復1を捨てる) 人格 往復2 往復3 往復4 使い回せるのは 点線の中だけ(人格) ここに別のものが来た =ここから全部読み直し 実測:再利用できたトークンが 142 → 67 に落ち、読み直しが 53 → 124 に増えた その結果 TTFT が 164 ms → 247 ms(約1.5倍)に悪化し、以降そのまま
ターン入力の総トークン再利用読み直しTTFT
319514253164.1 ms
4
★捨てた回
19167124247.5 ms
818367116242.2 ms

4ターン目、つまり最初に古い往復を捨てた瞬間に、再利用できるぶんが67トークン——ちょうど人格プロンプトの長さ——まで落ちています。以降は毎ターン、残り全部を読み直し続けることになります。

結局これはトレードオフでした。

記憶の持ち方良いところ悪いところ
全部積むTTFTが横ばいのまま。読み直しは毎ターン48トークン程度で固定入力が伸び続け、いずれ上限とVRAMに当たる
直近N往復だけ入力の長さが一定に収まる捨てた瞬間にキャッシュが崩れ、以降ずっとTTFTが1.5倍
⚠ 測り方でつまずいた話
最初にこれを測ったとき、「切り詰めてもキャッシュは崩れない。むしろ28msに高速化した」という結果が出ました。おかしいと思って中身を見たら、テスト用のダミー会話に毎ターン同じ文章を使っていたためでした。同じものを積んで古い順に捨てれば、できあがる文章は毎回そっくり同じ。まるごとキャッシュに当たって当然です😅
各ターンの内容を変えたら、上の正しい結果になりました。キャッシュを検証するときは、テストデータが実際と同じだけ変化しているかを先に確かめないといけません。

Unityに載せる前に潰しておいた3つの罠

ここまでは全部、Unityを開かずにMacで確かめています。C#のうちUnityに依存しない部分——プロンプトの組み立て、通信の読み取り、集計——だけを取り出して単体テストを書き、40件を通してから渡しました。おかげで3つ、渡す前に潰せました。

① 終了判定に使う "stop" が2か所にある

生成が終わると、サーバーは "stop": true を含む最後の1通を送ってきます。ところがこの最後の1通には、設定の控えとして "stop": ["<|im_end|>", ...] という別物も入っています。素直に文字列検索すると、こちらを拾って終了判定を誤ります。入れ子の深さを見て、いちばん外側のものだけを読むようにしました。

② 通信の区切りは、行の途中で来る

1回の読み取りが、ちょうど行の終わりで切れてくれる保証はありません。data: {"content":"こ まで来て、続きは次回、ということが普通に起きます。断片をためて、改行が来た行だけを処理する必要があります。

③ ★日本語がバイトの途中で切れて化ける

これがいちばん厄介でした。受け取ったバイト列を、読み取りの区切りごとに素直に文字へ直すと、マルチバイト文字の途中で切れたところが壊れます。実際に手元で再現しました。

元の文字列                        : 「data: こんにちは、旅人さん。」
GetString を4バイトずつ呼んだ結果 : 「data: ��んに�����、旅�����ん。」
Decoder を4バイトずつ呼んだ結果   : 「data: こんにちは、旅人さん。」

状態を持つ Encoding.UTF8.GetDecoder() を使えば、半端なバイトを次回に持ち越して正しく組み直してくれます。1・3・5・7・64バイトのどの刻み方でも元通りになることを確認しました。

⚠ ここでも一度引っかかりました
最初は「こんにちは、旅人さん。」だけで試したのですが、ひらがなは1文字ちょうど3バイトなので、3バイト刻みだとたまたま境界が揃って化けません。それで「化けないじゃないか」と誤解しかけました。ASCIIと日本語が混ざる実際の応答では揃いません。

もうひとつ、通信の設定でひとつだけ大事なものがあります。HttpCompletionOption.ResponseHeadersRead を付けないと、本文を全部受け取ってから返ってきます。ストリーミングにしたつもりが「全部できてから一気に届く」になり、逐次表示の意味がなくなります。

それでも実機で落ちた——40件のテストをすり抜けたバグ

ここまで準備しておいて、Windows機でUnityに載せた最初の実験がこうなりました。

実験A  req1 Background      ✅ 成功
       req2 MainThreadAsync ✅ 成功
       req3 Blocking        ❌ An error occurred while sending the request

実験B  turn1 ✅  turn2 ✅  turn3〜24 ❌ すべて失敗(24件中22件)

最初の2回だけ成功して、3回目から必ず失敗する。いかにも原因がありそうな、きれいな壊れ方です。

犯人は LlmChatClient——eightが書いた、まさにあの受信部でした。HttpResponseMessage と応答ストリームを解放していなかったのです。

HttpCompletionOption.ResponseHeadersRead を使うと、接続は本文を読み切って破棄するまで占有され続けます。おまけに stop:true を受け取った行で return; して抜けるので、正常に終わったときですら解放されません。そして .NET の既定は「1つの接続先につき同時2本まで」。2本使い切った時点で、以降のリクエストは全部通らなくなります。

// 修正: try の外で宣言して、finally で必ず解放する
HttpResponseMessage resp = null;
System.IO.Stream stream = null;
try
{
    resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, token)...;
    stream = await resp.Content.ReadAsStreamAsync()...;
    // …途中で return しても、
}
finally
{
    // ここは必ず通る
    if (stream != null) stream.Dispose();
    if (resp != null) resp.Dispose();
}

これでエラーは6件から0件になり、全実験が完走しました。以下の数字はすべて修正後のものです。

★40件のテストを通したのに、なぜ見つからなかったのか
理由は単純で、テストしたのがUnityEngineに依存しない部分だけだったからです。プロンプトの組み立ても、通信の読み取りも、集計も正しかった。間違っていたのは「後片付け」で、そこはネットワークを実際に何度も使わないと表に出ません
しかもMacでは出ません。macOS側は同時接続の既定値がずっと大きく、2本を使い切るところまで行かないからです。Windows+Unityでだけ顕在化するバグでした😅

もうひとつ怖かったのが、失敗しても静かに壊れたことです。リクエストが失敗すると計測コードは直前の統計を使い回すので、CSVには前回と同じ数値がずらりと並びます。エラーに気づかなければ「変化なし」という結論を、そのまま記事に書いていたはずです。今は成功・失敗の列を出すようにしました。

実測①:呼び方でフレームレートはどう変わるか

ようやく本題です。まず1つ、Unityで速度を測るときの落とし穴がありました。

⚠ エディタのGameビューでは、全条件がぴったり60fpsになる
コードで vSyncCount = 0targetFrameRate = -1 を設定しているのに、待機60.00 / Background 60.32 / MainThreadAsync 60.04 と差がまったく出ません。エディタのGameビューは普通のデスクトップ窓なので、OSの画面合成に乗って60Hzで頭打ちになるためです。
スタンドアロンをビルドしたら解放されました。フレームレートを測る回は、最初からビルドして測るのが確実です。

解放されたら今度は1287fpsも出てしまったので、負荷用の立方体を900個から9000個に増やし、基準線を194fpsに調整しました。以下はその条件での結果です(Qwen2.5-7B)。

条件平均fps1% Low最悪フレーム描画枚数
待機(基準線)194.15136.719.18 ms971
⭕ Background(別スレッド)189.40109.4210.85 ms179
△ MainThreadAsync192.30149.576.99 ms160
❌ Blocking(同期)0

同期呼び出しは「遅い」のではなく「1枚も描かない」

予想は当たりましたが、当たり方が予想以上でした。Blockingでは描画フレームが0枚です。平均fpsが低いのではなく、計算する材料が1つもありません。生成が終わるまでメインスレッドが一度も戻ってこないので、フレームの計測すらできないのです。

これは「重い処理」ではなく「アプリが固まっている」状態です。ユーザーから見れば不具合と区別がつきません。

★予想が外れた:別スレッドのほうが引っかかった

意外だったのはこちらです。eightは「別スレッドが最良」と予想していましたが、実測ではMainThreadAsync のほうが 1% Low が良い(149.6 対 109.4)という逆転が起きました。最悪フレームも 6.99ms 対 10.85ms で、別スレッドのほうが悪いのです。

理由はおそらくこうです。

なぜ「別スレッドに逃がす」だけでは足りないのか 別スレッド受信 トークンはキューに溜まる まとめ処理 まとめ処理 ← ここで山が立つ(最悪 10.85ms) メインスレッド受信 届くたび少しずつ処理 ← 薄く分散して山が立たない(最悪 6.99ms) 受信を別スレッドにしても、メインスレッドへの引き取り方を設計しないと1% Lowは良くならない 溜まったぶんを一気に処理するフレームが、そのまま「引っかかり」になる

別スレッド版は、受け取ったトークンをキューに積み、メインスレッドが Update() でまとめて引き取ります。すると溜まったぶんを一気に処理するフレームだけが重くなり、そこが山になります。一方メインスレッド受信は、トークンが届くたびに少しずつ処理するので、負荷がフレームに薄く広がります。

「別スレッドに逃がせば安全」は半分だけ正しくて、引き取り方まで設計しないと1% Lowは良くならない——というのが今回いちばんの収穫でした。1フレームあたりの引き取り数に上限を設ければ、別スレッド版でも山は均せるはずです。

なお平均fpsで見ると、待機194.2に対し生成中189.4でわずか2.4%の低下です。ところが 1% Low は 136.7 → 109.4 で20%も落ちていますプレイヤーが「重い」と感じるのは後者なので、平均fpsだけ見ていると「影響なし」と誤診します。

実測②:ゲームの中でも同じことが起きるか

Macで見つけた「記憶の持ち方」の問題は、ゲームの中でも同じように起きるのか。8ターンの会話を3条件で回しました。

会話のターンと、最初の1文字が出るまでの時間(Windows RTX3060 / Qwen2.5-7B) 0 100 200 300 400 500 ミリ秒 1 2 3 4 5 6 7 8 会話のターン 5ターン目で跳ねる 全部積む+キャッシュoff 直近3往復+キャッシュon 全部積む+キャッシュon
条件1ターン目4ターン目8ターン目8ターン目の入力
⭕ 全部積む+キャッシュon50.6 ms102.4 ms126.1 ms485 tok(再利用 465)
△ 直近3往復+キャッシュon44.4 ms101.2 ms226.2 ms256 tok(再利用 67
❌ 全部積む+キャッシュoff141.4 ms301.8 ms469.2 ms472 tok(再利用 0)

Macで見たとおりのことが、ゲームの中でもそのまま起きました。しかもより露骨にです。

直近3往復  4ターン目: 入力 260 tok(再利用 242)→ TTFT 101.2 ms
直近3往復  5ターン目: 入力 270 tok(再利用  67)→ TTFT 222.6 ms   ← 2.2倍

古い往復を捨てた瞬間、再利用できるぶんが 242 → 67 に落ちています。67というのは人格プロンプトの長さです。Macの3Bでは約1.5倍の悪化でしたが、Windowsの7Bでは2.2倍。モデルが大きいほど読み直しの代金は高くつきます。

★「履歴を切り詰めて軽くする」は逆効果になり得る
8ターン目を見比べると、直近3往復のほうが入力は約半分(256 対 485 トークン)なのに、待ち時間は約1.8倍(226 対 126 ミリ秒)です。
入力が短いほど速い、が成り立ちません。文脈の限界が来たときは「古いぶんを捨てる」のではなく「要約して先頭から作り直す」ほうが筋が良さそうです。どのみち先頭が変わるなら、変える回数を減らしたほうが得だからです。

実測③:逐次表示は待ち時間を何倍ごまかせるか

同じ生成でも、届いた端から出すか、全部できてから出すかで「待たされた感じ」は変わります。ここは数字にしやすいところです。

見せ方待たされる時間(平均)いちばん長い返事のとき
⭕ 逐次表示(最初の1文字まで)229.9 ms193 ms
❌ 全部できてから表示1105.0 ms1389 ms
4.81 倍7.2 倍

生成にかかった時間はまったく同じです。変えたのは出し方だけ。それで体感の待ち時間が約5倍変わります。

しかも返事が長いほど差が開きます。59トークンの長めの返事では 193ms 対 1389ms で7.2倍。全部できてから出す方式は、NPCが饒舌になるほど待たされるという、いちばん困る性質を持っています。

逐次表示は「速くする」技術ではなく「速く見せる」技術です。それでも、モデルを小さくして速度を稼ぐより効きます

実測④:モデルとゲームがVRAMを取り合う

ここが唯一、eightの予想が外れたところです。「14BはVRAMが足りずに激しく遅くなるはず」と踏んでいました。RTX 3060 のVRAMは12GB、14Bのモデルは約9GB。ゲームの描画と足したら溢れるだろう、と。

モデルLLMのVRAMUnityのVRAM合計 / 12,288MB生成中fps1% Low溢れ
3B1,966 MB196 MB2,162 MB189.59152.62なし
7B5,302 MB196 MB5,498 MB189.40109.42なし
14B9,034 MB196 MB9,230 MB188.62137.52なし
タスクマネージャーのGPUメモリ。専用9.7/12.0GB、共有0.9/15.7GB

何も起きませんでした。14Bを載せても 9,230MB / 12,288MB で3GB以上余り、共有メモリへの溢れはゼロ。fpsも 3B とほとんど変わりません。

外した原因ははっきりしています。Unity側のVRAM使用量が196MBしかなかったからです。立方体9000個とUIだけの画面で、テクスチャがほぼありません。eightは「ゲーム=VRAMを大量に使うもの」と決めつけて見積もっていました。

⚠ この数字は「軽いシーンなら」という条件つきです
実際のゲームはテクスチャとレンダーターゲットでGB単位を使います。同じ12GBでも、Unity側が3GB使えば14Bとの同居は成り立ちません。今回の結果は「14Bが載る」ではなく「VRAMの余裕はゲーム側の重さ次第で決まる」と読むのが正しいです。実用的には3B〜7Bが妥当な線だと思います。

もうひとつ分かったことがあります。3モデルでfpsがほとんど変わらないということは、この画面はGPUではなくCPUで詰まっているということです。だからLLMがGPUを使っても平均fpsは落ちず、影響が 1% Low にだけ出ていたわけです。GPU負荷の高いゲームなら、話はまた変わります。

NPCのセリフは、モデルを大きくすれば良くなるのか

数字だけでは分からないので、同じ質問・同じ人格・同じ乱数で、モデルだけ差し替えた返事をそのまま並べます。人格の指定は「無愛想だが情に厚い道具屋の主人ガロン。一人称はオレ。2文以内。セリフのみ」です。

質問3B(1.80GB / 約84〜110 tok/s)7B(4.36GB / 約50 tok/s)14B(8.37GB / 約28 tok/s)
森を抜けた先はどうなっているんだ?森を抜ければ広い平原にたどり着く。森を抜けると、川が流れている場所がある。その先には小さな集落が見えるよ。森を抜けると広大な平原があるよ。
火打ち石は売っているか?ある。欲しい色は?はい、火打ち石はあります。必要なときは言ってくださいね。売っているよ、必要なか?
あんたはずっとこの村に住んでいるのか?はい、ずっとここに住んでいます。お前も長くここに住むつもりか?うん、ずっとこの村で道具売買しているよ。何か欲しい物ある?うん、ずっとだ。必要なものはここにある。

並べてみると、大きいほど良いとは限らないことが分かります。

  • 3Bは「火打ち石の」を聞き返してきます。噛み合っていません。一方で「はい、ずっとここに住んでいます」と敬語になったり、人格の指定からもずれます
  • 7Bがいちばん自然です。会話を続ける問いかけも自然に出ます。ただし全体に敬語寄りで、「無愛想」という指定は守れていません
  • 14Bは最も簡潔で、2文以内という指定に忠実です。ところが「売っているよ、必要なか?」と日本語が崩れる箇所がありました

NPCのセリフという用途では、7Bが実用的で、14Bの優位は感じられませんでした。速度は倍近く違い(50 対 28 tok/s)、VRAMも倍使うのにです。

ついでに言えば、人格の指定は3モデルとも完全には守れていません。「一人称はオレ」は守られたり守られなかったり。人格を効かせたいなら、モデルを大きくするより、実例を数往復ぶんプロンプトに入れておくほうが早いと思います。

Unityの画面でNPC「ガロン」が日本語で返事をしている。下部にTTFTなどの計測値

実際に手で話しかけたときの様子です。画面下に出ているのが計測値で、TTFT 142 ms / 合計 1236 ms / 入力 82 tok(再利用 81) / 出力 34 tok / 31.0 tok/s。返事はこうでした。

プレイヤー: 森を抜けた先はどうなっているんだ?
ガロン: あんたが話してんのかな?オレには詳しくないが、森の向こうには平原と町があるさ。何か探しているのか?

「あんたが話してんのかな?」が少し変ですが、一人称は「オレ」になっていますし、無愛想な口調も出ています。決められた台詞ではない返事が、ゲームの中で200ミリ秒足らずで返ってくる——やりたかったことは、ひとまず形になりました😊

ネイティブ組み込みは現実的か

最後に、前半で保留にした構成B——Unityのプロセスの中に llama.cpp を直接入れる方式です。Unityから DllImportllama.dll を呼べるか、実際に確かめました。

DLL署名読み込み関数の解決
LM Studio 同梱あり成功llama_backend_init / llama_model_load_from_file / llama_decode / llama_tokenize すべて解決
公式GitHubビルドなし失敗(エラー127)

つまり技術的には成立します。C APIはちゃんとエクスポートされていて、DllImport の要件を満たしていました。

ただし配布を考えると現実的ではありません。読み込めたDLLはLM Studioというソフトの一部で、自作ゲームに同梱して再配布するのはライセンス上おすすめできません。かといって自前でビルドすると、今度は署名のないDLLになります。

⚠ 失敗の原因は特定できていません
公式ビルドが返した エラー127 は「指定されたプロシージャが見つかりません」という意味です。Windowsのセキュリティ機能に弾かれた可能性も考えましたが、127は普通「依存している別のDLLが見つからない・版が合わない」ときに出る番号で、ポリシーによる遮断とは別物です。
つまり「署名がないから弾かれた」とは断定できません。同梱すべき ggml 系のDLLが足りなかっただけ、という線も十分あります。ここは今回、切り分けきれていません。

現実的な選択肢としては、Unity向けにこの面倒を引き受けてくれる LLMUnity(undreamai)というパッケージがあります。今回は導入していませんが、調べた範囲ではライセンスが Apache-2.0(商用利用可)、最新版 v3.0.3、更新も続いています。自前でDLLを抱えるより筋が良さそうです。

まとめ

  • NPCに喋らせること自体は難しくありません。難しいのは、喋らせながらゲームを動かし続けることのほうでした
  • 同期呼び出しは「遅い」のではなく「1枚も描かない」。フレーム0枚は、ユーザーから見れば不具合と区別がつきません
  • 平均fpsは2.4%しか落ちないのに、1% Low は20%落ちます。平均だけ見ていると「影響なし」と誤診します
  • 予想が外れました。別スレッド受信のほうが引っかかった(1% Low 109.4 対 149.6)。溜めてから一気に引き取るフレームが山になるためで、「別スレッドに逃がせば安全」は半分だけ正しい
  • 記憶を切り詰めると、入力が半分になるのに待ち時間は1.8倍になります。キャッシュが人格プロンプトのぶんまで崩れるからで、入力が短いほど速い、が成り立ちません
  • 逐次表示で体感の待ち時間が4.8倍変わります。生成時間は1ミリ秒も変えずに、です
  • VRAMの予想は外れました。14B+Unityで9,230MB/12,288MB。ただし今回のシーンはVRAMを196MBしか使っておらず、余裕はゲーム側の重さで決まります
  • セリフの質は、大きいほど良いとは限りません。7Bが最も自然で、14Bは日本語が崩れる箇所がありました
  • 40件のテストを通したのに、実機では3回目から必ず落ちました。間違っていたのは後片付けで、そこはネットワークを何度も使わないと表に出ません

やってみて意外だったのは、詰まったのがVRAMでも推論速度でもなく、ほとんど「待たせ方」の設計だったことです。逐次表示で4.8倍、キャッシュの扱いで3.7倍。モデルを大きくするより、この2つを整えるほうがずっと効きます

そしてもうひとつ。速度の回でも精度の回でも「測っている場所が違うだけ」という話に行き着きましたが、今回もまた平均fpsと1% Lowという、どちらも正しい数字のあいだで同じところに戻ってきました。プレイヤーが感じるのは、いつも平均のほうではありません😅

それでは、今回はここまで。最後までありがとうございました😊

【実測環境】本編(Unity)=Windows 11 Pro 25H2 (26200.8875) / Ryzen 7 7840HS / RAM 31.3GB / GeForce RTX 3060 12GB(ドライバ 32.0.16.1047)。Unity 6000.4.5f1(Built-in RP)。フレームレートの計測はスタンドアロンビルドで実施(エディタのGameビューは画面合成により60fpsで頭打ちになるため)。負荷は回転する立方体9000個で基準線194fps。推論は LM Studio 同梱の署名済み llama.cpp Vulkan ランタイム(2.25.2 / cb295bf)の llama-server-ngl 99 -c 4096。モデルは bartowski/Qwen2.5-{3B,7B,14B}-Instruct-GGUF の Q4_K_M。セリフの比較は同一 seed(12345)・同一プロンプトで、出力は整形せず原文のまま掲載しています。
事前検証(Mac)=Apple M4 / RAM 32GB / llama.cpp b10090(7347430f4・Metal)/ Qwen2.5-3B-Instruct Q4_K_M。
TTFTはいずれも「最初に本文が届いた時刻」をクライアント側で実測した値で、サーバー申告の timings とは別に取得しています。1% Low は遅い側1%のフレームの平均から算出。C#の単体テストは mono(csc)で40件を実行。MacとWindowsは llama.cpp のビルドもバックエンドも同一版ではありません(Metal と Vulkan)。数値は2026年7月時点の実測で、モデル・版・シーンの重さにより変わります。