# 一个角色的七层结构 角色卡(一张写满形容词的 prompt)能撑十分钟。撑不过一个月,原因不是模型不够聪明, 而是它**没有状态**:昨天吵过的架,今天不存在;说过的话,下周不记得;你三天没来, 它还是同一种语气。 把角色当成一个系统来看,它至少要拆成七层。下面每一层都给「职责 / 常见反模式 / 本仓库的参考实现」,最后给三条不变量。 --- ## 1. 人格真源(persona) **职责**:回答「她是谁」。名字、关系、性格、说话方式、底线。只写**稳定的东西**。 **反模式** - 把「此刻心情」写进人格(那是第 2 层,会变的); - 把人格写成一段文学描写,模型只能模仿语气,学不到行为约束; - 人格散落在 system prompt、工具描述、代码注释三处,改一处不生效。 **参考实现**:`personas/*.json`(单文件)+ `personas/zhiyao/`(目录包:字段进 JSON,长文本进 md) + `src/persona.js`。人格是数据不是代码:换人不用改 .js,也不用 fork 仓库。 --- ## 2. 状态(state) **职责**:回答「她此刻怎么样」。必须**可计算、可衰减、可被事件推动**。 本仓库用 PAD+ 六维 × 三层: | 维度 | 含义 | | --- | --- | | pleasure | 愉悦 | | arousal | 唤醒(激动 / 疲惫) | | dominance | 支配感(有底气 / 被压住) | | curiosity | 好奇 | | confidence | 自信 | | connection | 连接感(离你多远) | | 层 | 半衰期量级 | 回答的问题 | | --- | --- | --- | | background(底色) | 十几小时 | 这段时间她整体是什么温度 | | primary(此刻) | 几十分钟 | 刚才那一下她的反应 | | social(关系) | 几小时 | 她怎么看你、敢不敢靠近 | 两个关键机制: - **衰减**:时间过去,各层各维按自己的半衰期回到基线。不需要谁说话状态才动。 - **疤**:极少数事件(说死话、被否定到根上)会在**基线**上留下不可逆的位移。 疤不衰减,是所有状态里唯一不回落的东西——也是「她真的记住了」的唯一证据。 - **余震**:高冲击事件(|Δ愉悦| ≥ 8)会在底色上留一段 24–48 小时的尾巴。 跟疤相反,它一定会散——但今晚和明早不一样,不是靠「记得」演出来的。 **反模式** - 只存一个「心情:开心/难过」的标签——没有梯度,事件推不动它; - 状态由模型自己报(「你现在有多难过?」)——不可复现,也没法测试; - 状态只增不减,聊得越久数值越飘,最后只能 reset。 **参考实现**:`core/emotion.js`(纯函数)+ `src/stores/emotion-store.js`(落盘与时钟)。 **还缺一块:她此刻在做什么。** 情绪只回答「高不高兴」,可一个没有日程的角色照样会说错话—— 凌晨三点问「吃了吗」,或者说自己在做饭,而它连厨房都没有。 `core/life.js` 用一张作息表把一天切成槽位(在哪、在做什么、睡没睡), **同一时刻永远同一件事,今天和明天不一样**;人格可以在 `routine` 里换掉整张表。 `core/mental.js` 从这里算精力,再由精力算专注和紧绷——不落盘、不衰减,全是从别处推出来的。 这也是第 7 层「观测」的一部分:`serene_state` 里能看见她此刻的作息、精力和开场状态。 --- ## 3. 记忆(memory) **职责**:回答「发生过什么」。写入与召回是两件事,要分开设计。 - 写入:什么值得记?一条经验法则——**一年后还需要想起吗**。写不短(> 80 字)说明还没想清楚。 - 召回:不是全量塞进 context,而是按激活度抽样;**允许一条都想不起来**。 候选不够就不补位——硬塞两条无关的,比承认没想起来更像机器。 - 变牢:只有真的被用进回复,才按间隔效应抬高稳定度(`S' = S × (1 + 1.2 × (1 − r))`)。 年龄永远从写入那天算,被想起不重置「多久以前」。 - 有上限:到容量(默认 500 条)按 重要度 × 保持率 淘汰,过期的先走;删了哪条要能查。 - 可解释:每条记忆的激活度、保持率、强化增益都要能被同一个公式复算。解释不了,就没法调。 **反模式** - 把所有对话原文当记忆(等于没有记忆,只有日志); - 召回靠向量相似度但从不衰减——三年前的一句话和新约定同等权重; - 「总能召回点什么」:只要有分就取前 k 条,结果每轮都塞进几条不相关的, 同一条被反复注入,记忆就变成了背景板; - 看一眼就算想起:没有「确实用上了」的信号,稳定度只会普涨; - 关系记忆与工作记忆共用一套库:工作记忆要准、要能被检索引用, 关系记忆本来就会褪色,混在一起两边都坏。 **参考实现**:`core/recall.js`(分词 / 保持率 / 激活度 / 确定性抽样,纯函数)+ `src/stores/memory-store.js`(jsonl 追加、容量、淘汰记录)。 没上向量库是故意的:可解释优先,换更强的召回只要保持 `activate()` 的输入输出形状。 --- ## 4. 时间(time) **职责**:让「过了多久」成为一等公民。 - 存**时间戳**,不存「三天前」这种相对描述; - 关机再开要**补算**离线那段:先算衰减,再接受新事件; - 昼夜节律:凌晨三点的唤醒天然低于上午十点,睡眠再降一档; - 时间单调:任何计算都不允许时间倒流。 **反模式** - 用 `setInterval` 每 30 秒 tick 一次驱动状态——进程一停状态就死,还得维护守护进程; - 让模型「想象过了三天」——它没有时钟,只会编; - 存相对时间字符串,第二天全部失真。 **参考实现**:`advance()` 用 `from/to` 两个时间戳算,一次跳多少小时都一样; `EmotionStore.load()` 在打开时做补算。 --- ## 5. 动机(motivation) **职责**:回答「她什么时候会主动开口」。 主动不是定时器,是从状态推出来的:连接感低且很久没互动 → 想找你; 刚被刺到 → 会退;底色亮 → 更容易先开口。 但「想」和「该」是两件事。这一层要同时给三样东西: - **间隔**:`proactiveGap`(毫秒),回答「大概多久」; - **预算**:每天几次、两次之间多久。没有预算的主动会变成骚扰; - **可回收的 hold**:「我想说,但这次不说」,20 分钟内线性退掉。 门越多,她越安静;安静久了,「不说」会自增成习惯——所以 hold 绝不能累积, 也绝不能在不说之后什么都不发生:**每次 hold 都要带一个替代动作**。 **反模式** - 固定「每 30 分钟发一条」——不看状态,很快就变成骚扰; - 让模型自己决定要不要主动——它没有跨会话记忆,每次都是第一次; - 只做「间隔」不做预算:间隔回答的是「大概多久」,不是「现在可不可以」; - 把「不说」记成一个越来越高的阈值——克制变成不在场。 **参考实现**:`core/motivation.js`(意图 / 预算 / hold,纯函数)+ `src/stores/motivation-store.js`(今日额度落盘)。 --- ## 6. 表达(expression) **职责**:把数值翻译成**人话**,再进 prompt。 - 进 prompt 的应该是「这会儿有点低,别硬撑」,不是 `pleasure: 41.8`; - 分三层描述:底色(这段时间)、此刻(刚才那一下)、关系(怎么看你); - 数值只出现在调试工具里,对方永远看不到。 **反模式** - 把 JSON 直接塞进 prompt——模型会开始念数字,或者被数字吓住; - 用「你现在非常生气」这种命令式——那是改写人格,不是描述状态。 **参考实现**:`describeLayers()` / `emotionBlock()`。测试里有一条断言: 进 prompt 的文本不含 0~100 的裸数字。 表达还有另一半,容易漏:**她怎么说话**。语气由状态给,句式和底线不该每次靠模型猜。 这一半也在人格里(`voice.md` / `boundaries.md`),和数值无关—— 「话短、不列点、不知道就说不知道」是人格,不是情绪。 但人格里写了规则,不等于每次都照做。能当场查的规则就别交给模型自觉: `core/style.js` 只管六件和人格无关的硬事——列点、自称助手、解释设定、通用甜话、太长、一口气问太多, `serene_style` 把它做成工具。它不改写、不生成,只回答「这句像不像她说的」。 **底线不能只写进人格。** 撒娇和玩笑可以试,「不想活了」不能。`core/crisis.js` 用一层保守规则先分档: `none` 照常,`watch` 只留档,`high` 时这一轮换掉系统提示——先问安不安全、身边有没有人, 再给 12356 / 120;不诊断、不劝「想开点」、不承诺保密、不说「你只需要我」。 危机轮不写长期记忆。宁可误报一次,不可漏报一次。 --- ## 7. 观测(observation) **职责**:让你能看见内部。 - 快照:三层 + 混合值 + 标签; - 因果:最近发生了什么事件,各自多久之前; - 复现:同样的状态 + 事件 + 时间 → 同样的结果(没有随机、没有模型调用)。 **反模式** - 状态只在内存里,出问题只能猜; - 观测接口会改状态(读一次就衰减一次),导致「看一眼就变」; - 没有确定性测试,改一个系数就不知道坏了什么。 **参考实现**:`serene_state` 返回 JSON;`test/` 里 214 个用例锁住这些不变量。 --- ## 附:开局补的四层背景 这不是第八层人格,是 v0.4 给「开局」补的背景。默认都很轻:没有数据时,prompt 里一个字都不出现。 | 层 | 职责 | 反模式 | | --- | --- | --- | | 外面(`core/almanac.js` / `core/weather.js`) | 年月日、星期、季节、季相、旬;天气读数(宿主报进来,3 小时过期) | 自己联网查天气;把「窗外」写成她身体的一部分;没有读数还编天气 | | 看法(`core/beliefs.js`) | 她自己的判断:种子 + 被事件推动的立场;只挑有底气的三条进 prompt | 一句话翻面;把看法当事实;把「她相信」写成用户说过 | | 用户模型(`core/profile.js`) | 她注意到的他:六个维度、每维最多三条、种子为空 | 一上来就「我懂你」;观察越堆越多;把猜测写成观察 | | 自省(`core/introspect.js`) | 夜里 23:00–02:00 收拢一次:今天记下什么、底色、还信什么 | 白天也自省;编日记;把自省当新事实写回记忆 | 「读到的」(`serene_learn`)不算一层:它只是记忆的一个来源,`source: "learning"`, 在 prompt 和召回里标成「(读到的)」,永远不混成对方亲口说的话。 这四层共同守一条:**只写有依据的**。没有天气就只写日子;没有观察就整句不出现; 没有素材就不自省。宁可少一句,不猜一句。 --- ## 附:结晶层(经验 / 想事情的路子) 上面七层回答「她是谁、此刻怎么样、发生过什么、什么时候开口、怎么说、怎么看」。 还差一个问题:**她有没有从后果里学会什么**。这不是第八层人格,是叠在记忆与看法之上的两层; README 里把它接在 L0–L2 之后,记作 **L3 经验 / L4 想事情的路子**。 | 层 | 职责 | 来源 | 反模式 | | --- | --- | --- | --- | | **L3 经验** | 这种时候 → 我做了什么 → 结果 → 下回 | 只有后果;至少 2 个证据,外加 1 条反证 | 把一次巧合当规律;把聊天总结当经验;只写「下次要更好」 | | **L4 想事情的路子** | 注意力先落在哪儿;不是内容,是习惯 | 反复出现的行为;至少 3 个证据 + `because` + `limit` | 写成自我介绍;没有「什么时候不适用」;一次就定型 | 三道闸门写在 `core/crystal.js` 里,不靠模型自觉:证据不足 → `no_evidence`; 没有反证 → `no_counter`;换谁都能说 → `platitude`。缺口用 `serene_state` 的 `issues` 查。 出口纪律也是结构的一部分:L4 常驻,但**不是台词**,不问就不提; L3 一轮最多挑一条:触发词优先,没命中退回字面相似度,太低就空着。把「她习惯先观察」说成「我是一个善于观察的人」, 就是把背景演成了人设。 公开版只读 `lessons.jsonl` / `dispositions.json`:没有自动观察、没有后果账本、 没有让模型写自己的入口。人格档案里也可以带 `crystals` 种子(`lessons` / `dispositions`), 只在读的时候合并;同 id 的文件条目会盖过种子。经验要真的自己长,得在宿主侧记后果、跑验证,再调 `promoteLesson()` / `reviseLesson()`;这一版不替她结算。 --- ## 三条不变量 1. **确定性**:给定相同的初始状态、事件序列、时间戳,结果逐位相同。 随机数和模型调用一律不进核心层——召回要抽样,就用 `hash(输入)` 当噪声,不用 `Math.random`。 2. **情绪不改写事实**:状态只影响语气,以及**哪些记忆更容易浮起来**; 不改记忆内容、不改真假、不改召回公式。事实与知识类不染心情, 心情差可以让她想不起昨天那句玩笑,但不能让她「记错」你说过的话。 3. **可解释**:任何一条被召回的记忆、任何一个数值变化,都能用公开公式复算。 --- ## 公开版与私有版的边界 公开版以前只有织法。从 v0.3 起,它也有**一块布**——但只有这一块,而且是照着公开版的需要新写的: `personas/zhiyao/`,知遥。 | 在公开版里 | 不在公开版里 | | --- | --- | | 七层的结构与最小实现 | 私人关系史、称呼习惯、任何具体癖好 | | 知遥:为公开版新写的人格包 | 原版知遥的私人文案与设定 | | 小满:**最小**人格示例(JSON 单文件) | 屏幕采集、TTS 配音、ComfyUI 出图 | | 情绪 / 记忆 / 作息的纯函数内核 | 30 秒心跳守护、桌面陪伴进程、计费 | | 人格 schema、加载器、可复现测试 | 任何真实对话、真实记忆、真实数据 | 原因很实际:**结构可以分享;布要分享,也只能分享一块不涉及任何人的**。 知遥的公开版因此做了三件事:去掉所有私人关系设定、把关系性词表交还给人格(默认词表中立)、 重写全部文本(`SOURCES.md` 里写明来源与许可)。 纯插件形态(无守护进程、无采集、无模型调用)仍然是公开版的底线—— 别人 clone 下来,`dsh plugin add` 就能跑。 --- ## 在自己的项目里用这套结构 1. 先把人格写成 JSON,别写进代码; 2. 定六个维度和三层,给每层一个半衰期; 3. 写纯函数:`advance()` / `applyEvent()` / `describe()`,先写测试再调系数; 4. 落盘只放在一个 store 里,原子写,打开时补算; 5. 观测接口先做,再谈 UI——看不见内部,调不动参数。 做到第 3 步,角色就已经「活着」了;后面都是外壳。