Files
mirosociety/docs/问题记录/问题记录-2026-09-28-居民生成兜底硬编码.md
T

150 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 问题记录: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 翻车时静默补齐 / 搜索依赖不稳定)之间的不一致**。建议未来评估时一起看。