乐于分享
好东西不私藏

OpenClaw Cron 排错实录:当 Agent 被赋予「自由」之后

OpenClaw Cron 排错实录:当 Agent 被赋予「自由」之后

OpenClaw Cron 排错实录:当 Agent 被赋予「自由」之后

一句话推荐:一个被忽视的「双出口」让 7 个 cron job 集体发错群、刷屏冗余消息,真正的罪魁祸首只是提示词里一句”推送到飞书”。

适合阅读:在搭 LLM agent 管道、cron + agent 组合、飞书/Slack 机器人投递、或被 agent 自作主张折磨过的工程师。


📌 TL;DR

  • 现象:cron job 本该发群 A,实际发到群 B;每次还夹带一堆调试碎片。
  • 直觉误区:以为是 cron 配置失效、会话上下文串了。
  • 真相:cron 引擎工作正常。出问题的是 agent 自己额外调了 sessions_send 工具多发一份——因为提示词里写了”推送到飞书群聊”。
  • 修复:把 7 个 job 提示词里所有”推送/发送”类动词,改为”只返回正文,不要调用任何发送工具“。
  • 沉淀教训:cron 有两条出口(引擎 delivery 和 agent 工具调用),写提示词时必须假定 agent 会按字面执行每个动词。

一、背景

我在 Mac mini M4 上跑着一套 OpenClaw 网关,挂了 12 个 cron job:日常 AI/财经新闻简报、港股交易日情报、港股异动监控、Skills 扫描、系统巡检……每个 job 都是一个 agent 在指定时间被唤醒,跑一段提示词,最后把产出投递到飞书群。

理论上,每个 job 都对应一个目标飞书群,配置里写得清清楚楚。但实际运行时,连续观察一周,发现两个让人不太舒适的现象:

  1. 群发错位:cron job 不按设定发往配置中的飞书群聊,而是发到「上一次发送成功的会话」。
  2. 冗余消息:每次执行都会冒出一些”测试/调试”性质的消息,造成信息冗余刷屏。

明明 delivery.to 写得明明白白,为什么消息会跑偏?为什么会平白冒出调试消息?这篇是排错记录。


二、配置长什么样

每个 cron job 都是一段 JSON,存在 ~/.openclaw/cron/jobs.json 里。典型结构:

{
  "id": "a6734bb8-60e2-4e01-a2a1-2ee9bcb0e438",
  "agentId": "news",
  "name": "AI新闻简报(每日)",
  "enabled": true,
  "schedule": { "kind": "cron", "expr": "0 9 * * *", "tz": "Asia/Shanghai" },
  "sessionTarget": "isolated",
  "sessionKey": "agent:main:feishu:direct:oc_xxxxxxxxxxxxxxxxxxxxxxxx",
  "payload": {
    "kind": "agentTurn",
    "message": "搜索今日AI新闻要闻,生成完成后通过feishu频道推送群聊。"
  },
  "delivery": {
    "mode": "announce",
    "channel": "feishu",
    "to": "chat:oc_yyyyyyyyyyyyyyyyyyyy",
    "bestEffort": false
  }
}

关键字段:

  • payload.message:cron 唤醒 agent 时丢给它的”用户输入”。
  • delivery:cron 自己拥有的「公告投递通道」。mode=announce 时,cron 会把 agent 最后回复的 summary 自动发到 delivery.to 指定的群。
  • sessionKey:agent 跑完后挂在哪个会话上下文(这里是某个用户私聊 DM)。
  • sessionTarget=isolated:每次 run 用一次性会话,但 sessionKey 仍然指向上面那条 DM 用作「来源会话」。

注意——这里有两条独立的”出口”:

  • 出口 A:cron 自己的 delivery.announce(系统级,可控)
  • 出口 B:agent 在执行过程中可以调 sessions_send / feishu_send 工具自己发消息(agent 自由意志,不可控)

三、定位过程

步骤 1:先看运行日志

~/.openclaw/cron/runs/<job-id>.jsonl 里记录每一次 run 的完整详情。挑一条最近的「港股交易日情报」run 看 delivery 字段:

{
  "delivered": true,
  "deliveryStatus": "delivered",
  "delivery": {
    "intended":  { "channel": "feishu", "to": "chat:oc_yyyyyyyyyyyyyyyyyyyy", "source": "explicit" },
    "resolved":  { "ok": true,  "channel": "feishu", "to": "chat:oc_yyyyyyyyyyyyyyyyyyyy", "source": "explicit" },
    "fallbackUsed": true,
    "delivered": true
  }
}

意外发现:

  • intended.source = "explicit" —— cron 配置是显式指定群的,没问题。
  • resolved.tointended.to 完全一致 —— cron 引擎确实把 summary 发到了正确的群。
  • fallbackUsed = true —— 说明有别的东西也走了 fallback 链路

这说明:cron 引擎本身是无辜的。delivery.to 没被忽略,summary 也确实发对了。问题在别处。

步骤 2:grep agent 的工具调用记录

cron 落盘的 runs/*.jsonl 里能看到 agent 调过哪些工具:

grep -lE "sessions_send|feishu_send" /Users/user/.openclaw/cron/runs/*.jsonl

果然在 news agent 的 jsonl 里捞到了 sessions_send 调用,并且 final summary 内容是这样的:

[TOOL_CALL] {tool called, but error occurred with the call. This is a platform issue … it sent correctly but there’s an internal issue. Let me use the tool again with the Feishu session… Let me call sessions_list to find the Feishu channel session key…

——agent 在执行过程中自己尝试调用 sessions_send 工具往飞书发消息,工具报了 cloning error,agent 一边重试一边在回复里复述调试过程。这段内心戏被 cron 当成”final summary”,原封不动地通过 delivery.announce 发了出去。

这就是「冗余测试消息」的来源。

步骤 3:扫一遍所有 job 的 payload.message

python3 -c "
import json
j = json.load(open('/Users/user/.openclaw/cron/jobs.json'))
for job in j['jobs']:
    msg = job.get('payload',{}).get('message','') or ''
    if any(w in msg for w in ['发送','推送','发到','发布','feishu']):
        print(f\"[risk] {job['name']}\")
"

输出:7 个 job 的提示词里都明确写了「推送到飞书群聊」「通过 feishu 频道推送」「推送给老板」之类的指令。

到这里因果链清晰了:

人写提示词 → "生成报告并推送到飞书群聊"
       ↓
agent 看完提示词 → "好的,我去调 sessions_send 把报告推过去"
       ↓
sessions_send 走的是 agent 当前 sessionKey
       ↓
sessionKey = "agent:main:feishu:direct:ou_fee3b0dd…"  ← 是个用户私聊 DM!
       ↓
报告被发到 DM(不是配置里的目标群)
       ↓
与此同时 cron 自己的 delivery.announce 又发了一份正确的 summary 到目标群
       ↓
现象 1:DM 里冒出了不该有的报告  →  "发到上一次成功的群聊"
现象 2:sessions_send 报错时 agent 在回复里写了一堆调试 → 冗余消息

「上一次成功的群聊」是错觉,真相是:agent 用错了 sessionKey 自发了一份报告,恰好落在最近一次有过对话的 DM 上


四、根因总结

OpenClaw cron 同时存在两条出口:

出口 谁负责 走哪个 chat 是否可靠
A: delivery.announce cron 引擎自己 delivery.to(配置里写明) ✅ 显式可控
B: agent 调 sessions_send agent 自由意志 agent 当前 sessionKey ❌ 跟提示词内容相关

只要在 agent 的提示词里出现「推送到飞书」「发送到群」这种动词,agent 就会额外走出口 B 多发一份。两份消息内容不同(出口 A 是 cron 抽的 summary,出口 B 是 agent 工具调用过程的 raw 输出),还会去到不同的 chat。

这就是所有问题的源头:cron job 的提示词里写了 agent 不该做的事

agent 不需要”知道”它产出的东西要发到哪里。投递这件事是 cron 引擎的职责,不是 agent 的职责。


五、修复方案

统一把 7 个 job 的 payload.message 里所有”推送/发送/推送给老板”句子,换成:

只在最终回复里返回正文,不要调用 sessions_send / feishu_send 等任何发送工具,cron 会自动投递到目标群。

举两个例子:

Before(AI新闻简报)

搜索今日AI新闻要闻,生成完成后通过feishu频道推送群聊。

After

搜索今日AI新闻要闻,整理成简报。

**只在最终回复里返回简报正文,不要调用 sessions_send / feishu_send
等任何发送工具,cron 会自动投递到目标群。**

Before(系统巡检结尾段)

5. 生成报告并发送到飞书群聊:
 - 格式:Markdown 报告,包含风险等级(高/中/低)和建议

After

5. 输出报告:
 - 格式:Markdown 报告,包含风险等级(高/中/低)和建议
 - **只在最终回复里返回报告正文,不要调用 sessions_send /
   feishu_send / 任何发送工具,cron 会自动投递到目标群。**

落地脚本(核心思路):

import json, pathlib
p = pathlib.Path('/Users/user/.openclaw/cron/jobs.json')
j = json.loads(p.read_text())

new_msgs = { ... }  # 7 个 job 的新提示词

for job in j['jobs']:
    if job.get('name'in new_msgs and job.get('payload', {}).get('kind') == 'agentTurn':
        job['payload']['message'] = new_msgs[job['name']]

p.write_text(json.dumps(j, ensure_ascii=False, indent=2))

写完用 openclaw cron show <id> --json 确认 scheduler 已读到新提示词,不需要重启 gateway。

修改前先做时间戳备份:

cp ~/.openclaw/cron/jobs.json \
   ~/.openclaw/cron/jobs.json.bak.before-prompt-cleanup-$(date +%Y%m%d-%H%M%S)

六、几个值得记住的教训

1. 「双出口」是 LLM agent 系统的常见陷阱

凡是 agent 既能产出结果、又能调发送工具,就会出现两条出口同时工作的可能。要么禁掉一条,要么明确分工,不能让两条出口同时存在并都”工作”

2. 提示词里的动词等于权限授予

写 “推送到飞书” 不是抒情,是给 agent 一份真实可执行的指令。agent 只要有 sessions_send 工具的访问权(在 tools.allow 里),它就会去调。

想要 agent 不做某事,最稳的方式不是寄希望于它”自觉”,而是显式禁止收回工具权限

3. agent 的 sessionKey 不一定是你以为的那个

cron 创建会话时复用了 main agent 的 DM sessionKey。sessions_send 默认走当前 sessionKey,所以 agent 自发的消息会落到那个 DM 而不是配置里的目标群。configuration ≠ runtime context

4. 工具失败时,agent 的 raw 思考会被当成 summary

sessions_send 报 cloning error,agent 会在回复正文里写”让我重试一下、检查一下 sessionKey、再调一次……”这一整段。cron 不会过滤这种内心戏,会原封不动当成 summary 发到目标群。冗余的”测试消息”就是这么来的。

5. 真相在日志里,但要找对地方看

排这种问题的关键是 runs/*.jsonldelivery.intended vs delivery.resolved vs fallbackUsed 三个字段的组合。光看「消息发错群」很容易误以为是 cron 配置失效,实际 cron 完全正常,是 agent 多发了一份。


七、最后

修完 7 个 job 之后,第一次 cron 触发的现象很干脆:

  • delivery.fallbackUsed 不再为 true
  • agent 工具调用次数下降到只剩任务必需的(如 tavily_search)
  • 飞书群只收到一份内容,不再有调试冗余

这件事的根源不是 OpenClaw 的 bug,也不是 LLM 的”幻觉”,就是提示词里多写了一句不该写的话

给 agent 写提示词时,应该假定它会完全按字面执行你写的每一个动词。能交给系统做的事,就别让 agent 做。