· Jeff · 教程  · 13 分钟阅读

AI Agent 定时任务为什么静默失败:两个我踩过的坑与实测数据

定时任务连续几天不出活,日志里却看不出错——这是跑 LLM 自动化最常见的一种失败。本文是我排查一个每日定时任务的完整记录,两个真实根因:推理型模型的 token 预算被思维链烧穿(实测 4096 下 2/6 空回复、8192 下 0/6),以及框架的「模型漂移保护」fail-closed 静默跳过任务。附复现方法、修法和一份模型横向压测表。

定时任务连续几天不出活,日志里却看不出错——这是跑 LLM 自动化最常见的一种失败。本文是我排查一个每日定时任务的完整记录,两个真实根因:推理型模型的 token 预算被思维链烧穿(实测 4096 下 2/6 空回复、8192 下 0/6),以及框架的「模型漂移保护」fail-closed 静默跳过任务。附复现方法、修法和一份模型横向压测表。

AI Agent 定时任务为什么静默失败:两个我踩过的坑与实测数据

我有一台小服务器,上面跑着几个 AI Agent 的定时任务:每天早上自动写点东西、发点东西、整理点东西。它们平时很省心,直到某天我发现——连续几天,任务什么都没产出

去翻日志,没有报错。任务状态显示「已执行」。但该出现的结果没出现。

这类失败最难受的地方就在这:它不报错。没有异常堆栈、没有非零退出码、没有告警邮件。你以为一切正常,直到某天想用它的产出时才发现,它已经空转了好几天。

这篇是我排查这个问题的完整记录,两个根因都是实测确认的,也都有明确的修法。如果你也在用 AI Agent 跑定时任务,这篇大概率能帮你省掉一次同样的排查。

环境:Node/Python 混合,自建 API 网关统一转发上游模型,任务由框架内置调度器触发。下文把具体产品名和网关地址做了泛化,不影响结论。

一、先看现象:失败长什么样

任务的职责很简单:每天定时跑一次,读上下文 → 调模型 → 执行几个工具 → 落盘 + 投递。

失败时的表现:

观察点表现
任务状态✅ 已执行(不是失败)
日志无异常、无堆栈
产出——文件没落盘,投递没发生
上下文记录只回写到失败前一天,之后断层

关键线索是最后一条:上下文记录出现了断层。这说明任务确实跑了,但跑完之后什么都没写回去——相当于每一步都空转。

二、复现:不要靠猜,把真实请求打回去

排查这类问题,最有效的动作不是读代码,而是把失败那次的真实请求原样重放

框架通常会保存请求转储(request dump),里面是发往模型的完整 body:消息列表、工具定义、参数。找到它,用它作为压测的输入——这比你自己编一个 prompt 准得多,因为真实 payload 里藏着 30 多个工具定义和几万 token 的上下文,这些正是触发问题的条件。

# 用真实转储作为基准 payload,只替换要测的变量
import json, subprocess, time

dump = "sessions/request_dump_cron_<job_id>_<timestamp>.json"
base = json.load(open(dump))["request"]["body"]
base.pop("stream", None)          # 压测用非流式,方便解析

def probe(model, max_tokens, n=6):
    empty = 0
    for i in range(n):
        body = dict(base, model=model, max_tokens=max_tokens)
        open("/tmp/q.json", "w").write(json.dumps(body, ensure_ascii=False))
        t = time.time()
        r = subprocess.run(["curl", "-sS", "-m", "240", "-X", "POST", API_URL,
                            "-H", "Content-Type: application/json",
                            "-H", f"Authorization: Bearer {API_KEY}",
                            "--data-binary", "@/tmp/q.json"],
                           capture_output=True, text=True)
        d, _ = json.JSONDecoder().raw_decode(r.stdout.split("data: [DONE]")[0].strip())
        msg = d["choices"][0]["message"]
        content = (msg.get("content") or "").strip()
        reasoning = msg.get("reasoning_content") or ""
        tools = msg.get("tool_calls") or []
        finish = d["choices"][0].get("finish_reason")
        if not content and not tools:
            empty += 1
        print(f"  #{i+1} {time.time()-t:.1f}s content={len(content)} "
              f"reasoning={len(reasoning)} tools={len(tools)} finish={finish}")
    print(f"  -> 空回复 {empty}/{n}")

第一次跑,问题就现形了。

三、根因一:推理型模型的 token 预算被思维链烧穿

先解释一下推理型模型(reasoning model)和普通模型的区别:它在输出正式回答之前,会先生成一段内部推理(不同厂商叫 reasoning_contentthinking 等)。这段推理和正式回答共享同一个 max_tokens 预算

于是就有了这样一个陷阱:

max_tokens = 4096
├─ 推理过程用掉 3800 tokens  →  预算见底
└─ 正式回答只分到 296 tokens  →  一个字都写不完
   finish_reason = "length",content = ""

实测数据(同一个真实 payload,各跑 6 次):

max_tokens空回复率备注
4096(框架默认)2/6推理烧掉 8522 / 10732 字符,finish=length连工具调用都没发出
81920/6全部正常发起工具调用
163840/6同上

空回复的形态非常典型,记住这三个特征就能一眼认出来:

  1. content 为空字符串
  2. finish_reason = "length"(不是 stop
  3. reasoning_content 异常长

为什么它表现为「静默失败」:因为从框架的视角看,这是一次成功的 HTTP 200。响应格式合法、字段齐全,只是 content 是空的。如果任务逻辑里没有对「空产出」做校验,它就会当作正常结果继续往下走,然后落盘一个空文件、或者干脆什么都不做。

对定时任务来说,这等于随机丢批次——2/6 的概率,一周就能丢两次。

修法

model:
  default: <推理型模型>
  max_tokens: 16384     # 关键:给足预算,别用默认值

判断标准很简单:只要你的模型有 reasoning_content 输出,max_tokens 就不该低于 8192。写长文的任务建议直接给 16384 以上。这一点值得单独记住——换模型时最容易漏掉的参数就是它

四、根因二:漂移保护把任务「静默跳过」了

修完 max_tokens,我以为结束了。但检查任务配置时发现了一个更隐蔽的坑。

背景是这样:框架的定时任务支持给单个任务「钉住」(pin)模型。我之前给这个任务钉过一个模型,现在想让它跟随全局主模型,就把 pin 清掉了。

清掉之后,框架自动写入了一个快照字段 model_snapshot = '<清 pin 那一刻的模型>'

而框架里有一个默认开启的保护机制,我称之为漂移保护(drift guard):

如果任务没有显式 pin 模型,但存在快照,且当前解析出的模型与快照不一致 → 任务被跳过(fail-closed),而不是按新模型执行。

它的设计初衷是好的:防止你的任务在不知情的情况下被路由到一个更贵的模型上,把账单跑爆。所以它选择「宁可不动」。

但副作用是——它跳过的时候是静默的。你看到的就是本文开头那个现象:任务「已执行」,但什么都没做。

这里有个反直觉的点值得强调:「清空 pin」这个动作,本意是让任务跟随主模型,实际效果却是让任务变成「只认快照模型」。清 pin 和跟随,不是一回事。

修法

显式声明路由,而不是依赖隐式继承:

cron:
  model: <模型名>              # 显式路由
  model_provider: <provider>   # 显式 provider

显式设置这两个字段后,任务真正跟随你指定的模型,同时该轴不再受漂移保护的 fail-closed 影响。

如果你确实需要漂移保护(比如按量计费、模型单价差异大),别关它,改成显式路由就好——保护的本意是「别乱花钱」,显式声明恰恰是告诉它「我知道我在用什么」。

五、顺手做的模型横向压测

排查过程中我把手上能用的几个模型都压了一遍,同一份真实 payload,结果差异很大,值得记录:

模型稳定性(长文 ×5)长上下文 ×3工具调用速度结论
模型 A5/5 全绿3/3 全绿最快(4–20s)选它
模型 B5/5 全绿3/3 全绿快(5–21s)✅ 可作备选
模型 C5/5 全绿3/3 全绿慢(22–38s)⚠️ 有同样的空回复缺陷(5 次中 2 次)
模型 D⚠️ 有 5043/3 全绿最慢(48–57s)❌ 不稳定

两个发现值得单独说:

  1. 空回复缺陷不是单个模型的问题。模型 C 复现了完全一样的症状(finish=lengthreasoning 1900+ 字符、content 为空),也是 5 次里 2 次。选模型时一定要测这一项,别只看「能不能返回 200」。
  2. HTTP 200 不代表任务成功。压测时如果只看状态码,四个模型全都是「通过的」。必须解析 content 长度、finish_reasontool_calls 才能看出真实差异。

六、我的排查清单(可直接抄)

按顺序做,能覆盖绝大多数「静默失败」:

步骤动作判据
1看任务状态与产出的不一致状态「已执行」但无产出 → 高度可疑
2找请求转储,用真实 payload 重放别自己编 prompt,真实上下文才是触发条件
3解析响应三要素content 长度 / finish_reason / tool_calls
4检查 max_tokensreasoning_content 就必须 ≥8192
5检查是否有漂移保护 + 快照字段有快照且未显式路由 → 会被静默跳过
6给任务加产出校验空产出应视为失败并告警,而不是静默通过
7横向压测候选模型每项至少跑 5–6 次,看空回复率

第 6 条是治本的。前五条是修具体问题,第六条是修「这类问题为什么会无声发生」——只要任务在产出为空时主动失败并告警,你下次就不会隔几天才发现。

# 最小可用的产出校验:空产出即失败
def assert_non_empty(result: dict, job_name: str):
    content = (result.get("content") or "").strip()
    tools = result.get("tool_calls") or []
    finish = result.get("finish_reason")
    if not content and not tools:
        raise RuntimeError(
            f"[{job_name}] 空产出:finish={finish},"
            f"reasoning_len={len(result.get('reasoning') or '')}。"
            f"检查 max_tokens 是否够大。"
        )

七、小结

两个根因,一句话各说一遍:

  1. 推理型模型的思维链和正式回答共享 max_tokens。预算不够时推理吃光额度,正式回答为空,但 HTTP 仍是 200——于是任务静默空转。reasoning_content 的模型,max_tokens 给到 8192 以上。
  2. 漂移保护是 fail-closed 的,且跳过时静默。清 pin 会让任务变成「只认快照模型」而非跟随主模型。用显式路由(cron.model)替代隐式继承。

还有一个跨两条根因的教训:「没报错」和「成功了」是两件事。Agent 的定时任务尤其如此——链路长、环节多、每一环都可能安静地返回一个空结果。给任务加一层产出校验,比修任何单个 bug 都值。

相关文章:

☕ 如果这篇文章对你有帮助

欢迎请 Jeff 喝杯咖啡,支持我持续分享更多软件技巧~

打赏功能即将上线,先点个赞也是支持 ❤️

返回博客

相关文章

查看全部 »
进程还在,端口已死:一个单线程 HTTP 服务的「假活」陷阱,和我的四层加固

进程还在,端口已死:一个单线程 HTTP 服务的「假活」陷阱,和我的四层加固

我自建的一个统计面板曾经挂了整整 16 天我才发现:进程从没退出、端口一直在 LISTEN、CPU 占用是零,看上去「健康得不得了」,可所有新连接都拿不到响应,公网一律 504。根因是单线程 HTTPServer 没有 socket 超时,被一条半开连接永久阻塞;更值得记的是第二层——进程监督器只认「进程存活」,这种假活对它完全不可见。这次我给它上了四层加固,也第一次想明白:为什么「重启脚本」这种修复,会被下一次部署悄悄冲掉。

1.3GB 内存的服务器上,浏览器自动化必然 OOM:我最后用纯 HTTP 重写了一遍签到

1.3GB 内存的服务器上,浏览器自动化必然 OOM:我最后用纯 HTTP 重写了一遍签到

一个每天跑的签到脚本,用 Playwright 时 6 分钟才启动完浏览器、随后必崩。我一度以为是脚本写得不对,换了三种写法都没用。真正的结论是:在 1.3GB 内存、swap 全满的机器上,浏览器自动化注定失败——不是代码问题,是物理问题。更值得记的是第二层翻车:当时得出的「必须借浏览器上下文」这个结论本身就是错的,而它被固化进了代码,白折腾了一周。

自建 AI Agent 记忆系统的冲突消解:软失效、事件账本,和同一个 bug 我修了两次

自建 AI Agent 记忆系统的冲突消解:软失效、事件账本,和同一个 bug 我修了两次

Agent 的记忆越攒越多,新事实和旧事实开始打架。本文记录我给自己那套记忆系统做「冲突消解」的完整实现:为什么不能直接删、软失效 + 事件账本怎么设计、用什么判据判定两条记忆冲突。重点是一个真实的翻车——判据太激进,一条新记忆横扫了 50 条无关事实,误杀 19 条。更值得记的是:同一个 bug,我修了两次。