一、搜"python 工作流引擎",你会先搜到什么
场景很常见:一个 Python 服务(FastAPI / Django / Flask 写的中台、内部系统),产品说要加审批流。请假、报销、采购,单子从申请人出发走到部门领导,复杂一点的要会签、按比例通过、退回发起人改材料、抄送一把手。
你去搜 python workflow,会搜到 Airflow、Prefect、Dagster(数据管道编排),再往外还有 Celery(任务队列)、Temporal(长时任务)、Camunda 的 Python 客户端。这些名字都很强,但花一个下午读下来你会发现,它们的主场分别是数据管道和任务编排 / 长时状态机——把它们请进一个 CRUD 系统审批三张单子,等于为了批三张单子,先部署一套调度平台、再学一套 DAG 或 workflow-as-code。
而你真正想要的,其实是件小事:一段 OA 审批语义,嵌进自己的 Python 服务,用自己已有的 MySQL 或 PostgreSQL,配一个能画流程的前端——最好别惊动你的部署拓扑。
jeeflow 就是做这件小事的引擎:串行/并行/按比例会签、一票否决、退回发起人、委托代理、抄送——这套审批语义,引擎核心自己扛。它已经在 Java/Go/Python/Node/PHP/Rust/MoonBit/C# 八门语言上各有一份实现,同一份 LogicFlow 流程 JSON 八门语言通用。
先把"它不是什么"说清楚,省得你装错:
- 不是 BPM / 自动化平台:不带用户体系、不带表单引擎,审批人是谁要你接一个用户接口告诉它;
- 不带 UI:但有配套开源前端 jeeflow-ui,
?lang=python直连 Python demo 可用; - 数据库支持 MySQL / PostgreSQL:内存仓储开箱即用(测试 / 内嵌场景),生产走
repository模块(驱动按 extras 选装,下面讲); - 不碰你的业务表:表单数据落哪张表、哪些字段谁可见,由你配置,引擎只管流程本身。
对 Python 开发者最特别的一句介绍:这是一个全异步引擎——公开方法全是 async def,await 到底。装它就一行:
pip install jeeflow
下一节直接跑,零依赖这件事我会在第四节单独拎出来讲。
二、pip install 一行,5 分钟跑通一条审批流
光说不练是伪代码,下面是一个全新 venv 的真实记录——pip install 从 PyPI 拉 jeeflow,到一条请假审批走完,全程 5 分钟。
$ python -m venv .venv
$ .venv/Scripts/pip install jeeflow
Successfully installed jeeflow-1.8.28
注意这行输出:Successfully installed jeeflow-1.8.28——就它一个包,没有"Collecting aiomysql…"、"Collecting pydantic…"那一串。装进来的 wheel 77 KB,依赖列表是空的。先把流程跑通,账我第四节拆。
流程用联邦共享测试资产里最简单的一条(01-simple.json,这里裁掉画布坐标展示骨架,设计器导出的完整文件原样也能用):开始 → 申请(assignee=applicant)→ 上级审批(assignee=leader)→ 结束。
完整代码如下,一个 main.py 能跑:
import asyncio
import json
from jeeflow import EngineImpl, MemoryRepository
from jeeflow.model import ProcessDefine
# flows/01-simple.json 裁掉画布坐标后的骨架
simple_flow = {
"name": "simple", "displayName": "简单审批流程", "type": "approval",
"nodes": [
{"id": "start", "type": "snaker:start", "properties": {}},
{"id": "apply", "type": "snaker:task",
"properties": {"assignee": "applicant", "taskType": 0, "performType": 0},
"text": {"value": "发起申请"}},
{"id": "task1", "type": "snaker:task",
"properties": {"assignee": "leader", "taskType": 0, "performType": 0},
"text": {"value": "上级审批"}},
{"id": "end", "type": "snaker:end", "properties": {}},
],
"edges": [
{"id": "e0", "sourceNodeId": "start", "targetNodeId": "apply", "properties": {}},
{"id": "e1", "sourceNodeId": "apply", "targetNodeId": "task1", "properties": {}},
{"id": "e2", "sourceNodeId": "task1", "targetNodeId": "end", "properties": {}},
],
}
async def print_doing(repo, inst_id):
ts = await repo.find_doing_tasks(inst_id)
if not ts:
print(" 待办: (无,流程已结束)")
return
for t in ts:
actors = await repo.find_task_actors(t.id)
print(f" 待办: 任务id={t.id} 节点={t.taskName}({t.displayName}) 参与人={actors}")
async def main():
repo = MemoryRepository()
engine = EngineImpl(repo)
# 1. 注册流程定义(id 留 0,看 memory 仓储自动分配)
d = ProcessDefine(name="simple", displayName="简单审批流程", type="approval",
state=1, content=json.dumps(simple_flow))
repo.add_define(d)
print(f"[1] 流程定义已注册 id={d.id}(id 留 0,memory 仓储自动分配)")
# 2. 张三发起流程
inst = await engine.start_process_instance_by_id(d.id, "张三")
print(f"[2] 张三 发起流程:实例id={inst.id} state={inst.state}")
print(" 注意:start 不会替你办申请节点——")
await print_doing(repo, inst.id)
# 3. 张三完成申请节点
doing = await repo.find_doing_tasks(inst.id)
inst2 = await engine.execute_process_task(doing[0].id, "张三")
print(f"[3] 张三 提交申请:state={inst2.state}")
await print_doing(repo, inst.id)
# 4. leader 审批 → 流程结束
doing = await repo.find_doing_tasks(inst.id)
inst3 = await engine.execute_process_task(doing[0].id, "leader")
print(f"[4] leader 审批通过:state={inst3.state}(10进行中 20已结束 45已驳回)")
await print_doing(repo, inst.id)
asyncio.run(main())
python main.py 的真实输出:
[1] 流程定义已注册 id=1(id 留 0,memory 仓储自动分配)
[2] 张三 发起流程:实例id=1789398521769 state=10
注意:start 不会替你办申请节点——
待办: 任务id=1789398522282 节点=apply(发起申请) 参与人=['张三']
[3] 张三 提交申请:state=10
待办: 任务id=1789398522467 节点=task1(上级审批) 参与人=['leader']
[4] leader 审批通过:state=20(10进行中 20已结束 45已驳回)
待办: (无,流程已结束)
四步,一条审批流走完。几个真实细节值得停一停:
全程 async/await,没有同步版。start_process_instance_by_id / execute_process_task 都是协程,最外层 asyncio.run(main()) 收口。这是刻意设计:审批这种 IO 密集型负载,异步引擎放进 FastAPI 的 async def 路由里是零成本的(await engine.execute_process_task(...) 直接写);Flask / Django 这类同步框架也不怕,一次 asyncio.run() 就能桥接。方法名是完整蛇形命名(start_process_instance_by_id),没有缩写——Python 的地盘守 Python 的规矩。
EngineImpl(repo) 一个参数就够:构造签名是 (repo, user_prov=None, id_gen=None, expr_eval=None),后面三个 SPI 全可省。不传 id 生成器,实例 id 退化成"毫秒时间戳 + 随机数"(所以你看到 13 位数字);不传表达式求值器,线性流程照样跑,但条件分支会踩坑——Python 版的坑和 Node 版不是同一种,下面细说。生产环境建议注入自带的 TsIDGenerator(),雪花 id 和其他语言版本同库共享不撞。
ProcessDefine 是个 dataclass,不是 dict。这是我的第一个真实坑:我把流程定义当字典传给 repo.add_define({...}),当场 AttributeError: 'dict' object has no attribute 'id'。引擎的模型全是带类型的 dataclass(ProcessDefine / ProcessInstance / ProcessTask),字段名沿用契约里的驼峰(displayName、processInstanceId)——为了和流程 JSON、跨语言契约逐字对齐,这里刻意不入乡随俗。只有流程内容本身(content)是 JSON 字符串,json.dumps 喂进去。
参与人=['张三'] 是引擎解析出来的:申请节点上写的 "assignee": "applicant" 是 mldong 契约的特殊值,引擎建任务时把它解析成流程发起人;assignee 里写 ${变量}、逗号分隔多人都认,还支持注册 assignment handler 按名字取人(部门领导这种要查组织架构的场景,7 个内置 handler 开箱即用)。
两个我亲手踩的坑,给你垫上:
第一个,start 不会替你办申请节点。张三 start_process_instance_by_id 之后,第一张待办是"发起申请",停在张三自己桌上——你要再 execute_process_task 一次才算真正提交。这是刻意设计(mldong 契约的 applicant 约定:申请节点也是节点,退回发起人时它就是退回的目的地),但第一次用很容易以为发起=已提交。上面的输出里我特意把这一步打出来了。不想记这步?走下一节的统一门面,startAndExecute 帮你把"发起 + 办申请"合成一步。
第二个,decision 节点带表达式、却没配求值器——Python 不报错,但会走错分支。第 21 篇讲 Node 版踩这个坑是"静默卡死":单子杵在那不前进。Python 版实测是另一副面孔。同一条 amount > 1000 走大额审批的分支流程,不传 expr_eval 发起一笔 amount=6666 的单:
不配求值器 amount=6666:state=10,待办=['taskA(小额审批)']
配了求值器 amount=6666:state=10,待办=['taskB(大额审批)']
看到了吗——不配求值器,流程照样往前走,只是 6666 元被分到了"小额审批"。Python 版的 decision 求值有个回退链:先按表达式匹配,匹配不上就走第一条没有表达式的边,再不行就走第一条边。两种失败模式摆在一起看很有意思:Node 卡死,单子"凭空消失",发起方一眼就能发现;Python 走错分支,单子看起来一切正常,钱算错了——后者其实更危险。用条件分支,就把第四个参数填上,它只是一个实现了 async def eval(expr, vars_) 的普通类:
class PyExprEval:
async def eval(self, expr: str, vars_: dict):
return eval(expr, {}, vars_) # 生产环境换成受控的表达式实现
engine = EngineImpl(repo, expr_eval=PyExprEval())
越权会被引擎直接拦住。我新开一单张三发起,第一张待办参与人是张三,然后让 leader 去批——
ValueError: operator leader not allowed
Python 引擎在 execute_process_task 内部就做了参与人硬校验,非参与者直接抛 ValueError。直接调引擎方法也躲不掉,是物理闸门。(顺带一个冷知识:flow.admin / flow.auto 这两个特殊操作人会被放行——系统自动任务和超管的钥匙。)
边界报错长什么样(引擎层原生 ValueError,负向实测):
重复审批同一单: ValueError: task not doing
审批不存在的任务:ValueError: task not found: 99999999
用不存在的定义发起:ValueError: define not found: 42
和 Node 版逐字一致——这套错误语义是八门语言对齐的契约,不是哪个语言的即兴发挥。
三、生产姿势:换数据库仓储、上统一门面
内存仓储适合测试和内嵌,生产换成数据库仓储。Python 版同时给了 MySQL 和 PostgreSQL 两个适配,驱动按 extras 选装:
$ pip install "jeeflow[mysql]" # 多装 aiomysql
$ pip install "jeeflow[postgres]" # 多装 asyncpg
import aiomysql
from jeeflow import EngineImpl, TsIDGenerator
from jeeflow.repository import JdbcRepository, MySqlAdapter
pool = await aiomysql.create_pool(
host="localhost", user="root", password="***", db="wf",
autocommit=True, # ⚠️ 必须:引擎在 with_tx 内显式 begin/commit,池上不开自动提交会错乱
charset="utf8mb4",
)
repo = JdbcRepository(MySqlAdapter(pool), TsIDGenerator())
engine = EngineImpl(repo)
autocommit=True 这一行不是可选项——MySQL 适配器要求连接池以自动提交创建,无事务时每条语句立即提交,引擎自己管事务边界。这是从 Go 版 database/sql 语义对齐过来的约定。表结构是 wf_ 前缀的核心五张表(wf_process_define / wf_process_instance / wf_process_task / wf_process_task_actor / wf_process_cc_instance),和 Java 版完全一致,建表 SQL 在仓库 tests/schema/ 里,幂等可重复执行。
再往上,如果你不想记引擎的方法名,直接用统一门面——这是 mldong 系框架接工作流的标准姿势,40+ 个 action、一个入口:
from jeeflow import JeeflowFacade
facade = JeeflowFacade(engine, repo)
# 发起并自动完成申请节点(startAndExecute = start + 办申请)
r = await facade.flow("processDefine/startAndExecute",
{"processDefineId": d.id, "operator": "user1"})
facade.flow(action, args) 返回统一的 {code, msg, data} 信封。真实的返回长这样:
{"code": 0, "msg": "成功", "data": {"processInstanceId": "1789398613846"}}
leader 查自己的待办列表(processTask/todoList,operator 过滤):
{"code": 0, "msg": "成功", "data": {"pageNum": 1, "pageSize": 10, "recordCount": 1, "totalPage": 1,
"rows": [{"id": "1789398612928", "processInstanceId": "1789398613846", "taskName": "task1",
"displayName": "上级审批", "taskState": 10, "createTime": "2026-09-14 23:10:12", ...}]}}
两个契约细节,跨语言都一样:code: 0 是成功(失败是 99999999,比如调一个不存在的 action,会拿到 {"code": 99999999, "msg": "未知 action: ..."});信封里的 ID 全部字符串化("1789398613846")。
说到大数 id,Python 有个天然优势值得一讲。第 21 篇 Node 版专门防了"前端把雪花 id 当 number 传"的坑:JS 的 number 超过 2^53 就丢精度,2096621342496391168 传到引擎面前已经是 ...1200 了,Node 版为此做了显式报错的硬校验。Python 没这个问题——int 是任意精度,同样的大数原样传进门面,不丢一位:
>>> await facade.flow("processTask/detail", {"id": 2096621342496391168})
{"code": 99999999, "msg": "任务不存在"} # 数字原样精确,只是这个任务真的不存在
不过别高兴太早:信封里的 id 依然是字符串契约,因为前端(JS)要消费这些 JSON。Python 服务端不丢精度,不等于你的 Vue/React 前端不丢——浏览器里 JSON.parse 那一刻就四舍五入了。所以前后端交互照旧把 id 当字符串传,Python 帮你守住的只是服务端这一段。
40+ 个 action 覆盖流程定义部署/版本管理、发起、审批、跳转、撤回、委托代理、抄送、候选人、高亮路径、审批记录,以及统计三件套(overview/trend/group)。演示站的后端就是这套门面套了个 FastAPI 壳:
想先玩再装:jeeflow-demo.mldong.com/?lang=pytho… (右上角可以切八门语言后端,前端是同一个)。
四、零依赖不是营销词:依赖列表真的是空的
这一段把第二节的"就它一个包"的账拆掉,也是这一篇和 C#/Go/Node 篇最大的不同。
先看 PyPI 元数据,包的依赖声明是真的空:
$ pip show jeeflow | grep Requires
Requires:
引擎核心(EngineImpl / MemoryRepository / JeeflowFacade,也就是 from jeeflow import ... 主入口给你的东西)运行时零第三方依赖,JSON 用标准库 json,异步用标准库 asyncio。数据库驱动不在依赖里,而在两个 extras 里:jeeflow[mysql] 带 aiomysql,jeeflow[postgres] 带 asyncpg。你为"用 MySQL"这件事付出的成本是显式的:一行 extras,多装且只多装一个驱动包——不像某些"全家桶"把驱动硬塞进默认依赖,也不用像 npm 那样靠 --omit=optional 才能把可选依赖挡在外面。
验证一下裸装裸 import:
$ pip install jeeflow # 干净 venv
$ python -c "import jeeflow; print(jeeflow.__doc__)"
jeeflow — lightweight async workflow engine (Python)
这个"裸装后能直接 import"值得单独说一句,因为它不是一直都这样。1.8.27 及更早的版本,裸装之后 import jeeflow 会当场炸出 ModuleNotFoundError: aiomysql——MySQL 适配器在模块顶层 import 了可选依赖,而开发环境永远装着全套 dev 依赖,测试全绿照样翻车,用户第一次 pip install 就撞墙。1.8.28 把顶层 import 改成了惰性引用(不装驱动也能导入,真连库时才加载),并加了一条回归测试专门模拟"裸装环境"钉死这个行为。这个修复对我来说不是轶事——这篇文稿的初稿就是在 1.8.27 上踩到它才当场升级重装的。
这比 Go 篇说的"编译期零第三方"是另一种实现路径:Go 是按包裁剪、不进二进制;Python 是包管理器层面的依赖列表为空,pip install jeeflow 对你的环境的影响面就是那 77 KB 本体,extras 把选择权留给你。
五、同一份流程 JSON,八门语言都能跑
这一段给不熟悉这个系列的新读者,老读者可以跳过。
jeeflow 是一个多语言联邦:Java 是参考实现,Go/Python/Node/PHP/Rust/MoonBit/C# 各有一份对齐实现,八门语言共享同一套流程定义 JSON(15 个模板,从最简单的线性审批到会签+分支+委托混合模式)、同一套 {code,msg,data} 契约、同一组状态码语义。升级走"参考实现先行 + 契约测试对齐",Java 发了新能力,各语言在下一版跟上。
对 Python 用户的实际意义,有两层。第一层是不锁语言:今天服务是 Python,明天加一个 Java 或 Go 服务,流程定义原样搬走,审批记录里的状态码一个都不用改。第二层是生态位:Python 最大的应用开发栈 FastAPI 是异步的,jeeflow-python 也是全异步的,await 直接写进路由函数;演示站的 Python 后端就是一个纯 FastAPI 应用,从引擎到 HTTP 接口中间没有任何 ORM 框架,jeeflow-ui 开源前端 ?lang=python 直连可用。
测试基线(写稿当日实测):pytest 106 个用例全绿,0.92 秒跑完——全程内存仓储,不碰任何数据库;另有需要真库的 JDBC 集成测试(MySQL/PostgreSQL 双库)单独跑。这个"引擎契约测试不依赖数据库"本身也是个设计质量的信号:仓储是 SPI,测试全部跑在内存实现上。
六、什么时候用它,什么时候别用
最后摆正预期,这张表比任何吹捧都有用:
| 你的需求 | 建议 |
|---|---|
| Python 服务里嵌审批流:请假/报销/采购,会签、退回、委托、抄送 | 正解。五张表 + 一个用户 SPI + 一个门面,jeeflow-ui 直连可用 |
| FastAPI / 异步栈服务 | 正中靶心。全异步引擎,await 直接写进路由,无桥接层 |
| Django / Flask 同步服务 | 能用,一次 asyncio.run() 桥接;但要是团队对 async 有心理阴影,先想清楚 |
| 前端还没有流程设计器 | 用 jeeflow-ui(开源,Vue3),?lang=python 就是给 Python 后端留的档位 |
| 多语言技术栈,流程定义要共用 | 同一份 LogicFlow JSON 八门语言跑,迁移引擎/混合栈不锁语言 |
| ETL / 数据管道 / 定时调度 | 别用,去 Airflow/Prefect,它们是那个赛道的 |
| 长时编排、任务重试、Saga 补偿、跨服务状态机 | 别用,去 Temporal,它们是那个赛道的 |
| 数据库不是 MySQL/PostgreSQL | 自己实现 ProcessRepository SPI(接口在 jeeflow.spi,内存仓储可参考,几百行的事) |
pip install jeeflow,Apache-2.0,引擎核心零第三方依赖、数据库驱动按 extras 选装,Python 3.10+。装之前想先玩,演示站在跑着;想看代码,仓库和文档站都在下面。
审批流的复杂度,值得一个 import 就能带走的引擎来扛,而不是一套独立的编排平台。
参考资料
jeeflow仓库(2026-09-14 核对):PyPI 当前版本 1.8.28(2026-09-11 发布);pyproject.toml依赖声明为空、aiomysql/asyncpg为 extras(jeeflow[mysql]/jeeflow[postgres]);40+ action 统一门面见仓库jeeflow/facade.py;信封 id 全程字符串化;裸装 import 回归测试tests/test_bare_import.py;测试矩阵 pytest 106 用例(当日实测 0.92s 全绿,不含需真库的 JDBC 集成测试)- 系列前篇:第 3 篇《工作流引擎的"灵魂":状态机与 submitType》、第 6 篇《"applicant" 契约:退回发起人的闭环设计》、第 17 篇《C# 开发者也有自己的轻量工作流引擎了》、第 18 篇《Go 开发者也有自己的轻量工作流引擎了》、第 20 篇《AI Agent 不能自己签字:Python 工作流引擎当人类闸门》、第 21 篇《Node 开发者也有自己的轻量工作流引擎了》
- Python 在线演示站(可直接玩):jeeflow-demo.mldong.com/?lang=pytho…
- GitHub 仓库:github.com/mldong/jeef…
- PyPI 包:pypi.org/project/jee…
- jeeflow-ui 前端仓库:github.com/mldong/jeef…
- 文档站:jeeflow-doc.mldong.com
- 开源演示站:jeeflow-demo.mldong.com
- 集成演示站:jeeflow-pro.mldong.com