在 AI Agent 应用开发中,比单个智能体更有意思的是多智能体之间的互动模拟。AI 小镇(AI Town)是这类项目的典型形态:多个角色被放进同一个虚拟空间,各自拥有性格、记忆、日程和人际关系,系统按时间步推进它们的行为,再让大模型补充计划外的反应。“七海去找星瞳玩,但是路上太晒了”这个场景,正好覆盖了多智能体模拟里的几个核心环节:出行意图、环境状态、决策调整和画面生成。这篇文章会以一个开源的 AI 小镇项目 my_ai_town 为参考,从工程实践角度拆解如何在本地搭建多智能体小镇,如何让角色在“太阳太晒”的条件下修正计划,以及如何把模拟结果输出成 AI 静态图。
项目开源地址为https://github.com/mewamew/my_ai_town,并提供了 macOS 和 Windows 两个平台的本地运行方式。文章会先讲清楚这类模拟的基本原理,再给出环境准备、数据建模、决策实现、图像生成和问题排查的完整链路。适合正在做 AI Agent 开发、AI 应用开发,或者想学习多智能体模拟、提示词工程的角色互动项目开发者阅读。看完后你可以得到一个包含角色配置、环境状态、决策日志和静态图生成的最小闭环。
1. 先理解“AI 小镇”式模拟的核心机制
1.1 从一个标题反推技术需求
把“七海去找星瞳玩,但是路上太晒了”拆开看,它实际上包含四个必须被程序表达的信息:
- “七海”和“星瞳”:两个可交互的 AI 角色,需要各自的身份描述、性格倾向和行动偏好。
- “去找…玩”:一个带有目的地和动机的行动计划,而不是一次随机的聊天。
- “路上太晒了”:环境状态(天气、日照强度)必须能进入决策逻辑,否则角色不会受到“晒”的影响。
- “AI 静态”:最终产物是一张静态插画,不是实时动画或视频流。
所以这个项目至少要包含四个模块:角色管理、环境状态、决策引擎、图像生成。四个模块之间用事件日志串联,模拟产生的每个动作都有时间戳、角色、动作和原因,后续图像生成才能从日志里提取场景要素。
1.2 什么是多智能体模拟
多智能体模拟(Multi-Agent Simulation)是指多个 AI 角色在共享环境中按照各自的内部状态和外部条件,逐步产生行动序列的过程。它和“一次对话就结束”的 ChatBot 不同,重点在于状态持续变化:角色有记忆、有当前位置、有日程,环境有天气、有时间、有地点关系,系统按固定时间步推进,每一轮都会产生新事件。
这类项目最早的公开代表是斯坦福提出的生成式智能体(Generative Agents)研究,后来社区里出现了大量“AI 小镇”实现。它们通常把每个角色建模成“性格摘要 + 记忆流 + 每日计划”的组合。角色不是背台词,而是随时间和环境调整行动。标题里的“路上太晒”就是一个典型的环境干扰:计划是出门,但环境条件导致出门这个动作需要被推迟或改变。
1.3 规则驱动和 LLM 驱动需要混合使用
一个常见误区是把所有决策都交给大模型,认为只要提示词写得好,角色就会自动演好故事。实际操作中,完全依赖 LLM 会带来两个问题:第一,模型每次输出有随机性,角色会频繁“精分”,上午决定出门,下午又忘记这个计划;第二,LLM 调用有延迟和成本,每个时间步都让模型重新决策,模拟速度会非常慢。
推荐做法是规则优先、LLM 补全。像“当前时间是否在日程范围内”“天气是否超过角色耐晒阈值”“目标地点是否存在”这类条件,用代码判断;像“遇到熟人会说什么”“看到路边小吃摊会不会改变路线”这类开放内容,再交给 LLM 生成。这样既保证模拟稳定,又保留角色表达的自然度。
2. 环境准备:本地跑通一个多智能体小镇
2.1 环境清单
| 项目 | 要求 | 说明 |
|---|---|---|
| 操作系统 | macOS 或 Windows | 项目提供两个平台的本地运行方式 |
| Python | 3.10 及以上 | 主要开发语言,建议使用虚拟环境 |
| Git | 2.x | 用于克隆代码和切换版本 |
| 大模型服务 | OpenAI 兼容接口或本地模型 | 负责角色行为、记忆概括等文本生成 |
| 图像生成服务 | 图片生成接口或本地扩散模型 | 负责把场景提示词渲染成静态图 |
| 可选工具 | Ollama、vLLM 或 ComfyUI | 本地部署大模型或图像模型时使用 |
如果没有服务端 API,也可以先用本地模型跑通文本部分,图像部分可以延迟到后面再接入。关键是先把模拟循环跑通,不要一开始就追求完整链路。
2.2 克隆项目与基础依赖
git clone https://github.com/mewamew/my_ai_town.git cd my_ai_town python -m venv .venv source .venv/bin/activate pip install -r requirements.txtWindows 用户的激活命令是.venv\Scripts\activate。安装依赖后,可以先运行一个自检命令,确认大模型接口连通。如果原始仓库的 README 里提供了启动脚本,优先使用仓库脚本;如果提示缺少依赖,再根据报错补装。
2.3 目录结构设计
参考常见 AI 小镇工程,一个可维护的项目通常按职责拆分。这里给出最小可运行的结构,实际仓库可能不同,落地前先看 README 和已有文件:
my_ai_town/ ├── config/ │ ├── characters/ │ │ ├── nanami.json │ │ └── xingtong.json │ ├── locations.yaml │ └── model.yaml ├── src/ │ ├── town/ │ │ ├── agent.py │ │ ├── location.py │ │ ├── weather.py │ │ ├── memory.py │ │ ├── planner.py │ │ └── event_log.py │ ├── image/ │ │ ├── prompt_builder.py │ │ └── generator.py │ └── main.py ├── data/ │ ├── events.jsonl │ └── images/ └── requirements.txt这个结构把“数据定义”“模拟逻辑”“图像生成”三块分开。角色配置属于数据,决策属于逻辑,图片保存属于产物,改动任意一块都不会影响另外两块。
2.4 配置模型服务
llm: provider: openai_compatible base_url: "https://api.example.com/v1" api_key: "${LLM_API_KEY}" model: "your-chat-model" temperature: 0.7 image: provider: openai_compatible base_url: "https://api.example.com/v1" api_key: "${IMAGE_API_KEY}" model: "your-image-model" size: "1024x1024"这里的关键点是:
base_url允许你对接 OpenAI 兼容的服务,也可以换成内网部署的网关。api_key不要直接写在文件里,使用环境变量注入。temperature控制 LLM 输出的随机性。模拟角色行为时建议 0.7 左右,太高容易角色失控,太低则显得机械。- 图像模型的名称和
size参数要以实际接口为准,不同服务差异很大。
配置完成后,写一个最小的连通性测试,验证文本接口和图像接口都能返回结果,再继续后面的开发。
3. 数据建模:角色、地点、天气和记忆怎么设计
3.1 角色配置不能只有一句人设
角色文件如果只写“七海是一个可爱的角色”,决策引擎完全无法使用这条信息。角色建模需要拆成可计算字段。
{ "id": "nanami", "name": "七海", "home": "nanami_home", "persona": "喜欢和朋友待在一起,方向感一般,怕晒,讨厌烈日", "sun_tolerance": 0.3, "daily_plan": [ { "time": "09:00", "action": "起床" }, { "time": "10:00", "action": "准备出门" }, { "time": "10:30", "action": "去星瞳家" } ], "relations": { "xingtong": "好姐妹,想去她家玩" } }其中sun_tolerance是一个量化字段,取值范围 0 到 1,表示角色能承受的最大日照强度。当天气状态的日照值大于这个阈值时,决策引擎就知道角色会觉得“太晒”。这个字段非常关键,它让“怕晒”从一段描述文字变成了可比较的数字,规则代码可以直接判断。
3.2 地点与天气状态
地点用 YAML 配置,方便人工维护。每个地点可以定义暴露程度,也就是受天气影响的程度。
locations: nanami_home: name: 七海的小屋 exposure: 0.0 xingtong_home: name: 星瞳的家 exposure: 0.1 road_to_xingtong: name: 去星瞳家的路 exposure: 0.9 town_square: name: 小镇广场 exposure: 0.5exposure表示该地点受天气影响的强弱。室内几乎不受日照影响,露天道路则接近 1。角色在一个地点感受到的实际日照强度,由全局天气乘以其暴露程度计算得到。
天气状态本身用 Python 数据类表达:
from dataclasses import dataclass @dataclass class WeatherState: temperature: int sunshine: float description: str def effective_sunshine(self, exposure: float) -> float: return round(self.sunshine * exposure, 2)sunshine是 0 到 1 的日照强度,effective_sunshine计算某个地点下角色实际感受到的日照。比如全局日照 0.9,露天道路暴露 0.9,实际日照就是 0.81,明显超过七海 0.3 的耐晒阈值。
3.3 记忆和事件日志
记忆是让角色“记得自己刚才想干什么”的关键。最基本的实现是事件日志加摘要:
import json from datetime import datetime class MemoryStore: def __init__(self, path: str): self.path = path self.events = [] def add(self, agent_id: str, action: str, detail: str, reason: str = ""): event = { "time": datetime.now().isoformat(), "agent": agent_id, "action": action, "detail": detail, "reason": reason, } self.events.append(event) self._append_to_file(event) def _append_to_file(self, event: dict): with open(self.path, "a", encoding="utf-8") as f: f.write(json.dumps(event, ensure_ascii=False) + "\n") def recent(self, agent_id: str, limit: int = 5): filtered = [e for e in self.events if e["agent"] == agent_id] return filtered[-limit:]事件日志既用于调试,也用于生成静态图的提示词。日志里必须包含agent、action、detail、reason四个字段,reason特别重要,它是“为什么这样做”的直接记录,也是后续图像提示词里最有画面感的信息。
4. 实现“去找朋友但路上太晒”的决策链路
4.1 行动规划:规则优先,LLM 补全
决策引擎不能直接让 LLM 从零决定,而是先检查日程,再检查环境,最后交给 LLM 补全表达。
class Planner: def __init__(self, llm, memory: MemoryStore): self.llm = llm self.memory = memory def decide(self, agent, world): plan = agent.get_current_plan(world.clock) if plan is None: return {"action": "idle", "reason": "当前没有日程安排"} if plan.action == "go_visit": return self._handle_visit(agent, world, plan) return plan.to_dict() def _handle_visit(self, agent, world, plan): target = world.locations[plan.target] sunshine = world.weather.effective_sunshine(target.exposure) if sunshine > agent.sun_tolerance: return { "action": "delay_visit", "original_target": plan.target, "detail": f"{agent.name}站在门口,看了一眼外面的太阳,决定晚点再出发", "reason": f"路径日照强度 {sunshine},超过角色耐晒阈值 {agent.sun_tolerance}", } return { "action": "go_visit", "target": plan.target, "detail": f"{agent.name}带上小包,往{target.name}的方向走去", "reason": f"日照强度 {sunshine} 在可接受范围内", }这个实现的关键在于“决策依据可解释”。角色推迟出行,不是因为提示词里写了“可能推迟”,而是因为0.81 > 0.3这个条件成立。规则代码先拦截确定性事实,LLM 只负责把事实翻译成自然语言描述。
4.2 环境状态如何进入世界循环
世界对象负责推进时间、更新天气、驱动角色行动:
class World: def __init__(self, agents, locations, weather, memory): self.clock = 0 self.agents = agents self.locations = locations self.weather = weather self.memory = memory self.planner = Planner(self._llm, memory) def tick(self): self.clock += 1 # 每 3 个时间步随机小幅变化天气 if self.clock % 3 == 0: self.weather.sunshine = max(0.0, min(1.0, self.weather.sunshine + 0.1)) def step(self): self.tick() for agent in self.agents: decision = self.planner.decide(agent, self) self.memory.add( agent_id=agent.id, action=decision["action"], detail=decision.get("detail", ""), reason=decision.get("reason", ""), ) return self.memory.events[-len(self.agents):]天气在每个时间步变化后,会直接影响所有室外角色的下一次决策。这就是“模拟感”的来源:同样的计划,在不同天气下会产生不同结果。七海在晴天会推迟出门,在阴天日照强度低的时候就会正常出发。
4.3 主流程:从模拟到画面
主程序把角色加载、世界循环、日志输出串起来:
def main(): agents = load_characters("config/characters") world = World( agents=agents, locations=load_locations("config/locations.yaml"), weather=WeatherState(temperature=32, sunshine=0.9, description="大晴天,太阳很大"), memory=MemoryStore("data/events.jsonl"), ) for _ in range(10): world.step() scene = build_scene_from_events(world.memory.events) prompt = build_prompt(scene) image_path = generate_image(prompt, "data/images/scene_001.png") print(f"场景图已保存: {image_path}")这里用build_scene_from_events把最近事件汇总成一个场景对象,再交给提示词构造器和图像生成器。注意模拟循环和图像生成是分离的,模拟可以跑很多轮,但只有选出关键事件后才生成图片,避免每次调用图像接口造成高成本。
5. 把模拟结果渲染成 AI 静态图
5.1 从事件日志构造场景提示词
静态图的质量,取决于提示词能否把日志里的结构化信息完整表达出来。直接拼接日志文本效果很差,建议先整理成场景对象,再生成提示词。
def build_scene_from_events(events): latest = events[-1] return { "characters": latest["agent"], "action": latest["detail"], "reason": latest.get("reason", ""), "weather": latest.get("weather_context", "晴天"), "location": latest.get("location", "小镇"), }提示词构造器需要固定风格前缀,再动态拼接场景要素:
def build_prompt(scene: dict) -> str: base_style = ( "动漫风格插画,明亮通透的日式小镇场景," "角色表情自然,构图具有生活气息," ) content = ( f"角色:{scene['characters']};" f"地点:{scene['location']};" f"天气:{scene['weather']};" f"正在进行的动作:{scene['action']};" f"画面理由:{scene['reason']};" ) return base_style + content + "画面干净,细节丰富,竖构图。"注意提示词里的“画面理由”非常重要。它来自日志里的reason字段,例如“路径日照强度 0.81,超过角色耐晒阈值 0.3”,图像模型会据此画出强烈的阳光、角色的遮挡动作或犹豫表情,让静态图有叙事感。
5.2 调用图像生成接口
图像生成统一封装在一个模块里,方便替换服务商:
import os from openai import OpenAI def generate_image(prompt: str, save_path: str) -> str: client = OpenAI( api_key=os.environ["IMAGE_API_KEY"], base_url=os.environ.get("IMAGE_BASE_URL"), ) response = client.images.generate( model=os.environ.get("IMAGE_MODEL", "your-image-model"), prompt=prompt, size="1024x1024", n=1, ) image_url = response.data[0].url download_image(image_url, save_path) return save_path代码里的模型名称故意写成环境变量。不同图像服务的模型名、请求格式、返回字段差异很大,落地前必须确认实际接口文档。如果你的图像服务返回的是 base64 内容而不是 URL,就要改成解析b64_json。
5.3 图片保存与命名
图片保存建议按日期或模拟轮次命名,避免覆盖:
from datetime import datetime def make_image_path(base_dir: str) -> str: ts = datetime.now().strftime("%Y%m%d_%H%M%S") os.makedirs(base_dir, exist_ok=True) return os.path.join(base_dir, f"scene_{ts}.png")生成完成后,除了保存图片,还要把对应的提示词和事件日志一并保留。这样回头排查“为什么生成出来不是想要的画面”时,可以直接对比日志、提示词和实际图片三者,定位是哪一环出了问题。
6. 运行验证:从日志到图片的完整闭环
6.1 启动方式
以最小验证目标运行:
python src/main.py首次运行建议只用两个角色、10 个时间步。时间太短看不到天气变化影响,太长会产生大量重复日志,不方便观察。
6.2 预期输出
正常运行的日志文件data/events.jsonl里应当出现类似记录:
{"time": "2025-06-01T10:30:00", "agent": "nanami", "action": "delay_visit", "detail": "七海站在门口,看了一眼外面的太阳,决定晚点再出发", "reason": "路径日照强度 0.81,超过角色耐晒阈值 0.3"}如果天气在第 6 个时间步降到阴天,日志后续应当出现七海正常出发的记录:
{"time": "2025-06-01T11:00:00", "agent": "nanami", "action": "go_visit", "detail": "七海带上小包,往星瞳的家方向走去", "reason": "日照强度 0.4 在可接受范围内"}同时data/images/目录下会生成图片文件,图片内容能明显看出晴天或转阴后的场景差异。
6.3 验证清单
| 验证项 | 检查方式 | 通过标准 |
|---|---|---|
| 角色是否执行每日计划 | 查看 events.jsonl 中的 action 字段 | 起床、出门、拜访等动作按时间出现 |
| 天气是否进入决策 | 对比天气变化前后的 reason 字段 | 日照超标时 action 变为 delay_visit |
| 日志是否完整 | 检查每个事件是否含 time/detail/reason | 字段齐全,无空值 |
| 提示词是否正确 | 打印 build_prompt 的输出 | 包含角色、地点、天气、动作、理由 |
| 图片是否保存 | 检查 images 目录 | 有非空 png 文件,且尺寸正确 |
| 重复运行是否稳定 | 连续运行两次并对比 | 规则分支结果一致,LLM 补全允许差异 |
注意:只验证程序能启动不算完成。要确认角色确实因为日照阈值改变了行动,并且变化体现在日志、提示词和最终图片三个层面。
7. 常见问题排查
7.1 按这条链路排查
多智能体项目最忌讳直接看现象改逻辑。出场顺序应该是:配置 -> 日志 -> 提示词 -> 图片。先确认角色配置里的sun_tolerance和exposure数值合理,再看事件日志里的action和reason是否符合预期,然后检查build_prompt拼出的提示词有没有遗漏场景字段,最后才怀疑图像模型本身。大部分“生成结果不对”的问题,根源都在提示词或日志,而不是图像模型。
7.2 高频问题表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 角色无视天气直接出门 | 决策代码没读取天气状态,或sun_tolerance设得过大 | 打印effective_sunshine和阈值 | 确认天气传入 planner,调低角色阈值 |
| 日志里的 reason 为空 | 模型输出格式不对或 detail 字段拼接失败 | 打印原始模型返回 | 增加 JSON 解析容错,使用结构化输出 |
| 图片里没有“太晒”的感觉 | 提示词没有包含 reason 字段 | 打印最终提示词 | 在内容拼接中显式加入“画面理由” |
| 生成图片内容与事件不符 | 提示词描述太泛 | 对比日志和提示词 | 把 location、action、reason 全部写进提示词 |
| 模拟每步都调用 LLM,速度很慢 | 没有规则前置拦截 | 查看调用日志 | 把日程、天气、阈值判断改为规则代码 |
7.3 三个容易踩的坑
第一个坑是角色记忆无限增长。每次行动都追加事件,几十轮之后记忆列表会非常长,每次决策都把它塞进提示词,既浪费 token 又让模型注意力分散。应该在达到上限时触发摘要,把旧事件压缩成一句短期记忆,只保留最近 N 条原始事件。
第二个坑是完全依赖 LLM 补全带来幻觉。如果你让模型自由决定“七海会不会出门”,它可能编造出完全不符合角色设定的事件,比如角色突然出现在一个不存在的地点。规则代码必须先把所有确定性条件过滤完,LLM 只负责润色和生成开放内容。
第三个坑是图像提示词过载。场景里有一点细节就往提示词里塞,角色、天气、动作、路边摊、云朵、衣服颜色全写进去,图像模型反而抓不住重点。提示词应遵循“风格前缀 + 角色 + 地点 + 天气 + 动作 + 理由”的结构,控制在一段话以内,理由只保留最关键的一条。
8. 最佳实践与扩展方向
8.1 多智能体项目的可复用清单
把这套工程经验沉淀成一份清单,每做一个类似项目都可以先过一遍:
- 角色是否具备可计算的属性字段(耐晒阈值、移动速度、社交偏好等)。
- 地点是否区分室内外暴露程度,天气是否会随时间步变化。
- 决策链路是否做到规则优先、LLM 补全。
- 每个事件是否都记录
action、detail、reason三个字段。 - 记忆是否有容量上限和摘要压缩机制。
- 静态图提示词是否从结构化场景对象生成,而不是直接拼接原始日志。
- 图像生成接口是否封装成独立模块,方便替换服务商。
- 模拟循环开头是否包含配置自检和环境检测。
8.2 从学习环境到生产环境的差异
本地跑通模拟和真正上线运行差别很大。学习环境只需要一个 Python 脚本和两个角色;生产环境至少要补上几块:配置外置化,模型地址和密钥走环境变量或配置中心;日志落盘并加上轮转,避免events.jsonl无限膨胀;增加调用限流和重试机制,防止大模型接口抖动导致模拟中断;对每次图像生成做成本控制和失败重试。如果项目面向 Web 用户,还需要考虑并发模拟、角色数据隔离、以及图像生成结果的对象存储托管,而不是直接存本地磁盘。
8.3 下一步可以扩展的方向
这个最小闭环稳定后,值得扩展的方向有几个。一是把单角色决策升级为社交关系网络,让角色之间的友好度、聊天记录影响后续行动,比如星瞳听说七海要来,会提前准备零食。二是接入语音或前端页面,把事件日志实时渲染成可视化小镇地图,用户能看到角色移动过程,而不仅仅是一张静态图。三是用 Spring AI Alibaba 之类的 Java 大模型接入层重构服务端,把模拟逻辑拆成独立服务,便于和现有业务系统集成。四是给记忆系统增加向量检索,让角色在决策时能“想起”与当前场景最相关的历史事件,而不是简单取最近几条。
对新手来说,最有价值的练习不是先把代码写完美,而是先跑通“天气导致行程变化”这一个分支,理解规则与 LLM 的分工边界。这个分支一旦跑顺,多智能体模拟的骨架就基本掌握了,后续加社交、加记忆、加可视化都只是在骨架上的扩展。