fetch agent 撞了 90 分钟 timeout —— 错的不是 cron,是 prompt 边界
今早 7 点 Telegram 又响:「今天为什么又没产出文章?」
昨天刚发了一篇复盘讲怎么修好 cron 静默失败那个坑。今天又一个新坑、不同根因。这一篇连着昨天那篇,凑成「让 LLM 跑生产 cron」这件事交的两次学费。
表象
打开今天 fetch 阶段的日志:
05:00:01 fetch cron 启动
05:01 trending-2026-04-26.json 写出(598KB)
05:24 knowledge-base/2026-04-26.md 写出(22KB)
↑ 核心产物 24 分钟内全部完成,STATE 也推到了 fetched
05:24~06:30 claude 主 agent 又跑了 66 分钟,干啥不知道
06:30:02 撞 timeout 5400s,被 kill,rc=124
06:30:03 pipeline 看到 fetch rc=124,停止 chain(write/finish 不再启动)
昨天加的两道防线全都按设计工作了:90 分钟 timeout 兜住、pipeline 退出码透传、Telegram 报警发出、下游不再被污染。但今天没文章。
错的猜测
第一反应是工具调用 hang——可能 fetch-trending.mjs 在跑 HN/Reddit/Twitter API 重试。或者 quota。或者 subagent 卡住。
都不对。trending JSON 在 5:01 就 598KB 落盘了,KB 在 5:24 就 22KB 落盘了。核心工作 24 分钟全部完成。后面那 66 分钟是 claude agent 在做 prompt 之外的事,不是工具卡住。
更具体地说,「prompt 之外」这个判断本身已经隐含一个假设:claude agent 应该按 prompt 字面执行。这个假设是错的。它会顺着 prompt 引用的所有文档去查、去拼装、去推断"应该还做什么"。交互式场景下我从来没在意过这件事——agent 做错一步用户立刻看到、立刻打断。Cron 里看不到、不能打断——它就一直做,做到 timeout。
真相:prompt 边界模糊
去翻 fetch.md,第一行就有问题:
按 SKILL.md 的 Turn 0 / 阶段一步骤采集当天数据
去 SKILL.md 找 “Turn 0” —— 没有这个段落。SKILL.md 在「多篇与编排」一节是这么写的:
分 3 turn:Turn1 采集→评分→写作 subagent → 结束 turn / Turn2 审核→封面→适配 → 结束 turn / Turn3 注入→分发→STATE
注意:采集和评分被绑在 Turn1。fetch agent 看到 prompt 让它"按 Turn 0 步骤",结果 SKILL.md 里没 Turn 0、有的是 Turn 1(包含采集 + 评分 + 写作)。
继续往下找,SKILL.md 把详细步骤指向 references/writing-pipeline.md。打开看:
⚠️ 必须执行的完整步骤清单(subagent 不得跳过任何步骤): 步骤 0:去重 / 步骤 0.5:爆款热文采集 / 步骤 0.7:评分幂等性 / 步骤 0.8:质量门控 / 步骤 1:选题评分 / 步骤 2:auto-spec / 步骤 3:auto-draft / …
文档自相矛盾的产物:fetch agent 看到一份"必须执行的完整步骤清单",又看到 SKILL.md 说"采集和评分是同一个 turn",它的合理推断就是:核心采集做完后接着跑评分、跑爆款分析,能做到第几步算第几步,直到撞 timeout。
它不是在"瞎做"。它是在按它能找到的最详细文档"勤勉地"做事。是我没给它划清边界。
第二个反直觉点
我以为 prompt 工程的核心是"把要做的事写清楚"。
错。LLM agent 的 prompt 边界不是由"做什么"决定的,是由"不做什么"决定的。
Bash 脚本不会有这个问题。脚本没写的命令永远不会跑、没引用的函数永远不会调用——边界由"未声明 = 不存在"自动构成。LLM 是反过来的:未声明 = 它自己拼。你不写"不要做评分",它在文档里看到「采集和评分是同 turn」就接着评分。你不写"不要 spawn subagent",它看到 SKILL.md 里"写作必须走 subagent"就 spawn。
举个对比。原 fetch.md 写的是:
执行:按 SKILL.md 的 Turn 0 步骤采集当天数据
这看起来很明确。但它给 agent 的实际 instruction 是:「去 SKILL.md 找 Turn 0」。SKILL.md 找不到 Turn 0、找到一份"完整管线步骤清单"——agent 没法知道该停在哪一步。
新版改成:
这一阶段只做三件事,做完立即结束:
- 跑 fetch-trending.mjs,产出 trending json
- 生成 KB md
- 把 STATE.stage 改成 fetched
禁止做:
- ❌ 不要做选题评分(那是 write/Turn 1 的事)
- ❌ 不要写 data/scored-{date}.md
- ❌ 不要跑 analyze-viral.mjs / ai-topic-research.mjs / enrich-spec-context.mjs
- ❌ 不要 spawn 任何 subagent
- ❌ 不要打开 references/writing-pipeline.md(那里面是写作 subagent 的步骤清单,与本阶段无关)
- ❌ 不要写 spec.md / 初稿 / 润色 / 任何 drafts/ 下的文件
「禁止做 X」的清单比「该做 Y」的清单更重要——前者画出了边界,后者只画出了起点。LLM 不是按字面执行,它会顺着每一个 reference 去查、去拼装、去推断"应该还做什么"。你不堵那些岔路,它就跑岔。
新版还加了一节「完成定义」:
满足全部 4 条 = 完成:trending JSON 存在且 > 10KB / KB md 存在且 > 2KB / STATE.stage = fetched / STATE.date = 今天。 满足后只输出一行
OK fetched {今天},立即停止,不要做任何额外工具调用。
这段比上面的「禁止列表」更关键。「完成定义」是 LLM 任务的 STOP 信号——agent 拿不到 STOP 信号就会一直做下去,因为 LLM 没有"差不多就行了"的本能。可量化的、可被 agent 自检的 4 条 + 一句明确的 “立即停止”,把"完成"变成了一个布尔判断而不是一种主观感觉。
验证
新 prompt 跑一次实测。今天 STATE 已经是 fetched/2026-04-26、产物全在,理论上 agent 看一眼就该退出。
[runner] === fetch start 2026-04-26 09:28:55 ===
[runner] pre-flight: STATE.date=2026-04-26 已是今天,无需 reset
所有 4 项完成定义已满足:trending JSON (598KB)、KB md (22KB)、
STATE stage=fetched、date=2026-04-26。
OK fetched 2026-04-26
[runner] === fetch end rc=0 2026-04-26 09:29:14 ===
19 秒。从 5400 秒到 19 秒,差 280 倍。
附带改动:把 timeout 也按 stage 分档(fetch 1800s / write 3600s / finish 2700s),任何 stage 撞顶都早 fail 早重试,不再统一 90 分钟。
更普适的两条
写到这里 1500 字。跟昨天那篇配起来看,「LLM 跑生产 cron」的两条铁律可以总结成:
一、状态机的「前序就绪」判断不要交给 LLM 主 agent(昨天那篇的 takeaway)。LLM 把"按规则跳过"和"做完了"输出成同样格式,rc 同样是 0。判断必须在 wrapper / 控制流层硬做。
二、prompt 边界用「禁止做 X」划清,不能只说「该做 Y」(今天这篇的 takeaway)。LLM 会读 prompt 引用的所有 reference,按它能找到的最详细文档自由延展。你列了"该做的 3 件事"它就做 3 件,但你不写"禁止做评分/不要打开 writing-pipeline.md/不要 spawn subagent",它撞到那些文档的时候就会顺手做。
把两条放一起看,是同一个性质的事——LLM agent 不是脚本,是一个"会推断"的执行体。脚本你不写它就不做;LLM 你不禁它就可能做。设计 LLM cron 任务的心智模型该是「拒绝列表」而不是「待办列表」。
至于今天 fetch 那 66 分钟它具体在做什么——评分?开 viral 分析?试图 spawn 写作 subagent?没开 verbose 日志,没法知道。下次再撞会带 --output-format stream-json --verbose 抓现场。但根因已经修好,不一定有下次了。
最后一句留给设计 LLM 任务的人:写 prompt 时本能会想「这要它做什么」,那是给人写指令的思维。给 LLM 写 prompt,至少要花一半时间想「它会顺着引用文档走到哪些岔路、哪些岔路要堵掉」。否则不是在写 prompt,是在埋 timeout。
来源
- 全部一手日志:
/data/daily-digest/logs/{fetch,write,finish}-2026-04-2[5-6]_*.log - 修复 commit:
runner/prompts/fetch.md重写为硬约束 +run-stage.shtimeout 分档 - 昨天那篇 takeaway 一:《我以为 claude -p rc=0 就是成功,今天踩了三层坑》