2105 字
约 7 分钟
10
定时工具
无标签

系列: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 主动推送
循环任务 无限次,无退避 最大次数 + 失败退避 + 熔断,防风暴
安全 任何请求都能注册 高危定时任务接审批门禁,注册需授权

面试考点

  1. "Agent 怎么处理'未来才执行'的任务?"——注册-执行分离:模型调用注册工具立刻拿到 job_id 回复用户,任务进后台调度器;关键在触发结果不能塞进本次响应,必须落盘事件日志并在后续对话中读回。
  2. "定时任务的服务重启问题怎么解?"——内存调度器重启丢任务;生产要持久化任务定义(数据库/延时队列),启动时恢复 PENDING 任务,分布式场景用分布式锁防重复触发。
  3. "一次性任务和循环任务在状态管理上有什么区别?"——一次性是 PENDING→DONE/CANCELLED 的有限状态机;循环多一个"已触发次数"的持续增长状态,且要加最大次数与失败退避,否则是潜在的任务风暴源。

生产化路径

  • 任务定义与状态持久化到数据库,启动时扫描恢复;分布式部署下用调度中心或数据库锁保证同一任务只触发一次;
  • 触发通知从"用户来问"升级为 SSE/WebSocket 主动推送,回执-触发-通知三段体感完整;
  • 循环任务加最大执行次数、失败指数退避、连续失败熔断与告警,避免坏任务无限空转;
  • 与审批门禁联动:涉及外发(邮件、短信、发布)的定时任务注册时需走审批,"定时"不该成为绕过风控的后门;
  • 与事件溯源打通:任务注册、触发、取消全部事件化,定时行为成为会话日志的一部分,审计与回放自然覆盖。
定时工具
https://clxhxhhr.top/posts/4726/
作者
clxstart
发布于
2026-10-13
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。