🐎 第7章 无为而治:Headless 模式与 CI/CD 集成
📄 一句话概括

管道的约定很朴素:标准输出接标准输入,一行一条记录。 把 agent 当普通命令用,它就自动能进任何脚本。 但有一个坑必须知道:管道的退出码只等于最后一段。前面失败了,整条还是绿色。

📝 本章和前面几章的关系

上一节把参数拆开了,这一节把它塞进命令行。 这一节真跑了一条两段管道,并让第一段故意失败,看退出码怎么被吃掉。

一、管道的形状

一句命令:第一段输出,直接进第二段的输入。没有临时文件、没有接口、没有协议。 这是它最值钱的地方:任何能读写标准输入输出的程序,天生就能接进来。 agent 只要遵守「输出一行一条记录」,它就和 grep、jq 是同一种东西。

所以「能不能进流水线」这个问题,答案往往不在功能,在输出格式。

二、真跑:两段接起来

演示里第一段吐三条 JSON,第二段过滤出通过的,再包一层。 三条全部穿过来了,格式也没变——因为两段都遵守「一行一条」的约定。 这里没有解析上的麻烦:一行就是一个完整对象,不需要攒、不需要猜边界。

这也是为什么 NDJSON 这种「一行一个 JSON」的格式在流水线里这么常见。

三、退出码被吃掉了

演示里第一段故意退出 3(假装最后一条有问题),第二段正常退出 0。 整条管道的退出码是 0——它只等于最后一段的退出码。 所以一个「数据生成失败、下游照常输出空结果」的流水线会一直显示绿色。

修法两个:bash 里打开 set -o pipefail,让它取第一个非零;或者逐段等、逐段看退出码。 第二条在脚本里更明确:把每一段的返回码收集起来,取最大值。

四、管道里该传什么

一行一条记录,不要一段多行。多行就要猜边界,猜就要出错。 每条自带类型字段。下游靠字段分流,不靠位置——位置一变全错。 出错也走记录。把「这条失败了」当成一条正常记录发出去,比抛异常好接。

每一步都能单独跑。管道里的一环,单独喂个文件也能跑,调试时才不用整条重来。 最后这条最实用:能单独跑,就能在本地复现;不能单独跑,就只能一遍遍推 CI。

五、三个阶段的分界

生成、过滤、格式化——这是管道里最稳的三段分法。 生成端不懂格式,过滤端不懂业务,格式化端不懂数据来源。三段各自可以替换。 反过来说,如果一段既要调模型、又要过滤、又要排版,那它就没法单独测。

拆段的判据不是「代码长短」,是「换掉其中一段,其他段要不要改」。

六、动手 Demo:三行命令串成管道,退出码去哪了

Unix 管道的约定很朴素:标准输出接标准输入,一行一条记录。 这里真跑一条两段管道,并让第一段故意失败。

p1 = subprocess.Popen([PY, u"gen.py"], stdout=subprocess.PIPE)
p2 = subprocess.Popen([PY, u"filt.py"], stdin=p1.stdout, stdout=subprocess.PIPE)
p1.stdout.close()
o, _e = p2.communicate()        # 只看 p2 的退出码,p1 的就被吃掉了

实跑输出:

--------------------------------------------------------------------
三行命令串成管道,退出码去哪了
--------------------------------------------------------------------

  Unix 管道的约定很朴素:标准输出接标准输入,一行一条记录。
  把 agent 当一个普通命令用,它就自动能进任何脚本——这是 headless 最值钱的地方。

  ① 管道长什么样
  --------------------------------------------------------------------
    python gen.py | python filt.py
  第一段输出,直接进第二段的输入。没有临时文件、没有接口、没有协议。

  ② 真跑:三段接起来
  --------------------------------------------------------------------
    {"n": 0, "tag": "通过"}

    {"n": 1, "tag": "通过"}

    {"n": 2, "tag": "通过"}

  第一段退出码 3,第二段退出码 0。

  ③ 退出码被吃掉了
  --------------------------------------------------------------------
| 这一段 | 退出码 | 说明 | 结论 |
|---|:---:|---|---|
| gen.py | 3 | 它才是真失败的 | — |
| filt.py | 0 | 它成功了 | — |
| 整条管道 | 0 | 只反映最后一段 | 失败被吞 |

  管道的退出码只等于最后一段的退出码。前面任何一段失败,都不会传出来。
  所以 CI 里两件事必须做:打开管道的失败传递(bash 的 set -o pipefail),或者逐段等、
  逐段看退出码。
  没有这一条,一个「数据生成失败但下游照常输出空结果」的流水线会一直显示绿色。

  ④ 管道里该传什么
  --------------------------------------------------------------------
  - 一行一条记录,不要一段多行。多行就要猜边界,猜就要出错。
  - 每条自带类型字段。下游靠字段分流,不靠位置。
  - 出错也走记录。把「这条失败了」当成一条正常记录发出去,比抛异常好接。
  - 每一步都能单独跑。管道里的一环单独喂文件也能跑,调试时才不用整条重来。

  ⑤ 三个阶段的分界
  --------------------------------------------------------------------
  生成、过滤、格式化——这是管道里最稳的三段分法。
  生成端不懂格式,过滤端不懂业务,格式化端不懂数据来源。三段各自可以替换。
  ! 这里的两段脚本是替身,替的是「模型输出 JSON、下游用 jq 取字段」这个形状。

①管道最值钱的地方:任何能读写标准输入输出的程序,天生就能接进来。 agent 只要遵守「一行一条记录」,它就和 grep、jq 是同一种东西。 ②第一段故意退出 3,第二段正常退出 0。整条管道的退出码是 0——它只等于最后一段的退出码。 所以「数据生成失败、下游照常输出空结果」的流水线会一直显示绿色。 修法:打开管道的失败传递,或者逐段等、逐段看退出码。 ③管道里传什么:一行一条记录、每条自带类型字段、出错也走记录、每一步都能单独跑。 最后这条最实用:能单独跑就能在本地复现,不能单独跑就只能一遍遍推 CI。

30 秒实验

  1. 把第一段的退出码改成 0,重跑一遍。整条管道的退出码变了吗?这说明「逐段检查」查的是什么?
  2. 让第一段故意输出一行不完整的 JSON(少半个括号),重跑一遍。第二段会报什么?真实的坑在哪儿?
  3. 把两段合成一段,重跑一遍。功能一样,但调试时会失去什么?

常见误区

  1. 只看最后一段的退出码。前面失败会被吞掉,流水线一路绿到底。
  2. 阶段之间传多行文本。下游要猜边界,一猜就错。
  3. 出错就抛异常。管道里抛异常,下游收到的是空输入,看起来像「没有数据」。
  4. 把生成、过滤、排版写在一个脚本里。单段没法测,也没法替换。
  5. 以为管道是 shell 的事,和 agent 无关。输出格式一改,它就接不进去了。
自测三问(点开对答案) 1. 管道的退出码反映的是哪一段? —— 只反映最后一段。前面任何一段失败都不会传出来,所以要开 pipefail 或者逐段检查。 2. 管道里为什么要求「一行一条记录」? —— 因为一行就能解一条,不用猜边界。多行文本要靠分隔符或者长度,一猜就错。 3. 「每一步都能单独跑」为什么重要? —— 能单独跑就能在本地复现问题;不能单独跑,就只能把整条流水线推到 CI 上试。

管道会接了,下一节进真正的 CI:让它在每次提 PR 时自动审查。

相关笔记