fix simulation reporting and research resilience

This commit is contained in:
2026-09-28 15:43:23 +08:00
parent 23297be2d7
commit a428214c53
25 changed files with 1488 additions and 244 deletions
@@ -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 翻车时静默补齐 / 搜索依赖不稳定)之间的不一致**。建议未来评估时一起看。