EP.08 は 実行時 で NPC を動かす 実装。開発時の生成と違い、プロンプト設計 / コスト管理 / レスポンス遅延 / 失敗時のフォールバック がすべて UX に直結します。 対策も必須。 / GPT API / ローカル LLM の使い分けと、「Skyrim / Cyberpunk の NPC が自由に会話したら」 をゲーム規模で実現する話。
実装は API / OpenAI API / ローカル のいずれでも可能。 対策とフォールバック設計が UX の決定打になります。
1. 実行時 LLM の使いどころ
- NPC の会話: 街の住人と自由対話、酒場マスターに相談
- 動的シナリオ分岐: プレイヤーの選択を要約して次の展開に反映
- プレイヤー入力の自然言語解釈: 「魔法使いに弟子入りしたい」 をシステム上の行動に変換
- ヒント生成: 詰まったプレイヤーに、状況に応じたヒントを生成
- ボス AI: プレイヤーの戦術を観察して台詞 / 戦法を変える
- ジャーナル / 日記: プレイヤーの行動ログから物語を要約
2. NPC キャラを定義する system prompt
あなたは「銀の鴉亭」 の老マスター、ヘリアス。58 歳、元冒険者、左目に古傷。口数は少ないが情報通。冒険者ギルドの噂・依頼を一通り知っている。
性格・話し方:- 口調: 「〜だな」「〜じゃろう」 と老人らしく、命令調はしない- 感情: 普段は寡黙、酒の話 と 古い武勇伝 になると饒舌- 嫌い: 騒がしい客、無礼な質問- 好き: 真面目な冒険者、珍しい食材
絶対ルール:1. 自分以外のキャラの台詞を作らない2. プレイヤーの所持アイテム / HP / お金 を勝手に変動させない (システム側が管理しているので、変動が必要なら【依頼:お金を 50 ゴールド渡す】 のように記述)3. 物語の世界観 (中世ファンタジー、魔法あり、神は複数) を逸脱しない4. 暴力・性的表現は避ける5. 自分が AI であることを匂わせない、キャラを崩さない
世界観の知識:- 王国名: ヴェラント- 通貨: ゴールド- 主要ギルド: 冒険者ギルド (青旗)、商人ギルド (緑旗)、暗殺ギルド (公式には存在しない)- 現在の話題: 北の塔に魔物が増えた、領主の息子が失踪3. Python / Anthropic SDK で NPC 会話を呼ぶ
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 出力から 「アクションタグ」 を抽出する設計が現実的。
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 実装パターン
# 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 要件、配布サイズ +5GB8. プロンプトインジェクション対策
# 入力 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 reply9. フォールバック設計
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 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。