游戏Agent开发:让NPC拥有记忆、规划与自主决策
在2026年的游戏行业里,“NPC 会不会记住我”已经从一个营销噱头变成了可落地的工程需求。玩家越来越难被固定台词打动:他们希望守卫记得自己昨晚翻墙被抓,酒馆老板记得上周欠下的金币,队友会在长途跋涉中主动提出补给计划。要做到这一点,仅靠状态机(FSM)和行为树(Behavior Tree)远远不够——它们没有记忆,也没有真正的目标分解能力。
本文会带你从零搭一个可运行的 NPC Agent 框架,包含四层能力:感知(Perception)、记忆(Memory)、规划(Planning)与自主决策(Decision)。技术栈使用 Python 3.12 + 任意 LLM API + Chroma 向量库,全部代码可以直接复制运行,再按需移植到 Unity(C#)或 Unreal(C++)侧。
一、Agent 的核心循环
一个 NPC Agent 的每一“帧”(可以是每秒一次,也可以由玩家交互触发)都在跑同一个循环:
- 感知:把游戏世界的状态压缩成结构化数据。
- 检索记忆:根据当前情境取出相关的过往经历。
- 规划:让 LLM 或规则系统决定“现在最该做什么”。
- 行动:把决策翻译成游戏内可执行的动作,并做合法性校验。
- 反思:把结果写回记忆,必要时沉淀成更高层的经验。
注意第 5 步是很多人会漏掉的:没有写回,Agent 就永远只有工作记忆,不会成长。接下来我们按顺序实现每一步。
步骤 1:准备环境与依赖
先用虚拟环境隔离依赖,注意向量库和 LLM SDK 的版本。
bash python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install "openai>=1.60" "chromadb>=0.6" "pydantic>=2.9" "numpy" "tenacity"
如果你打算把 Agent 跑在服务器端(推荐),再补一个异步 Web 框架:
bash pip install fastapi uvicorn
关键的架构决策放在最前面:不要让 LLM 直接驱动游戏逻辑。LLM 只负责“想”,游戏引擎只负责“做”,中间用一层严格的动作 Schema 隔开。
步骤 2:定义 NPC 的状态与感知层
先把 NPC 的“自我”和“看到的世界”定义清楚。用 Pydantic 可以让序列化、校验、调试三件事一次搞定。
python from pydantic import BaseModel, Field from typing import Literal
class NPCState(BaseModel): npc_id: str name: str role: str # 例如 "铁匠" hp: int = 100 stamina: int = 100 position: tuple[float, float, float] = (0.0, 0.0, 0.0) inventory: dict[str, int] = Field(default_factory=dict) mood: Literal["平静", "警觉", "愤怒", "愉快"] = "平静"
class Perception(BaseModel): """某一时刻 NPC 感知到的信息,作为记忆与规划的输入""" npc: NPCState nearby_entities: list[str] = [] # "玩家A(手持长剑, 距离2米)" recent_events: list[str] = [] # "10秒前:村口传来爆炸声" game_time: str = "06:00" weather: str = "晴"
感知层最重要的工作是压缩。别把整个场景图丢给模型,几万个三角形对决策毫无帮助。推荐做法是写一个 perceive() 函数,只保留:视野内实体、最近 N 条事件、自身状态,总长度控制在 300 字以内。
python def perceive(world, npc_id: str) -> Perception: npc = world.get_npc(npc_id) return Perception( npc=npc.state, nearby_entities=world.query_entities(npc.state.position, radius=15.0, limit=8), recent_events=world.event_log.tail(5), game_time=world.clock.strftime("%H:%M"), weather=world.weather, )
步骤 3:构建三层记忆系统
记忆是整个 Agent 的灵魂。工程上把它拆成三层最实用:
- 短期记忆:最近若干轮对话与事件,直接放进 Prompt。
- 长期记忆:向量化的历史经历,用相似度检索。
- 结构化记忆:确定的事实,比如“玩家 A 欠我 50 金币”,用键值或图存储。
长期记忆的经典实现参考了 Generative Agents 的思路:每条记忆带重要性分数、时间戳和访问计数,检索时综合“相关性 + 重要性 + 新近度”。
python import time, math from dataclasses import dataclass, field
@dataclass class MemoryItem: content: str created_at: float = field(default_factory=time.time) importance: float = 5.0 # 1~10,由 LLM 或规则打分 last_access: float = field(default_factory=time.time) access_count: int = 0
python class MemoryStream: def init(self, embedder, collection): self.embedder = embedder self.collection = collection # Chroma collection
def add(self, item: MemoryItem):
self.collection.add(
ids=[f"m_{int(item.created_at*1000)}"],
embeddings=[self.embedder(item.content)],
documents=[item.content],
metadatas=[{
"importance": item.importance,
"created_at": item.created_at,
"last_access": item.last_access,
"access_count": item.access_count,
}],
)
def retrieve(self, query: str, k: int = 5) -> list[str]:
res = self.collection.query(
query_embeddings=[self.embedder(query)], n_results=20
)
now = time.time()
scored = []
for doc, meta, dist in zip(res["documents"][0], res["metadatas"][0], res["distances"][0]):
recency = math.exp(-(now - meta["created_at"]) / 86400) # 一天衰减
relevance = 1.0 - dist
score = 0.5*relevance + 0.3*meta["importance"]/10 + 0.2*recency
scored.append((score, doc))
scored.sort(reverse=True)
return [d for _, d in scored[:k]]
技巧:把“检索到的记忆”和“最近对话”拼进 Prompt 时,一定标注时间,例如 [三天前] 玩家A在酒馆赊账 50 金币。没有时间戳,模型很容易把旧事当成刚刚发生。
步骤 4:用 LLM 做目标规划
规划器负责把“我想干什么”翻译成“现在做什么”。推荐分层:
- 长期目标(由策划预置):比如“守住村子北门”。
- 当前目标(Agent 自己维护):比如“寻找铁矿石”。
- 即时动作(每轮决策):比如“和玩家搭话”。
用 ReAct 风格的循环,让模型先思考再输出动作:
python PLANNER_PROMPT = """你是一个游戏NPC的决策中枢。
【角色设定】 {persona}
【当前状态】 {state}
【相关记忆】 {memories}
【可用动作】 {actions}
请先在 中分析局势(不超过60字), 然后在 中输出一个 JSON:{{"name": "动作名", "args": {{...}}}} 只允许从可用动作中选择,参数必须符合其 Schema。 """
调用部分:
python import json from openai import OpenAI
client = OpenAI()
def plan(prompt: str, allowed: set[str]) -> dict: resp = client.chat.completions.create( model="gpt-4o-mini", # 或任意 2026 年可用的低成本模型 messages=[{"role": "user", "content": prompt}], temperature=0.6, response_format={"type": "json_object"}, ) data = json.loads(resp.choices[0].message.content) action = data.get("action", data) if action.get("name") not in allowed: return {"name": "idle", "args": {}} # 兜底 return action
关键点:allowed 白名单必须由代码生成,而不是让模型自由发挥。这一层防护能挡掉 90% 的“NPC 试图调用不存在的函数”类事故。
步骤 5:把决策变成游戏内动作
动作层要做三件事:校验参数、执行、返回结果文本。
python ACTIONS = { "move_to": {"args": {"target": "str"}, "desc": "移动到某地点"}, "talk_to": {"args": {"target": "str", "topic": "str"}, "desc": "与目标交谈"}, "attack": {"args": {"target": "str"}, "desc": "攻击目标"}, "give_item":{"args": {"target": "str", "item": "str", "count": "int"}, "desc": "给予物品"}, "idle": {"args": {}, "desc": "原地待命"}, }
def execute(action: dict, world, npc_id: str) -> str: name, args = action["name"], action.get("args", {}) spec = ACTIONS.get(name) if spec is None: return "动作不存在" missing = set(spec["args"]) - set(args) if missing: return f"参数缺失: {missing}" return world.apply(npc_id, name, args) # 由游戏侧实现
这里有个容易被忽视的细节:执行结果必须以自然语言返回给 Agent。比如 attack 返回“你挥剑砍中了野狼,它掉血 12 点,但它变得愤怒了”。这样下一轮规划才能“知道”刚才发生了什么。
步骤 6:反思与记忆巩固
每执行 N 轮,或者当重要事件发生时,触发一次反思:让模型总结最近的经历,产出 1~3 条高层洞察,写回长期记忆。
python REFLECT_PROMPT = """以下是NPC最近经历的事件: {events}
请总结 1~3 条对未来的行动有帮助的经验, 每条一句话,带上因果关系。以 JSON 数组返回。 """
def reflect(events: list[str], memory: MemoryStream, llm): out = llm(REFLECT_PROMPT.format(events="\n".join(events))) for insight in json.loads(out): memory.add(MemoryItem(content=insight, importance=8.0))
反思是“从经历到经验”的跃迁。没有它,NPC 会记得一百次被打劫,却永远学不会绕开那条巷子。
步骤 7:性能、成本与并发
这套架构在真实项目里最大的敌人是延迟和账单。几个实战经验:
- 分层模型:闲聊用本地 3B 小模型,复杂规划才调大模型。
- 异步化:所有 LLM 调用走
asyncio,NPC 的“思考”不阻塞主循环。 - 缓存:相同(状态指纹 + 记忆指纹)的决策结果缓存 30 秒。
- 预算控制:给每个 NPC 设每秒 Token 上限,超了就退化为行为树。
- 降级策略:LLM 超时立即回落到规则决策,玩家不应该看到 NPC 卡住。
python import asyncio from tenacity import retry, stop_after_attempt, wait_exponential
@retry(stop=stop_after_attempt(2), wait=wait_exponential(multiplier=0.3)) async def safe_plan(prompt: str, allowed: set[str], timeout: float = 2.0): try: return await asyncio.wait_for(plan_async(prompt, allowed), timeout) except asyncio.TimeoutError: return {"name": "idle", "args": {}}
步骤 8:调试与评测
Agent 的行为是概率性的,“看起来没问题”不等于没问题。建议至少建立四类工具:
- 决策回放日志:把每轮的 Prompt、检索到的记忆、输出动作、执行结果存成 JSONL。
- 离线评测集:手写 200 个情境,断言“该情境下必须出现某个动作”。
- 人格一致性打分:让另一个模型判断 NPC 的回复是否符合角色设定,1~5 分。
- 越权检测:统计动作白名单命中率,低于 99% 就说明 Prompt 或 Schema 有问题。
python def log_turn(path: str, turn: dict): with open(path, "a", encoding="utf-8") as f: f.write(json.dumps(turn, ensure_ascii=False) + "\n")
步骤 9:串起来——主循环
把所有模块接起来,整个 Agent 不到 100 行:
python class NPCAgent: def init(self, npc_id, persona, world, memory, llm): self.npc_id, self.persona = npc_id, persona self.world, self.memory, self.llm = world, memory, llm self.buffer: list[str] = [] self.turns = 0
async def step(self):
perc = perceive(self.world, self.npc_id)
query = f"{perc.npc.role} 在 {perc.weather} 的 {perc.game_time},周围有 {perc.nearby_entities}"
memories = self.memory.retrieve(query, k=5)
prompt = PLANNER_PROMPT.format(
persona=self.persona,
state=perc.model_dump_json(),
memories="\n".join(memories) or "(暂无)",
actions=json.dumps(ACTIONS, ensure_ascii=False),
)
action = await safe_plan(prompt, set(ACTIONS))
result = execute(action, self.world, self.npc_id)
self.buffer.append(f"我做了{action['name']},结果:{result}")
self.memory.add(MemoryItem(content=f"{action['name']} -> {result}"))
self.turns += 1
if self.turns % 8 == 0:
reflect(self.buffer[-8:], self.memory, self.llm)
return action, result
在游戏侧,你只需要在服务器 tick 里调用 agent.step(),或者用事件驱动:玩家靠近 NPC、NPC 受到伤害、时间推进到新节点时触发一次。
常见坑与避雷清单
- 记忆污染:把未经验证的模型输出直接写回记忆,会形成错误累积。写回前要过滤。
- 时间错乱:Prompt 里必须带游戏内时间,并且区分“游戏时间”与“真实时间”。
- 过度拟人:让每个 NPC 都用大模型不现实。按重要性分级,主角级 NPC 用 Agent,群众 NPC 用规则。
- 不可复现:固定 seed + 记录完整输入,否则线上 bug 无法复现。
- 忘记兜底:任何 LLM 调用都要有超时和默认动作,否则 NPC 会“死机”。
小结
让 NPC 拥有记忆、规划与自主决策,本质上不是“接一个 API”这么简单,而是要搭一条完整的流水线:感知负责压缩世界,记忆负责跨越时间,规划负责产生意图,动作层负责把意图约束在游戏规则内,反思负责让 Agent 随时间成长。
从最小可用版本开始:先做感知 + 短期记忆 + 白名单动作,跑通之后再加向量记忆和反思。你会发现,只要 NPC 能记住玩家上一次的行为并做出不一样的反应,沉浸感就已经发生了质变。2026 年之后,玩家评价一个开放世界的第一句话,很可能会是:“那个铁匠,居然还记得我。”