AI Agent 不能自己签字:用 Python 工作流引擎给 AI 加一道人类审批闸门

AI 应用开发者的 2026 年大概是这样的:Agent 能自己查库、自己下单、自己发邮件、自己改配置。框架文档里"human-in-the-loop"那一节教你的做法,通常是让程序停在一行 input("按 y 继续") 上------或者更糟,往群里发张截图等老板回复。

这不是审批。审批是组织里最古老的风险控制,它至少包含四样东西:单据 (谁发起的、要干什么、金额多少)、留痕 (谁在几点批的、批了什么意见)、规则 (多少钱要谁批、要不要会签)、驳回 (不通过时单子怎么回去)。input() 一样都给不了。

而这四样东西,恰好是一个工作流引擎做了几十年的事。这篇用一个 98KB 级的 Python 轻量工作流引擎 jeeflowpip 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" 契约:申请节点的 assigneeapplicant,解析为实例发起人;谁发起,谁提交。所以 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 该不该被管,它只保证一件事:没有那双人类的手点过头,单子就停在那里

参考资料

相关推荐
她的男孩1 小时前
多租户隔离怎么落地?拆完这1600行Starter源码,我把5个坑全踩明白了
java·后端·架构
宫水三叶的刷题日记1 小时前
铁打的影视飓风,流水的新 iPhone
后端
青少儿编程课堂1 小时前
贪心算法进阶:区间调度与最少资源整合解析
c++·python·算法·贪心·信息学竞赛·区间调度
Yanjun2i1 小时前
Agent学习记录六:Tool 类 + Tool Registry
开发语言·python·学习
Bs_MoneyMagnet1 小时前
基于springboot+vue的医院陪诊服务预约平台的设计与实现 源码+文档
java·vue.js·spring boot·后端·spring
ShallWeL1 小时前
【Agent工程】(15)—— 评测集与回归门禁
人工智能·agent·工作流
SimonKing1 小时前
一个Docker命令,40万首古诗词API开箱即用
java·后端·程序员
kcuwu.1 小时前
第 1 课 · Hello, World 与一个 Go 程序的诞生
开发语言·后端·golang
php@king1 小时前
golang入门到精通
开发语言·后端·golang