跳至正文
来两杯美式
返回

定时是 Agent 平台的另一半:Trigger/Action Registry 与调度原语分层

By 来两杯美式
发布于

上一篇解决了「Agent 是谁」——身份体系让浏览器和 Agent 走同一扇门。但 Agent 平台还有一个和请求处理完全不同的维度:时间

会话式交互只覆盖「用户在场」的场景:用户说「记一下今天的工作」,Agent 记了,会话结束。可用户真正想要的往往是「每天下班前帮我记一遍」「每周五把周报生成好」——这些需求里,触发时刻用户根本不在场。没有这半个维度,Agent 只是一个更聪明的问答框;有了它,才是平台。

从两个烟囱说起

重构前的代码里有两个并存的调度模块:

两个烟囱各自能跑,问题在第三个需求出现时暴露:我们想让 Agent 也能定时执行别的动作(跑一个工作流、调一个 webhook),按既有结构就得写第三个烟囱——第三份 cron 解析、第三份领取逻辑、第三套 MCP 工具。

烟囱的病因是触发和动作耦合NotifySchedule 既是「什么时间做什么」的调度器,又是「怎么发通知」的执行器。解法因此清晰——把两者拆开,中间放一个通用的运行队列:

MCP / REST / tRPC(三个平级入口)


Automation Application Service(统一用例与校验)

        ├── Trigger Registry ── ONCE / CRON(未来 EVENT)
        ├── Action Registry  ── notification.send / work-log.generate-report / …

        ├── Scheduler ── 扫描到期 Automation,只创建 Run
        └── Worker    ── 领取 Run,调 Action 执行,处理续租与重试

用户侧产品概念统一为一条记录:Automation = 触发器 + 动作 + 策略。「工作日 18:30 生成日报」和「每周五 17:00 给我发条提醒」在数据模型里是同一种东西,只是 Trigger 和 Action 的组合不同。

双注册表:谁都不知道对方的全部

整个引擎最重要的设计决定写在依赖规则里,一共六条硬约束,核心是这几条:

  1. Scheduler 不调用任何 Action 执行器。它只做一件事:找到到期的 Automation,创建一条 Run 记录,推进下一次触发时间。事务提交,结束。
  2. Worker 不解析 cron。它拿到的 Run 里已经带着完整快照,照着执行就行。
  3. Action 执行器不更新 Run 状态。它返回一个标准化的 ActionExecutionResult(status + summary + retryable),由 Worker 统一推进状态机。
  4. Trigger/Action 注册表是 schema 和元数据的单一可信源。新增一种 Action 不需要改 Scheduler 一行代码——注册表长这样:
type AutomationActionDefinition = {
  type: string; // "notification.send"
  displayName: string;
  configDescription: string; // 给 Agent 读的配置说明
  idempotency: "STRONG" | "BEST_EFFORT" | "NONE";
  requiredMcpScopes: readonly string[];
  validateConfig: (config: unknown) => Record<string, unknown>;
  execute: (config, ctx) => Promise<AutomationActionResult>;
};

这个设计的检验标准很朴素:新增 workflow.run Action 时,Scheduler 和 Worker 的代码 diff 应该是零。触发器同理——第一阶段只有 ONCECRON,模型为 EVENT 预留了枚举值和 dedupeKey 规则(event:<eventId>),但事件总线一行代码都没写。预留扩展点而不提前实现,是这套设计里反复出现的克制。

Run 是快照,不是引用

AutomationRun 表里最值得注意的三个字段:actionTypeSnapshotactionConfigSnapshotpolicySnapshot

一条 Run 被创建的瞬间,它把当时的 Action 配置和策略整体拷贝进自己。之后用户改了 Automation 的配置、换了收件人、调了重试策略——已经排队和正在执行的 Run 不受影响,执行的永远是生成时刻的意图。

为什么这么设计?因为「定时任务」的本质是用户在过去某个时刻表达的意愿,在未来某个时刻执行。如果 Run 引用 Automation 的当前配置,就会出现「周五 17:00 排队的周报,在 Worker 执行前两分钟被用户改成发日报」的语义混乱——用户停用一个任务前,得先搞清楚有没有「半成品任务」正在引用它。快照语义把这个问题消掉了:改配置只影响未来的 Run,想阻止已排队的 Run,显式停用(未执行的 Run 会被批量标记 CANCELED)。

顺带一个防重细节:每条计划 Run 带唯一键 (automationId, dedupeKey),调度触发的 dedupeKey 是 schedule:<scheduledFor ISO>。多实例同时扫描到同一条到期 Automation,各自都想创建 Run,数据库唯一约束保证只有一条能插入成功——这是比应用层判断更硬的防线。

Redis 锁只是性能优化

多副本部署下,每个实例都会起调度心跳,怎么保证不重复触发?

答案分两层,而且层与层的职责被刻意分开了

关键在陈述顺序:Redis 挂掉,系统照样正确——每个副本都去扫表,CAS 和唯一约束保证不会产生重复的 Run,代价只是多了几次无效扫描。反过来就不成立:如果正确性依赖 Redis 锁,锁服务抖动的那几秒就是重复触发或漏触发的窗口。

「锁是优化不是保障」这句话很多人会背,落地时的检验是问自己:把 Redis 拔了,系统会发生什么? 如果答案是「出错」,设计就是错的;如果答案是「慢一点」,才算把这句话真正落了地。

心跳本身还有几个工程细节值得一提:它是进程内 setInterval 而不是外部 K8s CronJob——少一个部署依赖;用 globalThis 标记防 dev 热重载起出多个定时器;定时器 unref(),不阻止进程正常退出;实例 ID 取 HOSTNAME 回落到 pid-<pid>,让 lease 归属可查。

幂等三档,和一个叫 UNKNOWN 的诚实状态

崩溃恢复是这个领域躲不掉的问题:Worker 执行一条「发通知」的 Run,请求发出去了,进程在写结果之前死了。重启后看到这条 Run 处于 RUNNING 且 lease 过期——重试还是不重试?

重试,用户可能收到两条一样的消息;不重试标记失败,用户以为没发其实发了。两头都不对。我们的答案是把「能不能重试」的判断权交给 Action 自己声明,注册表里那个 idempotency 字段分三档:

档位含义崩溃恢复策略
STRONG下游有幂等键,重试绝对安全自动转重试队列
BEST_EFFORT能确认副作用未发生则重试不能确认时标 UNKNOWN
NONE下游无幂等协议一律标 UNKNOWN,禁止自动重试

notification.send 是 NONE——企业 IM 和邮件的下游没有统一幂等协议,老实承认。work-log.generate-report 是 BEST_EFFORT——报告服务按 owner + 日期做版本控制,重试大概率幂等命中。

于是状态机里有了一个大多数任务系统没有的状态:UNKNOWN——副作用可能已发生,但无法确认。它不进重试队列,Agent 拿到这个状态的解释规则也写死在协议里:「不得自动重试,必须向用户提示重复风险」。UI 上它是独立的警告色。

UNKNOWN 的价值不在功能而在诚实:与其假装失败,不如承认不知道。定时任务系统最大的信任杀手,是用户在「到底发没发出去」的疑问里失去对它的信赖。

权限在时间维度上的两道弯

上一篇的身份体系到这里遇到了新问题:权限是「请求时刻」的概念,而 Automation 是「持久存在」的配置。两道弯:

第一道:双重 scope。 mcp:automation:write 只代表「你能管理自动化这个对象」,不代表「你能做它配的动作」。创建一个发通知的 Automation,还要有 mcp:message:write。而且 automation_list_action_types 只返回当前凭据两项都满足的 Action——权限被收紧的 Agent 看到的动作列表会自动缩短,和能力目录是权限的投影是同一个思想。

这里有个刻意的非对称:停用(set_enabled(false))只要求 automation scope,不要业务 scope。想象 Agent 的消息权限被收回了,它创建的通知自动化还在每小时发消息——这时你希望 Agent 还能干什么?至少还能把这个任务停掉。权限收紧的路径上,「关掉」永远要比「打开」容易

第二道:凭据死了,任务还在。 Automation 创建后归属于用户,不属于创建它的那把 API Key。撤销那把 Key 不会自动停用任务——用户授权的是一个长期配置,不是给某把 Key 的永久代理权。那封禁呢?每次执行前检查 owner 仍然存在且未封禁,被封禁的用户的 Run 记为 SKIPPED(owner_inactive)。这呼应上一篇的结论:封禁是人的事件,不是凭据的事件——但和凭据不同,任务跟着人走。

Agent 怎么用这套东西

MCP 暴露给 Agent 的工具刻意做成「用户意图级」:预览触发时间、创建、更新、启停、手动跑一次、查执行历史。而引擎的内部控制面——tick、领取、续租、强制改状态——一个都不暴露。Agent 不需要知道 Worker 的存在,就像用户不需要知道数据库连接池的存在。

协议里写死的交互规则(这部分与薄壳系列的发现一脉相承——流程知识放服务端协议,不放 Skill):

  1. 创建或改时间规则前,先调 preview 拿未来触发时间;
  2. 向用户复述:名称、明确的触发时间、时区、动作、目标——自然语言日期必须转成明确时间,禁止静默猜时区;
  3. run_now 会立即产生真实副作用,必须获得本轮确认;
  4. 拿到 UNKNOWN 不自动重试,向用户说明重复风险。

第 1 条尤其值得说:预览是时间类系统对 Agent 最重要的工具。人看 cron 表达式会算错,Agent 也会。「每周五 17:00」被写成 0 17 * * 5 还是 0 17 * * 4,预览三次触发时间就能验证——让 Agent 学会先看效果再提交,比教它 cron 语法可靠得多。

设计文档里写了、落地时砍掉的东西

这个引擎有一份相当完整的设计文档(分层、不变量、必测场景都写全了),但落地版做了几处明显的裁剪,每一处都是有意的:

DDD 分层被压平。 设计里是 domain/application/repository/presentation 四层,落地后压成一个 service.ts 加几个协作模块。不是设计错了,是这个规模(两张表、两种触发器、两三个 Action)撑不起四层的间接成本。分层是为变化频率服务的——等 Action 多到注册表需要独立演进时,再拆不迟。

Secret Store 没做完,Webhook 渠道就显式不可用。 定时发 IM 群机器人消息需要存 webhook URL,而 Secret Store(加密存储 + 引用 + 脱敏展示)还没实现。Action 注册表里的处理是:配置校验直接抛「暂不可用,等待 Secret Store 完成」。不是偷偷支持明文 URL,不是注释掉入口,是一个显式的、可搜索的失败。半成品能力的正确形态是「大声地不可用」。

EVENT 触发器预留未实现。 枚举、dedupeKey 规则、文档都留了位,事件总线一行没写。没有真实需求驱动的事件总线,写出来一定是错的——和上一篇薄壳系列里「版本协商等第一个不兼容案例再设计」是同一条纪律。

小结

自动化调度引擎总览:Trigger/Action 双注册表、Scheduler 只创建 Run、Worker 领取执行,幂等三档与 UNKNOWN 状态贯穿全局

  1. 定时能力的关键解耦是触发和动作分离,双注册表让「新增一种动作」的引擎侧 diff 为零;
  2. Run 是快照不是引用,「过去表达的意愿按过去的意图执行」,改配置不影响已排队的任务;
  3. 数据库 CAS + 唯一约束是正确性,Redis 锁只是性能优化——拔掉 Redis 系统应该只是变慢,不是出错;
  4. 幂等三档 + UNKNOWN,让「不知道发没发出去」成为一个诚实的状态而不是被掩盖的失败;
  5. 权限在时间维度上要过两道弯:双重 scope 且「关掉比打开容易」;任务属于用户不属于凭据,但跟着封禁走。

可靠性的底层机制——CAS 领取、lease 续租、崩溃恢复、受控并发——我在任务编排那篇里展开过,这篇刻意不重复。

下一篇离开时间维度,讲 LLM 出口:内部所有要调模型的地方,怎么收口成一个客户端——以及那条被我们亲手标记为 deprecated 的对外代理,教了什么。

本系列下一篇:《收口内部 LLM 调用:一个客户端、错误脱敏、和一条被废弃的代理》


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.把存量系统交给 Agent,先想清楚 API、Skill、MCP 各是什么
  2. 02.存量系统 Agent 化,物理上到底怎么「接」
  3. 03.单点跑通之后,怎么把 Agent 能力沉淀成全公司可复用
  4. 04.DeepSeek Harness:把 Agent 宿主变成可组合的基础设施,企业能拿它做什么
  5. 05.一个系统,两种访客:给浏览器和 Agent 设计同一套身份体系
  6. 06.定时是 Agent 平台的另一半:Trigger/Action Registry 与调度原语分层
  7. 07.Agent 刚才干了什么:审计、调用日志与敏感数据豁免
  8. 08.提示词安全攻防全解析:越狱、注入与信息泄露
  9. 09.给生产 Agent 做体检(一):只用对话文本的黑盒诊断工作流
  10. 10.给生产 Agent 做体检(二):诊断之前,先定义人口——数据质检与风险筛选
  11. 11.给生产 Agent 做体检(三):让 LLM 稳定输出结构化诊断——严格 JSON Schema 实践
  12. 12.给生产 Agent 做体检(四):LLM 输出不完美怎么办——归一化、一次修复与失败隔离
  13. 13.给生产 Agent 做体检(五):不要消息队列——基于数据库的任务编排与可靠性
  14. 14.给生产 Agent 做体检(六):从诊断明细到 PM 能用的报告——评估结果的产品化
  15. 15.AI Agent:从工具到同事,中间隔着一层「自主性」
  16. 16.Agent 评测工程化(一):为什么不能只看平均分
  17. 17.Agent 评测工程化(二):Rubric 不是 Prompt,而是可执行的质量协议
  18. 18.Agent 评测工程化(三):从低分样本到问题簇
  19. 19.Agent 评测工程化(四):红线、一票否决与 1-5 分
  20. 20.Agent 评测工程化(五):让 LLM-as-judge 稳定输出结构化结果
  21. 21.Agent 评测工程化(六):评测前,先把 Agent 输出拆开
  22. 22.Agent 评测工程化(七):从评分报表到 PM 决策工作台
  23. 23.Agent 评测工程化(八):不用消息队列,也能跑长任务
  24. 24.Agent 评测工程化(九):把一次性评测变成持续优化体系

上一篇
Agent 刚才干了什么:审计、调用日志与敏感数据豁免
下一篇
一个系统,两种访客:给浏览器和 Agent 设计同一套身份体系