bl pipeline 踩坑记:一个 YAML 跑六步,改了三处文件、挂了两次链路
最近在搞一件重复的活
最近我手里有一件重复的活:给一个小功能写一句海报文案,配一张带中文标题的图,再配一句口播。三步,每步一个模型调用。以前在对话里一步步跑,跑完什么都不留下:文案在聊天记录里,图在临时目录里,下一步要用上一步的输出就得自己复制粘贴,中间哪一步不满意,整段重来。
上个月我把这些步骤搬进了一个文件。没上工作流平台,用的是阿里云百炼 CLI(bl)自带的命令组 bl pipeline:一个 YAML 写清楚几步、谁依赖谁、每步的输入从哪来,然后一条命令跑完。这篇是过程记录,把我踩到的坑都留在里面。
环境:bl 2.0.1,macOS,API Key 走环境变量。所有输出都是 10 月 7 号深夜到 8 号凌晨我在自己电脑上跑出来的。
装和配
装 CLI 用 npm install -g bailian-cli,安装说明在阿里云百炼 CLI 安装页。Key 在控制台密钥管理页领,领完 bl auth login --api-key sk-你的key。
命令组只有两条命令:bl pipeline validate --file <文件> 和 bl pipeline run --file <文件>。validate 不需要 API Key,也不发起模型调用,先拿它检查文件写得对不对。
第一次写文件,改了三处
第一处是 version。我把 version: workflow/v1 嵌在了 pipeline: 下面,validate 反复报 pipeline.version must be "workflow/v1",改了三次才定位到是缩进层级的问题:version 要写在文件最外层。
第二处是 inputs。我以为是普通键值对,写成 inputs: {topic: {type: string}},报错 pipeline.inputs has unsupported keyword "topic"。它其实是一段 JSON Schema,要 type: object 加 properties。
第三处 validate 不报。我在 message 里写过花括号占位符 {{inputs.topic}},它不会被替换,模型收到的是字面的 {{inputs.topic}},甚至在思考过程里自己吐槽了一句"变量 {{inputs.topic}} 原样出现,可能是模板占位符未填充"。从那以后我的规矩是:数据流一律用 {$input: "/brief"} 和 {$from: "copy", path: "..."} 这两种对象写法,前者取运行时传进来的参数,后者取前面某一步的输出。
三处改完,两步的最小链路跑通了:
bl pipeline run --file w6.yaml --input '{"topic":"用一句话解释什么是ASR热词表"}'
status: succeeded。模型答的是:"ASR热词表是一组需要提升识别准确率的关键词或短语(如人名、产品名、专业术语),用于引导语音识别模型更优先或更准确地识别这些内容。" check 步拿到这段文本,非空,返回 {"ok": true}。
六步链路跑通,103 秒
真正想跑的是这条:写海报主标题、出图、让视觉模型看图验收、判定、断言卡关、配音。完整文件如下:
version: workflow/v1
inputs:
type: object
properties:
brief:
type: string
steps:
- id: copy
type: text/chat
input:
model: qwen3.8-flash
message:
$input: /brief
- id: poster
type: image/generate
dependsOn: [copy]
input:
prompt:
$from: copy
path: /data/choices/0/message/content
size: "3:4"
out-dir: ./out2
out-prefix: hero
- id: check
type: vision/describe
dependsOn: [poster]
input:
image:
$from: poster
path: /data/urls/0
prompt: "这张图里有没有中文标题文字?先回答有或没有,再把你看到的文字逐字引用出来"
- id: judge
type: script/js
dependsOn: [check]
input:
code: "const t = args.text || ''; const ok = t.indexOf('有') === 0; return { ok: ok, quoted: t.slice(0, 80) }"
args:
text:
$from: check
path: /data/choices/0/message/content
- id: gate
type: logic/assert
dependsOn: [judge]
input:
condition:
$from: judge
path: /data/ok
message: "海报上没有中文标题,这条流水线不合格,停在这里"
- id: voice
type: speech/synthesize
dependsOn: [gate]
input:
voice: longhua_v3
text:
$from: copy
path: /data/choices/0/message/content
bl pipeline run --file chain.yaml --input '{"brief":"为阿里云百炼的命令行工具写一句不超过12个字的中文海报主标题,要求口语、有动作感,只输出标题本身,不要引号不要解释"}' --output json
跑完 status: succeeded,103 秒,每步耗时:
| 步 | 类型 | 耗时 | 产出 |
|---|---|---|---|
| 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 会把整条链路停在自己这里,配音根本不会发生。以前验收是我打开图看一眼,现在这一步在链路里,跑一次就留一次记录。

还有两件事值得记。产物落本地盘:image/generate 步给了 out-dir 和 out-prefix,图直接写进 ./out2/hero.png,不用自己下载;配音步返回音频 URL,我存成了本地 wav,96KB。分层写在文件里:copy 步我显式指定 qwen3.8-flash,写一句标题不需要最重的模型,哪一步用什么模型是文件里看得见的一行。
我故意让它挂了两次
只写成功跑法的教程是不够的,我更想知道它失败时长什么样,所以故意让它挂了两次。
第一次是断言卡关。我写了一条判定"文案里必须出现'雨'字"的链路,模型输出的是"今天晴":
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
报错直接告诉你缺的是哪一段路径。模型步的输出都在 /data 下,照输出 JSON 抄路径就不会错。
并发开关,和一次挂死
链路里有两个互不依赖的步时可以并发跑。我用"两个独立文本步 + 一个汇合步"的同一条 YAML 做了对照:
| 跑法 | 墙钟耗时 |
|---|---|
不传 --concurrency(默认 1) |
13s |
--concurrency 2 |
6.0s |
--concurrency 3 |
5.8s |
两个文本步各要六七秒,并发之后总时长约等于单步时长,汇合步等两边都完成才跑。这个收益是实的。
没料到的是后面这次挂死:显式传 --concurrency 1,同一条双根流水线我跑三次挂两次,进程不退出,--timeout 45、--timeout 90 都不生效,最后手动杀进程。不传这个参数(默认值也是 1)反而一次成功,传 2、传 3 各一次成功,线性流水线传 1 也成功。所以不是并发等于 1 就坏,是"多个无依赖起始步 + 显式传 1"这个组合下偶发的调度问题。我现在的做法是要么不传,要么传 2 以上,问题本身会反馈给 CLI 团队,这里不替它圆。
事件流:参数名踩了一下
事件流开关是 --events jsonl。注意它要的是格式关键字,不是文件名。我传了 --events events.jsonl,报错逐字是 Flag --events must be one of: jsonl.。改对之后事件逐行打到标准输出,每步带 timing.durationMs,skip 带 reason。

图里那条 step.failed 来自一次真实的卡死:出图那步的异步任务卡了 958 秒(16 分钟)才被中止,durationMs 958097,报 This operation was aborted,下游全部 skipped。顺带把一个误解打破了:--timeout 不是整条 run 的硬上限,异步模型步卡住时它兜不住。跑长链路的时候人别走开太远,或者把 --step-timeout 设上。
四个坑汇总
| 坑 | 表现 | 处置 |
|---|---|---|
| 花括号占位符 | {{inputs.topic}} 原样进模型,validate 不报 |
数据流一律用 $input / $from 对象写法 |
$from 的 path |
漏写 /data 报 Step output path not found |
模型步的输出都在 /data 下,照输出 JSON 抄路径 |
| 配音步没默认音色 | 不传 voice 报 [cosyvoice:]Engine return error code: 418 |
显式写 voice;音色表用 bl speech synthesize --model cosyvoice-v3-flash --list-voices 查 |
--concurrency 1 偶发挂死 |
双根流水线 3 次挂 2 次,timeout 不生效 | 不传,或传 2 以上 |
还有一条不算坑但要记住:validate 是弱校验。它查结构(version、steps、id、type、input 是不是对象)和语义(type 注册过没有、dependsOn 是不是字符串数组),不查引用路径写没写对,也不查步的 input 字段名存不存在。把它当语法检查用,别当正确性证明用。
感受和适用场景
跑完最大的感受是"留下什么"比"跑没跑"重要。耗时、尝试次数、产物、失败原因都在输出里,每一步带 startedAt、finishedAt、attempts,下次接手的人不用来问我。
也有它不适合的地方。video/generate 步是异步轮询的,单次几十秒到几分钟,要快反馈的链路别把它放进来。retry 和步级 timeout 这两个字段 schema 里存在,validate 也接受,但我这轮没能把它们的行为稳定验出来,所以这里不写语义,以官方文档为准。schema 没有官方文档页,本文的字段清单是我从 2.0.1 的安装包里逐段读出来、再用 validate 和 run 双向验过的,CLI 升级后字段可能变,照抄前先用 validate 过一遍。
想试的话,先 validate 看结构,想看运行时会解析成什么输入又不真的执行,加 --dry-run。第一次跑把 poster 步的 out-dir 指到一个空目录,跑完打开那张图,跟 check 步念回来的文字对一遍。对上了,这条链路就是真的会自己验收。

浙公网安备 33010602011771号