0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RTX 5060 Ti 16GB×3 と SGLang で、LLM が考えて動く NPC 100 体を同時に回してみた

0
Posted at

はじめに

「RTX 5060 Ti 16GB を 3 枚にしたのは無駄遣いではなかったのか?」シリーズの続きです。前々回・前回はこちらです。

vLLM や SGLang は、たくさんのリクエストをまとめて同時に処理するのが得意です。
それなら、たくさんの NPC が同時に LLM に問い合わせて、話したり動いたりする「鑑賞ゲーム」のようなものが作れるのではないかと考えて、やってみました。

動いているところの動画です。

知りたかったのは次の 2 つです。

  1. 処理が追いつくか。 1 台の PC の推論サーバーで、何体までなら待ち時間が気にならない速さで回せるか
  2. それらしく振る舞うか。 ローカル LLM に任せた NPC が、性格や記憶に沿った行動や会話をするか

作ったもの:llm-island

島で 100 体の住人が暮らすシミュレーションです。

  • 住人は歩き回り、出会った相手と会話する。会話のあとは相手への好き嫌いや聞いた噂を覚える
  • ときどき島の外から悪の魔物「ヤミゴースト」がやってきて、住人を爪でフリーズさせる。戦士は超能力で戦い、ヒーラーはフリーズした仲間を魔法の杖で助けに行く
  • 何を言うか・どこへ行くか・助けに行くかは、住人ごとの LLM 呼び出しで決まる(ヤミゴーストが誰を狙うかも LLM が決める)

島全体

島の全体。起動直後の 1 回目のウェーブで、100 体全員が「どこへ行くか」の返答を待っているところ(左上は状況の表示)

住人 1 体が LLM に聞くことは、次の 4 種類です。

種類 いつ 返してもらうもの
発話 会話中で自分の番のとき セリフ、会話を終えるか
移動の意図 前の行動が終わったとき・魔物に気づいたとき どこへ行くか・誰を探すか・逃げるか戦うか、心の声
救助の判断 ヒーラーがフリーズした住人のそばに来たとき 杖で癒すか、そのときのセリフ
会話の要約 会話が終わったとき 振り返り、要約、好感度の変化、聞いた噂

ヤミゴーストも、島にいる間は同じしくみで LLM に「次に誰を狙うか・もう去るか」を聞きます(後述)。

Python だけで書いていて、3D の表示は Ursina です。
リポジトリ: https://github.com/lumavrenor/llm-island

結論

先に結論を書きます。

  • 作れました。ただし 100 体くらいまで。 住人 100 体で、1 ウェーブ(後述。約 58 件の問い合わせ)が約 26 秒。200 体だと約 45 秒で、住人 1 体あたりの会話や行動の間隔も 2 倍近くになります
  • まとめて投げる効果は大きい。 1 件ずつ順番に投げるのに比べて、約 8 倍の数をさばけました。ただし「100 件を同時に投げても 1 件と同じ速さ」にはならず、8 件でも 1 件の約 2 倍の時間がかかりました
  • それらしい振る舞いは出ました。 性格どおりの口調、相手ごとの好き嫌い、苦手な相手を苦手なままにする態度、又聞きの噂などが、コードで決め打ちせずに出てきます。一方で、噂はあまり遠くまで広まりません
  • ヤミゴーストとの戦いも LLM で回りました。 ヤミゴーストは撃ってくる戦士を避けて無防備な住人を狙い、住人は「ヤミゴーストが近い、急げ!」と声をかけ合い、ヒーラーはフリーズした仲間を起こしに行きます

テンポは「リアルタイムに会話が飛び交う」ではなく、「20〜30 秒ごとに島のあちこちで一斉に話が進む」感じです。
眺めるだけの鑑賞ゲームなら、これで十分に成り立ちました。

環境

項目 値
GPU RTX 5060 Ti 16GB × 3(計 48GB、P2P なし、PCIe。1 枚は画面出力も兼ねる)
OS Windows 11 + WSL2(推論サーバーは WSL2 側、ゲームは Windows 側)
推論サーバー SGLang 0.5.21(OpenAI 互換 API)
モデル Qwen3.8-27B NVFP4(KV キャッシュは fp8_e4m3、コンテキスト 8192)
並列 PP3(パイプライン並列 3 段)

作り方のポイント

全員分を「ウェーブ」でまとめて投げる

住人ごとにスレッドや非同期タスクを作って LLM を呼ぶと、リクエストがバラバラに飛び、世界のデータを誰がいつ書き換えるのかもややこしくなります。
こうなると、Claude Code を使っていてもバグが取りきれません。そこで、LLM への問い合わせを「ウェーブ」という単位にまとめました。

  1. collect: 返答が必要な住人全員分のリクエストを集めて、一度に投げる
  2. wait: 全部返るまで待つ(その間も住人は歩き続ける)
  3. play: 結果をまとめて反映し、セリフのふきだしを見せる時間だけ待つ → 1 へ

メインループは毎フレーム World.update() を呼ぶだけで、その中で次のように状態を進めます。

sim/world.py
def _wave_frame(self):
    match self.wave_state.update():
        case "collect":
            ...
            reqs = self._build_requests()
            self.wave = self.llm.submit(reqs)   # 全員分を一度に投げる
            self.wave_state.change("wait")

        case "wait":
            if not self.wave.is_done():          # 全部返るまで毎フレーム確認するだけ
                return
            self.play_sec = self._apply_wave(self.wave.results())
            self.wave_state.change("play")

        case "play":
            if self.wave_state.elapsed >= self.play_sec / self.speed:
                self.wave_state.change("collect")
  • HTTP の送受信は裏のスレッド 1 本(asyncio)だけで行い、世界のデータには触らせません。渡すのはリクエストと結果のただのデータだけなので、ゲーム側はシングルスレッドのまま書けます
  • 住人 1 体が出すのは 1 ウェーブに 1 件まで(発話 > 救助の判断 > 移動の意図、の優先順)。要約や魔物の判断は行動を止めないので、同じウェーブに追加で乗せます
  • 返答を待っている間に状況が変わることがあります(魔物が来た、相手がフリーズした、など)。結果を反映する前に必ず確かめて、古い判断は捨てて聞き直します
  • play の間に次のウェーブを投げておけば、GPU が空く数秒を埋めて少し速く回せます。ただ流れが一直線でなくなるので、デモではシンプルさを優先してやっていません

この作りだと、ウェーブ 1 回の時間 = 住人 1 体が次に話したり動いたりできるまでの間隔になります。
ウェーブの中でいちばん遅い 1 件を全員が待つので、ここが短いほど島がにぎやかになります。

トーク

play の瞬間。返ってきたセリフが島のあちこちで一斉にふきだしになる。右上は画面の外の会話、左下はカメラが追っている住人の性格・最近の出来事・相手ごとの印象

返答は JSON スキーマで縛り、短くする

返答はすべて JSON スキーマで形を決めています(response_format の json_schema)。
例えば移動の意図を聞くと、住人はこういう JSON だけを返します。

{"intent": "seek", "target": "(会いたい住人の名前)", "pace": "fast",
 "manner": "早く会って話を聞いてほしい", "duration_sec": 60}

この形は、次の関数で作るスキーマで決めています。

llm/schemas.py
def _intent(kinds) -> dict:
    return _obj({
        "intent": {"type": "string", "enum": list(kinds)},  # go_spot / seek / avoid / flee / fight など
        "target": {"type": "string"},
        "pace": {"type": "string", "enum": list(PACES)},
        "manner": {"type": "string"},                      # 心の声
        "duration_sec": {"type": "integer"},
    })

選べる意図は住人ごと・状況ごとに変えています(魔物に気づいていなければ flee は無い、戦士だけ fight がある、など)。
選べない行動はスキーマの時点で出せないので、反映側で弾く処理が要りません。

長さはスキーマでは縛らず、プロンプトで「40字以内」と頼んだうえで、反映するときに切り詰めています(文字数の制約をスキーマに書くと、制約デコードの実装差で 400 エラーになることがあるため)。

Qwen3 の thinking は常にオフにしています(chat_template_kwargs: {"enable_thinking": false})。
考えさせたいところは、JSON の項目の順番を使って「先に振り返りを書かせてから要約を書かせる」ようにしました。

推論サーバーの設定

python -m sglang.launch_server \
  --model-path Qwen3.8-27B-NVFP4 --served-model-name npc \
  --tp-size 1 --pp-size 3 \
  --context-length 8192 --mem-fraction-static 0.93 --kv-cache-dtype fp8_e4m3 \
  --max-running-requests 48 --max-queued-requests 192 \
  --cuda-graph-max-bs-decode 48 --disable-prefill-cuda-graph \
  --disable-radix-cache --reasoning-parser qwen3 \
  --host 127.0.0.1 --port 30000

試してわかったこと:

  • 同時処理数(--max-running-requests)がいちばん効く。ただし大きければ速いわけではない。 Qwen3.8 は層の多くが線形アテンションで、リクエストごとに大きな状態を持ちます。サーバーは起動時に同時処理数ぶんの状態を確保し、残りが KV キャッシュになります。100 にしたら KV キャッシュが足りず、実際に同時に動いたのは 9 件ほどでした。48 の方が速くなりました
  • 同時処理数を超えた分は待ち行列(--max-queued-requests)で待ちます。ゲーム側で同時に投げる数を 100 件までにしている(1 ウェーブがそれより多いときは、超えた分をゲーム側で待たせてから投げる)ので、48 + 192 なら溢れません
  • --cuda-graph-max-bs-decode は同時処理数と同じにする。小さいと、それを超えたバッチが何倍も遅くなりました
  • P2P のない GPU 3 枚では、テンソル並列(TP2、2 枚)よりパイプライン並列(PP3、3 枚)の方が約 4 倍速くなりました(使う枚数が違うので、条件をそろえた比較ではありません)

処理は追いつくか

測り方

普段動かしている世界のセーブデータのコピーを使い、2 通りで測りました。

  1. ゲームを実際に動かす。 住人 100 体と 200 体で、本物の LLM を使って実時間 6〜9 分動かす。あわせて全ウェーブのリクエストをそのまま記録しておく
  2. 件数だけを変えて投げ直す。 1 で記録した実際のリクエストから K 件を選び、1 ウェーブとして一度に投げる(K = 1〜240)

「ゲームを動かす」だけだと件数を自由に選べず、「ダミーのプロンプトで測る」だけだと実際の出力の長さのばらつきが入りません。
実際のリクエストを記録して投げ直すことで、両方を見られるようにしました。

1 件ずつより約 8 倍さばける。でも「1 件と同じ速さ」ではない

一度に投げる件数を変えたときの、ウェーブ 1 回の時間です(2 の方法で、記録した実際のゲームのリクエストを投げ直したもの。各 2〜3 回測った範囲)。

一度に投げた件数 時間 1 件あたり TTFT 中央値
1 3.2〜4.6 秒 3.2〜4.6 秒 0.5〜0.6 秒
4 6.6〜7.0 秒 1.7 秒 1.0〜1.1 秒
8 6.6〜6.8 秒 0.84 秒 1.3〜1.4 秒
16 9.8〜12.6 秒 0.6〜0.8 秒 1.9〜2.0 秒
24 11.0〜15.4 秒 0.5〜0.6 秒 2.5〜2.8 秒
48 19.2〜21.0 秒 0.43 秒 4.3〜4.7 秒
96 42.1〜44.7 秒 0.44〜0.47 秒 11.8〜13.5 秒
144 61.0〜66.2 秒 0.42〜0.46 秒 21.5〜29.3 秒
192 81.6〜91.0 秒 0.43〜0.47 秒 32.1〜34.6 秒
  • 件数を 8 倍にしても、時間は 2 倍程度です(中央値で 1 件 3.4 秒 → 8 件 6.8 秒)。1 件から 4 件で約 2 倍になり、4 件から 8 件ではほとんど変わりません。1 件だけだと、3 段のパイプラインのうち 1 段ずつしか働かず、GPU が遊んでいるのだと思われます
  • 16〜48 件で 1 件あたりの時間が下がりきります
  • 48 件(同時処理の上限)から上は、件数に比例して長くなります。 1 件 ≒ 0.44 秒で一定。サーバー全体の出力は 120〜140 tok/s で頭打ちでした

「100 件同時に投げても 1 件と同じくらいで返る」はこの環境では成り立たず、100 件だと 1 件の 10 倍以上かかります。
それでも 1 件ずつ順番に投げるより約 8 倍速い(1 件あたり 3.4 秒(1 件だけのときの中央値)→ 0.44 秒)。
住人 100 体の普段のウェーブ(約 58 件)で言えば、1 件ずつなら 3 分以上かかるところが 26 秒で済んでいます。

測定で、同時処理 48 件 + 待ち行列 192 件 = 240 件ちょうどを一度に投げたら、30 件以上が失敗しました(どれも「返答が JSON として読めない」)。
ゲームでは同時に投げる数を 100 件までにしていて、それを超えた分はゲーム側で待ってから投げるので、この失敗は起きていません。

住人 100 体と 200 体

ゲームを実際に動かしたとき(1 の方法)の、落ち着いたあと(ウェーブ 4 以降)の値です。

住人 1 ウェーブの件数(中央値) 1 ウェーブの時間(中央値・範囲) 1 件あたり 失敗
100 体 57.5 件 25.8 秒(19.8〜34.5) 0.46 秒 0
200 体 115 件 45.4 秒(38.0〜72.3) 0.43 秒 0

住人を 2 倍にすると件数も 2 倍になり、1 件あたりの時間は変わりません。
住人 100 体の時点で、すでに「件数に比例して長くなる」区間に入っていたということです。

1 ウェーブの件数が住人の数の 0.57 倍のまま動かなかったのもおもしろいところです。
ウェーブが長くなればその間に出会いが増えて、次のウェーブの件数が膨らみそうなものですが、返答を待っている住人は次のウェーブに乗らないので、自然に釣り合うようです。

なお、起動直後の 1 回目のウェーブは全員が行き先を聞くので、100 体で 39 秒、200 体で 82 秒かかりました。

プロンプトの長さがそのまま効く

プロンプトは平均約 1,200 トークン、返答は発話 40・意図 60・要約 110〜140 トークンほどです。
入力が長く出力が短い、プリフィル(prefill)の重い負荷です。

開発の途中で HP・心・状態異常・噂などをプロンプトに足したら、入力が 4 割ほど長くなり、ウェーブの中央値は 17 秒から 26 秒に伸びました(原因を分けて確かめてはいません)。
NPC に「覚えていること」を増やすほど、島のテンポは遅くなります。

radix cache は効かなかった

プロンプトの先頭の約 4 割は、全員に共通の文(世界の説明など)です。
SGLang の radix cache は、共通の先頭を一度計算したら結果を取っておき、次のリクエストではその分の計算を省く仕組みです。
これを有効にすれば速くなると思いましたが、このモデルでは速くなりませんでした。

キャッシュ自体は効いていて、1 件あたり約 4 割のトークンが使い回されていました。
遅くなった原因は、前に書いた線形アテンションの状態です。共通の先頭を使い回すには、KV キャッシュに加えて「先頭まで読んだ時点の状態」も取っておく必要があります。
状態は大きいのでその分メモリが減り、同時処理数が 48 件から 12 件に下げられて、約 2 倍遅くなりました。
設定を調整して同時処理数を 42 件まで戻すと、普段の設定との差はばらつきの範囲内になりました。

設定 同時処理 1 件あたり(24 / 48 / 96 件)
radix 無効(普段の設定) 48 件 0.32〜0.36 / 0.24〜0.30 / 0.27〜0.30 秒
radix 有効 12 件 0.53〜0.59 / 0.48〜0.54 / 0.51〜0.54 秒
radix 有効 + 同時処理数を戻す調整 42 件 0.28〜0.36 / 0.28〜0.34 / 0.27 秒

線形アテンションを使わない普通のモデルなら、状態を取っておく必要がないので、有効のままの方が速い可能性があります。

この表は、ゲームのリクエストではなく、住人ごとに違う約 1,080 トークンのプロンプトを 24・48・96 件まとめて投げ、出力を 60 トークンにそろえて測りました(各 2 回)。
出力が短くそろっているぶん、1 件あたりは前の表(0.44 秒)より短くなります。前の表とは比べず、この表の行どうしで比べてください。

vLLM は SGLang より遅かった

同じ PC・同じモデルで vLLM 0.29.0 も試しました。SGLang と同じ構成(PP3・同時処理 48)に合わせて調整しましたが、1 分間に返せた数は SGLang の 6 割ほどで、100 件を一斉に投げたときは 2.6 倍の時間がかかりました。

SGLang vLLM
1 分間に返せたリクエストの数 146 回 91 回
100 件を一斉に投げて全部返るまで 28 秒 73 秒

「1 分間に返せたリクエストの数」は、100 人がそれぞれ約 30 秒おきに話しかける負荷をかけて測りました。

vLLM でも、このモデルでは prefix キャッシュが効きませんでした。KV のブロックが 1,568 トークンと大きく、共通の先頭部分がそれより短いので、使い回せなかったためです。

ただし、比べたのはゲームに似せた負荷テストのスクリプトだけです。ゲーム本体を vLLM で動かしてはいません。
また、プロンプトを長くする前の時期に測ったので、上の件数の表(96 件で 42〜45 秒)とは条件が違います。比べられるのは、この表の SGLang と vLLM どうしだけです。

それらしく振る舞うか

ここは数字より、ログから拾った例で紹介します(名前もセリフもすべて LLM が作ったものです)。

性格どおりに話す

住人の性格(口調・好きなもの・苦手なもの)は最初に LLM で 100 体分作っています。
古風な住人は「〜申す」、理屈っぽい住人は「移動コストを最小化する」と、口調は最後まで性格どおりでした。

好き嫌いができ、苦手な相手は苦手なまま

会話のあとに、それぞれの視点で振り返りと好感度の変化を LLM に書かせています。

ガクツ → クウガ(−2)「ぶっきらぼうで嫌味な奴だ。…静かにしろと言ったのに逆切れするあたり、騒音そのものだな」

ピクピク → プーピ(+2)「行動派で、ボクと波長が合う」

この振り返りを次に会ったときのプロンプトに入れると、苦手な相手との再会でも態度が変わらず、悪い印象が続くようになりました。
一方で、励まされて機嫌を直した例もあります。

ザッブ → クウガ(前回 −1「また拒絶された」)⇒ 今回 +1「励まされて少しだけ機嫌が直った気がする。急かすのはやめよう」

噂が伝わる

会話の要約で、相手が話した「ほかの住人の話」を噂として取り出し、次の会話のプロンプトに入れています。
「噂話をしなさい」とは指示していません。それでも、聞いた噂をさらに別の住人に話す「又聞き」が起きました(30 分で 2 件だけですが)。
ジップが知っていた「キクイクがフリーズしている」という話がカオシに伝わり、カオシからさらにソルナへと伝わっています。

会う前に噂で聞いていた相手に、初対面でその話を持ち出すこともありました。

(チクブは「ケイジがフリーズしたらしい」とチカチカから聞いていた)
チクブ: ケイジ、元気?ハイルに助けてもらって一安心だな。ワタシもそばに居るよ。

ハイルが助けたことは、この噂には入っていません。ほかの出来事から知ったのか、LLM が付け足したのかは確かめていません。

うまくいっていないこと

  • 噂はあまり遠くまで広まらない。 材料はプロンプトに入っているのに、セリフは目の前のこと(景色・気分・相手の様子)が中心で、第三者の名前が出るのはフリーズや救助が近くで起きたときがほとんどでした
  • 噂の中身も、人柄の評判(「○○は嫌なやつ」)より「○○がフリーズしたらしい」のような出来事が中心です
  • 話題を持ち出す動機(「今これを話したい」)を持たせるか、もっと長く動かして印象が育つのを待つか、まだ試しているところです

ヤミゴースト・戦士・ヒーラー

会話だけだと、島は穏やかすぎて眺めていても起伏がありません。そこで「困りごと」として、ときどき悪の魔物が来るようにしました。

ヤミゴーストバトル

ヤミゴースト(中央)と戦士のヌポラ・ノミュラ。手前の青い箱は、爪でフリーズしたチュプモ。
左上の表示にはヤミゴーストの心の声「戦士は遅い。まず目の前の獲物を食らわねば。」が出ている

登場するもの 数 LLM が決めること プログラムがやること
ヤミゴースト 1 体(去ってから 7〜12 分たつと、また島の端に現れる) 誰を狙うか・もう去るか、ひとこと、心の声 追いかける、爪で攻撃する、撃ってきた戦士に黒い弾で反撃する
戦士 住人のうち 5 体 戦う(fight)か逃げる(flee)か fight の間、届く距離に入ったら 5 秒おきに超能力を自動で撃つ
ヒーラー 住人のうち 3 体 どのフリーズした住人を助けに行くか、着いたら杖で癒すか、そのときのひとこと 3 秒の詠唱(その間は動けない)、終わったら HP 満タンで目覚めさせる
ほかの住人 残り 逃げるか、ほかの行動を続けるか 近づかれたら(35m 以内)気づいて、移動の意図を聞き直す

LLM は「決めるだけ」、動きはプログラム

結論に書いたとおり、ウェーブ 1 回は 20〜30 秒かかります。超能力を 1 発撃つたびに LLM に聞いていたら、戦いになりません。
そこで、LLM には「誰を狙うか」「戦うか逃げるか」「癒すか」だけを決めさせ、追いかける・撃つ・詠唱するといった毎フレームの動きはプログラムが受け持つように分けました。

ヤミゴーストの判断も、住人と同じウェーブに 1 件だけ乗せます。聞き直すのは、次のようなきっかけがあったときです。

  • 島に着いた、狙っていた住人をフリーズさせた、逃げられた(12 秒追っても届かない)
  • 戦士に撃たれた、超能力が当たって HP が減った、ヒーラーが仲間を目覚めさせた
  • 何もなくても 25 秒たった

プロンプトには、自分の HP、あと何体で満腹か、近い住人 10 体(距離・HP・戦士かヒーラーか)、撃ってきた戦士、これまでの襲来の結果を入れています。
返答はスキーマで hunt(狙う)か leave(去る)に限っています。

llm/schemas.py
RAIDER = _obj({
    "action": {"type": "string", "enum": ["hunt", "leave"]},
    "target": {"type": "string"},   # hunt なら狙う住人の名前
    "say": {"type": "string"},      # 住人に聞こえるひとこと(片言で)
    "thought": {"type": "string"},  # 心の中の考え
})

性格づけは、システムプロンプトで「話し方は片言で、カタカナまじりの不気味な言葉。住人と仲良くすることはない」としただけです。

戦士の強さは、LLM を使わずにゲーム本体のシミュレーションのコードだけで「戦士 N 体 対 ヤミゴースト」の決闘を 200 回ずつ回し、戦士 3 体でほぼ五分(勝つ率 46〜58%)になるように決めました。
ただしこれは、住人の HP・心のしくみを入れる前のルールで測った値で、今のルールでは測り直していません。

ある襲来の記録

10 月 13 日に実際の LLM で動かした世界の、1 回目の襲来です(時刻は現れてからの世界の時刻。ひとことと心の声は LLM の返答そのまま)。
ヤミゴーストは HP 100 で現れ、戦士の超能力が 1 発当たるごとに 10 減ります。

時刻 出来事
0 秒 ヤミゴーストが島の端に現れる
49 秒 キポクを狙う。
心の声「初狩り。近いのを切り裂いて、島をノイズで埋めろ。」
ひとこと「フリーズ、時間だ...」
103 秒 反撃の黒い弾を受けて、戦士ラポニがフリーズ
114 秒 HP 60。103 秒より前にラポニに撃たれたときの問い合わせの返答が届き、53m 先のヨルルに狙いを変える。
心の声「戦士は厄介だが、無防備なヨルルを先に仕留める。」
ひとこと「お前も…フリーズだ」
143 秒 HP 20。今度はアピュネを狙う。
心の声「戦士は後回し。目の前の奴、今すぐフリーズさせろ。」
ひとこと「クッ…止まれぇ」
162 秒 HP 10。6m まで迫っていた戦士ヌポラに狙いを変える。
心の声「戦士は守り固い。でも、近くにいる。先に倒さないと爪が届かない。」
ひとこと「電撃、やめて。爪で裂くぞ。」
168 秒 戦士 3 体(ロネラ 16 発中 5 発、ヌポラ 9 発中 3 発、ラポニ 11 発中 2 発が命中)に倒される
187 秒 ヒーラーのタミュネが杖で、フリーズしていたラポニを目覚めさせる

「戦士は守りが固い」「急ぐ住人には追いつけない」という弱点をプロンプトで伝えてあるので、撃たれると無防備な住人に狙いを変え、追い詰められると目の前の戦士に向かう、という判断をしています。
ただし 114 秒のように、判断はウェーブの待ち時間のぶん、状況から遅れて届きます。

フリーズ

別の襲来。HP 10 まで追い詰められたヤミゴーストのまわりで、ノミュラ・ネポセ・フムルがフリーズしている。
このときの心の声は「戦士は遠い。近いネポセを先に切り裂く。」

襲来のあいだ、住人たちは会話の中で声をかけ合っていました(1・2 回目の襲来から)。

「ヤミゴーストか。拙者は退く。」

「結論から言うと、戦士が対応中。移動は無駄。」(理屈っぽい住人)

「待てない!ヤミゴースト、ウポネ狙ってるぞ!」

「待ってないでさっさと!ヤミゴーストが近いぞ、急げ!」

ヒーラー

ヒーラーのソピュレ(右、杖を持っている)が、フリーズしていたフムルを目覚めさせたところ。奥のノミュラとネポセはまだフリーズしたまま

助けられた話は、そのあとの会話にも出てきます。

ラポニ「タミュネの杖で目覚めたのだ。朽ちゆくもよし、心は穏やか。」

(タミュネに)「杖の光、美しいわね。」 → タミュネの振り返り「杖の光を褒めてくれた。…励まし合いで心が温まりました。」

ヤミゴーストは「これまでの襲来」を覚えているので、負けが続くと心の声が変わります。

3 回目「前回負けたな。まず近い奴を切り裂く。」

4 回目「また倒されるか。速く爪を突き刺さなければ。」

ただ、この世界では 4 回の襲来がすべて戦士に倒されました(フリーズさせたのは 1 回あたり 0〜2 体)。
決闘のシミュレーションでは五分だったのに、実際は戦士の方が強く出ています。
4 回とも戦士 3 体が駆けつけて撃っていましたが、なぜ五分にならないのかはまだ確かめていません(決闘を今のルールで測り直していないのも、原因の候補です)。強さの調整は続けています。

まとめ

  • LLM で考えて動く NPC の鑑賞ゲームは、ローカルの推論サーバー 1 台でも作れました。この環境では 100 体くらいまでが目安です
  • コツは、NPC ごとに非同期で呼ぶのではなく、全員分を「ウェーブ」でまとめて投げること。推論サーバーのバッチ処理を使い切れるうえ、ゲーム側をシングルスレッドのまま書けます
  • 「まとめれば 1 件と同じ速さ」ではありません。同時処理の上限を超えると、ウェーブの時間は NPC の数に比例して伸びます。NPC の数・プロンプトの長さ・テンポのどれかを選ぶことになります
  • 性格・記憶・噂をプロンプトに入れるだけで、口調や好き嫌いは思った以上にそれらしくなりました。一方で、入れた噂を住人が自分から話題にすることは少なく、噂は遠くまで広まりません。話したくなる動機をどう持たせるかが、おもしろいところかもしれません
  • 戦いのように速さが要る場面は、LLM には「誰を狙うか・戦うか・癒すか」だけを決めさせ、動きはプログラムに任せると、20〜30 秒おきの判断でも成り立ちました

リポジトリと動画

llm-island(GitHub)

動いているところの動画(YouTube)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?