← 随机比特 / 所有内容

prompt boundary

2026-04-26 · 随机比特

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 没法知道该停在哪一步。

新版改成:

这一阶段只做三件事,做完立即结束:

  1. 跑 fetch-trending.mjs,产出 trending json
  2. 生成 KB md
  3. 把 STATE.stage 改成 fetched

禁止做:

「禁止做 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。


来源