三条推送线集体静默:一次 cron 简报系统的排障复盘
我家有一套自动情报系统:每天早上,Mac 上一个叫 Hermes 的 agent 守护进程从 arXiv、GitHub releases、几个技术博客抓新东西,整理成两三份中文简报,八点多推到我的 QQ。cron 调度、脚本抓取、LLM 翻译、QQ 投递,整条链路没有一个人参与。
它跑了三天,一切正常。第四天早上我打开 QQ,只有九点那份记忆简报,八点二十的生态晨报和八点四十五的情报简报都没来。
这篇复盘从「为什么没来」讲起。排查一共挖出五个 bug,每个单看都不难,但它们共享一个特征:全部对监控系统隐身。后台跑的东西最怕的不是出错,是坏了你不知道。
先看监控:所有信号都说一切正常
排查第一步永远是看监控。cron 的执行状态存在 ~/.hermes/cron/jobs.json,我当时看到的是这样:
1 | == AI生态晨报 |
两个 job 都在计划时间的 1 分钟内执行了,状态 ok,投递无错误。监控要是会说话,它说的是「我准时跑了,没有任何异常」。
但推送没到。监控说好、现实说坏,这种分歧只有一个解释:脚本确实跑了,但它认为没必要发任何东西。
翻开 cron 的运行记录归档,真相开始露头。每次运行都会落一个 Markdown 记录,当天那份是这样:
1 | # Cron Job: AI生态晨报 |
silent (empty output),脚本跑完了,输出为空,调度器把空输出解释成「今天没新闻,静默是设计行为」。
这里就是第一个陷阱:「没消息」和「消息系统坏了」在监控指标上是同一个值。两个 job 连续两天 silent,而 claude-code、codex 这些项目几乎天天发版。要么这两天全球 AI 圈集体休息,要么抓取环节出了问题。显然是后者。
第一个 bug:抓取失败伪装成无新闻
顺着抓取链路手动测。晨报脚本用 curl 拉 GitHub 的 releases.atom,正常:
1 | $ curl -s https://github.com/openai/codex/releases.atom | grep '<updated>' |
再测情报简报的 arXiv API,返回空。多试几次、换参数,全是空响应。当时 arXiv 的 export API 正故障。
回头看脚本的处理逻辑,问题就清楚了。抓取函数长这样:
1 | fetch() { |
curl 失败、返回空 body、返回 HTML 错误页,三种情况全都会让 XML 解析抛异常,然后走到 print("[]")。对下游来说,「API 故障」和「查询没有结果」是同一个输出:空数组。空数组一路传下去,渲染段发现没有新条目,输出空字符串,于是 silent。
修复分两层。fetch 函数先区分网络层失败:
1 | fetch() { |
渲染段拿到标记后,区分两种静默。真没新闻就保持静默(不能天天发空简报);两组查询都失败,推一条告警:
1 | ⚠️ 情报简报抓取异常 09-14:arXiv 两组查询均失败(非无新论文,明日重试) |
修完当天 arXiv 还在故障,告警路径直接实测通过。第二天恢复后正常推论文,一条故障让「失败可见」的设计立刻兑现了一次价值。
原则说穿了很朴素:任何「空输出 = 静默」的采集脚本,必须把「采集失败」和「没有新数据」拆成两个可区分的状态。静默策略越优雅,故障藏得越深。这两天的 silent 记录里,一天是真没新闻,一天是 API 挂了,站在监控视角它们一模一样,直到有人因为没收到推送来翻记录。
第二个 bug:三行代码的去重缺陷,让每次推送都可能重复
排障排出来的问题往往不止一个。查 seen 文件(已推送条目的去重记录)时我注意到一个细节:
1 | $ wc -l ~/.hermes/cache/ecosystem_release_seen.txt |
去重文件里只剩 1 条记录。这个文件记录着「已经推送过哪些条目」,跑了几天、推了十几个版本,怎么只剩一条?
看写回这段的 Python 代码,三行:
1 | def dedupe_write(path, new_ids, existing): |
set(existing) 把全部旧条目预填进 seen 集合,然后循环遍历 new_ids + existing。每个旧条目在循环里都会命中「已在 seen 里」,被跳过,最终写回磁盘的 out 列表里只剩本次的新条目。换句话说,每跑一次,历史去重记录就被清空一次。等今天的新条目也过期,同一条 GitHub release 下次抓到又会当新条目,重复推送。
这个 bug 最气人的地方在于:它的直接症状不是「坏了」,而是没有症状。推送照常、内容正确、没有任何报错,只是去重名存实亡。如果不是这次排障翻到 seen 文件,它大概会一直潜伏到某天重复推送被抓包。
修法反而简单,seen 集合从空集开始:
1 | seen, out = set(), [] # 旧 bug:set(existing) 预填,旧条目全部被跳过 |
验证也直观:跑两次脚本,第一次推 2 条新博客并写进 seen;第二次跑,seen 里的 5 条(3 旧 + 2 新)原样保留,输出为空,去重生效,静默正常。
第三个 bug:一次告警的超时,抹掉全部功劳
前两个是简报线的。排查时顺手看了别的 job,发现周维护任务当天也挂了,但挂法很特别:周报正常落库、19.7MB 的数据导出也完成了,job 状态却是 error。
原因在脚本最后一步:触发一个 LLM 反思会话。这个调用走 HTTP POST,但服务端是 SSE 长流,agent 要跑好几分钟反思才会结束。脚本里的 curl 只给了 20 秒超时:
1 | curl -s --max-time 20 -X POST ".../sessions/$SID/prompt" ... |
20 秒后 curl 超时退出,非零退出码让整个 cron job 被判失败。前面五步全部成功,最后一步的等待姿势不对,把整单功劳抹成 error。
修法是把这个调用改成「发出去就走」,不等结果:
1 | ( curl -s --max-time 1800 -X POST ".../sessions/$SID/prompt" \ |
子 shell 里后台挂一个长超时的 curl,主脚本立刻继续。服务端会话的执行不依赖客户端连接是否还在,这也是 SSE 流式接口的正确用法:客户端断开不等于任务取消,异步触发后大可撒手。
改完重跑整个脚本,exit 0,反思会话正常在后台产出了一条改进建议。这一步顺带验证了一件更重要的事:整条「cron 触发 → agent 会话 → 全工具执行 → 结论落库」的无人值守反思管线,端到端是通的。
第四个 bug:智能体把一次失败说成「大量反复失败」
反思会话跑通后,我去看了它的产出。报告里有句话:「feed 显示近两天大量后台任务反复失败,需进一步核查」。
这话要是不验证就采纳,方向就带偏了。实际查下来:feed 近两天无显著失败事件,唯一一条失败记录是 Mem 内部的 insight_detection,一次,两小时前。把「一次偶发」表述成「大量反复失败」,这不是数据错误,是归纳环节的夸大,agent 在总结时倾向把孤例放大成模式。
处理方式不是骂它一顿,而是给它的指令加约束。反思 prompt 里现在有一条:「只写新的反思结论,不要复述历史决策」,后续还要加一条:涉及失败/异常的结论必须附实测计数。「失败 1 次」和「失败 15 次」是两种完全不同的问题,表述里必须能区分。
这算比较前瞻的一条教训:自动化链路里的 LLM 环节,不光会错,还会把对的东西说错。它给你的是它的归纳,不是数据本身。凡是基于 agent 报告要做决策的地方,关键数字要亲手数一遍。
第五个 bug:我自己的大脑缓存
复盘到这还剩一个问题:为什么 arXiv 故障这件事,我前一天晚上就撞见过一次?
翻工作日志,确实撞见过。前一天晚上我在给简报加中文翻译通道,顺手测过 KIMI_API_KEY,拿到 401 就下了「key 失效」的结论。今天复测,key 好好的,错的是我拿这个 key 调了错误的 API 端点。key 属于 Kimi 的 coding 网关,我调的是 Moonshot 开放平台的接口,两个端点用同一套 key 格式,401 是必然。
这个 bug 和代码无关,是排障者自己的:遇到反常结果,先检查自己的操作姿势,再怀疑系统。端点、参数、环境变量、隐藏字符,这类「手滑」造成的假故障在排障里的占比比想象中高。我后来在两个简报脚本的 .env 读取里都加了 strip 处理,因为真实文件里那个 key 确实带着引号和空格,直接 source 会把脏字符带进 header。
五个 bug 齐了。前四个在代码里,第五个在键盘前。
从这次排障里沉淀的四条设计原则
故事讲完了。这五个 bug 没有一个是「难」的,难的是它们全部通过了「系统已运行三天一切正常」的考验。把教训压缩一下,是四条。
原则一:静默必须可区分
采集类系统的「空输出=今天没新闻」是优雅设计,但前提是**「没数据」和「取不到数据」必须走不同的出口**。判断标准很简单:故障发生时,用户应该收到一条「我坏了」的推送,而不是什么都没有。
1 | 真没新闻 → 静默(设计行为) |
原则二:监控要看「做了什么」,不是「跑没跑」
status: ok 只说明脚本退出码是 0,不说明它干了活。这次真正的告警信号藏在运行归档的 silent (empty output) 里,连续 silent 加上「上游明明在发版」的常识冲突,才是该响的警报。
以后我判断采集系统健康,会看三个值的组合:运行状态、产出量、上游活跃度。产出量长期为零而上游活跃,等价于故障,不管退出码多干净。
原则三:异步触发的事情,等待姿势要对
SSE、长轮询、agent 执行,这类「请求秒回、结果慢出」的接口,客户端同步等待是最容易踩的坑:超时被杀、退出码污染整个任务、服务端其实还在好好干活。规则是触发即返回,结果交给存储去取。cron 脚本判断成败只看自己代码的退出码,别把外部长任务的耗时算在自己头上。
原则四:agent 的报告是归纳,不是数据
自动化链路里 LLM 环节的价值在于归纳和发现模式,风险也恰恰在这里:归纳会把孤例放大,把边界抹平。人机分工的边界线是 agent 产出方向和线索,关键数字和事实由人(或一段 dumb code)复核。这次「大量反复失败」如果直接采纳,排查的注意力就会从「简报静默」被带偏到「后台任务故障」上,白烧一小时。
写在最后
这套系统现在的状态:三份简报每天准点推送,抓取故障会以告警的形式出现在同一个推送通道里,去重记录不再被清空,反思 agent 的结论落地前要过数字关。
这次复盘留给我的问题比修好的 bug 更有价值:**我还需要多少条推送没到,才能发现下一次故障?**答案是零,因为故障现在会自己说话。无人值守系统成熟的标志不是不出 bug,而是出了 bug 时,它至少会喊一声。
如果你也在搭类似的东西,cron 抓取、agent 值守、消息推送,建议拿着上面四条原则过一遍自己的脚本。尤其第一条,把你的抓取失败路径翻出来看看:它和「无数据」是同一个出口吗?
相关文章:同一台 Mac 上的多个 AI 会话,怎么不打架(会话治理),以及 AI 助手写会话清理插件(同款”监控发现问题 → 修复”叙事的插件版)。