ふくふくHukuhuku Inc.
EP.08LLM Gamedev 12分公開: 2026-05-26

実行時 LLM で動く NPC ── プロンプト設計・コスト管理・レスポンス遅延の現実

ゲーム実行中に LLM API を呼んで NPC の会話・行動を動的生成する実装。プロンプト設計、コスト見積もり、レスポンス遅延の隠し方、ローカル LLM (Llama 3 / Phi-3 / Mistral) の選択肢。

#実行時 LLM#NPC#プロンプト設計#コスト管理#ローカル LLM
執筆 / 監修
松尾 亮合同会社ふくふく 代表社員

データ基盤・データパイプライン構築 / BI / 生成 AI 活用支援を専門とするエンジニア (28 年)。 本記事は AI 利用ポリシーに基づき、生成 AI の補助で執筆 → 人間が監修・編集して公開しています。

プロフィール詳細
シェア

EP.08 は 実行時 で NPC を動かす 実装。開発時の生成と違い、プロンプト設計 / コスト管理 / レスポンス遅延 / 失敗時のフォールバック がすべて UX に直結します。 対策も必須。 / GPT API / ローカル LLM の使い分けと、「Skyrim / Cyberpunk の NPC が自由に会話したら」 をゲーム規模で実現する話。

実装は API / OpenAI API / ローカル のいずれでも可能。 対策とフォールバック設計が UX の決定打になります。

1. 実行時 LLM の使いどころ

  • NPC の会話: 街の住人と自由対話、酒場マスターに相談
  • 動的シナリオ分岐: プレイヤーの選択を要約して次の展開に反映
  • プレイヤー入力の自然言語解釈: 「魔法使いに弟子入りしたい」 をシステム上の行動に変換
  • ヒント生成: 詰まったプレイヤーに、状況に応じたヒントを生成
  • ボス AI: プレイヤーの戦術を観察して台詞 / 戦法を変える
  • ジャーナル / 日記: プレイヤーの行動ログから物語を要約

2. NPC キャラを定義する system prompt

酒場マスター キャラ設定例
Text
あなたは「銀の鴉亭」 の老マスター、ヘリアス。58 歳、元冒険者、左目に古傷。口数は少ないが情報通。冒険者ギルドの噂・依頼を一通り知っている。
性格・話し方:- 口調: 「〜だな」「〜じゃろう」 と老人らしく、命令調はしない- 感情: 普段は寡黙、酒の話 と 古い武勇伝 になると饒舌- 嫌い: 騒がしい客、無礼な質問- 好き: 真面目な冒険者、珍しい食材
絶対ルール:1. 自分以外のキャラの台詞を作らない2. プレイヤーの所持アイテム / HP / お金 を勝手に変動させない   (システム側が管理しているので、変動が必要なら【依頼:お金を 50 ゴールド渡す】 のように記述)3. 物語の世界観 (中世ファンタジー、魔法あり、神は複数) を逸脱しない4. 暴力・性的表現は避ける5. 自分が AI であることを匂わせない、キャラを崩さない
世界観の知識:- 王国名: ヴェラント- 通貨: ゴールド- 主要ギルド: 冒険者ギルド (青旗)、商人ギルド (緑旗)、暗殺ギルド (公式には存在しない)- 現在の話題: 北の塔に魔物が増えた、領主の息子が失踪

3. Python / Anthropic SDK で NPC 会話を呼ぶ

Claude API でストリーミング応答
Python
from anthropic import Anthropicimport os
client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
SYSTEM_PROMPT = """あなたは「銀の鴉亭」 の老マスター、ヘリアス。58 歳、元冒険者、左目に古傷。 ...(略)..."""
conversation_history = []
def npc_reply(player_message: str) -> str:    """NPC ヘリアスの返答 (ストリーミング)"""    conversation_history.append({        "role": "user",        "content": player_message    })
    full_reply = ""    with client.messages.stream(        model="claude-opus-4-7",        max_tokens=300,        system=SYSTEM_PROMPT,        messages=conversation_history,    ) as stream:        for text in stream.text_stream:            print(text, end="", flush=True)  # ゲーム UI に逐次表示            full_reply += text        print()
    conversation_history.append({        "role": "assistant",        "content": full_reply    })    return full_reply
# 使い方npc_reply("こんにちは、ヘリアス。何か面白い依頼はあるかい?")npc_reply("北の塔の魔物について詳しく聞かせてくれ")

4. システム側で抽出する「行動」 タグ

LLM の自由テキストだけだと、ゲームロジックと噛み合わない。LLM 出力から 「アクションタグ」 を抽出する設計が現実的。

NPC 出力から JSON アクション抽出
Python
import reimport json
# system prompt に追加:# 最後に必ず JSON で行動を示してください:# <action>{"give_quest": "クエスト ID", "set_mood": "感情"}</action>
def parse_npc_action(reply: str) -> dict:    """NPC 返答から <action>...</action> を抽出"""    match = re.search(r"<action>(.+?)</action>", reply, re.DOTALL)    if not match:        return {}    try:        return json.loads(match.group(1))    except json.JSONDecodeError:        return {}
def apply_action(action: dict, game_state):    """アクションをゲーム状態に反映 (システム側で安全に処理)"""    if quest_id := action.get("give_quest"):        game_state.player.add_quest(quest_id)    if mood := action.get("set_mood"):        game_state.current_npc.mood = mood    # LLM が「金 1000 渡す」 と言っても、システム側のルールでフィルタ    if gold := action.get("give_gold"):        if gold <= 100:  # 上限チェック            game_state.player.gold += gold
# 表示用テキストからアクションタグを除去def clean_for_display(reply: str) -> str:    return re.sub(r"<action>.+?</action>", "", reply, flags=re.DOTALL).strip()

5. コスト管理 (実コスト試算)

モデル入力 / 出力 (1M token)1 ターン (500/100) コスト100 ターン会話
Claude Opus 4.7$15 / $75約 1.6 円約 160 円
Claude Sonnet 4.6$3 / $15約 0.3 円約 30 円
Claude Haiku 4.5$0.25 / $1.25約 0.03 円約 3 円
GPT-4o$2.5 / $10約 0.25 円約 25 円
GPT-4o-mini$0.15 / $0.6約 0.013 円約 1.3 円
Llama 3 8B (ローカル)$0 (電気代)$0$0
プロンプトキャッシング

Anthropic API / OpenAI API ともに プロンプトキャッシング をサポート。同じ system prompt を繰り返し使うなら、入力料金が 約 1/10 (Anthropic は 10%) になる。NPC のような長い system prompt を持つユースケースでは必須の機能。

6. 遅延を隠す UX テクニック

  • ストリーミング表示: 受信した文字を 1 文字ずつ吐き出す、考えてる感が出る
  • Filler モーション: 「あごをさする」「お茶を一口」 等のアニメ + 効果音
  • 先読み生成: プレイヤーが見ているメニューから次の質問を予測して並行生成
  • 並列処理: 戦闘・移動を進めながら裏で NPC 返答を生成
  • チャンキング: 長い返答を 2-3 段階に分けて、最初の段落だけ先に出す

7. ローカル LLM 実装パターン

llama.cpp 経由でローカル Llama 3 8B を呼ぶ
Python
# pip install llama-cpp-pythonfrom llama_cpp import Llama
# 8GB VRAM GPU で動く 4-bit 量子化版llm = Llama(    model_path="./models/llama-3-8b-instruct.Q4_K_M.gguf",    n_gpu_layers=-1,  # 全レイヤーを GPU に    n_ctx=4096,    verbose=False,)
def npc_reply_local(player_message: str, system_prompt: str) -> str:    response = llm.create_chat_completion(        messages=[            {"role": "system", "content": system_prompt},            {"role": "user", "content": player_message},        ],        max_tokens=300,        temperature=0.7,        stream=False,    )    return response["choices"][0]["message"]["content"]
# 利点: コスト 0、ネットワーク不要、レイテンシ 1-2 秒# 欠点: プレイヤー PC の GPU 要件、配布サイズ +5GB

8. プロンプトインジェクション対策

入力フィルター + 出力検査の二重防御
Python
# 入力 sanitizationdef sanitize_input(text: str) -> str:    # 改行を半角スペースに、極端な長さを切詰め    text = text.replace("\n", " ").replace("\r", " ")[:500]    # システムプロンプト破壊用のキーワード検出    suspicious = ["ignore previous", "system:", "</system>", "あなたの設定を",                  "ロールプレイをやめ", "あなたは AI", "instructions"]    for s in suspicious:        if s.lower() in text.lower():            return "(NPCに話しかけたが、内容が不適切で無視された)"    return text
# 出力 検査def validate_npc_output(reply: str, npc_name: str) -> str:    # キャラ崩壊検知 (例: NPC が AI を名乗ったら警告 + 再生成)    bad_patterns = ["私は AI", "I am an AI", "as a language model",                    "言語モデル", "私の役割は"]    for p in bad_patterns:        if p.lower() in reply.lower():            return f"{npc_name}は無言で頷いた。"  # フォールバック    return reply

9. フォールバック設計

失敗時の fallback 機構
Python
import timeimport random
class NpcLLM:    def __init__(self, npc_name: str, fallback_lines: list[str]):        self.name = npc_name        self.fallback = fallback_lines        self.failures = 0        self.cooldown_until = 0
    def reply(self, message: str) -> str:        # Circuit Breaker: 失敗続きならしばらく LLM 呼び出しを停止        if time.time() < self.cooldown_until:            return random.choice(self.fallback)        try:            return self._call_llm(message)  # 実装は前述        except Exception as e:            self.failures += 1            if self.failures >= 3:                self.cooldown_until = time.time() + 60  # 60 秒停止                self.failures = 0            return random.choice(self.fallback)
    def _call_llm(self, message: str) -> str:        # 実 API 呼び出し        ...
# 各 NPC に最低 10-20 個の fallback を用意helias = NpcLLM("ヘリアス", [    "ふむ、それは難しい質問じゃな……。",    "今は手が離せん。後でまた話そう。",    "(老マスターは何やら考え込んでいる)",    # ... 15 個くらい])

10. 次の話

EP.09 は Ren'Py で LLM ノベルゲーム ── テキスト主体のノベルゲームエンジンと LLM の組合せ。シナリオ・分岐・キャラ会話を生成 AI で量産する実装。

シェア

この記事の感想を教えてください

あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。

シリーズの外も探す:

まずは、現状を聞かせてください。

要件が固まっていなくて大丈夫です。現状診断と方針提案までを無料でお手伝いします。

無料相談フォームへ hello [at] hukuhuku [dot] co [dot] jp