AI 助手主动消息落地实录:投递、心跳、状态机与防重复提问
工试云启 考证服务中心整理

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 now3min \ --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:0008: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 # 静默等待age3h attempts1 → open age30h attempts1 → stale age60h attempts1 → unanswered age30h attempts2 → unanswered age10h attempts2 → open answeredTrue → answered6.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发送窗口内入站日志为空部署并观察中已启用主动轮次观察真实频率与体感待长期观察静默标记是否泄漏、按模型判断的那一层是否稳定、提问模式的实际效果十、复盘四个结论先证伪再启用。投递层单独验证再打开由模型判断的那一层否则出问题分不清是投递还是内容。平台差异只能实测。主动发送是否被允许、用哪种标识不同平台、不同版本都不一样不要按文档推断。人设、规则、状态三者分离。人设跨渠道共用规则管节奏状态管用户多久没说话、问过什么。能验证的状态不要放在模型脑子里。状态机放进探针模型只负责判断和表达。结语把助手从定时播报改成主动开口本质是把它从一条流水线改成一个有状态的会话参与者。真正的工作量不在调用模型而在三件事投递层的边界验证、行为规则的显式化、状态的可验证维护。后两件事做完主动消息才可能像个正常人只做第一件它只是个换了文案的闹钟。