它是什么
先建立一个认知:这个项目和本系列前十一个不是同类。它的技术栈、评价标准、创新点都在另一个坐标系里。
8 个月 6.9 万星,它到底在卖什么
先看一组反差数字。
| 指标 | 数值 |
|---|---|
| Star | 69 573(2026-07-28 经 GitHub API 核对) |
| Fork · Watch | 10 868 · 430 |
| 仓库年龄 | 8 个月(2025-11-26 创建) |
| 真实代码量 | Python 业务 24 428 行 + Vue 20 461 行 = 44 889 行 |
| 许可 | AGPL-3.0——本系列唯一一个 |
每千行代码约 1 550 颗星。这个比值在本系列里是断崖式第一。作为对照:Open Design 每千行约 20 颗,MiMo Code 约 3 颗。
README 里那句最重要的话
Within this space, thousands of intelligent agents with independent personalities, long-term memory, and behavioral logic freely interact and undergo social evolution. You can inject variables dynamically from a "God's-eye view" to precisely deduce future trajectories.
三个关键词各自对应具体的工程事实:
EventConfig.scheduled_events,比如「第 24 小时官方发布通报」。两个官方演示,说明了它的射程
README 挂了两个演示,选得很讲究:
武汉大学舆情模拟——用舆情报告作种子,预测事件走向
《红楼梦》佚失结局推演——用前 80 回作种子,让贾府众人继续演
官方给自己的定位一句话:「简洁通用的群体智能引擎,预测万物」。项目由盛大集团(Shanda Group)战略支持与孵化,仿真引擎明确致谢 OASIS(CAMEL-AI 团队)。
技术栈全解:一张薄后端 + 两个重外部依赖
backend/requirements.txt 全文只有 12 个包。短得惊人:
| 类别 | 包 |
|---|---|
| 框架 | flask>=3.0.0 · flask-cors>=6.0.0 |
| LLM | openai>=1.0.0(统一走 OpenAI 格式) |
| 记忆 | zep-cloud==3.25.0 · httpx>=0.27.0 |
| 仿真 | camel-oasis==0.2.5 · camel-ai==0.2.78 |
| 文件 | PyMuPDF · charset-normalizer · chardet |
| 工具 | python-dotenv · pydantic>=2.0.0 |
没有 LangChain(虽然 report_agent.py:3 的 docstring 这么写着,但依赖里没有——ReACT 是手写的)。没有向量数据库(Zep 自己管)。没有消息队列(用文件系统 IPC)。没有 ORM(models/ 是纯 dataclass + JSON 落盘)。
flowchart LR
subgraph OUT["外包出去的(难,但有现成的)"]
A["社会仿真引擎
OASIS / camel-ai
平台环境·动作空间·推荐算法"]
B["时序知识图谱
Zep Cloud
实体·关系·时间有效期"]
C["语言模型
任意 OpenAI 兼容 API"]
end
subgraph OWN["自己写的(24 428 行)"]
D["本体设计"]
E["人设生成"]
F["参数生成"]
G["进程编排"]
H["闭环回写"]
I["检索与报告"]
end
A --- OWN
B --- OWN
C --- OWN
OWN --> P["产品:
上传 PDF + 一句话
→ 报告 + 可交互世界"]
为什么必须是时序图谱
Zep 提供的不只是「A 和 B 有关系」,而是「A 和 B 在 T1 到 T2 之间有关系,之后失效了」。代码里对时间维度的使用非常明确(services/zep_tools.py:133-142):
def is_expired(self) -> bool: ...
def is_invalid(self) -> bool: ...
一个硬约束
config.py:71-72,在 Config.validate() 里:
if os.environ.get("ZEP_API_URL"):
errors.append("ZEP_API_URL 不受支持;MiroFish 仅连接 Zep Cloud")
只支持 Zep Cloud,不支持自建。启动时直接报错。这是一个很强的产品决定——第 15 章会讨论它的代价。
后端分层:只有四层,非常扁平
| 层 | 文件数 | 干什么 |
|---|---|---|
api/ | 3 | graph(963) · simulation(2 878) · report(1 139) |
services/ | 11 | 核心逻辑全在这,约 12 000 行 |
models/ | 2 | project.py · task.py——纯 dataclass |
utils/ | 11 | llm_client · zep · retry · locale · file_parser · ontology … |
最大的三个 service:report_agent.py(2 619 行)· simulation_runner.py(2 033)· zep_tools.py(1 734)。
三个细节看工程习惯
resource_tracker 的警告来自 transformers 之类的库。\uXXXX。WERKZEUG_RUN_MAIN 区分。这三处都是被真实运行折磨过的痕迹。一个只写给自己看的 demo 不会处理这些。
五步流水线:从一份 PDF 到一个可对话的世界
flowchart TB
U["用户上传种子材料
PDF / MD / TXT(≤50MB)
+ 一句话预测需求"] --> S1
subgraph S1["① 图谱构建"]
O["ontology_generator
LLM 设计 10 类实体"]
G["graph_builder
Zep Batch API 摄取"]
O --> G
end
subgraph S2["② 环境搭建"]
E["zep_entity_reader
读图谱节点"]
P["oasis_profile_generator
实体 → 2000 字人设"]
C["simulation_config_generator
LLM 分步生成参数"]
E --> P --> C
end
subgraph S3["③ 模拟"]
M["simulation_manager"]
R["simulation_runner
后台子进程跑 OASIS"]
W["zep_graph_memory_updater
活动实时回写图谱"]
M --> R --> W
W -.闭环.-> R
end
subgraph S4["④ 报告生成"]
RA["report_agent
ReACT 分章生成"]
ZT["zep_tools
四件检索兵器"]
RA <--> ZT
end
subgraph S5["⑤ 深度交互"]
I1["跟世界里任何 Agent 对话"]
I2["跟 ReportAgent 追问"]
end
S1 --> S2 --> S3 --> S4 --> S5
S3 -.环境不关门.-> S5
W -.图谱.-> ZT
前端组件名直接对应这五步:
| 组件 | 行数 | 步骤 |
|---|---|---|
Step1GraphBuild.vue | 700 | 图谱构建 |
Step2EnvSetup.vue | 2 623 | 环境搭建 |
Step3Simulation.vue | 1 268 | 模拟运行 |
Step4Report.vue | 5 162 | 报告生成(最大的前端文件) |
Step5Interaction.vue | 2 584 | 深度交互 |
GraphPanel.vue | 1 423 | d3 知识图谱可视化 |
世界是怎么建起来的
从一份文本到一批能发帖、能点赞、有人格的账号,中间隔着四道工序。这一部分是全项目最见功力的地方。
本体生成:被 Zep 的 10 类上限逼出来的层次设计
提示词里最重要的一条约束
ONTOLOGY_SYSTEM_PROMPT(services/ontology_generator.py:48 起,一路到 :190)开头就把标准立死了。:70 那一段是关键:
因此,实体必须是现实中真实存在的、可以在社媒上发声和互动的主体:
可以是:具体的个人 / 公司企业 / 组织机构 / 政府部门 / 媒体机构 / 特定群体代表
不可以是:抽象概念(如"舆论"、"情绪"、"趋势")· 主题/话题(如"学术诚信")· 观点/态度(如"支持方"、"反对方")
通用启发:当流水线下游有硬性的形态要求时,最有效的做法是在上游的提示词里就把不合规的形态明确列出来禁掉, 而不是在下游做过滤。而且列反例比只说正面要求有效得多。
被 10 类上限逼出来的「兜底类型」
utils/ontology.py:6 写着 MAX_ONTOLOGY_TYPES = 10——这是 Zep 服务端的限制。于是提示词里出现了这样一段设计(:113-136):
flowchart TB
T["10 个实体类型(Zep 硬上限)"] --> A["8 个具体类型
从文本里识别的高频关键角色
如 Student / Professor / University"]
T --> B["2 个兜底类型(固定放最后)
Person —— 任何自然人
Organization —— 任何组织"]
A -.匹配不上时.-> B
提示词自己在 :128 解释了为什么需要兜底:「文本中会出现各种人物,如"中小学教师"、"路人甲"、"某位网友"。如果没有专门的类型匹配,他们应该被归入 Person。」
这条约束在输出格式段里又重复了一遍(:287-288),而且——代码里还有一道强制补齐(:500-544):如果 LLM 的返回缺了兜底类型,代码自己补上。
三层防御性归一化
LLM 生成的本体不能直接喂给 Zep。下面这个实验台是 utils/ontology.py 全文 + ontology_generator.py:22-45 + graph_builder.py:324-328 的可运行移植——不是示意图,是同一套逻辑。
EntityModel 的那一刻:
summary → entity_summary。
如果直接丢弃,LLM 精心设计的一个属性就凭空消失了,而且没有任何人会发现——因为下游只是少了一个字段,不会报错。 改名则保住了信息,代价只是名字不好看。在「数据丢失」和「名字难看」之间,永远选后者。
但这一层有一个静默的数据破坏行为
点上面实验台的「中文类型名 ⚠️」样例。这不是我编的边界情况——把源码函数抠出来直接跑,结果一致:
| 输入 | _to_pascal_case | _to_upper_snake_case |
|---|---|---|
college student | CollegeStudent ✅ | COLLEGE_STUDENT ✅ |
universityOfficial | UniversityOfficial ✅ | UNIVERSITY_OFFICIAL ✅ |
在校学生 | Unknown ❌ | UNKNOWN ❌ |
武汉大学 | Unknown ❌ | UNKNOWN ❌ |
2媒体机构 | 2 ❌ | REL_2 ❌ |
两个函数的核心都是 [^a-zA-Z0-9]——只认 ASCII。任何中文字符都被当成分隔符整段丢掉。
后果是级联的。graph_builder.py:331 建的是一个按名字索引的字典,:354 写入:
entity_types = {} # :331
...
entity_types[name] = entity_class # :354
IMPORTANT: Entity type names MUST be in English PascalCase.
它不是风格要求,是承重墙。而且它就拼在
get_language_instruction()
(可能正是「请使用中文回答」)的正后面——一句话让模型说中文,下一句话又要求类型名必须英文。
这条例外声明扛着整个本体流水线。
真正该做的是在归一化函数里加一道断言:
if not normalized: raise,而不是 return 'Unknown'。
返回一个看起来合法的兜底值,是这类静默数据破坏最典型的成因——兜底值应该用在「缺失」上,不该用在「损坏」上。
图谱构建:GraphRAG 与批量摄取
graph_builder.py(879 行)里有一个值得注意的数据结构(:52):
class BatchSubmission:
"""Durable identity for one Zep Batch API ingestion operation."""
「Durable identity」——持久化的身份标识。这说明摄取是异步、可能失败、需要追踪的:你提交一批数据给 Zep,拿到一个 ID,之后要能查它成没成。
切块参数在 config.py:41-42:DEFAULT_CHUNK_SIZE = 500、DEFAULT_CHUNK_OVERLAP = 50。支持 PDF(PyMuPDF)、MD、TXT,上限 50 MB。
charset-normalizer 和 chardet——「支持非 UTF-8 编码的文本文件」。
GBK 文本在中文用户里非常常见。
人设生成:个人与「群体代表账号」的分野
问题很直白:图谱里有 Student(学生)、也有 University(大学)。前者是一个人,后者是一个机构。但在社交媒体上,两者都要有一个账号。
怎么给「武汉大学」生成一个人设?
flowchart TB
E["图谱实体
entity_type"] --> Q{"_is_group_entity()
:536
type.lower() 在
GROUP_ENTITY_TYPES 里?"}
Q -->|否| I["_build_individual_persona_prompt
:721"]
Q -->|是| G["_build_group_persona_prompt
:770"]
I --> I2["真人人设 2000 字
年龄·MBTI·职业
立场·口头禅
个人记忆"]
G --> G2["机构官号设定
代表的群体画像
运营习惯·口径
gender = other"]
I2 --> N["OasisAgentProfile
__post_init__ :111
边界归一化"]
G2 --> N
N --> R["to_reddit_format() :123"]
N --> T["to_twitter_format() :151"]
lower() in [...] 的判定,分出两条完全不同的生成路径。社会事件里,机构和个人的发声逻辑完全不同:机构要考虑立场、口径、时机;个人可以情绪化。_is_group_entity()(:536-538),看它落到哪条提示词分支、生成什么形态的人设、以及最后如何转成两个平台的格式。
:754 自己标注了「人设的重要部分」,要求写出「这个个体在事件中的已有动作与反应」。
它把 Agent 和具体事件绑定起来——不是一个泛泛的「35 岁男性教师」, 而是「在这件事里已经做过什么、说过什么」的那个人。
MBTI 也不是装饰——它是 LLM 能稳定理解、并转化成行为倾向的一个压缩表示。用 MBTI 比写一段自由文字的「性格描述」更容易让模型保持一致性。
二次丰富:调 Zep 检索补充上下文
模块 docstring 列的第一条优化就是「调用 Zep 检索功能二次丰富节点信息」——从图谱拿到一个实体后,再用 Zep 的检索去捞它的相关边和上下文,然后一起喂给 LLM。所以人设里能写出「这个人在事件中的已有动作与反应」。上下文两条分支各自截断到 3 000 字(:732 和 :781)。
三道数据清洗
bio 返回成 list 或 dict,统一压成字符串;空了用兜底文案。在 dataclass 构造出口做一次,后面所有消费方就不用各自防了。"_fixed": True,上游检查完就删掉——修复痕迹只在内部流转。json.loads() 炸掉。这是踩过的坑。模拟参数:把社会学常识编码成配置
simulation_config_generator.py(993 行)是这个项目最有「领域知识」含量的文件。
先说分步生成策略
采用分步生成策略,避免一次性生成过长内容导致失败:
1. 生成时间配置 2. 生成事件配置 3. 分批生成 Agent 配置 4. 生成平台配置
这是所有「让 LLM 生成大块结构化数据」场景的通用解法:拆成小块、分批要,比一次要一大坨可靠得多。
时间配置:一条中国人的作息曲线
:30-50 的 CHINA_TIMEZONE_CONFIG,注释写着「中国作息时间配置(北京时间)」;TimeSimulationConfig(:85-112)的 docstring 是「时间模拟配置(基于中国人作息习惯)」。
minutes_per_round = 60(:91)→ 一轮 = 一小时,所以横轴的 24 格就是一天。
平台配置:三个传播学参数,两套取值
PlatformConfig(:131-145)的三组参数直接对应传播学的三个核心机制:推荐权重三元组(平台算法如何决定谁看到什么)、病毒传播阈值(信息破圈的临界点)、回声室效应强度(观点极化、信息茧房)。
关键在于——两个平台的默认值是不一样的(:342-360):
| 参数 | 这个差异说的是什么 | ||
|---|---|---|---|
| 时间新鲜度 | 0.4 | 0.3 | Twitter 是时间线,新的压倒一切 |
| 热度 | 0.3 | 0.4 | Reddit 是投票排序,热度权重更高 |
| 相关性 | 0.3 | 0.3 | 两边一样 |
| 病毒阈值 | 10 | 15 | Twitter 更容易破圈(转发机制) |
| 回声室强度 | 0.5 | 0.6 | Reddit 的 subreddit 结构天然更封闭 |
而且方向全对:Twitter 的转发链路确实让信息破圈门槛更低;Reddit 的社区分区确实让观点聚集更严重。这不是拍脑袋填的。
事件配置:上帝视角的注入口
| 字段 | 行 | 作用 |
|---|---|---|
initial_posts | :118 | 初始事件。可以指定由哪个 Agent 发(poster_agent_id)——安排大 V 在第 0 轮发第一条 |
scheduled_events | :121 | 定时事件——「第 24 小时官方发布通报」,然后看世界怎么反应 |
hot_topics | :124 | 热点话题关键词 |
narrative_direction | :127 | 舆论引导方向 |
narrative_direction 让这个工具不只能「预测舆情会怎么走」,还能测试某种引导策略的效果。这正是「政策和公关可以零风险试跑」的落点。
SimulationParameters 有 generation_reasoning 字段(:175),把 LLM 的推理过程一起存下来,
并且会序列化进最终配置 JSON(:192-193)。
当模拟结果反常时,你能回头看「当初为什么把高峰设成这几个小时」。
可解释性不是靠事后猜,是靠当时记。
世界是怎么跑起来的
两个平行世界、一条进程边界、一个闭环回边。这一部分决定了产品形态。
双平台并行:世界 1 与世界 2
zep_graph_memory_updater.py:231-234 给两个平台起了很有意思的名字:
# 平台名称映射(用于控制台显示)
PLATFORM_DISPLAY_NAMES = {
'twitter': '世界1',
'reddit': '世界2',
}
两个平台 = 两个平行世界。同一批 Agent、同一个事件,在两种不同的平台机制下会演化出不同的结果。
flowchart TB
S["同一批 Agent
同一个种子事件
同一条作息曲线"]
S --> W1
S --> W2
subgraph W1["世界 1 · Twitter 型"]
A1["6 个动作
REPOST · QUOTE_POST"]
B1["时间新鲜度 0.4 ↑"]
C1["破圈阈值 10 ↓
回声室 0.5"]
D1["→ 快、浅、易破圈"]
end
subgraph W2["世界 2 · Reddit 型"]
A2["13 个动作
DISLIKE · COMMENT · SEARCH · MUTE"]
B2["热度 0.4 ↑"]
C2["破圈阈值 15 ↑
回声室 0.6 ↑"]
D2["→ 慢、深、更抱团"]
end
D1 --> R["两条演化轨迹的差异
本身就是预测结论"]
D2 --> R
recency·popularity·relevance 三权重线性组合。试着把互动数调到 10–14 之间——那是两个世界分叉的地方。
一个 Windows 兼容性的坑
scripts/run_parallel_simulation.py:29-47:
# 解决 Windows 编码问题:在所有 import 之前设置 UTF-8 编码
# 这是为了修复 OASIS 第三方库读取文件时未指定编码的问题
if sys.platform == 'win32':
os.environ.setdefault('PYTHONUTF8', '1')
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
解法分两半:环境变量管输入(必须在 import 之前设,否则来不及),reconfigure 管输出(errors='replace' 保证再怎么样也不会因为一个字符崩掉整场模拟)。这类注释很能说明团队的真实用户构成——大量 Windows 中文用户。
进程边界:文件系统 IPC 与「跑完不关门」
OASIS 模拟是长时间、重计算、可能崩溃的。跑在 Flask 进程里的话:一次崩溃 = 整个后端挂掉、请求线程被占死、没法暂停。所以它跑在后台子进程里。
文件系统 IPC
sequenceDiagram
participant F as Flask(SimulationIPCClient :95)
participant FS as 文件系统
participant S as 模拟进程(SimulationIPCServer :288)
F->>FS: 写 commands/{cmd_id}.json
loop 轮询
S->>FS: 扫 commands/
end
S->>S: 执行命令(如采访某个 Agent)
S->>FS: 写 responses/{cmd_id}.json
loop 轮询
F->>FS: 扫 responses/
end
F->>F: 拿到结果
services/simulation_ipc.py(394 行)的命令/响应模式。| 方案 | 代价 |
|---|---|
| Redis / RabbitMQ | 多一个必须部署的中间件 |
| Socket / 管道 | 要处理连接生命周期、重连、跨平台差异 |
| 文件系统 | 零依赖,跨平台,进程崩了状态还在,可以直接 cat 出来 debug |
但要看清代价:轮询有延迟、有磁盘 IO、没有背压机制。如果哪天要支持高频命令,这一层就得换掉。
最关键的设计:跑完不关门
run_parallel_simulation.py 头部功能列表里的这几条决定了产品形态:
· 双平台(Twitter + Reddit)并行模拟
· 完成模拟后不立即关闭环境,进入等待命令模式
· 支持通过 IPC 接收 Interview 命令
· 支持远程关闭环境命令
你拿到的只是一堆日志和一份报告——一个死的结果
可以走进去问「你为什么转发了那条帖子」、可以让报告 Agent 回头采访、可以持续交互
关闭路径有上界
# simulation_runner.py:52
class SimulationStopPending(TimeoutError):
"""The monitor still owns a bounded graph-ingestion finalization."""
停止不是立刻的——还要等图谱摄取收尾。它继承自 TimeoutError,意思是「还没停下来,但这是预期内的,有上界」,而不是一个错误。zep_graph_memory_updater.py:207 那边还有一个对称的 _DrainDeadlineExceeded(TimeoutError)——排空缓冲区也有截止时间。两处加起来构成一条完整的关闭路径:停模拟 → 排空回写队列 → 真正退出,每一步都有上界。
闭环回写:模拟活动如何变回图谱记忆
这是整个架构的闭环点,也是最容易被忽略的创新。
监控模拟的 actions 日志文件,将新的 agent 活动实时更新到 Zep 图谱中。
所有有意义的行为都会被更新到 Zep,action_args中会包含完整的上下文信息:点赞/踩的帖子原文 · 转发/引用的帖子原文 · 关注/屏蔽的用户名 · 点赞/踩的评论原文
user_42 liked post_1337——那样的记录事后没法检索。
而是记「user_42 点赞了『xxx 原文』这条帖子」,这样语义检索才能找到它。
三个参数
BATCH_SIZE = 5 # 每个平台各自累积 5 条后发送 :228
SEND_INTERVAL = 0.5 # 发送间隔(秒),避免请求过快 :237
# Zep recommends keeping an episode below 10,000 characters. Leave room
# for future source formatting changes.
MAX_EPISODE_CHARS = 9_500 # :241
留了 500 字符的余量给未来的格式变化——这是有经验的写法。注意 BATCH_SIZE 是按平台各自计数的,不是全局;两个世界互不干扰地各攒各的。
统计做得很细
self._total_activities = 0 # 实际添加到队列的活动数
self._total_sent = 0 # 成功发送到Zep的批次数
self._total_items_sent = 0 # 成功发送到Zep的活动条数
self._failed_count = 0 # 发送失败的批次数
self._skipped_count = 0 # 被过滤跳过的活动数(DO_NOTHING)
self._failed_batches = [] # :281-287
批次数和条数分开统计,失败的批次留档。DO_NOTHING 被显式过滤掉(:371-373)——Agent 什么都没做,不该污染图谱——但跳过的条数仍然计数,所以你事后能知道「这场模拟里有多少比例的 Agent 在摸鱼」,这本身也是个有意义的指标。
flowchart LR
G[("Zep 时序图谱")] -->|实体| P["Agent 人设"]
P --> S["OASIS 模拟"]
S -->|actions.jsonl| U["MemoryUpdater
批量回写
带帖子原文"]
U -->|活动| G
G -->|GraphRAG 检索| R["ReportAgent"]
这是 MiroFish 区别于「跑个多 Agent 模拟然后让 LLM 总结日志」的根本所在。
结果是怎么取出来的
四件检索兵器、一个 ReACT 循环、三道防伪。最后一件兵器是本项目最有创新性的东西。
四件检索兵器:InsightForge / Panorama / Quick / Interview
services/zep_tools.py(1 734 行)的 docstring 分了两级——4 个核心检索工具 + 7 个基础工具。
InsightForge:把一个问题炸成多个
flowchart TB
Q["用户问题"] --> S1["① LLM 分解为子问题
_generate_sub_queries() :1090
max_sub_queries = 5"]
S1 --> S2["② 每个子问题各做一次语义搜索"]
S2 --> S3["③ 对原始问题也搜一次"]
S3 --> S4["④ 提取相关实体 + 获取详细信息"]
S4 --> S5["⑤ 追踪关系链"]
S5 --> S6["⑥ seen_facts 去重,整合成深度洞察"]
insight_forge()(:943-1089)。这就是 GraphRAG 的「多跳」能力落到工程上的样子:不是一次检索,而是问题分解 → 并行检索 → 实体扩展 → 关系追踪 → 整合。去重用 seen_facts 集合(:992 建集合,:1003 与 :1017 两处判重)——多个子问题会检索到重叠的事实,不去重会把上下文撑爆。子问题生成时会带上 report_context,所以子问题是针对当前正在写的那一节生成的,不是泛泛而问。
Panorama:要全貌,包括过期的
panorama_search()(:1143)的定位是「获取全貌,包括过期内容」。在时序图谱里,「曾经成立但现在失效」的关系恰恰是演化的证据——比如「A 曾经支持 B(第 3–15 轮),之后转为反对」,这条过期边是报告里最有价值的素材。因为「要全貌」意味着结果会很多,它内部有个闭包 relevance_score(fact)(:1213)做排序,否则等于没检索。
Interview:采访活着的 Agent
这是全项目最有创新性的一个函数(:1270-1482)。docstring 里那句话是重点:
· 调用真实的 OASIS 采访 API,采访模拟中正在运行的 Agent
· 需要获取模拟 Agent 的真实回答(非 LLM 模拟)
· 【重要】此功能需要模拟环境处于运行状态(OASIS 环境未关闭)
一个没有记忆、没有经历过模拟的临时角色
它带着 2 000 字人设 + 整场模拟的经历回答
四步流程:① 读人设文件(_load_agent_profiles,:1503)→ ② LLM 选人(:1549,返回 selection_reasoning——为什么选这几个)→ ③ LLM 生成问题(:1632)→ ④ 调 /api/simulation/interview/batch,双平台同时采访。
对做新产品的启发:如果你的系统里跑出了一个有状态的过程, 不要只保留它的输出,想办法保留「可以继续问它问题」的能力。前者是报告,后者是资产。
另有一处降级值得记:search_graph()(:457)在 Zep 语义搜索不可用时会退回 _local_search()(:542)做本地关键词匹配。检索质量当然下降,但报告不会因为一次网络抖动就整章空白。
ReportAgent:ReACT 分章生成与三道防伪
services/report_agent.py 2 619 行,是最大的服务文件。三个上限都可配(config.py:59-61):
MAX_TOOL_CALLS_PER_SECTION = 5 # :882
MAX_REFLECTION_ROUNDS = 3 # :885(config.py:60 默认是 2,此处不一致)
MAX_TOOL_CALLS_PER_CHAT = 2 # :888
stateDiagram-v2
[*] --> 规划: plan_outline() :1176
规划 --> 分章循环: outline.json 落盘
state 分章循环 {
[*] --> 思考
思考 --> 工具调用: 输出 tool_call
工具调用 --> 剥假结果: _strip_fake_tool_results :1144
剥假结果 --> 注入真结果
注入真结果 --> 思考: 未满 5 次
思考 --> 写本章: 已满 5 次 或 判定信息够了
写本章 --> [*]
}
分章循环 --> 分章循环: 还有下一章
分章循环 --> 反思: 全部章节写完
反思 --> 分章循环: 发现缺口且未满轮数
反思 --> 拼装: assemble_full_report() :2318
拼装 --> [*]: _post_process_report() :2348
sections/——第 5 章崩了,前 4 章还在。三道防伪
核心的一道是 _strip_fake_tool_results()(:1144-1174),它治的病是:
When the LLM generates a
<tool_call>block and then continues to generate a<tool_result>block in the same response, we must strip the fake result before appending to message history.
模型生成完 <tool_call> 之后,自己接着把 <tool_result> 也编出来了。如果不剥掉就进消息历史,模型会以为那是真的检索结果,后面全建立在幻觉上。
另外两道是 _is_valid_tool_call()(:1120,执行前校验结构与工具名)和 zep_tools._clean_tool_call_response()(zep_tools.py:1483)。三道加起来覆盖一个完整链路:进来的调用要合法 → 出去的响应要干净 → 模型自己编的结果要剥掉。
depth 停在 1,于是尾部整段都不会被追加。
一个对抗「工具偏食」的细节
# :1495
if unused_tools and tool_calls_count < self.MAX_TOOL_CALLS_PER_SECTION:
当这一章还没用满额度、而且有工具一次都没被用过时,系统会主动提示模型去试试。这是在对抗一个很常见的毛病:模型倾向于反复用它熟悉的那一个工具(通常是最简单的 quick_search),而不去碰 insight_forge 或 interview_agents。
双路日志
tail -f。两者都提供流式接口(:2114 / :2052)和 from_line 分页参数,前端靠它做增量拉取而不是每次全量重取。这解释了为什么 Step4Report.vue 有 5 162 行。
深度交互:走进世界跟 Agent 说话
| 交互对象 | 能干什么 |
|---|---|
| 世界里的任何 Agent | 直接对话——它带着人设和整场模拟的经历回答 |
| ReportAgent | 追问报告内容,它会自主调用检索工具(chat() :1810,MAX_TOOL_CALLS_PER_CHAT = 2) |
这是很重要的差别——用户对报告的信任,往往建立在「我能追问细节并得到一致回答」上。
评价与启发
创新到底在哪、哪里做得好、哪里是硬伤,以及——如果你要做一个新产品,能从这里拿走什么。
创新性到底在哪:五个真正新的东西
判定标准:在本系列十一个项目里是否见过。
① 把「预测」变成「模拟」,而不是「推理」
把材料喂给 LLM,让它直接说「我认为会怎样」
→ 给你一个结论,你只能选择信或不信
构建一个世界,让它自己演化,观察结果
→ 给你一个过程:谁在第几轮说了什么、信息怎么扩散、哪个节点是转折点。而且能改变量重跑
这不是新的学术思想(社会仿真研究几十年了),但把它做成一个上传 PDF 就能用的产品,是新的。
② 知识图谱 ↔ 模拟的双向闭环
flowchart LR
A["种子材料"] -->|本体+摄取| G[("Zep 时序图谱")]
G -->|实体→人设| S["OASIS 模拟"]
S -->|活动带原文回写| G
G -->|GraphRAG 检索| R["报告"]
S -->|采访活着的 Agent| R
③ 采访活着的 Agent
跑完不关门 + IPC 采访通道 + LLM 选人和拟题,三者加起来才有这个能力。「非 LLM 模拟」那五个字是它和「让模型扮演一下」的分界线。
④ 把社会科学参数显式产品化
回声室效应强度、病毒传播阈值、推荐算法三权重、中国人作息的五段曲线——这些是领域知识,不是代码。而且是分平台的两套取值。
本系列其他项目的「配置」都是工程参数(超时、并发、重试)。MiroFish 的核心配置是社会学参数。
⑤ 个人实体与群体实体的人设分野
给「武汉大学」生成一个机构官号运营设定,而不是硬塞一个「武汉大学先生,45 岁」——这是很实际的一个洞察。社会事件里,机构和个人的发声逻辑完全不同,用两套提示词分开处理,模拟出来的舆论场才像真的。
工程质量:亮点与硬伤
亮点
| 亮点 | 证据 |
|---|---|
| 测试覆盖有重点 | backend/tests/ 18 个文件 / 3 359 行(全仓 21 个 / 4 719 行),其中 9 个是 Zep 专项——把最不可控的外部依赖测透了 |
| 降级链完整 | 人设生成七级降级;search_graph 在 Zep 不可用时降级为本地关键词匹配 |
| 重试是统一的 | utils/retry.py,按 Zep/HTTPX 的错误类型分类重试,不是无脑重试 |
| 可解释性刻意保留 | generation_reasoning · selection_reasoning · agent_log.jsonl |
| Windows 兼容认真做了 | import 前强制 UTF-8 + reconfigure(errors='replace') + 编码检测双库 |
| 关闭路径有上界 | 两个 TimeoutError 子类,把「优雅关闭」写成了类型 |
特别值得单独说:全栈 i18n
frontend/src/i18n/index.js 全文 27 行:
import languages from '../../../locales/languages.json'
const localeFiles = import.meta.glob('../../../locales/!(languages).json', { eager: true })
for (const path in localeFiles) {
const key = path.match(/\/([^/]+)\.json$/)[1]
if (languages[key]) { // ← 取交集
messages[key] = localeFiles[path].default
availableLocales.push({ key, label: languages[key].label })
}
}
languages.json 是一张语言注册表,列了 7 种,每种带 label 和 llmInstruction。而 availableLocales 取的是注册表 ∩ 实际存在的翻译文件——目前只有 zh / en,所以切换器只显示两种,不会出现「选了没用」的空档。
而后端读的是同一个目录。backend/app/utils/locale.py:8 那串 '..', '..', '..', 'locales' 一路跳出 backend,指向项目根。前端 Vite glob 和后端 os.listdir 扫的是同一批文件。
llmInstruction 更妙:它不是给界面用的,是给模型用的,被注入到 7 个提示词构造点。而每一个注入点后面都紧跟一条「但这些字段必须是英文」的例外声明:
# simulation_config_generator.py:872
"...IMPORTANT: The 'stance' field value MUST be one of the English strings:
'supportive', 'opposing', 'neutral', 'observer'.
All JSON field names and numeric values must remain unchanged.
Only natural language text fields should use the specified language."
否则你让模型「用法语回答」,它会把
stance: "supportive" 写成 stance: "favorable",
下游 if stance == 'supportive' 直接失配——而且这种 bug 只在切到非默认语言时才出现,最难查。
MiroFish 在每一个注入点都写了这条例外,一个没漏。
另有一处细节:get_locale()(:29-33)有请求上下文时读 Accept-Language 头,没有时读线程本地变量——因为模拟和报告生成都跑在后台线程里,没有 Flask 请求上下文。配套 set_locale() 的注释写着 "Call at the start of background threads"。t() 还做了两级回退,最差返回键名本身,绝不返回 undefined。
硬伤
.env 示例里只有一行注释警告:
# High consumption, try simulations with fewer than 40 rounds first。
唯一的成本保护措施是「希望用户读注释」。
对比 MiMo Code 的
/context-limit(把计费档位编码进配置)——MiroFish 在成本这块基本是裸奔的。
Step4Report.vue 5 162 行 · api/simulation.py 2 878 行 · report_agent.py 2 619 行。
最后这个文件里塞了 8 个类——两个日志器和报告管理器完全可以拆出去,它们和 ReACT 循环没有任何耦合。
report_agent.py:3 写着「使用 LangChain + Zep」,但依赖里没有 LangChain——ReACT 是手写的。
MAX_REFLECTION_ROUNDS 类常量是 3,config.py:60 默认是 2。
[^a-zA-Z0-9] 把中文整段吃掉,多个中文类型坍塌成同一个 Unknown
后在 entity_types 字典里互相覆盖,不抛异常、不打日志。
这是全文唯一一处用可运行复现验证出来的缺陷——上面 LAB 01 的「中文类型名」样例就是它。 目前唯一的防线是提示词里的 IMPORTANT 声明,而它正好拼在「请使用中文回答」后面。
models/ 只有 dataclass,状态靠 JSON 文件落盘——没有并发控制、没有事务、多实例部署会打架。
AGPL 是本系列唯一——这是刻意的商业防御:你可以自用,但改了拿去做 SaaS 必须开源。对想商用的团队是门槛,对项目自己是保护。
对做新产品的十二条启发
这一章是全文的落点。
generation_reasoning、selection_reasoning、agent_log.jsonl——决策时就把理由存下来。事后想解释「当初为什么这么配」,靠猜是猜不出来的。
_strip_fake_tool_results() 处理的是一个所有 ReACT 实现都会遇到但很少人显式处理的问题。
cat 出来 debug。
永远英文:类型名(
PersonEntity)、枚举值(supportive / male)、JSON 字段名、数值。
locales/languages.json 同时被前端 Vite glob 和后端 os.listdir 读取,一处新增语言,前端菜单 / 后端日志 / LLM 输出语言同时生效。而且取的是注册表 ∩ 实际翻译文件的交集,不会出现「菜单里有但选了没用」。
它在本系列里的位置
| 维度 | 本系列前十一个 | MiroFish |
|---|---|---|
| 核心问题 | 怎么让 AI 替我干活 | 怎么让一群 AI 替我预演 |
| Agent 数量 | 1 个主 + 少量子 agent | 成百上千个,平等互动 |
| Agent 的目的 | 完成任务 | 表现得像那个人 |
| 成功标准 | 任务做对了 | 演化出的宏观现象像真的 |
| 循环形态 | ReAct(想→做→观察) | 社会演化(发帖→被推荐→被互动→影响他人) |
| 记忆 | Agent 自己读写 | 一整个世界共同书写的时序图谱 |
| 交付物 | 代码 / 文件 / 设计稿 | 一份报告 + 一个可以走进去的世界 |
| 代码量 | 数万到数十万行 | 24 000 行 Python |
| 护城河 | 工程复杂度 | 领域常识参数 + 产品点子 |
| 许可 | 多为 MIT / Apache | AGPL-3.0(商业防御) |
quadrantChart
title 本系列项目的两个维度
x-axis "少数 Agent,各司其职" --> "大量 Agent,平等互动"
y-axis "追求任务正确" --> "追求现象逼真"
quadrant-1 "群体智能 / 仿真"
quadrant-2 "创作与生成"
quadrant-3 "编码助手 / 工具型"
quadrant-4 "编排与工作流"
"Claude Code": [0.16, 0.14]
"MiMo Code": [0.22, 0.11]
"OpenCode": [0.13, 0.09]
"Codex": [0.11, 0.13]
"goose": [0.19, 0.17]
"OpenManus": [0.33, 0.20]
"Suna": [0.36, 0.24]
"Open Design": [0.38, 0.64]
"openworker": [0.28, 0.21]
"Raven": [0.26, 0.28]
"MiroFish": [0.90, 0.92]
三句话总结
本系列前十一个项目都在同一条赛道上比谁的 Agent 更能干。MiroFish 换了个问题——
「如果不用一个很能干的 Agent,而是用一千个很像人的 Agent,能做什么?」
答案是:你可以在事情发生之前,先把它跑一遍。