# MiroFish 源码分析：不是"帮你干活的 Agent"，而是"替你把未来跑一遍"的群体智能引擎

> **分析对象**：MiroFish（[`666ghj/MiroFish`](https://github.com/666ghj/MiroFish)），基于 **commit `60757b3`**（2026-07-23 04:47 UTC，`main`）。许可 **AGPL-3.0**。
> **代码规模**：仓库 **128 个 blob / 9.05 MB**（其中 **6.1 MB 是 `static/` 截图素材**——10 个 png + 4 个 jpeg）。真正的代码是 **62 个 Python 文件（1.09 MB）**——其中 **backend 业务代码 40 个 / 24 428 行**，**测试 21 个 / 4 719 行**（`backend/tests/` 占 18 个 / 3 359 行）——加 **16 个 Vue 文件（533 KB / 20 461 行）**，外加 **631 个 i18n 叶子键 × 中英双语**。
> **社区体量**：**69 573 star / 10 868 fork / 430 watch / 105 open issue**（2026-07-28 经 GitHub API 核对），仓库 2025-11-26 创建——**8 个月**。**盛大集团（Shanda Group）战略孵化**，团队在招全职/实习（`mirofish@shanda.com`）。
> **一句话定位**：**"简洁通用的群体智能引擎，预测万物"**。你上传一份种子材料（新闻、政策草案、财报、甚至一部小说），用自然语言说出你想预测什么；它自动构建一个**高保真平行数字世界**，让**成千上万个有独立人格、长期记忆、行为逻辑的 Agent** 在里面自由互动、社会演化，最后交给你一份预测报告——**外加一个你可以走进去跟任何一个 Agent 对话的世界**。
> **读者对象**：读过本系列任一份分析的读者。**但请先建立一个认知**：这个项目和前面十一个**不是同类**。前面十一个都在回答"怎么让 AI 替我干活"；MiroFish 回答的是"**怎么让一群 AI 替我把事情预演一遍**"。它的技术栈、评价标准、创新点都在另一个坐标系里。

---

## 目录

**第一部分 · 它是什么**
1. [项目概览：8 个月 6.9 万星，它到底在卖什么](#ch1)
2. [技术栈全解：一张薄后端 + 两个重外部依赖](#ch2)
3. [五步流水线：从一份 PDF 到一个可对话的世界](#ch3)

**第二部分 · 世界是怎么建起来的**
4. [第一步：本体生成——被 Zep 的 10 类上限逼出来的层次设计](#ch4)
5. [第二步：图谱构建——GraphRAG 与批量摄取](#ch5)
6. [第三步：人设生成——个人与"群体代表账号"的分野](#ch6)
7. [第四步：模拟参数——把社会学常识编码成配置](#ch7)

**第三部分 · 世界是怎么跑起来的**
8. [双平台并行：世界 1 与世界 2](#ch8)
9. [进程边界：文件系统 IPC 与"跑完不关门"](#ch9)
10. [闭环回写：模拟活动如何变回图谱记忆](#ch10)

**第四部分 · 结果是怎么取出来的**
11. [四件检索兵器：InsightForge / Panorama / Quick / Interview](#ch11)
12. [ReportAgent：ReACT 分章生成与三道防伪](#ch12)
13. [深度交互：走进世界跟 Agent 说话](#ch13)

**第五部分 · 评价**
14. [创新性到底在哪：五个真正新的东西](#ch14)
15. [工程质量：亮点与硬伤](#ch15)
16. [对做新产品的十二条启发](#ch16)
17. [它在本系列里的位置](#ch17)

---

<h2 id="ch1">第 1 章 项目概览：8 个月 6.9 万星，它到底在卖什么</h2>

### 1.1 先看一组反差数字

| 指标 | 数值 |
|---|---|
| Star | **69 573** |
| Fork | **10 868** |
| Watch | 430 |
| 仓库年龄 | **8 个月**（2025-11-26 → 最后一次 push 2026-07-23） |
| **真实代码量** | **Python 业务 24 428 行 + Vue 20 461 行 = 44 889 行** |
| Open issue | 105 |
| 主语言标记 | Python |
| 许可 | **AGPL-3.0**（本系列里唯一一个用 AGPL 的） |

**每千行代码约 1 550 颗星**——这个比值在本系列里是断崖式第一。作为对照：Open Design 每千行约 20 颗星，MiMo Code 约 3 颗。

**这说明什么？** 说明它的价值**不在代码量上**。MiroFish 卖的不是工程复杂度，是**一个此前没人做成产品的点子**：

> 上传一份材料 + 一句话需求 → 得到一份预测报告 + **一个能走进去的平行世界**。

### 1.2 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**.

拆开三个关键词：

| 关键词 | 工程含义 |
|---|---|
| **独立人格 + 长期记忆** | 每个 Agent 有 2 000 字人设 + 挂在 Zep 时序图谱上的记忆（[第 6 章](#ch6)、[第 10 章](#ch10)） |
| **上帝视角注入变量** | 模拟中途可以插入事件（`EventConfig.scheduled_events`，[第 7 章](#ch7)） |
| **在数字沙盒里彩排未来** | 跑完的世界**不关门**，可以采访、可以继续问（[第 9 章](#ch9)、[第 13 章](#ch13)） |

### 1.3 两个官方演示，说明了它的射程

README 挂了两个 B 站演示，选得很讲究：

1. **武汉大学舆情模拟**——严肃场景。用另一个项目（BettaFish）生成的舆情报告作种子，预测事件走向。
2. **《红楼梦》佚失结局推演**——娱乐场景。**用前 80 回的几十万字**作种子，让贾府众人在数字世界里继续演，推演后 40 回。

**这两个例子的跨度就是产品的定位**：

> From serious predictions to playful simulations, we let every "what if" see its outcome.

- **宏观**：决策者的彩排实验室，政策和公关可以零风险试跑
- **微观**：个人用户的创意沙盒，推演小说结局也行

> 🧠 **一句话**：一个"预测引擎"如果只能做金融和舆情，天花板是有限的；**把《红楼梦》续写也放进同一个引擎**，等于宣布"任何有人物、有关系、有事件的文本，都能变成一个世界"。这是产品叙事上很聪明的一步。

### 1.4 谁在做

- **盛大集团（Shanda Group）战略支持与孵化**——README 里挂了盛大 logo
- 官网 `mirofish.ai`，有 Discord / X / Instagram / QQ 群
- **正在招全职与实习**（`mirofish@shanda.com`）
- 仿真引擎明确致谢 **[OASIS](https://github.com/camel-ai/oasis)**（CAMEL-AI 团队）

---

<h2 id="ch2">第 2 章 技术栈全解：一张薄后端 + 两个重外部依赖</h2>

### 2.1 完整技术栈

```
┌─ 前端 ─────────────────────────────────────────────┐
│  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              │
   └───────────────────────────────────┘
```

**依赖清单短得惊人**（`backend/requirements.txt` 全文只有 12 个包）：

| 类别 | 包 |
|---|---|
| 框架 | `flask>=3.0.0` · `flask-cors>=6.0.0` |
| LLM | `openai>=1.0.0`（**统一走 OpenAI 格式**） |
| 记忆 | `zep-cloud==3.25.0` · `httpx>=0.27.0` |
| **仿真** | **`camel-oasis==0.2.5`** · `camel-ai==0.2.78` |
| 文件 | `PyMuPDF` · `charset-normalizer` · `chardet` |
| 工具 | `python-dotenv` · `pydantic>=2.0.0` |

**没有 LangChain**（虽然 `report_agent.py` 的 docstring 写着"使用 LangChain + Zep"，但实际依赖里没有——**这是文档与实现的一处不一致**，ReACT 循环是手写的，见 [第 12 章](#ch12)）。没有向量数据库（Zep 自己管）。没有消息队列（用文件系统 IPC，见 [第 9 章](#ch9)）。没有 ORM（`models/` 是纯 dataclass + JSON 落盘）。

```mermaid
flowchart LR
    subgraph OUT["外包出去的（难，但有现成的）"]
        A["社会仿真引擎<br/>OASIS / camel-ai<br/>平台环境·动作空间·推荐算法"]
        B["时序知识图谱<br/>Zep Cloud<br/>实体·关系·时间有效期"]
        C["语言模型<br/>任意 OpenAI 兼容 API"]
    end
    subgraph OWN["自己写的（24 428 行）"]
        D["本体设计"]
        E["人设生成"]
        F["参数生成"]
        G["进程编排"]
        H["闭环回写"]
        I["检索与报告"]
    end
    A --- OWN
    B --- OWN
    C --- OWN
    OWN --> P["产品：<br/>上传 PDF + 一句话<br/>→ 报告 + 可交互世界"]
    style OUT fill:#eef4ff
    style OWN fill:#fff4ec
```

> 🧠 **技术选型的核心判断**：**把最难的两件事外包出去**——社会仿真交给 OASIS，时序记忆交给 Zep Cloud。自己只写"把它们粘起来的那一层"。这就是 24 000 行能撑起一个 6.9 万星产品的原因。

### 2.2 两个重外部依赖，各自解决什么

#### OASIS（camel-ai）：社会仿真引擎

OASIS = **Open Agent Social Interaction Simulations**。它提供的是：

- 一个模拟的**社交平台环境**（Twitter 型 / Reddit 型）
- Agent 在其中的**动作空间**
- 推荐算法、时间推进、互动记录

MiroFish 在 `backend/app/config.py:49-56` 里声明了两个平台的动作集：

```python
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'
]
```

**注意 Reddit 的动作集明显更丰富**（13 个 vs 6 个）——多了踩、评论、搜索、看趋势、刷新、屏蔽。这不是随便配的，是两个平台真实交互形态的差异（见 [第 8 章](#ch8)）。

#### Zep Cloud：时序知识图谱记忆

Zep 提供的是**带时间维度的知识图谱**——不只是"A 和 B 有关系"，而是"A 和 B 在 T1 到 T2 之间有关系，之后失效了"。

代码里对这个时间维度的使用非常明确（`services/zep_tools.py:133-142`）：

```python
def is_expired(self) -> bool: ...
def is_invalid(self) -> bool: ...
```

`EdgeInfo` 有 `to_text(include_temporal: bool = False)`（`:117`），`panorama_search` 的定位就是"**获取全貌，包括过期内容**"。

> 📌 **为什么必须是时序图谱**：模拟是**演化过程**。第 3 轮时 A 支持 B，第 20 轮时 A 反对 B——如果记忆系统只能存"当前状态"，你就丢掉了整个演化轨迹，而**演化轨迹恰恰是预测报告最有价值的部分**。

**一个硬约束**（`config.py:71-72`，在 `Config.validate()` 里）：

```python
if os.environ.get("ZEP_API_URL"):
    errors.append("ZEP_API_URL 不受支持；MiroFish 仅连接 Zep Cloud")
```

**只支持 Zep Cloud，不支持自建**。启动时直接报错。这是一个很强的产品决定——[第 15 章](#ch15)会讨论它的代价。

### 2.3 后端的分层

`backend/app/` 只有四层，非常扁平：

| 层 | 文件数 | 干什么 |
|---|---|---|
| `api/` | 3 | Flask 蓝图：`graph`（963 行）· `simulation`（**2 878 行**）· `report`（1 139 行） |
| `services/` | 11 | **核心逻辑全在这**，共约 12 000 行 |
| `models/` | 2 | `project.py` · `task.py`——纯 dataclass |
| `utils/` | 11 | `llm_client` · `zep` · `retry` · `locale` · `file_parser` · `ontology` … |

**最大的三个 service**：

| 文件 | 行数 | 职责 |
|---|---|---|
| `report_agent.py` | **2 619** | ReACT 报告生成 + 报告管理 |
| `simulation_runner.py` | **2 033** | 后台跑模拟、解析日志、状态查询 |
| `zep_tools.py` | **1 734** | 四件检索兵器 + 七件基础工具 |

### 2.4 应用装配：三个细节看工程习惯

`app/__init__.py:33` 有一段处理 Flask debug 模式重复打印的代码：

```python
# 只在 reloader 子进程中打印启动信息（避免 debug 模式下打印两次）
is_reloader_process = os.environ.get('WERKZEUG_RUN_MAIN') == 'true'
```

以及 `:8-10`：

```python
# 抑制 multiprocessing resource_tracker 的警告（来自第三方库如 transformers）
# 需要在所有其他导入之前设置
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
```

还有 `:25-27`——为了让中文不被转义成 `\uXXXX`，同时兼容新旧 Flask：

```python
# 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
```

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

---

<h2 id="ch3">第 3 章 五步流水线：从一份 PDF 到一个可对话的世界</h2>

### 3.1 全景

```mermaid
flowchart TB
    U["用户上传种子材料<br/>PDF / MD / TXT（≤50MB）<br/>+ 一句话预测需求"] --> S1

    subgraph S1["① 图谱构建 Step1GraphBuild"]
        O["ontology_generator<br/>LLM 设计 10 类实体 + 6-10 类关系"]
        G["graph_builder<br/>Zep Batch API 摄取"]
        O --> G
    end

    subgraph S2["② 环境搭建 Step2EnvSetup"]
        E["zep_entity_reader<br/>读图谱节点 + 按本体过滤"]
        P["oasis_profile_generator<br/>实体 → 2000 字人设"]
        C["simulation_config_generator<br/>LLM 分步生成模拟参数"]
        E --> P --> C
    end

    subgraph S3["③ 模拟 Step3Simulation"]
        M["simulation_manager<br/>准备双平台文件"]
        R["simulation_runner<br/>后台子进程跑 OASIS"]
        W["zep_graph_memory_updater<br/>活动实时回写图谱"]
        M --> R --> W
        W -.闭环.-> R
    end

    subgraph S4["④ 报告生成 Step4Report"]
        RA["report_agent<br/>ReACT 分章生成"]
        ZT["zep_tools<br/>四件检索兵器"]
        RA <--> ZT
    end

    subgraph S5["⑤ 深度交互 Step5Interaction"]
        I1["跟世界里任何 Agent 对话"]
        I2["跟 ReportAgent 追问"]
    end

    S1 --> S2 --> S3 --> S4 --> S5
    S3 -.环境不关门.-> S5
    W -.图谱.-> ZT
```

### 3.2 前端就是这五步

`frontend/src/components/` 的文件名直接对应流水线：

| 组件 | 行数 | 对应步骤 |
|---|---|---|
| `Step1GraphBuild.vue` | 700 | 图谱构建 |
| `Step2EnvSetup.vue` | **2 623** | 环境搭建 |
| `Step3Simulation.vue` | 1 268 | 模拟运行 |
| `Step4Report.vue` | **5 162** | 报告生成（最大的前端文件） |
| `Step5Interaction.vue` | **2 584** | 深度交互 |
| `GraphPanel.vue` | 1 423 | d3 知识图谱可视化 |
| `HistoryDatabase.vue` | 1 348 | 历史记录 |

> 📌 **`Step4Report.vue` 5 162 行**——报告这一步的前端复杂度远超其他步骤。因为它要实时显示 ReportAgent 的**思考过程、工具调用、分章进度、控制台日志**四路流（[第 12 章](#ch12)）。

### 3.3 三个 API 蓝图

`app/__init__.py:67-69`：

```python
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 行**是最大的 API 文件——因为模拟这一步的状态最多（准备 / 运行 / 暂停 / 停止 / 采访 / 查状态 / 拿日志）。

---

<h2 id="ch4">第 4 章 第一步：本体生成——被 Zep 的 10 类上限逼出来的层次设计</h2>

### 4.1 任务

`ontology_generator.py` 的模块 docstring：

> 本体生成服务
> **接口1**：分析文本内容，生成适合社会模拟的实体和关系类型定义

也就是：读一遍你上传的材料，**设计出这个世界里有哪几类"人"、哪几类"关系"**。

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

`ONTOLOGY_SYSTEM_PROMPT`（`services/ontology_generator.py:48` 起，一路到 `:190`）开头就把标准立死了。`:70` 那一段是关键：

> 因此，**实体必须是现实中真实存在的、可以在社媒上发声和互动的主体**：
>
> **可以是**：具体的个人 / 公司企业 / 组织机构 / 政府部门 / 媒体机构 / 社交媒体平台本身 / 特定群体代表
>
> **不可以是**：
> - 抽象概念（如"舆论"、"情绪"、"趋势"）
> - 主题/话题（如"学术诚信"、"教育改革"）
> - 观点/态度（如"支持方"、"反对方"）

**这条约束是整个系统能跑起来的前提。** 因为下一步要把每个实体变成一个**能发帖、能点赞、能互动的社交账号**——"舆论"这种东西没法开账号。

> 🔑 **通用启发**：当你的流水线下游有硬性的**形态要求**时，最有效的做法是**在上游的提示词里就把不合规的形态明确列出来禁掉**，而不是在下游做过滤。列出反例（"不可以是"）比只说正面要求有效得多。

### 4.3 被 10 类上限逼出来的"兜底类型"设计

`utils/ontology.py:6`：

```python
MAX_ONTOLOGY_TYPES = 10
```

这是 **Zep 服务端的限制**。于是提示词里出现了这样一段设计（`ontology_generator.py:113-136`）：

> **数量要求：必须正好 10 个实体类型**
>
> **层次结构要求（必须同时包含具体类型和兜底类型）**：
>
> A. **兜底类型（必须包含，放在列表最后 2 个）**：
>    - `Person`: 任何自然人个体的兜底类型。当一个人不属于其他更具体的人物类型时，归入此类。
>    - `Organization`: 任何组织机构的兜底类型。当一个组织不属于其他更具体的组织类型时，归入此类。
>
> B. **具体类型（8 个，根据文本内容设计）**
>
> （`:136`）description 必须清晰说明这个类型**和兜底类型的区别**

**为什么需要兜底类型**（`:128` 提示词里自己解释了）：

> 文本中会出现各种人物，如"中小学教师"、"路人甲"、"某位网友"。**如果没有专门的类型匹配，他们应该被归入 `Person`**。

而且这条约束**在输出格式段里又重复了一遍**（`:287-288`）：

```
1. 必须正好输出10个实体类型
2. 最后2个必须是兜底类型：Person（个人兜底）和 Organization（组织兜底）
```

**更重要的是：代码里还有一道强制补齐。** `ontology_generator.py:500-544` 会检查 LLM 的返回，如果兜底类型缺了就**自己补上**：

```python
# 兜底类型定义        （:500）
# 检查是否已有兜底类型  （:521）
# 需要添加的兜底类型    （:526）
# 添加兜底类型          （:544）
```

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

```mermaid
flowchart TB
    T["10 个实体类型（Zep 硬上限）"] --> A["8 个具体类型<br/>从文本里识别的高频关键角色<br/>如 Student / Professor / University"]
    T --> B["2 个兜底类型（固定放最后）<br/>Person —— 任何自然人<br/>Organization —— 任何组织"]
    A -.匹配不上时.-> B
```

> 🧠 **这是"约束驱动设计"的一个漂亮例子**：Zep 只让你定 10 类，那就 **8 个具体 + 2 个兜底**——既覆盖了这份材料的特殊性，又保证任何漏网之鱼都有归宿。**如果 10 类全给具体类型，遇到没定义的角色就只能丢掉；全给宽泛类型，模拟就失去分辨率。**

### 4.4 三层防御性归一化

LLM 生成的本体不能直接喂给 Zep，`utils/ontology.py` 做了三件事：

**① 命名规范化**（`ontology_generator.py:22`、`:35`）：

```python
def _to_pascal_case(name):      # works_for → WorksFor        （:22，实体类型用）
def _to_upper_snake_case(name): # camelCase → CAMEL_CASE      （:35，关系类型用）
```

`_to_upper_snake_case` 还处理了数字开头的情况（`:42-43`）：

```python
if normalized[0].isdigit():
    normalized = f"REL_{normalized}"
```

因为标识符不能以数字开头。调用点在 `:465`（实体）和 `:585`（关系）。

#### ⚠️ 这两个函数有一个静默的数据破坏行为

两者的核心都是 `[^a-zA-Z0-9]` —— **只认 ASCII 字母数字**。任何中文字符都会被当成分隔符整段丢掉。实测（把源码函数抠出来直接跑）：

| 输入 | `_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` |

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

```python
entity_types = {}                      # :331
...
entity_types[name] = entity_class      # :354
```

所以如果 LLM 返回了三个中文实体类型，它们**全部坍塌成同一个 `Unknown`，后写的覆盖先写的，最后只剩一个**——而且**全程不抛任何异常**，日志里也看不出来。你会得到一个"成功构建"的图谱，只是里面少了两个类型。

> 🔑 **这解释了 `ontology_generator.py:228` 那句 IMPORTANT 为什么必须存在**：
>
> ```
> IMPORTANT: Entity type names MUST be in English PascalCase.
> Relationship type names MUST be in English UPPER_SNAKE_CASE.
> ```
>
> **它不是风格要求，是承重墙。** 而且它就拼在 `get_language_instruction()`（可能是"请使用中文回答"）**的正后面**——一句话让模型说中文，下一句话又要求类型名必须英文。这条例外声明扛着整个本体流水线。
>
> **真正该做的是在归一化函数里加一道断言**：`if not normalized: raise` 而不是 `return 'Unknown'`。返回一个看起来合法的兜底值，是这类静默数据破坏最典型的成因——**兜底值应该用在"缺失"上，不该用在"损坏"上**。

**② 保留字冲突：不丢弃，改名**

保留字清单定义在 `utils/ontology.py:9-17`：

```python
RESERVED_ONTOLOGY_ATTRIBUTE_NAMES = frozenset({
    "uuid", "name", "group_id", "graph_id",
    "name_embedding", "summary", "created_at",
})
```

但**执行在 `graph_builder.py:324-328`**，在动态构造 Zep `EntityModel` 的那一刻：

```python
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
```

> 🔑 **注意它做的是"改名"而不是"丢弃"**：`summary` → `entity_summary`，属性还在，只是换了个不冲突的名字。
>
> 如果直接丢弃，LLM 精心设计的一个属性就凭空消失了，而且**没有任何人会发现**——因为下游只是少了一个字段，不会报错。改名则保住了信息，代价只是名字不好看。**在"数据丢失"和"名字难看"之间，永远选后者。**

提示词里也明确告诉了 LLM（`ontology_generator.py:147`）：

> **注意**：属性名不能使用 `name`、`uuid`、`group_id`、`graph_id`、`created_at`、`summary`（这些是系统保留字）

**提示词里说 + 落库前改名兜底**，又是两道都做。

**③ 空值兜底**（`utils/ontology.py:19-23`）：

```python
_FALLBACK_ATTRIBUTE = {
    "name": "details", "type": "text",
    "description": "Additional details about this ontology type.",
}
```

`normalize_ontology_attributes()`（`:52-70`）如果过滤后一个属性都不剩，**塞一个 `details` 进去**（`:68`）——因为 Zep 要求属性列表非空；同时在 `:64` 处按 `MAX_ONTOLOGY_ATTRIBUTES = 10` 截断。

`normalize_ontology_source_targets()`（`:73`）还做了**去重**和**截断到 `MAX_ONTOLOGY_SOURCE_TARGETS = 10`**。

> 📌 **模式**：凡是"LLM 生成 → 喂给外部 API"的链路，中间**必须有一层归一化**。它要处理四件事：**格式转换、保留字规避、去重、空值兜底**。MiroFish 这一层写得相当完整。

---

<h2 id="ch5">第 5 章 第二步：图谱构建——GraphRAG 与批量摄取</h2>

`graph_builder.py`（879 行）的 docstring：

> 图谱构建服务
> **接口2**：使用 Zep API 构建 Standalone Graph

### 5.1 一个值得注意的数据结构

`graph_builder.py:52`：

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

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

对应的还有 `GraphInfo`（`:35`）保存图谱信息。

### 5.2 文本切块

`config.py:41-42`：

```python
DEFAULT_CHUNK_SIZE = 500     # 默认切块大小
DEFAULT_CHUNK_OVERLAP = 50   # 默认重叠大小
```

`utils/file_parser.py` 提供 `split_text_into_chunks()`，支持 PDF（PyMuPDF）、MD、TXT，上限 50 MB。

**编码处理很实在**：依赖里同时有 `charset-normalizer` 和 `chardet`——「支持非 UTF-8 编码的文本文件」。这是面向中文用户的必备（GBK 文本很常见）。

---

<h2 id="ch6">第 6 章 第三步：人设生成——个人与"群体代表账号"的分野</h2>

这一章是整个系统**最见功力**的地方。

### 6.1 问题

图谱里有 `Student`（学生）、也有 `University`（大学）。前者是一个人，后者是一个机构。**但在社交媒体上，两者都要有一个账号**。

怎么给"武汉大学"生成一个人设？

### 6.2 解法：两套提示词

`services/oasis_profile_generator.py:238-241` 定义了一个群体类型清单：

```python
GROUP_ENTITY_TYPES = [
    "university", "governmentagency", "organization", "ngo",
    "mediaoutlet", "company", "institution", "group", "community"
]
```

```mermaid
flowchart TB
    E["图谱实体<br/>entity_type"] --> Q{"_is_group_entity()<br/>:536<br/>type.lower() 在<br/>GROUP_ENTITY_TYPES 里？"}
    Q -->|否| I["_build_individual_persona_prompt<br/>:721"]
    Q -->|是| G["_build_group_persona_prompt<br/>:770"]
    I --> I2["真人人设 2000 字<br/>年龄·MBTI·职业<br/>立场·口头禅<br/><b>个人记忆</b>"]
    G --> G2["机构官号设定<br/>代表的群体画像<br/>运营习惯·口径<br/>gender = other"]
    I2 --> N["OasisAgentProfile<br/>__post_init__ :111<br/>边界归一化"]
    G2 --> N
    N --> R["to_reddit_format() :123<br/>karma"]
    N --> T["to_twitter_format() :151<br/>follower_count 等"]
    style I2 fill:#eef7ee
    style G2 fill:#fdf1e7
```

`_is_group_entity()`（`:536-538`，一行 `entity_type.lower() in self.GROUP_ENTITY_TYPES`）判定后走两条不同的提示词分支：

| | `_build_individual_persona_prompt()`（`:721`） | `_build_group_persona_prompt()`（`:770`） |
|---|---|---|
| 目标 | 一个**真人**的社媒人设 | 一个**机构官号**的运营设定 |
| 提示词开头 | "为实体生成详细的社交媒体用户人设" | "为**机构/群体实体**生成详细的社交媒体账号设定，**最大程度还原已有现实情况**"（`:783`） |
| 特有字段 | age / gender / mbti / profession | "**代表的群体画像、运营习惯**" |

### 6.3 个人人设的七个维度

`_build_individual_persona_prompt` 要求生成 **2 000 字纯文本**的 persona，`:746-754` 明确列出七块：

```
- 基本信息（年龄、职业、教育背景、所在地）
- 人物背景（重要经历、与事件的关联、社会关系）
- 性格特征（MBTI类型、核心性格、情绪表达方式）
- 社交媒体行为（发帖频率、内容偏好、互动风格、语言特点）
- 立场观点（对话题的态度、可能被激怒/感动的内容）
- 独特特征（口头禅、特殊经历、个人爱好）
- 个人记忆（人设的重要部分，要介绍这个个体与事件的关联，
            以及这个个体在事件中的已有动作与反应）
```

**最后一条"个人记忆"是关键**（`:754`，提示词里自己标注了"人设的重要部分"）。它把这个 Agent 和**具体事件**绑定起来——不是一个泛泛的"35 岁男性教师"，而是"**在这件事里已经做过什么、说过什么**"的那个人。

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

### 6.4 二次丰富：调 Zep 检索补充上下文

模块 docstring（`:1-10`）列了三条优化：

> 1. **调用 Zep 检索功能二次丰富节点信息**
> 2. 优化提示词生成非常详细的人设
> 3. 区分个人实体和抽象群体实体

第一条是说：从图谱拿到一个实体后，**再用 Zep 的检索去捞它的相关边和上下文**，然后把这些一起喂给 LLM 生成人设。所以人设里能写出"这个人在事件中的已有动作与反应"。

上下文有长度限制，**两条分支各自截断到 3 000 字**（`:732` 和 `:781`）：

```python
context_str = context[:3000] if context else "无额外上下文"
```

### 6.5 三道数据清洗

**① 边界归一化**（`OasisAgentProfile.__post_init__`，`:111-121`）：

```python
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)
```

LLM 可能把 `bio` 返回成 list 或 dict，`_coerce_to_str()` 统一压成字符串；空了就用兜底文案。**注释里的 "once at the profile boundary" 说明了设计意图——在 dataclass 的构造出口做一次，后面所有消费方就不用各自防了。**

**② 损坏 JSON 抢救**（`:700-714`）：

```python
# 如果提取到了有意义的内容，标记为已修复
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}。"
}
```

**七级降级**：解析失败 → 正则抢救 → 部分提取 → 完全兜底。**一个实体的人设生成失败，不能让整个流程崩掉。**

被修复过的结果会打上 `"_fixed": True` 标记（`:678` `:688` `:706`），上游 `:610-611` 检查完这个标记后再删掉——**修复痕迹只在内部流转，不污染最终 profile**。

**③ 提示词里的格式约束**（`:762-768`）：

```
重要:
- 所有字段值必须是字符串或数字，不要使用换行符
- persona必须是一段连贯的文字描述
- gender字段必须用英文male/female
- age必须是有效的整数
```

**为什么强调"不要换行符"**：因为 LLM 生成的 JSON 里未转义的换行会直接让 `json.loads()` 炸掉。这是踩过的坑。

### 6.6 双平台格式转换

同一个 profile 要喂给两个平台，字段不同——`to_reddit_format()`（`:123`）和 `to_twitter_format()`（`:151`）：

| | Reddit | Twitter |
|---|---|---|
| 特有字段 | `karma`（默认 1000，见 `:90`；未指定时按 `random.randint(500, 5000)` 生成，`:324`） | `friend_count` / `follower_count` / `statuses_count` |

有个细节注释，**两处各写了一遍**（`:127` 和 `:155`）：

```python
"username": self.user_name,  # OASIS 库要求字段名为 username（无下划线）
```

**内部用 `user_name`，出口转成 `username`**——适配第三方库的命名习惯，并且把原因写在注释里。

---

<h2 id="ch7">第 7 章 第四步：模拟参数——把社会学常识编码成配置</h2>

`services/simulation_config_generator.py`（993 行）是这个项目**最有"领域知识"含量**的文件。

### 7.1 分步生成策略

模块 docstring（`:1-11`）说明了为什么不一次生成：

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

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

### 7.2 时间配置：一条中国人的作息曲线

文件顶部有一个模块级常量 `CHINA_TIMEZONE_CONFIG`（`:30-50`），注释写着 **"中国作息时间配置（北京时间）"**；`TimeSimulationConfig`（`:85-112`）的 docstring 是 **"时间模拟配置（基于中国人作息习惯）"**。两处定义的是同一条曲线，**分成五个时段**：

| 时段 | 小时 | 活跃度倍数 | 源码注释 |
|---|---|---|---|
| **晚间高峰** | 19, 20, 21, 22 | **× 1.5** | "最活跃" |
| 夜间 | 23 | × 0.5 | "活跃度下降" |
| 工作时段 | 9–18 | × 0.7 | "工作时段中等" |
| 早间 | 6, 7, 8 | × 0.4 | "早间逐渐活跃" |
| **深夜低谷** | 0–5 | **× 0.05** | "**凌晨几乎无人**" |

```python
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
    },
}
```

对应的 dataclass 字段（`:88-111`）：

```python
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
```

画出来是这样：

```mermaid
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]
```

**这条曲线的形状很讲究**：从 22 点的 1.5 掉到 23 点的 0.5，再掉到 0 点的 0.05——**不是断崖，是两级台阶**。真实的社交平台流量曲线正是这样：22 点后开始有人睡，但真正清空要到凌晨。

**峰谷比 30 倍**（1.5 ÷ 0.05）。这个比值决定了模拟出来的舆情有没有"节奏感"——如果全天均匀，一个 72 小时的事件演化会变成一条平滑的曲线，看不出"哪个晚上炸了"。

> 🧠 **这几个数字是这个项目的"隐性资产"**。它们不是从代码里推导出来的，是**对中国社交媒体用户行为的经验判断**。凌晨 0.05 倍这个值——几乎无人活动——决定了模拟出来的舆情曲线是不是像真的。
>
> **对做新产品的启发**：当你的产品要模拟/预测人类行为时，**这类"常识参数"就是你的护城河的一部分**。它们很难从公开数据里抄，但对结果影响巨大。把它们显式写成可配置的 dataclass（而不是散落在代码里的魔数），是正确的工程姿势。

### 7.3 平台配置：三个传播学参数，两套取值

`PlatformConfig`（`:131-145`）：

```python
# 推荐算法权重
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
```

**这三组参数直接对应传播学的三个核心机制**：

| 参数 | 对应现象 |
|---|---|
| 推荐权重三元组 | 平台算法如何决定谁看到什么 |
| **病毒传播阈值** | 信息从小圈子破圈的临界点 |
| **回声室效应强度** | 观点极化、信息茧房 |

**关键在于：两个平台的默认值是不一样的**（`:342-360`）：

```python
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
    )                                       )
```

| 参数 | Twitter | Reddit | 这个差异说的是什么 |
|---|---|---|---|
| 时间新鲜度 | **0.4** | 0.3 | Twitter 是**时间线**，新的压倒一切 |
| 热度 | 0.3 | **0.4** | Reddit 是**投票排序**，热度权重更高 |
| 相关性 | 0.3 | 0.3 | 两边一样 |
| 病毒阈值 | **10** | 15 | Twitter **更容易破圈**（转发机制） |
| 回声室强度 | 0.5 | **0.6** | Reddit 的 **subreddit 结构天然更封闭** |

> 🧠 **这五行数字是全项目最浓缩的领域知识**。它们把"Twitter 快而浅、Reddit 慢而深且更抱团"这个每个人都有直觉但很难量化的判断，变成了可以驱动模拟的参数。
>
> **而且方向全对**：Twitter 的转发链路确实让信息破圈门槛更低；Reddit 的社区分区确实让观点聚集更严重。这不是拍脑袋填的。

> 📌 **这是本系列里第一次看到把社会科学模型直接编码成产品配置**——而且是**每个平台一份**。

### 7.4 事件配置：上帝视角的注入口

`EventConfig`（`:115-127`）：

```python
initial_posts: List[Dict]      # 初始事件（模拟开始时的触发事件）    :118
scheduled_events: List[Dict]   # 定时事件（在特定时间触发的事件）    :121
hot_topics: List[str]          # 热点话题关键词                     :124
narrative_direction: str       # 舆论引导方向                       :127
```

初始帖子可以**指定由哪个 Agent 发**（`:334` 统计了 `poster_agent_id` 非空的条数）——也就是说，你能安排"由某个大 V 账号在第 0 轮发出第一条"。

**`scheduled_events` 就是 README 里说的"上帝视角注入变量"**——你可以设定"第 24 小时官方发布通报"，然后看世界怎么反应。

**`narrative_direction`（舆论引导方向）**是一个值得注意的字段。它让这个工具不只能"预测舆情会怎么走"，还能"**测试某种引导策略的效果**"。这正是 README 说的"政策和公关可以零风险试跑"。

### 7.5 生成元数据里的一个好习惯

`SimulationParameters`（`:148`）有两个字段（`:174-175`），并且**都会被序列化进最终配置 JSON**（`:192-193`）：

```python
generated_at: str = field(default_factory=lambda: datetime.now().isoformat())
generation_reasoning: str = ""  # LLM的推理说明
```

**把 LLM 的推理过程一起存下来**。当模拟结果反常时，你能回头看"当初为什么把高峰设成这几个小时"。**可解释性不是靠事后猜，是靠当时记。**

---

<h2 id="ch8">第 8 章 双平台并行：世界 1 与世界 2</h2>

### 8.1 为什么要两个平台

`simulation_manager.py` docstring：

> 管理 **Twitter 和 Reddit 双平台并行模拟**

`services/zep_graph_memory_updater.py:231-234` 里给它们起了很有意思的名字：

```python
# 平台名称映射（用于控制台显示）
PLATFORM_DISPLAY_NAMES = {
    'twitter': '世界1',
    'reddit':  '世界2',
}
```

**两个平台 = 两个平行世界。**同一批 Agent、同一个事件，在两种不同的平台机制下会演化出不同的结果。

**这个设计的价值**：
- Twitter 型：广播式、转发驱动、信息扩散快
- Reddit 型：社区式、评论驱动、有踩、有搜索

同一件事在这两种环境里的走向差异，本身就是预测结论的一部分。

### 8.2 动作空间的差异

回看 `config.py:49-56`：

| 平台 | 动作数 | 特有动作 |
|---|---|---|
| Twitter | **6** | `REPOST` · `QUOTE_POST` |
| Reddit | **13** | `DISLIKE_POST` · `CREATE_COMMENT` · `LIKE_COMMENT` · `DISLIKE_COMMENT` · `SEARCH_POSTS` · `SEARCH_USER` · `TREND` · `REFRESH` · `MUTE` |

**Reddit 有"踩"，Twitter 没有**——这一个差异就足以让两个世界的舆论极化路径完全不同。

**再叠加上 [第 7 章](#ch7) 的参数差异**，两个"世界"就不只是换了个皮肤，而是**两套不同的社会物理规律**：

```mermaid
flowchart TB
    S["同一批 Agent<br/>同一个种子事件<br/>同一条作息曲线"]
    S --> W1
    S --> W2
    subgraph W1["世界 1 · Twitter 型"]
        A1["6 个动作<br/>REPOST · QUOTE_POST"]
        B1["时间新鲜度 0.4 ↑<br/>热度 0.3"]
        C1["破圈阈值 10 ↓<br/>回声室 0.5"]
        D1["→ 快、浅、易破圈"]
    end
    subgraph W2["世界 2 · Reddit 型"]
        A2["13 个动作<br/>DISLIKE · COMMENT · SEARCH · MUTE"]
        B2["时间新鲜度 0.3<br/>热度 0.4 ↑"]
        C2["破圈阈值 15 ↑<br/>回声室 0.6 ↑"]
        D2["→ 慢、深、更抱团"]
    end
    D1 --> R["两条演化轨迹的<b>差异</b><br/>本身就是预测结论"]
    D2 --> R
    style W1 fill:#eaf2fd
    style W2 fill:#fdeeea
```

同一个事件在两边跑出不同结果，这个差异本身就是分析素材：如果两个世界结论一致，说明这个走向很稳健；如果分叉，说明**平台机制是关键变量**，公关策略就得分平台设计。

### 8.3 预设脚本

`backend/scripts/` 下有三个模拟脚本：

| 脚本 | 行数 |
|---|---|
| `run_parallel_simulation.py` | **1 699**（双平台并行，主力） |
| `run_twitter_simulation.py` | 780 |
| `run_reddit_simulation.py` | 769 |

并行脚本支持三种模式：

```bash
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
```

日志结构（脚本头部注释）：

```
sim_xxx/
├── twitter/actions.jsonl    # Twitter 平台动作日志
├── reddit/actions.jsonl     # Reddit 平台动作日志
├── simulation.log           # 主模拟进程日志
└── run_state.json           # 运行状态（API 查询用）
```

**`run_state.json` 是给 Flask 查状态用的**——子进程写文件，主进程读文件。这是下一章的主题。

### 8.4 一个 Windows 兼容性的坑

`scripts/run_parallel_simulation.py:29-47`：

```python
# ============================================================
# 解决 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')
```

**第三方库的 `open()` 没指定编码**，在中文 Windows（默认 GBK）上读 UTF-8 文件就会炸。解法分两半：环境变量管**输入**（必须在 import 之前设，否则来不及），`reconfigure` 管**输出**（`errors='replace'` 保证再怎么样也不会因为一个字符崩掉整个模拟）。

> 这类注释很能说明团队的真实用户构成——**大量 Windows 中文用户**。

---

<h2 id="ch9">第 9 章 进程边界：文件系统 IPC 与"跑完不关门"</h2>

### 9.1 为什么模拟要跑在子进程里

OASIS 模拟是**长时间、重计算、可能崩溃**的。跑在 Flask 进程里的话：
- 一次模拟崩溃 = 整个后端挂掉
- Flask 的请求线程会被占死
- 没法暂停/停止

所以 `simulation_runner.py`（2 033 行）的职责是（docstring）：

> 1. 在**后台进程**中运行 OASIS 模拟
> 2. **解析运行日志**，记录每个 Agent 的动作
> 3. 提供**实时状态查询**接口
> 4. 支持**暂停/停止/恢复**操作

`app/__init__.py:47` 还注册了清理函数（实现在 `simulation_runner.py:1554`）：

```python
SimulationRunner.register_cleanup()
```

**服务器关闭时终止所有模拟进程**——不留孤儿进程。

### 9.2 文件系统 IPC

`services/simulation_ipc.py`（394 行）docstring（`:1-9`）把机制说得很清楚：

> 通过文件系统实现简单的命令/响应模式：
> 1. Flask 写入命令到 `commands/` 目录
> 2. 模拟脚本**轮询**命令目录，执行命令并写入响应到 `responses/` 目录
> 3. Flask **轮询**响应目录获取结果

文件里定义了六个类，两端各一个：

| 行 | 类 | 位置 |
|---|---|---|
| `:25` | `CommandType`（Enum） | 共用 |
| `:32` | `CommandStatus`（Enum） | 共用 |
| `:41` | `IPCCommand` | 共用 |
| `:67` | `IPCResponse` | 共用 |
| `:95` | **`SimulationIPCClient`** | **Flask 侧** |
| `:288` | **`SimulationIPCServer`** | **模拟子进程侧** |

```mermaid
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: 拿到结果
```

**为什么用文件系统而不是消息队列/socket**：

| 方案 | 代价 |
|---|---|
| Redis / RabbitMQ | 多一个必须部署的中间件 |
| Socket / 管道 | 要处理连接生命周期、重连、跨平台差异 |
| **文件系统** | **零依赖，跨平台，进程崩了状态还在，可以直接 `cat` 出来 debug** |

> 🧠 **这是"够用就好"的一个典型案例**。这个场景的 IPC 频率很低（用户点一次采访才发一条命令），延迟要求松（轮询几百毫秒完全可接受）。**上 Redis 是过度设计。**
>
> 但要注意它的代价：轮询有延迟、有磁盘 IO、没有背压机制。**如果哪天要支持高频命令，这一层就得换掉。**

### 9.3 最关键的设计：跑完不关门

`run_parallel_simulation.py` 头部功能列表：

> - 双平台（Twitter + Reddit）并行模拟
> - **完成模拟后不立即关闭环境，进入等待命令模式**
> - **支持通过 IPC 接收 Interview 命令**
> - 支持单个 Agent 采访和批量采访
> - 支持远程关闭环境命令

**这一条决定了产品形态。**

如果模拟跑完就退出，你拿到的只是一堆日志和一份报告——**一个死的结果**。

因为环境不关，你可以：
- 走进去问某个 Agent："你为什么转发了那条帖子？"
- 让 ReportAgent 去**采访**一批 Agent 来补充报告（[第 11 章](#ch11)）
- 在 Step5 里跟世界持续交互

> 🧠 **"跑完不关门"是 MiroFish 最重要的一个工程决定。**它把产品从"预测报告生成器"变成了"**可探索的平行世界**"。这两者的用户价值差了一个量级。

`simulation_runner.py` 的类清单也说明了它管的东西有多细：

| 行 | 类 |
|---|---|
| `:40` | `RunnerStatus`（Enum） |
| `:52` | **`SimulationStopPending(TimeoutError)`** |
| `:57` | `AgentAction` |
| `:84` | `RoundSummary` |
| `:110` | `SimulationRunState` |
| `:204` | `SimulationRunner` |

其中：

```python
class SimulationStopPending(TimeoutError):
    """The monitor still owns a bounded graph-ingestion finalization."""
```

**停止不是立刻的**——还要等图谱摄取收尾。这个类名和注释把"优雅关闭"这件事显式化了：它继承自 `TimeoutError`，意思是"**还没停下来，但这是预期内的，有上界**"，而不是一个错误。

`zep_graph_memory_updater.py:207` 那边还有一个对称的 `_DrainDeadlineExceeded(TimeoutError)`——**排空缓冲区也有截止时间**。两处加起来构成一条完整的关闭路径：停模拟 → 排空回写队列 → 真正退出，每一步都有上界。

---

<h2 id="ch10">第 10 章 闭环回写：模拟活动如何变回图谱记忆</h2>

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

### 10.1 机制

`zep_graph_memory_updater.py:213-226`：

> 监控模拟的 actions 日志文件，将新的 agent 活动**实时更新到 Zep 图谱**中。
> 按平台分组，每累积 `BATCH_SIZE` 条活动后批量发送到 Zep。
>
> 所有有意义的行为都会被更新到 Zep，`action_args` 中会包含完整的上下文信息：
> - **点赞/踩的帖子原文**
> - **转发/引用的帖子原文**
> - 关注/屏蔽的用户名
> - 点赞/踩的评论原文

**注意"包含完整的上下文信息"这句。** 不是记 `user_42 liked post_1337`——那样的记录事后没法检索。而是记"**user_42 点赞了『xxx原文』这条帖子**"，这样语义检索才能找到它。

### 10.2 三个参数

`:228-241`：

```python
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
```

**留了 500 字符的余量给未来的格式变化**——这是有经验的写法。注意 `BATCH_SIZE` 是**按平台各自计数**的，不是全局；两个世界互不干扰地各攒各的。

### 10.3 线程与统计

它跑在**后台工作线程**里（`_worker_thread`），有队列（`_activity_queue`）、按平台分的缓冲区（`_platform_buffers`，`:268` 注释"每个平台各自累积到 BATCH_SIZE 后批量发送"）、两把锁（`_buffer_lock` / `_acceptance_lock`）。

统计字段做得很细（`:281-287`）：

```python
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 = []
```

**批次数和条数分开统计**，失败的批次**留档**（`_failed_batches`），收尾时如果非空会打一条明确的告警（`:335-337`），最终统计一次性打全（`:344-348`）。这是给排查用的。

**`DO_NOTHING` 被显式过滤掉**（`:371-373`）：

```python
# 跳过DO_NOTHING类型的活动
if activity.action_type == "DO_NOTHING":
    self._skipped_count += 1
```

Agent 什么都没做，不该污染图谱——但**跳过的条数仍然计数**，所以你事后能知道"这场模拟里有多少比例的 Agent 在摸鱼"，这本身也是一个有意义的指标。

### 10.4 为什么这个闭环重要

```mermaid
flowchart LR
    G[("Zep 时序图谱")] -->|实体| P["Agent 人设"]
    P --> S["OASIS 模拟"]
    S -->|actions.jsonl| U["MemoryUpdater<br/>批量回写"]
    U -->|带原文上下文的活动| G
    G -->|检索| R["ReportAgent"]
    style G fill:#e8f5e9
```

**没有这个回写，报告 Agent 只能读到"模拟前的世界"。**有了它：

1. 图谱里同时有**初始事实**（来自你上传的材料）和**模拟中发生的事**
2. Zep 的**时序**能力让两者按时间线组织起来
3. ReportAgent 检索时，能拿到"事情原本是什么样 → 后来怎么演化的"完整链路

> 🧠 **一句话**：这个闭环把"**知识图谱**"从一个静态的输入，变成了一个**随模拟一起生长的记录**。这是 MiroFish 区别于"跑个多 Agent 模拟然后让 LLM 总结日志"的根本所在。

---

<h2 id="ch11">第 11 章 四件检索兵器：InsightForge / Panorama / Quick / Interview</h2>

`services/zep_tools.py`（1 734 行）给 ReportAgent 提供工具。docstring 分了两级：

> **【核心检索工具 - 优化后】**
> 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：把一个问题炸成多个

`insight_forge()`（`:943-1089`，签名里 `max_sub_queries: int = 5`，见 `:949`）的五步：

```mermaid
flowchart TB
    Q["用户问题"] --> S1["① LLM 分解为子问题<br/>_generate_sub_queries()<br/>max_sub_queries=5"]
    S1 --> S2["② 每个子问题各做一次语义搜索<br/>limit=15, scope=edges"]
    S2 --> S3["③ 对原始问题也搜一次"]
    S3 --> S4["④ 提取相关实体 + 获取详细信息"]
    S4 --> S5["⑤ 追踪关系链"]
    S5 --> S6["⑥ 整合去重，生成深度洞察"]
```

**去重用的是 `seen_facts` 集合**（`:992` 建集合，`:1003-1005` 与 `:1017-1019` 两处判重）——多个子问题会检索到重叠的事实，不去重会把上下文撑爆。

**子问题生成时会带上 `report_context`**（当前报告章节的上下文，`_generate_sub_queries()` 在 `:1090`），所以子问题是**针对当前正在写的那一节**生成的，不是泛泛而问。

> 📌 **这就是 GraphRAG 的"多跳"能力落到工程上的样子**：不是一次检索，而是**问题分解 → 并行检索 → 实体扩展 → 关系追踪 → 整合**。

### 11.2 Panorama：要全貌，包括过期的

`panorama_search()`（`:1143`）的定位是"**获取全貌，包括过期内容**"。

它有一个内部的闭包 `relevance_score(fact) -> int`（`:1213`）做排序——因为"要全貌"意味着结果会很多，必须有排序，否则等于没检索。

**为什么要"包括过期内容"**：因为在时序图谱里，"曾经成立但现在失效"的关系**恰恰是演化的证据**。比如"A 曾经支持 B（第 3–15 轮），之后转为反对"——这条过期边是报告里最有价值的素材。

对应 `EdgeInfo.is_expired()` / `is_invalid()`（`:133-142`）。

### 11.3 Interview：采访活着的 Agent

这是**全项目最有创新性的一个函数**（`:1270-1482`）。docstring 在 `:1276-1298`：

```python
"""
【InterviewAgents - 深度采访】

调用真实的OASIS采访API，采访模拟中正在运行的Agent：
1. 自动读取人设文件，了解所有模拟Agent
2. 使用LLM分析采访需求，智能选择最相关的Agent
3. 使用LLM生成采访问题
4. 调用 /api/simulation/interview/batch 接口进行真实采访（双平台同时采访）
5. 整合所有采访结果，生成采访报告

【重要】此功能需要模拟环境处于运行状态（OASIS环境未关闭）

【使用场景】
- 需要从不同角色视角了解事件看法
- 需要收集多方意见和观点
- 需要获取模拟Agent的真实回答（非LLM模拟）
"""
```

**"非 LLM 模拟"这五个字是重点。**

区别是：

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

四步流程：
1. **读人设文件**（`_load_agent_profiles`，`:1503`）
2. **LLM 选人**（`_select_agents_for_interview`，`:1549`；调用点 `:1326`）——按采访需求智能挑最相关的，返回三元组 `(selected_agents, selected_indices, selection_reasoning)`，其中 `selection_reasoning`（**为什么选这几个**）会挂到结果上（`:1334`），并出现在最终文本里（`:387`，缺省填"（自动选择）"）
3. **LLM 生成问题**（`_generate_interview_questions`，`:1632`）
4. **调 `/api/simulation/interview/batch`**——**双平台同时采访**

> 🧠 **这是把"模拟"变成"可查询数据源"的关键一步。**别的多 Agent 项目跑完就把日志丢给 LLM 总结；MiroFish 让报告 Agent **回头去问当事人**。
>
> **对做新产品的启发**：如果你的系统里跑出了一个有状态的过程，**不要只保留它的输出，想办法保留"可以继续问它问题"的能力**。前者是报告，后者是资产。

### 11.4 一个防御细节

`_clean_tool_call_response()`（`:1483`）——**静态方法**，清洗工具调用的响应。配合 [第 12 章](#ch12) ReportAgent 的 `_strip_fake_tool_results()`，两边都在防同一类问题：**LLM 在输出里混入不该有的结构化标记**。

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

---

<h2 id="ch12">第 12 章 ReportAgent：ReACT 分章生成与三道防伪</h2>

`services/report_agent.py` **2 619 行**，是最大的服务文件。

### 12.1 三阶段

`class ReportAgent`（`:871`）：

```python
"""
采用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
```

对应三个方法：`plan_outline()`（`:1176`）→ `_generate_section_react()`（`:1260`）→ `generate_report()`（`:1576`），另有 `chat()`（`:1810`）。

```mermaid
stateDiagram-v2
    [*] --> 规划: plan_outline() :1176
    规划 --> 分章循环: outline.json 落盘

    state 分章循环 {
        [*] --> 思考
        思考 --> 工具调用: 输出 tool_call
        工具调用 --> 剥假结果: _strip_fake_tool_results :1144
        剥假结果 --> 注入真结果
        注入真结果 --> 思考: 未满 5 次
        思考 --> 写本章: 已满 5 次 或 判定信息够了
        写本章 --> [*]
    }

    分章循环 --> 分章循环: 还有下一章
    分章循环 --> 反思: 全部章节写完
    反思 --> 分章循环: 发现缺口且未满轮数
    反思 --> 拼装: assemble_full_report() :2318
    拼装 --> [*]: _post_process_report() :2348
```

**每一章的产物单独落盘到 `sections/`**，所以这个循环在任何一步崩掉，已完成的章节都不会丢。

**为什么要分章生成**：一份完整报告可能上万字，一次性让 LLM 生成必然质量下降或截断。**分章 + 每章独立 ReACT**，每章都能针对性检索。

**三个上限都是可配的**（`config.py:59-61`）：

```python
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'))
```

> 📌 注意 `config.py:60` 里反思轮数默认是 **2**，但 `report_agent.py:885` 的类常量写的是 **3**。这是一处不一致。

**还有一个细节值得学**（`:1495`）：

```python
if unused_tools and tool_calls_count < self.MAX_TOOL_CALLS_PER_SECTION:
```

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

### 12.2 三道防伪

**① 剥掉伪造的工具结果**（`_strip_fake_tool_results`，`:1144-1175`）：

```python
"""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.
"""
```

**病症**：模型生成完 `<tool_call>` 之后，**自己接着把 `<tool_result>` 也编出来了**。如果不剥掉就进消息历史，模型会以为那是真的检索结果，后面全建立在幻觉上。

```mermaid
sequenceDiagram
    participant M as LLM
    participant A as ReportAgent
    participant Z as zep_tools
    M->>A: "<tool_call>quick_search…</tool_call><br/><tool_result>【伪造的检索结果】</tool_result>"
    Note over A: ① _strip_fake_tool_results() :1144<br/>深度计数扫描，剥掉伪造段<br/>连未闭合的畸形开标签一并清掉
    Note over A: ② _is_valid_tool_call() :1120<br/>校验工具名与结构
    A->>Z: 真正执行 quick_search
    Z-->>A: 真实结果
    Note over A: ③ _clean_tool_call_response()<br/>zep_tools:1483
    A->>M: 只把【真实结果】写进消息历史
```

实现是一个**带深度计数的标签扫描器**（不是简单正则替换），因为要处理嵌套。而且还额外处理了**未闭合的畸形开标签**：

```python
# Treat a malformed opening tag without a closing `>` as unsafe too.
cleaned = re.sub(r'<tool_result\b.*$', '', cleaned, flags=re.IGNORECASE | re.DOTALL)
```

**② 工具调用合法性校验**（`_is_valid_tool_call(self, data: dict)`，`:1120`）——在执行之前先判断这个 `<tool_call>` 是不是结构完整、工具名合法。

**③ 工具响应清洗**（`zep_tools._clean_tool_call_response`，`zep_tools.py:1483`）

三道加起来覆盖了一个完整链路：**进来的调用要合法（②）→ 出去的响应要干净（③）→ 模型自己编的结果要剥掉（①）**。

> 🧠 **"模型会伪造工具结果"是所有 ReACT 实现都会遇到的问题**，但很少有项目把它显式处理掉。MiroFish 不仅处理了，还考虑了嵌套和畸形标签两种边界。

### 12.3 双路日志

`report_agent.py` 开头定义了两个日志类：

| 类 | 输出 | 用途 |
|---|---|---|
| `ReportLogger`（`:36`） | `agent_log.jsonl` | **每行一个完整 JSON**，记录时间戳、动作类型、详情 |
| `ReportConsoleLogger`（`:307`） | `console_log.txt` | 控制台风格（INFO/WARNING） |

**为什么要两路**：
- `agent_log.jsonl` 给**程序**读——前端要渲染"思考 → 调用工具 → 得到结果"的时间线
- `console_log.txt` 给**人**读——排查问题时直接 `tail -f`

两者都提供流式接口（`ReportManager.get_agent_log_stream`，`:2114`；`get_console_log_stream`，`:2052`）和分页参数（`from_line`），前端靠它做增量拉取而不是每次全量重取。

> 这解释了为什么 `Step4Report.vue` 有 5 162 行——它要同时渲染这两路流、章节进度、和最终报告。

### 12.4 分章节持久化

`ReportManager`（`:1931`）的文件结构：

```
reports/{report_id}/
├── meta.json          # 报告元信息和状态
├── outline.json       # 大纲
├── progress.json      # 进度
├── sections/          # 分章节内容
├── agent_log.jsonl    # 结构化动作日志
└── console_log.txt    # 控制台日志
```

**分章节存**意味着：生成到第 5 章崩了，前 4 章还在，可以续。`assemble_full_report()`（`:2318`）最后再拼起来，`_post_process_report()`（`:2348`）做后处理。

顺带一提，这个文件里塞了 **8 个类**：`ReportLogger`(`:36`) · `ReportConsoleLogger`(`:307`) · `ReportStatus`(`:389`) · `ReportSection`(`:399`) · `ReportOutline`(`:419`) · `Report`(`:442`) · `ReportAgent`(`:871`) · `ReportManager`(`:1931`)。[第 15 章](#ch15)会把它列为硬伤。

---

<h2 id="ch13">第 13 章 深度交互：走进世界跟 Agent 说话</h2>

`Step5Interaction.vue`（2 584 行）+ `InteractionView.vue` 提供两种交互：

| 交互对象 | 能干什么 |
|---|---|
| **世界里的任何 Agent** | 直接对话——它带着人设和模拟经历回答 |
| **ReportAgent** | 追问报告内容，它会**自主调用检索工具**（`MAX_TOOL_CALLS_PER_CHAT = 2`） |

`report_agent.chat()`（`:1810`）就是第二种：

> 支持与用户对话，在对话中**自主调用检索工具**

**这一章内容不多，但它是产品闭环的最后一块。** 前面四步产出的是"一个世界 + 一份报告"，第五步让这两样都变成**可交互的**。

> 🧠 **产品设计上的观察**：大多数"AI 生成报告"类产品到第四步就结束了。MiroFish 多做的这一步，把交付物从"一份文档"变成了"**一个可以持续追问的对象**"。这是很重要的差别——用户对报告的信任，往往建立在"我能追问细节并得到一致回答"上。

---

<h2 id="ch14">第 14 章 创新性到底在哪：五个真正新的东西</h2>

这一章直接回答"创新性在哪"。我按"**在本系列十一个项目里是否见过**"来判定。

### ① 把"预测"变成"模拟"，而不是"推理"

**这是最根本的一条。**

| 路线 | 做法 | 代表 |
|---|---|---|
| **推理式预测** | 把材料喂给 LLM，让它直接说"我认为会怎样" | 绝大多数 AI 分析工具 |
| **模拟式预测** | **构建一个世界，让它自己演化，观察结果** | **MiroFish** |

区别在**可解释性**和**可干预性**：
- 推理式给你一个结论，你只能选择信或不信
- **模拟式给你一个过程**——你能看到谁在第几轮说了什么、信息怎么扩散的、哪个节点是转折点
- 而且你能**改变量重跑**（换个通报时间、换个引导策略）

> 这不是新的学术思想（社会仿真研究几十年了），但**把它做成一个上传 PDF 就能用的产品，是新的**。

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

```mermaid
flowchart LR
    A["种子材料"] -->|本体+摄取| G[("Zep 时序图谱")]
    G -->|实体→人设| S["OASIS 模拟"]
    S -->|活动带原文回写| G
    G -->|GraphRAG 检索| R["报告"]
    S -->|采访活着的 Agent| R
```

**两条回边是关键**：
1. 模拟活动**回写图谱**（[第 10 章](#ch10)）——世界的历史被结构化保存
2. 报告 Agent **采访模拟 Agent**（[第 11 章](#ch11)）——结果不只是日志摘要

本系列里的记忆系统（openworker 的 SQLite 事实、MiMo 的 FTS5、Raven 的 EverOS）都是**单向的**：Agent 写记忆、Agent 读记忆。**MiroFish 的图谱是被一整个模拟世界共同书写的。**

### ③ 采访活着的 Agent

前面说过，重复一次是因为它值得：

> 需要获取模拟 Agent 的**真实回答（非 LLM 模拟）**

**跑完不关门** + **IPC 采访通道** + **LLM 选人和拟题**，三者加起来才有这个能力。

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

回声室效应强度、病毒传播阈值、推荐算法三权重、中国人作息的五段曲线——**这些是领域知识，不是代码**。

而且是**分平台的两套取值**：Twitter 时间权重 0.4 / 破圈阈值 10 / 回声室 0.5，Reddit 是 0.3 / 15 / 0.6。这五个数字把"Twitter 快而浅、Reddit 慢而深且更抱团"量化了。

本系列其他项目的"配置"都是工程参数（超时、并发、重试）。MiroFish 的核心配置是**社会学参数**。

> 📌 这也意味着它的**竞争壁垒在别处**：抄它的代码很容易（24 000 行），但抄不走"凌晨活跃度应该设 0.05、22 点到 23 点应该有一级中间台阶"这类经验。

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

给"武汉大学"生成一个**机构官号运营设定**，而不是硬塞一个"武汉大学先生，45 岁"——这是很实际的一个洞察。

社会事件里，**机构和个人的发声逻辑完全不同**：机构要考虑立场、口径、时机；个人可以情绪化。用两套提示词分开处理，模拟出来的舆论场才像真的。

---

<h2 id="ch15">第 15 章 工程质量：亮点与硬伤</h2>

### 15.1 亮点

| 亮点 | 证据 |
|---|---|
| **测试覆盖有重点** | `backend/tests/` 18 个文件 / 3 359 行（全仓 21 个 / 4 719 行），**其中 9 个是 Zep 专项**（契约、分页、生命周期、重试、两个 barrier）——把最不可控的外部依赖测透了 |
| **降级链完整** | 人设生成七级降级；`search_graph`（`zep_tools:457`）在 Zep 搜索不可用时降级为 `_local_search`（`:542`）本地关键词匹配 |
| **重试是统一的** | `utils/retry.py` + `call_zep_read_with_retry()`，**按 Zep/HTTPX 的错误类型分类重试**，不是无脑重试；有专门的 `test_zep_retry_and_client.py` |
| **国际化彻底** | 631 个 i18n 叶子键 × 中英**双语等长**（两个文件键数完全一致），**连后端控制台日志都走 `t()`**（`console.insightForgeStart` 等） |
| **可解释性刻意保留** | `generation_reasoning`（为什么这样配参数）、`selection_reasoning`（为什么选这几个 Agent 采访）、`agent_log.jsonl`（每一步思考与调用） |
| **Windows 兼容认真做了** | import 前强制 UTF-8 + `stdout.reconfigure(errors='replace')` + 编码检测双库（chardet + charset-normalizer） |
| **关闭路径有上界** | `SimulationStopPending` 与 `_DrainDeadlineExceeded` 两个 `TimeoutError` 子类，把"优雅关闭"写成了类型 |

**特别值得单独说一下 i18n 的实现**（`frontend/src/i18n/index.js`，全文 27 行）：

```js
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 种（zh / en / es / fr / pt / ru / de），每种带 `label` 和 **`llmInstruction`**：

```json
"fr": { "label": "Français", "llmInstruction": "Veuillez répondre en français." }
```

而 `availableLocales` 取的是**注册表 ∩ 实际存在的翻译文件**。目前只有 `zh.json` / `en.json` 存在，所以切换器只显示两种——**不会出现"选了没用"的空档**。

**加一种语言 = 丢一个 `locales/xx.json` 进去，零代码改动。**

**而后端读的是同一个目录。** `backend/app/utils/locale.py:8` 里那串 `'..', '..', '..', 'locales'` 一路跳出 backend，指向项目根的 `locales/`：

```python
_locales_dir = os.path.join(os.path.dirname(__file__), '..', '..', '..', 'locales')
```

前端 Vite glob 和后端 `os.listdir` **扫的是同一批文件**。加一种语言，前端菜单、后端日志、LLM 输出语言**同时**生效，一处都不用改。

`llmInstruction` 那个字段更妙：它不是给界面用的，是给**模型**用的。`get_language_instruction()`（`locale.py:66-69`）取出来拼进 **7 个提示词构造点**：

```python
def get_language_instruction() -> str:
    locale = get_locale()
    lang_config = _languages.get(locale, _languages.get('zh', {}))
    return lang_config.get('llmInstruction', '请使用中文回答。')
```

| 调用点 | 生成什么 |
|---|---|
| `ontology_generator.py:227-228` | 本体 |
| `oasis_profile_generator.py:719` `:765` `:814` | 人设（通用 / 个人 / 群体） |
| `simulation_config_generator.py:591` `:708` `:872` | 时间配置 / 事件配置 / Agent 配置 |

**而每一个注入点后面都紧跟一条"但这些字段必须是英文"的例外声明**，这个模式非常值得学：

```python
# 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."
```

甚至连 `gender` 都要按分支区别对待——个人分支要 `male`/`female`（`:765`），群体分支要 `"other"`（`:814`）。

> 🔑 **这是多语言 LLM 应用里最容易踩的坑，也是最重要的一条纪律**：
>
> **自然语言字段跟随用户语言，枚举值、类型名、字段名永远是英文。**
>
> 否则你让模型"用法语回答"，它会把 `stance: "supportive"` 写成 `stance: "favorable"`，下游 `if stance == 'supportive'` 直接失配——而且这种 bug 只在切到非默认语言时才出现，最难查。MiroFish 在**每一个**注入点都写了这条例外，一个没漏。

还有一处细节：`t()`（`:35`）的语言取值走 `get_locale()`（`:29-33`），**有请求上下文时读 `Accept-Language` 头，没有时读线程本地变量**：

```python
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')
```

配套的 `set_locale()`（`:24`）注释写着 **"Call at the start of background threads"**——因为模拟和报告生成都跑在后台线程里，**没有 Flask 请求上下文**。如果不显式传递，后台线程打出来的日志就会全部退回中文。

而且 `t()` 还做了**两级回退**：目标语言没有这个键 → 回退到 `zh`（`:46-54`）→ 还没有就**返回键名本身**（`:57`），绝不返回 `undefined` 或空串。参数插值用的是朴素的 `{k}` 字符串替换（`:59-61`）。

> 🧠 **一张 JSON 注册表同时驱动：前端菜单 + 后端日志 + LLM 输出语言 + 缺失回退**。这是我在本系列里见过的最干净的 i18n 设计，而且总共不到 80 行代码。

### 15.2 硬伤

**① 只能用 Zep Cloud，且强制**

```python
if os.environ.get("ZEP_API_URL"):
    errors.append("ZEP_API_URL 不受支持；MiroFish 仅连接 Zep Cloud")
```

- 数据必须出境到第三方
- 免费额度有限（README 说"简单使用够用"）
- **服务挂了整个产品不可用**
- 对企业客户（政务、金融）这几乎是致命的

**② 成本没有护栏**

README 的环境变量说明里（`README.md:120`）自己写了一行警告注释：

> `# High consumption, try simulations with fewer than 40 rounds first`

**这条警告出现在 `.env` 示例的注释里**——也就是说，唯一的成本保护措施是"希望用户读注释"。

一次模拟 = 数百个 Agent × 数十轮 × 每轮 LLM 调用。代码里**没有看到 token 预算控制或成本估算**。用户很容易一次跑掉几百块。

对比 MiMo Code 的 `/context-limit`（把计费档位编码进配置）——**MiroFish 在成本这一块基本是裸奔的**。

**③ 单文件过大**

| 文件 | 行数 |
|---|---|
| `frontend/Step4Report.vue` | **5 162** |
| `backend/api/simulation.py` | **2 878** |
| `backend/services/report_agent.py` | **2 619** |

`report_agent.py` 一个文件里塞了 **8 个类**（两个日志器、四个数据类、Agent、Manager）。**日志器和报告管理器完全可以拆出去**——它们和 ReACT 循环没有任何耦合。

**④ 文档与实现不一致**

- `report_agent.py:3` 的 docstring 写着"使用 **LangChain** + Zep 实现 ReACT 模式"，但 `requirements.txt` 里**没有 LangChain**——ReACT 循环是手写的。这大概率是早期用过 LangChain、后来拆掉了但 docstring 忘了改
- `MAX_REFLECTION_ROUNDS` 类常量（`report_agent.py:885`）是 3，`config.py:60` 默认是 2

**⑤ 本体归一化会静默破坏非 ASCII 类型名**

见 [§4.4](#ch4)。`_to_pascal_case` / `_to_upper_snake_case` 的 `[^a-zA-Z0-9]` 会把中文整段吃掉，多个中文类型坍塌成同一个 `Unknown` 后在 `entity_types` 字典里互相覆盖，**不抛异常、不打日志**。

这是本文唯一一处**用可运行复现验证出来的缺陷**——把源码函数抠出来直接跑就能看到。目前唯一的防线是提示词里的 IMPORTANT 声明，而它正好拼在"请使用中文回答"后面。

修法很简单：归一化函数在结果为空时应该 `raise`，而不是 `return 'Unknown'`。

**⑥ 无持久化数据库**

`models/` 只有 dataclass，状态靠 JSON 文件落盘。单机够用，但：
- 没有并发控制
- 没有事务
- 多实例部署会打架

**⑦ AGPL-3.0**

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

---

<h2 id="ch16">第 16 章 对做新产品的十二条启发</h2>

这一章是本文的落点。

### ① 把最难的部分外包，只写粘合层

MiroFish 用 **24 000 行**撑起 6.9 万星，靠的是：
- 社会仿真 → **OASIS**
- 时序记忆 → **Zep Cloud**
- 模型 → **任意 OpenAI 兼容 API**

**它自己只写"怎么把这三样接起来，并且让普通人能用"。**

> **反过来想**：如果这个团队先花半年自研仿真引擎和图数据库，很可能到今天还没发布。**先验证点子，再考虑替换依赖。**

### ② 领域常识就是护城河

回声室强度、病毒阈值、凌晨 0.05 倍活跃度——**这些数字抄不走**。

**如果你要做一个模拟/预测类产品，先想清楚：你的领域里有哪些"老手才知道"的经验参数？** 把它们显式化、可配置化，就是产品的核心资产。

### ③ 上游用提示词约束下游的形态要求

本体生成时明确列出"**不可以是**：抽象概念 / 主题 / 观点"——因为下游要把每个实体变成社交账号。

**在流水线的第一步就把不合规的东西挡住，比在第五步做过滤便宜得多。**而且**列反例比说正面要求有效**。

### ④ 约束是设计的助产士

Zep 限制 10 类实体 → 逼出了"**8 个具体 + 2 个兜底**"的层次设计。

这个设计**比不受限时更好**——它同时保证了分辨率和覆盖率。

> **下次遇到外部限制，先别急着绕过去，想想它是不是在逼你做一个更好的设计。**

### ⑤ LLM 生成结构化数据：分步 + 归一化 + 多级降级

三件套，缺一不可：

| 环节 | 做法 |
|---|---|
| **分步** | `simulation_config_generator` 拆成时间/事件/Agent分批/平台四步 |
| **归一化** | `utils/ontology.py`：格式转换 + 保留字规避 + 去重 + 空值兜底 |
| **降级** | 人设生成七级降级，最差也给出可用的基础结构 |

### ⑥ 让过程可查询，而不只是可读

**"跑完不关门"** + **采访 API** = 把一次性的模拟变成了可持续查询的数据源。

> **如果你的系统里跑出了一个有状态的过程，不要只保留它的输出。想办法保留"能继续问它"的能力。**报告是消耗品，可交互的世界是资产。

### ⑦ 记录"为什么"，不只记录"是什么"

`generation_reasoning`、`selection_reasoning`、`agent_log.jsonl`——**决策时就把理由存下来**。

事后想解释"当初为什么这么配"，靠猜是猜不出来的。

### ⑧ 防模型伪造工具结果

`_strip_fake_tool_results()` 处理的是一个**所有 ReACT 实现都会遇到但很少人显式处理**的问题：模型自己把 `<tool_result>` 编出来。

**任何让模型输出结构化标记的系统，都要假设它会伪造。**而且要处理嵌套和畸形标签。

### ⑨ 够用就好：文件系统 IPC 的价值

低频、低延迟要求的进程间通信，**文件系统完全够用**，而且零依赖、跨平台、崩了状态还在、能直接 `cat` 出来 debug。

> **上 Redis 之前，先问一下这个场景的 QPS 是多少。**

### ⑩ 多语言 LLM 应用的铁律：只有自然语言字段跟随语言

`get_language_instruction()` 的 7 个注入点，**每一个后面都跟着一条"但这些字段必须是英文"**（[第 15 章](#ch15)）。

**如果你的产品要支持多语言，从第一行代码就把这条纪律立起来**：

| 跟随用户语言 | 永远英文 |
|---|---|
| description / summary / content / 人物设定 | 类型名（`PersonEntity`） |
| 报告正文 / 界面文案 | 枚举值（`supportive` / `male`） |
| LLM 的推理说明 | JSON 字段名、数值 |

否则 bug 只在非默认语言下出现，几乎不可能在测试里发现。

### ⑪ 一张注册表驱动全栈 i18n

`locales/languages.json` 同时被前端 Vite glob 和后端 `os.listdir` 读取，一处新增语言，前端菜单 / 后端日志 / LLM 输出语言同时生效。而且 `availableLocales` 取的是**注册表 ∩ 实际翻译文件**的交集，不会出现"菜单里有但选了没用"。

**总共不到 80 行代码。** 值得直接抄。

### ⑫ 产品叙事的射程决定天花板

严肃场景（舆情、金融、政策）+ 娱乐场景（**推演《红楼梦》结局**）用同一个引擎——这个组合让"预测万物"这个宏大的口号变得可信。

> **一个只能做 A 的工具，和一个能做 A 也能做 B 的引擎，估值不在一个量级。**关键是找到那个能证明"通用性"的、出人意料的 B。

---

<h2 id="ch17">第 17 章 它在本系列里的位置</h2>

### 17.1 一张表

| 维度 | 本系列前十一个 | **MiroFish** |
|---|---|---|
| **核心问题** | 怎么让 AI 替我干活 | **怎么让一群 AI 替我预演** |
| **Agent 数量** | 1 个主 + 少量子 agent | **成百上千个，平等互动** |
| **Agent 的目的** | 完成任务 | **表现得像那个人** |
| **成功标准** | 任务做对了 | **演化出的宏观现象像真的** |
| **循环形态** | ReAct（想→做→观察） | **社会演化**（发帖→被推荐→被互动→影响他人） |
| **记忆** | Agent 自己读写 | **一整个世界共同书写的时序图谱** |
| **交付物** | 代码 / 文件 / 设计稿 | **一份报告 + 一个可以走进去的世界** |
| **代码量** | 数万到数十万行 | **24 000 行 Python** |
| **护城河** | 工程复杂度 | **领域常识参数 + 产品点子** |
| **许可** | 多为 MIT / Apache | **AGPL-3.0**（商业防御） |

### 17.2 一张坐标图

```mermaid
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]
```

**右上角只有 MiroFish 一个。** 这不是说它更好，是说它在**回答另一个问题**。

### 17.3 三句话总结

**它是什么**：一个把"社会仿真"这件学术界做了几十年的事，包装成"上传 PDF + 说一句话"就能用的产品。技术栈是 **Flask + Vue + OASIS + Zep Cloud + 任意 OpenAI 兼容模型**，自研代码只有 24 000 行 Python。

**创新在哪**：① 用**模拟**而非推理来预测；② 知识图谱与模拟的**双向闭环**；③ 能**采访活着的 Agent**；④ 把**社会科学参数**做成产品配置；⑤ **个人与机构人设分野**。

**最值得学什么**：**把最难的部分外包，把领域常识变成护城河，把一次性的过程变成可持续查询的资产。**

> 🧠 **最后一句**：本系列前十一个项目都在同一条赛道上比谁的 Agent 更能干。MiroFish 换了个问题——**"如果不用一个很能干的 Agent，而是用一千个很像人的 Agent，能做什么？"**
>
> 答案是：**你可以在事情发生之前，先把它跑一遍。**
>
> 这个答案对不对，8 个月 6.9 万星给了一个初步的市场判断。但它真正的启发在于：**当所有人都在优化"单个 Agent 的能力上限"时，"很多个平庸 Agent 的涌现行为"可能是一片没人认真做过的空地。**

---

## 附录 A · 源码导览索引

| 想看什么 | 去哪个文件 |
|---|---|
| 应用装配与蓝图注册 | `backend/app/__init__.py`（`:8-10` 警告抑制 · `:25-27` 中文 JSON · `:47` 清理注册 · `:67-69` 蓝图） |
| 全部配置（含社会学参数默认值） | `backend/app/config.py`（`:41-42` 切块 · `:49-56` 双平台动作集 · `:59-61` 报告上限 · `:71-72` 拒绝自建 Zep） |
| 本体生成 + 10 类层次设计提示词 | `backend/app/services/ontology_generator.py`（732 行；提示词 `:48-190`，层次要求 `:113-136`，代码兜底 `:500-544`） |
| 本体归一化（去重/截断/兜底） | `backend/app/utils/ontology.py`（全文 100 行；`:6-8` 三个上限 · `:9-17` 保留字表 · `:19-23` 兜底属性 · `:26` `:52` `:73` 三个归一化函数） |
| 图谱构建与批量摄取 | `backend/app/services/graph_builder.py`（879 行；`BatchSubmission:52` · **保留字改名 `safe_attr_name:324-328`**） |
| 实体读取与过滤 | `backend/app/services/zep_entity_reader.py`（446 行） |
| **人设生成（个人 vs 群体）** | `backend/app/services/oasis_profile_generator.py`（1 248 行；`__post_init__:111` · `GROUP_ENTITY_TYPES:238` · `_is_group_entity:536` · 两套提示词 `:721` `:770` · JSON 抢救 `:700-714`） |
| **社会学参数** | `backend/app/services/simulation_config_generator.py`（`CHINA_TIMEZONE_CONFIG:30` · `TimeSimulationConfig:85` · `EventConfig:115` · `PlatformConfig:131` · **双平台差异化取值 `:342-360`**） |
| 双平台管理 | `backend/app/services/simulation_manager.py`（564 行） |
| **模拟运行器** | `backend/app/services/simulation_runner.py`（2 033 行；`SimulationStopPending:52` · `register_cleanup:1554`） |
| **文件系统 IPC** | `backend/app/services/simulation_ipc.py`（394 行；`Client:95` / `Server:288`） |
| **闭环回写** | `backend/app/services/zep_graph_memory_updater.py`（791 行；参数 `:228-241` · 统计 `:281-287` · `DO_NOTHING` 过滤 `:371-373`） |
| **四件检索兵器** | `backend/app/services/zep_tools.py`（1 734 行；`is_expired:133` · `search_graph:457` · `_local_search:542` · InsightForge `:943` · Panorama `:1143` · Quick `:1235` · **Interview `:1270`** · 选人 `:1549` · 拟题 `:1632`） |
| **ReportAgent** | `backend/app/services/report_agent.py`（2 619 行；三个上限 `:882-888` · **防伪 `:1144`** · ReACT `:1260` · `chat:1810` · 日志器 `:36` `:307` · Manager `:1931`） |
| **全栈 i18n** | `backend/app/utils/locale.py`（80 行；`_locales_dir:8` · `get_locale:29` · `t:35` · `get_language_instruction:66`）+ `frontend/src/i18n/index.js`（27 行）+ `locales/languages.json` |
| 双平台并行脚本 | `backend/scripts/run_parallel_simulation.py`（1 699 行；Windows 编码 `:29-47`） |
| LLM 客户端与重试 | `backend/app/utils/llm_client.py` · `retry.py` |
| Zep 客户端与分页 | `backend/app/utils/zep.py` · `zep_paging.py` · `zep_lifecycle.py` |
| 前端五步 | `frontend/src/components/Step{1..5}*.vue`（最大 `Step4Report.vue` 5 162 行） |
| 知识图谱可视化 | `frontend/src/components/GraphPanel.vue`（1 423 行，d3） |

## 附录 B · 测试清单

全仓 **21 个测试文件 / 4 719 行**，其中 `backend/tests/` **18 个 / 3 359 行**。

**Zep 专项占 9 个（全部 18 个里的一半）**，说明团队清楚最大的风险在哪：

| 测试 | 覆盖什么 |
|---|---|
| `test_zep_cloud_contracts.py` | **Zep Cloud API 契约** |
| `test_zep_cloud_validation_script.py` | 连通性校验脚本 |
| `test_zep_simulation_barrier.py`（484 行） | 模拟与图谱写入的屏障 |
| `test_zep_report_barrier.py` | 报告读取前的写入屏障 |
| `test_zep_graph_lifecycle.py` | 图谱生命周期 |
| `test_zep_graph_memory_updater.py` | **闭环回写** |
| `test_zep_edge_paging.py` · `test_zep_entity_reader_edges.py` | 分页 |
| `test_zep_retry_and_client.py`（112 行） | 重试分类 |
| `test_ontology_generator.py` · `test_ontology_attributes.py` · `test_ontology_api_errors.py` | 本体生成与归一化 |
| `test_llm_json_responses.py` · `test_openai_chat_compat.py` | **LLM 返回值畸形处理** |
| `test_profile_field_normalization.py` · `test_platform_profiles.py` | 人设归一化与双平台格式 |
| `test_report_tool_result_sanitizer.py` | **防伪工具结果** |
| `test_simulation_prepare_failure.py` | 准备阶段失败路径 |
| `backend/scripts/test_profile_format.py` | 人设格式（脚本侧） |
| `tests/test_local_star_count_fetch.py` · `test_local_star_history.py` | ⭐ **给 README 的 star history 图表写的测试**——他们连自己那张星标曲线 SVG 的生成脚本都测了 |

> 📌 **"两个 barrier 测试"值得注意**：`test_zep_simulation_barrier` 和 `test_zep_report_barrier` 测的是同一类问题——**模拟活动写进 Zep 之后、报告去读之前，中间必须有一道等待屏障**，否则报告读到的是半截图谱。这是[第 10 章](#ch10)那个闭环最脆弱的地方，他们用两个测试文件（其中一个 484 行）盯住了它。

---

*本文基于 `666ghj/MiroFish` commit `60757b3`（2026-07-23 04:47 UTC）逐文件核对写成。全部 106 个源文件（排除图片与 lock）均已抓取通读；所有 `文件:行号` 引用已用 `grep -n` / `sed -n` 逐条回验；统计数字由 GitHub Git Tree API 全量遍历计算（128 条 blob，`truncated: false`），社区数据由 GitHub REST API 于 2026-07-28 取得。*

**本系列其他分析** → [总目录](../总目录.md) · [横向对比与总结评价](横向对比与总结评价.md)
