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 步念回来的文字对一遍。对上了,这条链路就是真的会自己验收。

posted @ 2026-10-08 22:56  新的白白酱  阅读(12)  评论(0)    收藏  举报