AI 助手主动消息落地实录:投递、心跳、状态机与防重复提问
本文为脱敏示例。文中出现的主机、路径、账号、渠道标识与配置项均为通用描述或占位符,不代表任何具体部署环境;示例代码只保留最小可运行片段。
一、目标:不是"定时播报",而是"有事才说"
把个人助手接进聊天工具之后,很快会遇到一个体验问题:它只在被叫到时才说话。
按时推送早报是一种常见解法,但那是"闹钟"——到点必发,内容模板化。真正想做的是让助手像人一样:有值得说的事就说,没有就不打扰。
这两件事在工程上是完全不同的路径:
| 定时任务 | 心跳轮次 | |
|---|---|---|
| 触发 | 时间到,必然发送 | 周期唤醒,每次由模型判断 |
| 内容 | 模板渲染 | 带完整对话上下文组织 |
| "今天没事" | 没有这个状态 | 回一个静默标记,投递前丢弃 |
| 适合 | 早报、固定提醒 | 主动关怀、异常告知、主动提问 |
这篇文章记录一次完整落地的过程,重点放在遇到的具体问题和解决方式,而不是某个框架的用法说明。
二、投递层:主动发消息为什么比回复难
2.1 问题一:回复有上下文,主动发送没有
回复消息时,平台会告诉程序"谁发来的、回给谁"。主动发送没有这个上下文,必须显式指定收件人;而不同平台的收件人标识并不通用——有的用用户维度 ID,有的用会话维度 ID。
2.2 问题二:用户维度接口对主动发送有限制
实测中最典型的一个失败是这样的:
平台返回:230101 Sending messages to users is temporarily unavailable排查思路:
- 先怀疑收件人类型,而不是网络或权限。同一账号、同一渠道,用"用户 ID"投递失败,换成同一个私聊会话的"会话 ID"后返回成功码。
- 这类限制通常是平台防骚扰策略的一部分:不允许应用随意按用户维度主动触达,但允许在已有会话里继续说话。
- 因此目标选择规则可以固定为:有会话 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": "读取行为规则文件并严格执行;没有具体理由就回复静默标记。" } }配置流程本身也有讲究:
- 先干跑:把改动写成补丁文件,先做一次
--dry-run,确认解析出的变更条数符合预期; - 再应用:应用后做一次配置校验;
- 最后重启:有些配置热加载生效,有些(尤其涉及调度器)必须重启;重启后要确认启动日志里出现了"心跳已启动"。
3.4 问题七:重启期间 CLI 连不上
重启后立刻执行命令,遇到的是:
TLS mismatch (connecting with ws:// to a wss:// gateway, or vice versa) Gateway process stopped or became unreachable原因不是配置坏了,而是重启窗口里 CLI 连不上服务端。解决方式很简单:重启后先做一次连通性检查(能看到"网关可达"和渠道状态),再继续后续操作;不要拿重启中途的失败结果当配置问题去排查。
四、行为规则:把"像人"写成可执行的文件
心跳轮次能看到完整对话,但"该不该说"是模型每次的判断。要让判断稳定,得给一份可执行的规则文件,而不是一句"请自然地提醒用户"。
五道门,顺序固定,任一道不通过就停止:
- 依据门:只能引用真实存在的数据;不编造经历、情绪、进度;推断要标注;
- 空闲门:用户最近还在说话时不插嘴;
- 频率门:每天上限;同一件事每天只说一次;
- 打扰门:静默时段,只有紧急故障才允许突破;
- 内容门:不追问、不重放旧对话、不暴露内部路径和日志。
# 主动接触纪律(模板) 只在「有依据 + 此刻有意义 + 不打扰」三条同时成立时才开口。 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 → answered6.2 为什么状态机要放在探针里
这是整个设计里最重要的一个决定:模型只需要按状态表行动,不需要"记住"自己问过什么(它本来也记不准)。状态由探针推进,即使某一轮模型没有照做,状态也不会停在原地,同一个问题不会反复出现。
6.3 提问本身的纪律
- 一次只问一个,能让用户一两句答完;不列选项清单,不像问卷;
- 开头一句说明为什么问,再问;
- 不问敏感面(凭据、住址、关系、财务细节);
- 已有答案的不重复问;
- 用户不回答不代表可以催——不重述上次的问题。
七、回答落库:别让问答停在聊天里
主动提问如果聊完就没了,谈不上"更了解用户"。回答要按类型落到不同位置:
| 回答类型 | 归档位置 |
|---|---|
| 个人事实与状态 | 个人档案的具体页面 |
| 沟通与工作偏好 | 偏好页 |
| 单次观察、待验证 | 观察记录;重复出现并经确认后再升级为偏好 |
| 问答记录 | 单独的问答汇总页 |
三条纪律:
- 标注来源与日期;不确定的标为推断,不把随口一句写成长期事实;
- 从"待确认问题"列表里移除已回答条目,避免下次当新问题再问;
- 敏感内容不进提问范围,也不因为"能问"就扩大记录范围。
八、问题排查与工程卫生小结
上面十一个问题里,有一半跟功能无关,而是远程操作和脚本层面的。集中记一下,省得重复踩:
| 问题 | 现象 | 解决方式 |
|---|---|---|
| 非交互远程 shell 找不到命令 | date: command not found | 显式指定可执行文件路径或用绝对路径调用 |
| 依赖安装目录不在 PATH | env: '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;发送窗口内入站日志为空 |
| 部署并观察中 | 已启用主动轮次,观察真实频率与体感 |
| 待长期观察 | 静默标记是否泄漏、按模型判断的那一层是否稳定、提问模式的实际效果 |
十、复盘:四个结论
- 先证伪,再启用。投递层单独验证,再打开"由模型判断"的那一层;否则出问题分不清是投递还是内容。
- 平台差异只能实测。主动发送是否被允许、用哪种标识,不同平台、不同版本都不一样,不要按文档推断。
- 人设、规则、状态三者分离。人设跨渠道共用,规则管节奏,状态管"用户多久没说话、问过什么"。
- 能验证的状态不要放在模型脑子里。状态机放进探针,模型只负责判断和表达。
结语
把助手从"定时播报"改成"主动开口",本质是把它从一条流水线改成一个有状态的会话参与者。真正的工作量不在调用模型,而在三件事:投递层的边界验证、行为规则的显式化、状态的可验证维护。
后两件事做完,主动消息才可能像个正常人;只做第一件,它只是个换了文案的闹钟。