fix simulation reporting and research resilience
This commit is contained in:
@@ -107,8 +107,3 @@ with DDGS() as ddgs:
|
||||
## 五、与上一份记录的关系
|
||||
|
||||
这是第二份问题记录(第一份是 [问题记录-硬编码与千人模拟.md](./问题记录-硬编码与千人模拟.md))。
|
||||
|
||||
两者的区别:
|
||||
|
||||
- **第一份** 只聚焦硬编码 / 千人模拟的业务问题(问题一),是完整提问人视角的论述。
|
||||
- **这份** 把搜索替换作为独立问题追加进来,并标注"今天已修 / 明天待办"的拆分。
|
||||
@@ -0,0 +1,150 @@
|
||||
# 问题记录:2026-09-28 — 居民生成兜底分支的硬编码污染
|
||||
|
||||
> 状态:仅记录问题,不附解决方案
|
||||
> 记录人:项目相关方
|
||||
> 与上一份记录的关系:第三份问题记录(之前两份:[硬编码与千人模拟](./问题记录-硬编码与千人模拟.md)、[2026-09-27 硬编码与搜索替换](./问题记录-2026-09-27-硬编码与搜索替换.md))
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
在排查居民命名逻辑时发现,[`backend/app/services/citizen_generator.py`](../backend/app/services/citizen_generator.py) 的 `_generate_cast` 末尾有一段"L1 数量兜底"分支:当 LLM 返回的居民数少于 `count` 时,会用一份**硬编码模板**把剩余名额补齐。
|
||||
|
||||
这条分支只在 LLM 翻车时才触发,正常路径下不会执行。但它一旦触发,会**完整流过后续所有生成阶段**,并且会污染最终模拟。
|
||||
|
||||
---
|
||||
|
||||
## 二、现象
|
||||
|
||||
在 [`citizen_generator.py` L422–L441](../backend/app/services/citizen_generator.py#L422-L441):
|
||||
|
||||
```python
|
||||
if len(cast) < count:
|
||||
logger.warning("居民生成仅返回 %d/%d 人,将使用默认居民补齐", len(cast), count)
|
||||
while len(cast) < count:
|
||||
idx = len(cast)
|
||||
if self._locale == "zh":
|
||||
cast.append({
|
||||
"name": f"居民{idx + 1}号",
|
||||
"role": "劳动者",
|
||||
"age": 30 + (idx % 40),
|
||||
"personality_hook": "一个安静过日子的人",
|
||||
"stance": "对规则漠不关心",
|
||||
})
|
||||
else:
|
||||
cast.append({
|
||||
"name": f"Citizen {idx + 1}",
|
||||
"role": "Laborer",
|
||||
"age": 30 + (idx % 40),
|
||||
"personality_hook": "A quiet person trying to get by",
|
||||
"stance": "Indifferent to the rules",
|
||||
})
|
||||
```
|
||||
|
||||
触发条件只有一条:`len(cast) < count`。可能的成因:
|
||||
|
||||
1. LLM 真的只生成了少于 `count` 个(截断 / token 超限)
|
||||
2. LLM 返回的不是 list(比如返回了 dict 包 citizens 字段但缺失),被外层容错吞掉
|
||||
3. JSON 解析失败后 `parse_json` 返回默认 dict,`cast = []`,整批都走兜底
|
||||
|
||||
---
|
||||
|
||||
## 三、我观察到的问题
|
||||
|
||||
### 问题 1:兜底居民会完整流过后续所有阶段
|
||||
|
||||
兜底 dict 不是终态。它会进入 `_generate_personas`([L445–L543](../backend/app/services/citizen_generator.py#L445-L543)),在那里:
|
||||
|
||||
- `member["name"]` → 写入 `agent.name`([L522](../backend/app/services/citizen_generator.py#L522))
|
||||
- `member["personality_hook"]` → 在 persona 阶段被当作 `background` 兜底([L526](../backend/app/services/citizen_generator.py#L526))
|
||||
- `member["stance"]` → 进入 persona prompt,**直接影响 LLM 生成的 `beliefs`**
|
||||
- `communication_style` 字段缺失 → 走 `random.choice(...)`([L518](../backend/app/services/citizen_generator.py#L518))
|
||||
- personality 各维度 → 因为 dict 里没有该字段,走 `_clamp(..., 0.5)`,**全部中位**
|
||||
|
||||
后续 `_generate_life_histories` / `_generate_relationships` 完全依赖这些字段**二次调 LLM 生成**——也就是说兜底居民**不会**因为字段缺失而崩,但会带着一套"模板种子"反向编造一份合理履历。
|
||||
|
||||
### 问题 2:污染路径有放大效应
|
||||
|
||||
兜底居民的四个硬编码值不是独立字段,它们会**耦合放大**:
|
||||
|
||||
- `personality_hook = "一个安静过日子的人"` → 提示 LLM 该居民"安静/内向"
|
||||
- `stance = "对规则漠不关心"` → 提示 LLM 该居民"政治冷漠"
|
||||
- personality 全 0.5 中位 → LLM 接收"无显著倾向"信号
|
||||
- `role = "劳动者"` + `age = 30 + idx%40` → 提示 LLM 该居民是普通劳动者
|
||||
|
||||
最终产出大概率是一群"性格中位、政治冷漠、安于现状的劳动者",**同质化严重**。
|
||||
|
||||
### 问题 3:会人为制造"政治冷漠者"集群
|
||||
|
||||
`stance = "对规则漠不关心"` 这个种子会被 LLM 在 persona 阶段放大成 beliefs:
|
||||
|
||||
- "我对规则不感兴趣"
|
||||
- "我只想过自己的日子"
|
||||
- "别来烦我"
|
||||
|
||||
如果兜底补了 5–10 个(比如 LLM 完全没返回),社会派系分布里会**凭空出现一整批"冷漠者"**——而这个派系在原始 world blueprint 里可能根本不是预设张力。**世界张力被人为扭曲了。**
|
||||
|
||||
### 问题 4:兜底数量没有熔断
|
||||
|
||||
`while len(cast) < count` 是无条件循环。**没有上限保护、没有重试、没有错误抛出**。
|
||||
|
||||
最坏情况:
|
||||
|
||||
- `count = 25`,LLM 返回 `[]`
|
||||
- 循环 25 次,全部生成 `居民1号` … `居民25号`
|
||||
- 全部走 persona → life_history → relationship
|
||||
- 整个社会的居民全是"居民N号 + 安静 + 漠不关心 + 中位 personality"
|
||||
|
||||
**没有 API 错误、没有日志 ERROR、没有任何给前端的信号**。只剩一条 `logger.warning`,调用方默认看不到。
|
||||
|
||||
### 问题 5:兜底名会直接暴露给用户
|
||||
|
||||
`"居民{idx+1}号"` 和 `"Citizen {idx+1}"` 最终会出现在:
|
||||
|
||||
- 前端 AgentSwarmCanvas / AgentDetailPanel 的姓名展示
|
||||
- 叙事生成(narrator)的人名引用
|
||||
- 关系网中的 target 名称
|
||||
- 报告分析里的称呼
|
||||
|
||||
用户会看到"居民3号"这种程序化名字出现在叙事里——这不只是不美观,而是**暴露了系统的内部失败模式**,对产品可信度是直接打击。
|
||||
|
||||
### 问题 6:兜底逻辑掩盖了上游问题
|
||||
|
||||
`_generate_cast` 上游已经有三层容错:
|
||||
|
||||
- `parse_json` 容错(如果失败返回默认 dict)
|
||||
- dict/list 容错([L390–L395](../backend/app/services/citizen_generator.py#L390-L395))
|
||||
- zh 模式重试([L399–L420](../backend/app/services/citizen_generator.py#L399-L420))
|
||||
|
||||
到了末尾还要兜底,说明上游任何一层吞掉错误都**静默变成兜底数据**,没有 signal 上浮。运维视角下,**没法区分"LLM 健康但数量不够"和"LLM 完全翻车"**——这两种情况走的代码路径不同,但落到调用方是同一个结果。
|
||||
|
||||
---
|
||||
|
||||
## 四、待澄清的几个点
|
||||
|
||||
- LLM 返回 < count 的实际频率是多少?是否有线上数据?
|
||||
- 触发兜底后,用户实际看到的体验是什么样?)
|
||||
- 兜底数据应该让前端感知到吗?(比如标灰、标"系统生成"、给个 retry 按钮)
|
||||
- persona 阶段是否应该检测兜底居民并**主动反向纠正**其"冷漠"立场?
|
||||
- 兜底策略本身合理吗?还是应该**直接报错让用户重试**?
|
||||
- 当前 `logger.warning` 之外,是否应该有结构化告警(metrics / trace)?
|
||||
|
||||
---
|
||||
|
||||
## 五、相关代码位置
|
||||
|
||||
- 兜底分支:[`backend/app/services/citizen_generator.py` L422–L441](../backend/app/services/citizen_generator.py#L422-L441)
|
||||
- 上游 LLM 调用:[`backend/app/services/citizen_generator.py` L381–L387](../backend/app/services/citizen_generator.py#L381-L387)
|
||||
- 中文重试:[`backend/app/services/citizen_generator.py` L399–L420](../backend/app/services/citizen_generator.py#L399-L420)
|
||||
- persona 阶段对兜底字段的消费:[`backend/app/services/citizen_generator.py` L501–L537](../backend/app/services/citizen_generator.py#L501-L537)
|
||||
- LLM 客户端 / `parse_json`:[`backend/app/services/llm.py`](../backend/app/services/llm.py)
|
||||
|
||||
---
|
||||
|
||||
## 六、跟前面两份记录的关系
|
||||
|
||||
- 第一份([硬编码与千人模拟](./问题记录-硬编码与千人模拟.md))关注的是**运行时**的硬编码抽样参数
|
||||
- 第二份([2026-09-27 硬编码与搜索替换](./问题记录-2026-09-27-硬编码与搜索替换.md))关注的是**搜索后端**的硬编码依赖
|
||||
- 这份(2026-09-28)关注的是**生成时**的硬编码兜底
|
||||
|
||||
三份都指向同一个母题:**业务承诺("千人社会涌现" / "千人社会模拟")和实际实现(少数人表态 / LLM 翻车时静默补齐 / 搜索依赖不稳定)之间的不一致**。建议未来评估时一起看。
|
||||
@@ -0,0 +1,270 @@
|
||||
# 问题记录:2026-09-28 — 报告缺失"系统性总结",概念无数据来源
|
||||
|
||||
> 状态:待处理
|
||||
> 记录人:项目相关方
|
||||
> 触发场景:跑完一次 30 天模拟(`sim_id=8a2c6953-224`,10 人 / 90 轮 / 2238 秒 / 无报错),拿到报告 PDF 后**读不懂**。
|
||||
> 与已有记录的关系:第四份问题记录。前三份见 [目录](./)
|
||||
|
||||
---
|
||||
|
||||
## 一、背景与现象
|
||||
|
||||
30 天模拟正常跑完(`engine_finished` + `simulation_run_complete` 都在日志里,无 `pipeline_failed`),但生成的报告**读起来"不明不白"**:
|
||||
|
||||
- 概念讲得含糊:"低冲突与高信任掩盖了深层不稳定"——读完不知道具体指哪个字段
|
||||
- 怀疑有数据没被记录/没被带出来:"可能是不完整,或者有些该说的东西没说或者没记录"
|
||||
|
||||
本文件拆解这个"看不懂",并给出改造方案。
|
||||
|
||||
---
|
||||
|
||||
## 二、先纠正一个误判:数据其实是全的
|
||||
|
||||
我最初怀疑"日志只存了 6 个指标,导致报告里其他概念找不到数据来源"。**这个判断是错的**,实际排查结果:
|
||||
|
||||
### 2.1 DB 里 14 个指标全存
|
||||
|
||||
[`WorldMetrics`](../backend/app/models/world.py#L30-L43) 定义了 14 个字段:
|
||||
|
||||
```python
|
||||
stability, prosperity, trust, freedom, conflict,
|
||||
brand_sentiment, purchase_intent, word_of_mouth, churn_risk, adoption_rate,
|
||||
information_spread, echo_chamber_index, rumor_distortion
|
||||
```
|
||||
|
||||
[`store.save_metrics`](../backend/app/db/store.py#L231-L237) 用 `metrics.model_dump_json()` **全量序列化**,`get_metrics_history` 原样反序列化返回。
|
||||
|
||||
**所以 `conflict`(冲突)、`freedom`(自由度)这些"报告里提到但日志里没看到"的指标,DB 里一直有。**
|
||||
|
||||
### 2.2 只有 JSONL 日志是 6 个
|
||||
|
||||
[`sim_logger.log_round`](../backend/app/services/sim_logger.py#L153-L160) 只写 6 个 market/social 指标:
|
||||
|
||||
```python
|
||||
"metrics": {
|
||||
"brand_sentiment": ..., "purchase_intent": ..., "churn_risk": ...,
|
||||
"adoption_rate": ..., "stability": ..., "trust": ...,
|
||||
}
|
||||
```
|
||||
|
||||
**结论**:`logs/*.jsonl` 缺 `conflict / prosperity / freedom / information_spread / echo_chamber_index / rumor_distortion` 是**日志抽样**行为,不是数据丢失。报告的数据源是 DB,**数据完整**。
|
||||
|
||||
### 2.3 真正的问题定位
|
||||
|
||||
既然数据是全的,那"看不懂"只能是**报告生成 pipeline 的加工方式**问题。这才是本记录的核心。
|
||||
|
||||
---
|
||||
|
||||
## 三、报告生成 pipeline 现状
|
||||
|
||||
[`narrator.generate_report`](../backend/app/services/narrator.py#L226-L486) 现在是 5 段式:
|
||||
|
||||
| Pass | 输入 | 输出 | 实现位置 |
|
||||
|------|------|------|---------|
|
||||
| 0 | 全部叙事文本 | 每 30 天一个 epoch 摘要(历史学家口吻) | [L240-L288](../backend/app/services/narrator.py#L240-L288) |
|
||||
| 1 | 指标历史 + 行动 + 居民 | `metric_stats` / `agent_stats` / `segment_stats` / `inflection_points` / `confidence` | [L290-L297](../backend/app/services/narrator.py#L290-L297) |
|
||||
| 1b | metrics_history | forecast 因果 / 反事实 / 一致性 | [L299-L326](../backend/app/services/narrator.py#L299-L326) |
|
||||
| 2 | epoch 摘要 + metric_stats + inflection + agents | `executive_brief`(headline+summary)、`metric_explanations`、`insights`(3-5 条) | [L328-L380](../backend/app/services/narrator.py#L328-L380) |
|
||||
| 3 | segment_stats | 每 segment 的 reaction / objection / champion / 代表语 | [L382-L415](../backend/app/services/narrator.py#L382-L415) |
|
||||
| 4 | brief + insights + segments + scorecard | `action_items` / `risks` / `second_order_effects` | [L417-L441](../backend/app/services/narrator.py#L417-L441) |
|
||||
|
||||
最终 `report` 字典在 [L452-L478](../backend/app/services/narrator.py#L452-L478) 组装。
|
||||
|
||||
---
|
||||
|
||||
## 四、"看不懂"的四个具体成因
|
||||
|
||||
### 4.1 缺少"时间轴串联",产物是拼盘不是故事
|
||||
|
||||
Pass 0 产出了按时间顺序的 epoch 摘要,Pass 2/3/4 又产出了洞察、分群、建议,**但没有任何一个 pass 把这些按时间轴串成一篇完整叙述**。
|
||||
|
||||
最终结构是:
|
||||
|
||||
```
|
||||
executive_brief.summary ← 1 段话
|
||||
scorecard ← 9 行「指标: 评级 (值) — 解释」
|
||||
insights ← 3-5 条
|
||||
segments ← N 段
|
||||
action_items / risks ← 各 3-5 条
|
||||
```
|
||||
|
||||
读者要自己在脑子里把这些拼回去,所以"每个部分都懂,合起来不知道在讲什么"。
|
||||
|
||||
### 4.2 `metric_explanations` 是逐指标独立生成,没有交叉引用
|
||||
|
||||
[`INTERPRET_METRICS_SYSTEM`](../backend/app/services/narrator.py#L46-L79) 要求"每个指标一句解释 WHY"。这导致:
|
||||
|
||||
- 每条解释**孤立**,不说"trust 高是因为 conflict 低"这类**指标间关系**
|
||||
- 读者看到 trust=100/100 和 conflict=低/xx 两行,**不会自动联想到"这俩矛盾"**
|
||||
- 于是报告只能靠 Pass 2 的 `insights` 补一句文学化描述(如"掩盖了深层不稳定")——**这句在替数据做结论,但没给出推导过程**
|
||||
|
||||
### 4.3 `insights` 允许"不可追溯",且有 `surprise` 类型
|
||||
|
||||
`INTERPRET_METRICS_SYSTEM` 要求 insights "Things that were NOT predictable from the simulation setup alone",且 `evidence` 是**可选字段**([L71-L75](../backend/app/services/narrator.py#L71-L75))。
|
||||
|
||||
后果:LLM 可以写一条听起来很有洞察、但**没有具体 agent / day / action 支撑**的结论。这正是"讲得不明不白"的直接来源——**读者无法判断哪句有据、哪句是猜的**。
|
||||
|
||||
### 4.4 没有任何地方标注"数据盲区"
|
||||
|
||||
`report` 字典里([L452-L478](../backend/app/services/narrator.py#L452-L478))**没有 data gaps / limitations 字段**。
|
||||
|
||||
本次 30 天模拟的真实盲区(从 [`logs/8a2c6953-224.jsonl`](../../backend/logs/8a2c6953-224.jsonl) 尾部实测):
|
||||
|
||||
| 盲区 | 实测值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `adoption_rate` | 全程 0.0 | 90 轮无人 `PROPOSE_RULE` → "制度演化"叙事无数据支撑 |
|
||||
| `defect_count` | 每轮 0 | `has_defected` 一次性标记 + 无回退机制(见 [resolver.py:178](../backend/app/services/resolver.py#L178-L179))→ 叛徒事件结构性稀缺 |
|
||||
| `trust` | 恒定 1.0 | 衰减只由 `DEFECT` 触发([resolver.py:545-L549](../backend/app/services/resolver.py#L545-L549)),DEFECT=0 → trust 永不下滑 |
|
||||
| `purchase_intent` | 0.458→0.458 | 30 天内几乎不动 → "经济演化"在 30 天尺度不可见 |
|
||||
| 张力事件 | 15 次 | 但 `tension_event` 内容未进报告任何字段 |
|
||||
|
||||
**报告完全没提这 5 点。** 读者只能靠自己去翻 JSONL 才发现——这就是"该说的东西没说"的实质。
|
||||
|
||||
---
|
||||
|
||||
## 五、方案:在现有 pipeline 后加 Pass 5 / Pass 6
|
||||
|
||||
### 5.1 Pass 5:系统性总结(核心)
|
||||
|
||||
**目的**:把前面所有产物按时间轴 + 矛盾剖析 + 数据盲区串成一篇完整叙述。
|
||||
|
||||
**输入**(全部来自已有产物,不新增数据源):
|
||||
- `epoch_summaries`(Pass 0)
|
||||
- `scorecard` 含 `explanation`(Pass 2)
|
||||
- `insights`(Pass 2)
|
||||
- `segments_final`(Pass 3)
|
||||
- `action_items` / `risks` / `second_order_effects`(Pass 4)
|
||||
- `inflection_points`
|
||||
- `metric_stats` 全量 14 字段(DB 已有)
|
||||
- `meta`(agent_count / total_days / total_actions / rules / world_name)
|
||||
|
||||
**建议 prompt 骨架**:
|
||||
|
||||
```
|
||||
你是 {world_name} 的历史学家,刚刚目睹了一场为期 {total_days} 天、
|
||||
{agent_count} 人、{total_actions} 次行动的社会实验。
|
||||
|
||||
# 观测数据(全部)
|
||||
## 时间轴摘要
|
||||
{epoch_text}
|
||||
|
||||
## 指标全表(含 14 项原始值 + 评级 + 逐项解释)
|
||||
{metrics_full_table}
|
||||
|
||||
## 关键转折点
|
||||
{inflection_summary}
|
||||
|
||||
## 居民快照
|
||||
{agent_summary}
|
||||
|
||||
## 分群结果
|
||||
{segments_text}
|
||||
|
||||
## 已有洞察 / 风险 / 建议
|
||||
{insights_text}
|
||||
|
||||
请写一篇系统性总结,结构如下:
|
||||
|
||||
1. 【开场】150 字:一句话点出这场模拟最核心的故事
|
||||
2. 【起承转合】按时间轴分段,每段 300-500 字:发生了什么、为什么、谁推动
|
||||
3. 【关键人物】3-5 个对结果影响最大的居民,各 200 字:性格 → 选择 → 后果
|
||||
4. 【矛盾剖析】300 字:把"表面读数"和"深层动力"分开,指出哪些是规则必然、哪些是涌现
|
||||
5. 【数据盲区】200 字:明确列出这份数据**不能**回答的问题
|
||||
|
||||
硬性要求:
|
||||
- 每个断言必须能追溯到上面某个具体指标值、某个具名居民、或某个具体 day
|
||||
- 不许用"也许/可能/或许";要么写"数据显示 X",要么写"数据未覆盖"
|
||||
- 不要重复 executive_brief 的 verdict,但要呼应它
|
||||
- 指标必须带原始数值出现(如"trust 从 0.50 到 1.00"),不许只说"很高"
|
||||
- 中文人名直接用原文
|
||||
```
|
||||
|
||||
**返回 JSON**:
|
||||
|
||||
```json
|
||||
{
|
||||
"narrative_summary": "完整正文(Markdown,1500-2500 字)",
|
||||
"chapters": [
|
||||
{"title": "...", "body": "...", "evidence_days": [1, 15, 30]}
|
||||
],
|
||||
"key_figures": [
|
||||
{"name": "...", "arc": "...", "evidence_days": [3, 20]}
|
||||
],
|
||||
"contradictions": [
|
||||
{"surface": "...", "underlying": "...", "evidence": "..."}
|
||||
],
|
||||
"data_gaps": [
|
||||
{"gap": "...", "why_it_matters": "...", "suggested_fix": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`data_gaps` 字段是**这次改造的关键收益**——它把第四节列的 5 个盲区变成报告的正式输出,读者不用再自己去翻日志。
|
||||
|
||||
### 5.2 Pass 6:开场旁白(可选,营造"故事感")
|
||||
|
||||
**目的**:你提到的"像旁白一样讲完这个故事"。
|
||||
|
||||
**输入**:`executive_brief.headline` + top3 居民 + top3 转折点
|
||||
|
||||
**prompt 骨架**:
|
||||
|
||||
```
|
||||
你是 {world_name} 的旁白者,为一份模拟报告写开场词。
|
||||
|
||||
核心事件:{headline}
|
||||
主线人物:{top3_agents_with_traits}
|
||||
关键转折:{top3_inflections}
|
||||
|
||||
用 300 字写一段旁白:
|
||||
- 第一句必须是一个具体画面或场景(有人/有地方/有动作),不要"在这个社会中"
|
||||
- 中间三句讲清起承转合
|
||||
- 最后一句留一个未解的问题
|
||||
- 不解释数据,不下结论,不出现指标名
|
||||
```
|
||||
|
||||
### 5.3 前端改动
|
||||
|
||||
[ReportView.vue](../frontend/src/views/ReportView.vue) 增加两个区块:
|
||||
|
||||
- `narrative_summary`(全文渲染,支持 Markdown)
|
||||
- `chapters`(时间轴折叠列表,点击跳转到对应 day)
|
||||
- `data_gaps`(**高亮显示**,建议用警示色,因为这是"诚实度"卖点)
|
||||
|
||||
`key_figures` 和 `contradictions` 可折叠,默认收起。
|
||||
|
||||
---
|
||||
|
||||
## 六、改动量评估
|
||||
|
||||
| 方案 | 改动 | LLM 调用增量 | 效果 |
|
||||
|------|------|-------------|------|
|
||||
| **A. 只加 Pass 5** | narrator.py +100 行、1 个新 prompt、ReportView.vue +40 行 | +1 次(约 10-20s) | 报告从拼盘变故事,且带数据盲区 |
|
||||
| **B. A + Pass 6** | 再 +40 行 | +1 次 | 额外开场旁白,阅读体验更好 |
|
||||
| **C. A + epoch 粒度细化** | epoch 从 30 天改 7 天 | +3~4 次 | 时间轴更细,但收益递减 |
|
||||
| **D. 修 sim_logger 指标缺失** | sim_logger.py +4 行 | 0 | 只影响日志可观测性,**不影响报告**(报告读 DB) |
|
||||
|
||||
**建议先做 A**,验证效果后再决定要不要 B/C。D 是独立的可观测性改进,成本极低可顺手做。
|
||||
|
||||
---
|
||||
|
||||
## 七、待澄清
|
||||
|
||||
- Pass 5 的 1500-2500 字对 30 天模拟够吗?长模拟(365 天)是不是要按 epoch 动态伸缩?
|
||||
- `data_gaps` 要不要让 LLM 生成,还是**用代码硬规则检测**(如"adoption_rate 全程为 0 → 报为盲区")?后者更可靠但覆盖不全
|
||||
- `insights.evidence` 是否应改为**必填**,LLM 填不出证据就不让出该 insight?这样能根治"讲得不明不白"
|
||||
- 报告 PDF 是渲染型(pypdf 抽不出文字),Pass 5 的 Markdown 需确认导出链路是否支持
|
||||
|
||||
---
|
||||
|
||||
## 八、相关代码位置
|
||||
|
||||
- 报告生成主流程:[`backend/app/services/narrator.py` `generate_report`](../backend/app/services/narrator.py#L226-L486)
|
||||
- Pass 2 指标解释 prompt:[`backend/app/services/narrator.py` `INTERPRET_METRICS_SYSTEM`](../backend/app/services/narrator.py#L46-L79)
|
||||
- Pass 3 分群 prompt:[`backend/app/services/narrator.py` `SEGMENT_ANALYSIS_SYSTEM`](../backend/app/services/narrator.py#L81-L110)
|
||||
- 纯统计计算:[`backend/app/services/report_analyzer.py`](../backend/app/services/report_analyzer.py)
|
||||
- 指标定义(14 项):[`backend/app/models/world.py` `WorldMetrics`](../backend/app/models/world.py#L30-L43)
|
||||
- 指标持久化:[`backend/app/db/store.py` `save_metrics` / `get_metrics_history`](../backend/app/db/store.py#L231-L243)
|
||||
- JSONL 日志(只 6 项):[`backend/app/services/sim_logger.py` `log_round`](../backend/app/services/sim_logger.py#L153-L160)
|
||||
- 报告前端:[`frontend/src/views/ReportView.vue`](../frontend/src/views/ReportView.vue)
|
||||
- 本次模拟日志:[`backend/logs/8a2c6953-224.jsonl`](../../backend/logs/8a2c6953-224.jsonl)
|
||||
@@ -0,0 +1,231 @@
|
||||
# 问题记录汇总:模拟规模、居民兜底与搜索后端
|
||||
|
||||
> 调查日期:2026-09-28
|
||||
> 状态:首轮代码优化已完成,待实际运行验证
|
||||
> 本文同时保留问题证据、决策依据和后续未覆盖项。
|
||||
|
||||
## 一、文档用途
|
||||
|
||||
本文汇总以下三份原始记录,并根据当前代码重新核对结论:
|
||||
|
||||
- [问题记录-硬编码与千人模拟](./问题记录-硬编码与千人模拟.md)
|
||||
- [问题记录-2026-09-27-硬编码与搜索替换](./问题记录-2026-09-27-硬编码与搜索替换.md)
|
||||
- [问题记录-2026-09-28-居民生成兜底硬编码](./问题记录-2026-09-28-居民生成兜底硬编码.md)
|
||||
- [问题记录-2026-09-28-报告缺失系统性总结](./问题记录-2026-09-28-报告缺失系统性总结.md)
|
||||
|
||||
原始记录保留作为发现过程和现场观察;本文作为当前阶段的统一判断,避免后续继续沿用已经过时的描述。
|
||||
|
||||
本轮已落地的范围:SearXNG 配置化调用与短缓存、完整指标进入报告和 JSONL 观测、确定性数据盲区检测、报告 Pass 5 系统性总结、居民兜底补生成与来源标记、抽样上限修正和抽样快照记录、时间配置校验,以及报告 schema 版本变更后的自动重生成。由于本轮之后尚未重新运行完整模拟,LLM 生成质量、报告耗时和真实搜索连通性仍需验证。
|
||||
|
||||
## 二、结论总览
|
||||
|
||||
| 问题 | 当前判断 | 说明 |
|
||||
|---|---|---|
|
||||
| 每轮只抽 3–10 人 | 部分成立,原描述已过时 | `TimeConfig` 默认值确实是 3–10,但正常世界生成会按人口提示约 40% 的上限;当前 API 人口上限又只有 50。 |
|
||||
| “千人社会模拟”是否已实现 | 尚未成立 | 当前 API 不接受超过 50 人;即使放开人口上限,当前每轮 LLM 调用量、状态更新方式和成本模型也需要重新设计。 |
|
||||
| 抽样是否有代表性 | 问题成立 | 当前是带情绪、派系、桥接节点、沉睡补偿等因素的加权抽样,不能直接等同于总体民意。 |
|
||||
| 60% 是抽样硬上限 | 判断不准确 | 当前公式并不能保证最终人数不超过人口的 60%。 |
|
||||
| semaphore=10 即 TPS 没问题 | 判断过强 | semaphore 只限制本地并发,不代表供应商 TPS、TPM、总耗时或总成本没有风险。 |
|
||||
| 居民数量不足时的硬编码兜底 | 问题确认存在 | 解析失败、返回数量不足等情况都会静默补齐,并继续进入 persona 及后续生成流程。 |
|
||||
| 兜底居民会污染模拟 | 问题确认存在 | 模板姓名、角色、性格钩子和立场会被继续送入 LLM,并可能影响 beliefs、生活史和关系。 |
|
||||
| DuckDuckGo 搜索不稳定 | 工程风险成立 | 当前代码仍直接使用 `duckduckgo_search`,没有 SearXNG 配置或缓存。 |
|
||||
| 换成 SearXNG 就能保证重复性 | 不能直接保证 | SearXNG 减少单一后端限流问题,但聚合搜索结果仍会随时间和上游引擎变化;需要缓存或结果快照。 |
|
||||
| 报告“缺数据”或“读不懂” | 数据缺失判断不成立,报告解释层问题成立 | DB 会完整保存当前 `WorldMetrics` 的 13 个字段;JSONL 运行日志只记录 6 个指标。当前报告缺少时间线串联、证据约束和数据盲区说明。 |
|
||||
|
||||
## 三、问题一:模拟规模与每轮居民抽样
|
||||
|
||||
### 3.1 当前实现事实
|
||||
|
||||
1. `TimeConfig` 默认值是:365 天、每天 3 轮、每轮最少 3 人、最多 10 人。
|
||||
2. `WorldGenerator` 会向 LLM 提示 `active_agents_per_round_max` 约为人口的 40%,缺省值也按这个比例计算。
|
||||
3. 如果 LLM 自己返回了 `time_config`,当前代码会优先接受 LLM 返回的值,没有对上下限关系做严格业务校验。
|
||||
4. API 当前明确限制人口为 2–50 人,因此目前产品入口并不支持 1000 人。
|
||||
5. `_select_active_agents` 采用加权、无放回抽样。情绪激动、属于派系、处于桥接位置、市场特征明显或沉睡较久的居民,权重会提高。
|
||||
6. “沉睡”补偿记录的是最近一次非 `DO_NOTHING` 行动,而不是最近一次被选中。被选中但没有行动的居民仍可能继续获得沉睡加成。
|
||||
7. 当前 `max_active = max(base_count, 60%人口)` 不是严格的 60% 上限。当 `base_count` 本身大于 60% 时,仍会保留这个更大的数量。
|
||||
8. 居民生成使用 `generate_fast` 先生成 cast/persona,生活史和关系在后台补充。因此当前也不是“所有居民的完整背景先生成完,再开始模拟”。
|
||||
|
||||
### 3.2 哪些原始判断需要修正
|
||||
|
||||
原记录把当前行为概括为“每轮固定 3–10 人”,这只对直接使用 `TimeConfig` 默认值的路径成立,不足以描述正常世界生成路径。
|
||||
|
||||
更准确的说法是:抽样参数同时受到模型默认值、世界生成结果和运行时人口规模影响;但产品入口目前最多 50 人,所以“千人社会”更多是产品叙事/未来目标,而不是当前 API 已支持的运行模式。
|
||||
|
||||
原记录把 `60%` 描述为上限也不准确,应改成“事件加成在特定范围内会受到一个不严格的 60% 参考值影响”。
|
||||
|
||||
### 3.3 问题仍然成立的部分
|
||||
|
||||
- 抽样数量属于决定模拟质量和成本的核心参数,却与世界时间配置放在同一模型里。
|
||||
- 加权抽样会主动放大高情绪、高参与倾向的小群体;这可能是设计意图,也可能造成民意代表性偏差。
|
||||
- 仅查看“每轮抽中了多少人”不足以证明千人社会产生了有效涌现,需要观察居民覆盖率、重复参与率、行动率、沉默率和群体分布。
|
||||
- semaphore=10 只能说明本地同时执行的 LLM 请求有上限,不能单独推出“供应商 TPS 没问题”。
|
||||
|
||||
### 3.4 建议方向与首轮实现
|
||||
|
||||
1. 将“世界时间配置”和“居民参与/LLM 预算配置”拆开。后者至少应包含抽样策略、每轮预算、最小/最大参与数、事件加成和是否允许全量参与。
|
||||
2. 明确“人口数量”的产品含义:是状态空间规模,还是每轮实际接受 LLM 决策的人数。两者不能用同一个数字直接代表。
|
||||
3. 如果目标确实是 1000 人,优先研究分层/代表性抽样、群体级状态更新、局部精细模拟和按需唤醒,而不是简单把人口上限和 `active_agents_per_round_max` 调大。
|
||||
4. 需要严格上限时,应重新定义抽样公式,并通过测试验证事件发生、人口较小、人口较大等边界情况。
|
||||
5. 增加运行指标:每轮选中数、实际行动数、居民最近参与间隔、每个居民累计参与次数、LLM 调用数、失败数、耗时和 token 成本。
|
||||
|
||||
首轮已实现其中的抽样快照、事件标记和 JSONL 完整指标记录;LLM 成本和每居民累计覆盖率仍需在新一轮模拟中补充统计。
|
||||
|
||||
## 四、问题二:居民生成兜底的硬编码污染
|
||||
|
||||
### 4.1 当前实现事实
|
||||
|
||||
当 `_generate_cast` 返回的居民少于目标数量时,代码会无条件补齐到 `count`。中文和英文分支分别使用程序化姓名、固定角色、固定年龄模式、固定性格钩子和固定立场。
|
||||
|
||||
触发来源包括:
|
||||
|
||||
- LLM 截断或确实返回数量不足;
|
||||
- 返回对象结构不符合预期;
|
||||
- JSON 解析失败;
|
||||
- 中文重试后仍没有得到足够居民。
|
||||
|
||||
`parse_json` 在最终失败时返回空字典,因此整批失败会直接转化为整批兜底居民。
|
||||
|
||||
### 4.2 已确认的影响
|
||||
|
||||
1. 兜底居民不是终态数据,会进入 persona 生成。
|
||||
2. 固定的 `personality_hook`、`stance`、角色和年龄会成为后续 LLM 的输入条件。
|
||||
3. 兜底居民缺失的字段会触发中位 personality、随机 communication style 等默认逻辑。
|
||||
4. `generate_fast` 会先把这些居民投入模拟,生活史和关系生成在后台继续进行;如果后台成功,它们还会继续进入后续阶段。
|
||||
5. 没有结构化的“本居民由兜底生成”标记,前端、叙事和报告无法区分正常居民与降级居民。
|
||||
6. warning 和 JSON 解析 error 会写入后端日志,但不会把“整批 LLM 失败”和“少返回几人”作为不同状态传递给调用方。
|
||||
|
||||
### 4.3 额外发现
|
||||
|
||||
中文姓名检查阶段直接对列表元素调用 `.get()`。如果 LLM 返回的数组中混入非字典元素,可能在兜底逻辑之前直接抛异常。居民条目的类型和必需字段目前也没有统一校验。
|
||||
|
||||
### 4.4 建议方向与首轮实现
|
||||
|
||||
1. 把“数量不足”“结构错误”“JSON 解析失败”“LLM 调用失败”分成不同错误类型并记录指标。
|
||||
2. 对数量不足进行一次有界重试,仍不足时由产品选择:让模拟失败并提示重试,或进入明确标记的降级模式。
|
||||
3. 如果保留降级模式,不应使用固定的政治立场和同质化模板;至少要带有来源标记,并避免把模板立场直接注入 beliefs。
|
||||
4. 对 cast 做逐项 schema/type 校验,避免非字典元素或缺失字段导致新的异常。
|
||||
5. 把兜底数量、触发原因、是否影响前端展示和是否允许继续运行纳入验收标准。
|
||||
|
||||
## 五、问题三:DuckDuckGo 替换为 SearXNG
|
||||
|
||||
### 5.1 当前实现事实
|
||||
|
||||
当前 `ResearchService` 仍然直接导入并调用 `duckduckgo_search.DDGS`,配置中只有搜索开关和每轮最大搜索次数,没有 SearXNG 地址、token 或 timeout 字段。
|
||||
|
||||
当前研究流程已经具备每轮最多 5 个搜索请求的限制,并会并发执行这些请求;但没有查询短缓存,也没有记录搜索结果快照。
|
||||
|
||||
### 5.2 替换动机是否成立
|
||||
|
||||
成立:DuckDuckGo 依赖外部服务,存在反爬、CAPTCHA、限流和结果波动风险。自托管 SearXNG 可以把入口和认证控制收回来,但不能自动解决上游引擎波动,也不能单独提供严格重复性。
|
||||
|
||||
### 5.3 建议方案与首轮实现
|
||||
|
||||
1. 使用 skill 文档中确认的部署地址、GET 方法和 token 认证方式;token 只通过环境变量注入,不写入代码或提交文件。
|
||||
2. 将 `base_url`、token、timeout、language、safesearch 和可选的 time range 配置化。timeout 暂定 15 秒是较平衡的起点。
|
||||
3. 将 SearXNG 的 `title/content/url` 适配为当前内部使用的 `title/body/href`,保持后续摘要逻辑稳定。
|
||||
4. 复用 `httpx` 的异步客户端,设置明确的连接、读取和总超时,并对非 200、JSON 错误和空结果分别记录。
|
||||
5. 默认不保留 DDG fallback,以免搜索结果在失败时悄悄切换来源;若确实需要,应做成显式的降级开关。
|
||||
6. 为“可重复模拟”增加按模拟 ID 或查询键保存的结果快照。单纯换成 SearXNG 不足以保证同一 query 得到同一结果。
|
||||
7. safesearch 不应硬编码在业务代码中。普通产品默认可以取 1;需要完整社会议题研究时,再由明确配置选择 0,并记录该选择。
|
||||
|
||||
## 六、问题四:报告缺少系统性总结与数据盲区
|
||||
|
||||
### 6.1 先区分“数据缺失”和“报告没有解释”
|
||||
|
||||
新增记录中最初的怀疑是“报告提到的冲突、自由度等指标可能没有被记录”。根据当前实现,这个判断需要修正:
|
||||
|
||||
- `WorldMetrics` 当前实际定义了 13 个指标(原始记录写成 14 个,是数量统计误差);
|
||||
- `store.save_metrics` 使用完整的 `model_dump_json()` 保存指标;
|
||||
- `get_metrics_history` 会把完整 JSON 读回;
|
||||
- 报告生成使用 `metrics_history` 和 `ReportAnalyzer`,并非以 JSONL 日志为主要指标来源。
|
||||
|
||||
因此,DB 层的指标数据并没有因为 JSONL 日志缺字段而丢失。真正存在的是两个不同层面的问题:
|
||||
|
||||
1. JSONL 日志只记录 6 个指标,导致人工排查和审计时容易误以为其他指标不存在;
|
||||
2. 报告生成 pipeline 没有把已有数据、时间线、人物行为、指标关系和数据盲区组织成一条可追溯的解释链。
|
||||
|
||||
### 6.2 当前报告 pipeline 的结构性缺口
|
||||
|
||||
当前报告大致包含 epoch 摘要、数据统计/预测分析、指标解释与洞察、分群分析、行动建议,最终组装为 executive brief、scorecard、insights、segments、action items、risks 等字段。
|
||||
|
||||
但最终报告没有专门的系统性总结字段,因此读者需要自行拼接:
|
||||
|
||||
- epoch 摘要没有和后续洞察按时间轴串联;
|
||||
- 每个指标的解释基本独立生成,缺少指标之间的因果/矛盾关系;
|
||||
- `insights` 虽然在 prompt 中要求 evidence,但代码没有 schema 校验,LLM 仍可能返回没有具体 agent、day 或 action 支撑的结论;
|
||||
- 报告没有 `data_gaps`、`limitations` 或“本次模拟未覆盖什么”的正式字段;
|
||||
- 报告中的 `narrative.summary` 目前主要复用 executive brief,不等同于完整的历史叙事。
|
||||
|
||||
所以“报告读不懂”是一个真实的产品问题,但更准确的名称是“报告解释层和可追溯性不足”,不是数据库指标丢失。
|
||||
|
||||
### 6.3 数据盲区记录的使用边界
|
||||
|
||||
新增记录中列出的 adoption_rate、defect_count、trust、purchase_intent 和张力事件,是某次 30 天模拟的观测结果,适合成为该次报告的 `data_gaps` 或限制说明,但不能直接当作所有模拟都成立的系统规律。
|
||||
|
||||
还需要注意:`trust` 在 resolver 中并非只由 `DEFECT` 影响,其他行动也会对 trust 产生增减;因此“trust 只能因 DEFECT 衰减”的表述应改成“本次样本中没有出现足以让 trust 明显下降的有效行动,具体原因需要结合全部行动和指标轨迹判断”。
|
||||
|
||||
更稳妥的做法是将数据盲区分为两类:
|
||||
|
||||
- **代码可检测盲区**:例如某指标全程不变、某类行动次数为 0、某类事件发生但没有进入报告;
|
||||
- **LLM 解释盲区**:已有数据存在,但报告没有给出证据、时间点或指标间关系。
|
||||
|
||||
### 6.4 建议方向与首轮实现
|
||||
|
||||
1. 增加一个独立的“系统性总结”阶段,把 epoch 时间线、指标全表、转折点、居民行为、分群结果、洞察和风险统一输入,输出带章节的完整叙事。
|
||||
2. 增加 `data_gaps` 或 `limitations` 字段,明确列出本次模拟无法回答的问题、原因和建议补充数据。
|
||||
3. 将 `insights.evidence` 设为结构化必填项;缺少具体 agent、day、action 或指标值的洞察不应直接进入最终报告。
|
||||
4. 对不变量和盲区优先使用代码规则检测,例如指标全程为 0、全程不变、某类行动从未发生,再让 LLM 负责解释其意义。
|
||||
5. 把 `sim_logger` 的 6 指标限制视为独立的可观测性问题修复;它不等于修复报告数据源,但能降低人工排查时的误判。
|
||||
6. 前端报告可以增加时间线章节、关键人物、矛盾剖析和数据盲区区块;这些属于后续实现,不在本次文档整理中执行。
|
||||
|
||||
首轮已增加系统性总结、章节、数据盲区展示和 Markdown 导出;关键人物/矛盾字段已由后端预留,但尚未做完整的前端专门区块。
|
||||
|
||||
### 6.5 推荐实施顺序(未来)
|
||||
|
||||
优先做“只增加 Pass 5 系统性总结 + data_gaps”,先验证报告是否从“指标拼盘”变成“有证据的故事”;再考虑开场旁白、缩短 epoch 粒度或扩展前端交互。不要先增加更多文学化文本,否则可能进一步掩盖证据不足的问题。
|
||||
|
||||
## 七、共同的业务问题
|
||||
|
||||
三个问题指向同一件事:产品对外描述的是“有大量居民、能产生社会涌现的模拟”,而当前实现中:
|
||||
|
||||
- API 尚不支持千人规模;
|
||||
- 每轮真正进行 LLM 决策的是一个经过偏置的子集;
|
||||
- LLM 失败时可能静默制造模板居民;
|
||||
- 外部搜索结果不稳定且无法复现。
|
||||
|
||||
因此不能只把这些问题分别当成“一个参数、一个 fallback、一个搜索库”的工程整理。需要先确定产品承诺:模拟追求的是全体居民的细粒度决策,还是大规模社会的统计/分层涌现。
|
||||
|
||||
## 八、建议的处理顺序
|
||||
|
||||
### 阶段 0:先定口径
|
||||
|
||||
- 明确支持的人口规模和“千人模拟”是否是当前能力还是未来目标。
|
||||
- 明确每轮居民参与率、可接受延迟、LLM 成本和结果重复性要求。
|
||||
- 决定居民生成失败时是阻断模拟,还是允许带标记的降级运行。
|
||||
|
||||
### 阶段 1:补观测,不先调参数
|
||||
|
||||
- 记录抽样覆盖率、行动率、居民重复参与和沉睡补偿效果。
|
||||
- 记录居民生成短缺、解析失败、兜底数量和各阶段耗时。
|
||||
- 记录搜索成功率、超时率、结果来源和缓存命中率。
|
||||
|
||||
### 阶段 2:分别处理三条技术线
|
||||
|
||||
1. 抽样策略与规模模型;
|
||||
2. 居民生成错误边界与降级策略;
|
||||
3. SearXNG 配置、适配、缓存和可重复性。
|
||||
|
||||
### 阶段 3:用小规模实验验证
|
||||
|
||||
先使用 25/50 人进行不同抽样策略和兜底策略的 A/B 对比,再决定是否扩大人口上限。不能先把人口直接放大到 1000,再用运行成本和结果噪声反推设计是否合理。
|
||||
|
||||
## 九、本轮尚未覆盖的事情
|
||||
|
||||
- 尚未把人口上限扩展到 1000,也没有实现分层/群体级模拟;
|
||||
- 尚未根据真实供应商限流数据校准 LLM 成本和 TPS;
|
||||
- 尚未实现搜索结果按模拟 ID 的持久化快照;当前只有进程内短缓存;
|
||||
- 尚未实现报告 Pass 6 开场旁白、关键人物和矛盾区块的完整前端交互;
|
||||
- 尚未安装 pytest 并执行完整自动化测试套件;
|
||||
- 尚未重新跑一轮完整模拟验证新增 Pass 5、兜底补生成和 SearXNG 连通性。
|
||||
|
||||
本文用于后续决策和实施前对照。任何代码修改前,应先在本文对应章节补充具体决策、验收指标和回滚方案。
|
||||
Reference in New Issue
Block a user