麻烦不在跑,在跑完之后什么都没留下
我手里有一类重复活:给一个小功能写一句海报文案,配一张带中文标题的图,再配一句口播。三步,每步都是一次模型调用。
以前我在对话里一步步跑。文案留在聊天记录里,图落在临时目录里,下一步要引用上一步的输出就得手动复制粘贴,中间哪一步不满意,整段对话重头再来。验收这一步更靠人:图上的中文标题到底渲染对没有,我得自己打开图看,看漏了就发出去了。
bl 2.0.1(macOS)里有个命令组叫 bl pipeline,只有两条命令:bl pipeline validate --file <文件> 和 bl pipeline run --file <文件>。一个 YAML 文件写清楚几步、谁依赖谁、每步的输入从哪来,然后一条命令跑完。这周我用它跑通了一条六步链路:写文案、出海报、让视觉模型自己看图验收、判定、断言卡关、配音。全程 103 秒,产出一张图、一段音频、一份每步带耗时的 JSON。
环境交代清楚:API Key 走环境变量,下面所有命令和输出都是 2026-10-07 深夜到 10-08 凌晨我自己跑的的,逐字可复现。

十种步类型,这条链路用掉六种
| 分类 | 类型 |
|---|---|
| 模型步(七种) | text/chat、vision/describe、image/generate、image/edit、video/generate、speech/synthesize、speech/recognize |
| 逻辑与脚本步(三种) | logic/assert、logic/select、script/js |
我这条链路用到了六种:text/chat 写文案、image/generate 出图、vision/describe 看图、script/js 做判定、logic/assert 卡关、speech/synthesize 配音。logic/select 在类型表里,我这轮没用到它,也没单独验它的行为。image/edit、video/generate、speech/recognize 同理,没进这条链路。
架构:依赖、数据流、分层,都写在文件里
步与步的关系靠 dependsOn 声明,跑起来就是一张 DAG。数据不靠复制粘贴,靠两种引用写法:
{$input: "/brief"},取运行时传进来的参数(--input '{"brief": "..."}'){$from: "copy", path: "/data/choices/0/message/content"},取前面某一步的输出,path 是 JSON 路径
文件最外层的 version 必须是 workflow/v1,inputs 是一段 JSON Schema(type: object 加 properties),不是随便写的键值对。这两处我都错过:version 嵌到 pipeline: 下面,validate 反复报 pipeline.version must be "workflow/v1";inputs 写成 inputs: {topic: {type: string}},报 pipeline.inputs has unsupported keyword "topic"。
先用一条两步的最小链路确认引用写得对:
version: workflow/v1
inputs:
type: object
properties:
topic:
type: string
steps:
- id: say
type: text/chat
input:
message:
$input: /topic
- id: check
type: logic/assert
dependsOn: [say]
input:
condition:
$from: say
path: /data/choices/0/message/content
message: "上一步没有产出文本"
bl pipeline run --file w6.yaml --input '{"topic":"用一句话解释什么是ASR热词表"}'
status: succeeded。模型答的是:"ASR热词表是一组需要提升识别准确率的关键词或短语(如人名、产品名、专业术语),用于引导语音识别模型更优先或更准确地识别这些内容。" check 步拿到这段文本,非空,返回 {"ok": true}。
分层也是写在 YAML 里的。六步链路的 copy 步我显式指定了 qwen3.8-flash:写一句海报标题不需要最重的模型,其余步用各自默认。哪一步用什么模型,是文件里看得见的一行,不是对话里的一句口头约定。产物同样有归属:image/generate 步给了 out-dir 和 out-prefix,图直接写进 ./out2/hero.png;配音步返回音频 URL,我把它另存为本地 wav,96KB。
103 秒花在哪
bl pipeline run --file chain.yaml --input '{"brief":"为阿里云百炼的命令行工具写一句不超过12个字的中文海报主标题,要求口语、有动作感,只输出标题本身,不要引号不要解释"}' --output json
| 步 | 类型 | 耗时 | 产出 |
|---|---|---|---|
| copy | text/chat | 9.1s | 文案"百炼,一敲就灵" |
| poster | image/generate | 91.5s | 海报图落盘 out2/hero.png |
| check | vision/describe | 2.1s | "有。 看到的文字是:百炼,一敲就灵" |
| judge | script/js | 0.0s | {"ok": true} |
| gate | logic/assert | 0.0s | {"ok": true},放行 |
| voice | speech/synthesize | 0.5s | 一段 wav 口播 |

验收这一步是真的在看图。check 的输入是 poster 返回的图片 URL,视觉模型把图上的字逐字念回来,和 copy 步写的文案一致。念不出来,judge 的 ok 就是 false,gate 会把整条链路停在自己这里,配音根本不会发生。验收从"我打开图看一眼"变成链路里的一个节点。
--output json 的结果里每步都带 startedAt、finishedAt、attempts。91.5 秒花在出图上,其余五步加起来 11.7 秒。下次要优化不用猜,看这张表就知道该动哪一步。
放进日常流程之前,先看失败长什么样
教程只写成功跑法是不够的,失败时的行为才决定你敢不敢把它交给别人跑。我故意让它挂了两次,看它怎么停。
第一次是断言卡关。判定"文案里必须出现'雨'字",模型输出的是"今天晴":
gate failed {"code":"logic_assertion_failed",
"message":"判定不通过:文案里没有'雨'字,停在这里"}
after skipped dependency gate failed
整条 run 的退出码是 1,下游全部 skipped,skip 原因写在输出里。放进脚本里就是 if [ $? -ne 0 ] 能接住的失败,不是静默通过。
第二次是引用路径写错,我把 check 的 path 写成 /urls/0,漏了 data 一层:
check failed {"code":"pipeline_reference_error",
"message":"Step output path not found: poster/urls/0"}
judge skipped dependency check failed
gate skipped all dependencies skipped
报错直接点出缺的是哪一段路径。
这类失败还能逐条回放,开关是 --events jsonl。它要的是格式关键字,不是文件名:我传 --events events.jsonl,报错逐字是 Flag --events must be one of: jsonl.。改对之后,pipeline.started、step.started、step.input.resolved、step.succeeded、step.failed、step.skipped 这些事件按发生顺序逐行打到标准输出,每行一个 JSON 对象,带 timing.durationMs 和 skip 的 reason。

图里那条红色的 step.failed 来自一次真实的卡死:出图那步的异步任务卡了 958 秒(16 分钟)才被中止,durationMs 是 958097,报 This operation was aborted,它后面的 check、judge 全部 skipped。顺带把一个误解打破了:--timeout 不是整条 run 的硬上限,异步模型步卡住时它兜不住。跑长链路的时候人别走开太远,或者把 --step-timeout 设上。
上手三步
装 CLI:npm install -g bailian-cli,安装说明在阿里云百炼 CLI 安装页。领 Key:控制台密钥管理页,领完 bl auth login --api-key sk-你的key。然后把六步 YAML 存成 chain.yaml,先 validate 再 run。
validate 不需要 API Key,也不发起模型调用,可以先拿它检查文件写得对不对;想看运行时会把输入解析成什么又不真的执行,加 --dry-run。第一次跑建议把 poster 步的 out-dir 指到一个空目录,跑完打开那张图,跟 check 步念回来的文字对一遍。
边界与未验项
validate是弱校验。它查结构(version、steps、id、type、input 是不是对象)和语义(type 注册过没有、dependsOn 是不是字符串数组),不查引用路径写没写对,也不查步的 input 字段名存不存在。当语法检查用,别当正确性证明用。- 花括号占位符不生效。我在 message 里写过
{ {inputs.topic}},它不会被替换,模型收到的是字面的{ {inputs.topic}},而 validate 对此不报错。数据流一律走$input/$from。 - 显式传
--concurrency 1时,双根流水线我跑了三次挂了两次,进程不退出,--timeout 45和--timeout 90都不生效,最后是手动杀的。同一条 YAML 不传这个参数(默认值也是 1)一次成功,传 2 和 3 各一次成功,线性流水线传 1 也成功。所以是偶发的调度问题,只在"多个无依赖起始步 + 显式传 1"时出现。我的处置是要么不传,要么传 2 以上,这个问题我会反馈给 CLI 团队。 - 链路里有两个互不依赖的步时可以并发。同一条"两个独立文本步 + 一个汇合步"的 YAML,不传
--concurrency是 13s,传 2 是 6.0s,传 3 是 5.8s,汇合步等两者都完成再跑。 retry和步级timeout这两个字段 schema 里存在,validate 也接受,但这轮没能把它们的行为稳定验出来,所以本文不写它们的具体语义。video/generate是异步轮询的,单次几十秒到几分钟,要快反馈的链路别把它放进来。- schema 没有官方文档页。本文的字段清单是我从 2.0.1 的安装包里逐段读出来、再用 validate 和 run 双向验过的,CLI 升级后字段可能变,照抄前先用 validate 过一遍。