# 主动开口(`proactive` / 活跃对话) > 冷场时主动冒个泡、聊起来之后免 @ 接话与补一句。群里那两条路受 `proactive` 总开关约束, > 私聊补一句有自己的开关;私聊**永远**不主动打扰。判据全在 `speaking-rules.js`,可以单测。 ## 偶尔主动说话(`proactive`) 复读是「跟着别人说」,这一条是反过来:**群里安静下来之后,它自己想一句冒个泡**。 ```yaml config: proactive: true # 默认 true:不想让它开话头就设 false proactiveChance: 0.35 # 每次「考虑」时真的开口的概率 proactiveDailyMax: 4 # 每个群一天最多主动说几次 ``` 默认**开启**。它也只在**群**里说话 —— 私聊对象是具体的人,被机器人无端戳一下 比在群里搭一句话冒犯得多,所以私聊**永远**不会被主动打扰(没有对应的开关,这是刻意的)。 **它说什么?** 不是模板,是让模型带着上下文自己想:插件往**这个群自己的那个会话**里 投一条提醒(「这个群已经安静了大约 N 分钟,你可以自己冒个泡」),模型看到的仍然是同一套 系统提示、同一份长期记忆、同一段[群聊环境上下文](speaking.md#像真实群友一样说话) —— 于是它接得上的 是真在聊的话题,而不是一句「大家好啊」。回复走**同一条拟人通道**(拆短句 + 打字延迟), 和平时回话没有任何区别。 **什么时候会开口?** 定时器每 `proactiveCheckMs` 醒来一次,醒来的那一刻要同时满足: | 条件 | 配置 | 为什么 | |---|---|---| | 在「清醒时段」内 | `proactiveFromHour` / `proactiveToHour` | 真人凌晨不会突然在群里冒泡 | | 群里安静够久了 | `proactiveIdleMs` | 有人正在聊的时候插嘴最不像人 | | 上一条**不是**它自己说的 | — | 它刚说完、没人接话时再开口就是**独白**,真人不会对着空群自说自话 | | 这个群最近活跃过 | `proactiveFreshMs` | 三天没人说话的冷群突然冒一句最吓人 | | 距上次主动说话够久 | `proactiveGapMs` | 同一个群别连着搭话 | | 今天的额度没用完 | `proactiveDailyMax` | 一天最多几次(本地日界线) | | 掷骰子中了 | `proactiveChance` | 让「开口的时刻」不可预测,而不是每到整点就说话 | 一次醒来最多只在**一个**群里开口(哪个群也是随机的)—— 同时在几个群冒泡比不说话更像机器。 另外它只对**已经聊过的群**生效:`conversations` 里没有的群(从没人 @ 过它、它也没回过话) 不会成为候选,重启之后也要等那个群再有人说话才会重新进入视线的范围。 **可以什么都不说。** 提醒里给了模型一个「我不说话」的出口:整条回复写 `SKIP`(后面顺手 带一句括号里的解释也行),插件就一个字都不发。这是**正常且常见**的选择 —— 不给出路时, 模型面对「没话找话」的场景会硬编一句,那正是最像机器人的地方。 这个出口是**整个会话**的,不限于主动搭话那一轮:提醒落在同一个会话的上下文里,模型很 容易在**回别人**的那一轮(尤其那一轮还引用了消息的时候)也照样写一个 `SKIP`。所以判据 对所有轮次都生效,否则群里收到的就是一句字面的 `SKIP` —— 那句话本来是「什么都不做」的 意思(见 `speaking-rules.js` 的 `proactiveSkip`)。 几条实现上的取舍: - **掷中就消耗机会**:判成 `SKIP`、或者这一轮失败,`proactiveGapMs` 与当天额度照样扣掉。 否则每次检查都会重新掷骰子,`SKIP` 会变成「再来一次」,开口频率直接失控。 - **出错不道歉**:主动搭话的 turn 失败时**不会**在群里回「我这边处理时出错了」—— 根本没人问过它,那句话只是噪音(平时回话失败时照旧会提示)。 - **状态在内存里**:`lastProactiveAt` / 当天计数随重启清零。重启后至少还要等 `proactiveIdleMs` 才可能开口,所以正常重启不会让它多说话,但**别把 `proactiveIdleMs` 设成 0 又反复重启**(那时每次重启都可能补一句)。 - **每次搭话都是一次模型调用**:这是「不是模板」的代价。把 `proactiveCheckMs` 调大、 `proactiveChance` 调小,花的钱就少。 排查:日志里主动搭话会记一条 `... 已经安静 N 分钟,主动开个话头`,说出去的那句标着 `(主动)`,判成 `SKIP` 时记 `(判成 SKIP,不发送)`。`/reset` 会连同该群的主动说话 计时与计数一起清掉。 ## 聊起来之后保持搭话(活跃对话) 上面那套 `proactive` 只在**冷场**(群里安静 45 分钟)之后才开口 —— 可真人聊起来的时候 不需要你每句都叫一遍名字,也不会在你说完十秒之后就彻底闭嘴。这一节是「正聊着」的那一半, 三件事共用一个**活跃对话窗口**: ```yaml config: activeWindowMs: 60000 # 被 @ 到之后的 1 分钟里算「还跟你聊着」(多人搭话也用它量热度) activeFollow: true # A:窗口里同一个人接着说的话也当作在跟它说(默认开) activeChimeIdleMs: 20000 # B:它回完之后你安静 20 秒,它可能再补一句(需要 proactive: true) activeGroupLines: 6 # C:最近窗口里大家说够 6 条话就进入「多人搭话」 activeGroupSpeakers: 2 # C:而且至少要有 2 个不同的人说过话 activeGroupBatchScale: 2 # C:搭话状态里攒批窗口 ×2 —— 多听几句再合成一轮回复 activeTalkChance: 0.05 # A/C:够资格之后接话的起始概率(抽卡) activeTalkChanceStep: 0.1 # A/C:每多一条没抽中的消息,概率涨这么多 activeTalkChanceMax: 0.8 # A/C:概率上限 ``` **窗口怎么算**:被叫到(群里的 @ / 前缀,或私聊的一条消息)就开一个窗口,之后**同一个人** 每接一句就往后滑 `activeWindowMs`。窗口只靠**别人发来的消息**往后滑 —— 机器人自己说十句也 撑不住这个窗口。**默认只有 1 分钟**:@ 它一次买的是这一小段,不是「这一整天它都归你」; 想让它陪你聊久一点就把 `activeWindowMs` 调大。 **A:免 @ 接话(`activeFollow`,默认开)** `groupTrigger: mention` 的意思是「只在被 @ 时说话」,这符合「不打扰群」的默认预期,代价是 聊起来之后你每句都得 @ 一下,很出戏。打开 `activeFollow` 之后,窗口内**那个人**接着说的话 不再需要 @。四条同时成立才算「够资格接话」: - 说话的就是**正在跟它聊的那个人** —— 别人冒一句不会被它抢答(除非那句让群聊热到 C 的门槛); - 这句**没有 @ 别人**(`@某人`、`@全体` 都不算跟它说的); - 中间**没有别人说过话**(它记着群里最后一句接话时的进度序号):有人插过话,你俩这段 一对一就算断了,要重新叫它一次; - 这次对话考虑过的免 @ 消息还没到 `activeMaxTurns`(默认 10),够数就要重新叫它。 够资格之后**还要抽一次卡**(见下面的「够资格之后还要抽一次卡」)—— 不是每句都回。 也就是说:**它放松的是「被叫到之后」的约定,不是把 `mention` 变成 `all`**。私聊不需要这条 (那边每条消息本来就都是跟它说的),所以 `activeFollow` 只在群聊生效。 **B:自己补一句(`activeChime*`)** 人常有「啊对了」:回完一句、对方沉默了一会儿,再追一条。前提是 `proactive: true`(「允许它 主动发消息」的唯一总开关)。满足之后,它在**正常回完一轮**、对方安静超过 `activeChimeIdleMs` (默认 20 秒,按 `delayJitter` 抖动)、中间没有别人插话、这条消息之后还没补过 (`activeChimeMax`,默认 1 句)、当天没超量(`activeChimeDailyMax`,默认 12 句)时,才掷一次 `proactiveChance` 决定补不补。补的那句问的是「你已经回过他了,还有想说的吗」,同样可以回 `SKIP` 一个字都不发。 **C:多人搭话(`activeGroup*`)** 一个人 @ 一次只买到 1 分钟,但几个人**自己聊起来**的时候,它不该还在旁边等 @。所以除了 一对一窗口,它还看着群里的「热度」:最近 `activeWindowMs`(默认 1 分钟)里,**别人**说的话 累计够 `activeGroupLines`(默认 6)条、并且来自 `activeGroupSpeakers`(默认 2)个不同的 人,就进入**搭话状态** —— 那之后群里**谁**接话都算数(但仍然要过下面那次抽卡),直到这个 窗口里凑不齐条数或人数(大家一停,它自己就退出,不需要谁通知它)。 搭话状态里它的**倾听时间变长**:攒批窗口与整批等待上限一起乘 `activeGroupBatchScale` (默认 2),也就是「多听几句再合成一轮回复」,而不是每两秒截一句就回。两个条件一起卡住 两类误判:一个人自己刷屏凑不出「几个人」,两个人半天说一句也凑不出「聊起来了」。 `activeGroupLines: 0` 可以整块关掉多人搭话,只留一对一的免 @ 接话。 别人插一句因此不再是「你们聊完了」的信号:那句话本身就会让热度往门槛上走,够热就整体升级 成多人搭话(这也是它不消耗 `activeMaxTurns` 的原因 —— 那个上限是防一个人揪着它刷屏的)。 **够资格之后还要抽一次卡(`activeTalkChance*`)** A 与 C 都只决定「够不够资格」,够资格不等于就接 —— 每条够资格的消息还要掷一次**会自己涨** 的概率,像抽卡那样: - 起始概率是 `activeTalkChance`(默认 5%); - 每多一条**没抽中**的消息,下一次的概率就加上 `activeTalkChanceStep`(默认 +10%), 直到 `activeTalkChanceMax` 封顶(默认 80%)—— 也就是「连着几句没接上,就越可能接一句」; - 抽中、或者被明确 @ 到(那是不用抽的直接回复)之后,累计归零,重新从 5% 攒; - 这段对话凉了(一对一窗口过期、群也不再算聊热)之后累计同样归零 —— 上一场聊天攒下来的 「保底」不会在下一场开头突然炸出来。 没抽中的那条**什么都不发**(但它照常进转录、环境上下文与群聊热度),所以群里看到的是 「它偶尔接一句」,而不是「聊热了它就每条都回」。A 路里没抽中的消息仍然算「人还在跟它说话」: 一对一窗口照滑、`activeMaxTurns` 照扣 —— 否则窗口会在概率攒够之前先过期,那条路就名存实亡了。 > 这套曲线让「搭话频率」变成一个可调的数:想让它更爱接就调大 `activeTalkChance` 或 > `activeTalkChanceStep`,想让它在热群里彻底闭嘴就把 `activeTalkChance` 与 `Step` 都设成 > `0`(那就永远不会接)。被 @ 到的话永远照常回,抽卡只影响「免 @ 搭话」这一层。 几条要点: - **不会连珠炮**:对方每说一句之后最多补 `activeChimeMax` 句;它自己补的那一句**不会**再 触发下一次补(否则就成了对着空气自言自语)。 - **对方又说话就作废**:挂着的「补一句」会被新消息取消 —— 那条消息自己会走正常回复, 不需要它再补。 - **接话不是指令**:窗口里免 @ 接上的那句只当闲聊交给模型,`/reset` 这类旁路仍然 要求明确叫它(@ 或前缀)—— 否则窗口里随便一句「reset」就绕开模型把会话重置了。 - **私聊也补,但冷场不主动**:私聊里窗口照样滑,所以它会在你回完、安静一会儿之后追一句; 而「主动找人聊天」(冷场开口)仍然只在群里发生。这就是[偶尔主动说话](#偶尔主动说话proactive) 里那句「私聊永远不主动打扰」的边界 —— **聊着的时候可以补,没人理的时候不会去戳你**。 - **`/reset` 把「还聊着」也一起忘掉**:一对一窗口、多人搭话的热度、抽卡攒下的概率、挂着的 补一句都作废(新开的那一轮不带这些)。 - 日志里补的那句标 `(主动)`,并且前面会有一条 `... 对话还热着(对方安静了约 N 秒),主动补一句`。 > 为什么一对一那么保守:群聊是公开的,别人一插话,你接着说的一句很可能是冲着他去的。 > 宁可少接一句,也不要抢话 —— 想继续聊,重新 @ 它一下;而如果大家本来就在聊,走的是上面 > C 的多人搭话,不需要谁来叫它。 ### 私聊补一句(L2,默认开) 群聊的「补一句」跟着 `proactive`(那是在群里主动开口的总开关);**私聊的补一句不搭那趟车** —— 它是对着一个具体的人「刚聊完、再看有没有想补充的」,语义不同,所以有自己的开关与额度: - 只私聊;只在你**正常回完一轮之后**挂一次(命令、出错、SKIP、空消息之后的轮次不补); - 对方安静 `privateAfterthoughtIdleMs`(默认 15 秒,会抖动)之后才考虑; - 对方在这期间**又说话了** → 不补(那句自己会走正常回复); - 一次用户消息最多补一句,不连锁;当天到 `privateAfterthoughtDailyMax`(默认 10)就停; - 概率 `privateAfterthoughtChance`(默认 0.3);判成 `SKIP` 就一个字都不发。 它仍然属于「主动给真人发消息」,所以上面每一条都是一道闸;`privateAfterthought: false` 一键关掉。