游戏Agent开发:让NPC会记忆、能规划、可自主行动

游戏Agent开发:让NPC会记忆、能规划、可自主行动

很多开发者第一次给NPC接上大模型时,都会经历同一种失望:NPC能说会道,但像个失忆的演员——上一秒答应帮你找药草,下一秒就问你叫什么名字;你让它去巡逻,它站在原地念了三分钟独白。

问题的根源不在模型,而在架构。对话机器人只需要「输入→输出」,而游戏Agent需要一条完整闭环:感知世界、写入记忆、基于记忆做规划、把规划翻译成可执行动作、再把动作结果写回记忆。少了任何一环,NPC都会退化成话痨或者木头人。

本文用一套可以直接跑起来的最小实现,带你从零搭出一个会记忆、能规划、可自主行动的NPC Agent。代码是Python,框架无关,你可以接到Unity、Godot、Unreal,也可以放在纯服务端模拟器里跑。

一、先分清:对话NPC和Agent NPC的差别

对话NPC:状态约等于无。每次请求独立,一致性全靠提示词里的人设撑着。

Agent NPC:状态 = 记忆 + 目标 + 计划 + 世界模型。每个决策周期都在读状态、改状态。

判断架构是否健康,有一个很简单的测试:把LLM整个拔掉,你的NPC还能不能走路、能不能攻击?如果还能,说明行动层是独立的,LLM只是决策层;如果LLM一挂NPC就僵在原地,那你其实是在跟API可用性对赌。

一个完整的Agent包含四层闭环:

  1. 感知 Perception:从世界事件流里裁剪出「这个NPC能看到、能听到」的部分。
  2. 记忆 Memory:短期窗口保证接得住话,长期记忆保证长期一致性。
  3. 规划 Planning:把目标拆成3~4步可执行动作。
  4. 行动 Action:白名单校验后真正修改游戏状态。

二、环境准备

你需要:Python 3.11+、一个LLM接口、一个向量化接口、一个能读写世界状态的 world 对象。

第一步是把两个最容易耦合的东西抽象出来:

python def llm(prompt: str) -> str: raise NotImplementedError

def embed(text: str) -> list[float]: raise NotImplementedError

这两个函数是整个架构的关键。它们让Agent逻辑和具体模型彻底解耦,以后换模型、换供应商、本地化部署,你都不需要改一行业务代码。

三、步骤1:定义核心数据结构

先把三种东西定义清楚:观察、记忆、目标。

python from dataclasses import dataclass, field from typing import Any

@dataclass class Observation: tick: int source: str # 谁触发的,如 'player' / 'npc_102' / 'world' kind: str # 事件类型,如 'speak' / 'move' / 'attack' payload: dict[str, Any] importance: float = 0.5

@dataclass class MemoryItem: text: str created_tick: int last_access_tick: int importance: float kind: str = 'episodic' # episodic / semantic / reflection embedding: list[float] = field(default_factory=list)

@dataclass class Goal: description: str priority: int status: str = 'pending' # pending / active / done / failed steps: list[str] = field(default_factory=list)

字段说明:importance 是记忆的重要度,检索时作为权重之一;last_access_tick 用来计算新近度——被反复想起的记忆会更容易再次被想起,这和人类记忆的复述效应非常像。

四、步骤2:写感知层

感知层只做一件事:把原始事件流裁剪成这个NPC「看得见、听得见」的部分,并转成结构化的 Observation。

python def perceive(raw_events: list[dict], npc_state: dict) -> list[Observation]: out = [] for ev in raw_events: if not _in_perception_range(ev, npc_state): continue out.append(Observation( tick=ev['tick'], source=ev['source'], kind=ev['kind'], payload=ev['payload'], importance=_score_importance(ev, npc_state), )) return out

def _in_perception_range(ev: dict, npc_state: dict) -> bool: # 视野半径与听觉半径,二者取或 dist = _distance(ev['payload'].get('pos'), npc_state['pos']) return dist <= npc_state['sight'] or dist <= npc_state['hearing']

重要度打分可以先用规则版本,成本几乎为零:

python def _score_importance(ev: dict, npc_state: dict) -> float: base = {'attack': 0.9, 'speak': 0.5, 'move': 0.2, 'item_given': 0.8} s = base.get(ev['kind'], 0.3) if ev['payload'].get('target') == npc_state['id']: s += 0.3 # 直接指向我,加分 return min(s, 1.0)

注意:感知层一定要做裁剪。把整个服务器的事件无差别塞给每个NPC,你的token账单会在两周内教你做人。

五、步骤3:短期记忆——滑动窗口

短期记忆用滑动窗口就够了。它的职责不是长期一致性,而是让NPC「接得住话」——玩家上一句说了什么,它必须记得。

python from collections import deque

class ShortTermMemory: def init(self, maxlen: int = 20): self.buf = deque(maxlen=maxlen)

def push(self, obs: Observation):
    self.buf.append(obs)

def render(self) -> str:
    return '\n'.join(f'[{o.tick}] {o.source}: {o.payload}' for o in self.buf)

六、步骤4:长期记忆——三因子检索打分

长期记忆是Agent的灵魂。这里用经典的「相似度 + 新近度 + 重要度」三因子打分:

score = α·cosine(query, memory) + β·decay^(now - last_access) + γ·importance

python class MemoryStream: def init(self, decay: float = 0.995, top_k: int = 8): self.items: list[MemoryItem] = [] self.decay = decay self.top_k = top_k

def add(self, item: MemoryItem):
    self.items.append(item)

def _recency(self, item: MemoryItem, now: int) -> float:
    return self.decay ** (now - item.last_access_tick)

def retrieve(self, query_vec, now: int,
             alpha: float = 1.0, beta: float = 0.6, gamma: float = 0.8):
    scored = []
    for it in self.items:
        sim = _cosine(query_vec, it.embedding)
        score = alpha * sim + beta * self._recency(it, now) + gamma * it.importance
        scored.append((score, it))
    scored.sort(key=lambda x: x[0], reverse=True)
    picked = [it for _, it in scored[: self.top_k]]
    for it in picked:
        it.last_access_tick = now      # 被想起,新近度刷新
    return picked

调参建议:α=1.0、β=0.6、γ=0.8 是个不错的起点。decay 取 0.99~0.995,意味着大约100~200个决策周期后,记忆权重衰减一半。

写入时机同样重要:不是所有事件都值得进长期记忆。建议只有 importance >= 0.5 的事件才写入,否则记忆库很快会被「我向前走了一步」这种噪声淹没。

七、步骤5:反思——让NPC形成观点

反思的作用是压缩。玩家和NPC互动了两百轮,逐条塞进提示词是不可能的;但三条高层观点可以。

python REFLECT_PROMPT = ''' 你是NPC「{name}」。以下是你最近记住的事情: {facts}

请输出3条你由此形成的高层认知(观点、计划倾向、对某人的态度)。 每条一行,不要编号,不要解释。 '''

def maybe_reflect(mem: MemoryStream, name: str, now: int, threshold: float = 30.0): recent = [m for m in mem.items if m.kind == 'episodic' and now - m.created_tick <= 200] if sum(m.importance for m in recent) < threshold: return None facts = '\n'.join(f'- {m.text}' for m in recent[-30:]) text = llm(REFLECT_PROMPT.format(name=name, facts=facts)) for line in [l.strip() for l in text.splitlines() if l.strip()]: mem.add(MemoryItem( text=line, created_tick=now, last_access_tick=now, importance=0.8, kind='reflection', ))

反思还有一个隐性收益:它让NPC的行为出现连续性。因为昨天被玩家救过一次,今天它就更愿意把情报交出去——这种因果感,是玩家觉得NPC「活着」的核心来源。

八、步骤6:规划器——把目标拆成动作

规划器负责把「目标」翻译成「动作序列」。

python PLAN_PROMPT = ''' 你在扮演游戏NPC「{name}」。 当前状态:{state} 当前目标:{goal} 相关记忆: {memories} 可用动作:{actions}

请输出一个不超过4步的行动计划,每行一步,格式为: 动作名|参数=值|理由 '''

def make_plan(agent) -> list[str]: mem_text = '\n'.join(f'- {m.text}' for m in agent.retrieve_relevant()) raw = llm(PLAN_PROMPT.format( name=agent.name, state=agent.state_text(), goal=agent.current_goal().description, memories=mem_text, actions=','.join(agent.available_actions()), )) return _parse_plan(raw)

def _parse_plan(raw: str) -> list[str]: steps = [] for line in raw.splitlines(): line = line.strip() if '|' not in line: continue steps.append(line) return steps[:4]

关键设计:每次只规划3~4步,而不是一次性排满一整天。因为游戏世界随时会变——玩家会打断,目标会失效。短计划配合高频重规划,比长计划配低容错要稳得多。

重规划触发条件建议如下:

python def _should_replan(self, now: int) -> bool: if not self.plan: return True if now >= self.next_plan_tick: return True if self.consecutive_failures >= 2: return True if self.pending_urgent_event: # importance >= 0.8 的突发事件 return True return False

九、步骤7:行动层与白名单校验

行动层是唯一允许直接改游戏状态的地方。所有LLM输出的动作名必须在注册表里,否则直接拒绝执行。这既是安全边界,也是防幻觉的关键。

python class ActionRegistry: def init(self): self._handlers = {}

def register(self, name: str):
    def deco(fn):
        self._handlers[name] = fn
        return fn
    return deco

def execute(self, step: str, ctx: dict) -> dict:
    name, _, rest = step.partition('|')
    params = {}
    for kv in rest.split('|'):
        if '=' in kv:
            k, _, v = kv.partition('=')
            params[k.strip()] = v.strip()
    fn = self._handlers.get(name.strip())
    if fn is None:
        return {'ok': False, 'error': f'未知动作: {name}'}
    return fn(params, ctx)

actions = ActionRegistry()

@actions.register('move_to') def move_to(params: dict, ctx: dict) -> dict: ok = ctx['world'].move(ctx['npc_id'], params.get('target')) return {'ok': bool(ok)}

@actions.register('say') def say(params: dict, ctx: dict) -> dict: ctx['world'].broadcast(ctx['npc_id'], params.get('text', '')) return {'ok': True}

有个容易忽略的点:LLM输出的参数永远是字符串,必须做校验。move_to|target=仓库 里的「仓库」到底存不存在?校验失败就返回错误、触发重规划,而不是让世界状态崩掉。

十、步骤8:接进游戏主循环

把所有零件拼起来:

python class NPCAgent: def init(self, name, npc_id, world, replan_interval: int = 5): self.name = name self.npc_id = npc_id self.world = world self.memory = MemoryStream() self.short = ShortTermMemory() self.actions = actions self.plan: list[str] = [] self.replan_interval = replan_interval self.next_plan_tick = 0 self.consecutive_failures = 0

def tick(self, now: int, raw_events: list[dict]):
    state = self.world.snapshot(self.npc_id)
    for obs in perceive(raw_events, state):
        self.short.push(obs)
        if obs.importance >= 0.5:
            self.memory.add(self._to_memory(obs, now))

    maybe_reflect(self.memory, self.name, now)

    if self._should_replan(now):
        self.plan = make_plan(self)
        self.next_plan_tick = now + self.replan_interval

    if self.plan:
        step = self.plan.pop(0)
        result = self.actions.execute(
            step, {'world': self.world, 'npc_id': self.npc_id})
        if result.get('ok'):
            self.consecutive_failures = 0
        else:
            self.consecutive_failures += 1
        self.memory.add(MemoryItem(
            text=f'我执行了 {step},结果 {result}',
            created_tick=now, last_access_tick=now,
            importance=0.4,
        ))

主循环里有一个必须注意的点:Agent的决策周期不应该和游戏帧率绑定。NPC决策是低频的(1~5秒一次),渲染是高频的(60fps)。把Agent放进独立的决策线程或定时任务,通过消息队列和世界交互,否则你的服务器会被推理请求压垮。

十一、步骤9:成本、性能与稳定性

几个实用手段,按投入产出比排序:

  1. 分级模型:感知打分用规则或小模型,规划用中等模型,只有反思和重大剧情节点才动用强模型。
  2. 缓存embedding:同一句文本绝不重复向量化。
  3. 全局预算:每秒最多允许K次LLM调用,超预算的NPC本轮降级到行为树。
  4. 批量规划:同一区域的NPC合并成一次调用,让模型一次性输出多个NPC的计划。
  5. 降级路径:任何超时或失败,都回退到有限状态机,保证NPC不会站桩。

python import time

class LLMBudget: def init(self, per_second: int = 3): self.per_second = per_second self.window_start = time.time() self.count = 0

def try_acquire(self) -> bool:
    now = time.time()
    if now - self.window_start >= 1.0:
        self.window_start = now
        self.count = 0
    if self.count >= self.per_second:
        return False
    self.count += 1
    return True

十二、步骤10:可观测性

Agent最难的部分不是写出来,而是调。一定要把每一次决策落盘:

python import json

def log_decision(now: int, npc_id: str, stage: str, payload: dict): record = {'tick': now, 'npc': npc_id, 'stage': stage, **payload} with open(f'logs/agent_{npc_id}.jsonl', 'a', encoding='utf-8') as f: f.write(json.dumps(record, ensure_ascii=False) + '\n')

stage 取 perceive / retrieve / plan / act / reflect。出问题时,你可以完整回放某个NPC那30秒到底「想」了什么。

另外强烈建议做一个调试面板,实时显示:当前目标、计划队列、最近检索到的5条记忆、本周期消耗的token。你会发现,在面板上看到的问题,比读日志快十倍。

十三、步骤11:测试与调参

参数起点参考:replan_interval=5秒,短期记忆20条,长期记忆top_k=8,反思阈值累计importance=30。

十四、常见坑与排查清单

  1. 记忆污染:把模型自己的猜测当成事实写进记忆。解决:写入时标注来源,只有 observed 和 told_by 才可作为事实。
  2. 计划僵化:NPC被打断了还在执行「去河边打水」。解决:每一步执行前检查前置条件。
  3. 提示词膨胀:记忆无脑全塞。解决:检索top_k + 反思压缩。
  4. 幻觉动作:模型编出不存在的动作名。解决:白名单校验,直接拒绝。
  5. 没有降级:LLM一挂全服NPC站桩。解决:行为树兜底。
  6. 过度拟人:让所有NPC都长篇大论。解决:按重要性给不同NPC配置不同的表达预算。

结语

让NPC会记忆、能规划、可自主行动,本质上是把「一次性对话」升级成「持续运行的状态机 + 记忆库 + 决策器」。模型能力决定上限,架构决定下限。先把四层闭环跑通,哪怕每一层都很粗糙,你也已经比90%「接个大模型就当NPC」的项目走得更远。

下一步可以做的:给记忆加时间衰减曲线、给规划加多NPC协商、给行动层加失败重试与在线学习。祝你做出一个玩家会记住的NPC。