源码解析 第十二份 commit 60757b3

在事情发生之前,先把它跑一遍

本系列前十一个项目都在回答「怎么让 AI 替我干活」。MiroFish 换了个问题: 「怎么让一群 AI 替我把事情预演一遍」。 你上传一份材料、说一句话,它自动搭出一个高保真平行世界,让成百上千个有人格、有记忆、 有行为逻辑的 Agent 在里面自由演化——最后交给你一份预测报告, 外加一个你可以走进去跟任何一个 Agent 对话的世界

69 573
star(创建于 2025-11-26,8 个月)
24 428
行 Python 业务代码(40 个文件)
≈1 550
每千行代码的 star 数 · 本系列第一
2
个重外部依赖:OASIS + Zep Cloud
Part I

它是什么

先建立一个认知:这个项目和本系列前十一个不是同类。它的技术栈、评价标准、创新点都在另一个坐标系里。

Chapter 01

8 个月 6.9 万星,它到底在卖什么

先看一组反差数字。

指标数值
Star69 573(2026-07-28 经 GitHub API 核对)
Fork · Watch10 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 颗。

这说明什么 它的价值不在代码量上。MiroFish 卖的不是工程复杂度,是一个此前没人做成产品的点子: 上传一份材料 + 一句话需求 → 得到一份预测报告 + 一个能走进去的平行世界

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.

三个关键词各自对应具体的工程事实:

关键词一
独立人格 + 长期记忆
每个 Agent 有 2 000 字人设,记忆挂在 Zep 时序图谱上,并随模拟持续生长
关键词二
上帝视角注入变量
模拟中途可以插入事件——EventConfig.scheduled_events,比如「第 24 小时官方发布通报」。
关键词三
在沙盒里彩排未来
跑完的世界不关门,可以采访、可以继续问。这决定了产品形态。

两个官方演示,说明了它的射程

README 挂了两个演示,选得很讲究:

严肃场景
武汉大学舆情模拟——用舆情报告作种子,预测事件走向
+
娱乐场景
《红楼梦》佚失结局推演——用前 80 回作种子,让贾府众人继续演
这个组合是产品叙事上很聪明的一步 一个「预测引擎」如果只能做金融和舆情,天花板是有限的。 把《红楼梦》续写也放进同一个引擎,等于宣布「任何有人物、有关系、有事件的文本,都能变成一个世界」。

官方给自己的定位一句话:「简洁通用的群体智能引擎,预测万物」。项目由盛大集团(Shanda Group)战略支持与孵化,仿真引擎明确致谢 OASIS(CAMEL-AI 团队)。

Chapter 02

技术栈全解:一张薄后端 + 两个重外部依赖

backend/requirements.txt 全文只有 12 个包。短得惊人:

类别
框架flask>=3.0.0 · flask-cors>=6.0.0
LLMopenai>=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 + 一句话
→ 报告 + 可交互世界"]
图 1 技术选型的核心判断:把最难的两件事外包出去,自己只写粘合层。这就是 24 000 行能撑起一个 6.9 万星产品的原因。

为什么必须是时序图谱

Zep 提供的不只是「A 和 B 有关系」,而是「A 和 B 在 T1 到 T2 之间有关系,之后失效了」。代码里对时间维度的使用非常明确(services/zep_tools.py:133-142):

def is_expired(self) -> bool: ...
def is_invalid(self) -> bool: ...
模拟是演化过程 第 3 轮时 A 支持 B,第 20 轮时 A 反对 B——如果记忆系统只能存「当前状态」,你就丢掉了整个演化轨迹, 而演化轨迹恰恰是预测报告最有价值的部分

一个硬约束

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/3graph(963) · simulation(2 878) · report(1 139)
services/11核心逻辑全在这,约 12 000 行
models/2project.py · task.py——纯 dataclass
utils/11llm_client · zep · retry · locale · file_parser · ontology

最大的三个 service:report_agent.py2 619 行)· simulation_runner.py2 033)· zep_tools.py1 734)。

三个细节看工程习惯

__init__.py:8-10
抑制第三方警告
「需要在所有其他导入之前设置」——resource_tracker 的警告来自 transformers 之类的库。
__init__.py:25-27
中文不转义
兼容新旧 Flask 两种写法,让 JSON 里的中文不变成 \uXXXX
__init__.py:33
避免打印两次
debug 模式下 reloader 会起两个进程,靠 WERKZEUG_RUN_MAIN 区分。

这三处都是被真实运行折磨过的痕迹。一个只写给自己看的 demo 不会处理这些。

Chapter 03

五步流水线:从一份 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
图 2 五步流水线全景。注意两条虚线回边——它们是这个架构区别于「跑个模拟然后让 LLM 总结日志」的根本所在。

前端组件名直接对应这五步:

组件行数步骤
Step1GraphBuild.vue700图谱构建
Step2EnvSetup.vue2 623环境搭建
Step3Simulation.vue1 268模拟运行
Step4Report.vue5 162报告生成(最大的前端文件)
Step5Interaction.vue2 584深度交互
GraphPanel.vue1 423d3 知识图谱可视化
Step4Report.vue 为什么有 5 162 行 因为它要实时显示 ReportAgent 的思考过程、工具调用、分章进度、控制台日志四路流。 对应后端的双路日志设计(第 12 章)。
Part II

世界是怎么建起来的

从一份文本到一批能发帖、能点赞、有人格的账号,中间隔着四道工序。这一部分是全项目最见功力的地方。

Chapter 04

本体生成:被 Zep 的 10 类上限逼出来的层次设计

提示词里最重要的一条约束

ONTOLOGY_SYSTEM_PROMPTservices/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
图 3 8 个具体 + 2 个兜底。如果 10 类全给具体类型,遇到没定义的角色就只能丢掉;全给宽泛类型,模拟就失去分辨率。

提示词自己在 :128 解释了为什么需要兜底:「文本中会出现各种人物,如"中小学教师"、"路人甲"、"某位网友"。如果没有专门的类型匹配,他们应该被归入 Person。」

这条约束在输出格式段里又重复了一遍:287-288),而且——代码里还有一道强制补齐:500-544):如果 LLM 的返回缺了兜底类型,代码自己补上。

同一条约束说了三遍 系统提示词里说、输出格式段里再说、代码里兜底强制。这是对「LLM 会漏掉指令」这件事的正确态度—— 越是下游依赖的硬约束,越不能只靠提示词

三层防御性归一化

LLM 生成的本体不能直接喂给 Zep。下面这个实验台是 utils/ontology.py 全文 + ontology_generator.py:22-45 + graph_builder.py:324-328可运行移植——不是示意图,是同一套逻辑。

LAB 01 本体归一化流水线 改左边的 JSON,看归一化层做了什么。默认样例是一份典型的脏输出:命名不规范、撞保留字、有空属性、有重复关系对、关系名以数字开头、还少了两个兜底类型。务必点一下「中文类型名 ⚠️」——那里藏着一个真实的静默数据破坏。
LLM 返回的原始本体 JSON
归一化结果与逐条改动记录
保留字:改名,不丢弃 执行点在 graph_builder.py:324-328,在动态构造 Zep EntityModel 的那一刻: summaryentity_summary

如果直接丢弃,LLM 精心设计的一个属性就凭空消失了,而且没有任何人会发现——因为下游只是少了一个字段,不会报错。 改名则保住了信息,代价只是名字不好看。在「数据丢失」和「名字难看」之间,永远选后者。
可复用的模式 凡是「LLM 生成 → 喂给外部 API」的链路,中间必须有一层归一化。它要处理四件事: 格式转换、保留字规避、去重、空值兜底。MiroFish 这一层写得相当完整。

但这一层有一个静默的数据破坏行为

点上面实验台的「中文类型名 ⚠️」样例。这不是我编的边界情况——把源码函数抠出来直接跑,结果一致:

输入_to_pascal_case_to_upper_snake_case
college studentCollegeStudent ✅COLLEGE_STUDENT ✅
universityOfficialUniversityOfficial ✅UNIVERSITY_OFFICIAL ✅
在校学生UnknownUNKNOWN
武汉大学UnknownUNKNOWN
2媒体机构2REL_2

两个函数的核心都是 [^a-zA-Z0-9]——只认 ASCII。任何中文字符都被当成分隔符整段丢掉。

后果是级联的。graph_builder.py:331 建的是一个按名字索引的字典:354 写入:

entity_types = {}                      # :331
...
entity_types[name] = entity_class      # :354
三个中文类型 → 一个 Unknown 它们全部坍塌成同一个键,后写的覆盖先写的,最后只剩一个——而且全程不抛任何异常,日志里也看不出来。 你会得到一个「成功构建」的图谱,只是里面少了两个类型。
这解释了 :228 那句 IMPORTANT 为什么必须存在 IMPORTANT: Entity type names MUST be in English PascalCase.

它不是风格要求,是承重墙。而且它就拼在 get_language_instruction() (可能正是「请使用中文回答」)的正后面——一句话让模型说中文,下一句话又要求类型名必须英文。 这条例外声明扛着整个本体流水线。

真正该做的是在归一化函数里加一道断言:if not normalized: raise,而不是 return 'Unknown'。 返回一个看起来合法的兜底值,是这类静默数据破坏最典型的成因——兜底值应该用在「缺失」上,不该用在「损坏」上。
Chapter 05

图谱构建:GraphRAG 与批量摄取

graph_builder.py(879 行)里有一个值得注意的数据结构(:52):

class BatchSubmission:
    """Durable identity for one Zep Batch API ingestion operation."""

「Durable identity」——持久化的身份标识。这说明摄取是异步、可能失败、需要追踪的:你提交一批数据给 Zep,拿到一个 ID,之后要能查它成没成。

切块参数在 config.py:41-42DEFAULT_CHUNK_SIZE = 500DEFAULT_CHUNK_OVERLAP = 50。支持 PDF(PyMuPDF)、MD、TXT,上限 50 MB。

一个面向中国用户的必备细节 依赖里同时有 charset-normalizerchardet——「支持非 UTF-8 编码的文本文件」。 GBK 文本在中文用户里非常常见。
Chapter 06

人设生成:个人与「群体代表账号」的分野

问题很直白:图谱里有 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"]
图 4 一个 lower() in [...] 的判定,分出两条完全不同的生成路径。社会事件里,机构和个人的发声逻辑完全不同:机构要考虑立场、口径、时机;个人可以情绪化。
LAB 02 人设分野判定台 输入一个实体类型,走一遍 _is_group_entity():536-538),看它落到哪条提示词分支、生成什么形态的人设、以及最后如何转成两个平台的格式。
「个人记忆」是七个维度里最关键的一条 提示词在 :754 自己标注了「人设的重要部分」,要求写出「这个个体在事件中的已有动作与反应」。

它把 Agent 和具体事件绑定起来——不是一个泛泛的「35 岁男性教师」, 而是「在这件事里已经做过什么、说过什么」的那个人。

MBTI 也不是装饰——它是 LLM 能稳定理解、并转化成行为倾向的一个压缩表示。用 MBTI 比写一段自由文字的「性格描述」更容易让模型保持一致性。

二次丰富:调 Zep 检索补充上下文

模块 docstring 列的第一条优化就是「调用 Zep 检索功能二次丰富节点信息」——从图谱拿到一个实体后,再用 Zep 的检索去捞它的相关边和上下文,然后一起喂给 LLM。所以人设里能写出「这个人在事件中的已有动作与反应」。上下文两条分支各自截断到 3 000 字(:732:781)。

三道数据清洗

:111-121
边界归一化
注释写着 "Normalize structured LLM fields once at the profile boundary"。LLM 可能把 bio 返回成 list 或 dict,统一压成字符串;空了用兜底文案。在 dataclass 构造出口做一次,后面所有消费方就不用各自防了。
:700-714
损坏 JSON 抢救
七级降级:解析失败 → 正则抢救 → 部分提取 → 完全兜底。被修复过的结果打上 "_fixed": True,上游检查完就删掉——修复痕迹只在内部流转
:762-768
格式约束
「所有字段值必须是字符串或数字,不要使用换行符」——因为未转义的换行会直接让 json.loads() 炸掉。这是踩过的坑。
Chapter 07

模拟参数:把社会学常识编码成配置

simulation_config_generator.py(993 行)是这个项目最有「领域知识」含量的文件。

先说分步生成策略

采用分步生成策略,避免一次性生成过长内容导致失败
1. 生成时间配置 2. 生成事件配置 3. 分批生成 Agent 配置 4. 生成平台配置

这是所有「让 LLM 生成大块结构化数据」场景的通用解法:拆成小块、分批要,比一次要一大坨可靠得多。

时间配置:一条中国人的作息曲线

:30-50CHINA_TIMEZONE_CONFIG,注释写着「中国作息时间配置(北京时间)」;TimeSimulationConfig:85-112)的 docstring 是「时间模拟配置(基于中国人作息习惯)」。

LAB 03 中国作息活跃度曲线 这条曲线是 MiroFish 的「隐性资产」——不是从代码推导出来的,是对中国社交媒体用户行为的经验判断。拖动看它怎么影响整场模拟的动作分布。
total_simulation_hours :88
每轮基准活跃 Agent 数
minutes_per_round = 60:91)→ 一轮 = 一小时,所以横轴的 24 格就是一天。

平台配置:三个传播学参数,两套取值

PlatformConfig:131-145)的三组参数直接对应传播学的三个核心机制:推荐权重三元组(平台算法如何决定谁看到什么)、病毒传播阈值(信息破圈的临界点)、回声室效应强度(观点极化、信息茧房)。

关键在于——两个平台的默认值是不一样的:342-360):

参数TwitterReddit这个差异说的是什么
时间新鲜度0.40.3Twitter 是时间线,新的压倒一切
热度0.30.4Reddit 是投票排序,热度权重更高
相关性0.30.3两边一样
病毒阈值1015Twitter 更容易破圈(转发机制)
回声室强度0.50.6Reddit 的 subreddit 结构天然更封闭
这五行数字是全项目最浓缩的领域知识 它们把「Twitter 快而浅、Reddit 慢而深且更抱团」这个每个人都有直觉但很难量化的判断,变成了可以驱动模拟的参数。

而且方向全对:Twitter 的转发链路确实让信息破圈门槛更低;Reddit 的社区分区确实让观点聚集更严重。这不是拍脑袋填的。

事件配置:上帝视角的注入口

字段作用
initial_posts:118初始事件。可以指定由哪个 Agent 发poster_agent_id)——安排大 V 在第 0 轮发第一条
scheduled_events:121定时事件——「第 24 小时官方发布通报」,然后看世界怎么反应
hot_topics:124热点话题关键词
narrative_direction:127舆论引导方向

narrative_direction 让这个工具不只能「预测舆情会怎么走」,还能测试某种引导策略的效果。这正是「政策和公关可以零风险试跑」的落点。

一个好习惯:记录「为什么」 SimulationParametersgeneration_reasoning 字段(:175),把 LLM 的推理过程一起存下来, 并且会序列化进最终配置 JSON(:192-193)。 当模拟结果反常时,你能回头看「当初为什么把高峰设成这几个小时」。 可解释性不是靠事后猜,是靠当时记。
Part III

世界是怎么跑起来的

两个平行世界、一条进程边界、一个闭环回边。这一部分决定了产品形态。

Chapter 08

双平台并行:世界 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
图 5 两个世界不只是换了皮肤,而是两套不同的社会物理规律Reddit 有「踩」,Twitter 没有——这一个差异就足以让两个世界的舆论极化路径完全不同。
LAB 04 双世界参数对照台 同一条帖子,在两套平台参数下会得到不同的推荐分和破圈判定。参数取自 simulation_config_generator.py:342-360,推荐分按 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 中文用户

Chapter 09

进程边界:文件系统 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: 拿到结果
    
图 6 services/simulation_ipc.py(394 行)的命令/响应模式。
方案代价
Redis / RabbitMQ多一个必须部署的中间件
Socket / 管道要处理连接生命周期、重连、跨平台差异
文件系统零依赖,跨平台,进程崩了状态还在,可以直接 cat 出来 debug
「够用就好」的一个典型案例 这个场景的 IPC 频率很低(用户点一次采访才发一条命令),延迟要求松(轮询几百毫秒完全可接受)。上 Redis 是过度设计。

但要看清代价:轮询有延迟、有磁盘 IO、没有背压机制。如果哪天要支持高频命令,这一层就得换掉。

最关键的设计:跑完不关门

run_parallel_simulation.py 头部功能列表里的这几条决定了产品形态:

· 双平台(Twitter + Reddit)并行模拟
· 完成模拟后不立即关闭环境,进入等待命令模式
· 支持通过 IPC 接收 Interview 命令
· 支持远程关闭环境命令

如果跑完就退出
你拿到的只是一堆日志和一份报告——一个死的结果
因为环境不关
可以走进去问「你为什么转发了那条帖子」、可以让报告 Agent 回头采访、可以持续交互
这是 MiroFish 最重要的一个工程决定 它把产品从「预测报告生成器」变成了「可探索的平行世界」。这两者的用户价值差了一个量级。

关闭路径有上界

# 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)——排空缓冲区也有截止时间。两处加起来构成一条完整的关闭路径:停模拟 → 排空回写队列 → 真正退出,每一步都有上界。

Chapter 10

闭环回写:模拟活动如何变回图谱记忆

这是整个架构的闭环点,也是最容易被忽略的创新。

监控模拟的 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"]
图 7 闭环。没有这条回边,报告 Agent 只能读到「模拟前的世界」。
一句话 这个闭环把「知识图谱」从一个静态的输入,变成了一个随模拟一起生长的记录。 图谱里同时有初始事实(来自你上传的材料)和模拟中发生的事, Zep 的时序能力让两者按时间线组织起来——检索时能拿到「事情原本是什么样 → 后来怎么演化的」完整链路。

这是 MiroFish 区别于「跑个多 Agent 模拟然后让 LLM 总结日志」的根本所在。
Part IV

结果是怎么取出来的

四件检索兵器、一个 ReACT 循环、三道防伪。最后一件兵器是本项目最有创新性的东西。

Chapter 11

四件检索兵器: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 去重,整合成深度洞察"]
图 8 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 环境未关闭)

❌ 让 LLM「扮演」张三回答
一个没有记忆、没有经历过模拟的临时角色
vs
✅ 采访模拟里真实运行的那个 Agent
它带着 2 000 字人设 + 整场模拟的经历回答

四步流程: 读人设文件(_load_agent_profiles:1503)→ LLM 选人(:1549,返回 selection_reasoning——为什么选这几个)→ LLM 生成问题(:1632)→ /api/simulation/interview/batch双平台同时采访

把「模拟」变成「可查询数据源」的关键一步 别的多 Agent 项目跑完就把日志丢给 LLM 总结;MiroFish 让报告 Agent 回头去问当事人

对做新产品的启发:如果你的系统里跑出了一个有状态的过程, 不要只保留它的输出,想办法保留「可以继续问它问题」的能力。前者是报告,后者是资产。

另有一处降级值得记:search_graph():457)在 Zep 语义搜索不可用时会退回 _local_search():542)做本地关键词匹配。检索质量当然下降,但报告不会因为一次网络抖动就整章空白

Chapter 12

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
    
图 9 为什么要分章生成:一份完整报告可能上万字,一次性生成必然质量下降或截断。分章 + 每章独立 ReACT,每章都能针对性检索,而且每章单独落盘到 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> 也编出来了。如果不剥掉就进消息历史,模型会以为那是真的检索结果,后面全建立在幻觉上。

LAB 05 伪造工具结果剥离器 这是 report_agent.py:1144-1174逐行移植——同一个深度计数扫描器、同一条畸形标签兜底正则。四个样例分别对应:普通伪造、嵌套伪造、永不闭合的畸形开标签、以及一个老实的模型。
LLM 的原始响应
扫描轨迹与剥离结果

另外两道是 _is_valid_tool_call():1120,执行前校验结构与工具名)和 zep_tools._clean_tool_call_response()zep_tools.py:1483)。三道加起来覆盖一个完整链路:进来的调用要合法 → 出去的响应要干净 → 模型自己编的结果要剥掉

很少有项目把这件事显式处理掉 「模型会伪造工具结果」是所有 ReACT 实现都会遇到的问题。MiroFish 不仅处理了,还考虑了嵌套畸形标签两种边界—— 上面那个实验台里,「畸形开标签」那个样例会让 depth 停在 1,于是尾部整段都不会被追加。

一个对抗「工具偏食」的细节

# :1495
if unused_tools and tool_calls_count < self.MAX_TOOL_CALLS_PER_SECTION:

当这一章还没用满额度、而且有工具一次都没被用过时,系统会主动提示模型去试试。这是在对抗一个很常见的毛病:模型倾向于反复用它熟悉的那一个工具(通常是最简单的 quick_search),而不去碰 insight_forgeinterview_agents

双路日志

:36 ReportLogger
agent_log.jsonl
每行一个完整 JSON。给程序读——前端要渲染「思考 → 调用工具 → 得到结果」的时间线。
:307 ReportConsoleLogger
console_log.txt
控制台风格(INFO/WARNING)。给读——排查问题时直接 tail -f

两者都提供流式接口(:2114 / :2052)和 from_line 分页参数,前端靠它做增量拉取而不是每次全量重取。这解释了为什么 Step4Report.vue 有 5 162 行。

Chapter 13

深度交互:走进世界跟 Agent 说话

交互对象能干什么
世界里的任何 Agent直接对话——它带着人设和整场模拟的经历回答
ReportAgent追问报告内容,它会自主调用检索工具chat() :1810MAX_TOOL_CALLS_PER_CHAT = 2
产品闭环的最后一块 大多数「AI 生成报告」类产品到第四步就结束了。MiroFish 多做的这一步, 把交付物从「一份文档」变成了「一个可以持续追问的对象」。

这是很重要的差别——用户对报告的信任,往往建立在「我能追问细节并得到一致回答」上。
Part V

评价与启发

创新到底在哪、哪里做得好、哪里是硬伤,以及——如果你要做一个新产品,能从这里拿走什么。

Chapter 14

创新性到底在哪:五个真正新的东西

判定标准:在本系列十一个项目里是否见过

① 把「预测」变成「模拟」,而不是「推理」

推理式预测
把材料喂给 LLM,让它直接说「我认为会怎样」

→ 给你一个结论,你只能选择信或不信
vs
模拟式预测
构建一个世界,让它自己演化,观察结果

→ 给你一个过程:谁在第几轮说了什么、信息怎么扩散、哪个节点是转折点。而且能改变量重跑

这不是新的学术思想(社会仿真研究几十年了),但把它做成一个上传 PDF 就能用的产品,是新的

② 知识图谱 ↔ 模拟的双向闭环

flowchart LR
    A["种子材料"] -->|本体+摄取| G[("Zep 时序图谱")]
    G -->|实体→人设| S["OASIS 模拟"]
    S -->|活动带原文回写| G
    G -->|GraphRAG 检索| R["报告"]
    S -->|采访活着的 Agent| R
    
图 10 两条回边是关键。本系列里的记忆系统(openworker 的 SQLite 事实、MiMo 的 FTS5、Raven 的 EverOS)都是单向的:Agent 写记忆、Agent 读记忆。MiroFish 的图谱是被一整个模拟世界共同书写的。

③ 采访活着的 Agent

跑完不关门 + IPC 采访通道 + LLM 选人和拟题,三者加起来才有这个能力。「非 LLM 模拟」那五个字是它和「让模型扮演一下」的分界线。

④ 把社会科学参数显式产品化

回声室效应强度、病毒传播阈值、推荐算法三权重、中国人作息的五段曲线——这些是领域知识,不是代码。而且是分平台的两套取值

本系列其他项目的「配置」都是工程参数(超时、并发、重试)。MiroFish 的核心配置是社会学参数

这意味着它的竞争壁垒在别处 抄它的代码很容易(24 000 行),但抄不走「凌晨活跃度应该设 0.05、22 点到 23 点应该有一级中间台阶」这类经验。

⑤ 个人实体与群体实体的人设分野

给「武汉大学」生成一个机构官号运营设定,而不是硬塞一个「武汉大学先生,45 岁」——这是很实际的一个洞察。社会事件里,机构和个人的发声逻辑完全不同,用两套提示词分开处理,模拟出来的舆论场才像真的。

Chapter 15

工程质量:亮点与硬伤

亮点

亮点证据
测试覆盖有重点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 种,每种带 labelllmInstruction。而 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."
多语言 LLM 应用最容易踩的坑,也是最重要的一条纪律 自然语言字段跟随用户语言,枚举值、类型名、字段名永远是英文。

否则你让模型「用法语回答」,它会把 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

硬伤

① 只能用 Zep Cloud,且强制 数据必须出境到第三方 · 免费额度有限 · 服务挂了整个产品不可用 · 对企业客户(政务、金融)几乎是致命的。
② 成本没有护栏 一次模拟 = 数百个 Agent × 数十轮 × 每轮 LLM 调用。README 的 .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。
⑤ 本体归一化会静默破坏非 ASCII 类型名第 4 章[^a-zA-Z0-9] 把中文整段吃掉,多个中文类型坍塌成同一个 Unknown 后在 entity_types 字典里互相覆盖,不抛异常、不打日志

这是全文唯一一处用可运行复现验证出来的缺陷——上面 LAB 01 的「中文类型名」样例就是它。 目前唯一的防线是提示词里的 IMPORTANT 声明,而它正好拼在「请使用中文回答」后面。
⑥ 无持久化数据库 ⑦ AGPL-3.0 models/ 只有 dataclass,状态靠 JSON 文件落盘——没有并发控制、没有事务、多实例部署会打架。

AGPL 是本系列唯一——这是刻意的商业防御:你可以自用,但改了拿去做 SaaS 必须开源。对想商用的团队是门槛,对项目自己是保护。
Chapter 16

对做新产品的十二条启发

这一章是全文的落点。

01把最难的部分外包,只写粘合层
MiroFish 用 24 000 行撑起 6.9 万星,靠的是:社会仿真 → OASIS,时序记忆 → Zep Cloud,模型 → 任意 OpenAI 兼容 API。它自己只写「怎么把这三样接起来,并且让普通人能用」。
反过来想
如果这个团队先花半年自研仿真引擎和图数据库,很可能到今天还没发布。先验证点子,再考虑替换依赖。
02领域常识就是护城河
回声室强度、病毒阈值、凌晨 0.05 倍活跃度——这些数字抄不走
怎么用
如果你要做模拟/预测类产品,先想清楚:你的领域里有哪些「老手才知道」的经验参数?把它们显式化、可配置化(而不是散落在代码里的魔数),就是产品的核心资产。
03上游用提示词约束下游的形态要求
本体生成时明确列出「不可以是:抽象概念 / 主题 / 观点」——因为下游要把每个实体变成社交账号。
为什么
在流水线的第一步就把不合规的东西挡住,比在第五步做过滤便宜得多。而且列反例比说正面要求有效
04约束是设计的助产士
Zep 限制 10 类实体 → 逼出了「8 个具体 + 2 个兜底」的层次设计。这个设计比不受限时更好——它同时保证了分辨率和覆盖率。
下次遇到外部限制
先别急着绕过去,想想它是不是在逼你做一个更好的设计。
05LLM 生成结构化数据:分步 + 归一化 + 多级降级
三件套,缺一不可。分步:拆成时间/事件/Agent分批/平台四步。归一化:格式转换 + 保留字规避 + 去重 + 空值兜底。降级:七级降级,最差也给出可用的基础结构。
06让过程可查询,而不只是可读
「跑完不关门」+ 采访 API = 把一次性的模拟变成了可持续查询的数据源。
这条最值钱
如果你的系统里跑出了一个有状态的过程,不要只保留它的输出。想办法保留「能继续问它」的能力。报告是消耗品,可交互的世界是资产。
07记录「为什么」,不只记录「是什么」
generation_reasoningselection_reasoningagent_log.jsonl——决策时就把理由存下来。事后想解释「当初为什么这么配」,靠猜是猜不出来的。
08防模型伪造工具结果
_strip_fake_tool_results() 处理的是一个所有 ReACT 实现都会遇到但很少人显式处理的问题。
纪律
任何让模型输出结构化标记的系统,都要假设它会伪造。而且要处理嵌套和畸形标签。
09够用就好:文件系统 IPC 的价值
低频、低延迟要求的进程间通信,文件系统完全够用,而且零依赖、跨平台、崩了状态还在、能直接 cat 出来 debug。
上 Redis 之前
先问一下这个场景的 QPS 是多少。
10多语言 LLM 应用的铁律
只有自然语言字段跟随用户语言:description / summary / 人物设定 / 报告正文 / 界面文案。

永远英文:类型名(PersonEntity)、枚举值(supportive / male)、JSON 字段名、数值。
为什么要从第一行代码就立起来
否则 bug 只在非默认语言下出现,几乎不可能在测试里发现。
11一张注册表驱动全栈 i18n
locales/languages.json 同时被前端 Vite glob 和后端 os.listdir 读取,一处新增语言,前端菜单 / 后端日志 / LLM 输出语言同时生效。而且取的是注册表 ∩ 实际翻译文件的交集,不会出现「菜单里有但选了没用」。
成本
总共不到 80 行代码。值得直接抄。
12产品叙事的射程决定天花板
严肃场景(舆情、金融、政策)+ 娱乐场景(推演《红楼梦》结局)用同一个引擎——这个组合让「预测万物」这个宏大的口号变得可信。
一句话
一个只能做 A 的工具,和一个能做 A 也能做 B 的引擎,估值不在一个量级。关键是找到那个能证明「通用性」的、出人意料的 B。
Chapter 17

它在本系列里的位置

维度本系列前十一个MiroFish
核心问题怎么让 AI 替我干活怎么让一群 AI 替我预演
Agent 数量1 个主 + 少量子 agent成百上千个,平等互动
Agent 的目的完成任务表现得像那个人
成功标准任务做对了演化出的宏观现象像真的
循环形态ReAct(想→做→观察)社会演化(发帖→被推荐→被互动→影响他人)
记忆Agent 自己读写一整个世界共同书写的时序图谱
交付物代码 / 文件 / 设计稿一份报告 + 一个可以走进去的世界
代码量数万到数十万行24 000 行 Python
护城河工程复杂度领域常识参数 + 产品点子
许可多为 MIT / ApacheAGPL-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]
    
图 11 右上角只有 MiroFish 一个。这不是说它更好,是说它在回答另一个问题

三句话总结

它是什么
把学术界做了几十年的社会仿真,包装成产品
技术栈是 Flask + Vue + OASIS + Zep Cloud + 任意 OpenAI 兼容模型,自研代码只有 24 000 行 Python。
创新在哪
五个
① 用模拟而非推理来预测 ② 图谱与模拟的双向闭环 ③ 能采访活着的 Agent ④ 把社会科学参数做成产品配置 ⑤ 个人与机构人设分野
最值得学什么
三句
把最难的部分外包,把领域常识变成护城河,把一次性的过程变成可持续查询的资产

本系列前十一个项目都在同一条赛道上比谁的 Agent 更能干。MiroFish 换了个问题——

「如果不用一个很能干的 Agent,而是用一千个很像人的 Agent,能做什么?」

答案是:你可以在事情发生之前,先把它跑一遍。

最后一句 这个答案对不对,8 个月 6.9 万星给了一个初步的市场判断。但它真正的启发在于: 当所有人都在优化「单个 Agent 的能力上限」时,「很多个平庸 Agent 的涌现行为」可能是一片没人认真做过的空地。