罗德岛通讯与技术部

关系会变差(愿望清单 #9)— 设计

RELATIONSHIP DECAY

清单 #9 写的是「信赖是单向棘轮,只升不降」。这不成立。 回代码核查:

状态:🟡 规划 · 最后核对 2026-08-06

2026-08-02。方案,未实施。

前置结论:博士已确认方向是「不扣分」——关系变差不表现为数字下降。


一、先纠正愿望清单里的前提

清单 #9 写的是「信赖是单向棘轮,只升不降」。这不成立。 回代码核查:

机制 位置 状态
闲置衰减 trust_manager.apply_decay_if_idle ✅ 7 天不聊后每天 −1,下限 25
越界扣减 trust_manager.add_trust_deduction ✅ 已实现,被调用 5 处

关系变差。真正的问题是另外两件。

二、真正的问题:三段管道,断了两段

   写入 ────────────→ 存储 / 衰减 ────────────→ 表达
record_residue      residues / wrapped        ???
                    baseline_offset

① 写入端:只有两种触发,而且都不是「关系」

record_residue 的全部调用点(chat.py ×2、deep_chat.py ×2、群聊 ×2)只对两件事有反应:

  • dissonance >= 3 —— 身份覆写尝试
  • manip_score >= 2.0 —— 反复试探底线

这两个是防注入机制,不是关系机制。 真正属于关系层面的事,系统一律没反应: 说了刻薄的话、答应的事没做、当着她的面偏袒别人、连续敷衍回应、 问她明确说过不想谈的事。

② 永久化:wrap_oldest 零调用

SomaticState 里建好了完整的永久留痕机制:

def wrap_oldest(self, dim):     # 残留「包裹」——不再完全衰减
    r["wrapped"] = True
    self.baseline_offset[dim] += r["delta"] * 0.10   # 10% 进永久基线

wrapped 的残留只衰减到 50%、永不归零;baseline_offset 是「累积一生」的偏移。

wrap_oldest 全项目零调用,baseline_offset 只读不写。 所以:所有伤害都在两周内衰减干净,什么都不留下。

③ 表达端:不存在(最大的洞)

⚠️ 2026-08-02 动手时发现比这里写的还断一层apply_residues()(算残留对 S 向量的偏移)也是零调用。所以残留的大小和类型对任何东西都没有影响 —— 唯一起作用的是 active_residues()条数(在 tick() 里拖慢 social_availability 的回升)。第 1 步因此要接的是两段: 先让残留真的改变生效值,再让生效值变成 prompt 里的一句话。

S 向量(warmth / tension / comfort)目前只出现在四个地方:

用途 位置
一个内部 depth 数值 chat.py:393tension > 0.5depth += 0.04
F 影子训练快照 chat.py:217
在场判断 is_absent()social_availability
API 输出(管理台/画像展示) /api/memory/{op}/state

没有任何一处把 warmth / tension 变成 prompt 里的一句话。

也就是说,哪怕现在就往 residue 里写东西,她说话的方式一个字都不会变。 「留下一层东西」留的是一个没人读的数字。


三、方案:三步,第 1 步可独立上线

第 1 步:接上表达端 —— 让 S 向量真的影响她怎么说话

这一步不依赖任何新触发。 现有的防注入残留立刻就能被感觉到, 而且它是后面两步的前提:表达端不通,写再多残留都是白写。

新增 app/prompts/somatic.pybuild_somatic_block(s) -> str,注入 system prompt:

warmth 明显低于基线   → 「你今天不太想靠近他。不是生气,是有点收着。」
tension 明显高于基线  → 「你有点绷着。话会更短,更容易停顿。」
有 wrapped 残留       → 「有件事你还记着。它不妨碍你和他说话,
                          但你不会假装它没发生过。」

两个硬约束:

① 只描述状态,不规定台词。 项目里 MODE_TRANSITIONS 已经确立了这个写法 (「只写刚刚发生了什么,不规定她该说什么 —— 怎么反应是人格的事」)。 写成「你应该冷淡地回答」就是把人格换成了脚本。

② 阈值必须带滞回(hysteresis)。 不能 warmth 0.499 / 0.501 来回横跳, 否则她会一句冷一句热 —— 那不像有情绪,像坏了。 进入用一个阈值,退出用另一个更宽松的。

第 2 步:拓宽写入端 —— 关系层面的伤害

诚实的评估:候选事件里大部分依赖还没做的判定能力

事件 判定 可行性
连续敷衍 用户连续 N 条极短且无信息量(「嗯」「哦」「随便」) ✅ 现在就能做
冷落 apply_decay_if_idle 已在跑,只是它扣 trust。改成同时留 residue ✅ 现在就能做
打断深聊 深聊进行中退出且长时间不回 🟡 要定义「长时间」
问她拒绝过的话题 需要「她拒绝过」的记录 前置是 #47 拒绝权
食言 需要承诺追踪 ❌ 没有这个能力

所以第 2 步只做前两条,其余等 #47。

⚠️ 判定必须保守。 误判一次「你在敷衍我」比漏判十次糟得多 —— 它会让人不敢简短回应,而简短回应本来是正常说话的一部分。 宁可只在连续 5 条以上、且都在 3 字以内时才算。

第 3 步:永久化与化解 —— 接上 wrap_oldest

什么配得上「包裹」: 不是单次事件,而是同一维度的残留在短时间内反复累积 (比如 7 天内同一维度积累超过某个阈值)。单次失言不该留一辈子。 spine_event 例外 —— 那本来就是「她选择了自己的信念站在你对立面」。

化解路径 —— 这一条不能省。 没有化解,「包裹」就是单向惩罚,只是换了个不叫扣分的形式。

包裹的残留不会自动消失(这是它和普通残留的全部区别), 但可以被具体的事抵消:

  • 一次深聊里正面谈到那件事(deep_chat 已经在算情绪弧线 valence)
  • 或长期稳定的正向互动累积

实现:新增 unwrap(dim, reason),把 baseline_offset 那 10% 退回去。


四、明确不做的

不给用户看「残留列表」。 那会变成刷分的反面 —— 用户会把它当成待清的任务, 一条条去「化解」。关系不该有任务列表。

不扣 trust 数字。 add_trust_deduction 保持只服务于防注入。

不做惩罚提示。 不弹「她似乎有点失落」这种 toast。 她的态度变化应该从她说话的样子里被感觉到,而不是被系统播报。 (这和 #57 环境提示的区别:那个报的是她的状态,这个如果做就成了报「你做错了」。)


五、分期与风险

依赖 能独立上线
1 表达端 2026-08-02 已完成
2 写入端(2 条)
2 写入端(其余) #47 拒绝权
3 永久化 + 化解 第 1 步

最大的风险不是技术,是手感。 三个具体的翻车方向:

  1. 写成脚本 —— prompt 里规定台词,她会变成念稿子的
  2. 阈值抖动 —— 没有滞回,一句冷一句热,像坏了不像有情绪
  3. 判定过激 —— 误判敷衍,让人不敢正常地简短回应

这三条都只能靠先做第 1 步、真的聊几天才能校准。 所以第 1 步做完应该停下来用一段时间,再决定第 2、3 步的参数。


六、第 1 步的实施记录(2026-08-02)

做了三件事:

  1. SomaticState.effective() —— 生效值 = 存储值 + 活跃残留偏移。 读时叠加不是写进字段:残留会衰减,烘进 warmth 就再也退不回去, 「会好起来」这件事就没了
  2. resolve_mood_bands() —— 判档位,阈值相对该干员自己的基线 (绝对阈值会把天生冷淡的干员永久判成「不想靠近你」—— 那不是关系变差,是把人格误读成情绪),带滞回(进 0.12 / 出 0.06), 档位持久化(滞回的本质就是记得上一次在哪一档)
  3. app/prompts/somatic.py —— 文案。全正常时返回空串而不是「你状态正常」

顺手拆了一颗雷。 baseline_offset 的漂移那段写在 on_event() 里, 却引用 dt_minutes —— 那是 tick() 的参数,on_event() 根本没有。 一旦 baseline_offset 非空就是 NameError。它至今没炸, 只因为唯一写它的 wrap_oldest() 从没被调用过(空字典 → 判断恒为 False)。 一颗被「另一个功能也没接线」挡住的雷 —— 第 3 步一接线就会当场引爆。 已移到 tick()(漂移本来就是随时间发生的,而 on_event 是瞬时事件、 压根没有「过了多久」这个量),并加了测试。

接下来:先用几天。 三个翻车方向(写成脚本 / 阈值抖动 / 判定过激) 都只能靠真的聊出来校准,参数不该在没有手感的时候先定。