1. 项目概述:为什么我们需要另一个工作流引擎?

如果你和我一样,在构建数据管道、编排API调用或者设计复杂的AI智能体(Agent)时,已经受够了那些要么过于笨重、要么过于简陋的框架,那么Routilux的出现,可能正是你等待的那个答案。这不是一个试图解决所有问题的“巨无霸”平台,而是一个专注于 事件驱动编排 的Python库,它的核心设计哲学是:用最简单、最直观的方式,将独立的“例程”(Routine)连接成可靠、可观测、可恢复的工作流。

在过去的项目中,我常常面临这样的困境:用Celery做异步任务队列,但编排复杂依赖关系得自己写状态机;用Airflow做调度,感觉像是为了定时跑个脚本而开动了一艘航空母舰;自己手写事件循环和队列,又很快会陷入状态管理和错误处理的泥潭。Routilux的出发点很明确——它认为工作流的核心是 事件 响应事件的例程 。一个例程完成工作后,发射(emit)一个事件;其他监听该事件的例程的“插槽”(slot)被触发,执行它们的逻辑,再发射新的事件,如此循环,构成一个非阻塞的、清晰的数据流图。

这种模式带来的直接好处是 解耦 灵活性 。你的每个业务模块(比如数据清洗、模型推理、结果通知)都可以实现为一个独立的Routine。它们之间通过事件名来连接,而不需要直接引用对方。这意味着你可以像搭积木一样,轻松地重组工作流。今天可能是A->B->C的线性管道,明天就能改成A同时触发B和C的并行模式,或者根据条件动态路由到D。这种“基于事件队列的编排”模型,用一个统一的机制同时处理了顺序执行、并发执行、错误恢复和状态持久化,这正是Routilux的“一招鲜”。

它特别适合以下几类场景:

  • 数据工程师 :需要构建可维护、可监控的ETL/ELT管道,尤其当步骤间有复杂依赖或需要分支合并时。
  • 后端开发者 :需要编排多个微服务或第三方API调用,处理重试、降级和结果聚合。
  • AI应用开发者 :正在设计LLM智能体(Agent)工作流,其中包含工具调用、条件判断、多步推理等复杂逻辑链。
  • 自动化脚本编写者 :有超过“简单线性脚本”复杂度的业务自动化需求,需要状态跟踪和断点续跑。

接下来,我将带你从零开始,深入Routilux的每一个核心环节,不仅告诉你“怎么用”,更会分享我在实际集成和踩坑过程中总结的“为什么这么设计”以及“有哪些需要注意的坑”。

2. 核心概念深度解析:事件、例程、流与状态

要玩转Routilux,必须吃透它的四个核心抽象: 事件(Event) 例程(Routine) 流(Flow) 任务状态(JobState) 。官方文档可能一笔带过,但理解它们之间的关系和设计意图,是写出优雅、健壮工作流的关键。

2.1 例程(Routine):你的原子业务单元

可以把Routine理解为一个有明确输入输出和内部状态的函数“增强版”。但和普通函数不同,它是一个 ,并且继承自 routilux.Routine

from routilux import Routine

class DataEnricher(Routine):
    def __init__(self):
        super().__init__()
        # 定义输入插槽:当名为"input"的事件到来时,触发handle_data方法
        self.input_slot = self.define_slot("input", handler=self.handle_data)
        # 定义输出事件:本Routine可以发射名为"enriched_data"的事件
        self.output_event = self.define_event("enriched_data", ["data", "metadata"])
    
    def handle_data(self, data=None, **kwargs):
        # 这里是你的核心业务逻辑
        enriched = self._enrich(data)
        # 发射事件,触发下游Routine。注意:不需要手动传递flow对象!
        self.emit("enriched_data", data=enriched, metadata={"source": "enricher"})
    
    def _enrich(self, data):
        # 内部辅助方法
        return f"ENRICHED_{data}"

关键解读与实操心得:

  1. define_slot define_event :这是在 __init__ 中完成的“布线”工作。 define_event 的第二个参数是一个列表,定义了该事件能携带哪些命名的数据字段。这相当于一个轻量级的接口契约,让连接更清晰。
  2. self.emit() 的魔法 :这是Routilux最精妙的设计之一。你调用 emit 时,不需要传入当前是哪个Flow在执行它。框架会自动从运行时上下文中获取到当前的Flow和JobState。这极大简化了代码,让Routine的实现完全不用关心自己被谁调用、在哪个流中运行,实现了真正的解耦。
  3. 状态管理 :每个Routine实例都有一个 _stats 字典。你可以在 handle_data 方法里像 self._stats["processed"] = self._stats.get("processed", 0) + 1 这样更新它。通过 routine.stats() 方法可以获取。 注意 _stats 是实例变量,如果你在Flow中多次使用同一个Routine类(但不同实例),它们的状态是独立的。如果你想共享全局状态,需要通过事件数据传递,或者使用Flow/JobState级别的上下文。

2.2 流(Flow):工作流的蓝图与执行引擎

Flow是工作流的容器和调度器。它持有所有Routine实例,管理它们之间的连接关系,并控制执行策略。

from routilux import Flow

# 1. 创建流
flow = Flow(flow_id="my_data_pipeline")
# 2. 添加例程实例,并给每个实例一个唯一ID
extractor_id = flow.add_routine(DataExtractor(), "extractor")
enricher_id = flow.add_routine(DataEnricher(), "enricher")
loader_id = flow.add_routine(DataLoader(), "loader")
# 3. 连接事件与插槽:extractor的output事件 -> enricher的input插槽
flow.connect(extractor_id, "output", enricher_id, "input")
flow.connect(enricher_id, "enriched_data", loader_id, "input")

连接(Connection)的实质 flow.connect(A, "event_x", B, "slot_y") 意味着当例程A发射(emit)名为“event_x”的事件时,事件所携带的数据会被传递给例程B中名为“slot_y”的插槽所关联的处理函数(handler)。数据传递是基于键值匹配的,因此发射事件时的参数名最好与插槽处理函数的参数名对应。

2.3 事件(Event)与插槽(Slot):松耦合的通信协议

这是Routilux异步编排的核心。事件不是一个持久化的对象,而是一个即时触发的信号。

  • 事件发射(Emit)是非阻塞的 self.emit() 方法会将一个任务(包含事件名和数据)放入Flow的事件队列,然后立即返回。实际的槽函数调用由Flow的 执行器 异步处理。
  • 多对多连接 :一个事件可以触发多个插槽(扇出),一个插槽也可以监听多个事件(扇入)。这为构建复杂拓扑(如分支、聚合)提供了基础。
  • 数据传递 :事件数据以关键字参数的形式传递给槽函数。槽函数应使用 **kwargs 来接收,或者明确声明可能的数据字段,以提高可读性。

2.4 任务状态(JobState):执行过程的快照

每次调用 flow.execute() 都会产生一个JobState对象。它是整个工作流单次执行的记录器、状态持有者和持久化单元。

  • 执行历史 job_state.get_execution_history() 返回一个列表,按顺序记录了哪个例程的哪个插槽被触发、输入输出数据是什么、是否成功、耗时多少。 排查问题时的第一手资料
  • 性能追踪 flow.execution_tracker 提供了更详细的性能指标。
  • 持久化与恢复 job_state.save(“path.json”) JobState.load(“path.json”) 是实现“断点续跑”的关键。它保存了每个Routine的当前状态( _stats )、事件队列中未处理的任务等。 重要提示 :持久化保存的是数据,而不是代码。恢复时,你需要用相同的Routine类和Flow结构重新构建环境,然后 flow.resume(saved_job_state)

3. 从零构建一个生产级数据管道:实战演练

理论说得再多,不如亲手搭一个。我们来构建一个模拟的真实数据管道:从API提取数据,进行验证和清洗,并行执行两个不同的分析,最后聚合结果并发送通知。这个过程会涵盖顺序、并行、错误处理等核心模式。

3.1 定义业务例程

首先,创建我们的五个业务Routine。

# routines.py
import time
import random
from routilux import Routine

class DataExtractor(Routine):
    """模拟从API提取数据"""
    def __init__(self):
        super().__init__()
        self.define_slot("trigger", handler=self.extract)
        self.define_event("raw_data", ["data_batch"])
    
    def extract(self, **kwargs):
        # 模拟API调用
        time.sleep(0.5)
        simulated_data = [{"id": i, "value": random.randint(1, 100)} for i in range(5)]
        # 模拟偶尔失败
        if random.random() < 0.1:
            raise ConnectionError("API endpoint timeout")
        self._stats["batches_extracted"] = self._stats.get("batches_extracted", 0) + 1
        self.emit("raw_data", data_batch=simulated_data)
        print(f"[Extractor] Fetched {len(simulated_data)} records.")

class DataValidator(Routine):
    """验证数据完整性"""
    def __init__(self):
        super().__init__()
        self.define_slot("input", handler=self.validate)
        self.define_event("valid_data", ["data"])
        self.define_event("invalid_data", ["data", "reason"])
    
    def validate(self, data_batch=None, **kwargs):
        valid = []
        invalid = []
        for item in data_batch:
            if item.get("value") > 0:
                valid.append(item)
            else:
                invalid.append({"item": item, "reason": "Non-positive value"})
        
        self._stats["valid_count"] = self._stats.get("valid_count", 0) + len(valid)
        self._stats["invalid_count"] = self._stats.get("invalid_count", 0) + len(invalid)
        
        if valid:
            self.emit("valid_data", data=valid)
        if invalid:
            self.emit("invalid_data", data=invalid, reason="Validation failed")
        print(f"[Validator] Valid: {len(valid)}, Invalid: {len(invalid)}")

class DataAnalyzerA(Routine):
    """分析路径A:计算平均值"""
    def __init__(self):
        super().__init__()
        self.define_slot("input", handler=self.analyze)
        self.define_event("analysis_result", ["type", "value"])
    
    def analyze(self, data=None, **kwargs):
        time.sleep(0.8)  # 模拟耗时计算
        values = [item["value"] for item in data]
        avg = sum(values) / len(values) if values else 0
        result = {"analysis_type": "average", "value": avg, "input_size": len(data)}
        self.emit("analysis_result", type="average", value=result)

class DataAnalyzerB(Routine):
    """分析路径B:找出最大值"""
    def __init__(self):
        super().__init__()
        self.define_slot("input", handler=self.analyze)
        self.define_event("analysis_result", ["type", "value"])
    
    def analyze(self, data=None, **kwargs):
        time.sleep(0.6)  # 模拟耗时计算
        values = [item["value"] for item in data]
        max_val = max(values) if values else 0
        result = {"analysis_type": "max", "value": max_val, "input_size": len(data)}
        self.emit("analysis_result", type="max", value=result)

class ResultAggregator(Routine):
    """聚合分析结果并触发通知"""
    def __init__(self):
        super().__init__()
        # 这个插槽会监听来自AnalyzerA和AnalyzerB的`analysis_result`事件
        self.define_slot("result_input", handler=self.aggregate)
        self.define_event("final_output", ["report"])
        self.partial_results = []
    
    def aggregate(self, type=None, value=None, **kwargs):
        self.partial_results.append({"type": type, "value": value})
        print(f"[Aggregator] Received result from {type}. Total: {len(self.partial_results)}")
        
        # 假设我们等待2个结果后聚合
        if len(self.partial_results) >= 2:
            final_report = {
                "summary": f"Analysis complete. {len(self.partial_results)} results aggregated.",
                "details": self.partial_results,
                "timestamp": time.time()
            }
            self.emit("final_output", report=final_report)
            self.partial_results.clear()  # 重置状态以备下次使用

实操心得:

  • 清晰的命名 :事件和插槽的名字要像API接口一样清晰,例如 valid_data analysis_result 。这能在复杂工作流中极大提升可读性。
  • 状态重置 :注意 ResultAggregator 在发射最终事件后清空了 partial_results 。因为Routine实例在Flow中通常是长期存在的,如果处理连续的任务,必须小心避免状态污染。对于一次性工作流,这不是问题;对于需要处理多个独立任务的流,这是关键。

3.2 组装工作流并配置执行策略

接下来,我们把各个部分连接起来,并配置关键的并发和错误处理策略。

# pipeline.py
from routilux import Flow, ErrorHandler, ErrorStrategy
import routines  # 导入上面定义的模块

def create_data_pipeline():
    flow = Flow(flow_id="production_data_pipeline")
    
    # 1. 添加所有例程
    extractor_id = flow.add_routine(routines.DataExtractor(), "extractor")
    validator_id = flow.add_routine(routines.DataValidator(), "validator")
    analyzer_a_id = flow.add_routine(routines.DataAnalyzerA(), "analyzer_a")
    analyzer_b_id = flow.add_routine(routines.DataAnalyzerB(), "analyzer_b")
    aggregator_id = flow.add_routine(routines.ResultAggregator(), "aggregator")
    
    # 2. 连接工作流
    # 线性部分:提取 -> 验证
    flow.connect(extractor_id, "raw_data", validator_id, "input")
    
    # 分支部分:有效数据并行触发两个分析器
    flow.connect(validator_id, "valid_data", analyzer_a_id, "input")
    flow.connect(validator_id, "valid_data", analyzer_b_id, "input")
    
    # 聚合部分:两个分析器的结果汇聚到聚合器
    flow.connect(analyzer_a_id, "analysis_result", aggregator_id, "result_input")
    flow.connect(analyzer_b_id, "analysis_result", aggregator_id, "result_input")
    
    # (可选)连接无效数据的处理路径,例如发送到死信队列或日志
    # flow.connect(validator_id, "invalid_data", some_logger_id, "input")
    
    # 3. 配置错误处理策略:重试3次,指数退避
    flow.set_error_handler(ErrorHandler(
        ErrorStrategy.RETRY,
        max_retries=3,
        retry_delay=1.0,
        backoff_multiplier=2.0  # 第一次等1秒,第二次2秒,第三次4秒
    ))
    
    # 4. 设置并发执行策略
    flow.set_execution_strategy("concurrent", max_workers=4)
    # 这意味着事件队列中的任务最多有4个被同时执行。
    # 对于我们的管道:Validator处理完后,AnalyzerA和AnalyzerB的任务会同时进入队列,并被两个空闲的工作线程并行执行。
    
    return flow, extractor_id  # 返回流和入口例程ID

if __name__ == "__main__":
    flow, entry_id = create_data_pipeline()
    
    # 5. 执行工作流
    print("Starting data pipeline execution...")
    job_state = flow.execute(entry_id, entry_params={})  # 触发extractor的`trigger`插槽
    
    # 6. 等待所有异步任务完成
    flow.wait_for_completion()
    
    print(f"\n=== Execution Finished ===")
    print(f"Final Job Status: {job_state.status}")
    print(f"Execution History Length: {len(job_state.get_execution_history())}")
    
    # 打印各个例程的统计信息
    for routine_id, routine in flow.routines.items():
        print(f"\n{routine_id} Stats: {routine.stats()}")

运行这个脚本,你会看到类似以下的输出,清晰地展示了事件的流动和并发执行:

Starting data pipeline execution...
[Extractor] Fetched 5 records.
[Validator] Valid: 5, Invalid: 0
[Aggregator] Received result from average. Total: 1
[Aggregator] Received result from max. Total: 2
[Extractor] Fetched 5 records.
...
=== Execution Finished ===
Final Job Status: completed
Execution History Length: 12

extractor Stats: {'batches_extracted': 2}
validator Stats: {'valid_count': 10, 'invalid_count': 0}
analyzer_a Stats: {}
analyzer_b Stats: {}
aggregator Stats: {}

注意,由于设置了并发, AnalyzerA AnalyzerB 的输出顺序可能是不确定的,这正是我们想要的并行效果。

4. 高级特性与生产实践指南

掌握了基础构建后,我们来看看那些能让你的工作流真正健壮、可运维的高级功能。

4.1 错误处理策略详解:不止于重试

Routilux内置了四种错误策略( ErrorStrategy ),你需要根据业务场景选择。

  • STOP(默认) :任何例程抛出未捕获异常,整个工作流立即停止。适合对数据一致性要求极高、一步出错则全盘皆输的场景。
  • CONTINUE :记录错误,但继续执行流中的其他任务。适合数据批处理,其中少数记录失败不应影响整体作业。
  • RETRY :对失败的任务进行重试。 这是生产环境最常用的策略 。配合 max_retries retry_delay backoff_multiplier ,可以实现指数退避重试,有效应对临时性网络故障。
  • SKIP :跳过当前失败的任务,继续执行该例程后续的插槽或流中的其他任务。使用场景较特殊。

生产环境建议 :对于 外部依赖 (如API调用、数据库查询)使用 RETRY 策略。对于 业务逻辑错误 (如数据格式不符),应在Routine内部捕获并转换为发出一个特定的“错误事件”(如 validation_error ),然后由专门的错误处理Routine来接管(如记录日志、发送告警),主流程使用 CONTINUE 。这样实现了关注点分离。

# 在Routine内部进行细粒度错误处理
def handle_data(self, data=None, **kwargs):
    try:
        result = call_unstable_external_api(data)
        self.emit("success", data=result)
    except TransientError as e:  # 临时性错误,如网络超时
        # 可以选择重试,或者发射一个需要重试的事件
        self._stats["api_errors"] = self._stats.get("api_errors", 0) + 1
        self.emit("api_error", data=data, error=str(e), should_retry=True)
    except BusinessError as e:  # 业务逻辑错误,重试无意义
        self.emit("business_error", data=data, error=str(e))

4.2 状态持久化与断点续跑:应对长时任务

这是Routilux相对于简单脚本的核心优势。实现断点续跑只需要三步:

# 1. 在关键节点或定期保存状态
checkpoint_path = f"./checkpoints/job_{job_state.job_id}.json"
if some_condition:  # 例如每处理100条记录,或某个重要阶段完成后
    job_state.save(checkpoint_path)
    print(f"Checkpoint saved at {checkpoint_path}")

# 2. 程序中断后,重新初始化相同的Flow结构
# 注意:必须使用相同的Routine类和相同的例程ID来重建Flow
flow, entry_id = create_data_pipeline()  # 和之前一样的函数

# 3. 从检查点恢复并继续执行
saved_state = JobState.load(checkpoint_path)
# resume方法会从保存的状态中恢复所有Routine的_stats和事件队列
continued_state = flow.resume(saved_state)
flow.wait_for_completion()

重要警告 :持久化保存的是 数据状态 _stats 、事件队列等),而不是代码对象。确保恢复时,你的Routine类定义没有发生不兼容的更改(例如,删除了一个在 _stats 中存在的键)。建议为关键工作流的检查点文件加入版本号。

4.3 使用内置例程加速开发

Routilux提供了一些开箱即用的内置例程,位于 routilux.builtin_routines 模块。它们封装了常见模式,能极大减少样板代码。

from routilux.builtin_routines import ConditionalRouter, DataTransformer, TextClipper

# ConditionalRouter: 根据条件动态路由事件
router = ConditionalRouter()
flow.add_routine(router, "router")
# 配置路由规则:如果data['priority'] == 'high',发往high_priority插槽,否则发往low_priority
# 这需要在连接后通过routine的某个方法或属性来配置,具体请查阅最新文档。

# DataTransformer: 使用Jinja2模板或自定义函数转换数据
transformer = DataTransformer(template="{{ value * 2 }}")
# 或者
transformer = DataTransformer(transform_func=lambda data: {"doubled": data["value"] * 2})

# TextClipper: 截断长文本,非常适合处理LLM的上下文窗口
clipper = TextClipper(max_length=1000, truncate_from="end")

在构建工作流时,先看看内置例程能否满足需求,可以节省大量时间。

4.4 通过CLI和Server实现运维与集成

对于生产部署,你不可能总是通过Python脚本手动触发。Routilux的CLI和HTTP服务器提供了标准的运维接口。

使用YAML定义工作流(DSL) : 你可以将工作流定义在YAML文件中,实现代码与配置分离。

# flow.yaml
flow_id: "daily_report"
routines:
  - id: "fetcher"
    type: "module.path.to.DataFetcher"
  - id: "processor"
    type: "module.path.to.ReportProcessor"
connections:
  - from: "fetcher"
    event: "data_ready"
    to: "processor"
    slot: "input"
error_strategy:
  type: "RETRY"
  max_retries: 3
execution:
  strategy: "concurrent"
  max_workers: 2

然后通过CLI运行:

# 安装CLI组件
pip install "routilux[cli]"
# 运行工作流
routilux run --workflow flow.yaml

启动HTTP服务器 : 这对于将Routilux集成到现有微服务架构中非常有用。

# 启动服务器,自动加载指定目录下的所有YAML工作流定义
routilux server start --flows-dir ./flows --port 8080

服务器启动后,会提供RESTful API(具体端点请参考官方文档),允许你提交任务、查询状态、管理工作流。 --flows-dir 支持热重载,修改YAML文件后无需重启服务。

5. 常见问题、性能调优与踩坑实录

在实际项目中使用Routilux,你肯定会遇到一些问题。下面是我总结的一些典型场景和解决方案。

5.1 问题排查速查表

问题现象 可能原因 排查步骤与解决方案
工作流启动后没有任何输出,直接结束。 1. 入口例程的插槽未被触发。
2. execute 方法传入的 entry_params 与入口插槽的参数名不匹配。
1. 检查 flow.execute(entry_id, entry_params={...}) ,确保 entry_params 的键名与入口Routine的插槽处理函数参数名匹配。
2. 在入口Routine的 handler 函数开头加 print 或日志,确认是否被调用。
事件似乎没有触发下游例程。 1. flow.connect 连接错误(事件名或插槽名拼写错误)。
2. 上游例程的 self.emit() 事件名与连接时指定的不一致。
3. 下游例程的槽函数抛出了未捕获的异常,且错误策略是 STOP
1. 仔细核对 connect 语句中的四个ID/名字。
2. 查看 job_state.get_execution_history() ,确认事件是否被正确记录发射和接收。
3. 检查下游槽函数代码,添加更详细的异常捕获和日志。
并发模式下,任务执行顺序混乱或出现竞态条件。 这是并发执行的预期行为。事件队列保证任务按入队顺序被 取出 ,但执行是并行的,完成顺序不确定。 如果业务强依赖顺序,不要使用并发 ( max_workers=1 )。如果只是部分环节需要顺序,可以通过设计事件流来控制,例如让AnalyzerA完成后发射一个 phase1_done 事件,再触发后续并行任务。
JobState.load() 后恢复执行,状态不对或报错。 1. 恢复时重建的Flow结构与保存时不一致(例程ID、类型或连接关系改变)。
2. Routine类的代码发生不兼容变更(如删除了 _stats 中用到的属性)。
1. 确保恢复代码与保存代码在Flow结构上完全一致 。将Flow创建逻辑封装成函数可避免错误。
2. 对Routine类的修改要向后兼容。可以考虑在 _stats 中使用默认值 .get(key, default)
内存使用随着任务增多持续增长。 1. JobState 中的执行历史未清理。
2. Routine实例内部积累了未释放的大对象。
1. 对于超长运行的工作流,考虑定期保存检查点并重启新的Flow实例,或者配置 JobState 只保留最近N条历史。
2. 在Routine的槽函数中,及时清理不再需要的大型临时变量。对于需要缓存的数据,评估其生命周期。

5.2 性能调优建议

  1. 合理设置 max_workers :这并非越大越好。对于I/O密集型任务(网络请求、文件读写),可以设置得高一些(如CPU核心数的2-5倍)。对于CPU密集型任务,设置接近或等于CPU核心数即可。可以通过监控任务队列长度和系统负载来调整。
  2. 避免在Routine的 __init__ 中执行重型操作 __init__ 在Flow构建时执行。耗时的初始化(如加载大模型、连接数据库)应放在首次槽函数调用时进行懒加载,或使用单独的初始化事件。
  3. 善用内置例程 ConditionalRouter DataTransformer 等是用Cython优化过的,通常比自己实现的Python版本更快。
  4. 事件数据尽量轻量 :事件携带的数据会在队列中传递和序列化/反序列化(如果持久化)。传递大型对象(如DataFrame)会严重影响性能。考虑传递引用(如文件路径、数据库ID)而非数据本身。

5.3 设计模式心得

  • 单一职责 :每个Routine只做一件事。一个“数据清洗”Routine可能不如拆成“去除空值”、“格式标准化”、“类型转换”三个Routine来得灵活。
  • 事件命名即文档 :使用 user_data_validated payment_processed 这样的名字,而不是 event_1 step_2 。连接关系会因此变得一目了然。
  • 为错误设计路径 :不要只考虑成功流。为验证失败、API调用失败、超时等设计专门的事件和处理例程(如 dead_letter_processor ),使工作流更加健壮。
  • 考虑可测试性 :由于Routine高度解耦,你可以非常方便地为每个Routine编写单元测试,模拟输入事件,断言输出事件。Flow的集成测试也可以通过构建小型测试流来完成。

Routilux给我的感觉像是一套精心设计的乐高积木。它没有试图提供所有可能的零件,而是提供了最核心、最通用的连接器(事件/插槽)和基础砖块(Routine基类),让你可以自由地构建任何你想象中的结构。它的学习曲线平缓,但所能构建的系统复杂度上限却很高。如果你正在寻找一个Python中轻量、灵活且功能强大的工作流编排框架,它绝对值得你花一个下午的时间深入尝试。

更多推荐