关系会变差(愿望清单 #9)— 设计
RELATIONSHIP DECAY
状态:🟡 规划 · 最后核对 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:393,tension > 0.5 → depth += 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.py 的 build_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 步 | ✅ |
最大的风险不是技术,是手感。 三个具体的翻车方向:
- 写成脚本 —— prompt 里规定台词,她会变成念稿子的
- 阈值抖动 —— 没有滞回,一句冷一句热,像坏了不像有情绪
- 判定过激 —— 误判敷衍,让人不敢正常地简短回应
这三条都只能靠先做第 1 步、真的聊几天才能校准。 所以第 1 步做完应该停下来用一段时间,再决定第 2、3 步的参数。
六、第 1 步的实施记录(2026-08-02)
做了三件事:
SomaticState.effective()—— 生效值 = 存储值 + 活跃残留偏移。 读时叠加不是写进字段:残留会衰减,烘进 warmth 就再也退不回去, 「会好起来」这件事就没了resolve_mood_bands()—— 判档位,阈值相对该干员自己的基线 (绝对阈值会把天生冷淡的干员永久判成「不想靠近你」—— 那不是关系变差,是把人格误读成情绪),带滞回(进 0.12 / 出 0.06), 档位持久化(滞回的本质就是记得上一次在哪一档)app/prompts/somatic.py—— 文案。全正常时返回空串而不是「你状态正常」
顺手拆了一颗雷。 baseline_offset 的漂移那段写在 on_event() 里,
却引用 dt_minutes —— 那是 tick() 的参数,on_event() 根本没有。
一旦 baseline_offset 非空就是 NameError。它至今没炸,
只因为唯一写它的 wrap_oldest() 从没被调用过(空字典 → 判断恒为 False)。
一颗被「另一个功能也没接线」挡住的雷 —— 第 3 步一接线就会当场引爆。
已移到 tick()(漂移本来就是随时间发生的,而 on_event 是瞬时事件、
压根没有「过了多久」这个量),并加了测试。
接下来:先用几天。 三个翻车方向(写成脚本 / 阈值抖动 / 判定过激) 都只能靠真的聊出来校准,参数不该在没有手感的时候先定。