fix simulation reporting and research resilience
This commit is contained in:
@@ -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 翻车时静默补齐 / 搜索依赖不稳定)之间的不一致**。建议未来评估时一起看。
|
||||
Reference in New Issue
Block a user