Subject: MiroFish (
666ghj/MiroFish), against commit60757b3(2026-07-23 04:47 UTC,main). License AGPL-3.0. Code scale: repo 128 blobs / 9.05 MB (of which 6.1 MB isstatic/screenshot assets—10 png + 4 jpeg). The real code is 62 Python files (1.09 MB)—of which backend business code 40 / 24 428 lines, tests 21 / 4 719 lines (backend/tests/accounts for 18 / 3 359)—plus 16 Vue files (533 KB / 20 461 lines), plus 631 i18n leaf keys × Chinese/English bilingual. Community scale: 69 573 stars / 10 868 forks / 430 watch / 105 open issues (checked via GitHub API on 2026-07-28), repo created 2025-11-26—8 months. Shanda Group strategic incubation; the team is hiring full-time/interns (mirofish@shanda.com). One-sentence position: “A simple, general swarm-intelligence engine that predicts everything.” You upload a seed (news, a policy draft, a financial report, even a novel) and say in natural language what you want predicted; it automatically builds a high-fidelity parallel digital world, lets thousands of agents with independent personalities, long-term memory, and behavioral logic interact freely and socially evolve inside it, and finally hands you a prediction report—plus a world you can walk into and talk to any agent. Audience: a reader who has already read any analysis in this series. But first establish a cognitive frame: this project and the previous eleven are not the same kind. The previous eleven all answer “how to let AI do the work for me”; MiroFish answers “how to let a swarm of AIs rehearse the thing for me.” Its stack, evaluation criteria, and points of innovation sit in another coordinate system.
Contents
Part I · What it is 1. Project overview: 8 months, 69k stars—what is it actually selling 2. Full stack: a thin backend + two heavy external dependencies 3. Five-step pipeline: from one PDF to a world you can talk to
Part II · How the world is built 4. Step one: ontology generation—the layered design forced by Zep’s 10-type cap 5. Step two: graph construction—GraphRAG and batch ingest 6. Step three: persona generation—the split between individuals and “group-representative accounts” 7. Step four: simulation parameters—encoding sociological common sense as config
Part III · How the world runs 8. Dual platforms in parallel: World 1 and World 2 9. Process boundary: filesystem IPC and “don’t close the door when it finishes” 10. Closed-loop writeback: how simulated activity becomes graph memory
Part IV · How the result is taken out 11. Four retrieval weapons: InsightForge / Panorama / Quick / Interview 12. ReportAgent: ReACT section-by-section generation and three anti-spoof layers 13. Deep interaction: walk into the world and talk to an agent
Part V · Evaluation 14. Where the novelty actually is: five genuinely new things 15. Engineering quality: highlights and hard flaws 16. Twelve inspirations for building a new product 17. Its place in this series
Chapter 1 · Project overview: 8 months, 69k stars—what is it actually selling
1.1 First look at a set of contrast numbers
| Metric | Value |
|---|---|
| Stars | 69 573 |
| Forks | 10 868 |
| Watch | 430 |
| Repo age | 8 months (2025-11-26 → last push 2026-07-23) |
| Real code volume | Python business 24 428 lines + Vue 20 461 lines = 44 889 lines |
| Open issues | 105 |
| Primary-language tag | Python |
| License | AGPL-3.0 (the only AGPL project in this series) |
About 1 550 stars per thousand lines of code—this ratio is a cliff-edge first in this series. As contrast: Open Design about 20 stars per thousand lines, MiMo Code about 3.
What does this mean? It means its value is not in code volume. What MiroFish sells is not engineering complexity; it is an idea nobody had previously turned into a product:
Upload one seed + one-sentence requirement → get a prediction report + a parallel world you can walk into.
1.2 The most important sentence in the 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 — rehearse the future in a digital sandbox, and win decisions after countless simulations.
Unpack three keywords:
| Keyword | Engineering meaning |
|---|---|
| Independent personality + long-term memory | Each agent has a 2 000-character persona + memory hung on Zep’s temporal graph (Chapter 6, Chapter 10) |
| God’s-eye-view variable injection | Mid-simulation you can insert events (EventConfig.scheduled_events, Chapter 7) |
| Rehearse the future in a digital sandbox | The finished world does not close the door; you can interview, you can keep asking (Chapter 9, Chapter 13) |
1.3 Two official demos, showing its range
The README hangs two Bilibili demos, chosen with care:
- Wuhan University public-opinion simulation—a serious scene. Uses a public-opinion report generated by another project (BettaFish) as seed, predicts how the event will go.
- Deduction of Dream of the Red Chamber’s lost ending—an entertainment scene. Uses the several hundred thousand characters of the first 80 chapters as seed, lets the Jia household keep performing in the digital world, and deducts the last 40 chapters.
The span of these two examples is the product’s position:
From serious predictions to playful simulations, we let every "what if" see its outcome.
- Macro: a rehearsal lab for decision-makers; policy and PR can be trial-run at zero risk
- Micro: a creative sandbox for individual users; deducting a novel ending is also fine
🧠 One sentence: a “prediction engine” that can only do finance and public opinion has a limited ceiling; putting a Dream of the Red Chamber continuation into the same engine is equivalent to announcing that “any text that has characters, relations, and events can become a world.” This is a very clever step in product narrative.
1.4 Who is making it
- Shanda Group strategic support and incubation—the README hangs the Shanda logo
- Site
mirofish.ai, with Discord / X / Instagram / QQ groups - Hiring full-time and interns (
mirofish@shanda.com) - The simulation engine explicitly thanks OASIS (the CAMEL-AI team)
Chapter 2 · Full stack: a thin backend + two heavy external dependencies
2.1 The complete stack
┌─ 前端 ─────────────────────────────────────────────┐
│ Vue 3.5 + Vite 7 + vue-router + vue-i18n │
│ d3 7.9(知识图谱可视化)· axios │
│ 16 个 .vue / ~16 000 行,最大 Step4Report.vue 5162 行 │
└────────────────────────────────────────────────────┘
│ HTTP /api/*
┌─ 后端 ─────────────────────────────────────────────┐
│ Flask 3 + flask-cors(应用工厂模式) │
│ 业务代码 40 个 .py / 24 428 行 │
│ ├─ api/ 3 个蓝图:graph · simulation · report │
│ ├─ services/ 11 个服务(核心逻辑全在这) │
│ ├─ models/ project · task │
│ ├─ utils/ llm_client · zep · retry · locale … │
│ └─ scripts/ 模拟预设脚本(子进程跑) │
└────────────────────────────────────────────────────┘
│ │
┌────▼─────┐ ┌─────▼──────┐
│ Zep Cloud│ │ OASIS │
│ 时序知识 │ │ camel-oasis│
│ 图谱记忆 │ │ 0.2.5 │
│ v3.25.0 │ │ camel-ai │
└──────────┘ │ 0.2.78 │
└────────────┘
│
┌────▼──────────────────────────────┐
│ 任意 OpenAI 格式 LLM │
│ 推荐阿里百炼 qwen-plus │
└───────────────────────────────────┘
The dependency list is astonishingly short (the whole of backend/requirements.txt has only 12 packages):
| Category | Package |
|---|---|
| Framework | flask>=3.0.0 · flask-cors>=6.0.0 |
| LLM | openai>=1.0.0 (everything goes through OpenAI format) |
| Memory | zep-cloud==3.25.0 · httpx>=0.27.0 |
| Simulation | camel-oasis==0.2.5 · camel-ai==0.2.78 |
| Files | PyMuPDF · charset-normalizer · chardet |
| Tools | python-dotenv · pydantic>=2.0.0 |
No LangChain (although report_agent.py’s docstring says “uses LangChain + Zep,” it is not in the actual dependencies—this is one inconsistency between docs and implementation; the ReACT loop is hand-written, see Chapter 12). No vector database (Zep handles it itself). No message queue (filesystem IPC, see Chapter 9). No ORM (models/ is pure dataclass + JSON on disk).
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 + 一句话
→ 报告 + 可交互世界"]
style OUT fill:#eef4ff
style OWN fill:#fff4ec
🧠 The core judgment of the tech choice: outsource the two hardest things—social simulation to OASIS, temporal memory to Zep Cloud. Write yourself only “the layer that glues them together.” That is why 24 000 lines can hold up a 69k-star product.
2.2 Two heavy external dependencies, each solving what
OASIS (camel-ai): social-simulation engine
OASIS = Open Agent Social Interaction Simulations. What it provides:
- A simulated social-platform environment (Twitter-type / Reddit-type)
- Agents’ action space in it
- Recommendation algorithm, time advance, interaction records
MiroFish declares the two platforms’ action sets in backend/app/config.py:49-56:
OASIS_TWITTER_ACTIONS = [
'CREATE_POST', 'LIKE_POST', 'REPOST', 'FOLLOW', 'DO_NOTHING', 'QUOTE_POST'
]
OASIS_REDDIT_ACTIONS = [
'LIKE_POST', 'DISLIKE_POST', 'CREATE_POST', 'CREATE_COMMENT',
'LIKE_COMMENT', 'DISLIKE_COMMENT', 'SEARCH_POSTS', 'SEARCH_USER',
'TREND', 'REFRESH', 'DO_NOTHING', 'FOLLOW', 'MUTE'
]
Note that Reddit’s action set is obviously richer (13 vs 6)—it adds downvote, comment, search, trend, refresh, mute. This is not casual config; it is the difference of the two platforms’ real interaction forms (see Chapter 8).
Zep Cloud: temporal knowledge-graph memory
Zep provides a knowledge graph with a time dimension—not just “A and B have a relation,” but “A and B had a relation between T1 and T2, and after that it expired.”
Use of this time dimension in the code is very explicit (services/zep_tools.py:133-142):
def is_expired(self) -> bool: ...
def is_invalid(self) -> bool: ...
EdgeInfo has to_text(include_temporal: bool = False) (:117); the positioning of panorama_search is “get the full picture, including expired content.”
📌 Why it must be a temporal graph: simulation is an evolutionary process. In round 3 A supports B; in round 20 A opposes B—if the memory system can only store “the current state,” you lose the entire evolutionary trajectory, and the evolutionary trajectory is precisely the most valuable part of the prediction report.
One hard constraint (config.py:71-72, inside Config.validate()):
if os.environ.get("ZEP_API_URL"):
errors.append("ZEP_API_URL 不受支持;MiroFish 仅连接 Zep Cloud")
Zep Cloud only; self-hosting is not supported. It errors at startup. This is a very strong product decision—Chapter 15 will discuss its cost.
2.3 Backend layering
backend/app/ has only four layers, very flat:
| Layer | File count | What it does |
|---|---|---|
api/ |
3 | Flask blueprints: graph (963 lines) · simulation (2 878 lines) · report (1 139 lines) |
services/ |
11 | All core logic is here, about 12 000 lines total |
models/ |
2 | project.py · task.py—pure dataclass |
utils/ |
11 | llm_client · zep · retry · locale · file_parser · ontology … |
The three largest services:
| File | Lines | Duty |
|---|---|---|
report_agent.py |
2 619 | ReACT report generation + report management |
simulation_runner.py |
2 033 | Run simulation in the background, parse logs, query status |
zep_tools.py |
1 734 | Four retrieval weapons + seven base tools |
2.4 Application assembly: three details that show engineering habits
app/__init__.py:33 has a stretch that handles Flask debug mode printing twice:
# 只在 reloader 子进程中打印启动信息(避免 debug 模式下打印两次)
is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
And :8-10:
# 抑制 multiprocessing resource_tracker 的警告(来自第三方库如 transformers)
# 需要在所有其他导入之前设置
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
And :25-27—so Chinese is not escaped into \uXXXX, while remaining compatible with old and new Flask:
# Flask >= 2.3 使用 app.json.ensure_ascii,旧版本使用 JSON_AS_ASCII 配置
if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'):
app.json.ensure_ascii = False
These three places are all traces of “having been tortured by real runs.” A demo written only for yourself would not handle these.
Chapter 3 · Five-step pipeline: from one PDF to a world you can talk to
3.1 Panorama
flowchart TB
U["用户上传种子材料
PDF / MD / TXT(≤50MB)
+ 一句话预测需求"] --> S1
subgraph S1["① 图谱构建 Step1GraphBuild"]
O["ontology_generator
LLM 设计 10 类实体 + 6-10 类关系"]
G["graph_builder
Zep Batch API 摄取"]
O --> G
end
subgraph S2["② 环境搭建 Step2EnvSetup"]
E["zep_entity_reader
读图谱节点 + 按本体过滤"]
P["oasis_profile_generator
实体 → 2000 字人设"]
C["simulation_config_generator
LLM 分步生成模拟参数"]
E --> P --> C
end
subgraph S3["③ 模拟 Step3Simulation"]
M["simulation_manager
准备双平台文件"]
R["simulation_runner
后台子进程跑 OASIS"]
W["zep_graph_memory_updater
活动实时回写图谱"]
M --> R --> W
W -.闭环.-> R
end
subgraph S4["④ 报告生成 Step4Report"]
RA["report_agent
ReACT 分章生成"]
ZT["zep_tools
四件检索兵器"]
RA <--> ZT
end
subgraph S5["⑤ 深度交互 Step5Interaction"]
I1["跟世界里任何 Agent 对话"]
I2["跟 ReportAgent 追问"]
end
S1 --> S2 --> S3 --> S4 --> S5
S3 -.环境不关门.-> S5
W -.图谱.-> ZT
3.2 The frontend is exactly these five steps
The filenames under frontend/src/components/ correspond directly to the pipeline:
| Component | Lines | Corresponding step |
|---|---|---|
Step1GraphBuild.vue |
700 | Graph construction |
Step2EnvSetup.vue |
2 623 | Environment setup |
Step3Simulation.vue |
1 268 | Simulation run |
Step4Report.vue |
5 162 | Report generation (the largest frontend file) |
Step5Interaction.vue |
2 584 | Deep interaction |
GraphPanel.vue |
1 423 | d3 knowledge-graph visualization |
HistoryDatabase.vue |
1 348 | History records |
📌
Step4Report.vue5 162 lines—the frontend complexity of the report step far exceeds the other steps. Because it has to display in real time ReportAgent’s thinking process, tool calls, section progress, console log, four streams (Chapter 12).
3.3 Three API blueprints
app/__init__.py:67-69:
app.register_blueprint(graph_bp, url_prefix='/api/graph')
app.register_blueprint(simulation_bp, url_prefix='/api/simulation')
app.register_blueprint(report_bp, url_prefix='/api/report')
simulation.py 2 878 lines is the largest API file—because this step has the most states (prepare / run / pause / stop / interview / query status / fetch logs).
Chapter 4 · Step one: ontology generation—the layered design forced by Zep’s 10-type cap
4.1 The task
The module docstring of ontology_generator.py:
本体生成服务 接口1:分析文本内容,生成适合社会模拟的实体和关系类型定义
That is: read through the material you uploaded, design which kinds of “people” and which kinds of “relations” this world has.
4.2 The most important constraint in the prompt
ONTOLOGY_SYSTEM_PROMPT (from services/ontology_generator.py:48 all the way to :190) sets the standard dead at the opening. The stretch at :70 is the key:
因此,实体必须是现实中真实存在的、可以在社媒上发声和互动的主体:
可以是:具体的个人 / 公司企业 / 组织机构 / 政府部门 / 媒体机构 / 社交媒体平台本身 / 特定群体代表
不可以是: - 抽象概念(如"舆论"、"情绪"、"趋势") - 主题/话题(如"学术诚信"、"教育改革") - 观点/态度(如"支持方"、"反对方")
This constraint is the premise of the whole system being able to run. Because the next step turns every entity into a social account that can post, like, and interact—something like “public opinion” cannot open an account.
🔑 General inspiration: when a downstream stage of your pipeline has a hard shape requirement, the most effective practice is to explicitly list the non-compliant shapes in the upstream prompt and ban them, rather than filter downstream. Listing counterexamples (“cannot be”) is far more effective than only stating the positive requirement.
4.3 The “catch-all type” design forced by the 10-type cap
utils/ontology.py:6:
MAX_ONTOLOGY_TYPES = 10
This is a Zep server-side limit. So a stretch like this appears in the prompt (ontology_generator.py:113-136):
数量要求:必须正好 10 个实体类型
层次结构要求(必须同时包含具体类型和兜底类型):
A. 兜底类型(必须包含,放在列表最后 2 个): -
Person: 任何自然人个体的兜底类型。当一个人不属于其他更具体的人物类型时,归入此类。 -Organization: 任何组织机构的兜底类型。当一个组织不属于其他更具体的组织类型时,归入此类。B. 具体类型(8 个,根据文本内容设计)
(
:136)description 必须清晰说明这个类型和兜底类型的区别
Why catch-all types are needed (the prompt itself explains at :128):
文本中会出现各种人物,如"中小学教师"、"路人甲"、"某位网友"。如果没有专门的类型匹配,他们应该被归入
Person。
And this constraint is repeated once more in the output-format section (:287-288):
1. 必须正好输出10个实体类型
2. 最后2个必须是兜底类型:Person(个人兜底)和 Organization(组织兜底)
More important: the code also has a forced fill-in. ontology_generator.py:500-544 checks the LLM’s return; if a catch-all type is missing it adds it itself:
# 兜底类型定义 (:500)
# 检查是否已有兜底类型 (:521)
# 需要添加的兜底类型 (:526)
# 添加兜底类型 (:544)
📌 The same constraint said three times: in the system prompt, again in the output-format section, and forced as a fill-in in code. This is the correct attitude toward “the LLM will drop instructions”—the more a hard constraint is depended on downstream, the less you can rely on the prompt alone.
flowchart TB
T["10 个实体类型(Zep 硬上限)"] --> A["8 个具体类型
从文本里识别的高频关键角色
如 Student / Professor / University"]
T --> B["2 个兜底类型(固定放最后)
Person —— 任何自然人
Organization —— 任何组织"]
A -.匹配不上时.-> B
🧠 This is a pretty example of “constraint-driven design”: Zep only lets you define 10 types, so 8 specific + 2 catch-all—it both covers this material’s particularity and guarantees every leftover fish has a home. If all 10 types are given to specific types, a role that was not defined can only be dropped; if all are given to broad types, the simulation loses resolution.
4.4 Three layers of defensive normalization
An LLM-generated ontology cannot be fed to Zep directly; utils/ontology.py does three things:
① Name normalization (ontology_generator.py:22, :35):
def _to_pascal_case(name): # works_for → WorksFor (:22,实体类型用)
def _to_upper_snake_case(name): # camelCase → CAMEL_CASE (:35,关系类型用)
_to_upper_snake_case also handles the case of starting with a digit (:42-43):
if normalized[0].isdigit():
normalized = f"REL_{normalized}"
Because an identifier cannot start with a digit. Call sites at :465 (entities) and :585 (relations).
⚠️ These two functions have a silent data-corruption behavior
The core of both is [^a-zA-Z0-9] — they only recognize ASCII letters and digits. Any Chinese character is treated as a separator and the whole run is dropped. Measured (the source functions scooped out and run directly):
| Input | _to_pascal_case |
_to_upper_snake_case |
|---|---|---|
college student |
CollegeStudent ✅ |
COLLEGE_STUDENT ✅ |
universityOfficial |
UniversityOfficial ✅ |
UNIVERSITY_OFFICIAL ✅ |
在校学生 |
Unknown ❌ |
UNKNOWN ❌ |
媒体机构 |
Unknown ❌ |
UNKNOWN ❌ |
武汉大学 |
Unknown ❌ |
UNKNOWN ❌ |
2媒体机构 |
2 ❌ |
REL_2 ❌ |
3RD_PARTY_MENTION |
3rdPartyMention |
REL_3_RD_PARTY_MENTION |
The consequence is cascading. graph_builder.py:331 builds a dictionary indexed by name, written at :354:
entity_types = {} # :331
...
entity_types[name] = entity_class # :354
So if the LLM returns three Chinese entity types, they all collapse into the same Unknown, later writes overwrite earlier ones, and only one remains—and no exception is thrown anywhere along the way, nor is it visible in the logs. You get a graph that “built successfully,” only with two types missing.
🔑 This explains why the IMPORTANT sentence at
ontology_generator.py:228must exist:
IMPORTANT: Entity type names MUST be in English PascalCase. Relationship type names MUST be in English UPPER_SNAKE_CASE.It is not a style requirement; it is a load-bearing wall. And it is spliced immediately after
get_language_instruction()(which may be “请使用中文回答”)—one sentence tells the model to speak Chinese, the next requires type names to be English. This exception declaration is carrying the entire ontology pipeline.What should really be done is add an assertion in the normalize functions:
if not normalized: raiserather thanreturn 'Unknown'. Returning a fallback that looks legitimate is the most typical cause of this class of silent data corruption—a fallback value should be used for “missing,” not for “corrupted.”
② Reserved-word collision: do not drop, rename
The reserved-word list is defined in utils/ontology.py:9-17:
RESERVED_ONTOLOGY_ATTRIBUTE_NAMES = frozenset({
"uuid", "name", "group_id", "graph_id",
"name_embedding", "summary", "created_at",
})
But execution is at graph_builder.py:324-328, at the moment of dynamically constructing a Zep EntityModel:
def safe_attr_name(attr_name: str) -> str:
"""将保留名称转换为安全名称"""
if attr_name.lower() in RESERVED_ONTOLOGY_ATTRIBUTE_NAMES:
return f"entity_{attr_name}"
return attr_name
🔑 Note that what it does is “rename” rather than “drop”:
summary→entity_summary; the attribute is still there, just under a non-colliding name.If you dropped it directly, an attribute the LLM carefully designed would vanish into thin air, and nobody would notice—because downstream is only missing one field, and will not error. Renaming keeps the information; the cost is only an ugly name. Between “data loss” and “ugly name,” always pick the latter.
The prompt also tells the LLM explicitly (ontology_generator.py:147):
注意:属性名不能使用
name、uuid、group_id、graph_id、created_at、summary(这些是系统保留字)
Said in the prompt + rename as fallback before persist, again both layers.
③ Empty-value fallback (utils/ontology.py:19-23):
_FALLBACK_ATTRIBUTE = {
"name": "details", "type": "text",
"description": "Additional details about this ontology type.",
}
normalize_ontology_attributes() (:52-70), if after filtering not one attribute remains, stuffs a details in (:68)—because Zep requires a non-empty attribute list; and at :64 it truncates by MAX_ONTOLOGY_ATTRIBUTES = 10.
normalize_ontology_source_targets() (:73) also does dedup and truncate to MAX_ONTOLOGY_SOURCE_TARGETS = 10.
📌 Pattern: on any “LLM generates → feed an external API” chain, there must be a normalization layer in the middle. It has to handle four things: format conversion, reserved-word avoidance, dedup, empty-value fallback. MiroFish’s layer here is written quite completely.
Chapter 5 · Step two: graph construction—GraphRAG and batch ingest
The docstring of graph_builder.py (879 lines):
图谱构建服务 接口2:使用 Zep API 构建 Standalone Graph
5.1 A data structure worth noticing
graph_builder.py:52:
class BatchSubmission:
"""Durable identity for one Zep Batch API ingestion operation."""
“Durable identity”—a persisted identity. This shows ingest is asynchronous, may fail, needs tracking: you submit a batch of data to Zep, get an ID, and afterwards must be able to check whether it succeeded.
Corresponding is GraphInfo (:35) saving graph information.
5.2 Text chunking
config.py:41-42:
DEFAULT_CHUNK_SIZE = 500 # 默认切块大小
DEFAULT_CHUNK_OVERLAP = 50 # 默认重叠大小
utils/file_parser.py provides split_text_into_chunks(), supporting PDF (PyMuPDF), MD, TXT, cap 50 MB.
Encoding handling is very practical: the dependencies have both charset-normalizer and chardet—“support text files that are not UTF-8 encoded.” This is required for Chinese users (GBK text is common).
Chapter 6 · Step three: persona generation—the split between individuals and “group-representative accounts”
This chapter is the place in the whole system that most shows craft.
6.1 The problem
The graph has Student (a student) and also University (a university). The former is a person, the latter an institution. But on social media, both need an account.
How do you generate a persona for “Wuhan University”?
6.2 The solution: two prompt sets
services/oasis_profile_generator.py:238-241 defines a group-type list:
GROUP_ENTITY_TYPES = [
"university", "governmentagency", "organization", "ngo",
"mediaoutlet", "company", "institution", "group", "community"
]
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
karma"]
N --> T["to_twitter_format() :151
follower_count 等"]
style I2 fill:#eef7ee
style G2 fill:#fdf1e7
After _is_group_entity() (:536-538, one line entity_type.lower() in self.GROUP_ENTITY_TYPES) decides, it takes two different prompt branches:
_build_individual_persona_prompt() (:721) |
_build_group_persona_prompt() (:770) |
|
|---|---|---|
| Target | A real person’s social-media persona | An institution official-account operating setup |
| Prompt opening | “为实体生成详细的社交媒体用户人设” | “为机构/群体实体生成详细的社交媒体账号设定,最大程度还原已有现实情况” (:783) |
| Distinct fields | age / gender / mbti / profession | “the represented group’s portrait, operating habits” |
6.3 Seven dimensions of an individual persona
_build_individual_persona_prompt requires generating a 2 000-character plain-text persona; :746-754 explicitly lists seven blocks:
- 基本信息(年龄、职业、教育背景、所在地)
- 人物背景(重要经历、与事件的关联、社会关系)
- 性格特征(MBTI类型、核心性格、情绪表达方式)
- 社交媒体行为(发帖频率、内容偏好、互动风格、语言特点)
- 立场观点(对话题的态度、可能被激怒/感动的内容)
- 独特特征(口头禅、特殊经历、个人爱好)
- 个人记忆(人设的重要部分,要介绍这个个体与事件的关联,
以及这个个体在事件中的已有动作与反应)
The last item, “personal memory,” is the key (:754, the prompt itself marks it “an important part of the persona”). It binds this agent to a concrete event—not a generic “35-year-old male teacher,” but the person who “has already done and said such-and-such in this event.”
MBTI is also not decoration—it is a compressed representation the LLM can stably understand and turn into behavioral tendency. Using MBTI is easier for keeping model consistency than writing a free-text “personality description.”
6.4 Second enrichment: call Zep retrieval to supplement context
The module docstring (:1-10) lists three optimizations:
- 调用 Zep 检索功能二次丰富节点信息
- 优化提示词生成非常详细的人设
- 区分个人实体和抽象群体实体
The first says: after getting an entity from the graph, use Zep’s retrieval again to fetch its related edges and context, then feed all of that together to the LLM to generate the persona. So the persona can write “this person’s already-taken actions and reactions in the event.”
Context has a length limit; both branches each truncate to 3 000 characters (:732 and :781):
context_str = context[:3000] if context else "无额外上下文"
6.5 Three data-cleaning passes
① Boundary normalization (OasisAgentProfile.__post_init__, :111-121):
def __post_init__(self):
"""Normalize structured LLM fields once at the profile boundary."""
self.bio = _coerce_to_str(self.bio) or self.name
self.persona = _coerce_to_str(self.persona) or (
f"{self.name} is a participant in social discussions."
)
self.country = _coerce_to_str(self.country) or None
self.profession = _coerce_to_str(self.profession) or None
self.gender = _coerce_to_str(self.gender) or None
self.mbti = _coerce_to_str(self.mbti) or None
self.interested_topics = _coerce_to_str_list(self.interested_topics)
The LLM may return bio as a list or dict; _coerce_to_str() uniformly crushes it to a string; if empty, a fallback copy is used. The comment’s “once at the profile boundary” states the design intent—do it once at the dataclass construction exit, so every later consumer does not have to defend itself.
② Salvaging corrupted JSON (:700-714):
# 如果提取到了有意义的内容,标记为已修复
if bio_match or persona_match:
logger.info(f"从损坏的JSON中提取了部分信息")
return {"bio": bio, "persona": persona, "_fixed": True}
# 7. 完全失败,返回基础结构
logger.warning(f"JSON修复失败,返回基础结构")
return {
"bio": entity_summary[:200] if entity_summary else f"{entity_type}: {entity_name}",
"persona": entity_summary or f"{entity_name}是一个{entity_type}。"
}
Seven-level degradation: parse fails → regex salvage → partial extract → complete fallback. One entity’s persona generation failing must not collapse the whole flow.
A repaired result is tagged "_fixed": True (:678 :688 :706); upstream :610-611 checks this tag then deletes it—the repair trace only circulates internally, and does not pollute the final profile.
③ Format constraints in the prompt (:762-768):
重要:
- 所有字段值必须是字符串或数字,不要使用换行符
- persona必须是一段连贯的文字描述
- gender字段必须用英文male/female
- age必须是有效的整数
Why emphasize “no newlines”: because an unescaped newline in LLM-generated JSON will blow up json.loads() directly. This is a pit already stepped in.
6.6 Dual-platform format conversion
The same profile has to be fed to two platforms, with different fields—to_reddit_format() (:123) and to_twitter_format() (:151):
| Distinct fields | karma (default 1000, see :90; when unspecified, generated by random.randint(500, 5000), :324) |
friend_count / follower_count / statuses_count |
There is a detail comment, written once in each of the two places (:127 and :155):
"username": self.user_name, # OASIS 库要求字段名为 username(无下划线)
Internally user_name, at the exit converted to username—adapting a third-party library’s naming habit, and writing the reason in a comment.
Chapter 7 · Step four: simulation parameters—encoding sociological common sense as config
services/simulation_config_generator.py (993 lines) is this project’s file with the highest “domain-knowledge” content.
7.1 Stepwise generation strategy
The module docstring (:1-11) explains why it does not generate in one shot:
采用分步生成策略,避免一次性生成过长内容导致失败: 1. 生成时间配置 2. 生成事件配置 3. 分批生成 Agent 配置 4. 生成平台配置
This is the general solution for every “have the LLM generate a large block of structured data” scene: split into small pieces, ask in batches; far more reliable than asking for one huge lump.
7.2 Time config: a Chinese daily-schedule curve
At the top of the file is a module-level constant CHINA_TIMEZONE_CONFIG (:30-50), commented "中国作息时间配置(北京时间)"; TimeSimulationConfig (:85-112)’s docstring is "时间模拟配置(基于中国人作息习惯)". The two places define the same curve, split into five periods:
| Period | Hours | Activity multiplier | Source comment |
|---|---|---|---|
| Evening peak | 19, 20, 21, 22 | × 1.5 | “最活跃” |
| Night | 23 | × 0.5 | “活跃度下降” |
| Work hours | 9–18 | × 0.7 | “工作时段中等” |
| Morning | 6, 7, 8 | × 0.4 | “早间逐渐活跃” |
| Pre-dawn trough | 0–5 | × 0.05 | “凌晨几乎无人” |
CHINA_TIMEZONE_CONFIG = { # :30
"dead_hours": [0, 1, 2, 3, 4, 5], # 深夜时段(几乎无人活动)
"morning_hours": [6, 7, 8], # 早间时段(逐渐醒来)
"work_hours": [9, 10, ..., 18], # 工作时段
"peak_hours": [19, 20, 21, 22], # 晚间高峰(最活跃)
"night_hours": [23], # 夜间时段(活跃度下降)
"activity_multipliers": {
"dead": 0.05, "morning": 0.4, "work": 0.7, "peak": 1.5, "night": 0.5
},
}
Corresponding dataclass fields (:88-111):
total_simulation_hours: int = 72 # 默认模拟 72 小时(3 天) :88
minutes_per_round: int = 60 # 每轮 = 1 小时 :91
agents_per_hour_min: int = 5 # :94
agents_per_hour_max: int = 20 # :95
off_peak_activity_multiplier: float = 0.05 # 凌晨活跃度极低 :103
Drawn, it looks like this:
xychart-beta
title "MiroFish 内置的中国社交媒体活跃度曲线(北京时间)"
x-axis "小时" [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23]
y-axis "活跃度倍数" 0 --> 1.6
bar [0.05,0.05,0.05,0.05,0.05,0.05,0.4,0.4,0.4,0.7,0.7,0.7,0.7,0.7,0.7,0.7,0.7,0.7,0.7,1.5,1.5,1.5,1.5,0.5]
The shape of this curve is carefully considered: from 22:00’s 1.5 down to 23:00’s 0.5, then down to 00:00’s 0.05—not a cliff, two steps. Real social-platform traffic curves are exactly like this: after 22:00 people start going to sleep, but true emptying waits until pre-dawn.
Peak-to-trough ratio 30× (1.5 ÷ 0.05). This ratio decides whether the simulated public opinion has “rhythm”—if the whole day is uniform, a 72-hour event evolution becomes a smooth curve, and you cannot see “which evening exploded.”
🧠 These numbers are this project’s “tacit assets.” They are not derived from the code; they are an experiential judgment of Chinese social-media user behavior. The pre-dawn 0.05× value—almost nobody active—decides whether the simulated public-opinion curve looks real.
Inspiration for building a new product: when your product has to simulate/predict human behavior, this kind of “common-sense parameter” is part of your moat. They are hard to copy from public data, but have huge effect on the result. Writing them explicitly as a configurable dataclass (rather than magic numbers scattered in the code) is the correct engineering posture.
7.3 Platform config: three communication-studies parameters, two sets of values
PlatformConfig (:131-145):
# 推荐算法权重
recency_weight: float = 0.4 # 时间新鲜度 :136
popularity_weight: float = 0.3 # 热度 :137
relevance_weight: float = 0.3 # 相关性 :138
# 病毒传播阈值(达到多少互动后触发扩散)
viral_threshold: int = 10 # :141
# 回声室效应强度(相似观点聚集程度)
echo_chamber_strength: float = 0.5 # :144
These three groups of parameters correspond directly to three core mechanisms of communication studies:
| Parameter | Corresponding phenomenon |
|---|---|
| Recommendation-weight triple | How the platform algorithm decides who sees what |
| Viral-spread threshold | The critical point at which information breaks out of a small circle |
| Echo-chamber-effect strength | Opinion polarization, information cocoons |
The key is: the two platforms’ defaults are not the same (:342-360):
if enable_twitter: if enable_reddit:
twitter_config = PlatformConfig( reddit_config = PlatformConfig(
platform="twitter", platform="reddit",
recency_weight=0.4, ←→ recency_weight=0.3,
popularity_weight=0.3, ←→ popularity_weight=0.4,
relevance_weight=0.3, == relevance_weight=0.3,
viral_threshold=10, ←→ viral_threshold=15,
echo_chamber_strength=0.5 ←→ echo_chamber_strength=0.6
) )
| Parameter | What this difference is saying | ||
|---|---|---|---|
| Recency | 0.4 | 0.3 | Twitter is a timeline; new crushes everything |
| Popularity | 0.3 | 0.4 | Reddit is vote ranking; popularity weight is higher |
| Relevance | 0.3 | 0.3 | Same on both sides |
| Viral threshold | 10 | 15 | Twitter breaks circle more easily (repost mechanism) |
| Echo-chamber strength | 0.5 | 0.6 | Reddit’s subreddit structure is naturally more closed |
🧠 These five rows of numbers are the most concentrated domain knowledge in the whole project. They turn “Twitter is fast and shallow, Reddit is slow and deep and more cliquish”—a judgment everyone has intuition for but is hard to quantify—into parameters that can drive a simulation.
And the directions are all correct: Twitter’s repost chain really does make the break-circle threshold lower; Reddit’s community partitions really do make opinion clustering more severe. This was not filled in by guessing.
📌 This is the first time in this series we see a social-science model encoded directly as product config—and one copy per platform.
7.4 Event config: the God’s-eye-view injection port
EventConfig (:115-127):
initial_posts: List[Dict] # 初始事件(模拟开始时的触发事件) :118
scheduled_events: List[Dict] # 定时事件(在特定时间触发的事件) :121
hot_topics: List[str] # 热点话题关键词 :124
narrative_direction: str # 舆论引导方向 :127
An initial post can specify which agent posts it (:334 counts how many have a non-empty poster_agent_id)—that is, you can arrange “a certain influencer account posts the first item in round 0.”
scheduled_events is what the README calls “God’s-eye-view variable injection”—you can set “at hour 24 the official issues a bulletin,” then watch how the world reacts.
narrative_direction (public-opinion guidance direction) is a field worth noticing. It lets this tool not only “predict how public opinion will go,” but also “test the effect of some guidance strategy.” This is exactly the README’s “policy and PR can be trial-run at zero risk.”
7.5 A good habit in generation metadata
SimulationParameters (:148) has two fields (:174-175), and both are serialized into the final config JSON (:192-193):
generated_at: str = field(default_factory=lambda: datetime.now().isoformat())
generation_reasoning: str = "" # LLM的推理说明
Store the LLM’s reasoning process together with it. When a simulation result is anomalous, you can look back at “why the peak was set to these hours in the first place.” Explainability is not guessing afterwards; it is recording at the time.
Chapter 8 · Dual platforms in parallel: World 1 and World 2
8.1 Why two platforms
simulation_manager.py docstring:
管理 Twitter 和 Reddit 双平台并行模拟
services/zep_graph_memory_updater.py:231-234 gives them very interesting names:
# 平台名称映射(用于控制台显示)
PLATFORM_DISPLAY_NAMES = {
'twitter': '世界1',
'reddit': '世界2',
}
Two platforms = two parallel worlds. The same batch of agents, the same event, will evolve different results under two different platform mechanisms.
The value of this design: - Twitter-type: broadcast, repost-driven, information diffuses fast - Reddit-type: community, comment-driven, has downvotes, has search
The difference in how the same event goes in these two environments is itself part of the prediction conclusion.
8.2 Difference of action spaces
Look back at config.py:49-56:
| Platform | Action count | Distinct actions |
|---|---|---|
| 6 | REPOST · QUOTE_POST |
|
| 13 | DISLIKE_POST · CREATE_COMMENT · LIKE_COMMENT · DISLIKE_COMMENT · SEARCH_POSTS · SEARCH_USER · TREND · REFRESH · MUTE |
Reddit has “downvote,” Twitter does not—this one difference is enough to make the two worlds’ opinion-polarization paths completely different.
Stack on the parameter differences of Chapter 7, and the two “worlds” are not just a skin change, but two different sets of social-physics laws:
flowchart TB
S["同一批 Agent
同一个种子事件
同一条作息曲线"]
S --> W1
S --> W2
subgraph W1["世界 1 · Twitter 型"]
A1["6 个动作
REPOST · QUOTE_POST"]
B1["时间新鲜度 0.4 ↑
热度 0.3"]
C1["破圈阈值 10 ↓
回声室 0.5"]
D1["→ 快、浅、易破圈"]
end
subgraph W2["世界 2 · Reddit 型"]
A2["13 个动作
DISLIKE · COMMENT · SEARCH · MUTE"]
B2["时间新鲜度 0.3
热度 0.4 ↑"]
C2["破圈阈值 15 ↑
回声室 0.6 ↑"]
D2["→ 慢、深、更抱团"]
end
D1 --> R["两条演化轨迹的差异
本身就是预测结论"]
D2 --> R
style W1 fill:#eaf2fd
style W2 fill:#fdeeea
The same event running to different results on both sides is itself analysis material: if the two worlds’ conclusions agree, this trajectory is robust; if they fork, the platform mechanism is the key variable, and PR strategy has to be designed per platform.
8.3 Preset scripts
Under backend/scripts/ there are three simulation scripts:
| Script | Lines |
|---|---|
run_parallel_simulation.py |
1 699 (dual-platform parallel, the main force) |
run_twitter_simulation.py |
780 |
run_reddit_simulation.py |
769 |
The parallel script supports three modes:
python run_parallel_simulation.py --config simulation_config.json
python run_parallel_simulation.py --config ... --no-wait # 完成后立即关闭
python run_parallel_simulation.py --config ... --twitter-only
python run_parallel_simulation.py --config ... --reddit-only
Log structure (script-header comment):
sim_xxx/
├── twitter/actions.jsonl # Twitter 平台动作日志
├── reddit/actions.jsonl # Reddit 平台动作日志
├── simulation.log # 主模拟进程日志
└── run_state.json # 运行状态(API 查询用)
run_state.json is for Flask to query status—the child process writes files, the main process reads files. That is the next chapter’s theme.
8.4 A Windows-compatibility pit
scripts/run_parallel_simulation.py:29-47:
# ============================================================
# 解决 Windows 编码问题:在所有 import 之前设置 UTF-8 编码
# 这是为了修复 OASIS 第三方库读取文件时未指定编码的问题
# ============================================================
import sys, os
if sys.platform == 'win32':
# 设置 Python 默认 I/O 编码为 UTF-8
# 这会影响所有未指定编码的 open() 调用
os.environ.setdefault('PYTHONUTF8', '1')
os.environ.setdefault('PYTHONIOENCODING', 'utf-8')
# 重新配置标准输出流为 UTF-8(解决控制台中文乱码)
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
The third-party library’s open() did not specify encoding, so on Chinese Windows (default GBK) reading a UTF-8 file blows up. The solution is in two halves: env vars govern input (must be set before import, otherwise too late), reconfigure governs output (errors='replace' guarantees that no matter what, one character will not collapse the whole simulation).
This kind of comment says a lot about the team’s real user composition—a large number of Windows Chinese users.
Chapter 9 · Process boundary: filesystem IPC and “don’t close the door when it finishes”
9.1 Why the simulation has to run in a child process
OASIS simulation is long-running, compute-heavy, and may crash. If it ran in the Flask process: - One simulation crash = the whole backend hangs - Flask’s request threads would be occupied to death - You could not pause/stop
So simulation_runner.py (2 033 lines)’s duty is (docstring):
- 在后台进程中运行 OASIS 模拟
- 解析运行日志,记录每个 Agent 的动作
- 提供实时状态查询接口
- 支持暂停/停止/恢复操作
app/__init__.py:47 also registers a cleanup function (implemented at simulation_runner.py:1554):
SimulationRunner.register_cleanup()
When the server shuts down, terminate all simulation processes—leave no orphans.
9.2 Filesystem IPC
services/simulation_ipc.py (394 lines) docstring (:1-9) states the mechanism clearly:
通过文件系统实现简单的命令/响应模式: 1. Flask 写入命令到
commands/目录 2. 模拟脚本轮询命令目录,执行命令并写入响应到responses/目录 3. Flask 轮询响应目录获取结果
The file defines six classes, one at each end:
| Line | Class | Location |
|---|---|---|
:25 |
CommandType (Enum) |
Shared |
:32 |
CommandStatus (Enum) |
Shared |
:41 |
IPCCommand |
Shared |
:67 |
IPCResponse |
Shared |
:95 |
SimulationIPCClient |
Flask side |
:288 |
SimulationIPCServer |
Simulation child-process side |
sequenceDiagram
participant F as Flask(SimulationIPCClient)
participant FS as 文件系统
participant S as 模拟进程(SimulationIPCServer)
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: 拿到结果
Why filesystem rather than a message queue / socket:
| Scheme | Cost |
|---|---|
| Redis / RabbitMQ | One more middleware that must be deployed |
| Socket / pipe | Have to handle connection lifetime, reconnect, cross-platform differences |
| Filesystem | Zero dependency, cross-platform, if the process crashes the state is still there, you can cat it out to debug |
🧠 This is a typical case of “good enough.” This scene’s IPC frequency is very low (the user clicks interview once to send one command), latency requirement is loose (polling a few hundred milliseconds is completely acceptable). Putting Redis on it is over-design.
But note its cost: polling has latency, has disk IO, has no backpressure. If someday high-frequency commands have to be supported, this layer has to be swapped.
9.3 The most critical design: don’t close the door when it finishes
run_parallel_simulation.py header feature list:
- 双平台(Twitter + Reddit)并行模拟
- 完成模拟后不立即关闭环境,进入等待命令模式
- 支持通过 IPC 接收 Interview 命令
- 支持单个 Agent 采访和批量采访
- 支持远程关闭环境命令
This one item decides the product form.
If the simulation exits when it finishes, what you get is a pile of logs and one report—a dead result.
Because the environment does not close, you can: - Walk in and ask some agent: “why did you repost that post?” - Have ReportAgent interview a batch of agents to supplement the report (Chapter 11) - Keep interacting with the world in Step5
🧠 “Don’t close the door when it finishes” is MiroFish’s most important engineering decision. It turns the product from a “prediction-report generator” into a “explorable parallel world.” The user-value difference of those two is an order of magnitude.
simulation_runner.py’s class list also shows how fine-grained what it governs is:
| Line | Class |
|---|---|
:40 |
RunnerStatus (Enum) |
:52 |
SimulationStopPending(TimeoutError) |
:57 |
AgentAction |
:84 |
RoundSummary |
:110 |
SimulationRunState |
:204 |
SimulationRunner |
Of which:
class SimulationStopPending(TimeoutError):
"""The monitor still owns a bounded graph-ingestion finalization."""
Stop is not immediate—you still have to wait for graph ingest to finish. This class name and comment make “graceful shutdown” explicit: it inherits from TimeoutError, meaning “it has not stopped yet, but this is expected, there is an upper bound,” rather than an error.
On the zep_graph_memory_updater.py:207 side there is a symmetric _DrainDeadlineExceeded(TimeoutError)—draining the buffer also has a deadline. The two together constitute a complete shutdown path: stop simulation → drain writeback queue → truly exit, every step with an upper bound.
Chapter 10 · Closed-loop writeback: how simulated activity becomes graph memory
This is the whole architecture’s closed-loop point, and also the innovation most easily overlooked.
10.1 Mechanism
zep_graph_memory_updater.py:213-226:
监控模拟的 actions 日志文件,将新的 agent 活动实时更新到 Zep 图谱中。 按平台分组,每累积
BATCH_SIZE条活动后批量发送到 Zep。所有有意义的行为都会被更新到 Zep,
action_args中会包含完整的上下文信息: - 点赞/踩的帖子原文 - 转发/引用的帖子原文 - 关注/屏蔽的用户名 - 点赞/踩的评论原文
Note the sentence “containing complete context information.” It is not recording user_42 liked post_1337—that kind of record cannot be retrieved afterwards. It records “user_42 liked the post whose original text is ‘xxx’,” so semantic retrieval can find it.
10.2 Three parameters
:228-241:
BATCH_SIZE = 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
It left 500 characters of slack for future format changes—this is experienced writing. Note that BATCH_SIZE is counted per platform, not globally; the two worlds each accumulate their own, without interfering.
10.3 Threads and stats
It runs in a background worker thread (_worker_thread), with a queue (_activity_queue), per-platform buffers (_platform_buffers, comment at :268 “each platform accumulates to BATCH_SIZE then batch-sends”), two locks (_buffer_lock / _acceptance_lock).
The stats fields are done finely (:281-287):
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 = []
self._pending_episode_uuids = []
Batch count and item count are counted separately, failed batches are kept on file (_failed_batches); at wrap-up if non-empty a clear warning is logged (:335-337); final stats are logged all at once (:344-348). This is for investigation.
DO_NOTHING is explicitly filtered (:371-373):
# 跳过DO_NOTHING类型的活动
if activity.action_type == "DO_NOTHING":
self._skipped_count += 1
If the agent did nothing, it should not pollute the graph—but skipped count is still counted, so afterwards you can know “what proportion of agents in this simulation were idling,” which is itself a meaningful metric.
10.4 Why this closed loop matters
flowchart LR
G[("Zep 时序图谱")] -->|实体| P["Agent 人设"]
P --> S["OASIS 模拟"]
S -->|actions.jsonl| U["MemoryUpdater
批量回写"]
U -->|带原文上下文的活动| G
G -->|检索| R["ReportAgent"]
style G fill:#e8f5e9
Without this writeback, the report agent can only read “the world before the simulation.” With it:
- The graph has both initial facts (from the material you uploaded) and things that happened in the simulation
- Zep’s temporal capability organizes the two along a timeline
- When ReportAgent retrieves, it can get the complete chain “what the thing originally was → how it later evolved”
🧠 One sentence: this closed loop turns the “knowledge graph” from a static input into a record that grows with the simulation. This is the fundamental place MiroFish differs from “run a multi-agent simulation then have an LLM summarize the logs.”
Chapter 11 · Four retrieval weapons: InsightForge / Panorama / Quick / Interview
services/zep_tools.py (1 734 lines) provides tools to ReportAgent. The docstring splits two levels:
【核心检索工具 - 优化后】 1.
insight_forge— 深度洞察检索(最强大,自动生成子问题,多维度检索) 2.panorama_search— 广度搜索(获取全貌,包括过期内容) 3.quick_search— 简单搜索(快速检索) 4.interview_agents— 深度采访(采访模拟 Agent,获取多视角观点)【基础工具】
search_graph·get_all_nodes·get_all_edges·get_node_detail·get_node_edges·get_entities_by_type·get_entity_summary
11.1 InsightForge: explode one question into several
insight_forge() (:943-1089, signature max_sub_queries: int = 5, see :949) in five steps:
flowchart TB
Q["用户问题"] --> S1["① LLM 分解为子问题
_generate_sub_queries()
max_sub_queries=5"]
S1 --> S2["② 每个子问题各做一次语义搜索
limit=15, scope=edges"]
S2 --> S3["③ 对原始问题也搜一次"]
S3 --> S4["④ 提取相关实体 + 获取详细信息"]
S4 --> S5["⑤ 追踪关系链"]
S5 --> S6["⑥ 整合去重,生成深度洞察"]
Dedup uses a seen_facts set (set built at :992, two dedup tests at :1003-1005 and :1017-1019)—multiple sub-questions will retrieve overlapping facts; without dedup, context would bloat.
Sub-question generation carries report_context (the current report-section context; _generate_sub_queries() at :1090), so sub-questions are generated against the section currently being written, not asked generically.
📌 This is what GraphRAG’s “multi-hop” capability looks like when it lands in engineering: not one retrieval, but question decomposition → parallel retrieval → entity expansion → relation tracing → integration.
11.2 Panorama: want the full picture, including expired
The positioning of panorama_search() (:1143) is “get the full picture, including expired content.”
It has an internal closure relevance_score(fact) -> int (:1213) for ranking—because “want the full picture” means there will be many results; there must be ranking, otherwise it equals no retrieval.
Why “including expired content”: because in a temporal graph, a relation that “once held but now expired” is precisely evidence of evolution. For example “A once supported B (rounds 3–15), afterwards turned to opposition”—this expired edge is the most valuable material in the report.
Corresponding EdgeInfo.is_expired() / is_invalid() (:133-142).
11.3 Interview: interview a living agent
This is the most innovative function in the whole project (:1270-1482). Docstring at :1276-1298:
"""
【InterviewAgents - 深度采访】
调用真实的OASIS采访API,采访模拟中正在运行的Agent:
1. 自动读取人设文件,了解所有模拟Agent
2. 使用LLM分析采访需求,智能选择最相关的Agent
3. 使用LLM生成采访问题
4. 调用 /api/simulation/interview/batch 接口进行真实采访(双平台同时采访)
5. 整合所有采访结果,生成采访报告
【重要】此功能需要模拟环境处于运行状态(OASIS环境未关闭)
【使用场景】
- 需要从不同角色视角了解事件看法
- 需要收集多方意见和观点
- 需要获取模拟Agent的真实回答(非LLM模拟)
"""
The five characters “非 LLM 模拟” (“not an LLM simulation”) are the point.
The difference is:
| Practice | Result |
|---|---|
| ❌ Have the LLM “play” Zhang San answering | A temporary role with no memory, that has not lived through the simulation |
| ✅ Interview the agent actually running in the simulation | It answers carrying a 2 000-character persona + the whole simulation’s experience |
Four-step flow:
1. Read persona files (_load_agent_profiles, :1503)
2. LLM picks people (_select_agents_for_interview, :1549; call site :1326)—intelligently pick the most relevant by interview need, returning a triple (selected_agents, selected_indices, selection_reasoning), of which selection_reasoning (why these were picked) is hung on the result (:1334) and appears in the final text (:387, defaulting to “(自动选择)”)
3. LLM generates questions (_generate_interview_questions, :1632)
4. Call /api/simulation/interview/batch—interview both platforms at once
🧠 This is the key step that turns “simulation” into a “queryable data source.” Other multi-agent projects finish the run and dump logs for an LLM to summarize; MiroFish lets the report agent go back and ask the parties.
Inspiration for building a new product: if a stateful process ran out of your system, do not only keep its output; find a way to keep the ability to “keep asking it questions.” The former is a report; the latter is an asset.
11.4 One defensive detail
_clean_tool_call_response() (:1483)—a static method, cleaning the tool-call response. Together with ReportAgent’s _strip_fake_tool_results() in Chapter 12, both sides are defending the same class of problem: the LLM mixing structured markers that should not be there into its output.
Another degradation worth remembering: search_graph() (:457), when Zep semantic search is unavailable, falls back to _local_search() (:542) doing local keyword match. Retrieval quality of course drops, but the report will not go blank for a whole chapter because of one network jitter.
Chapter 12 · ReportAgent: ReACT section-by-section generation and three anti-spoof layers
services/report_agent.py 2 619 lines, the largest service file.
12.1 Three stages
class ReportAgent (:871):
"""
采用ReACT(Reasoning + Acting)模式:
1. 规划阶段:分析模拟需求,规划报告目录结构
2. 生成阶段:逐章节生成内容,每章节可多次调用工具获取信息
3. 反思阶段:检查内容完整性和准确性
"""
MAX_TOOL_CALLS_PER_SECTION = 5 # :882
MAX_REFLECTION_ROUNDS = 3 # :885
MAX_TOOL_CALLS_PER_CHAT = 2 # :888
Corresponding three methods: plan_outline() (:1176) → _generate_section_react() (:1260) → generate_report() (:1576), plus chat() (:1810).
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
Each chapter’s artifact is persisted separately under sections/, so if this loop collapses at any step, completed chapters are not lost.
Why generate by chapter: a complete report may be tens of thousands of characters; having the LLM generate it in one shot will necessarily drop quality or truncate. By chapter + independent ReACT per chapter, each chapter can retrieve targeted.
All three caps are configurable (config.py:59-61):
REPORT_AGENT_MAX_TOOL_CALLS = int(os.environ.get('REPORT_AGENT_MAX_TOOL_CALLS', '5'))
REPORT_AGENT_MAX_REFLECTION_ROUNDS = int(os.environ.get('REPORT_AGENT_MAX_REFLECTION_ROUNDS', '2'))
REPORT_AGENT_TEMPERATURE = float(os.environ.get('REPORT_AGENT_TEMPERATURE', '0.5'))
📌 Note that in
config.py:60the reflection-rounds default is 2, but the class constant atreport_agent.py:885writes 3. This is one inconsistency.
Another detail worth learning (:1495):
if unused_tools and tool_calls_count < self.MAX_TOOL_CALLS_PER_SECTION:
When this chapter has not used up its tool-call quota, and some tool has not been used even once, the system will actively prompt the model to try that tool. This is fighting a very common disease: the model tends to repeatedly use the one tool it is familiar with (usually the simplest quick_search), and not touch insight_forge or interview_agents.
12.2 Three anti-spoof layers
① Strip fabricated tool results (_strip_fake_tool_results, :1144-1175):
"""Strip any <tool_result> blocks the LLM fabricated in its response.
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. The real tool result will be injected
separately by the system.
"""
The disease: after the model generates <tool_call>, it then continues and fabricates <tool_result> as well. If you do not strip it before it enters message history, the model will think that is a real retrieval result, and everything after is built on hallucination.
sequenceDiagram
participant M as LLM
participant A as ReportAgent
participant Z as zep_tools
M->>A: "quick_search…
【伪造的检索结果】 "
Note over A: ① _strip_fake_tool_results() :1144
深度计数扫描,剥掉伪造段
连未闭合的畸形开标签一并清掉
Note over A: ② _is_valid_tool_call() :1120
校验工具名与结构
A->>Z: 真正执行 quick_search
Z-->>A: 真实结果
Note over A: ③ _clean_tool_call_response()
zep_tools:1483
A->>M: 只把【真实结果】写进消息历史
The implementation is a tag scanner with depth counting (not a simple regex replace), because it has to handle nesting. And it additionally handles malformed opening tags that were never closed:
# Treat a malformed opening tag without a closing `>` as unsafe too.
cleaned = re.sub(r'<tool_result\b.*$', '', cleaned, flags=re.IGNORECASE | re.DOTALL)
② Tool-call validity check (_is_valid_tool_call(self, data: dict), :1120)—before executing, first judge whether this <tool_call> is structurally complete and the tool name is legal.
③ Tool-response cleaning (zep_tools._clean_tool_call_response, zep_tools.py:1483)
The three together cover a complete chain: the incoming call must be legal (②) → the outgoing response must be clean (③) → results the model fabricated itself must be stripped (①).
🧠 “The model will fabricate tool results” is a problem every ReACT implementation will meet, but few projects handle it explicitly. MiroFish not only handled it, it also considered the two edges of nesting and malformed tags.
12.3 Dual-path logs
At the start of report_agent.py two log classes are defined:
| Class | Output | Use |
|---|---|---|
ReportLogger (:36) |
agent_log.jsonl |
One complete JSON per line, recording timestamp, action type, details |
ReportConsoleLogger (:307) |
console_log.txt |
Console style (INFO/WARNING) |
Why two paths:
- agent_log.jsonl for a program to read—the frontend has to render the timeline of “think → call tool → get result”
- console_log.txt for a human to read—when investigating, tail -f directly
Both provide streaming interfaces (ReportManager.get_agent_log_stream, :2114; get_console_log_stream, :2052) and a paging parameter (from_line); the frontend relies on them for incremental pull rather than full re-fetch every time.
This explains why
Step4Report.vuehas 5 162 lines—it has to render these two streams, section progress, and the final report at the same time.
12.4 Per-section persistence
ReportManager (:1931) file structure:
reports/{report_id}/
├── meta.json # 报告元信息和状态
├── outline.json # 大纲
├── progress.json # 进度
├── sections/ # 分章节内容
├── agent_log.jsonl # 结构化动作日志
└── console_log.txt # 控制台日志
Storing by section means: if generation collapses at chapter 5, the first 4 chapters are still there and can continue. assemble_full_report() (:2318) stitches them at the end; _post_process_report() (:2348) does post-processing.
Incidentally, this file stuffed in 8 classes: ReportLogger(:36) · ReportConsoleLogger(:307) · ReportStatus(:389) · ReportSection(:399) · ReportOutline(:419) · Report(:442) · ReportAgent(:871) · ReportManager(:1931). Chapter 15 will list this as a hard flaw.
Chapter 13 · Deep interaction: walk into the world and talk to an agent
Step5Interaction.vue (2 584 lines) + InteractionView.vue provide two kinds of interaction:
| Interaction target | What you can do |
|---|---|
| Any agent in the world | Direct dialogue—it answers carrying persona and simulation experience |
| ReportAgent | Follow up on report content; it will autonomously call retrieval tools (MAX_TOOL_CALLS_PER_CHAT = 2) |
report_agent.chat() (:1810) is the second kind:
支持与用户对话,在对话中自主调用检索工具
This chapter’s content is not much, but it is the last piece of the product closed loop. The previous four steps produce “a world + a report”; the fifth step makes both of those interactive.
🧠 A product-design observation: most “AI generates a report” products end at step four. The extra step MiroFish took turns the deliverable from “a document” into “an object you can keep following up with.” This is a very important difference—a user’s trust in a report is often built on “I can follow up on details and get a consistent answer.”
Chapter 14 · Where the novelty actually is: five genuinely new things
This chapter answers “where is the novelty” directly. I judge by “whether it has been seen in the eleven projects of this series.”
① Turn “prediction” into “simulation,” not “reasoning”
This is the most fundamental one.
| Road | Practice | Representative |
|---|---|---|
| Reasoning-style prediction | Feed the material to an LLM, let it say directly “I think it will be like this” | The vast majority of AI analysis tools |
| Simulation-style prediction | Build a world, let it evolve by itself, observe the result | MiroFish |
The difference is in explainability and intervenability: - Reasoning-style gives you a conclusion; you can only choose to believe or not - Simulation-style gives you a process—you can see who said what in which round, how information diffused, which node was the turning point - And you can change a variable and re-run (change the bulletin time, change a guidance strategy)
This is not a new academic idea (social-simulation research is decades old), but turning it into a product you can use by uploading a PDF is new.
② Bidirectional closed loop of knowledge graph ↔ simulation
flowchart LR
A["种子材料"] -->|本体+摄取| G[("Zep 时序图谱")]
G -->|实体→人设| S["OASIS 模拟"]
S -->|活动带原文回写| G
G -->|GraphRAG 检索| R["报告"]
S -->|采访活着的 Agent| R
The two back-edges are the key: 1. Simulated activity writes back to the graph (Chapter 10)—the world’s history is saved structured 2. The report agent interviews simulation agents (Chapter 11)—the result is not just a log summary
Memory systems in this series (openworker’s SQLite facts, MiMo’s FTS5, Raven’s EverOS) are all one-way: the agent writes memory, the agent reads memory. MiroFish’s graph is co-written by an entire simulated world.
③ Interview a living agent
Said before; repeating once because it deserves it:
需要获取模拟 Agent 的真实回答(非 LLM 模拟)
Don’t close the door when it finishes + IPC interview channel + LLM picking people and drafting questions, the three together make this capability.
④ Explicitly productize social-science parameters
Echo-chamber-effect strength, viral-spread threshold, recommendation-algorithm three weights, the five-segment Chinese daily-schedule curve—these are domain knowledge, not code.
And they are two sets of values per platform: Twitter recency weight 0.4 / break-circle threshold 10 / echo chamber 0.5, Reddit is 0.3 / 15 / 0.6. These five numbers quantified “Twitter is fast and shallow, Reddit is slow and deep and more cliquish.”
The “config” of other projects in this series is all engineering parameters (timeout, concurrency, retry). MiroFish’s core config is sociological parameters.
📌 This also means its competitive moat is elsewhere: copying its code is easy (24 000 lines), but you cannot copy away experience like “pre-dawn activity should be set to 0.05, from 22:00 to 23:00 there should be one intermediate step.”
⑤ Persona split between individual entities and group entities
Generating for “Wuhan University” an institution official-account operating setup, rather than stuffing in a “Mr. Wuhan University, 45 years old”—this is a very practical insight.
In a social event, the speaking logic of institutions and individuals is completely different: an institution has to consider stance, talking points, timing; an individual can be emotional. Handling them with two prompt sets, the simulated public-opinion field looks real.
Chapter 15 · Engineering quality: highlights and hard flaws
15.1 Highlights
| Highlight | Evidence |
|---|---|
| Test coverage has a focus | backend/tests/ 18 files / 3 359 lines (whole repo 21 / 4 719), of which 9 are Zep-specific (contracts, paging, lifecycle, retry, two barriers)—the most uncontrollable external dependency is tested through |
| Degradation chain is complete | Persona generation seven-level degradation; search_graph (zep_tools:457) when Zep search is unavailable degrades to _local_search (:542) local keyword match |
| Retry is unified | utils/retry.py + call_zep_read_with_retry(), retry classified by Zep/HTTPX error type, not brainless retry; there is a dedicated test_zep_retry_and_client.py |
| Internationalization is thorough | 631 i18n leaf keys × Chinese/English bilingual equal length (the two files’ key counts are completely identical), even backend console logs go through t() (console.insightForgeStart etc.) |
| Explainability is deliberately kept | generation_reasoning (why parameters were configured this way), selection_reasoning (why these agents were picked for interview), agent_log.jsonl (every step of thinking and calling) |
| Windows compatibility was done seriously | Force UTF-8 before import + stdout.reconfigure(errors='replace') + dual encoding-detection libraries (chardet + charset-normalizer) |
| Shutdown path has an upper bound | SimulationStopPending and _DrainDeadlineExceeded, two TimeoutError subclasses, write “graceful shutdown” as types |
The i18n implementation especially deserves a separate paragraph (frontend/src/i18n/index.js, the whole file 27 lines):
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 is a language registry, listing 7 kinds (zh / en / es / fr / pt / ru / de), each with label and llmInstruction:
"fr": { "label": "Français", "llmInstruction": "Veuillez répondre en français." }
And availableLocales takes registry ∩ translation files that actually exist. Currently only zh.json / en.json exist, so the switcher only shows two—there will not be an empty slot of “selected but useless.”
Adding a language = drop in a locales/xx.json, zero code change.
And the backend reads the same directory. The stretch '..', '..', '..', 'locales' in backend/app/utils/locale.py:8 jumps all the way out of backend, pointing at the project-root locales/:
_locales_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'locales')
Frontend Vite glob and backend os.listdir scan the same batch of files. Add a language, frontend menu, backend logs, LLM output language take effect at the same time, nowhere to change.
The llmInstruction field is even cleverer: it is not for the UI, it is for the model. get_language_instruction() (locale.py:66-69) is taken out and spliced into 7 prompt-construction sites:
def get_language_instruction() -> str:
locale = get_locale()
lang_config = _languages.get(locale, _languages.get('zh', {}))
return lang_config.get('llmInstruction', '请使用中文回答。')
| Call site | What it generates |
|---|---|
ontology_generator.py:227-228 |
Ontology |
oasis_profile_generator.py:719 :765 :814 |
Persona (generic / individual / group) |
simulation_config_generator.py:591 :708 :872 |
Time config / event config / agent config |
And every injection site is immediately followed by an exception declaration “but these fields must be English”; this pattern is very worth learning:
# ontology_generator.py:228
"...IMPORTANT: Entity type names MUST be in English PascalCase ...
Only description fields and analysis_summary should use the specified language above."
# 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."
Even gender has to be treated differently by branch—the individual branch wants male/female (:765), the group branch wants "other" (:814).
🔑 This is the easiest pit to step in in a multilingual LLM application, and also the most important discipline:
Natural-language fields follow the user’s language; enum values, type names, field names are always English.
Otherwise you tell the model “answer in French,” it will write
stance: "supportive"asstance: "favorable", and downstreamif stance == 'supportive'mismatches directly—and this kind of bug only appears when you switch to a non-default language, the hardest to find. MiroFish wrote this exception at every injection site, not one missed.
One more detail: t() (:35)’s language value goes through get_locale() (:29-33); when there is a request context it reads the Accept-Language header, when there is not it reads a thread-local variable:
def get_locale() -> str:
if has_request_context():
raw = request.headers.get('Accept-Language', 'zh')
return raw if raw in _translations else 'zh'
return getattr(_thread_local, 'locale', 'zh')
The accompanying set_locale() (:24) is commented "Call at the start of background threads"—because simulation and report generation both run in background threads, with no Flask request context. If you do not pass it explicitly, logs printed by background threads will all fall back to Chinese.
And t() also does two-level fallback: the target language does not have this key → fall back to zh (:46-54) → still not, return the key name itself (:57), never return undefined or empty string. Parameter interpolation uses a naive {k} string replace (:59-61).
🧠 One JSON registry simultaneously drives: frontend menu + backend logs + LLM output language + missing fallback. This is the cleanest i18n design I have seen in this series, and in total under 80 lines of code.
15.2 Hard flaws
① Can only use Zep Cloud, and it is forced
if os.environ.get("ZEP_API_URL"):
errors.append("ZEP_API_URL 不受支持;MiroFish 仅连接 Zep Cloud")
- Data must leave the country to a third party
- Free quota is limited (the README says “enough for simple use”)
- If the service hangs the whole product is unusable
- For enterprise customers (government, finance) this is almost fatal
② Cost has no guardrail
In the README’s environment-variable notes (README.md:120) it itself wrote a warning comment:
# High consumption, try simulations with fewer than 40 rounds first
This warning appears in a comment of the .env sample—that is, the only cost-protection measure is “hope the user reads the comment.”
One simulation = hundreds of agents × tens of rounds × an LLM call per round. In the code no token-budget control or cost estimate is seen. A user can easily burn through a few hundred kuai in one run.
Contrast MiMo Code’s /context-limit (encoding a billing tier into config)—MiroFish on the cost piece is basically running naked.
③ Single files too large
| File | Lines |
|---|---|
frontend/Step4Report.vue |
5 162 |
backend/api/simulation.py |
2 878 |
backend/services/report_agent.py |
2 619 |
report_agent.py stuffed 8 classes into one file (two loggers, four dataclasses, Agent, Manager). The loggers and the report manager can be split out completely—they have no coupling with the ReACT loop.
④ Docs and implementation disagree
report_agent.py:3’s docstring says “uses LangChain + Zep to implement ReACT mode,” butrequirements.txthas no LangChain—the ReACT loop is hand-written. This is most likely that LangChain was used early, later torn out, but the docstring was forgottenMAX_REFLECTION_ROUNDSclass constant (report_agent.py:885) is 3,config.py:60default is 2
⑤ Ontology normalization silently corrupts non-ASCII type names
See §4.4. _to_pascal_case / _to_upper_snake_case’s [^a-zA-Z0-9] will eat a whole Chinese run, multiple Chinese types collapse into the same Unknown then overwrite each other in the entity_types dictionary, no exception thrown, no log.
This is the only place in this lecture that is a defect verified by a runnable reproduction—scoop the source functions out and run them directly and you can see it. Currently the only defense is the IMPORTANT declaration in the prompt, and it is spliced right after “请使用中文回答.”
The fix is simple: the normalize functions should raise when the result is empty, not return 'Unknown'.
⑥ No persistent database
models/ is only dataclasses; state is persisted as JSON files. Enough for a single machine, but:
- No concurrency control
- No transactions
- Multi-instance deploy will fight
⑦ AGPL-3.0
The only AGPL project in this series. This is deliberate commercial defense—you can use it yourself, but if you change it and take it to do SaaS you must open-source. A threshold for teams that want to commercialize; a protection for the project itself.
Chapter 16 · Twelve inspirations for building a new product
This chapter is the landing of this lecture.
① Outsource the hardest parts, write only the glue layer
MiroFish uses 24 000 lines to hold up 69k stars, relying on: - Social simulation → OASIS - Temporal memory → Zep Cloud - Model → any OpenAI-compatible API
It itself only writes “how to connect these three, and let an ordinary person use them.”
Think the other way: if this team first spent half a year self-researching a simulation engine and a graph database, they might well not have shipped by today. Validate the idea first, then consider replacing the dependencies.
② Domain common sense is the moat
Echo-chamber strength, viral threshold, pre-dawn 0.05× activity—these numbers cannot be copied away.
If you want to build a simulation/prediction product, first think clearly: in your domain, which “old-hand-only” experiential parameters are there? Making them explicit and configurable is the product’s core asset.
③ Use the prompt upstream to constrain downstream’s shape requirement
When generating the ontology it explicitly lists “cannot be: abstract concepts / topics / viewpoints”—because downstream has to turn every entity into a social account.
Blocking non-compliant things at the first step of the pipeline is far cheaper than filtering at the fifth. And listing counterexamples is more effective than stating the positive requirement.
④ A constraint is a midwife of design
Zep limits 10 entity types → forced the layered design of “8 specific + 2 catch-all.”
This design is better than when unconstrained—it guarantees both resolution and coverage.
Next time you meet an external limit, do not rush to go around it; think whether it is forcing you to make a better design.
⑤ LLM generating structured data: stepwise + normalize + multi-level degradation
A three-piece kit, none dispensable:
| Stage | Practice |
|---|---|
| Stepwise | simulation_config_generator split into time / event / agent-in-batches / platform, four steps |
| Normalize | utils/ontology.py: format conversion + reserved-word avoidance + dedup + empty-value fallback |
| Degrade | Persona generation seven-level degradation; the worst still gives a usable base structure |
⑥ Make the process queryable, not only readable
“Don’t close the door when it finishes” + interview API = turn a one-shot simulation into a sustainably queryable data source.
If a stateful process ran out of your system, do not only keep its output. Find a way to keep the ability to “keep asking it.” A report is a consumable; an interactive world is an asset.
⑦ Record “why,” not only “what”
generation_reasoning, selection_reasoning, agent_log.jsonl—store the reason at decision time.
Afterwards wanting to explain “why it was configured this way in the first place,” you cannot guess it out.
⑧ Prevent the model fabricating tool results
What _strip_fake_tool_results() handles is a problem every ReACT implementation will meet but few people handle explicitly: the model fabricates <tool_result> itself.
Any system that lets the model output structured markers has to assume it will fabricate. And it has to handle nesting and malformed tags.
⑨ Good enough: the value of filesystem IPC
Low-frequency, loose-latency inter-process communication, the filesystem is completely enough, and zero dependency, cross-platform, if it crashes the state is still there, you can cat it out to debug.
Before putting Redis on it, first ask what this scene’s QPS is.
⑩ Iron law of multilingual LLM applications: only natural-language fields follow language
The 7 injection sites of get_language_instruction(), every one followed by a “but these fields must be English” (Chapter 15).
If your product has to support multiple languages, establish this discipline from the first line of code:
| Follow the user’s language | Always English |
|---|---|
| description / summary / content / persona | Type names (PersonEntity) |
| Report body / UI copy | Enum values (supportive / male) |
| The LLM’s reasoning notes | JSON field names, numeric values |
Otherwise the bug only appears under a non-default language, almost impossible to find in tests.
⑪ One registry driving full-stack i18n
locales/languages.json is read by both frontend Vite glob and backend os.listdir; add a language in one place, frontend menu / backend logs / LLM output language take effect at the same time. And availableLocales takes the intersection of registry ∩ actual translation files, so you will not get “it’s in the menu but selecting it does nothing.”
In total under 80 lines of code. Worth copying directly.
⑫ The range of the product narrative decides the ceiling
A serious scene (public opinion, finance, policy) + an entertainment scene (deducing the ending of Dream of the Red Chamber) using the same engine—this combination makes the grand slogan “predict everything” believable.
A tool that can only do A, and an engine that can do A and also B, are not in the same valuation class. The key is finding that unexpected B that can prove “generality.”
Chapter 17 · Its place in this series
17.1 One table
| Dimension | The previous eleven in this series | MiroFish |
|---|---|---|
| Core question | How to let AI do the work for me | How to let a swarm of AIs rehearse for me |
| Agent count | 1 main + a few sub-agents | Hundreds to thousands, interacting as equals |
| Agent’s purpose | Finish a task | Behave like that person |
| Success criterion | The task was done right | The evolved macro phenomenon looks real |
| Loop shape | ReAct (think→do→observe) | Social evolution (post→get recommended→get interacted with→influence others) |
| Memory | The agent itself reads and writes | A temporal graph co-written by an entire world |
| Deliverable | Code / files / design drafts | A report + a world you can walk into |
| Code volume | Tens of thousands to hundreds of thousands of lines | 24 000 lines of Python |
| Moat | Engineering complexity | Domain-common-sense parameters + the product idea |
| License | Mostly MIT / Apache | AGPL-3.0 (commercial defense) |
17.2 One coordinate chart
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]
The upper-right corner has only MiroFish. This is not saying it is better; it is saying it is answering another question.
17.3 Three-sentence wrap-up
What it is: a product that wraps “social simulation,” a thing academia has done for decades, into something you can use by “uploading a PDF + saying one sentence.” The stack is Flask + Vue + OASIS + Zep Cloud + any OpenAI-compatible model; self-written code is only 24 000 lines of Python.
Where the novelty is: ① predict with simulation rather than reasoning; ② bidirectional closed loop of knowledge graph and simulation; ③ can interview a living agent; ④ turn social-science parameters into product config; ⑤ persona split between individuals and institutions.
What is most worth learning: outsource the hardest parts, turn domain common sense into a moat, turn a one-shot process into a sustainably queryable asset.
🧠 Last sentence: the previous eleven projects in this series are all on the same track comparing whose agent can do more. MiroFish swapped the question—“if you do not use one very capable agent, but a thousand very person-like agents, what can you do?”
The answer is: you can run the thing through once before it happens.
Whether this answer is right, 8 months and 69k stars gave a preliminary market judgment. But its real inspiration is: when everyone is optimizing “the capability ceiling of a single agent,” “the emergent behavior of many mediocre agents” may be a vacant lot nobody has seriously worked.
Appendix A · Source-guide index
| Want to see | Go to this file |
|---|---|
| Application assembly and blueprint registration | backend/app/__init__.py (:8-10 warning suppression · :25-27 Chinese JSON · :47 cleanup registration · :67-69 blueprints) |
| All config (including sociological-parameter defaults) | backend/app/config.py (:41-42 chunking · :49-56 dual-platform action sets · :59-61 report caps · :71-72 refuse self-hosted Zep) |
| Ontology generation + 10-type layered-design prompt | backend/app/services/ontology_generator.py (732 lines; prompt :48-190, layered requirements :113-136, code fill-in :500-544) |
| Ontology normalization (dedup/truncate/fallback) | backend/app/utils/ontology.py (the whole file 100 lines; :6-8 three caps · :9-17 reserved-word table · :19-23 fallback attribute · :26 :52 :73 three normalize functions) |
| Graph construction and batch ingest | backend/app/services/graph_builder.py (879 lines; BatchSubmission:52 · reserved-word rename safe_attr_name:324-328) |
| Entity read and filter | backend/app/services/zep_entity_reader.py (446 lines) |
| Persona generation (individual vs group) | backend/app/services/oasis_profile_generator.py (1 248 lines; __post_init__:111 · GROUP_ENTITY_TYPES:238 · _is_group_entity:536 · two prompt sets :721 :770 · JSON salvage :700-714) |
| Sociological parameters | backend/app/services/simulation_config_generator.py (CHINA_TIMEZONE_CONFIG:30 · TimeSimulationConfig:85 · EventConfig:115 · PlatformConfig:131 · dual-platform differentiated values :342-360) |
| Dual-platform management | backend/app/services/simulation_manager.py (564 lines) |
| Simulation runner | backend/app/services/simulation_runner.py (2 033 lines; SimulationStopPending:52 · register_cleanup:1554) |
| Filesystem IPC | backend/app/services/simulation_ipc.py (394 lines; Client:95 / Server:288) |
| Closed-loop writeback | backend/app/services/zep_graph_memory_updater.py (791 lines; parameters :228-241 · stats :281-287 · DO_NOTHING filter :371-373) |
| Four retrieval weapons | backend/app/services/zep_tools.py (1 734 lines; is_expired:133 · search_graph:457 · _local_search:542 · InsightForge :943 · Panorama :1143 · Quick :1235 · Interview :1270 · pick people :1549 · draft questions :1632) |
| ReportAgent | backend/app/services/report_agent.py (2 619 lines; three caps :882-888 · anti-spoof :1144 · ReACT :1260 · chat:1810 · loggers :36 :307 · Manager :1931) |
| Full-stack i18n | backend/app/utils/locale.py (80 lines; _locales_dir:8 · get_locale:29 · t:35 · get_language_instruction:66) + frontend/src/i18n/index.js (27 lines) + locales/languages.json |
| Dual-platform parallel script | backend/scripts/run_parallel_simulation.py (1 699 lines; Windows encoding :29-47) |
| LLM client and retry | backend/app/utils/llm_client.py · retry.py |
| Zep client and paging | backend/app/utils/zep.py · zep_paging.py · zep_lifecycle.py |
| Frontend five steps | frontend/src/components/Step{1..5}*.vue (largest Step4Report.vue 5 162 lines) |
| Knowledge-graph visualization | frontend/src/components/GraphPanel.vue (1 423 lines, d3) |
Appendix B · Test checklist
Whole repo 21 test files / 4 719 lines, of which backend/tests/ 18 / 3 359 lines.
Zep-specific accounts for 9 (half of all 18), which shows the team knows where the largest risk is:
| Test | What it covers |
|---|---|
test_zep_cloud_contracts.py |
Zep Cloud API contracts |
test_zep_cloud_validation_script.py |
Connectivity-validation script |
test_zep_simulation_barrier.py (484 lines) |
Barrier between simulation and graph write |
test_zep_report_barrier.py |
Write barrier before report read |
test_zep_graph_lifecycle.py |
Graph lifecycle |
test_zep_graph_memory_updater.py |
Closed-loop writeback |
test_zep_edge_paging.py · test_zep_entity_reader_edges.py |
Paging |
test_zep_retry_and_client.py (112 lines) |
Retry classification |
test_ontology_generator.py · test_ontology_attributes.py · test_ontology_api_errors.py |
Ontology generation and normalization |
test_llm_json_responses.py · test_openai_chat_compat.py |
Malformed LLM return-value handling |
test_profile_field_normalization.py · test_platform_profiles.py |
Persona normalization and dual-platform formats |
test_report_tool_result_sanitizer.py |
Anti-spoof tool results |
test_simulation_prepare_failure.py |
Prepare-stage failure path |
backend/scripts/test_profile_format.py |
Persona format (script side) |
tests/test_local_star_count_fetch.py · test_local_star_history.py |
⭐ Tests written for the README’s star-history chart—they even tested the generation script of their own star-curve SVG |
📌 The “two barrier tests” are worth noticing:
test_zep_simulation_barrierandtest_zep_report_barriertest the same class of problem—after simulated activity is written into Zep, before the report goes to read, there must be a wait barrier in the middle, otherwise the report reads a half-cut graph. This is the most fragile place of the closed loop in Chapter 10; they stared at it with two test files (one of them 484 lines).
This lecture was written file-by-file against 666ghj/MiroFish commit 60757b3 (2026-07-23 04:47 UTC). All 106 source files (images and lock excluded) were fetched and read through; every file:line citation was walked back item by item with grep -n / sed -n; statistics were computed by a full traversal of the GitHub Git Tree API (128 blobs, truncated: false); community data was taken from the GitHub REST API on 2026-07-28.
Other analyses in this series → catalog · Comparative review and wrap-up evaluation