意思就是,如何让LLM 定时做任务,这一篇就讲这件事------给 Agent 加定时任务,让它到了点自己做事情、报错了还能自己收场。定时任务难的从来不是「定时」,而是「没人在旁边盯着的时候,怎么保证它运行得对、任务报错了有人管」。
前言
「让 Agent 定时跑」从来不只是加一个 cron 表达式这么简单,到点了把入口函数调一下,亦或者说不是这样做。于是你会看到------一个 setInterval 或者几行 cron 配置,模型就被「挂」到了时间上。
然后第二天早上打开日志,哦豁,是满屏的报错:凌晨三点有一批任务反复失败、重试、再失败,把一天的额度烧光了;或者某个任务跑一半进程崩了,定时器跟着一起没了,你还以为它在跑。
问题不在「定时」这个动作,而在「定时之后会发生什么」------一个没有人盯着、自己触发、自己执行、自己失败的循环 。这跟「人敲一句话、Agent 回一句」是完全不同的两件事。要把它做好,得先回答四个问题:谁在到点叫醒它?它醒了跑什么?重启之后任务还在吗?跑挂了谁收拾?
一、调度器:谁在到点的时候叫醒 Agent?
定时任务的第一层,是一个「调度器」------它盯着时间,到了点就触发某个动作。选型上常见的两条路:
- 进程内调度器 :在你的 Agent 进程里跑一个 cron 库(JS 生态里像
croner、node-cron),解析 cron 表达式、在匹配的瞬间回调你的函数。 - 系统级调度:用 OS 的 cron / systemd timer,到点拉起一个独立的进程去跑。
ts
// 进程内调度器:解析 cron 表达式,到点执行一次「跑一轮 Agent」
import { Cron } from 'croner'
const job = new Cron('0 9 * * *', { timezone: 'Asia/Shanghai' }, async () => {
await runAgent({ prompt: '帮我汇总昨天的项目进展并发给我' })
})
bash
# 系统级调度:到点拉起一个独立进程
0 9 * * * cd /app && tsx run-daily-summary.ts
取舍点:进程内调度器简单、和 Agent 共享同一份内存与上下文、增删任务就是「增删对象」,但它跟进程同生共死------进程一挂,任务全没。系统级调度更稳、崩溃也能拉起,但它是「另一个进程」,跟 Agent 的运行时割裂,状态、日志、通知都要自己再打通一层。
对一个要长期自己跑的 Agent,常见的折中是:用进程内调度器,但把「任务定义」持久化到磁盘 。这样进程崩了,重启后把任务从磁盘读回来,调度器「复活」。这里藏着一个容易被忽略的点------cron 表达式本身只是一串字符串,真正的难点在下一层:到点了,Agent 要跑什么?
二、Agent 醒了跑什么:定时任务 = 时间 + 一段「意图」
这是「定时任务」和普通 cron 最大的不同。一个普通的定时任务,到点执行的是一个确定的函数 ------backup()、send_email()。而 Agent 的「动作」不是写死的,它要靠一段 prompt 来描述意图,然后让模型自己决定调哪些工具、怎么完成。
所以一个「Agent 定时任务」,本质上是两样东西的组合:
ts
interface ScheduledTask {
id: string
schedule: string // 触发时间:'0 9 * * *'
prompt: string // 到点要执行的「意图」:'每天九点汇总昨天的提交'
status: 'idle' | 'running' | 'failed'
}
到点的那一刻,调度器不是「调一个函数」,而是把这段 prompt 当作用户消息,灌进一次完整的 Agent 循环,让它像人发了一条消息一样跑起来。
ts
async function runScheduled(task: ScheduledTask) {
const messages = [{ role: 'user', content: task.prompt }]
await agentLoop(messages) // 一轮完整的「思考 → 调工具 → 再思考」
return lastAssistantText(messages)
}
取舍点 :这一步把定时任务从「确定性的脚本」变成了「非确定性的生成」。确定性脚本跑一万次结果都一样;而 Agent 每次跑,模型都可能给出不同的动作、调用不同的工具。这带来两个后果:一是结果要落盘、要可追溯 (否则跑完连它干了啥都不知道);二是必须预设它可能失败------这就引到最关键的一节。
三、失败与熔断:半夜跑挂了,谁来收拾?
这是「让 Agent 定时自己跑」里最值钱、也最容易被跳过的一节。Agent 的一次运行,可能因为模型限流、网络抖动、某个工具报错、甚至 prompt 本身写得不对而失败。而它跑在「没人盯着的凌晨」,失败如果没人处理,就会变成三种坏结果:
- 静默失败:跑挂了,没留下任何记录,你以为它在正常工作。
- 无限重试:写了「失败就重试」但没有上限,一个坏任务整夜循环、把 token 额度烧光。
- 连锁放大:一个失败的任务又触发别的任务,雪崩。
所以光「重试」是不够的,得配一套兜底策略:
ts
async function runWithGuard(task: ScheduledTask) {
try {
await runScheduled(task)
task.failCount = 0
log(task.id, 'ok')
} catch (err) {
task.failCount++
log(task.id, 'fail', err)
if (task.failCount >= 3) { // 连续失败 N 次 → 熔断
pause(task) // 停掉,别再烧钱
notify(`任务「${task.id}」连续失败,已熔断`)
}
}
}
取舍点 :重试要「退避」,熔断要「设限」。失败重试用指数退避(第一次等 1 分钟、第二次等 2 分钟、再 4 分钟...),而不是立刻无脑重来;连续失败到阈值就熔断 ------把任务停掉、通知人,而不是让它继续空转。原因很简单:Agent 的每一次运行都在花真金白银(token 和钱),重试和熔断不是「尽量把事办成」,而是「在办成和止损之间做取舍」。 一个坏任务,停掉比硬跑更对。
四、原点:定时任务把「触发」从人手里,交到了时间手里
把前面三节收拢成一个判断:
普通程序是「人触发,程序执行」;加了定时任务的 Agent,变成了「时间触发,模型执行,人只在出错时被叫回来」。
这就是「定时任务」真正的价值,也是它所有复杂度的来源。价值在于:Agent 从被动应答 (你敲一句它回一句)变成了主动驱动 (它到点自己动起来)。复杂度在于:以前有人在旁边兜底,现在没有,所以「记录、重试、熔断、通知」这一整套没人盯着的保险,一样都不能省。
所以判断一个「定时 Agent」做得成不成熟,就看一条:任务在凌晨三点失败时,第二天早上你是「一眼能看到它失败了、为什么失败」,还是「完全不知道发生了什么」。 前者才配叫「自己跑」,后者只是「放养」。
结语
回到开头那句:定时任务难的从来不是「定时」,而是「没人在旁边盯着的时候,怎么保证它运行得对、任务报错了有人管」。
答案是一层层兜底:调度器 负责「到点叫醒」,任务定义 负责「醒来了跑什么」,持久化 负责「重启不丢」,重试 + 熔断 + 通知负责「跑挂了有人收拾」。四层里,前两层是「让它跑起来」,后两层才是「让它长期靠谱」------而大多数人只做了前两层,就以为完成了。
让 Agent 定时自己跑,不是给它挂个闹钟,而是给它配一套能在无人值守下自己兜底的机制。
参考: