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

7.6 KiB
Raw Permalink Blame History

问题记录: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。可能的成因:

  1. LLM 真的只生成了少于 count 个(截断 / token 超限)
  2. LLM 返回的不是 list(比如返回了 dict 包 citizens 字段但缺失),被外层容错吞掉
  3. 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 生成的 beliefs
  • communication_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 上游已经有三层容错:

  • parse_json 容错(如果失败返回默认 dict)
  • dict/list 容错(L390–L395)
  • zh 模式重试(L399–L420)

到了末尾还要兜底,说明上游任何一层吞掉错误都静默变成兜底数据,没有 signal 上浮。运维视角下,没法区分"LLM 健康但数量不够"和"LLM 完全翻车"——这两种情况走的代码路径不同,但落到调用方是同一个结果。


四、待澄清的几个点

  • LLM 返回 < count 的实际频率是多少?是否有线上数据?
  • 触发兜底后,用户实际看到的体验是什么样?)
  • 兜底数据应该让前端感知到吗?(比如标灰、标"系统生成"、给个 retry 按钮)
  • persona 阶段是否应该检测兜底居民并主动反向纠正其"冷漠"立场?
  • 兜底策略本身合理吗?还是应该直接报错让用户重试?
  • 当前 logger.warning 之外,是否应该有结构化告警(metrics / trace)?

五、相关代码位置


六、跟前面两份记录的关系

三份都指向同一个母题:业务承诺("千人社会涌现" / "千人社会模拟")和实际实现(少数人表态 / LLM 翻车时静默补齐 / 搜索依赖不稳定)之间的不一致。建议未来评估时一起看。