2105 字
约 7 分钟
10
定时工具
2026-10-13
无标签
系列:ToyAgent 智能体知识库 26/26
定时工具:给智能体装上时间维度
一句话核心:此前的工具都是"调用 → 执行 → 返回"的即时闭环,定时工具把"什么时候做"也变成模型可决策的对象——任务注册进后台调度器,响应立即返回,触发结果落盘事件日志、在后续对话中回看。
为什么需要
"每天早上 8 点给我汇总新闻""30 秒后提醒我开会"——这类需求有一个共同结构:请求已经返回,行动却在未来。即时工具的同步闭环在这里失效了,而且暴露出一个经典的分布式问题:
- HTTP 响应几秒内就要返回,但任务要到未来某个时刻才执行——触发结果不能塞进本次响应,这决定了异步任务的架构必须回答"结果去哪";
- 任务在后台跑,用户随时会问"我的提醒设了吗""跑完了吗"——模型需要一个能查询任务状态的工具,调度器对 Agent 必须可观测;
- 服务重启时,还没触发的任务不能凭空消失——这是定时任务从玩具到生产的分水岭。
关键工程点:触发结果落盘(呼应事件溯源——append 到事件日志),模型后续对话中通过"查询已完成任务"的工具读回。这一手把"异步"从黑盒变成了可回看的账本。
核心机制
三种调度能力,对应三种真实需求:
| 能力 | 调度原语 | 示例指令 |
|---|---|---|
| 一次性 | schedule + delay | "定时:5秒 提醒我喝水" → 返回 job-1 |
| 循环 | scheduleAtFixedRate(首延迟 = 周期) | "循环:每10秒 报一次时" → 触发次数持续增长 |
| 管理 | 列表 / 取消(job_id 定位) | "任务列表""取消:job-2""完成了什么" |
骨架三件套:守护线程调度池、注册即返回、触发即落盘:
// 守护线程调度池(daemon:JVM 退出不被拖住;生产换持久化调度器/延时队列)
ScheduledExecutorService pool = Executors.newScheduledThreadPool(1, r -> {
Thread t = new Thread(r, "agent-scheduler");
t.setDaemon(true);
return t;
});
// 一次性:n 秒后触发;循环:scheduleAtFixedRate(task, 周期, 周期, ...)
JobId id = register(job);
pool.schedule(() -> fire(id, content), delaySeconds, TimeUnit.SECONDS);
// 注册路径在这里立即返回 job_id —— 控制权交还给模型与用户
// 触发即落盘:append-only JSONL(事件溯源思想)
Files.writeString(eventFile,
"{\"time\":\"" + time + "\",\"job\":\"" + id + "\",\"content\":\"...\"}\n",
CREATE, APPEND);
理解这个设计的三个要点:
- 注册与执行分离:
schedule()把任务交给调度线程后立即返回,模型拿到 job_id 就能答复用户"已安排好"。模型不需要等待,用户不需要等待——异步的正确体感就是"立刻有回执,未来有结果"。 - 结果走事件日志而非内存:如果触发结果只写内存 Map,重启即丢、跨进程不可见;落盘后它成为持久事实,"完成了什么"这类回溯查询直接读文件即可,模型像查账本一样回答。
- 循环任务的状态是可增长的:
scheduleAtFixedRate的语义是"到点就跑、次数无限",所以查询工具要能报告"已触发 N 次"——让用户能确认循环真的在跑,而不是只有设一次时的回执。
sequenceDiagram
participant U as 用户
participant MD as 模型
participant SC as 后台调度器
participant LG as 事件日志 JSONL
U->>MD: 定时 5 秒提醒我喝水
MD->>SC: schedule 注册任务
SC-->>MD: 返回 job-1
MD-->>U: 立即回执 已设定 job-1
Note over SC: 5 秒后到点触发
SC->>LG: fire 并 append 触发记录
U->>MD: 提醒设置成功了吗
MD->>LG: 查询已完成任务读回账本
MD-->>U: 已触发完成 喝水提醒
异步任务时序:注册立即返回回执,触发结果落盘事件日志,后续对话读回——"立刻有回执,未来有结果"。
举例:一次异步任务的完整时间线
t=0s user: 定时:5秒 提醒我喝水
模型调用 schedule_reminder(delay=5, content="喝水")
→ 注册成功,返回 job_id="job-1"
agent› 好的,已设定 5 秒后的提醒(任务 job-1) ← 响应立即返回
t=1s user: 任务列表
模型调用 list_jobs → job-1: PENDING, 剩余约4秒
agent› 你有一个待触发任务:job-1(喝水提醒)
t=5s 调度线程到点 → fire(job-1) → 追加事件到 tasks.jsonl
{"time":"...","job":"job-1","content":"喝水提醒"}
t=7s user: 提醒设置成功了吗?
模型调用 list_jobs → job-1: DONE(5秒前触发)
agent› 已触发完成:喝水提醒(job-1,5 秒前)
—— 循环任务 ——
user: 循环:每10秒 报一次时
→ 注册 job-2(fixedRate=10s)
t=30s user: 任务列表
→ job-2: RUNNING, 已触发 3 次(10s/20s/30s 各一次)
← "已触发次数"是循环任务区别于一次性任务的关键状态
user: 取消:job-2
→ future.cancel(false) → job-2: CANCELLED,后续不再触发
—— 重启 ——
服务重启后再问"完成了什么":
→ 从 tasks.jsonl 读回全部已触发记录,历史不丢
← 但 PENDING 任务丢了:教学版调度器是内存的,
这正是"任务持久化"列为生产化第一条的原因
从生产智能体开发视角举一反三:定时工具是 Agent 从"被动应答"走向"主动服务"的第一步。日报生成、巡检报告、定期摘要——这些"值班型智能体"全部建立在这个原语上。而"时间"作为一种工具输入,还引出模型能力的新考核点:模型需要正确解析"每天早上 8 点""30 秒后"这类时间表达并转成调度参数,错译(如把相对时间当绝对时间)是真实系统里常见的故障源。
教学版 vs 生产版
| 维度 | 教学版 | 生产版 |
|---|---|---|
| 任务存储 | 内存 Map,重启即丢 | 数据库/持久化队列,重启后 PENDING 任务恢复 |
| 调度器 | 进程内 ScheduledExecutorService | 分布式调度器/延时队列,多实例不重复触发 |
| 触发通知 | 用户主动来问 | SSE / WebSocket 主动推送 |
| 循环任务 | 无限次,无退避 | 最大次数 + 失败退避 + 熔断,防风暴 |
| 安全 | 任何请求都能注册 | 高危定时任务接审批门禁,注册需授权 |
面试考点
- "Agent 怎么处理'未来才执行'的任务?"——注册-执行分离:模型调用注册工具立刻拿到 job_id 回复用户,任务进后台调度器;关键在触发结果不能塞进本次响应,必须落盘事件日志并在后续对话中读回。
- "定时任务的服务重启问题怎么解?"——内存调度器重启丢任务;生产要持久化任务定义(数据库/延时队列),启动时恢复 PENDING 任务,分布式场景用分布式锁防重复触发。
- "一次性任务和循环任务在状态管理上有什么区别?"——一次性是 PENDING→DONE/CANCELLED 的有限状态机;循环多一个"已触发次数"的持续增长状态,且要加最大次数与失败退避,否则是潜在的任务风暴源。
生产化路径
- 任务定义与状态持久化到数据库,启动时扫描恢复;分布式部署下用调度中心或数据库锁保证同一任务只触发一次;
- 触发通知从"用户来问"升级为 SSE/WebSocket 主动推送,回执-触发-通知三段体感完整;
- 循环任务加最大执行次数、失败指数退避、连续失败熔断与告警,避免坏任务无限空转;
- 与审批门禁联动:涉及外发(邮件、短信、发布)的定时任务注册时需走审批,"定时"不该成为绕过风控的后门;
- 与事件溯源打通:任务注册、触发、取消全部事件化,定时行为成为会话日志的一部分,审计与回放自然覆盖。
评论
0 条
还没有评论,先写一条吧。