Cron Scheduler - 定时调度机制
L14 要回答一个问题:如何在 L13 的基础上实现”将来某个时间再开始做事”,而不是”稍后回来拿结果”?
L13 给了我们骨架:后台执行线 + 通知队列 + 任务表分离。但真实 Agent 需要定时执行任务——每天早晨检查日志、每周一提醒备份、定时轮询外部状态。这些不是”等结果回来”,而是”等开始时机”。怎么做?
本章讲三个机制:
| 机制 | 解决什么问题 | 核心设计 |
|---|---|---|
| 三组件模型 | 调度记录如何回到主循环 | 调度记录 → 定时检查器 → 通知队列 |
| 关键字段 | 防重复、跨会话、反复触发 | last_fired_at + durable + recurring |
| 通知注入 | 统一主循环处理 | 都走通知队列,下一轮注入 messages |
这三个机制叠加在 L13 的 Background Tasks 上,循环本身不变。理解 L14,就理解了 Agent 如何”定时触发回归主循环”。
一、本质定位:等开始 vs 等结果
“定时调度解决的是’将来某个时间再开始做事’,而不是’稍后回来拿结果’。”
这句话概括了 Cron Scheduler 的本质。不是后台任务的延续——而是另一类需求。
与 L13 Background Tasks 的区别
| 机制 | 回答的问题 | 关注点 |
|---|---|---|
| 后台任务(L13) | “已启动的慢操作,结果什么时候回来?“ | 等结果 |
| 定时调度(L14) | “一件事应该在未来什么时候开始?“ | 等开始 |
关键洞察:定时调度不是另一套 agent,最终还是回到同一条主循环。
三组件心智模型
调度记录 → 定时检查器 → 通知队列完整流程:
- schedule_create(…) → 把记录写到列表或文件里
- 后台检查器每分钟看一次”现在是否匹配”
- 如果匹配,就把 prompt 放进通知队列
- 主循环下一轮把它当成新的用户消息喂给模型
验证你的理解
Q1:定时调度为什么不能”后台默默执行”?
:::details 答案 系统行为不透明 + 模型不统一决策 + 主循环结构乱。
如果后台默默执行:
- 模型不知道定时任务触发 → 行为不透明
- 后台直接调用工具 → 权限检查缺失
- 多条执行线 → 主循环结构混乱
正确设计:走通知队列 → 主循环统一处理 → 模型决策 → 行为透明。 :::
二、关键数据结构:ScheduleRecord
定时调度需要记录”未来要做什么、什么时候做”。
ScheduleRecord 定义
schedule = { "id": "job_001", # 唯一编号 "cron": "0 9 * * 1", # 定时规则(分 时 日 月 周) "prompt": "...", # 到点后要注入主循环的提示 "recurring": True, # 是否反复触发 "durable": True, # 是否落盘保存(跨会话) "created_at": 1710000000, # 创建时间 "last_fired_at": None, # 上次触发时间(防重复)}字段职责
| 字段 | 职责 | 为什么要 |
|---|---|---|
| id | 唯一标识 | 查询、删除、更新需要 |
| cron | 定时规则 | 判断何时触发 |
| prompt | 注入提示 | 触发后做什么 |
| recurring | 反复触发 | 周期性 vs 一次性 |
| durable | 跨会话保存 | 重启后是否保留 |
| last_fired_at | 防重复触发 | 检查周期内避免多次触发 |
关键字段详解
last_fired_at:防止短时间内重复触发同一任务。
def should_fire(schedule: ScheduleRecord, now: datetime) -> bool: # 1. cron 匹配 if not cron_matches(schedule["cron"], now): return False
# 2. 未触发过 if schedule["last_fired_at"] is None: return True
# 3. 上次触发不是同一分钟 last_minute = schedule["last_fired_at"].minute current_minute = now.minute return last_minute != current_minute没有这个字段,系统容易在检查周期内多次触发。
durable:是否落盘保存。
| 值 | 行为 | 适用场景 |
|---|---|---|
| true | 写磁盘,重启后保留 | 跨会话任务(每天提醒) |
| false | 仅内存,重启后丢失 | 临时任务(本次会话有效) |
recurring:是否反复触发。
| 值 | 行为 | 适用场景 |
|---|---|---|
| true | 触发后保留,下次再触发 | 周期性任务(每周备份) |
| false | 触发后删除 | 一次性任务(明天提醒) |
验证你的理解
Q2:为什么 durable=true 需要落盘,而不只是内存存储?
:::details 答案 跨会话保留——程序重启后调度记录仍然存在。
如果只存内存:
- 用户设置”每天早晨检查日志”
- 程序重启(用户关闭应用)
- 调度记录丢失 → 不再触发
- 用户困惑:为什么定时任务消失了?
正确设计:durable=true → 写磁盘 → 重启后重新加载 → 定时任务继续有效。 :::
三、Cron 表达式:5字段版
Cron 表达式定义何时触发。教学版用5字段,不是6字段或7字段。
5字段格式
分 时 日 月 周| 字段 | 含义 | 范围 |
|---|---|---|
| 分 | 分钟 | 0-59 |
| 时 | 小时 | 0-23 |
| 日 | 日期 | 1-31 |
| 月 | 月份 | 1-12 |
| 周 | 星期 | 0-7 (0和7都是周日) |
示例解析
| Cron | 含义 | 触发时间 |
|---|---|---|
*/5 * * * * | 每5分钟 | 0, 5, 10, 15, … |
0 9 * * 1 | 每周一9点 | 周一早晨9:00 |
30 14 * * * | 每天14:30 | 下午2:30 |
0 0 1 * * | 每月1号0点 | 月初午夜 |
特殊符号
| 符号 | 含义 | 示例 |
|---|---|---|
* | 任意值 | * * * * * = 每分钟 |
*/N | 每N单位 | */5 * * * * = 每5分钟 |
N-M | 范围 | 0-30 9 * * * = 9点0-30分每分钟 |
N,M | 列表 | 0,30 9 * * * = 9:00和9:30 |
注意:初学者常见错误——沉迷 cron 语法细节。主线是”调度记录如何回到主循环”,不是语法。
验证你的理解
Q3:
0 9 * * 1表示每周一9点,那0 9 1 * 1表示什么?:::details 答案 每月1号且是周一的9点。
解析:
- 分=0,时=9 → 9:00
- 日=1 → 1号
- 月=* → 任意月
- 周=1 → 周一
触发条件:同时满足”1号”和”周一”。
这意味着不是”每月1号或周一”,而是”既是1号又是周一”。如果某月1号不是周一,就不会触发。
设计决策:日和周是”AND”关系,不是”OR”。这是 cron 标准行为。 :::
四、检查周期与通知注入
定时检查器需要决定”多久检查一次”和”如何通知主循环”。
检查周期:分钟级而非秒级
# 后台检查循环def cron_checker_loop(): while True: sleep(60) # 每分钟检查一次 check_all_schedules()为什么分钟级?
| 原因 | 说明 |
|---|---|
| 精度够用 | 大多数 cron 任务不需要秒级精度 |
| 资源消耗低 | 秒级检查消耗高(每秒遍历所有 jobs) |
| 后台独立 | 检查是后台循环,不是每轮对话触发 |
通知注入:统一主循环处理
def check_all_schedules(): now = datetime.now()
for schedule in schedules: if should_fire(schedule, now): # 1. 放入通知队列 notification = { "type": "scheduled_prompt", "schedule_id": schedule["id"], "prompt": schedule["prompt"], } notifications.append(notification)
# 2. 更新 last_fired_at schedule["last_fired_at"] = now
# 3. 处理 recurring if not schedule["recurring"]: schedules.remove(schedule)为什么走通知队列?
| 不走的问题 | 走的好处 |
|---|---|
| 后台直接执行 → 权限检查缺失 | 主循环统一处理 → 权限检查完整 |
| 模型不知道触发 → 行为不透明 | 通知注入 → 模型看到触发原因 |
| 多条执行线 → 结构混乱 | 单一主循环 → 结构清晰 |
验证你的理解
Q4:为什么定时检查器是后台独立循环,而不是每轮对话触发?
:::details 答案 检查频率固定,与对话轮次无关。
如果每轮对话触发:
- 用户不说话 → 不检查 → 定时任务错过
- 用户频繁说话 → 检查过多 → 资源浪费
- 检查频率不可控 → 行为不确定
正确设计:后台独立循环 → 每分钟固定检查 → 不依赖对话 → 行为确定。 :::
五、回顾:L14 在循环上叠加了什么
回到开头的问题:如何在 L13 的基础上实现”将来某个时间再开始做事”?
答案:
| 叠加机制 | 代码位置 | 循环的变化 |
|---|---|---|
| 调度记录 | ScheduleRecord 数据结构 | 不变——只是存储层扩展 |
| 定时检查器 | 后台独立循环 | 不变——只是触发源增加 |
| 通知注入 | notifications 队列 | 不变——只是注入内容增加 |
循环本身仍是 L01 的 while True + stop_reason + messages[]。L14 只是在”触发层”叠加了定时机制。
系统整合
到了 L14,系统有两条外部事件输入:
| 事件源 | 内容 | 统一方式 |
|---|---|---|
| 后台任务完成 | background_completed | 通知队列 → drain → messages |
| 定时调度触发 | scheduled_prompt | 通知队列 → drain → messages |
统一方式:都走通知队列,在下一轮模型调用前统一注入。
与后续课程的关系
| 课程 | 关系 |
|---|---|
| L13 Background Tasks | 前置:理解”等结果”vs”等开始”的区别 |
| L15 Agent Teams | 后续:定时调度可用于团队任务轮询 |
| L06 Context Compact | 调度记录在 system prompt 不在 messages,不受压缩影响 |
核心洞察:Cron Scheduler 是”等开始时机”的机制。理解 L14,就理解了 Agent 如何”定时触发回归主循环”。后续课程都在扩展调度机制——团队轮询、跨会话保留。
六、心智模型升级:从 L0 到 L2
读完这篇文章,你的理解经历了怎样的升级?
L14 核心概念的理解升级路径
| 概念 | L0(表面) | L1(关联) | L2(深层) |
|---|---|---|---|
| 本质定位 | ”定时任务" | "等开始 vs 等结果" | "定时调度解决的是’将来某个时间再开始做事’,而不是’稍后回来拿结果’。不是另一套 agent,最终回到同一条主循环。类比闹钟:设定时间后等待触发,不是立刻执行” |
| 三组件模型 | ”调度记录 → 检查器 → 通知" | "存储 → 触发 → 注入" | "调度记录(存储意图)→ 定时检查器(后台独立循环)→ 通知队列(统一注入)。类比 Git:commit(记录)→ push(触发)→ remote(通知)。每个组件职责单一” |
| 关键字段 | ”cron/prompt/durable" | "定时规则 + 提示 + 跨会话" | "last_fired_at 防重复——检查周期内避免多次触发。durable 跨会话——重启后保留。recurring 反复触发——周期性 vs 一次性。三个字段缺一不可” |
| 通知注入 | ”时间到放队列" | "后台检查 → 通知队列 → 主循环" | "统一走通知队列——后台任务完成和定时调度触发都走同一通道。下一轮 drain → 注入 messages → 模型统一决策。系统行为透明,主循环结构不乱” |
如何检验你达到了 L2?
用这个问题自测:
如果定时检查器每秒检查一次(秒级),会有什么问题?完整链条是什么?
:::details L2 层级回答 资源消耗高 + 重复触发风险 + 检查频率不可控。
完整链条:
- 每秒检查一次 → 每秒遍历所有 jobs
- 100个 jobs → 每秒100次检查 → CPU消耗高
- cron 匹配后 → 1分钟内可能触发60次(每秒检查一次)
- last_fired_at 需要更精细(秒级而非分钟级)→ 字段设计复杂
- 检查频率固定 → 与对话轮次无关 → 但资源消耗不可控
正确设计:分钟级检查 → 资源消耗可控 → 大多数任务精度够用 → last_fired_at 分钟级防重复。 :::
本章目标:让每个核心概念都从 L0 升级到 L2。理解 L14,不只是记住 cron 语法,而是掌握”如何在循环上叠加定时触发”的设计原则。
七、复习可视化
调度器核心流程
与后台任务的对比
系统整合:两条外部事件
关键字段职责
durable + recurring 组合
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!