07 · 项目三:赛博小镇从 0 到 1
目标:跑通并理解
code/chapter15/Helloagents-AI-Town—— 一个像素风办公室小镇游戏:用 WASD 走动,按 E 和三位 AI NPC(张三/Python工程师、李四/产品经理、王五/UI设计师)对话。NPC 有性格、有记忆、有好感度:你聊过的内容它下次还记得,你说话客气或刻薄,它对你的态度会真实变化。技术要点:Godot 游戏引擎入门 + NPC 人设提示词 + MemoryManager 记忆系统(短期+长期)+ 好感度状态机 + 游戏与后端的 HTTP 通信。
预计 3~5 天。
0. 架构总览:游戏也只是一个"前端"
Godot游戏(GDScript) FastAPI后端(:8000)
┌────────────────────┐ ┌─────────────────────────┐
│ player.gd 玩家移动 │ POST /chat │ main.py API路由 │
│ npc.gd NPC交互 │ ─────────────────→ │ agents.py NPC管理器 │
│ dialogue_ui.gd 对话框│ ←───────────────── │ ├ SimpleAgent ×3 │
│ api_client.gd ★通信 │ JSON响应 │ ├ MemoryManager ×3 记忆 │
└────────────────────┘ GET /npcs/status │ └ RelationshipManager 好感度│
│ state_manager.py 自主行为 │
└─────────────────────────┘
和前两个项目结构完全同构:后端还是 FastAPI + SimpleAgent,只是"前端"从浏览器换成了游戏引擎。学完本项目你会彻底明白:任何能发 HTTP 请求的东西,都能当 Agent 的前端。
1. 里程碑一:后端跑起来(半天)
cd hello-agents/code/chapter15/Helloagents-AI-Town/backend
uv venv && source .venv/bin/activate
uv pip install -r requirements.txt
cp .env.example .env
.env 最小配置(Qdrant/Neo4j 等向量、图数据库配置先全部留着不动,记忆系统默认用本地 SQLite——一种"整个数据库就是一个文件"的轻量数据库,Python 自带支持、无需安装,先跑通再说):
LLM_MODEL_ID=Qwen/Qwen2.5-72B-Instruct
LLM_API_KEY=你的key
LLM_BASE_URL=https://api-inference.modelscope.cn/v1/
首次启动前的重要一步:记忆系统的语义检索依赖一个本地向量模型(
sentence-transformers/all-MiniLM-L6-v2,约 90MB),第一次运行会自动从 HuggingFace 下载。国内网络直连经常超时,先设置镜像再启动:
bash export HF_ENDPOINT=https://hf-mirror.com # Windows PowerShell: $env:HF_ENDPOINT="https://hf-mirror.com"建议把这行加进
~/.zshrc/~/.bashrc一劳永逸。下载卡住的排错见第 09 篇项目三部分。
启动:
python main.py
预期输出:
🎮 赛博小镇后端服务启动中...
✅ LLM初始化成功
✅ 张三(Python工程师) Agent创建成功 (记忆系统已启用)
💾 张三的记忆系统已初始化 (存储路径: .../memory_data/张三)
...
✅ 所有服务已启动!
Checkpoint 1: 打开 http://localhost:8000/docs,用 POST /chat 发 {"npc_name": "张三", "message": "你好,你是做什么的?"},收到符合人设的回复。再连发一句"我刚才问了你什么?",它能答上来——记忆生效了。
2. 里程碑二:读懂后端三大系统(1~2 天)
2.1 NPC 即 Agent:人设完全由提示词塑造
agents.py 顶部的 NPC_ROLES 字典定义了三个角色的全部人设(职位/性格/专长/说话风格/爱好/位置/当前活动),create_system_prompt() 把它们拼成系统提示词,核心段落:
你是Datawhale办公室的Python工程师张三。
【角色设定】性格: 技术宅,喜欢讨论算法和框架 …
【行为准则】
1. 保持角色一致性,用第一人称"我"回答
2. 回复简洁自然,控制在30-50字以内
…
【重要】
- 不要说"我是AI"或"我是语言模型"
- 要像真实的办公室同事一样自然对话
想加第四个 NPC?往
NPC_ROLES加一个条目就行,Agent、记忆、好感度全部自动创建——这就是数据驱动设计的好处。(游戏侧还要摆一个 NPC 节点,见里程碑四。)
2.2 chat():一次对话的六步流水线
NPCAgentManager.chat() 是全项目最核心的函数,六步背下来(下面是骨架示意,... 处省略了参数,不能直接运行,记结构就行):
def chat(self, npc_name, message, player_id="player"):
# ① 查好感度 → 生成"对话风格指令"
affinity = self.relationship_manager.get_affinity(npc_name, player_id)
affinity_context = f"你与玩家的关系: {level} (好感度: {affinity}/100)\n【对话风格】{modifier}"
# ② 按当前消息检索相关记忆(工作记忆+情景记忆,最多5条,重要性>=0.3)
relevant_memories = memory_manager.retrieve_memories(query=message, ...)
# ③ 组装增强消息: 好感度上下文 + 记忆上下文 + 当前对话
enhanced_message = affinity_context + memory_context + f"玩家: {message}"
# ④ 调用Agent生成回复
response = agent.run(enhanced_message)
# ⑤ 用LLM分析这轮对话的情感 → 更新好感度
self.relationship_manager.analyze_and_update_affinity(...)
# ⑥ 把玩家消息和NPC回复都存进记忆
memory_manager.add_memory(content=f"玩家说: {message}", memory_type="working", importance=0.5, ...)
memory_manager.add_memory(content=f"我说: {response}", memory_type="working", importance=0.6, ...)
注意送进模型的不是裸消息,而是三段式的"增强消息":
【当前关系】你与玩家的关系: 亲密 (好感度: 65/100)
【对话风格】热情友好,主动分享,像朋友一样交流
【之前的对话记忆】
[14:32] 玩家说: 我喜欢喝咖啡
[14:33] 我说: 我也是!休息区的手冲不错
【当前对话】
玩家: 推荐个饮料?
NPC 的"人味"= 人设提示词 + 关系状态 + 相关记忆,三者在每轮对话前动态拼装。 这是整个项目最值得带走的一张图。
2.3 记忆系统:短期与长期
每个 NPC 一个 MemoryManager(配置详见第 03 篇 3.5 节),两层结构:
- 工作记忆(working):最近 10 条对话、2000 token 上限,像人的"刚聊过的印象"。满了之后,重要的会被整合进情景记忆,不重要的被挤掉。
- 情景记忆(episodic):最多 100 条,持久化在
memory_data/张三/memory.db(SQLite),带重要性分数和时间衰减(decay_factor=0.95)——越久远的事越难被想起来,除非它足够重要。
对话时用 retrieve_memories(query=当前消息) 做语义检索:聊"咖啡"就召回咖啡相关的记忆,而不是机械地取最近 5 条。
调试接口(边玩边看记忆怎么长):
GET /npcs/张三/memories # 查看记忆
DELETE /npcs/张三/memories # 清空记忆(重新开始)
2.4 好感度系统:数值驱动态度
relationship_manager.py 维护每个 NPC 对玩家的好感度(0~100,初始 50),两个方向的作用:
读:好感度 → 档位 → 说话风格指令(拼进增强消息):
| 好感度 | 档位 | 风格指令(示意) |
|---|---|---|
| 80+ | 挚友 | 亲昵随意,开玩笑,主动关心 |
| 60~80 | 亲密 | 热情友好,主动分享 |
| 40~60 | 熟悉 | 礼貌友善,正常交流,保持专业 |
| 20~40 | 冷淡 | 简短回应,保持距离 |
| <20 | 疏远 | 敷衍,不愿多说 |
写:每轮对话后,analyze_and_update_affinity() 让 LLM 给这轮对话的情感倾向打分(friendly/neutral/rude…),换算成好感度增减。用 LLM 当"情感裁判"——这个技巧(LLM-as-a-Judge)在评估、审核场景到处都在用。
调试接口:GET /affinities 看全部好感度;PUT /npcs/张三/affinity?affinity=90 直接改数值,立刻体验"挚友模式"的说话方式。
Checkpoint 2: 用 /docs 连续对张三说三句刻薄话,观察 GET /npcs/张三/affinity 数值下降、回复变冷淡;清空记忆后再问"我们聊过什么",它应该不记得了。
2.5 state_manager.py:NPC 的"自主行为"
游戏里 NPC 头顶会冒泡自言自语("这个bug真难修…")。实现:state_manager 每 30 秒(NPC_UPDATE_INTERVAL)调用一次 batch_generator 批量为三个 NPC 生成当前状态台词,游戏端轮询 GET /npcs/status 拿去显示。批量生成(一次 LLM 调用生成三个人的台词)是为了省 token 和延迟——值得注意的小优化。
3. 里程碑三:游戏跑起来(半天)
- 安装 Godot 4.2+(见第 02 篇第 8 节)
- Godot → 导入 → 选择
Helloagents-AI-Town/helloagents-ai-town/project.godot - 确认后端已启动,按 F5 运行
- WASD 移动,走近 NPC 按 E 对话,Enter 发送,ESC 关闭
Checkpoint 3: 游戏内与张三对话成功;关掉游戏重开,它还记得上一次的对话内容(记忆持久化在后端,与游戏进程无关)。
4. 里程碑四:读懂游戏侧(1 天,GDScript 速成)
GDScript 语法极像 Python(缩进、动态类型),Python 学完的你直接能读。scripts/ 下五个文件:
- config.gd —— 后端地址配置(
http://localhost:8000),连不上先查这里 - api_client.gd —— ★ 唯一和后端通信的文件。Godot 的
HTTPRequest节点发 JSON,回调里解析响应。把它读懂,游戏侧就通了 - player.gd —— 读 WASD 输入 → 改速度 → 播放走路动画;碰撞体积防止穿墙
- npc.gd —— 检测玩家靠近(Area2D 信号)→ 显示"按E对话"提示 → 按下 E 发信号打开对话框
- dialogue_ui.gd —— 对话框 UI:输入框、历史记录、调 api_client 发消息、收到回复后追加显示
Godot 两个核心概念,读脚本前先建立:
- 场景树:游戏画面是节点树(玩家节点、NPC 节点、UI 节点),
.tscn文件就是一棵子树。scenes/main.tscn是主场景,里面摆了地图、玩家、三个 NPC。 - 信号(signal):节点间通信靠"信号"(等于事件/回调)。NPC 被交互 → 发信号 → 对话框 UI 接住 → 打开。
改造练习(至少做第 1 个):
- 加第四个 NPC:后端
NPC_ROLES加"赵六/测试工程师";游戏里复制一个 NPC 节点(scenes/npc.tscn实例),改它的 npc_name 属性和贴图。完整走一遍"数据驱动 + 场景实例化"。 - 显示好感度:对话框里加一行小字显示当前好感度(api_client 调
GET /npcs/{name}/affinity)。 - 记忆面板:按 M 键弹出当前 NPC 的记忆列表(调
GET /npcs/{name}/memories),直观看到"它记住了什么"。
5. 本项目的可迁移经验
- 游戏引擎 = 又一种前端:HTTP + JSON 的后端可以对接任何客户端
- 动态上下文拼装:人设(静态)+ 关系状态(数值)+ 相关记忆(检索)→ 每轮重组提示词,是"有状态 AI 角色"的通用配方
- 两层记忆 + 语义检索 + 重要性/时间衰减:对话式产品的记忆标准架构
- LLM-as-a-Judge:用模型给对话打分驱动数值系统(好感度),而不是写死关键词规则
- 批量生成:多个角色的周期性内容合并成一次 LLM 调用
自测清单
- 画出"按 E 对话"到"看到回复"的完整调用链(游戏脚本 → 接口 → 后端函数)。
chat()的六个步骤,不看文档默写出来。- 工作记忆和情景记忆的区别?
memory.db存的是哪种? - 好感度是怎么"读"进对话、又是怎么"写"回去的?
- 为什么 NPC 头顶的自言自语要批量生成、周期刷新,而不是每次实时调 LLM?
- 已完成"加第四个 NPC"练习。