摘要:企业测试造数不是把几个接口串起来就结束。真正难的是:业务链路长、场景差异多、请求体复杂、状态异步、失败路径难排查。如果每个场景都写一份脚本,后期会很快失控;如果全部塞进低代码平台,又会变成节点巨图。本文复盘一个 AI Agent 造数 Skill 的 Python 编排引擎设计:用 YAML 描述流程,用 9 种 Action 表达 HTTP、SQL、模板、条件、分支、循环、路由、数据修正和集合过滤,把复杂造数链路从"脚本堆"变成可配置、可复用、可校验、可排障的轻量流程 DSL。
写在前面
适合谁看: 测试开发、后端开发、平台工程师、正在做 AI Agent 工具工程化的人。
核心问题: 复杂测试造数流程,如何从脚本自动化升级成配置驱动的编排系统?
核心结论: YAML 不是简单参数表,而是一个小型流程 DSL;Action 数量要少,但表达力要够。核心 6 种 Action 加扩展 3 种 Action,已经能覆盖大多数企业造数链路。
一句话答案: 把稳定能力抽成 Action,把业务差异放进 YAML,把复杂请求体交给模板,把执行通道留给 Dify。
系列定位: 这是执行骨架篇。第 3 篇讲多模态如何把截图变成参数;本篇讲这些参数进入引擎后,如何被 YAML 和 Action 稳定编排。
关键边界: 多模态不是第 10 种 Action,它发生在 Action 之前;Action 只消费已经结构化、已校验的参数。
一、为什么需要一个编排引擎
在做造数 Skill 之前,最直接的方案是写脚本。
造数脚本最麻烦的地方,不是第一条链路难写,而是第五条、第十条链路开始出现时,你会发现每个脚本都长得很像,但谁也不敢合并。
它们都有 HTTP、SQL、审批、等待、重试、结果查询;又都有一点点业务差异。继续复制,短期最快,长期最难维护。
我在这个项目里做的关键工作,不是多接了几个接口,而是把"造数流程里反复出现的动作"抽象成 9 种 Action,让业务差异留在 YAML,让稳定能力沉淀到引擎。
比如一条采购订单测试数据,可以写成这样:
python
check_material()
create_request()
query_request_no()
approve_request()
query_contract()
create_order()
approve_order()
query_final_no()
这个脚本能跑通一个场景。
但问题是,企业造数很少只有一个场景。
真实系统里会有很多变化:
- 不同订单类型走不同创建接口。
- 不同组织需要不同参数。
- 有些场景要查合同,有些场景不用。
- 有些物料要额外校验。
- 有些字段为空时不能传。
- 审批和下游推送需要等待。
- SQL 查询必须避免查到历史数据。
- 失败后要能知道卡在哪一步。
如果继续复制脚本,最后会变成:
text
场景 A 脚本
场景 B 脚本
场景 C 脚本
场景 D 脚本
...
每个脚本里都有相似的 HTTP、SQL、模板、审批、等待、重试逻辑,但又夹杂着少量业务差异。
这就是最难维护的状态:大量相似,但没有抽象。
所以我没有继续堆脚本,而是做了一个轻量编排引擎。
这个项目里,我真正投入最多的不是某一个接口调用,而是抽象流程语言:哪些能力应该成为 Action,哪些应该留在配置里,哪些应该沉淀为共享步骤。这个抽象决定了系统能不能从"跑通一个场景"扩展到"维护一类场景"。
二、整体设计:YAML 是流程 DSL,不是参数表
很多人一听 YAML,会觉得它只是配置文件。
但在这个造数 Skill 里,YAML 承担的是流程描述能力。
它要表达:
- 输入参数。
- 执行步骤。
- 步骤之间的依赖。
- 条件分支。
- 循环。
- 子流程路由。
- 模板渲染。
- 结果提取。
- 终止状态。
一个脱敏后的简化示例:
yaml
name: sample-order-flow
inputs:
orderType:
required: true
supplierCode:
required: true
materials:
required: true
type: list
steps:
- id: build_request
action: template
template: templates/order-request.json.j2
context:
orderType: "{{ inputs.orderType }}"
supplierCode: "{{ inputs.supplierCode }}"
materials: "{{ inputs.materials }}"
- id: create_request
action: http_call
params:
url: "{{ config.api_base }}/purchase/request"
method: POST
body: "{{ steps.build_request.output }}"
extract:
code: "$.body.code"
request_no: "$.body.data.requestNo"
- id: check_result
action: condition
if: "{{ steps.create_request.extracted.code }} == '0'"
then: query_request
else: END_CREATE_FAIL
这里的关键不是 YAML 语法,而是这套结构背后的模型。
我把每一步都抽象成:
text
step = id + action + params/context + extract + goto
也就是说,一个步骤至少要回答四个问题:
| 问题 | 对应字段 |
|---|---|
| 这一步叫什么 | id |
| 这一步做什么 | action |
| 这一步需要什么输入 | params / context |
| 这一步产出什么结果 | extract / output |
| 执行完去哪 | goto / then / else |
这样做之后,流程不再藏在 Python 函数调用顺序里,而是变成了可以被阅读、校验、复用的配置。

三、9 种 Action:核心 6 种,扩展 3 种
我没有设计很多 Action。
Action 太多,配置会变得难学;Action 太少,又表达不了真实业务。
最后沉淀下来的是"核心 6 种 + 扩展 3 种"。
| 类型 | Action | 作用 | 是否调用 Dify |
|---|---|---|---|
| 核心 | http_call |
调业务接口 | 是 |
| 核心 | sql_query |
查询数据库状态 | 是 |
| 核心 | template |
渲染复杂请求体 | 否 |
| 核心 | condition |
条件分支 | 否 |
| 核心 | switch |
多路分支 | 否 |
| 核心 | loop |
遍历集合并执行子步骤 | 间接调用 |
| 扩展 | route |
按业务类型路由到子流程 | 否 |
| 扩展 | sql_execute |
必要时做测试数据修正 | 是 / 可配置 |
| 扩展 | filter_collection |
对查询结果做集合过滤 | 否 |
这 9 种 Action 的边界很重要。
http_call 和 sql_query 是执行动作,负责和外部系统交互。
template、condition、switch、loop 是流程表达动作。
route、sql_execute、filter_collection 是复杂企业场景里补出来的扩展动作。
我不希望 Action 变成"一个业务接口一个 Action"。
例如不要设计成:
text
create_purchase_request
approve_purchase_request
query_contract
create_purchase_order
approve_purchase_order
query_final_no
这种设计看起来语义更强,但扩展性会变差。
更稳的抽象应该是:
text
http_call + sql_query + template + condition + loop + route
业务语义放在 YAML 和模板里,底层动作保持通用。
四、Action 逐个拆开看
1. http_call:业务接口统一入口
http_call 负责调业务接口。
它不关心这是创建申请、触发审批,还是重新推送下游。它只关心:
- URL 是什么。
- Method 是什么。
- Header 是什么。
- Body 是什么。
- 返回结果怎么提取。
示例:
yaml
- id: create_order
action: http_call
params:
url: "{{ config.api_base }}/purchase/order"
method: POST
headers:
Authorization: "{{ context.auth_header }}"
body: "{{ steps.build_order_body.output }}"
extract:
code: "$.body.code"
message: "$.body.message"
order_no: "$.body.data.orderNo"
这里要注意:复杂请求体不在 http_call 里拼,而是前一步由 template 生成。
2. sql_query:查询中间态和结果
sql_query 负责查流程状态。
在造数链路里,很多关键结果不是接口直接返回的,而是需要查数据库确认:
- 申请是否落库。
- 审批状态是否变化。
- 订单是否生成。
- 下游单号是否回写。
示例:
yaml
- id: query_order
action: sql_query
wait: 5
retry:
max: 3
interval_ms: 5000
params:
db_uri: "{{ config.db_uri }}"
query: >
SELECT order_no
FROM purchase_order
WHERE request_no = '{{ steps.create_request.extracted.request_no }}'
ORDER BY id DESC
LIMIT 1
extract:
order_no: "$.rows[0].order_no"
SQL 查询的关键不是"查到一条",而是"查到本次流程生成的那条"。
所以我会强调:
- 查询条件尽量绑定当前流程上下文。
- 默认加
LIMIT。 - 查不到时要结合
wait和retry判断是异步延迟还是业务失败。
3. template:复杂 JSON 的治理层
企业接口请求体一复杂,就不适合直接写在 Python 里,也不适合塞在低代码节点里。
template 负责调用 Jinja2 模板生成请求体:
yaml
- id: build_order_body
action: template
template: templates/order-body.json.j2
context:
supplierCode: "{{ inputs.supplierCode }}"
materials: "{{ inputs.materials }}"
orderType: "{{ inputs.orderType }}"
模板里可以处理:
- 多物料循环。
- 条件字段。
- 默认值。
- 枚举映射。
- 空字段裁剪。
template 的价值是把请求体结构从流程逻辑里分离出来。
4. condition:二选一分支
condition 用于简单判断。
例如创建成功继续,创建失败终止:
yaml
- id: check_create_result
action: condition
if: "{{ steps.create_order.extracted.code }} == '0'"
then: query_order
else: END_CREATE_FAIL
我不建议把特别复杂的业务表达式都塞进 condition。
如果判断越来越复杂,应该拆成多个步骤,或者沉淀成更明确的校验 Action。
5. switch:多路分支
switch 适合处理枚举型分支。
例如不同订单类型走不同后续步骤:
yaml
- id: choose_flow
action: switch
switch_on: "{{ inputs.orderType }}"
cases:
- match: STANDARD
goto: build_standard_body
- match: SPECIAL
goto: build_special_body
default: END_UNSUPPORTED_TYPE
condition 适合二选一,switch 适合多选一。
这个边界清楚后,配置会更容易读。
6. loop:多物料、多记录的循环处理
造数场景里经常会有集合:
- 多个物料。
- 多个订单行。
- 多条合同候选。
- 多条校验结果。
loop 用来遍历集合并执行子步骤:
yaml
- id: check_materials
action: loop
items: "{{ inputs.materials }}"
loop_var: material
collect: material_check_results
steps:
- id: check_one_material
action: http_call
params:
url: "{{ config.api_base }}/material/check"
method: POST
body:
materialCode: "{{ context.material.code }}"
extract:
code: "$.body.code"
message: "$.body.message"
这里有一个小但重要的经验:collect 不要都叫 results。
如果多个循环都叫 results,后面会覆盖前面的结果。语义化命名可以减少排障成本:
material_check_resultscontract_query_resultsstatus_update_results
7. route:按业务类型进入子流程
当场景差异很大时,不适合在一个 YAML 里写满 switch。
这时可以用 route 路由到子流程:
yaml
- id: route_by_order_type
action: route
switch_on: "{{ inputs.orderType }}"
routes:
STANDARD: configs/standard-order.yaml
SPECIAL: configs/special-order.yaml
SEMI: configs/semi-finished-order.yaml
goto_fail: END_UNSUPPORTED_TYPE
这样主流程只负责分发,子流程负责各自细节。
这能避免一个 YAML 文件无限膨胀。
8. sql_execute:必要的数据修正
正常情况下,造数工具应该尽量通过业务接口完成数据流转。
但在测试环境里,有些链路会遇到需要补齐测试状态或修正测试数据的情况。
这时可以提供受控的 sql_execute:
yaml
- id: fix_test_status
action: sql_execute
params:
db_uri: "{{ config.db_uri }}"
query: >
UPDATE test_order
SET status = 'READY'
WHERE request_no = '{{ steps.create_request.extracted.request_no }}'
这个 Action 要谨慎使用。
我的建议是:
- 只用于测试环境。
- 只用于明确的测试数据修正。
- SQL 必须绑定当前流程上下文。
- 必须有日志和审查。
- 公开文章不展示真实库表和真实 SQL。
9. filter_collection:把大结果收敛成当前需要的数据
有时接口或 SQL 会返回一批候选数据,但流程只需要其中几条。
例如用户输入了物料列表,接口返回了更多物料信息。此时需要按用户输入过滤:
yaml
- id: filter_materials
action: filter_collection
source: "steps.fetch_materials.extracted.materials_data"
match_field: "materialCode"
input_ref: "inputs.materials"
output: "matched_materials"
filter_collection 的价值是让集合筛选显式化。
否则这类逻辑很容易散落在模板、SQL 或 Python 临时代码里。
五、上下文如何传递
编排引擎最重要的能力之一,是让步骤之间可以传递结果。
我把上下文分成几类:
| 上下文 | 作用 |
|---|---|
inputs |
用户输入的原始参数 |
config |
环境配置,如接口基址、数据库连接占位 |
steps |
每个步骤的执行结果和提取字段 |
context |
临时上下文,常用于 loop 当前项 |
collected |
loop 收集到的批量结果 |
一个典型引用长这样:
yaml
body: "{{ steps.build_order_body.output }}"
或者:
yaml
query: >
SELECT order_no
FROM purchase_order
WHERE request_no = '{{ steps.create_request.extracted.request_no }}'
LIMIT 1
循环里会用到 context:
yaml
body:
materialCode: "{{ context.material.code }}"
循环结果会进入 collected:
yaml
items: "{{ collected.material_check_results }}"
这个上下文设计让步骤之间不需要硬编码 Python 变量,而是通过统一引用规则传递数据。
六、为什么要有共享步骤
当场景变多后,会出现很多重复步骤:
- 获取鉴权上下文。
- 查询物料信息。
- 校验供应商关系。
- 查询申请信息。
- 审批申请。
- 查询订单信息。
- 审批订单。
- 查询下游单号。
- 重新推送下游。
如果每个场景 YAML 都复制这些步骤,维护成本会很高。
所以我引入了共享步骤,通过 use 引用。
示例:
yaml
use:
- common-steps.get_auth
steps:
- use: common-steps.check_materials
- use: common-steps.approve_request
- id: custom_step_for_this_scene
action: http_call
params:
url: "{{ config.api_base }}/scene/custom"
method: POST
use 有两个价值:
-
把通用能力沉淀到共享步骤。
场景文件只保留业务差异。
-
让变更集中。
如果审批逻辑调整,只需要改共享步骤,不需要改所有场景文件。
这也是配置化能不能长期维护的关键。
配置化不是把所有内容摊平到 YAML 里,而是要继续分层:公共步骤归公共步骤,场景差异归场景文件。
七、错误处理、等待和状态快照
造数链路里,失败是常态。
常见失败包括:
- 参数缺失。
- 接口返回业务错误。
- 数据还没落库。
- 审批状态还没更新。
- SQL 查不到结果。
- 路由目标写错。
- 模板文件不存在。
所以编排引擎不能只设计成功路径。
1. 等待和重试
对于异步链路,可以在步骤上声明等待和重试:
yaml
- id: query_final_no
action: sql_query
wait: 5
retry:
max: 3
interval_ms: 5000
这样流程不会因为下游慢几秒就立即失败。
2. 终止状态
失败时不要只抛异常,最好能给出明确终止原因:
yaml
terminals:
END_CREATE_FAIL:
type: fail
message: "创建申请失败,请检查参数或接口返回"
END_SUCCESS:
type: success
message: "造数完成,订单号:{{ steps.query_order.extracted.order_no }}"
终止状态让用户看到的是业务语义,而不是一串堆栈。
3. 状态快照
长链路最怕跑到第 8 步失败,然后不知道前面发生了什么。
所以执行过程中要保存状态快照,包括:
- 当前步骤。
- 用户输入。
- 步骤结果。
- 提取字段。
- 循环收集结果。
- 临时上下文。
状态快照的价值是:失败后可以复盘,不需要靠记忆排查。
八、静态校验:让配置错误尽量提前暴露
当 YAML 变成流程 DSL 后,它就必须能被校验。
否则配置化会变成另一种不稳定。
我补了一层静态校验,用来检查:
| 校验项 | 目的 |
|---|---|
| YAML 能否加载 | 避免格式错误 |
| action 是否合法 | 避免写错动作 |
| step id 是否重复 | 避免结果覆盖 |
goto / then / else 是否存在 |
避免跳到不存在的步骤 |
route 子流程是否存在 |
避免运行中才发现配置缺失 |
| template 文件是否存在 | 避免执行到一半才失败 |
loop 是否使用默认 collect=results |
提醒改成语义化名称 |
| 是否存在明显敏感信息 | 避免 Token、连接串、内部地址泄露 |
这层校验的意义很直接:能在执行前发现的问题,不要拖到执行中。
对于企业内部工具来说,静态校验不是锦上添花。只要工具开始给别人用,配置错误就会成为稳定性问题。
九、这个设计带来的变化
做编排引擎之前,造数能力更像脚本集合。
做完之后,它变成了一个可扩展的流程系统。
| 原来 | 现在 |
|---|---|
| 每个场景一份脚本 | 每个场景一份 YAML |
| 请求体写在代码里 | 请求体放在 Jinja2 模板 |
| 流程顺序靠函数调用 | 流程顺序由 steps 描述 |
| 分支逻辑散落在 if 里 | condition / switch 显式表达 |
| 多物料处理靠临时代码 | loop + collect 统一表达 |
| 公共步骤复制粘贴 | use 引用共享步骤 |
| 执行失败靠人工猜 | 状态快照和终止状态辅助排障 |
| 配置错误运行时才发现 | 静态校验提前暴露 |
最重要的变化是:新增场景的方式变了。
以前是复制一份脚本,然后小心翼翼地改。
现在是组合已有 Action、复用共享步骤、补充场景 YAML 和模板。
这就是从"自动化脚本"到"编排引擎"的区别。
十、如果你也要设计类似引擎
如果你也在做测试造数、企业流程自动化或 Agent 工具编排,我建议按这个顺序设计。
1. 先抽动作,不要先抽业务名词
不要一上来就设计很多业务专用 Action。
先抽稳定动作:
- HTTP。
- SQL。
- 模板。
- 条件。
- 分支。
- 循环。
- 路由。
业务差异尽量放到配置和模板里。
2. Action 要少,但上下文要清楚
Action 少,学习成本低。
上下文清楚,组合能力才强。
至少要定义清楚:
- 用户输入在哪里。
- 环境配置在哪里。
- 上一步结果在哪里。
- 循环当前项在哪里。
- 批量结果在哪里。
3. 共享步骤要早做
只要第二个场景开始复制同样的步骤,就应该考虑共享。
共享步骤能把团队经验沉淀下来,也能减少场景文件里的噪音。
4. 静态校验要早做
不要等配置变多之后才补校验。
一开始就校验 action、step id、goto、route、template、敏感信息,会少掉很多低级故障。
5. 不要把 YAML 写成另一种代码
这是最容易走偏的地方。
如果 YAML 里出现越来越复杂的表达式、越来越长的业务判断、越来越多的临时字段,说明抽象可能需要调整。
YAML 应该描述流程,不应该承载所有业务计算。
十一、总结:Action 设计决定系统能走多远
这个造数 Skill 的编排引擎,本质上不是为了"让 YAML 看起来高级"。
它解决的是一个很实际的问题:如何把复杂业务造数流程,从个人脚本升级成团队可维护的工程资产。
核心设计可以概括成几句话:
- 用 YAML 描述流程,而不是把流程写死在脚本里。
- 用 9 种 Action 覆盖大多数造数动作。
- 用 Jinja2 管理复杂请求体。
- 用
inputs、config、steps、context、collected传递上下文。 - 用
use沉淀共享步骤。 - 用等待、重试、终止状态、状态快照处理失败路径。
- 用静态校验把低级错误挡在执行前。
- Dify 只作为 HTTP / SQL 执行通道,不承载业务编排复杂度。
对我来说,这个项目最有价值的地方,不是跑通了某一条链路,而是把"怎么造数"这件事抽象成了一套可描述、可复用、可校验的流程语言。
FAQ
1. 为什么是 9 种 Action,不是更多?
Action 太多会增加学习和维护成本。当前核心 6 种加扩展 3 种,已经能覆盖 HTTP、SQL、模板、条件、分支、循环、路由、数据修正和集合过滤,大多数造数链路可以组合出来。
2. YAML 会不会变得很复杂?
会有这个风险。所以需要共享步骤、模板拆分和静态校验。YAML 应该描述流程,不应该承载所有业务计算。
3. 为什么不用低代码平台直接画完整流程?
短流程可以直接画。复杂企业造数链路涉及多分支、多模板、多 SQL、多异步状态和失败恢复,全部放进低代码平台会变成难维护的节点巨图。
4. condition 和 switch 怎么区分?
condition 适合二选一判断,例如成功或失败。switch 适合枚举型多路分支,例如不同订单类型进入不同步骤。
5. route 和 switch 有什么区别?
switch 通常在当前流程内跳转。route 更适合把不同业务类型分发到不同子流程文件,避免一个 YAML 文件无限膨胀。
6. 共享步骤的价值是什么?
共享步骤能把通用的鉴权、查询、审批、结果获取等能力沉淀下来。场景文件只保留差异,后续修改公共逻辑也更集中。
7. 这个设计最大的个人价值体现在哪里?
价值在于抽象能力:识别哪些能力应该成为通用 Action,哪些差异应该留在 YAML,哪些步骤应该共享,哪些错误应该由静态校验提前发现。这个抽象决定系统能不能从一个场景扩展到一类场景。