RSS

第 14 章 / 共 21 章

定时调度:让 Agent 自己醒过来

约 9 分钟 更新于

上一章留下的问题:后台任务解决了”不阻塞”,但触发方式仍然是人。你想要”每天早上九点跑一次测试并把失败汇总发我”,现在只能每天早上自己打开终端说一句。

有人会想:让模型记住不就行了?不行——模型没有时钟,也没有在你不说话时自己运行的能力。周期性工作应该由 harness 创建,而不是让模型记住。

14.1 闹钟模型

一个闹钟为什么可靠?因为它有一个独立于你的计时器,时间到了就响,不管你在不在看它。照这个思路搭四层:

层干什么频率
调度线程每秒检查一遍所有任务的 cron 表达式是否匹配当前时间1 秒
队列匹配上的任务写进 cron_queue—
队列处理器队列非空且 Agent 空闲时,启动一轮 Agent0.2 秒
消费层循环从队列取出任务,作为 user 消息注入每轮
第14章:定时调度的四层结构
图 14.1:从时间匹配到消息注入的四层通路。要看的是”队列处理器”上那个”且 Agent 空闲”的条件——它是整套设计里最容易被省略、也最容易出事的一环:没有它,定时任务会在 Agent 正忙时闯进来,把两段工作搅在一起。

14.2 数据结构与 cron 匹配

from dataclasses import dataclass

@dataclass
class CronJob:
    id: str
    cron: str          # 标准五段式:"0 9 * * *"
    prompt: str        # 触发时注入的内容
    recurring: bool    # True 重复,False 一次性
    durable: bool      # True 落盘,重启后恢复

五段依次是:分 时 日 月 周。支持 *(任意)、*/N(每 N 个)、N(具体值)、N-M(区间)、N,M(列表)。

表达式含义
0 9 * * *每天 9
*/5 * * * *每 5 分钟
0 9 * * 1-5工作日 9
30 18 1 * *每月 1 号 18

匹配函数里有一处反直觉的地方:

def cron_matches(cron_expr: str, dt) -> bool:
    fields = cron_expr.strip().split()
    if len(fields) != 5:
        return False
    minute, hour, dom, month, dow = fields
    dow_val = (dt.weekday() + 1) % 7          # Python 周一=0,cron 周日=0

    if not (_field(minute, dt.minute) and _field(hour, dt.hour) and _field(month, dt.month)):
        return False

    dom_free, dow_free = dom == "*", dow == "*"
    if dom_free and dow_free:
        return True
    if dom_free:
        return _field(dow, dow_val)
    if dow_free:
        return _field(dom, dt.day)
    return _field(dom, dt.day) or _field(dow, dow_val)   # 两者都限定时是「或」

最后那行的”或”是 Unix cron 的标准语义,也是最常被搞错的一点:当日期和星期都被限定时,满足任意一个就触发,不是两个都要满足。0 9 1 * 1 的意思是”每月 1 号或每周一的 9 点”,不是”1 号且是周一”。

14.3 调度线程

import time, threading
from datetime import datetime

scheduled_jobs: dict[str, CronJob] = {}
cron_queue: list[CronJob] = []
cron_lock = threading.Lock()
_last_fired: dict[str, str] = {}

def cron_scheduler_loop():
    while True:
        time.sleep(1)
        now = datetime.now()
        minute_marker = now.strftime("%Y-%m-%d %H:%M")
        with cron_lock:
            for job in list(scheduled_jobs.values()):
                try:
                    if cron_matches(job.cron, now):
                        if _last_fired.get(job.id) != minute_marker:   # 同一分钟只触发一次
                            cron_queue.append(job)
                            _last_fired[job.id] = minute_marker
                            if not job.recurring:
                                scheduled_jobs.pop(job.id, None)
                                if job.durable:
                                    save_durable_jobs()
                except Exception as e:
                    print(f"[cron 错误] {job.id}: {e}")     # 单个任务出错不影响其他

两处细节值得停一下:

**minute_marker 防重复触发。**线程每秒跑一次,一分钟内会检查 60 次,cron_matches 都会返回 True。用”年-月-日 时

”做标记,保证同一分钟只入队一次;而因为标记里含日期,明天同一时刻还能正常触发。

**每个任务单独 try/except。**一个写错的 cron 表达式不该把整个调度线程干掉——那会导致所有定时任务集体静默失效,而且很难发现。

14.4 队列处理器与消费

agent_lock = threading.Lock()

def queue_processor_loop():
    while True:
        time.sleep(0.2)
        with cron_lock:
            has_work = bool(cron_queue)
        if not has_work:
            continue
        if not agent_lock.acquire(blocking=False):    # Agent 正忙,等下一轮
            continue
        try:
            run_agent_turn_locked()                   # Agent 空闲,启动一轮
        finally:
            agent_lock.release()

循环里消费:

        with cron_lock:
            fired, cron_queue[:] = list(cron_queue), []
        for job in fired:
            messages.append({"role": "user", "content": f"[定时任务] {job.prompt}"})

那个 [定时任务] 前缀是给模型看的:它需要知道这条输入不是人打的,而是系统按计划注入的——两者该有不同的应答风格(比如不需要说”好的,我这就去做”)。

14.5 走一遍:注册到触发

你:每天早上 9 点跑一次测试,如果有失败就整理成清单给我。
Agent:调用 schedule_cron(cron="0 9 * * *",
                         prompt="运行测试套件,把失败用例整理成清单",
                         recurring=True, durable=True)
       → "已创建定时任务 cron_0001,每天 09:00 执行"

第二天 09:00

,调度线程匹配上,任务入队;队列处理器发现 Agent 空闲,启动一轮;循环取出任务,注入 [定时任务] 运行测试套件……;Agent 跑测试、整理清单、输出结果。全程没有人参与。

14.6 一个必须说清楚的限制

durable=True 的含义常被误解。它保证的是任务定义落盘到 .scheduled_tasks.json,进程重启后能被重新加载。

它不保证进程关闭期间任务会执行。调度线程活在你的 Agent 进程里,进程没跑,就没人看时间。晚上关了电脑,早上 9 点的任务不会执行;你 10 点开机,它也不会补跑(因为那一分钟已经过去了)。

要做到真正与应用状态无关的定时执行,得把触发权交给操作系统——系统的 crontab、systemd timer,或者一个常驻的服务端调度器。教学版的定时器是”应用内闹钟”,不是”操作系统闹钟”。

14.7 工程现实

生产系统在这一层还会做这些事:

  • 文件锁防止同一项目的多个会话重复触发同一个任务;
  • 抖动:重复任务按周期的一定比例(有上限)随机延后,避免所有实例在整点同时打过来;
  • 任务数量上限(比如 50 个)和自动过期(比如重复任务默认 7 天后失效),防止无人清理的任务无限累积;
  • 优先级标记:定时触发的请求打上标签,在容量紧张时以较低优先级服务,不影响交互式请求;
  • 时区:表达式按本地时区解释,跨时区团队要显式约定。

14.8 常见坑

14.9 写出你需要的那几条 cron

14.10 说清 durable 保证了什么、没保证什么