# 问题记录: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 翻车时静默补齐 / 搜索依赖不稳定)之间的不一致**。建议未来评估时一起看。