AI 应用开发者的 2026 年大概是这样的:Agent 能自己查库、自己下单、自己发邮件、自己改配置。框架文档里"human-in-the-loop"那一节教你的做法,通常是让程序停在一行 input("按 y 继续") 上------或者更糟,往群里发张截图等老板回复。
这不是审批。审批是组织里最古老的风险控制,它至少包含四样东西:单据 (谁发起的、要干什么、金额多少)、留痕 (谁在几点批的、批了什么意见)、规则 (多少钱要谁批、要不要会签)、驳回 (不通过时单子怎么回去)。input() 一样都给不了。
而这四样东西,恰好是一个工作流引擎做了几十年的事。这篇用一个 98KB 级的 Python 轻量工作流引擎 jeeflow(pip install jeeflow),给 Agent 装一道真正的人类闸门:Agent 发起审批 → 单子停在审批人手里 → 人点头(或驳回)→ Agent 才继续。整条链都是真机实录。
一、五分钟跑通:Agent 发起一笔需要审批的退款
装包(要求 Python ≥ 3.10,核心零依赖):
bash
pip install jeeflow
插一个刚踩的真坑:写这篇时用干净 venv 装的是 1.8.27,第一行
import jeeflow就炸------ModuleNotFoundError: No module named 'aiomysql'。MySQL 适配器是可选依赖,但包的顶层__init__无条件把它 import 了一遍;开发环境永远装着 dev 依赖所以测试全绿,只有用户裸装 才会炸。已随 1.8.28 修复(对齐同仓 postgres 适配器的惰性引用写法)。你装到的新版没这个问题。
然后部署一条最简单的审批流。流程定义就是一份 JSON(LogicFlow 格式,画布拖出来的产物长这样):开始 → 申请 → 上级审批 → 结束:
python
import asyncio, json
from pathlib import Path
from jeeflow import EngineImpl, MemoryRepository
from jeeflow.model import InstanceState, ProcessDefine, TaskState
FLOW = json.loads(Path("01-simple.json").read_text(encoding="utf-8"))
async def main():
repo = MemoryRepository()
engine = EngineImpl(repo)
d = ProcessDefine(name=FLOW["name"], displayName=FLOW["displayName"],
content=json.dumps(FLOW, ensure_ascii=False))
await repo.save_define(d) # 流程入库,拿到 define_id
# Agent 要退款 5000,先发起审批
inst = await engine.start_process_instance_by_id(d.id, "ai-agent", {"amount": 5000})
print(int(inst.state)) # 10(进行中)
print([(t.displayName, t.actorIds) for t in inst.tasks
if t.taskState == TaskState.DOING])
# → [('发起申请', ['ai-agent'])]
asyncio.run(main())
第一处反直觉来了:start 之后,单子并不在审批人手里,而在 Agent 自己手里 。待办列表里那条"发起申请"的参与者是 ai-agent------流程的第一个任务节点永远是申请节点,而引擎从不自动替任何人执行节点,包括发起人自己。
这是 jeeflow 的 "applicant" 契约:申请节点的 assignee 写 applicant,解析为实例发起人;谁发起,谁提交。所以 Agent 发起之后要自己把申请节点办掉,单子才会走到审批人面前:
python
apply_task = [t for t in inst.tasks if t.taskState == TaskState.DOING][0]
inst = await engine.execute_process_task(apply_task.id, "ai-agent") # Agent 提交申请
print([(t.displayName, t.actorIds) for t in inst.tasks
if t.taskState == TaskState.DOING])
# → [('上级审批', ['leader'])]
现在单子停在"上级审批"节点,参与者是 leader。这一刻起,Agent 的任何代码都不再碰得到这张单 ------流程实例 state=10(进行中),唯一的推进方式是参与者 leader 办理它。
二、Agent 想给自己签字?引擎第一个不答应
Agent 是有"自作主张"的能力和冲动的。如果 Agent 的代码(或者操纵它的 LLM)决定不排队了,直接把审批任务办掉呢?
python
inst = await engine.execute_process_task(leader_task.id, "ai-agent")
跑一下,引擎直接抛异常:
vbnet
ValueError: operator ai-agent not allowed
这不是约定,是校验:每次办理前引擎都会核对操作人是否在任务参与者列表里(_load_and_check)。leader 的单,只有 leader 办得了。闸门之所以是闸门,因为它得是物理的------ prompt 里写"请记得等待人工审批"是教育的,参与人校验是强制的。
人类的两个决定,Agent 各有一套下游动作。审批人驳回走独立方法(对齐 submitType=2 REJECT 契约):
python
inst = await engine.execute_and_jump_to_end(task.id, "leader") # 驳回,直接到结束
# → inst.state == InstanceState.REJECT (45)
审批人同意走普通办理,实例被引擎推进到结束节点自动完成:
python
inst = await engine.execute_process_task(task.id, "leader") # 同意
# → inst.state == InstanceState.DONE (20)
Agent 侧的闸门逻辑因此只有一个判断:state == 20 放行干活,state == 45 不动钱转人工。完整跑一遍两种结局(真实输出):
ini
------ 单人闸门:¥5000 ------
发起申请 state=10 待办=[('发起申请', 'ai-agent')]
提交申请 state=10 待办=[('上级审批', 'leader')]
单子停在「上级审批」,等人:leader
✗ 越权自批被拒:operator ai-agent not allowed
leader 驳回 state=45(45=REJECT)
⛔ 审批未过,Agent 不动钱
------ 单人闸门:¥300 ------
发起申请 state=10 待办=[('发起申请', 'ai-agent')]
提交申请 state=10 待办=[('上级审批', 'leader')]
单子停在「上级审批」,等人:leader
✗ 越权自批被拒:operator ai-agent not allowed
leader 同意 state=20(20=DONE)
✅ Agent 执行退款 ¥300
三、人这一侧:待办、页面、留痕
上面 demo 里"人类"是同一段脚本,现实里人和 Agent 是两个世界。jeeflow 的分法很朴素:引擎是嵌入式 SDK(Agent 进程里 pip install 直接用),但同一套门面也能起成 HTTP 服务,人这一侧用任何能调接口的东西对接------审批页面、企业微信、或者自带的演示前端。

把引擎起成服务后(jeeflow-python 仓自带 FastAPI demo,/wf/{action} 单入口转发 40+ 个 action),Agent 先把审批流部署上去。流程定义是一份 LogicFlow 格式的 JSON(画布拖拽的产物就是这个格式),首任务节点挂业务表单、参与人是发起人本人,第二个节点是主管审批:
json
{
"name": "refund-approval",
"displayName": "退款审批流程",
"type": "approval",
"nodes": [
{ "id": "start", "type": "snaker:start", "text": { "value": "开始" } },
{ "id": "apply", "type": "snaker:task", "text": { "value": "发起申请" },
"properties": { "form": "expense-form", "assignee": "applicant" } },
{ "id": "task1", "type": "snaker:task", "text": { "value": "主管审批" },
"properties": { "form": "expense-form", "assignee": "leader" } },
{ "id": "end", "type": "snaker:end", "text": { "value": "结束" } }
],
"edges": [
{ "id": "e0", "sourceNodeId": "start", "targetNodeId": "apply", "properties": {} },
{ "id": "e1", "sourceNodeId": "apply", "targetNodeId": "task1", "properties": {} },
{ "id": "e2", "sourceNodeId": "task1", "targetNodeId": "end", "properties": {} }
]
}
然后两个 HTTP 调用:deploy 部署流程,startAndExecute 发起单据。表单字段带 f_ 前缀------这是 mldong 系的表单契约:f_ 变量进实例变量,审批页面的表单按首任务节点 的 form 配置渲染、从这些变量取值。Agent 发起时把单据数据带上,审批人看到的就是一张填好的单:
python
POST /wf/processDefine/deploy
{"content": "<上面的流程 JSON>", "operator": "ai-agent"}
POST /wf/processInstance/startAndExecute
{"processDefineId": 111, "operator": "ai-agent",
"f_reason": "客户订单 #A1024 退款", "f_category": "other", "f_amount": 6666}
→ {"code": 0, "msg": "成功", "data": {"processInstanceId": "91195913311328"}}
两个契约细节:雪花 id 出口一律字符串化 (超过 JS 的 2^53 精度上限,裸数字到前端会丢位);响应信封 code=0 表示成功。踩坑提示:查详情的 action 入参名是 id,传成 processInstanceId 会得到 {"code": 99999999, "msg": "id 缺失或非法"}------错误码契约同样是引擎的一部分。
人这边,打开 jeeflow-ui(Vue3 演示前端,切到 Python 后端),审批人李四的"我的待办"里多了一张「退款审批流程」,点开详情------单据数据齐齐整整:报销事由"客户订单 #A1024 退款"、报销金额 6666、费用类别其他,发起人 ai-agent:

李四核对单据、填审批意见、点同意(面板上还有拒绝/退回上一步/退回发起人/跳转/转办------都是引擎原生的办理动作):

而 Agent 那一侧,从发起那一刻起就在挂起轮询实例状态(生产上也可以订阅引擎事件替代轮询)。完整实录,两边对上了:
ini
① Agent 部署审批流:define_id=111「退款审批流程」
② Agent 发起退款申请(单据:客户订单 #A1024 退款 ¥6666)
③ 单子 #91195913311328 停在审批人手里,Agent 挂起等待(每 2s 轮询 detail)...
④ 实例终态 state=20(20=DONE / 45=REJECT)
✅ 审批通过,Agent 执行退款 ¥6666
钱动没动、谁点的头、几点点的、批了什么意见,审批留痕里永远可查(连引擎自动生成的单据标题都带着发起人:用户ai-agent的退款审批流程-2026-09-11 00:41):

注意一个容易忽略的价值:Agent 走了人没走。审批人看到的是标准待办单据,他不需要知道背后是人是 Agent;哪天公司换成"超过 1 万要总监加签",改的是流程,不是人的习惯。
四、审批规则是数据,不是代码
闸门最值钱的性质:审批规则不在 Agent 的 prompt 里,在流程 JSON 里。把上面单人闸门的流程换成三人并行会签(05-countersign-parallel.json,assignee: "userA,userB,userC" + countersignType: PARALLEL),Agent 代码一行不改,闸门语义自动变成"三个人都点头才放行":
ini
------ 会签闸门:¥50000,userA/userB/userC 全员点头才放行 ------
发起申请 state=10 待办=[('发起申请', 'ai-agent')]
提交申请 state=10 待办=[('会签审批', 'userA'), ('会签审批', 'userB'), ('会签审批', 'userC')]
userA 同意 state=10 待签人数=2
userB 同意 state=10 待签人数=1
userC 同意 state=20 待签人数=0
✅ Agent 执行退款 ¥50000
会签没签满时,实例会一直停在节点上(引擎不做" majority 先走"的私自推断)。串行会签、按比例会签、一票否决(countersignCompletionCondition: ONE_VOTE_VETO,配合 submitType=20)都是同一套 JSON 属性开关,不再展开------这里只提醒一个分层细节:一票否决若走引擎方法直连,否决后剩余任务被废弃(任务态 99)、实例照常到结束(state 20),"被否决"这个语义在门面层以 countersignDisagreeFlag=1 变量记录;所以 Agent 判断闸门结果时要么走门面读变量,要么核对任务态,别只盯实例 state。
五、什么时候别用,以及生产姿势
诚实对照:如果你要的是 BPMN 全家桶(多级子流程、复杂事件网关、CMMN 案例管理),Flowable/Camunda 生态更全;如果是服务编排/长事务补偿(SAGA),Temporal 那类 durable execution 是正解。Python 生态里也有 BPMN 系的 SpiffWorkflow。jeeflow 这一格是:嵌入式、OA 审批语义(会签/退回/委托/抄送/转办)开箱即用、五张表契约、同一份流程 JSON 有 Java/Go/Python/Node/PHP/Rust/C#/MoonBit 八语言实现------适合"给业务系统/Agent 加一道标准审批闸门",不适合当通用编排器。
生产接线(内存仓储换 MySQL,pip install "jeeflow[mysql]"):
python
import aiomysql
from jeeflow import EngineImpl, JeeflowFacade, JdbcRepository, MySqlAdapter, TsIDGenerator
pool = await aiomysql.create_pool(host="...", user="...", password="...", db="...",
autocommit=True)
repo = JdbcRepository(MySqlAdapter(pool), TsIDGenerator())
engine = EngineImpl(repo, MyUserProvider())
facade = JeeflowFacade(engine, repo) # 40+ action 统一信封,含委托/流程设计扩展
AI Agent 这个物种还会继续进化,但"重要动作需要人类签字"大概率是长期需求------它不是 Agent 的能力问题,是组织的责任问题。引擎不替你判断 Agent 该不该被管,它只保证一件事:没有那双人类的手点过头,单子就停在那里。
参考资料
- GitHub 仓库:github.com/mldong/jeef...
- PyPI:pypi.org/project/jee...
- 文档站:jeeflow-doc.mldong.com/
- 在线演示站(八语言切换):jeeflow-demo.mldong.com/
- 前端 jeeflow-ui:github.com/mldong/jeef...