本文是「数字伴侣建造记」的第三篇。伴侣类系统绕不开一个工程问题:内容分级。本项目将系统划分为「正常模式」与「私密模式」两个语境,要求不是“提示词里叮嘱一下”,而是结构上让正常模式无法读到私密语境的任何内容。这套机制经历了三次失效与重构,是全项目最值得记录的部分。
阀门:不对称是故意的
两个模式之间只有一道门,规则从第一天定死:
- 开机永远落在正常侧。曾经默认落在私密侧,后来意识到:若系统记住上次状态,“重启”就成了绕过验证的后门。
- 一切失败坍缩到正常侧。模式查询超时、接口不可达、配置缺失——任何异常的结果都是正常模式。fail-safe 必须朝安全的方向倒。
- 进门要验证,出门不用。不对称是设计而非疏漏:误入私密侧是事故,误出只是扫兴。
- 自动判定只能收紧、永远不能放开。任何自动化检测只被允许把系统推向正常侧。
记忆的四道墙
- 双人设:私密侧的补充人设只在私密模式注入上下文,正常模式的提示词里不存在这段文本。
- 双摘要:滚动摘要按模式分键各自折叠。关键细节:正常侧摘要的折叠输入使用遮蔽后的视图——私密段落在被压缩进长期记忆之前就已经不可见,泄漏在源头被切断。
- 双长期记忆:两份独立的事实便签,私密侧那份正常模式永不加载。
- 段落遮蔽:正常模式回看完整历史时,每一段私密时光被替换为一条占位语。
占位语为什么必须恒定
占位语的内容是一句手写的常量,大意是“此前有过一段私密相处,不需要复述,现在已回到日常”。曾考虑让模型按上下文生成更自然的衔接语,被否决,理由值得写下来:
衔接语若由私密内容生成,它本身就成了泄漏通道。
一条“概括了那段内容”的衔接语,无论多含蓄,都携带了信息。恒定常量携带的信息量是零——正常侧只知道“存在过一段”,仅此而已。自然感的代价由常量的措辞承担(写得足够通用),而不是由安全性承担。
同理,离开私密模式时她说的过渡台词采用“意图固定、措辞可变”的方案:指令是常量(关心对方、递杯水、接回日常话题、不提及具体内容),变化只发生在措辞层。
崩溃语义:fail closed
若系统在私密模式中途崩溃,时间线上会留下一个“开放段落”(有起点无终点)。重启加载时的处理规则:开放段落一律在记录末尾封闭——崩溃前的内容全部落在段落内(被遮蔽),重启后的内容全部落在段落外。宁可多遮一段正常对话,不可漏出一句私密内容。
三次隔离失效
事故一:隔离从未生效,而测试全绿。 前端页面与本地服务分属不同源,浏览器的跨域策略静默拦截了模式查询;按 fail-safe 设计,查询失败即视为正常模式——于是系统永远“正常”,隔离逻辑从未真正运行。而测试因为 mock 了网络层,全部通过。教训有两条:fail-safe 正确地兜住了故障(这是它该做的),但被 mock 的测试无法发现“链路根本不通”;此后的回归测试中加入了对真实链路的检查。
事故二:双重裁剪。 一次重构中,上下文窗口对“已被摘要覆盖的部分”做了二次减除,结果每轮对话的可见历史都被裁成零——表现为她每句话都像冷启动,反复问候、毫无记忆。从用户视角看是“智力骤降”,根因却在裁剪算术。修复后为这个精确症状写了回归测试。
事故三:整段前缀切除。 最初的遮蔽实现是“切掉私密段落之前的一切”——简单,但把正常模式自己的早期历史也切掉了,跨越私密段落的正常对话无法衔接。重构为按段落精确遮蔽后,正常侧的“段落前”与“段落后”重新连续,占位语恰好落在中间承担过渡。
验证
隔离语义由 22 项回归测试守护,要点包括:正常侧保留自己段落前后的历史、每段私密时光恰好产生一条占位、多段落各自占位、崩溃遗留段落封闭且重启后内容不落入段内、双摘要水位互不推进、摘要折叠输入零私密字符、检索(记忆召回)在正常侧只搜索遮蔽后的视图。后来整个记忆系统从上游项目迁出重写时,这 22 项测试原样迁移,作为迁移正确性的验收标准。
可复用的原则
- 内容分级要做在数据可见性层,提示词只是礼貌,不是墙。
- 一切自动行为的失败方向必须预先声明,并朝安全侧倒。
- 任何“由受保护内容生成的元信息”都是泄漏通道,包括看起来无害的过渡语。
- fail-safe 会掩盖链路故障——它工作得越好,越需要针对“链路通不通”的独立测试。