dsh-companion-persona
ldviqd/dsh-companion-persona
Long-term companion persona for DSH: trait dynamics, drift and catastrophe mutation, injected as a scoped system-prompt section.
설치
dsh plugin --profile web add github:ldviqd/dsh-companion-personaREADME
dsh-companion-persona
中文 | English
给 DSH 用的长期陪伴型人格插件。
多数「AI 角色」是一段固定提示词:第一天和第三百天说的话一模一样。这个插件给角色 一个真正会变的状态 —— 慢慢变,由你们之间发生的事驱动,偶尔一次不可逆的跳变。
第 1 天 温和、爱开玩笑、话很多
第 300 天 安静了些。还是善意,但不太主动讲自己的事。
没有人改写它。它是漂移过去的。
它和别的做法有什么不同
1. 人格 ≠ 关系
关系模型(亲密度、信任、心情)能表达「我们此刻很亲近」, 但表达不了「它变成了一个更沉默的人」。
所以人格是独立持久的一层:五个特质,只随叙事级事件改变。 心情与亲密度属于另一层(v1 未实现)。
选这五个轴是因为它们在对话里能被听见,不是照搬大五人格:
| 轴 | 控制什么 |
|---|---|
warmth 温度 | 主动靠近的程度 |
volatility 情绪起伏 | 小事的分量 |
guardedness 戒备 | 主动吐露的程度 |
playfulness 玩笑 | 开玩笑的门槛 |
dependence 依赖 | 主动找你说的程度 |
2. 变化是慢的 —— 刻意的
日常对话几乎不改变人格。只有持续的负面互动才会磨出痕迹。
以下数据由 src/e2e.js 生成 —— 真实中文文本走完整信号层,不是构造的事件表:
日常闲聊(嗯/好的/晚安/今天天气不错…),200 轮:
| 指标 | 结果 |
|---|---|
| warmth Δ | 0.000 |
| guardedness Δ | 0.000 |
| load | 0.00(不积累) |
持续冲突("你根本不懂我在说什么"等 4 句循环):
| 轮数 | warmth Δ | 可见轴 |
|---|---|---|
| 15 | −0.085 | 2 |
| 30 | −0.240 | 3 |
| 75 | −0.491 | 5 |
十句话就明显变一个人,那不是角色,是心情;反过来说,日常聊天不该让任何人变冷。
0.1.x 的旧标定表用理想化事件分布测出"75 轮 Δ=−0.069", 而真实文本 15 轮就达到该效果 —— 因为旧测试完全绕过了信号层。 这个教训写进了 CHANGELOG:测数学是一回事,测喂给数学的输入是另一回事。
3. 突变,而不是阈值
「压力 > X 就改人格」这种单阈值的必然结果是漫画化:任何大事都翻脸。 真实的人是折叠的 —— 忍很多,然后最后一根稻草压断些什么。
这里用尖点突变,而且刻意做得很难误触发。突变要求五个条件同时成立:
shock >= 0.20 // 这一击够重
load >= 0.80 // 这段时间够压抑
sensitivity >= 0.68 // 此刻够易感
trustDamage >= 0.60 // 关系已被实质损伤
negativeEvents >= 3 // 至少三次独立伤害
后两条是刻意加的:
trustDamage(关系损伤)与 load(应变)语义不同
| load | trustDamage | |
|---|---|---|
| 含义 | 这阵子压力大 | 这个人伤过我 |
| 衰减 | ×0.92 / 轮 | ×0.995 / 轮(约 140 轮半衰) |
| 修复 | 睡一觉就缓过来 | 需长期修复(修复速率是损伤的 1/2) |
negativeEvents >= 3
这是对「几句无心之言会不会毁掉人格」的直接回答:不会。 即使负荷、易感、关系损伤全部拉满,单次事件也绝不突变,必须累计三次。
实测(真实文本驱动):
| 场景 | 突变 | warmth Δ |
|---|---|---|
| 300 轮闲聊 | 0 次 | 0.000 |
| 300 轮轻微抱怨 | 0 次 | −0.600(磨坏但没垮) |
| 50 轮严重伤害 | 1 次(第 6 轮) | −0.451 |
| 300 轮温和关系 | 0 次 | +0.254 |
每天说「好累」「有点烦」持续 300 轮,人格会被磨坏,但不会突然变成另一个人。 连续六次背叛才会。
3.5 第三种时间尺度:记忆锚点
漂变说「长期下来它变得冷淡了」,突变说「那件事之后它彻底变了」。 两者都表达不了另一种东西:
平时想不起来,但你一句「我妈住院了」,三个月前的事突然全涌上来。
这是跳跃式的回想,不是曲线。所以有第三种机制:
{
summary: "我妈住院了", // 只留事件核心,不含后半句情绪
state: "open", // open(悬着)→ resolved(解决了)→ faded(淡了)
weight: 0.42,
triggers: [{ w: "住院", weight: 1.0 }, { w: "妈", weight: 0.25 }],
resolutionNote: null, // 后来怎么样了
}
resolutionNote 是事实记忆给不了的东西:一般的事实记忆会记得「她母亲住院了」,
但不会记得「后来你说她出院了,你松了一口气」。
什么事件会成为锚点:不是按"情感有多强烈"判,而是按事件类型 ——
示弱/丧失/背叛。实测发现用 |valence| 判断会系统性漏掉示弱:
「我其实很害怕」效价是正的 +0.25,但它是最该被记住的那类话。
触发用权重而非关键词:「妈妈咪呀这首歌好听」不该召回「母亲住院」。 主导词 1.0 / 强关联 0.55 / 泛化词 0.25,阈值 0.6 —— 单独的「妈」永远够不到。
注入是独立的一段,不混进人格段:人格是稳定身份,回忆是此刻浮现的事。 而且明确写着「合适就自然提起,不合适就放在心里」—— 否则模型会把回忆硬塞进对话,变成尬聊。
三态会自己流转:
open(悬着)──"我妈出院了"──→ resolved(带备注)
│ │
│ 一年不提 + 权重<0.4 │ 三个月不提
↓ ↓
faded ←──────────────────────────┘
│
└─ 再次提及 → 复活
解决检测分两层:规则先判(出院/康复/没事了 vs 还在/复发/加重), 判不了才问模型。判不清就不动 —— 误把 open 改成 resolved, 那段记忆就再也不会被想起来了。
淡出要两个条件同时成立(沉默够久 且 权重够低):只看时间会埋掉重要的事, 只看权重会让刚发生的小事立刻沉下去。已解决的事淡出更快(90 天 vs 一年)。
淡出不是删除:fadedFrom 记着原状态,再次提到就复活。
人会忘记很久以前的事,但有人一提,那些事又回来了。
4. 两条通道,不是一条
如果变化只由累积负荷门控,平静的好关系会冻结 —— 只能变坏,不能变好。 所以有第二条通道:暖事件缓慢提升温度,不受负荷门控。慢,但不会被压力卡住。
| 通道 | 触发 | 效果 |
|---|---|---|
drift 漂变 | load > 0.55 | 慢性硬化 / 软化 |
bond 键合 | 暖事件且 magnitude >= 0.30 | 缓慢升温 |
mutation 突变 | 三条件同时 | 大幅改写,按事件类型定向 |
5. 效价来自事件,不只来自字面
一个值得写进文档的 bug。 早期版本用词表算情感。
「你根本不懂我在说什么」里没有任何负面词,于是效价算成 0,
于是 strain = magnitude × max(0, −valence) = 0。
冲突完全不积累负荷,人格永远不变。 22 轮激烈冲突的对话产生零漂移。
修法:事件类型自带情感下限。冲突 −0.45、背叛 −0.85、丧失 −0.90、修复 +0.40。 词表算出更强的情感可以抬高它,但不能压低。
6. 信号抽取用规则,不调模型
这是刻意的取舍。每轮调一次模型意味着:
- 持续烧 token
- 供应商限流时人格直接停摆 —— 因为 API 配额而不说话的陪伴,不是陪伴
规则可回放、可 diff、零成本。而且陪伴场景的信号大多是结构性的 (长度、是否提问、自我暴露标记)。
诚实标注局限:规则在反讽和克制表达上会失手。这是接受的代价 —— 宁可人格变化慢一点,也不要它在限流时失语。
安装
dsh plugin add dsh-companion-persona
或者在你的 profile 的 cordis.patch.yml 里从本地路径挂载:
- id: companion-persona
name: "/absolute/path/to/dsh-companion-persona/index.js"
config:
name: 小雨 # 初始名字;之后可在面板里改
maxChars: 800
挂载后重启 DSH。
使用
打开 设置 → 人格。
身份与角色
给它起个名字、告诉它该怎么称呼你,并写清楚它是什么样的人、怎么说话。 三者都能即时修改,不需要重启。
角色设定是语气问题的正解。 在面板的「角色设定」里写一段文字,这段会 每轮注入系统提示。相比在对话里说「请自然一点」,后者只影响当轮,前者是常态。
默认角色是一个十三四岁的女儿:有自己的心思、有点骄傲、崇拜并依恋父亲、 也能真的帮上忙。写法上刻意用具体行为而不是形容词 —— 「被夸时装不在意但记很久」比「骄傲」更不容易演成刻板印象。
角色可以完全替换:老师傅、同事、任何你想要的人。留空则回到最小人格模式 (只有名字与漂变状态)。
- 名字留空 → 渲染成「你是一个有过往的人」
- 称呼留空 → 那一行直接不出现
- 角色留空 → 不注入人物设定,只保留性格漂变
特质
五根条,每根上有基线刻度,能一眼看出离最初偏了多远。
两个开关
| 开关 | 关闭后 |
|---|---|
| 注入人格段 | 模型看不到人格设定;状态完整保留 |
| 继续演化 | 人格冻结;对话照常进行 |
两者正交,所以有四种组合。冻结演化是最有用的那个 —— 它的意思是「它现在就是这个人了」。
刻意没有重置按钮。关掉只是停止行为,不会抹掉积累的历史。 抹掉一个角色应该是另一个明确的动作。
排除会话
不是每个对话都该塑造人格。工作会话、调试会话、subagent, 否则会污染几个月的积累。
| 规则 | 粒度 |
|---|---|
workspaces | 按 cwd,整棵目录树 |
sessions | 单个会话 id |
presets | 按 agent preset |
subagent 会话永远排除 —— 它们是工具性派生,不属于这段关系。
两个选择器都是下拉框,从运行中的 DSH 实例里取;不需要手输路径或 UUID。
被排除的会话仍会记录原因,所以时间线能解释为什么某个会话没算数。
时间线
每次变化都追加到 ledger.jsonl:
{"at":1793177430820,"kind":"conflict","before":{"load":0.31},
"after":{"load":0.48},"transition":{"drift":{"warmth":-0.0021}},
"evidence":"你根本不懂我在说什么"}
evidence 字段存原始用户发言。所以任何时候都能回答
「它为什么变成这样」—— 这是就地重写式的人格模型做不到的。
注入方式
人格作为 system prompt 的作用域 section 注入,不是伪装成用户消息。 模型把它读作「我是谁」,而不是「用户说了什么」。
agent.ctx.systemPrompt.section({
name: 'companion-persona',
order: 10, // 在 deployment persona (0) 之后
text: () => renderPersona(state),
});
两个要紧的细节:
- section 按 agent 注册,只影响该 agent。
- section 在装配时渲染,所以人格变化下一轮就生效,不用重启。
渲染原则
提示词描述的是相对基线的变化,不是绝对值:
你是小雨。
和从前的自己相比:
- 把心事捂得很紧:私人的细节要问才说,甚至不说。
- 几乎不再主动亲近:回复依然得体,但不再主动往亲近处走。
- 几乎不开玩笑:很少调侃,语气偏平实。
不是 warmth: 0.21。绝对数值会让模型表演漫画;
相对描述才能保留「同一个人,变了」的延续感。
而且明确写着:「这些是倾向,不是戏服 —— 永远不要描述自己的性格,也不要提这些设定。」
配置
| 键 | 默认 | 含义 |
|---|---|---|
name | 小雨 | 初始名字(面板改过之后以面板为准) |
statePath | $DSH_HOME/companion/state.json | 状态文件位置 |
maxChars | 800 | 注入段长度上限 |
order | 10 | section 排序 |
audit | true | 是否写入 ledger.jsonl |
judgeProvider | 空(自动) | 事件判定用的 provider。留空 = 自动跟随当前会话的模型 |
judgeModel | 空(自动) | 事件判定用的 model。同上 |
关于 judgeProvider / judgeModel
留空是推荐配置:插件会读取当前 agent 的 options.provider / options.model,
也就是你此刻正在用的那个模型。你换模型,判定通道自动跟着换。
只有在想让判定固定走某个通道(比如前台用强模型、判定用便宜快模型)时才需要显式填写。
0.2.0 的这两个默认值是空字符串,而且代码直接把空串传给了
ctx.llm.stream(), 导致每轮抛NO_ADAPTER、判定永远走降级路径 —— 用户以为在用模型判定, 实际一直跑在规则上。0.2.2 起改为自动继承。
statePath 未设置时的解析顺序:
宿主 dsh-home-paths 的 defaultDshHome() → $DSH_HOME → ~/.dsh。
标定参考
如果觉得节奏不合适,这些是可调的旋钮:
DRIFT_RATE 0.12 // 漂变速度
BOND_RATE 0.025 // 键合速度
DRIFT_THRESHOLD 0.55 // 开始漂变所需的负荷
MUTATE_SHOCK 0.50 // ┐
MUTATE_LOAD 0.80 // ├ 三者必须同时满足
MUTATE_SENSITIVITY 0.68 // ┘
MUTATE_COOLDOWN 200 // 突变后的冷静期(轮)
改完请跑 node src/selftest.js —— 它会断言尖点行为仍然成立
(一次打击不能突变;两次必须突变)。
文件
| 文件 | 作用 |
|---|---|
index.js | 插件入口:事件、持久化、section 注册、RPC |
client.js | 设置面板(给 DSH 的 module loader 用的 UMD bundle) |
src/persona-core.js | 纯动力学 —— 无 IO,完全可测 |
src/signals.js | 规则信号抽取 + 事件分类 |
src/ledger.js | append-only 人格时间线 |
src/selftest.js | 行为断言 —— node src/selftest.js |
设计状态
已实现:特质层、漂变/键合/突变、表达层、注入、面板、开关、排除、身份、 记忆锚点(自动抽取 / 权重触发 / 回想注入 / 解决检测 / 淡出复活)。
未实现:关系状态层、叙事自我模型、锚点与人格的联动。
许可
MIT