欢迎光临
我们一直在努力

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 "<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 → answered

    6.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;发送窗口内入站日志为空
    部署并观察中 已启用主动轮次,观察真实频率与体感
    待长期观察 静默标记是否泄漏、按模型判断的那一层是否稳定、提问模式的实际效果

    十、复盘:四个结论

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

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

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

    赞(0)
    未经允许不得转载:171主机测评 » AI 助手主动消息落地实录:投递、心跳、状态机与防重复提问
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址