暖屿 Warm Isle · 会记事、会主动找你的 QQ AI 陪聊机器人 · Gitee
menran/warm_isle· QQ 群 601921002 系列第 1 篇。项目:暖屿 Warm Isle(AGPL-3.0)。这篇只讲数据模型,不讲部署。 所有字段与阈值都来自仓库src/main/java/com/qqbot/memory/。
一、第一版记忆长什么样
最早的记忆只有一张 facts 表:
CREATE TABLE facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
qq_id INTEGER NOT NULL,
category TEXT NOT NULL, -- profile / preference / relationship / event / commitment
key TEXT NOT NULL, -- 归一化主题,比如「工作」
value TEXT NOT NULL, -- 具体内容,比如「在字节做后端」
confidence REAL NOT NULL DEFAULT 1.0,
status TEXT NOT NULL DEFAULT 'active',
first_seen TEXT,
last_confirmed TEXT
);
每天中午跑一次抽取,把聊天记录丢给模型,让它输出若干条 key/value。这个设计能跑,但用一周就会撞墙:
-
只有"是什么",没有"什么时候"。
key=加班, value=加班到十点——今天是哪天?下周还成立吗? -
没有"该不该提"。
妈妈住院了和喜欢冰美式权重一样,主动消息会去关心你喝不喝咖啡。 -
没有生命周期。一次性的"今天吃了牛肉面"永远躺在库里,还会被检索出来。
-
冲突没处放。换了工作,新旧两条
key=工作同时 active,模型每次看到两个答案。
二、第二版:四类记忆 + 可行动性
v2 换了张表,加了三个维度的信息:
public record Memory(long id, long qqId, String type, String subject,
String key, String value, String source,
double importance, double confidence, double emotion,
String firstSeen, String lastConfirmed, String expiresAt,
String followUpAt, String followUpState, String actionHint,
String status, Long supersededBy,
double strength, String strengthAt, boolean pinned,
String recurMd, String recurLast, int recallCount) {}
类型决定它怎么被用:
| type | 含义 | 例子 |
|---|---|---|
profile | 长期属性 | 在杭州、养了只猫 |
event | 有时间的经历 | 周五面试、昨天加班到十点 |
relation | 你们之间 | 她叫你老板、约好下月看海 |
world | 文档设定 | 知识库里导入的世界观 |
subject 决定它是谁的:user / bot / third:名字。key 归一化主题,事件类会拼上日期变成 面试@2026-10-26,这样"下个月还有一次面试"不会覆盖掉上一次。
importance 不再是"重要程度",而是"可行动性"——这条记忆能不能支撑一次关心、追问或延续话题:
actionable = 0.4 × 能不能关心(care)
+ 0.25 × |情绪强度(emotion)|
+ 0.2 × 时效性(timeliness)
+ 0.15 × 稳定性(stability)
写入门槛 0.4,排主动回忆的门槛 0.65。阈值是调过的:设计稿原定 0.7,但实测模型给分普遍保守,最高只到 0.68,导致"该关心的事"永远排不出来,于是按"宁可多问一句"下调到 0.65——这个数字现在写在 NightlyMemoryService 的常量注释里。
event 默认 7 天过期(EVENT_TTL_DAYS = 7),过期后 status='expired',不再参与检索;profile 不会。
三、冲突合并:同 key 怎么办
写入走 upsert(qqId, draft),逻辑很短但很关键:
Memory existing = findActive(qqId, type, subject, key);
if (existing != null && sameValue(existing.value(), value)) {
// 又说了一遍同一件事 → 刷新确认时间、强度回满(复习效应,下一篇细讲)
} else {
// 新值 → 插入新行,旧行 archived + superseded_by 指向新行
}
sameValue 会先去掉空格和中文标点再比较,避免"在杭州。"和"在杭州"被当成两件事。
被 superseded_by 标记的旧行不会被删——排查"模型为什么记错"时,能一路看到它是什么时候被谁覆盖的。
四、每日 episode:给"那一天"留个底
除了记忆条目,还有一张 episodes 表,每天一条:
CREATE TABLE episodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
qq_id INTEGER NOT NULL,
day TEXT NOT NULL,
summary TEXT NOT NULL, -- 当天一句话摘要
events TEXT, -- JSON 数组
emotion REAL NOT NULL DEFAULT 0,
msg_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
它和 memories 的分工:memories 是"抽出来的结论",episodes 是"那天的原始脉络"。检索命中不了任何记忆时,兜底会带上最近 1~2 条事件,而不是硬塞几条长期属性。
五、迁移:不删旧数据
facts 表没有删,只是从 v2 起不再写入,转为只读,作为迁移来源和回滚依据。迁移方法幂等:同 type + key 已有 active 记忆就跳过,可以反复跑。
public int migrateFromFacts(long qqId) { ... }
六、这一版仍然没解决的问题
-
world类型目前没有写入来源(知识库"设定"用途还没接),表里有这一类,实际是空的; -
记忆的"强度"是后加的(下一篇),v2 刚上线时只有 importance,衰减策略是"90 天 ×0.85"那种粗糙做法;
-
同一件事的多次提及只会刷新一条记录,不会留下"说过三次"的证据链,只有
recall_count能侧面反映。
下一篇讲怎么让它"忘"——以及我们第一版遗忘逻辑里两个真实的 bug。
项目地址
-
Gitee(主仓):gitee.com/menran/warm…
-
QQ 交流群:601921002(部署、记忆设计、想抄作业都可以来)
-
协议:AGPL-3.0;请用小号,注意 QQ 风控。