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 都对应一个目标飞书群,配置里写得清清楚楚。但实际运行时,连续观察一周,发现两个让人不太舒适的现象:
-
群发错位:cron job 不按设定发往配置中的飞书群聊,而是发到「上一次发送成功的会话」。 -
冗余消息:每次执行都会冒出一些”测试/调试”性质的消息,造成信息冗余刷屏。
明明 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.to跟intended.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/*.jsonl 里 delivery.intended vs delivery.resolved vs fallbackUsed 三个字段的组合。光看「消息发错群」很容易误以为是 cron 配置失效,实际 cron 完全正常,是 agent 多发了一份。
七、最后
修完 7 个 job 之后,第一次 cron 触发的现象很干脆:
-
delivery.fallbackUsed不再为 true -
agent 工具调用次数下降到只剩任务必需的(如 tavily_search) -
飞书群只收到一份内容,不再有调试冗余
这件事的根源不是 OpenClaw 的 bug,也不是 LLM 的”幻觉”,就是提示词里多写了一句不该写的话。
给 agent 写提示词时,应该假定它会完全按字面执行你写的每一个动词。能交给系统做的事,就别让 agent 做。
夜雨聆风