Featured image of post 给'脚本 + Cron + Agent'的黑盒自动化装上控制面:12 平台真实调研

给"脚本 + Cron + Agent"的黑盒自动化装上控制面:12 平台真实调研

我的自动化靠 17 条 cron 和一堆脚本撑着,工作流崩了几天才发现。这次把 Kestra、Prefect、Windmill、n8n、Dagster、Temporal、Airflow、Argo、Hatchet、Inngest 全部用官方资料核实了一遍,结论:我缺的不是工具,是一层控制面。

结论先讲:我缺的不是另一个编排工具,而是一层"控制面"(Control Plane)——统一回答"哪个流程、哪个版本、哪次运行、哪一步挂了、重试了几次、下次什么时候跑"的协调层。cron 只负责"到点喊一声",喊完就不管了。实地调研 11 个平台后,我的选择:Windmill 首选、Kestra 并列备选,n8n/Temporal/Argo 明确不选,Hermes 归位成"被调度的 Agent",而不是调度者。

Workflow Control Plane 架构全景
控制面架构全景:黑盒 cron → 控制面 → 执行层与 Agent 层 → 分层可观测|本文

先交代背景:我的自动化家底长什么样

我的自动化体系是典型的"脚本堆积"形态:17 条 cron、13 个 systemd timer、17 个 Docker 容器、分散在六七个目录里的 Python/Shell 脚本,外加一条 AI 内容管线(RSS → 抓取 → LLM 判断 → 改写 → 发布 → Telegram 通知)。它跑得起来,但有一堆结构性问题:

  1. 工作流非常黑盒——cron 喊完脚本,脚本里发生什么没人知道
  2. 工作流脚本和监控脚本是两个体系,各记各的日志
  3. 有次管线崩了,三天后我才从"怎么公众号没更新"倒推出来
  4. 同一流程出现 workflow_final / workflow_final2 / workflow_new2 这种命名,生产环境跑的到底是哪版,说不清
  5. Agent 工作流尤其难追踪——步骤发生在 LLM 的 token 里

我把这些痛点归成四个正交问题:执行语义缺失(没有 run/task/状态的概念)、信号缺失(没有"失败即推")、世界状态失真(版本与部署脱节)、Agent 黑盒。

先分层,再选型

这次调研我坚持一个认知:不要把这些平台塞进一个排行榜,它们根本不在同一层。

定义平台
Automation / 集成事件驱动的连接器编排n8n
Workflow Orchestrator(控制面)调度+编排+DAG+运行状态+版本+审计Kestra、Windmill、Prefect、Airflow
Data Orchestrator以数据资产/血缘为中心Dagster
Durable Execution状态持久化、精确恢复Temporal、Hatchet、Inngest
Agent Runtime承载 LLM 会话/工具调用Hermes、Claude Agent SDK

我要的是第二层——控制面。分完层之后很多"要不要上"的问题自己就解了:n8n 不是不好,是它不在我要的那层。

调研方法论:全部事实当场联网核实

结论要经得起复核,所以这次没凭印象流:

  • 11 个平台各派一个调研 agent,逐家核对 GitHub 最新发布、官方文档、架构文档,版本号全部锁定到 2026-08-22
  • Top 6 再派对抗核验 agent,专门挑错。真挑出了问题:Temporal 最新版发布时间的错误——这种坑靠"印象"是查不出来的
  • 核验也是递归的:初轮核验曾认定"Prefect secrets 明文存数据库",第二轮对照官方 v3 文档后发现与官方的"encrypted at rest(静态加密)“口径矛盾——我在正文里改正了这个错,并把 Prefect 的分数从 7.9 回调到 8.0(排序不变)。能抓别人的错,也要能抓自己的错
  • 文章发布后有群友追问"多步骤任务”,于是补了第二轮:六个平台的多步骤语义全部从官方一手材料(schema 页 / OpenFlow 规范 / 官方仓库 docs 源码)核实,四个核验 agent 交叉裁定,我又对关键语法逐字抽查了原文——这轮真抓出错的是我自己:之前 POC 里的 {{ outputDir }} 写法在现行文档已标记弃用(改用 outputFiles),脚本任务镜像字段也从 docker: 换成了 containerImage
  • 自己的机器也做了一次只读盘点,迁移计划基于真实家底而非想象

核到的最新稳定版:Kestra 1.3.30(Apache-2.0)、Windmill v1.795.0(AGPL-3.0)、Prefect 3.8.3(Apache-2.0)、n8n 2.35.7(Sustainable Use License)、Dagster 1.13.19、Temporal v1.31.2、Airflow 2.10.3、Argo v4.1.2、Hatchet v0.101.27、Inngest 1.43.0。

决赛圈:加权打分与修正

我按自己的诉求设了权重:脚本复用 20%、编排 15%、可观测 15%、版本化 10%、Agent 工作流 10%、其余分散。加权结果(十分制):

平台加权分一句话
Windmill8.2脚本即一等公民,Git Sync 原生 → 首选
Kestra7.7一个 YAML 声明全部运行语义,Apache-2.0 → 并列备选
Prefect8.0Python 体验最好,版本化是隐性短板 → 第三
n8n8.3分数最高,但 license 与 JSON 工作流让 git 评审成逆过程 → 不做控制面
Dagster8.2强,但资产范式与我内容管线错配
Temporal7.9retry 语义冠军,代价是所有脚本重写成 activity
Airflow / Argo7.6 / 7.5运维范式太重(Argo 必须 K8s)
Inngest8.0补齐调研后(脚本须包 step.run,42 LOC + worker)
Hatchet7.6补齐调研后(脚本须包 SDK task,8-12 LOC/条 + 常驻 worker)

分数解释权要留给判断:初版榜单里 Inngest/Hatchet 拿了 8.3+,但那是"没算 SDK 包装成本"的分。我随后为这两家做了第二轮对抗核验 + 迁移成本实测——Hatchet 要把每条 shell 脚本包成 SDK task(实测 8-12 行胶水 + 常驻 worker 容器,而且 cron 无时区、无暂停、宕机丢调度),Inngest 要包 step.run handler(42 行胶水 + worker)——实测后修正为 7.6 和 8.0。更戏剧的是,复核还推翻了我初版自己的一个核验结论:Prefect 的 secrets,官方 v3 文档明确说"encrypted at rest(静态加密)",我此前写的"明文存库"与之矛盾,已改正并把 Prefect 回调到 8.0(排序不变)。权重表测不到的成本,只能用实测补;连核验本身都要被再核验——这恰好反证了 Windmill/Kestra 把脚本当一等公民的价值。

十平台加权评分条形图
十平台加权评分(2026-08-23 补齐修正):暖色为决赛圈;‡ 为包装成本实测后修正|版本/许可基于 2026-08-22 官方资料核实

为什么明确不用那几个

  • n8n:最强的拖拽集成平台,500+ 连接器。但我的 git 版本化诉求撞上它的 JSON 工作流定义,diff 和评审都难;Sustainable Use License 也让长期依赖不舒服。它是 Automation 层,不是控制面。
  • Temporal:Durable Execution 事实标准,跨小时级任务精确续跑无出其右。但我的管线没有这个硬需求,而它的代价(全部 SDK 化 + 多服务运维)是确定的;另一个常被忽略的扣分项:它的 Web UI 只显示调度事件流,应用日志不进去,每步日志要自建导出——详见下文多步骤小节。出现硬需求时只包那一条子流程。
  • Argo:很端正的 K8s 工作流,但我在 WSL2 单机 + docker-compose 上生活,为它上 K8s 是本末倒置。

Hermes 的归位:Agent,不是掌舵人

我的 Hermes(多模型 TG 网关)有内建 cron、有 WebUI,但它没有 workflow DAG、没有版本化的工作流、没有 run 语义——它天生是 Agent Runtime。正确的姿势:控制面把它当"agent 任务"调用(HTTP/CLI/MCP),调度、重试、超时、版本全部留在控制面。这样今天用 Hermes,明天换 Claude Code headless,控制面一行不改。

群友的追问:多步骤任务到底长什么样

文章发出去后,群友一句话点中了我的软肋:你给的例子只有"定时触发 → 跑一个脚本 → 重试规则 → 失败规则",没有 GitHub Actions 那样的"一个任务多个步骤"。这里的责任在我——我为了证明"纳管成本低",挑了最短的片段,结果把控制面最核心的价值藏了起来。这一节补回来,并正面回应他提出的心智模型:任务应该分步骤定义,从触发那一刻起,每一步都有运行日志,能回答"现在跑到第几步、挂在哪一步、每一步是怎么跑的"。

我先给结论:这个模型不是"额外的需求",正是控制面的定义。Kestra 与 Windmill(以及 Prefect)都原生满足;真正反常识的是,若把"每步日志开箱可查"当硬指标,retry 之王 Temporal 反而第一个出局——细节在后面讲。

五个问题,运行视图一次答完

把群友的心智模型拆成五个问题,对照两个决赛圈平台的运行视图:

群友的问题Kestra(Execution / TaskRun 两级视图)Windmill(Run 视图)
本次触发了吗计划表 + Execution 列表:每次触发生成一个 execution,记录触发方式与时间Schedules + Runs 列表:历次运行和下次计划时间
任务完成了吗Execution 状态:SUCCESS / WARNING / FAILEDrun 状态与总耗时
现在跑到第几步每个 TaskRun 有独立状态,实时更新运行树逐 module 实时标识
挂在哪一步失败 TaskRun 单独标出,同页可看它上游成功到哪失败 module 标红,上游绿、下游灰
每步怎么跑的每个 TaskRun 独立 logs 页 + attempt 次数 + inputs/outputs 预览每个 module 独立日志 + 重试次数 + 结果值

表中能力全在两家社区版可用(我逐条以官方文档核实过),不是 Enterprise 专属。

配上一次运行的全景(第 2 步失败时你看到的样子):

一次多步骤运行的全景:每步独立状态/日志/重试/输出
多步骤运行语义:五连问与每步状态对照|2026-08-23 官方文档核验

这五个问题里,cron 只能回答"下次什么时候触发",其余四个全是黑盒——这就是"控制面"三个字的全部意义。

多步骤真身:同一个管线,两种写法

Kestra——一个 YAML 声明四个步骤,每步自带重试与超时,步骤间用 outputFiles 传产物,重试耗尽后 errors 分支点名到步发告警:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
id: rss_news_pipeline
namespace: lynx.news
triggers:
  - id: hourly
    type: io.kestra.plugin.core.trigger.Schedule
    cron: "0 * * * *"
tasks:
  - id: fetch                          # 步骤 1:抓取
    type: io.kestra.plugin.scripts.python.Script
    containerImage: python:3.12-slim
    timeout: PT10M
    retry: { type: exponential, interval: PT30S, maxAttempts: 3 }
    outputFiles: ["raw.json"]
    script: |
      import json
      rows = fetch_feeds()             # 现有抓取逻辑,原样搬进来
      json.dump(rows, open("raw.json", "w"))
  - id: dedupe                         # 步骤 2:去重
    type: io.kestra.plugin.scripts.python.Script
    containerImage: python:3.12-slim
    retry: { type: constant, interval: PT1M, maxAttempts: 2 }
    outputFiles: ["deduped.json"]
    script: |
      import json
      rows = json.load(open("raw.json"))   # 上游 outputFiles 自动挂进工作目录
      seen, uniq = set(), []
      for r in rows:
          if r["link"] not in seen:
              seen.add(r["link"]); uniq.append(r)
      json.dump(uniq, open("deduped.json", "w"), ensure_ascii=False)
  - id: llm_rewrite                    # 步骤 3:LLM 改写
    type: io.kestra.plugin.scripts.python.Script
    containerImage: python:3.12-slim
    timeout: PT15M
    outputFiles: ["rewritten.json"]
    script: |
      import json
      rows = json.load(open("deduped.json"))
      rewritten = [llm_rewrite(r) for r in rows]
      json.dump(rewritten, open("rewritten.json", "w"), ensure_ascii=False)
  - id: publish                        # 步骤 4:发布
    type: io.kestra.plugin.scripts.python.Script
    containerImage: python:3.12-slim
    retry: { type: constant, interval: PT1M, maxAttempts: 3 }
    script: |
      import json
      publish(json.load(open("rewritten.json")))
errors:
  - id: tg_alert                       # 任何一步重试耗尽 → 告警点名到步
    type: io.kestra.plugin.scripts.python.Script
    containerImage: python:3.12-slim
    script: |
      import requests
      msg = "flow 挂了:步骤 {{ tasksWithState('failed')[0]['taskId'] }},run {{ execution.id }}"
      requests.post("https://api.telegram.org/bot<token>/sendMessage",
                    json={"chat_id": "<chat_id>", "text": msg})

Windmill——现有脚本注册为资源零改动,flow 负责串接;每个 module 的 retry/timeout/数据接续都是模块级字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
value:
  modules:
    - id: fetch
      value: { type: script, path: u/lynx/rss_fetch }   # 现有脚本,零改动
      retry: { constant: { attempts: 3, seconds: 30 } }
      timeout: 600
    - id: dedupe
      value: { type: script, path: u/lynx/rss_dedupe }
      input_transforms: { rows: results.fetch }         # 取上一步输出
      retry: { constant: { attempts: 2, seconds: 60 } }
    - id: llm_rewrite
      value: { type: script, path: u/lynx/llm_rewrite }
      input_transforms: { rows: results.dedupe }
      timeout: 900
    - id: publish
      value: { type: script, path: u/lynx/tg_publish }
      input_transforms: { rows: results.llm_rewrite }
      retry: { constant: { attempts: 3, seconds: 60 } }
  failure_module:                        # 全局失败兜底 → TG 告警
    value: { type: script, path: u/lynx/tg_alert_failure }
schedule:
  cron: "0 * * * *"
  timezone: Asia/Shanghai

步骤形态还能更丰富:并行(branchall)、循环处理(forloop)、以及"跑到某一步停下等人审批"(suspend 挂起,等回复事件再继续)——都是模块级配置,不改变脚本本身。Kestra 一侧有对应物:Parallel 任务,errors 分支里可以放任意脚本/HTTP 任务。

“用最终产物定义任务”:方向对,但要看清边界

群友的第二层主张是"用最终产物定义任务"。按字面去找,最贴的平台其实是 Dagster:它把每个产物显式建为资产(asset),资产之间用依赖连线,每次运行都留下产出记录。这也正是当初我把 Dagster 归到"数据编排层"、不选它做控制面的原因:资产建模的舒适区是数据表/数据集,而我的家底一半是"推一条 TG、重启一个容器、调一次发布 API"这类副作用型步骤——它们没有"产物",硬套资产语义是削足适履。

控制面里的"产物"以轻量形式存在,已经够用:Kestra 每个任务产出 typed outputs(UI 可预览、下游可引用),Windmill 每个模块产出 result(自动持久化,下游用 results.<id> 引用)。于是最终产物的路线很自然:大件产物(生成的 PPT、MP4、图片)由发布步骤自己上传对象存储,控制面负责"到没到那一步、那一步产出了什么、往哪儿去了"——这正是 GitHub Actions 里 upload-artifact 的职责,不必为此引入资产血缘引擎。

迁移原则:先纳管,不改脚本

现在这些脚本一行都不重写。四个阶段:平台已于 2026-08-23 单机 docker-compose 落位(Windmill,localhost:8760),本周把第一条真实管线纳进去跑 7 天;第二到四周迁完高频 cron(vendor_watch、节点探活),每迁一条、同 commit 删掉对应 cron 行;第二个月 17 条 cron 全部纳管、secrets 从 .env 迁入平台、监控脚本也变成带告警的 flow;第三个月接 Langfuse 把 LLM 的 token 花销晒出来。完整报告(含全部评分依据与核验纠错)存在私库。

十条结论

  1. 缺的是控制面,不是工具;2. Windmill 首选、Kestra 并列备选,二选一即可——读者要的"多步骤 + 每步独立日志/重试"是这两家的默认形态,不是加分项;3. Prefect 只在工作流 90% Python 化后才反超;4. n8n 留给"第三方 SaaS 集成"场景,现在不用;5. Temporal 出现跨小时精确续跑硬需求再上(先记住它没有内置日志聚合,每步日志要自建导出);6. Hermes 是被调度的 Agent,不是调度者;7. Langfuse 二期要(token/cost 是控制面盲区);8. Grafana/Loki 初期不装,平台原生 UI + Telegram 告警先跑顺;9. cron 只留系统维护级,业务 cron 一条不新增;10. 平台已在 2026-08-23 落位(单机 compose,localhost:8760),本周只剩一件事:把第一条真实管线纳管、打通失败告警。

调研局限性:每个平台的评分出自单一调研 agent(其中八家过了对抗核验;Hatchet/Inngest 另做了包装成本实测;Airflow/Argo 未过,分数按较宽置信区间读);多步骤语义为 2026-08-23 第二轮补齐(六平台官方文档核实 + 对抗核验,关键语法经官方 schema 页/仓库原文逐字抽查);所有版本与许可均以 2026-08-22 官方 GitHub/docs 为准。参考来源:Kestra 官方文档Windmill 官方文档Prefect v3 文档Temporal Releasesn8n 文档Dagster Releases