让 AI 自己决定什么时候开口——一个还在改的择时引擎

海龙2026-6-23编程AI

我想做一个会主动找我说话的 AI,结果卡在了"什么时候说"

这篇不是教程,是我做一个常驻陪伴 AI 时的一段思路记录。它有一块我自认为想得还算清楚、也有几个旋钮我到现在都不确定调得对不对。先把话说在前面,省得你当结论看。

最开始我以为难的是"说什么"。真做下去才发现,"什么时候说"才是那个一上来就把人摁住的问题。


定时器这条路,我一开始就觉得不对,但还是试了

最直觉的做法:起个定时器,每 N 分钟触发一次,到点就开口。

我写完就觉得别扭。聊得正起劲时它在傻等下一个周期;你忙了半天没理它,它又准时蹦出来烦你。节奏是死的,对话的热度是活的——拿一个常数去套一个变量,怎么调都是错。

我甚至想过给不同时段配不同周期、加一堆 if。写了两行就放弃了——那是在用规则打补丁,补丁永远补不完。问题不在周期设多少,在"周期"这个东西本身就不该是常数。


转折:把"何时开口"当成一个会衰减的物理量

某次我盯着"热度"这个词发呆,突然觉得——干嘛不真的把它当成一个会冷却的量?

我引了一个 heat ∈ [0,1]

  • 你说一句话 → 给它加热;
  • 没人说话 → 它按时间自己凉下去;
  • 下次该不该开口,完全看当前还有多热。

越热,间隔越短;越冷,间隔越长。这下节奏不是我定的了,是从对话本身长出来的。想到这儿的时候有点小爽,感觉摸到了对的那条路。(•̀ω•́)


一个我挺得意的小决定:衰减不主动发生

热度要凉,最笨的办法是再起个定时器周期性往下调。但那样我就又养了一个后台循环,还得操心并发——等于为了解决一个循环,引入了另一个循环。

后来想通了:衰减干嘛要主动算?读的时候现算不就行了。

只存两个数:上次的热度 heat、它对应的时刻 heatAt。平时它躺着不动,谁来问,才按经过的时间算一次指数衰减:

const HALF_LIFE_MS = 25 * 60 * 1000; // 25 分钟没人说话,热度减半

function effectiveHeat(s = {}, now = Date.now()) {
    const heat = Number(s.heat || 0), heatAt = Number(s.heatAt || 0);
    if (heat <= 0 || heatAt <= 0) return 0;
    return heat * Math.exp(-(now - heatAt) * Math.LN2 / HALF_LIFE_MS);
}

没有定时器、没有后台任务、没有并发。状态是惰性的,你不问它就不算。这一刀是整篇里我自己最满意的。

顺带说个我后来才意识到的事:这种"惰性求值 + 时间戳"的套路,其实在限流器、缓存过期里到处都是。我是撞上的,不是设计出来的——回头看才发现踩在了前人的路上。


这条语义我改了三遍才顺:用户消息只能"拉近",不能"推远"

function bumpHeat(s = {}, now = Date.now()) {
    const heat = Math.min(1, effectiveHeat(s, now) + 0.3); // 加热 0.3
    s.heat = heat; s.heatAt = now;
    const candidate = now + intervalMs(heat);
    const current = Number(s.nextSpeakAt || 0);
    // 只取更近的那个
    s.nextSpeakAt = current > now ? Math.min(current, candidate) : candidate;
    return s;
}

Math.min(current, candidate) 这一行,我前两版都没写对。

第一版我直接覆盖 nextSpeakAt,结果出现一个反人类的现象:你刚说完话,AI 反而要等更久才回——因为某次计算出的新间隔比原来的远。后来才钉死这条:你的活跃只能让它来得更快,绝不能因为一次计算把它往后推。 少这一行,体验就拧巴。

间隔公式我用了 (1-heat)² 这条平方曲线:

const base = Math.max(4*60_000, 75*60_000 * (1 - heat) ** 2);
return Math.round(base * (0.8 + Math.random() * 0.4)); // 抖动 0.8~1.2

为什么是平方?……老实说,因为我试了线性觉得"刚冷下来就拉太远"不像话,平方让它"刚凉时还惦记着、彻底凉了才迅速拉远",体感对了。但这纯是凑出来的,不是推出来的。 立方会不会更好?我没试。

那个 0.8~1.2 的随机抖动倒是想清楚了的:真人不会掐着秒表说话,节奏一旦可预测就露馅。


还有一条:AI 自己说话,不许加热

只有开口才加热,AI 自己说一句不算。

这条是被坑出来的——早期版本里它自己说一句把自己点热了,于是越说越起劲,停不下来,活像个话痨。把"加热权"只交给用户,是防它自嗨的根本约束。


一个白捡的好处:它会自愈

因为 nextSpeakAt 只在"成功说完一句"之后才往前推,万一某轮开口被打断、话没发出去,时间戳就不动,下一个检查点自动重试。我没写任何重试逻辑——状态机的定义本身就保证了这点。这种"不用额外兜底、结构自带容错"的感觉,是我做这块时最舒服的一刻。


我到现在都不确定的几件事

写出来,也是想看看有没有人能点我一下:

  • 半衰期 25 分钟、加热量 0.3——这俩数是我盯着自己的聊天习惯拍的。换个人、换个场景,多半得重调。有没有办法让它自适应?我没想好。
  • 平方曲线是凑的,不是有依据的。
  • **三档热度(hot/warm/cold)**切话题贴合度,会不会太粗?该不该连续化?
  • 最大的疑问:这套东西真的只适合"陪聊"吗? 我越想越觉得不是——

"一个主动型 agent,在没有明确指令时,该在什么时刻介入?"

监控告警的智能降噪、任务跟进的疏密、CI 失败后的择时通知……这些好像都是同一个问题换了层皮。把 heat 当"活跃度"、nextSpeakAt 当"下次该不该触发",这 80 行说不定是个能搬走的择时层。

但这只是我的猜,还没真在别的场景里验过。先撂这儿,万一哪天用上了,回来补一笔。

最后更新日期 6/23/2026, 12:32:17 PM