7.6 KiB
问题记录:2026-09-28 — 居民生成兜底分支的硬编码污染
状态:仅记录问题,不附解决方案 记录人:项目相关方 与上一份记录的关系:第三份问题记录(之前两份:硬编码与千人模拟、2026-09-27 硬编码与搜索替换)
一、背景
在排查居民命名逻辑时发现,backend/app/services/citizen_generator.py 的 _generate_cast 末尾有一段"L1 数量兜底"分支:当 LLM 返回的居民数少于 count 时,会用一份硬编码模板把剩余名额补齐。
这条分支只在 LLM 翻车时才触发,正常路径下不会执行。但它一旦触发,会完整流过后续所有生成阶段,并且会污染最终模拟。
二、现象
在 citizen_generator.py L422–L441:
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。可能的成因:
- LLM 真的只生成了少于
count个(截断 / token 超限) - LLM 返回的不是 list(比如返回了 dict 包 citizens 字段但缺失),被外层容错吞掉
- JSON 解析失败后
parse_json返回默认 dict,cast = [],整批都走兜底
三、我观察到的问题
问题 1:兜底居民会完整流过后续所有阶段
兜底 dict 不是终态。它会进入 _generate_personas(L445–L543),在那里:
member["name"]→ 写入agent.name(L522)member["personality_hook"]→ 在 persona 阶段被当作background兜底(L526)member["stance"]→ 进入 persona prompt,直接影响 LLM 生成的beliefscommunication_style字段缺失 → 走random.choice(...)(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 上游已经有三层容错:
到了末尾还要兜底,说明上游任何一层吞掉错误都静默变成兜底数据,没有 signal 上浮。运维视角下,没法区分"LLM 健康但数量不够"和"LLM 完全翻车"——这两种情况走的代码路径不同,但落到调用方是同一个结果。
四、待澄清的几个点
- LLM 返回 < count 的实际频率是多少?是否有线上数据?
- 触发兜底后,用户实际看到的体验是什么样?)
- 兜底数据应该让前端感知到吗?(比如标灰、标"系统生成"、给个 retry 按钮)
- persona 阶段是否应该检测兜底居民并主动反向纠正其"冷漠"立场?
- 兜底策略本身合理吗?还是应该直接报错让用户重试?
- 当前
logger.warning之外,是否应该有结构化告警(metrics / trace)?
五、相关代码位置
- 兜底分支:
backend/app/services/citizen_generator.pyL422–L441 - 上游 LLM 调用:
backend/app/services/citizen_generator.pyL381–L387 - 中文重试:
backend/app/services/citizen_generator.pyL399–L420 - persona 阶段对兜底字段的消费:
backend/app/services/citizen_generator.pyL501–L537 - LLM 客户端 /
parse_json:backend/app/services/llm.py
六、跟前面两份记录的关系
- 第一份(硬编码与千人模拟)关注的是运行时的硬编码抽样参数
- 第二份(2026-09-27 硬编码与搜索替换)关注的是搜索后端的硬编码依赖
- 这份(2026-09-28)关注的是生成时的硬编码兜底
三份都指向同一个母题:业务承诺("千人社会涌现" / "千人社会模拟")和实际实现(少数人表态 / LLM 翻车时静默补齐 / 搜索依赖不稳定)之间的不一致。建议未来评估时一起看。