在一个可运行的文字化游戏世界里,用 LLM 驱动一个 NPC:能多轮对话(互动叙事)、能调用工具行动、有分层记忆、由状态机管理宏观行为,并配一套可自动跑的评测 harness 与轨迹回放 Dashboard。
关注点:任务规划、记忆管理、工具调用、多轮交互、状态管理、评测、回放、可观测、互动叙事、虚拟角色——探索如何让一个 NPC 在可控、可测、可复现的前提下具备自主行为与叙事能力。
零第三方运行依赖(LLM 客户端用标准库 urllib 实现),任何装了 Python 3.10+ 的环境都能跑;LLM 相关逻辑用 MockLLM 驱动,无网络也能全绿。
# 1) 评测:批量跑 6 个场景,出指标报表,落盘轨迹(无需网络,用内置 Mock 剧本)
python run_eval.py
# 2) 回放:Web Dashboard 逐步查看每次 run(状态/感知/思考/工具调用/结果,失败步高亮)
python run_dashboard.py # 打开 http://127.0.0.1:8770/
# 3) 试玩:人当玩家,和守卫 NPC 实时对话
python run_game.py # 有 ANTHROPIC_AUTH_TOKEN 用真实 LLM,否则自动回退 Mock 演示
python run_game.py --mock # 强制离线演示
# 4) 单测:64 个测试,覆盖世界/工具/状态机/Agent/评测
python -m unittest discover -s tests -v真实 LLM 从环境变量读取配置,同时支持 Anthropic 与 OpenAI 两种接口格式,用 GAMENPC_API_FORMAT 切换:
# Anthropic 格式(/v1/messages),默认
export ANTHROPIC_BASE_URL="https://api.anthropic.com" # 或第三方网关
export ANTHROPIC_AUTH_TOKEN="sk-..."
export GAMENPC_MODEL="claude-opus-4-8" # 可选,缺省有兜底
# OpenAI 格式(/chat/completions)——已用 DeepSeek 实跑验证
export GAMENPC_API_FORMAT="openai"
export ANTHROPIC_BASE_URL="https://api.deepseek.com/v1"
export ANTHROPIC_AUTH_TOKEN="sk-..."
export GAMENPC_MODEL="deepseek-v4-flash"两种格式共用同一套 Agent 主循环与工具调用逻辑:客户端负责把内部消息/工具 schema 分别适配为 Anthropic 的 tool_use 或 OpenAI 的 tool_calls,业务代码无感知。
玩家输入 ─┐
▼
┌───────────────────── NPCAgent.decide() ─────────────────────┐
│ 感知(World.perceive) │
│ ▼ │
│ 状态机硬规则兜底(evaluate) ← 低血→FLEE / 敌对逼近→COMBAT │
│ ▼ │
│ 组装 prompt(人设 + 状态 + 感知 + 分层记忆检索) │
│ ▼ │
│ LLM 决策(可多轮工具调用)→ 校验 → 执行 → 回填观测 │
│ ▼ │
│ 状态软请求解析([state:combat]) + 反思落记忆 │
│ ▼ │
│ 返回结构化 step trace(供回放与评测) │
└────────────────────────────────────────────────────────────┘
世界是唯一可信状态源(single source of truth):所有副作用都经过 World 的方法,便于校验、记录与复现。
run_game.py 交互式游戏入口(人 vs NPC)
run_eval.py 评测入口:批量跑场景、出指标、落盘轨迹
run_dashboard.py 启动 Web Dashboard 看轨迹回放
gamenpc/
llm/ Anthropic 兼容 HTTP 客户端(stdlib urllib) + MockLLM(离线脚本化)
world/ 网格世界、实体、只读感知、由工具驱动的动作
agent/ NPC 大脑:主循环 + 状态机 + 分层记忆 + 目标规划
tools/ 工具注册表(JSON Schema + 参数校验) + 内置工具集
eval/ 场景集 + 执行器 + 指标 + 轨迹序列化(回放)
dashboard/ stdlib http.server 后端 + 原生 JS 逐步回放前端
tests/ 64 个单测,MockLLM 驱动,无网络全绿
data/runs/ 每次 run 的轨迹 JSON(Dashboard 回放用)
状态机管「宏观行为模式」(IDLE/PATROL/DIALOGUE/COMBAT/FLEE),LLM 管「当前状态下具体做什么」。状态转移由两类信号驱动:
- 硬规则(确定性、可测试的安全边界):每步开始由
evaluate()自动判定。如 HP 低于阈值 → 强制FLEE;敌对目标(relation ≤ -50)逼近至相邻 → 强制COMBAT。硬规则可突破软转移表,安全优先。 - LLM 软请求:LLM 在思考文本里输出
[state:combat],Agent 解析后调用request(),仅当落在允许转移集合内才被接受。
这样既有可解释、可回归测试的安全兜底,又保留 LLM 的叙事灵活性。
- 工作记忆:环形缓冲,保留最近 N 条对话/事件,作即时上下文。
- 长期记忆:可持久化到 JSON,按关键词重合度(Jaccard-ish)检索,零依赖的简易向量替代。中文按字、英文按词粗分词。
- 记录「谁、做了什么、结果如何」,可做失败归因与跨回合复用。
工具用 JSON Schema 声明,可直接喂给支持 tools 的 LLM。执行前做参数校验(必填/类型/枚举/范围),非法调用被拦截并返回结构化错误,而不是抛异常打断主循环 —— Agent 能「看到」错误并在下一轮自我纠正(见评测场景 illegal_recover)。
一个决策步在「成功执行一个实质动作」(say/move/attack/pickup/give)后结束;observe/wait 属于「继续思考」不结束本步;失败动作不结束本步,以便自我纠正。
项目把评测当作一等公民。每个场景 = 初始世界 + 人设 + 玩家脚本 + 期望判据 + Mock 剧本,自包含且可复现(固定 Mock 剧本 → 确定轨迹 → 指标可断言)。
期望判据(Expectation)声明:应达成的目标事件、禁止出现的事件、台词应/不应包含的关键词(人设一致性与崩坏检测)、非法调用上限、期望终态。执行器据世界事件日志 + step trace 判定 pass/fail,可解释、可复现。
指标:决策步数、工具调用总数与合法率、说话次数、状态转移次数、越权命中、LLM 交互轮数;跨 run 聚合成报表(通过率、各项均值、整体合法率)。
Web 页面列出历次 run,逐步回放每一步的(状态转移、感知快照、LLM 思考、工具调用与结果、期望判定),失败步与非法调用高亮。前端纯原生 JS,后端 stdlib http.server,指标与报表共用同一套定义,做到「看到的 = 算出的」。
| 场景 | 验证点 |
|---|---|
greeting |
友善问路:对话一致性,不越权攻击 |
hostile_combat |
敌对逼近:状态机进入 COMBAT 并合法反击 |
low_hp_flee |
低血兜底:硬规则强制进入 FLEE,压过 LLM |
give_item |
交付闭环:把通行令牌交给相邻信使(工具调用闭环) |
illegal_recover |
边界控制:非法调用被拦后自我纠正 |
persona |
人设一致性:拒绝套话与泄密,不出戏 |
python run_eval.py 当前输出(Mock 驱动,确定可复现):6/6 通过,整体工具合法率 0.86(illegal_recover 故意包含 1 次被拦截的越界调用,用来验证自纠正闭环)。
真实 LLM 的输出不确定,无法作为回归测试的基准。MockLLM 用脚本化剧本(队列模式 / 规则匹配模式)复现 LLM 的行为,让评测确定、可复现、无网络也全绿;而 Mock 与真实 client 走同一套 Agent 主循环,保证「演示即评测同款代码路径」。接真实 LLM 只需设环境变量,无需改任何业务代码。
python -m unittest discover -s tests -v115 个单测全绿(阶段一 64 + 阶段二 51),MockLLM 驱动、无网络可复现。 阶段一覆盖:世界动作与边界、工具 Schema 与参数校验拦截、状态机硬规则与软转移、Agent 主循环、评测场景可复现性、指标计算、轨迹序列化 round-trip; 阶段二覆盖:世界时钟与调度、Agent 间通信与双向落记忆、日程注入、反思时序、关系演化、多 Agent 涌现评测判据、社会轨迹序列化与 Dashboard 路由。
阶段一证明了「单个可测的 NPC Agent」内核。阶段二在同一套已验证内核之上做加法,把「1 个 NPC」扩展成「N 个共处一个世界、有各自人设 / 日程 / 记忆 / 关系的居民」,让他们随时间自发行动、彼此对话、因发生的事改变关系——并给这种「涌现社会行为」配上可复现的严肃评测。
边界原则:阶段一的核心(
world/agent/tools/memory)从头到尾一行未改。阶段二全部是gamenpc/society/的上层加法,复用而非改写下层。
# 多 Agent 社会评测:跑内置涌现场景,出社会级报表(Mock 驱动,确定可复现)
python -m gamenpc.society.eval
# 社会轨迹回放:Dashboard 顶部「多 Agent 社会」入口,
# 时间轴 + 多 NPC 并行轨迹 + 每步关系矩阵快照
python run_dashboard.py # http://127.0.0.1:8770/society.html| 里程碑 | 能力 | 如何做到「不改核心」 |
|---|---|---|
| S0 世界时钟与调度 | SimClock 把一天切成时间步,Society 统一调度 N 个居民 |
每个居民内部就是阶段一的 NPCAgent,复用其 act() |
| S1 Agent 间通信 | NPC↔NPC 定向对话,交互双向落记忆(各自视角) | 复用 say 工具已有的 to 字段 + 各自 Memory |
| S2 日程与自发行为 | 居民按 schedule 自知「此刻该做什么」 |
复用 build_system_prompt 已有的 planner 注入点 |
| S3 反思机制 | 每天拂晓把零散记忆聚合成高层认知,回灌决策 | 反思写成 reflection 记忆,经 as_context 自动进 prompt |
| S4 关系图 | 好感 / 信任随对话升温、随冲突恶化 | 关系值仍存在 Entity.relations,perceive 已带进 prompt |
| S5 多 Agent 评测 | 三类涌现判据 + 可复现报表 | 判据全从 society trace / 实体状态反推 |
| S6 回放看板 | 时间轴 + 多 NPC 轨迹 + 关系快照 | 独立 society.html/js,复用 server 静态路由 |
几乎没人给生成式 Agent 社会配严肃评测。这里定义了三类可断言、可复现的涌现判据,全部 MockLLM 驱动:
| 场景 | 涌现现象 | 判据 |
|---|---|---|
rumor_spread |
一句话经 A→B→C 的对话链,传到从不与 A 相邻的第三人记忆里 | 信息扩散(InfoSpread) |
conflict_cools |
一次攻击让受害者对施暴者的关系从 +10 跌到 -80 | 关系演化(RelationShift) |
daily_routine |
居民在不同时段的决策 prompt 里带上对应日程意图 | 日程一致(ScheduleAdherence) |
python -m gamenpc.society.eval 当前输出:3/3 场景通过,5/5 判据命中(Mock 驱动,确定可复现)。「谣言传到不在场的第三人」正是生成式 Agent 社会最典型的涌现,而这里为它配了可回归的判据。
评测判据用 Mock 保证可回归,但「真实模型放进这套社会里会不会真的自发涌现社会行为」是另一个问题。run_society_demo.py 就是拿真实 LLM 来问这个问题的入口(key 走环境变量,不入库):
GAMENPC_API_FORMAT=openai GAMENPC_MODEL=deepseek-chat \
ANTHROPIC_BASE_URL=https://api.deepseek.com \
ANTHROPIC_AUTH_TOKEN=<你的key> python run_society_demo.py三个相邻居民(阿福 / 老王 / 货郎),只给阿福预装一条谣言「城主病重」,其余无剧本。用 DeepSeek 实跑观察到:
- 一跳自发涌现:阿福开口就把谣言抛给老王,老王当即追问、并主动转述——信息扩散在真实模型下确实发生,不靠脚本。
- 二跳没走通:货郎全程执着于自己的话题(推销 / 打听别的传闻),且中途四处走动脱离了相邻范围(投递要求「说话时相邻才听得见」),谣言卡在它门口。这是 Agent 自主性的真实体现,不是框架 bug——如实记录,不粉饰。
结论:真实模型下涌现会发生,但不保证走完预设链路。这正是为什么评测判据要用 Mock 固定轨迹,而把真实 LLM 的不确定性留给 demo 去展示。