☰
AI 助手主动消息落地实录:投递、心跳、状态机与防重复提问
2026/10/12 4:00:26 网站建设 项目流程

AI 助手主动消息落地实录:投递、心跳、状态机与防重复提问

本文为脱敏示例。文中出现的主机、路径、账号、渠道标识与配置项均为通用描述或占位符,不代表任何具体部署环境;示例代码只保留最小可运行片段。

一、目标:不是"定时播报",而是"有事才说"

把个人助手接进聊天工具之后,很快会遇到一个体验问题:它只在被叫到时才说话。

按时推送早报是一种常见解法,但那是"闹钟"——到点必发,内容模板化。真正想做的是让助手像人一样:有值得说的事就说,没有就不打扰。

这两件事在工程上是完全不同的路径:

定时任务心跳轮次
触发时间到,必然发送周期唤醒,每次由模型判断
内容模板渲染带完整对话上下文组织
"今天没事"没有这个状态回一个静默标记,投递前丢弃
适合早报、固定提醒主动关怀、异常告知、主动提问

这篇文章记录一次完整落地的过程,重点放在遇到的具体问题和解决方式,而不是某个框架的用法说明。

二、投递层:主动发消息为什么比回复难

2.1 问题一:回复有上下文,主动发送没有

回复消息时,平台会告诉程序"谁发来的、回给谁"。主动发送没有这个上下文,必须显式指定收件人;而不同平台的收件人标识并不通用——有的用用户维度 ID,有的用会话维度 ID。

2.2 问题二:用户维度接口对主动发送有限制

实测中最典型的一个失败是这样的:

平台返回:230101 Sending messages to users is temporarily unavailable

排查思路:

  1. 先怀疑收件人类型,而不是网络或权限。同一账号、同一渠道,用"用户 ID"投递失败,换成同一个私聊会话的"会话 ID"后返回成功码。
  2. 这类限制通常是平台防骚扰策略的一部分:不允许应用随意按用户维度主动触达,但允许在已有会话里继续说话。
  3. 因此目标选择规则可以固定为:有会话 ID 就用会话 ID;用户 ID 只用于鉴权、身份识别和会话路由。

这类差异往往跟客户端版本有关,文档不一定写清楚,只能靠实测确定。

2.3 问题三:命令型投递把 stdout/stderr 一起发出去了

为了先单独验证投递层(不引入模型变量),我们用"执行一条命令并把输出投递出去"的方式测试。结果用户收到的消息长这样:

stdout: probe-ok stderr: sh: 35: [: not found

两个问题:

  • 投递层会把标准输出和标准错误分开标注后一起发出,对用户来说非常工程化;
  • 命令本身有语法错误,产生了一行噪声,也被原样投递。

解决方式:

  • 验证阶段允许这种噪声,但要记得它是包装层造成的,不是投递本身有问题;
  • 正式使用改为参数数组形式调用脚本,或者让脚本只向标准输出写正文、把诊断信息写日志文件;
  • 助手生成的(走模型那一侧)消息是干净的,所以"验证投递"和"验证内容"要分开测。

2.4 问题四:怎么证明是"主动发送"而不是"回复"

很关键但容易被忽略的一点:如果只看到"用户收到了消息",无法区分它是主动发送成功,还是刚刚回复了一条入站消息。

验收要同时核对三件事:

检查项期望结果
用户端实际收到可见,内容与预期一致
任务回执运行状态 ok,投递状态 delivered
发送窗口内的入站日志为空

第三项才是"主动"的证据。命令大致如下:

# 通用形态:建一个一次性任务,稍后只投递一条固定内容 scheduler add \ --at "<now+3min>" \ --command "printf 'probe-ok'" \ --announce --channel "<channel>" --to "<conversation-id>" \ --delete-after-run
小细节:创建任务时想加"精确时间"或"输出 JSON"之类的参数,不同实现支持程度不一样,遇到不认识的参数先看帮助文本再判断,不要凭经验硬套。

三、心跳:从"没配"到"真的在跑"

3.1 问题五:以为没开,其实一直在空转

启用之前先做基线检查,结果发现一件反直觉的事:

  • 配置里没有任何心跳相关字段;
  • 但会话记录里每 30 分钟就有一条系统注入的"轮次提示"。

也就是说,心跳一直在跑,只是它的投递目标默认是"无":模型照常思考、照常回复静默标记,但结果不发给任何人。

结论:"用户收不到主动消息"不一定是故障,可能只是投递目标没打开。排查顺序应该是:先确认机制是否在运行,再确认结果是否被投递。

3.2 问题六:静默标记必须在投递前被剥离

心跳的静默约定是:没事时回复一个固定标记(例如IDLE_MARK),系统识别到就把整条消息丢掉。最小判断逻辑:

SILENT_MARK = "IDLE_MARK" def should_deliver(reply_text: str, max_ack_chars: int = 300) -> bool: """True 表示需要投递给用户。""" text = (reply_text or "").strip() if not text: return False # 只有"标记 + 少量残留"才当作没事;残留过长说明模型确实说了内容 if text.startswith(SILENT_MARK) and len(text) <= max_ack_chars: return False return True

坑在于:这段判断在真实实现里分散在很多环节——回复归一化、运行时收尾、轮次结束、会话记录过滤、任务策略……任何一处漏掉,静默标记都会作为一条消息发到用户手机上。这类机制最常见的线上事故就是这么来的。

两个对策:

  • 阈值要显式配置(残留超过 N 个字符就不视为"没事"),不要依赖默认值;
  • 上线顺序必须是先验证"没事时真的不发",再验证"有事时能发"。反过来的话,异常现象会混在一起,很难定位。

3.3 配置怎么写才可靠

心跳的配置项不多,但每个都影响行为。实际使用的一组(脱敏后):

{ "heartbeat": { "every": "30m", // 轮次周期 "target": "last", // 投给最近活跃的渠道;默认通常是"不投递" "activeHours": { "start": "08:00", "end": "23:30", "timezone": "Asia/Shanghai" }, "ackMaxChars": 300, // 静默标记的残留阈值 "lightContext": false, // 需要读状态文件,不能只带最小上下文 "isolatedSession": false, // 要保留对话上下文才"像人" "skipWhenBusy": true, // 有任务在跑时让路 "timeoutSeconds": 120, "prompt": "读取行为规则文件并严格执行;没有具体理由就回复静默标记。" } }

配置流程本身也有讲究:

  1. 先干跑:把改动写成补丁文件,先做一次--dry-run,确认解析出的变更条数符合预期;
  2. 再应用:应用后做一次配置校验;
  3. 最后重启:有些配置热加载生效,有些(尤其涉及调度器)必须重启;重启后要确认启动日志里出现了"心跳已启动"。

3.4 问题七:重启期间 CLI 连不上

重启后立刻执行命令,遇到的是:

TLS mismatch (connecting with ws:// to a wss:// gateway, or vice versa) Gateway process stopped or became unreachable

原因不是配置坏了,而是重启窗口里 CLI 连不上服务端。解决方式很简单:重启后先做一次连通性检查(能看到"网关可达"和渠道状态),再继续后续操作;不要拿重启中途的失败结果当配置问题去排查。

四、行为规则:把"像人"写成可执行的文件

心跳轮次能看到完整对话,但"该不该说"是模型每次的判断。要让判断稳定,得给一份可执行的规则文件,而不是一句"请自然地提醒用户"。

五道门,顺序固定,任一道不通过就停止:

  1. 依据门:只能引用真实存在的数据;不编造经历、情绪、进度;推断要标注;
  2. 空闲门:用户最近还在说话时不插嘴;
  3. 频率门:每天上限;同一件事每天只说一次;
  4. 打扰门:静默时段,只有紧急故障才允许突破;
  5. 内容门:不追问、不重放旧对话、不暴露内部路径和日志。
# 主动接触纪律(模板) 只在「有依据 + 此刻有意义 + 不打扰」三条同时成立时才开口。 1. 依据门:只能引用真实存在的数据;推断必须标注。 2. 空闲门:距用户最近一次发言小于 2 小时 → 静默。 3. 频率门:每天最多 N 条;同一件事每天最多一次。 4. 打扰门:23:30—08:00 静默(紧急故障除外,且一句话说完)。 5. 内容门:不追问用户为什么不回;不出现路径、日志、行号、凭据。 按优先级挑第一件成立的:系统异常 > 到期任务 > 当天情况变化 > 有依据的建议 > 其余不发。 措辞:一句话为主,最多两段;不用标题、序号,不用「提醒」「播报」这类框架词。 没有可说的,只回静默标记。

还有一条容易忽略的经验:人设要单独成文件,并在所有渠道共用。否则同一个助手在网页里克制专业、在聊天工具里像换了个人。这种不一致几乎总是来自"每个渠道各写一份提示词",而不是模型本身的问题。

正确分层是:

层作用生效范围
人设文件语气底色、边界所有渠道共用
行为规则文件该不该开口、说什么只在主动轮次注入
轮次提示词这一轮怎么做每次主动轮次逐字注入

五、状态文件与探针:为什么"多久没说话"必须自己记

5.1 问题八:机制里没有"距上次用户发言多久"

心跳轮次有完整上下文,但通常没有现成的空闲时长字段。没有它,"空闲门"只能靠模型猜,稳定性很差。

解法:在对话之外维护一个小状态文件,由独立探针定期更新,轮次开始前先读:

{ "lastUserMessageAt": "2026-01-01T10:00:00+08:00", "lastUserMessageAgeMinutes": 134, "proactiveCountToday": 0, "pendingQuestion": null, "unansweredQuestions": [] }

5.2 问题九:把会话文件修改时间当"用户活跃"是错的

第一版探针图省事,用会话文件的 mtime当作"用户刚说过话"的信号。结果完全失效:心跳轮次本身也在往同一个会话里写,每次轮次都刷新 mtime,于是系统永远认为"用户刚刚活跃"。

改成读取会话记录里的真实用户消息才好使,但紧接着遇到下一个坑。

5.3 问题十:系统注入的轮次提示会被当成用户消息

心跳是往会话里注入一条消息来驱动的,这条消息的角色也是 user。如果不过滤,探针会把"每 30 分钟的轮次提示"当成"用户又说了一句",空闲判断照样失真。

过滤条件要写清楚:

SKIP_MARKS = ("heartbeat poll", "[Tool") def latest_real_user_message(lines): """从后往前找最近一条真实用户发言的时间戳。""" for line in reversed(lines): msg = line.get("message") if not isinstance(msg, dict) or msg.get("role") != "user": continue text = (msg.get("content") or "").strip() if not text: continue # 1) 系统注入的轮次提示带固定标记 if any(m in text for m in SKIP_MARKS): continue # 2) 工具回执也是 user 角色 # 3) 超长内容基本是系统提示词,不是用户打的字 if len(text) > 1500: continue return msg.get("timestamp") return None

配套三条经验:

  • 探针不调用模型,只读写文件和查任务回执,成本几乎为零;
  • 探针必须能独立、反复运行,这样状态迁移可以写用例断言,而不是靠观察;
  • 状态文件建议只有一个写入者(探针),不要让模型也直接改,否则并发写会损坏状态。

六、主动提问:怎样不变成重复打扰

如果主动消息只有"报告事项",大部分时间其实无话可说。自然的扩展是:没事时向用户提一个问题,慢慢了解他的偏好。

6.1 问题十一:用户不回答怎么办

最差的做法是每轮重新问一遍,或者把问题永远挂着等回答。都会变成骚扰。

把提问也做成状态机:

距首次提问动作
< 24 小时静默,不催
24–48 小时换一种问法再问一次(换切入点、给个具体例子)
> 48 小时,或换角度后仍无回应标记为无回应,永久放弃这个方向,改问新方向

两条硬约束:

  • 同一问题方向最多出现两次;
  • 维护"已放弃方向"列表作为去重依据,新问题先比对,命中相似方向直接跳过。

迁移逻辑很小,可以直接用隔离用例断言:

def next_state(age_hours, attempts, answered): if answered: return "answered" if age_hours > 48 or (attempts >= 2 and age_hours > 24): return "unanswered" # 放弃该方向,换新的 if age_hours > 24: return "stale" # 该换一种问法了 return "open" # 静默等待
age=3h attempts=1 → open age=30h attempts=1 → stale age=60h attempts=1 → unanswered age=30h attempts=2 → unanswered age=10h attempts=2 → open answered=True → answered

6.2 为什么状态机要放在探针里

这是整个设计里最重要的一个决定:模型只需要按状态表行动,不需要"记住"自己问过什么(它本来也记不准)。状态由探针推进,即使某一轮模型没有照做,状态也不会停在原地,同一个问题不会反复出现。

6.3 提问本身的纪律

  • 一次只问一个,能让用户一两句答完;不列选项清单,不像问卷;
  • 开头一句说明为什么问,再问;
  • 不问敏感面(凭据、住址、关系、财务细节);
  • 已有答案的不重复问;
  • 用户不回答不代表可以催——不重述上次的问题。

七、回答落库:别让问答停在聊天里

主动提问如果聊完就没了,谈不上"更了解用户"。回答要按类型落到不同位置:

回答类型归档位置
个人事实与状态个人档案的具体页面
沟通与工作偏好偏好页
单次观察、待验证观察记录;重复出现并经确认后再升级为偏好
问答记录单独的问答汇总页

三条纪律:

  • 标注来源与日期;不确定的标为推断,不把随口一句写成长期事实;
  • 从"待确认问题"列表里移除已回答条目,避免下次当新问题再问;
  • 敏感内容不进提问范围,也不因为"能问"就扩大记录范围。

八、问题排查与工程卫生小结

上面十一个问题里,有一半跟功能无关,而是远程操作和脚本层面的。集中记一下,省得重复踩:

问题现象解决方式
非交互远程 shell 找不到命令date: command not found显式指定可执行文件路径或用绝对路径调用
依赖安装目录不在 PATHenv: 'node': No such file or directory在命令里前置补充 PATH,不要覆盖原有的
管道脚本出现乱码报错$'\r': command not found把标准输入里的回车符统一去掉再执行
层间引号被吞导致参数错位Too many arguments/ 参数变乱改为把脚本写入临时文件后执行,而不是层层拼接字符串
一次性任务的开关参数不被识别unknown option '--exact'先读帮助文本确认适用范围,再决定参数
输出重定向失败tail: option used in invalid context改用兼容写法,或先落盘再读取
长等待命令被前台超时截断命令被判定超时放后台执行并显式等待,避免前台超时

还有两条"顺序"经验:

  • 先建基线:改动前记录配置文件哈希、任务列表快照、当前时间戳,出问题能回退;
  • 可逆优先:所有测试任务都带"运行后自删",配置改动保留备份文件,重启类操作先确认回滚方式。

九、完成层级:怎么描述才不算夸大

落地状态建议按层级表述,而不是一句"做完了":

层级本次状态
文件已修改人设、行为规则、探针、状态文件均已更新并同步
测试已通过投递实测通过;状态机 6 个分支用例全部通过
运行中已验证任务回执为 ok / delivered;发送窗口内入站日志为空
部署并观察中已启用主动轮次,观察真实频率与体感
待长期观察静默标记是否泄漏、按模型判断的那一层是否稳定、提问模式的实际效果

十、复盘:四个结论

  1. 先证伪,再启用。投递层单独验证,再打开"由模型判断"的那一层;否则出问题分不清是投递还是内容。
  2. 平台差异只能实测。主动发送是否被允许、用哪种标识,不同平台、不同版本都不一样,不要按文档推断。
  3. 人设、规则、状态三者分离。人设跨渠道共用,规则管节奏,状态管"用户多久没说话、问过什么"。
  4. 能验证的状态不要放在模型脑子里。状态机放进探针,模型只负责判断和表达。

结语

把助手从"定时播报"改成"主动开口",本质是把它从一条流水线改成一个有状态的会话参与者。真正的工作量不在调用模型,而在三件事:投递层的边界验证、行为规则的显式化、状态的可验证维护。

后两件事做完,主动消息才可能像个正常人;只做第一件,它只是个换了文案的闹钟。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询