s14:定时调度(Scheduled Tasks)

与 s13 的关系

  • s13(后台任务):手动触发的命令 → 异步执行,但不会自动触发
  • s14(定时调度)到点自动入队、空闲自己跑、人不用在场

一句话概括区别:s13 解决"命令太慢",s14 解决"人不在也要干活"。


1. 四层解耦模型(本章灵魂)

时间判断(生产者)和执行(消费者)彻底分开。调度器自己跑自己的,不管 agent 在不在忙;agent 忙完空闲了,queue processor 才把活递过去。互不阻塞。

角色 干什么
Scheduler 独立 daemon 线程 1s 轮询,判断"现在到点了吗"
Queue cron_queue 调度线程把触发的 job 写进去(生产者缓冲区)
Queue Processor 另一 daemon 线程 队列非空且 agent 空闲时,拉起一轮 agent_loop
Consumer agent_loop 从队列取出 job,注入 messages 执行

解耦的好处

  • Scheduler 不关心 agent 忙不忙,只管判断时间
  • Queue 做缓冲,削峰填谷
  • Processor 只在 agent 空闲时才消费,不打扰用户交互
  • 四层各自独立线程,互不阻塞

2. CronJob 数据结构

@dataclass
class CronJob:
    id: str           # 唯一标识
    cron: str         # 触发条件 —— 五段式 cron 表达式,如 "0 9 * * *"
    prompt: str       # 触发时注入给 agent 的消息/指令
    recurring: bool   # 是否重复:True=周期性, False=一次性
    durable: bool     # 是否持久化:True=写磁盘跨重启保留, False=仅内存

字段说明

字段 类型 说明
id str 任务唯一标识符
cron str Cron 表达式,定义触发时间规则
prompt str 触发时作为 user 消息注入给 agent 的文本指令
recurring bool True=周期重复执行,False=只执行一次
durable bool True=持久化到磁盘(重启后恢复),False=仅存在于内存

3. Cron 表达式速查

Cron 是 Linux 里跑了 50 年的定时任务语法。"0 9 * * *" 就是一张"时间表",表示每天早上 9 点执行。

五段式结构如下:

字段 取值范围 含义 示例值
(Minute) 0–59 第几分钟 0 = 整点
(Hour) 0–23 几点(24小时制) 9 = 上午9点
(Day of month) 1–31 每月几号 * = 每天
(Month) 1–12 几月 * = 每月
星期(Day of week) 0–7(0和7都代表周日) 星期几 * = 每星期

常用示例

表达式 含义
0 9 * * * 每天早上 9:00
*/15 * * * * 每 15 分钟
0 9 * * 1-5 周一到周五早上 9:00
30 8 1 * * 每月1号早上 8:30
0 0 * * 0 每周日午夜 00:00

注意* 表示"每一个"(通配符),即该字段的所有可能值。


4. 各组件详解

4.1 cron_scheduler_loop — 独立调度线程

调度线程每秒读取一次本地时间,对每个已注册的 CronJob 执行 cron_matches() 判断:

  • 到点了 → 把任务塞进 cron_queue
  • 没到点 → 啥也不干,等下一秒
# 伪代码示意
def cron_scheduler_loop():
    while running:
        now = datetime.now()
        for job in all_cron_jobs:
            if cron_matches(job.cron, now):   # 判断当前时间是否匹配 cron 表达式
                cron_queue.put(job)            # 匹配则入队
        sleep(1)                               # 每秒轮询一次

4.2 cron_matches() — 时间匹配

遍历 cron 表达式的五个字段与当前时间的各分量进行比对。被调度线程每秒调一次,返回 True 就被塞进 cron_queue,之后等 agent 空闲时执行即可。

4.3 queue_processor_loop() — 交付端(消费者线程)

后台线程,职责很简单:

  1. 检查队列是否有待办任务
  2. 检查 agent 当前是否空闲
  3. 两个条件同时满足 → 自动拉起一轮 agent_loop 去处理

使用 agent_lock 避免定时任务与用户正在进行的回合同时修改会话(防止冲突)。

4.4 agent_loop — 执行端

从队列取出已经触发的任务,将它变成一条普通 user 消息注入对话,让 LLM 去执行:

fired = consume_cron_queue()   # 从队列取出并清空
for job in fired:
    messages.append({
        "role": "user",
        "content": f"[Scheduled] {job.prompt}"   # 加上 [Scheduled] 前缀标记来源
    })

错误恢复:如果模型调用失败,这些消息会从当前会话中移除,任务重新放回队列等待下次执行。

4.5 durable 持久化

将任务定义写入/读出磁盘,让定时任务跨重启保留:

  • save_durable_jobs():只挑 durable=True 的任务,序列化写入 .scheduled_tasks.json
  • load_durable_jobs():启动时读回文件,恢复任务到内存