
上午刚把线上一个 Agent 服务从“只能等人问话”改成“每天自己找活干”下午就有同事跑来问我是怎么做到的。他那边的情况我太熟了模型能力不差Skill 也写了不少可 Agent 永远处于被动状态——用户不问它就不动系统里一堆定时任务需求只能靠外部脚本先顶着。这其实不是个例很多 Agent 项目做到中期都会撞上同一个瓶颈怎么让 Agent 在没有对话输入的时候也能按照计划主动执行一批任务。我这次改造的核心就两件事把 Agent 的“单项能力”封装成 Skill再把 Skill 挂到定时调度器上让它像闹钟一样到点自动跑。这里面的坑比想象中多尤其是分布式环境下定时任务重复执行、Agent 上下文缺失、任务跑飞没有反馈这几个问题搞不定的话整套系统会变成事故现场。这篇文章我把完整思路、实现方案和踩坑记录写出来给正在做 Agent 自动化的朋友一个可以直接参考的版本。1. 为什么 Agent 需要“没人说话也能干活”1.1 对话式 Agent 的被动本质早期做 Agent我们习惯把交互入口设计成“用户提问 → Agent 回答”。这个模式没有错但它有两个先天缺陷。第一个缺陷是Agent 的利用率完全取决于用户的活跃度。白天有人问问题它就在跑到了凌晨或者节假日整个服务就在空转。你说省钱吧资源倒是省了可那些本该在低峰期处理的脏活累活比如日志归档、报表预生成、缓存预热全都得靠人工写 cron 脚本去补Agent 在这个过程中反而成了局外人。第二个缺陷更隐蔽很多任务是“周期性发生”的而不是“用户主动提出”的。比如每天早上九点要汇总前一天的销售数据、每个小时要巡检一遍服务健康状态、每周一要把竞品页面上的价格变化整理成报告。这类任务你不可能指望用户每次都手动输入一句“帮我巡检一下”来触发它需要的是 Agent 自己有“时间观念”到点了就该主动行动。这里的本质是Agent 不应该只是一个“知识问答机器人”它应该是一个能独立打工的数字员工。数字员工的标志就是——没人盯着的时候它也知道自己该干什么。1.2 定时任务让 Agent 从“应答者”变成“执行者”把定时任务和 Agent 结合起来听起来像是用“定时器调接口”这种老办法但实际上区别很大。普通定时任务脚本是死的到点执行固定逻辑而 Agent 版本的定时任务到点后是带着一个“目标”醒来自己判断需要调用哪些 Skill、按照什么顺序、遇到异常如何处理。举个例子。我用传统脚本写一个“每日巡检”任务大概是这样的逻辑请求几个固定的健康检查接口检查返回值是不是 200不是就报警。这套东西能用但非常脆弱——新增一个服务你得改脚本异常的种类一变脚本的分类逻辑就不够用了。换成 Agent 加 Skill 的组合后定时任务到点会向 Agent 注入一个指令“现在开始每日巡检重点看核心服务的响应时间、错误率、资源水位发现异常先定位根因再输出处理建议”。Agent 收到指令后会自己决定调用“服务状态获取 Skill”“日志检索 Skill”“指标分析 Skill”最后生成一份巡检报告推送到钉钉群。整个过程没有一行固定调用逻辑全是 Agent 根据当前实际情况动态规划出来的所以环境和业务逻辑发生变化时它的适应能力远超传统脚本。这个转变的核心是把“定时触发”和“任务执行”解耦了。定时调度器只负责在正确的时间点把“工作指令”投递给 Agent而 Agent 负责拆解和执行。调度器不关心 Agent 具体怎么干活Agent 也不用担心自己什么时候被叫醒两者各司其职整个系统瞬间就灵活起来。2. Skill把“干活能力”封装成可复用模块2.1 Skill 到底是什么和普通函数、插件有啥区别我在团队里推 Skill 这个概念的时候总有人问“这不就是个函数吗”严格来说Skill 是函数的超集它不只是“一段可执行的代码”而是“一份包含触发条件、执行逻辑、输入输出约定、使用说明的完整工作包”。在 OpenAI 的 Tool Calling 体系里Skill 对应的就是带 JSON Schema 的 function。但到了实际工程中Skill 还应该包含更多内容技能描述让模型知道这个技能什么时候该用、参数定义每个参数的格式和含义、执行器具体干活的后端逻辑、以及可能用到的一些辅助资源Prompt 模板、参考文档、上下文数据源。我用一个生活化的类比来解释函数就像一把螺丝刀它只会“拧螺丝”你给它什么螺丝它就拧什么而 Skill 是一个“工具箱加说明书”它不仅包含螺丝刀本身还告诉你哪些情况下该用十字口、哪些情况该用六角扳手、拧到什么力度算到位、拧花了怎么处理。模型在使用 Skill 时能看到这个“说明”所以它能做出更合理的判断。正因如此Skill 的设计质量直接决定了 Agent 在无人值守状态下干活的上限。定时任务把 Agent 叫醒后模型能不能准确选对 Skill、传对参数全靠你提前把 Skill 描述写清楚。2.2 设计 Skill 的边界与输入输出约定在给定时任务设计 Skill 时我总结出三条边界原则踩过坑之后觉得必须一开始就定下来。第一Skill 必须是“可独立验证”的。意思是你把它单独拎出来喂给模型不给任何其他上下文模型也能知道它是干什么的、什么时候该调用它。很多新手写的 Skill 描述是“处理数据”模型根本不知道这个 Skill 适合处理什么类型的数据、数据从哪来、结果送到哪去到定时任务真正跑起来的时候模型要么选错 Skill要么漏选。第二Skill 的输入参数要尽量减少“隐性知识”。我踩过一个很典型的坑写了一个“生成日报 Skill”输入参数里有个“report_date”描述只写了“报告日期”结果模型自动传了当前时间而不是“业务数据日期”。后来我把参数描述改成“业务数据所属日期格式 YYYY-MM-DD默认取 T-1 日节假日顺延到最近工作日”错误率立刻降下来了。第三Skill 的输出必须有结构。自由文本的输出在对话场景下没问题但在无人值守的任务场景里后面可能还有别的 Skill 依赖它的输出或者需要把结果渲染成报表、推送到 IM。所以我在设计 Skill 时统一采用 JSON 结构返回字段固定模型只负责填充不负责临时发挥格式。2.3 Skill 的注册与加载从 Spring AI 到 Codex Skill 的借鉴Skill 的注册和管理方式在不同框架里差异很大。我用的技术栈以 Java 为主Spring AI 的 Tool 机制用得比较多最近也在看 Codex Skill 那套文件组织方式它把 Skill 定义成独立目录里面有 SKILL.md 描述文件和对应的脚本这种约定对团队协作特别友好。在 Spring AI 里注册一个 Skill 实际上很简洁。我需要定义一个类在方法上加上 Tool 注解然后写好描述即可。比如一个读取数据库指标数据的 Skill 长这样Component public class MetricQueryTool { Tool(description 查询指定业务指标在某个时间范围内的值。参数 metricName 为指标名称startTime 和 endTime 为 ISO 格式时间字符串。返回 JSON 数组包含时间和值。) public String queryMetric(String metricName, String startTime, String endTime) { // 实际查询逻辑可以走 JDBC、InfluxDB、Prometheus 等 return metricDataService.query(metricName, startTime, endTime); } }要让定时任务环境下模型也能“看得到”这批 Skill还要把它们注入到 Agent 的 ToolCalling 管理器里并保证每次任务触发时使用的是同一个注册好的 Skill 集合。这个集合最好通过配置中心下发因为定时任务的 Skill 列表和聊天场景的 Skill 列表经常不完全一样。比如“查询数据库 Schema”这个 Skill在线聊天时我不想暴露给所有用户但定时任务跑数据质量稽核时需要用到那就得分开注册按场景加载。3. 定时调度从“到点执行”到“把 Agent 叫醒干活”3.1 单机环境下够用的方案如果你们的 Agent 服务还处在单机部署阶段定时调度不需要引入太多重量级组件先把最朴素的方案用扎实才是最实际的。Java 生态里最基础的是 ScheduledExecutorService它支持简单的延迟执行和固定频率执行但要对 cron 表达式做解析就比较吃力所以大多数项目会直接上 Quartz。Quartz 的 JobDetail 加 Trigger 模型非常经典我们可以把“触发 Agent 干活”定义成一个 Job在 execute 方法里调用 Agent 的入口方法。public class AgentScheduledJob implements Job { Override public void execute(JobExecutionContext context) throws JobExecutionException { String taskId context.getMergedJobDataMap().getString(taskId); String instruction context.getMergedJobDataMap().getString(instruction); agentTaskService.executeTask(taskId, instruction); } }这一层的重点是Quartz 的 Job 里不要写任何业务逻辑只做“触发”。真正执行 Agent 任务的过程比较耗时一定不能占住 Quartz 的工作线程否则后续任务会被阻塞。我的做法是在 execute 里把任务提交到独立的线程池再用 CompletableFuture 回调来记录结果。到了 Spring Boot 项目里还有更省事的 Scheduled 注解配合 EnableScheduling 使用代码量更少适合任务量不大、管理要求不高的团队。但它的短板也很明显没有持久化、没有失败重试机制、任务执行状态不透明。所以它只能算“起步方案”想做得严谨一些还是得往分布式调度方向走。3.2 分布式环境选 XXL-Job 还是自研生产环境里 Agent 服务一般会做多节点部署这时候单机定时器直接报废。原因很简单同一个 cron 表达式在每个节点都会触发一次Agent 任务就重复执行了。要解决这个问题需要引入分布式调度框架让任务只在集群中的某一个节点上执行。业界成熟的方案有 XXL-Job、ElasticJob、Quartz 集群版等。我自己的项目用的是 XXL-Job因为它部署简单、可视化控制台做得不错对“分片广播”“失败重试”“动态调整 cron”这些需求支持都很直接。Spring AI 服务只需要引入 xxl-job 的 client 依赖然后在配置中心注册好执行器控制台上添加任务时选择对应的 JobHandler 就行。用 XXL-Job 的好处不只是去重更重要的是“可观测”。控制台能看到每次触发的耗时、执行日志、成功失败状态出问题的时候不用去服务器上翻日志这个体验比裸写 Quartz 好太多。如果你的团队不想引入一套额外组件也可以自研一个轻量调度器核心就是往数据库里放一个任务表用一个轮询线程扫表到期就把任务投递给 Agent。这个方案做起来不难但你要自己想清楚分布式锁、失败补偿、任务状态迁移这些细节工作量并不小。我的建议是除非有强定制需求否则直接用成熟框架别重复造轮子。3.3 触发 Agent 的两种模式直接调用与消息队列定时器和 Agent 之间怎么对接我试过两种模式各有适用场景。第一种是“同步直接调用”。定时任务触发后直接调用 AgentService 的 executeTask 方法等 Agent 跑完全程再返回结果。这种模式实现简单适合任务执行时间短、无状态、不需要跨系统协调的场景比如每 10 分钟刷新一次某个指标的缓存。第二种是“消息队列解耦”。定时任务触发后只把任务指令封装成一条 MQ 消息发到队列里然后立刻返回。真正负责执行 Agent 的消费者从队列里取消息再开始干活。这个模式的好处是任务量大时可以靠消费者扩容来吞吐任务执行过程中如果 Agent 服务重启了消息还在队列里恢复后可以继续消费。我强烈建议在线上环境用第二种。因为 Agent 任务往往不是秒级完成的——调模型、调工具、写报告一套跑下来动不动几十秒甚至几分钟。如果同步调用的过程中服务发布重启任务直接就断了。走 MQ 的话消息不丢Agent 重启之后还能把没干完的活捡起来鲁棒性高了一个档次。4. 核心难题定时任务重复执行与分布式锁4.1 重复执行是怎么来的即使你用了 XXL-Job也只能解决“定时调度层”的重复触发问题。到 Agent 任务执行层重复执行依然防不胜防。最常见的来源是“超时重试”。Agent 任务调模型接口模型返回超时了从业务层面看这次任务没跑完需要重试但实际上模型可能已经执行完并写好了结果只是响应回来时时延太高。你重试一次任务就多执行一次。如果任务写的是幂等操作倒还好可很多 Agent 任务是把结果写入数据库、推送消息、发送邮件这类操作天然不具备幂等性重复执行就会产生重复数据、重复通知。还有一类重复是“任务恢复”产生的。某个节点执行到一半宕机了调度中心检测到任务中断把同一个任务重新分给另一个节点执行。如果旧节点其实已经完成了大部分工作新节点再来一次就是重复劳动。4.2 RedisTemplate 实现分布式锁value 存当前执行批次要保证同一个时刻只有一台机器在执行同一个 Agent 任务分布式锁是最直接的手段。市面上有不少用 RedisTemplate 写锁的文章但很多代码细节是有问题的尤其是锁的 value 处理非常关键。我见过很多教程让锁的 value 存固定字符串比如 lock。这么写有个隐患如果线程 A 获得锁后执行时间过长锁自动过期了线程 B 紧接着拿到锁开始执行。这时候 A 终于执行完走到释放锁的步骤它用 del 命令直接把锁删掉——可这个锁现在是 B 的结果就是 A 把 B 的锁误删了后续 C 又进来拿锁整个锁机制形同虚设。正确的做法是value 必须是“当前执行批次”的唯一标识不能是固定字符串。我习惯用 UUID 生成一个 executionId或者直接用当前节点的 IP任务ID时间戳。释放锁之前先比对 value 是否还是自己的 executionId是才能删不是说明锁已经过期被别人持有了这时候不能删。对应到 RedisTemplate这段代码可以参考public boolean tryLock(String lockKey, String executionId, long expireSeconds) { // 注意必须用 setIfAbsent并且同时设置过期时间不能先 setnx 再 expire return redisTemplate.opsForValue() .setIfAbsent(lockKey, executionId, Duration.ofSeconds(expireSeconds)); } public void unlock(String lockKey, String executionId) { String currentValue (String) redisTemplate.opsForValue().get(lockKey); // 只有 value 匹配时才删除避免误删其他执行批次持有的锁 if (executionId.equals(currentValue)) { redisTemplate.delete(lockKey); } }这里有个细节值得单独强调setIfAbsent 时必须把过期时间作为参数一起传进去。这么做是为了保证“加锁”和“设置过期时间”的原子性。如果分两步走先 setnx 再 expire一旦进程在两步之间崩溃锁就没有过期时间会变成永不释放的死锁后续所有任务都会被卡住。关于“value 为当前日期”的说法我在热词里也看到了本质上大家关注的是“用执行批次或时间戳作为锁的唯一标识”。我习惯用 UUID 而不是日期因为日期精度只能到天同一天内多个任务重试时会撞车UUID 全局唯一绝对安全。4.3 锁过期而任务没跑完怎么处理锁过期时间设置多少是个需要拿捏的事。设置得太短Agent 还没干完活锁就被自动释放了其他节点会趁机进来插一脚设置得太长一旦节点宕机锁要等很久才能自动释放影响任务恢复速度。我一般按任务的历史 P99 耗时再放一个余量来设置比如任务通常 30 秒跑完我把锁过期时间设为 120 秒。但这样治标不治本总有任务会慢到超出预期。更稳妥的方案是“看门狗续期”。拿到锁的线程启动一个守护线程每过锁过期时间的三分之一就检查一次任务还在不在跑、锁还在不在自己手上在的话就把过期时间重置。这个逻辑可以参考 Redisson 的 watchdog 实现自己写也不复杂只需要一个 Daemon 线程加定时续期。但续期也要有个上限。我见过一个任务因为下游接口阻塞看门狗无限续期结果锁持有了一整天严重影响其他任务。所以实际实现时我会加“最大持有时长”超过这个值就主动中断任务并释放锁宁可这一轮跑失败也不能让整个调度线卡死。4.4 幂等设计锁失效后的最后防线分布式锁不是万能药它只能防住“同时并发”防不住“先后重复”。什么叫先后重复就是第一个节点拿锁跑完了锁释放了但因为它返回结果时网络超时调度中心判定任务失败过几秒钟又触发了一次重试这时候第二个节点也会正常拿到锁、正常执行一遍。要拦住这种重复还得靠任务本身的幂等设计。我总结了一个套路每个 Agent 任务都可以定义自己的唯一业务键比如“日报生成任务的业务键是 2025-06-18”在写入结果之前先去数据库查这个业务键有没有已经生成好的结果有就直接返回没有才继续执行。如果任务涉及多步写入比如先写报告再发通知那就要用本地事务表或者消息表把这些步骤串起来每一步都记录执行状态重试时只补跑未完成的步骤不能从头再来。这个设计和传统的“落库 补偿”思路一致只是把执行者从普通代码换成了 Agent核心思想没有变。5. 从“到点”到“真干活”上下文组装与记忆5.1 定时任务如何给 Agent 组好 Prompt定时任务触发 Agent 干活和用户对话框里触发 Agent 干活最大的区别是“没有一段自然的对话上下文”。用户提问时前面可能还有几句聊天记录可以当上下文定时任务触发时冷冰冰地来一句“开始巡检”模型很可能一脸茫然——巡什么检从哪开始标准是什么所以给定时任务设计“指令模板”是必不可少的一步。这个模板要包含四块内容角色设定、任务目标、参考数据、输出要求。拿每日巡检来说我组的指令大概是这样的你是生产环境巡检助手。当前时间是 {current_time}本时段值班负责人是 {owner}。 请执行以下巡检任务 1. 调用 getServiceHealthStatus 获取所有核心服务的健康状态。 2. 对异常服务调用 getRecentErrorLogs 查询最近 1 小时错误日志。 3. 分析异常根因输出严重级别和建议处理动作。 4. 将结果提交到 sendInspectionReport消息格式按 JSON。 注意所有推断必须基于工具返回的数据不允许编造指标。这段指令看起来普通但每句话都有用途。角色设定约束模型的语气和立场任务目标让模型知道按什么顺序调用 Skill参考数据让模型不会凭空发挥输出要求则保证了结果能被下游系统消费。要注意的是指令里不写死具体选项而是要留给 Agent 自己判断。比如“获取所有核心服务状态”并不是在指令里列出所有服务名而是让 Agent 通过一个“服务注册查询 Skill”去获取最新列表。这样服务增减时不需要同步改定时任务的配置Agent 每次都能拿到最新清单。5.2 轻量记忆方案让 Agent 记得上次干到哪无人值守场景下Agent 经常会遇到“跨批次”的任务比如数据同步任务一天要同步到一万条数据但单次能同步一千条那就要分十次完成。如果不对上一次的执行进度做记忆下一次定时触发时 Agent 会重新从第一条开始前面的活全白干了。我目前用的是一个非常轻量的 KV 持久化方案Redis 加一张元数据表。每次 Agent 执行任务前会调用一个 getTaskState(topic) 的 Skill把当前任务的执行进度 JSON 取出来步骤执行完后再调用 saveTaskState 把新进度写回去。这个状态里我主要记三样东西已完成到哪一步、本次处理的批次键、以及一些必要的现场快照。这个方案要注意一个坑Redis 里的状态只能当缓存不能当唯一真相。因为 Agent 任务跑着跑着可能崩掉崩掉后 Redis 里的状态是上一次写入的不一定反映真实情况。所以我会同时把执行日志落库任务重启时先扫描日志里最后一条成功的记录以它为准恢复进度。5.3 执行结果反馈通知、日志、工单Agent 定时任务跑完了不能就“自嗨”完了一定要把结果主动送出去。这个道理是我第一版被业务骂了一顿才明白的——任务执行失败但日志文件安安静静躺着没人看等于没跑。我给每类任务都定义了反馈通道。日常巡检结果推送到运维钉钉群异常告警直接电话加短信数据报表发送到订阅邮箱如果 Agent 在任务中发现了需要人工审批的事件就自动创建一条待办工单。实现上就是封装一个 Notification Skill模型可以在任务流程中自主调用。反馈的内容也要结构化。不能只发“巡检完成”要让接收方一眼看到重点异常服务有几个、风险等级是什么、建议动作是什么、有没有需要人介入的。这块我在生成通知时会让模型按固定的 Markdown 模板输出一定要把最重要的结论放在最前面细节放后面否则一群同事盯着手机上的长文会疯掉。日志链路也同样重要。我给每次定时任务都分配一个 traceId贯穿调度触发、Agent 执行、Skill 调用、结果推送全流程。排查问题的时候对着 traceId 一查整条链路在哪里卡住、哪一步失败一目了然。6. 常见问题与排查技巧实录6.1 任务到点了却没执行这是最让人头大的问题第一反应往往是“调度器是不是挂了”但实际查下来大部分情况是下面几种。第一cron 表达式时区没对齐。XXL-Job 控制台和 Agent 服务所在机器的时区不一致任务配置的是北京时间但执行器在 UTC 环境里跑一差就是 8 小时看起来就是“没到点”。这个排查最简单看控制台最近触发记录有没有比预期早或晚 8 小时的记录。第二执行器 IP 自动注册到了错误的网络段。多网卡机器上XXL-Job 客户端可能注册了内网 IP而调度中心访问不到这个 IP导致任务一直处于“调度失败”。我这边遇到过 Docker 容器里注册了 172.17.x.x 地址调度中心在宿主机网络里根本访问不了。第三JobHandler 名称对不上。控制台配置的 JobHandler 和代码里 XxlJob(xxx) 的 value 不匹配任务报“job handler not found”触发直接就失败了。这种情况控制台日志里会有明确报错定位不算难只要你第一时间去看日志。6.2 Agent 执行到一半超时Agent 任务整体超时我复盘后总结了三个高频原因模型接口慢、工具调用慢、上下文太长。模型接口慢最无解只能靠超时重试和降级策略兜底。我的做法是在模型调用层封一层超时控制单次模型调用超过 60 秒就快速失败转用备用小模型处理或者直接标记任务失败并进入重试队列不让它无限期占着线程。工具调用慢往往和外部系统有关。比如查询某个服务状态的 Skill如果服务本身不稳定接口不返回Agent 就会被卡住。我在实现工具时强制规定所有 Skill 必须自带超时比如 HTTP 类操作统一设 10 秒超时数据库查询设 30 秒超时。Skill 层不超时的话Agent 整体超时无从谈起。上下文太长这个很多人会忽略。定时任务连续跑了很多次如果把历史执行记录全塞进上下文token 数会爆炸模型处理速度急剧下降响应时间拉长。我的做法是每次任务开始前对上下文做裁剪只保留当次任务的指令、最近一次执行状态、以及与当前步骤相关的数据历史记录一律改从向量数据库里按需检索。6.3 一条任务被多个节点同时执行明明用了 XXL-Job怎么还会同时执行我排查过一次最后发现锅在“手动执行”和“自动调度”撞车了。运维同学在控制台手动触发了一次任务同时调度中心按 cron 又自动触发了一次而代码里没做任务级去重两边各干各的。要堵住这个口子不能只靠调度中心。我现在的做法是任务入口统一走一个 TaskExecutionService在这层做两件事——先抢分布式锁再校验任务状态表里当前是否已经有正在执行的同业务键任务。有了这两道防线不管请求来自调度中心、手动触发还是消息队列都会先过一遍“这个任务当前允不允许再跑”的检查。还有一种情况是同一个 JobHandler 被配置到了两个执行器分组里两个分组同时触发。这个属于配置问题控制台里把任务只绑定到一个执行器分组就能解决不用过分纠结。6.4 新手避坑清单做无人值守 Agent 这段时间我最大的体会是模型能力已经不是瓶颈工程细节才是。下面这份避坑清单是我拿真金白银换来的经验新手照着做能少踩很多坑。锁的 value 必须用唯一批次标识不能存固定字符串释放前先比对再删除。RedisTemplate 加锁时setIfAbsent 和过期时间必须原子提交。Skill 描述写到“模型不需要额外猜测”为止否则无人值守时出错概率直线上升。定时任务触发后不要同步阻塞等待 Agent 完成用 MQ 解耦或独立线程池。任务重试必须考虑幂等业务键去重是最简单有效的防线。上下文在每次任务前做裁剪别把历史记录一股脑塞给模型。日志链路必须贯穿traceId 没有的话线上排查会让你生不如死。最后再分享一个小技巧。我写定时任务指令模板时会在指令末尾加一句“如果上述步骤无法完成请直接报告卡住的具体位置和原因不要尝试编造结果”。这句话看似简单实际对无人值守场景提升巨大。原因也自然——Agent 在没人盯着的时候一旦强行编造结果整个自动化流程就失去了可信度允许它“说不知道”反而让系统更容易被信任也更符合工程上“可观测、可回放、可审计”的底线。