Skip to main content

对一次 B 站视频内容提取工具的 Spec-first 实践的Review

27 min read

如果我打算就此写一篇review文档,应该怎么写?

实际上你可以参考

https://blog.lucc.dev/2025/from-baidunetdisk-to-arch-design


但是很明显我在实现过程中吸取了之前的教训

这次的实现非常顺利,根据你的spec,codex一次就直接实现了

所以这里要写出来:

1、我吸取了哪些教训?

2、这类工具有哪些共同点?

3、还应该做哪些优化?


---



另外还有这部分 summarize task (设计 prompt执行之类的)


---


这篇blog应该以什么为抓手来写?或者说,应该以什么角度来写?


3、还有哪些我没考虑到的问题?或者说我的 unknown unkowns? 也都提出来


---

不要直接写,在写之前我们需要先对齐以上问题





TLDR

写了一个 B 站字幕批量提取 + AI 总结工具,先写 spec 后写代码,fetch 和 transcript 阶段一次成功。但 summarize 阶段落地时遇到了真正的挑战:LLM runner 行为不可靠、prompt 需要工程化迭代、JSON 输出需要多层兜底修复。

核心教训:spec 防止方向性错误,但不能消除实现层的意外。真正的价值不是"一次成功",而是"失败时可追溯、可修复"。

那么,这次做对了什么?

  • 1、最重要的 spec-first

这次又学到了什么?


全文围绕四个递进问题展开:

  1. 为什么小工具应该按流水线设计而非脚本
  2. spec-first 的实际效果和局限
  3. LLM 落地时最脆弱的几个环节
  4. 这类工具的普遍结构和演进方向

最近我又写了一个"小工具"。

需求看起来很简单:我手上有几十个 B 站视频,不想逐个打开、逐个看完、逐个摘笔记。更理想的方式是:

  1. 批量从 URL 提取字幕;
  2. 把字幕清洗成可读的 transcript;
  3. 再对每个视频做 summary;
  4. 最后得到一个统一的 Markdown 文档,方便我快速判断哪些视频值得看原视频。

这听起来像一个普通脚本。

但这次我没有一上来就写脚本。相反,我先花时间写了一份很详细的 spec,然后把 spec 丢给 Codex。结果是:fetch 和 transcript 阶段一次实现,summarize 阶段经历了一些迭代后也跑通了。

这件事本身比这个工具更值得复盘。

因为我之前写过一篇复盘:从百度网盘到 Arch:一次失败的小工具设计复盘。那次的核心问题是:我以为自己在写一个小脚本,结果实际上是在搭一条内容搬运流水线;我没有先定义最终交付物、没有状态、没有失败模型,最后只能不断补救。

这一次,我确实吸取了上次的教训。但更有意思的是,即使有 spec,实现阶段仍然遇到了新问题——只是这次的问题可追溯、可定位、可修复。

这篇文章不是介绍 B 站字幕工具怎么用,而是复盘:为什么这次一个小工具可以顺利实现、实现过程中 LLM 落地的真实挑战是什么、以及这类工具到底应该怎么设计。


1. 前言:这次为什么又是一个"小工具"

最开始的需求非常朴素。

我手上有大概 50 个 B 站视频。如果逐个看,会非常耗时。既然大多数视频都有字幕,那理论上可以先把字幕批量提取出来,再让 LLM 基于字幕生成 summary。

最早的问题是:

有没有办法根据一批 B 站 URL,批量提取字幕,然后生成摘要?

一开始我考虑过一些现成工具,比如浏览器插件、字幕导出插件、B 站总结项目。但看了一圈后发现,不是太重,就是没人维护,要么适合单个视频,不适合批量处理。

最后方案收敛成:

urls.txt
-> yt-dlp 下载字幕和 metadata
-> pysubs2 规范化字幕
-> 生成 transcript/*.md
-> LLM 生成结构化 summary JSON
-> 脚本统一渲染成 summary.md

这仍然是一个小工具。

它没有数据库,没有 Web UI,没有后台任务,也没有复杂的项目结构。最终源码可以简单到只有:

run.nu
urls.txt

所有中间产物都放在 /tmp 下面:

/tmp/bz-2026-05-12/
raw/
normalized/
transcript/
summary_items/
logs/
reports/
summary.md

表面上,这是一个脚本。

但我越来越觉得,这类东西不应该被理解成"脚本",而应该被理解成一条小型流水线。

脚本只强调"执行命令";流水线强调"输入、状态、中间产物、失败、最终交付物"。

这两者的设计方式完全不同。


2. 和上次的不同:我没有直接写脚本,而是先写 spec

这次最大的不同是:我没有直接让 Codex 写代码。

我先把问题拆开,写成了一份可以执行的 spec。

这个 spec 里不是简单写:

帮我写个 Nushell 脚本,批量下载 B 站字幕并总结。

而是明确写了:

  • CLI 入口是什么;
  • 默认 workdir 放在哪里;
  • 输入文件格式是什么;
  • 运行产物放在哪些目录;
  • 状态如何记录;
  • 失败如何记录;
  • 哪些事情明确不做;
  • summary 阶段如何设计;
  • LLM 输出什么格式;
  • 最终 Markdown 长什么样;
  • 验收标准是什么。

比如一开始我就定义了:

nu run.nu all urls.txt
nu run.nu init urls.txt
nu run.nu fetch
nu run.nu transcript
nu run.nu summarize --runner "codex exec"

也定义了默认工作目录:

/tmp/bz-YYYY-MM-DD

例如:

/tmp/bz-2026-05-12

还定义了一个很重要的原则:

源目录保持干净,只放 run.nuurls.txt;所有运行产物都进入 /tmp/bz-YYYY-MM-DD

这对 Codex 非常友好。

AI coding agent 最怕的不是需求复杂,而是需求边界不清楚。只要边界足够清楚,它可以非常稳定地实现很多重复性工作。

这次 fetch 和 transcript 阶段 Codex 一次实现成功,并不只是因为模型变聪明了,而是因为我给它的不是一个 vague prompt,而是一份足够具体的 spec。

这里有一个很重要的转变:

我不再把 AI coding 理解为"让模型猜我要什么",而是把它理解为"把设计变成可执行规范,再让模型实现"。

这就是所谓的 spec-first。


3. 这次需求的真实形态:不是下载字幕,而是内容加工流水线

最开始看,这个需求是"下载字幕"。

但真正做起来以后,很快会发现:下载字幕只是第一步。

真正的需求其实是:

把一批视频 URL 加工成一个可阅读、可判断、可追溯的 summary 文档。

这意味着整个系统至少有几个阶段:

URL
-> subtitle
-> normalized subtitle
-> transcript markdown
-> per-video summary JSON
-> final summary markdown

每一层都有不同价值:

阶段产物作用
URLurls.txt原始输入
原始字幕raw/保留下载结果,便于排查
标准化字幕normalized/消除字幕格式差异
Transcripttranscript/*.md给人和 LLM 都可读的文本
Summary JSONsummary_items/*.json可缓存、可校验、可重渲染
Final Summarysummary.md最终交付物

如果只看"下载字幕",很容易把工具写成:

for url in urls:
yt-dlp ...

但如果看成内容加工流水线,就会自然地追问:

  • 哪个阶段失败了?
  • 中间产物是否可检查?
  • summary 是否可重跑?
  • 最终文档是否稳定?
  • prompt 改了以后能否重新生成?
  • URL 能否追溯回原视频?
  • 质量不好时如何标记?

这就是脚本和流水线的区别。

脚本追求跑完;流水线追求可恢复、可检查、可交付。


4. 我吸取的几个教训

4.1 先定最终交付物

上次做百度网盘那个工具时,我最大的问题之一是:一开始并没有定义清楚最终交付形态。

我以为目标是"下载文件",但真正目标其实是"让别人能稳定访问处理后的视频"。结果后面才不断补:本地目录、OpenList、文件结构、访问路径、状态记录。

这次我从一开始就问:

最后我要看的东西是什么?

答案不是字幕文件,也不是一堆 JSON,而是一个最终的 Markdown 文档:

summary.md

而且这个 Markdown 里,每个视频都是一个 heading-2:

## 【城】密码学工程的早期杰作—IPsec VPN防窥防篡改

URL: https://www.bilibili.com/video/BV1AjrVB5ErP
UP主:网络小白_Uncle城
时长:964 秒
综合评分:4.6/5
处理质量评分:5.0/5
是否推荐查看原视频:是
推荐理由:信息密度高,讲解结构清晰,适合回看原视频。

### 一句话摘要

...

### 核心内容

...

这个最终交付物一旦确定,前面的设计就很自然了。

为什么要保留 URL?因为我要能回到原视频。

为什么要保留标题?因为最终文档应该用视频标题当 heading,而不是 BV 号。

为什么要有综合评分?因为我要快速决定值不值得看原视频。

为什么要有处理质量评分?因为字幕质量会影响 summary 可信度。

所以第一条教训是:

不要从"我要写什么脚本"开始,而要从"最终我要交付什么东西"开始。

4.2 状态先设计

上次工具最狼狈的地方之一,是没有状态。

几十个任务跑到一半时,我需要知道:

  • 哪些完成了;
  • 哪些失败了;
  • 哪些需要重试;
  • 失败原因是什么;
  • 哪些输出已经存在。

如果没有状态,只能从本地目录反推,最后一定会乱。

这次我一开始就在状态方案上花了不少时间。

最初考虑过 DuckDB、SQLite、每个任务一个 JSON 文件、JSONL event log。最后选择了 JSONL event log。

原因是:这次暂时不做 retry,也不需要长期维护复杂状态表。我们只需要记录发生过什么:

{"type":"task_created","id":"BV1AjrVB5ErP","url":"https://www.bilibili.com/video/BV1AjrVB5ErP","at":"2026-05-12T17:20:00+08:00"}
{"type":"fetch_succeeded","id":"BV1AjrVB5ErP","subtitle_path":"raw/BV1AjrVB5ErP/subtitle.srt","at":"2026-05-12T17:21:00+08:00"}
{"type":"transcript_ready","id":"BV1AjrVB5ErP","transcript_path":"transcript/BV1AjrVB5ErP.md","at":"2026-05-12T17:22:00+08:00"}

Summary 阶段也是类似:

{"type":"summary_started","id":"BV1AjrVB5ErP","at":"2026-05-12T18:40:00+08:00"}
{"type":"summary_ready","id":"BV1AjrVB5ErP","summary_item_path":"summary_items/BV1AjrVB5ErP.json","at":"2026-05-12T18:41:00+08:00"}

这比数据库轻,也比一个可变的 state.json 更安全。

这里的关键不是"JSONL 一定比数据库好",而是:

先判断状态到底需不需要二次修改,再选择最小可用的状态模型。

如果未来要支持复杂 retry,每个任务一个 JSON 或 SQLite 可能更合适。但这次不需要,所以 append-only JSONL 就够了。

4.3 链路砍短

上次复盘里我最大的结论之一是:链路越长,失败面越大。

这次我刻意把链路砍到最短:

Nushell 负责编排
yt-dlp 负责下载字幕
pysubs2 负责字幕格式标准化
jq 负责 JSON 校验
LLM runner 负责 summary
脚本负责最终 Markdown 渲染

没有数据库,没有服务端,没有 GUI,没有后台任务。

但是链路短不代表随便写。

这个工具仍然有:

  • 明确 workdir;
  • 明确事件日志;
  • 明确中间产物;
  • 明确日志目录;
  • 明确最终产物;
  • 明确失败报告。

我越来越觉得,好的小工具不是"什么都不设计",而是:

只设计不可省略的部分。

这次不可省略的是:输入、状态、中间产物、最终 Markdown、失败日志。

除此之外,先不要做。

4.4 中间产物可检查

这次我没有让流程变成一个黑盒。

每个阶段都有可检查的中间产物:

raw/              原始下载结果
normalized/ 标准化字幕
transcript/ 清洗后的 Markdown 转录稿
summary_items/ 每个视频的结构化 summary JSON
logs/ 每个任务的日志
reports/ 状态和失败报告
summary.md 最终结果

这很重要。

如果 summary 不好,我可以检查 transcript。

如果 transcript 不好,我可以检查 normalized subtitle。

如果字幕本身有问题,我可以检查 raw。

如果 runner 输出了非法 JSON,我可以看 logs。

中间产物不是浪费空间,而是调试接口。

特别是 LLM 加入之后,中间产物更重要。因为 LLM 的输出有不确定性,如果没有中间结果,很难定位问题到底出在字幕、prompt、runner,还是最终渲染。

4.5 Prompt 是接口协议

这次最大的新增教训之一是:prompt 不是一句自然语言请求,而是一个接口协议。

如果我只是写:

请总结这些视频。

那输出很可能不稳定。

有的视频可能有评分,有的没有;有的视频标题会被模型改写;有的视频漏掉 URL;有的视频会输出 Markdown,有的视频会输出列表。

所以我把 summary 阶段设计成:

transcript.md
-> prompt
-> LLM 输出 JSON
-> jq 校验
-> summary_items/*.json
-> 脚本统一渲染 summary.md

LLM 负责理解内容,脚本负责格式。

这是一个很关键的边界。

LLM 不应该负责最终 Markdown 的 heading 层级,也不应该负责决定 URL 放在哪里。它只应该输出结构化内容。

最终 Markdown 由脚本渲染,才能保证:

  • 每个视频都是 ## title
  • URL 一定存在;
  • 评分格式一致;
  • "是否推荐查看原视频"格式一致;
  • 空字段渲染为"无";
  • 失败项出现在文档底部。

所以 prompt 实际上定义的是一个数据契约:

{
"overall_score": 4.6,
"processing_quality_score": 5.0,
"recommend_original_video": "yes",
"one_sentence_summary": "...",
"summary": "...",
"key_points": []
}

这就是这次最值得保留的经验:

Prompt 不应该只描述"你要什么",还应该定义"下游如何消费它"。

4.6 Runner 抽象是整个流水线最脆弱的一环

这是实现 summarize 阶段后新增的教训。

我最初的设计很理想:用 claude -p 作为通用 LLM runner,通过 pipe 传 prompt,期望 stdout 输出结构化 JSON。

但实际跑起来后,claude -p 的行为远不如预期稳定:

  • 它会进入 plan mode(即使我说"不要进入 plan mode")
  • 它会创建文件(而不是输出到 stdout)
  • 它会调用工具
  • 它在普通问题下 exit code 是 0,但在某些场景下即使输出正常也返回 exit code 1

这意味着 "runner contract" 不可靠——你无法保证它只输出 JSON。

这个问题花了最多的时间去处理。最终的解决方案是组合拳:

  1. 在 prompt 中显式加入 anti-plan-mode 指令
  2. --effort max 降为 --effort high(max 更容易触发 plan mode)
  3. 实现多级 JSON 提取策略(直接解析 → json code block → generic code block → Python 修复 fallback)
  4. 放弃对 exit code 的依赖,改为检查 stdout 是否有内容

教训是:

把 agentic coding tool 当成纯 LLM API 用,是很危险的假设。

如果追求稳定性,应该用 Anthropic API 直接调用,或用 codex exec --output-schema 获取结构化输出。但 v1 为了复用现有工具链,我选择了在 pipeline 内兜底。

4.7 Prompt 迭代也是一个工程过程

这是另一个实现后才完全理解的教训。

我从 v1 到 v2 迭代了 prompt,变化过程如下:

Version变化效果
v1基础 JSON schema + transcript部分输出进入 plan mode
v1 + --effort max提高 effortplan mode 更频繁触发
v2加 instruction 5: 禁止 plan mode、禁止创建文件、禁止使用工具几乎不再进入 plan mode
v2 + --effort high降级 effort完全避免 plan mode,质量可接受
v2 + instruction 11加反斜杠转义要求减少 JSON 中未转义引号的出现
v2 + fix_json.pyPython 修复 fallback兜住剩余含有未转义引号的 case

这里最出乎我意料的是:模型的行为不仅受 prompt 影响,还受 effort 参数影响--effort max 在复杂任务下会触发 agentic 行为(plan mode、工具调用),而 --effort high 反而更稳定。在质量上,两者的差异在实际阅读中几乎不可感知。

另一个细节是 Nushell 单引号字符串中的 \n'...\n...' 是字面量 backslash-n,不是换行符。虽然 LLM 能理解 \n 含义,但更好的做法是用实际换行符。


5. 这类工具的共同结构

复盘完这次工具,我发现它和上次百度网盘工具其实属于同一类东西:

个人工作流里的批处理型内容加工工具。

它们的共同点不是技术栈,而是结构。

5.1 输入

这类工具通常都有一批外部输入:

  • URL 列表;
  • 网盘链接;
  • CSV;
  • Excel;
  • Markdown;
  • 字幕;
  • 网页;
  • 人工整理的文本。

这些输入通常不干净。

它们可能有重复、缺字段、格式不统一、权限问题、路径带空格、文件名特殊字符、链接失效、时间戳异常。

所以第一步不是处理数据,而是承认输入是脏的。

这次 urls.txt 看似简单,但仍然要考虑:

  • 空行;
  • 注释;
  • 重复 URL;
  • URL 里有追踪参数;
  • 多 P 视频;
  • B 站字幕缺失;
  • 标题包含特殊字符。

5.2 执行器

这类工具一般不会自己实现核心能力,而是组合已有工具。

这次用到的是:

yt-dlp
pysubs2
jq
Nushell
LLM runner

上次可能是:

BaiduPCS-Go
OpenList
DuckDB
shell scripts

真正的难点不是某个工具本身,而是把它们串起来。

这类工具的工程价值通常不在算法,而在胶水层:

  • 命令怎么调用;
  • 参数怎么传;
  • 输出放哪里;
  • 失败怎么记录;
  • 哪些可以跳过;
  • 哪些可以重跑;
  • 最终报告怎么生成。

5.3 状态

只要是几十条以上的批处理,就几乎一定需要状态。

状态不一定是数据库。它可以是:

  • DuckDB;
  • SQLite;
  • JSONL event log;
  • 每个任务一个 JSON;
  • 目录结构;
  • 文件存在性。

关键是要提前决定:

状态在哪里,谁更新它,它能回答哪些问题。

这次 JSONL 能回答的问题包括:

  • 总共有多少任务;
  • 哪些下载成功;
  • 哪些 transcript 生成成功;
  • 哪些 summary 生成失败;
  • 最终 summary 是否 assembled。

这已经够用了。

5.4 中间产物

成熟的工具不会只有输入和输出,而会保留中间产物。

中间产物的意义是:

  • 可检查;
  • 可复跑;
  • 可缓存;
  • 可定位问题;
  • 可替换某一阶段。

这次的 summary_items/*.json 就是一个典型例子。

如果最终 summary.md 格式不满意,我不需要重新调用 LLM,只要重新渲染这些 JSON。

如果 prompt 改了,我可以选择 --force 重新生成。

如果某个视频失败了,我可以只看对应 log。

5.5 最终报告

这类工具最终一定要有一个面向人的报告。

不是命令行里打印一句 done,而是一个人真正会消费的产物。

这次是:

summary.md

它解决的问题不是"程序有没有跑完",而是:

  • 我能不能快速扫完所有视频?
  • 我能不能看到原视频 URL?
  • 我能不能知道哪些视频值得看?
  • 我能不能知道处理质量?
  • 我能不能知道哪些失败了?
  • 我能不能把这个文件放进知识库?

这也是我把最终产物设计成单个 Markdown 文件的原因。


6. Summarize 阶段的实现与教训

这次最值得单独讲的是 summarize 阶段。

因为从设计到实现,这里经历了最大的落差。

6.1 为什么不是直接 prompt

最简单的做法是让 Codex 或 Claude Code 直接读整个 transcript 目录,然后输出一个总结。

但这样有几个问题:

  1. 不知道哪个视频处理成功;
  2. 不知道哪个视频失败;
  3. 输出格式不稳定;
  4. 不能缓存每个视频的结果;
  5. prompt 改了以后很难局部重跑;
  6. 最终 Markdown 结构可能被模型搞乱;
  7. 无法系统性校验评分、URL、标题这些字段。

所以我没有把 summarize 设计成一次性大 prompt,而是设计成一个阶段:

for each transcript:
build prompt
call runner
validate JSON
write summary_items/<id>.json

assemble all successful items:
render summary.md

这看起来多了一步,但实际上极大提高了稳定性。

6.2 为什么 LLM 输出 JSON

最终我需要的是 Markdown,但我不让 LLM 直接输出最终 Markdown。

原因是最终 Markdown 的格式是强约束:

## 原视频标题

URL:
UP主:
时长:
综合评分:
处理质量评分:
是否推荐查看原视频:
推荐理由:

### 一句话摘要
...

如果让 LLM 直接写,它可能每个视频格式都不一样。

所以更合理的分工是:

LLM: 负责理解和提炼
脚本: 负责校验和渲染

LLM 输出 JSON:

{
"overall_score": 4.6,
"processing_quality_score": 5.0,
"recommend_original_video": "yes",
"recommendation_reason": "信息密度高,值得回看原视频。",
"one_sentence_summary": "...",
"summary": "...",
"key_points": []
}

脚本再渲染成 Markdown。

这让系统从"让模型自由写文章"变成"让模型填结构化字段"。

6.3 为什么脚本渲染 Markdown

脚本渲染 Markdown 的好处是确定性。

它能保证:

  • heading 一定是 ## {title}
  • title 一定来自 transcript frontmatter;
  • URL 一定来自原始 metadata;
  • 评分一定显示成 x/5
  • 推荐字段一定映射成中文;
  • 空字段统一显示为"无";
  • 失败项统一放在底部。

LLM 负责语义,脚本负责结构。

这比"写一个很长的 prompt 祈祷模型输出稳定 Markdown"可靠得多。

6.4 Prompt 迭代:从理想到现实

设计阶段我以为"写个 prompt 就行了"。

实际实现时发现事情没那么简单。

第一版 prompt 包含了基本的 JSON schema 和评分标准。跑起来后出了问题:Claude Code 开始进入 plan mode,自己分析、自己规划、自己创建文件。它把自己当成了一个 agent,而不是一个 JSON 生成器。

第二版 prompt 加了一行 instruction 5:"不要进入 plan mode,不要创建任何文件,不要使用任何工具。" 几乎解决了 plan mode 问题。但仍然有一些输出不合法 JSON——字符串内部出现了未转义的双引号。

最终方案 是 prompt v2 + --effort high(不是 max)+ fix_json.py Python 修复脚本的组合。

最终的 prompt 实际上做了三件事:

  1. 定义行为边界:不要 plan、不要创建文件、不要输出解释文字
  2. 定义数据契约:JSON schema + 评分 rubric
  3. 定义兜底规则:如果内容有问题,写在 uncertainties 里,不要编造

还有一个值得记录的细节:prompt 版本需要固化。每条 summary item 都应该记录它是由哪个 prompt 版本生成的,否则未来改了 prompt 后,新旧结果混在一起无法区分。在最终实现中,每个 summary item 都关联了一个 prompt_version,prompt 本身也存档到 summary_prompts/ 目录中。

6.5 Runner 选择是最大的架构脆弱点

如果用一句话总结 summarize 阶段最大的工程教训,那就是:

把 agentic tool 当 API 用,是 v1 最脆弱的抽象。

claude -p 的设计目标是交互式 agent,而不是纯 LLM 调用。它即使在 -p 模式下仍然可以:

  • 进入 plan mode 并阻塞
  • 创建文件
  • 调用 shell 工具

这意味着 pipeline 里"调用 runner → 获取结构化 JSON" 这个看似简单的步骤,实际上需要多层容错:

runner 输出
-> 直接解析 JSON(最快路径)
-> 提取 ```json ``` block
-> 提取 ``` ``` block(无语言标签)
-> 正则找最外层 {} + Python 修复
-> 仍失败则标记为 summary_failed

一共 5 层降级策略。

这不是一个优雅的设计,但它让系统在"runner 行为不可靠"的前提下仍然能跑通 42 个视频。

如果未来重构,我会用 Anthropic API 直接调用,或 codex exec --output-schema,而不是依赖 claude -p 的行为巧合。

6.6 JSON 提取的四层策略

由于 runner 输出的不确定性,我实现了一个逐级降级的 JSON 提取逻辑:

  1. 直接解析:trim 后直接 from json,最快路径
  2. 提取 json code block:用正则找 ```json ... ```
  3. 提取 generic code block:找 ``` ... ```(无语言标签)
  4. Python 修复:用 fix_json.py 修复未转义引号等常见问题

每一层失败就降级到下一层。如果全部失败,标记为 summary_failed

这个策略确实兜住了不少边界情况,但它也增加了一整个代码路径。而且 fix_json.py 是作为独立 Python 脚本存在的,引入了一个额外的依赖点。

Nushell 内嵌 Python 代码的体验尤其不好:引号层层转义让调试成本远高于独立文件。我的教训是:

分界要早。一开始就应该把 fix_json.py 写为独立文件。


7. 还可以继续优化什么

这次实现下来,我有了一个和设计阶段很不一样的优化优先级列表。

7.1 Runner 可移植性(高优先级)

现在 pipeline 严重绑定 claude -p。如果这个工具以后换 runner、升级版本、或改为 API 调用,整个 summarize 阶段都需要调整。

理想情况下,runner 应该是一个可替换的抽象:

nu run.nu summarize --runner "codex exec --output-schema summary_schema.json"
nu run.nu summarize --runner "claude -p"
nu run.nu summarize --runner "llm -m gpt-4o"

每种 runner 的行为不同,抽象层需要处理 exit code、输出格式、错误语义的差异。v1 没有做这个抽象,这是未来最值得补的工程投入。

7.2 Schema 演化管理(高优先级)

当前 JSON schema 是硬编码在 run.nu 中的 const,同时 validate-summary-json 中硬编码了 required fields 列表。两者可能不一致——这是一个隐患,我把它叫做"schema 双写问题"。

更严重的是:如果 schema 加了字段,所有旧 summary_items/*.json 立即过时。唯一迁移路径是 --force 全量重跑 2.5 小时。

解决方案可能是:每个 summary item 记录它的 schema 版本,assemble 时检测版本不兼容并给出警告。

7.3 超时保护(中优先级)

42 个视频 × ~3 分钟/视频 ≈ 2.5 小时。这是一个可接受的 batch 时间。

但问题是:如果一个视频卡住 10 分钟,整个 pipeline 就卡住。当前 invoke-runner 没有超时保护,应该用 timeout 命令包装。

7.4 成本追踪(中优先级)

42 videos × ~3min/video × deepseek-v4-flash,按 API 定价算月成本不低。目前没有任何成本监控或预算控制。如果这个工具变成日常使用,成本是需要正视的问题。

7.5 工作目录清理(中优先级)

截至写这篇文章时,/tmp/ 下已有 15+ 个 bz-* 目录。每次运行都创建新目录,没有清理策略。用一次留一个。

7.6 固化 prompt version

每个 summary item 里应该记录:

prompt_version: single_video_v1

最终 summary.md 顶部也应该写:

Prompt version: `single_video_v1`

否则未来 prompt 改了以后,很难知道旧结果是怎么生成的。

这个在 v1 中已经部分实现了(prompt 会存档到 summary_prompts/ 目录),但还没有和 summary.md 联动。

7.7 Nushell 单文件复杂度

run.nu 最终大约 1788 行,已接近 Nushell 单文件的合理上限。Nushell 没有静态类型检查,一个 typo 在运行时才能暴露。如果要继续加 stage,建议拆分文件。

7.8 局部重跑

现在可以通过 --force 重跑全部 summary。

未来更实用的是:

nu run.nu summarize --force-id BV1AjrVB5ErP

或者:

nu run.nu summarize --failed-only

这会把工具从"一次性批处理"推进到"长期可复用工具"。

7.9 其他设计阶段的优化方向

还有一些是设计阶段就识别了、但 v1 没做的:

  • 校准评分标准:当前 LLM 倾向于打高分,需要更明确的 rubric
  • 校验 JSON schema:现在用 jq -e . 可以校验是不是合法 JSON,但还不能校验字段类型
  • 长 transcript 的 chunking:如果未来遇到长视频,需要分段 summarize 再合并
  • 生成索引:按标签、评分、主题聚合,从"摘要工具"变成"知识整理工具"

这些在 spec 阶段就已经标记为"v1 不做",现在仍然是对的。


8. Unknown unknowns

实现之前我以为我知道了所有风险。实现之后发现,真正的问题往往不在你预判的列表里。

以下是这次实现后才暴露的问题,按影响优先级排列。

8.1 Runner 行为不一致(🔴)

claude -p 的行为随版本变化。这次遇到了 plan mode 问题,下次升级可能又有新问题。runner 可移植性是最大隐患。

8.2 Schema 演化的迁移成本(🔴)

加字段时所有旧 summary_items/*.json 立即过时。唯一迁移路径是 --force 全量重跑 2.5h。没有前向兼容设计。

8.3 成本没有监控(🔴)

42 videos × ~3min/video × deepseek-v4-flash。目前没有任何 API 成本监控或预算控制。如果每天跑一次,月成本可观。

8.4 中途中止的恢复体验不好(🟡)

events.jsonl 能恢复状态,但终端意外关闭后没有进度提示,只能 summary-status 手动查。进程退出后没有可见状态。

8.5 无法部分重试(🟡)

main summarize 是全量操作。如果 3/42 失败,必须用 summary-failed 查出 ID,再通过删除对应 event 或手动调整才能局部重跑。

8.6 Prompt 版本漂移(🟡)

嵌入式 prompt 更新后,用户无法判断何时需要 --force。prompt 变了但旧 summary 还在,新旧混用。

8.7 Schema 双写(🟡)

summary_schema_json 常量中定义了 JSON schema,同时 validate-summary-json 中硬编码了 required fields。两者可能不一致。

8.8 工作目录膨胀(🟢)

15+ 个 /tmp/bz-* 目录且持续增长。没有自动清理策略。

8.9 评分虚高(🟢)

LLM 很容易把大多数内容打成 4 分以上。如果所有视频都是 4.5/5,评分就失去区分度。

8.10 转录稿质量差异(🟢)

部分视频 ASR 错误严重。processing_quality_score 存在,但下游读者可能不检查该值就信任内容。

8.11 引用完整性(🟢)

源 transcript 被删除/移动后,summary_items/<id>.json 中的 title/url 等字段不会再更新。

8.12 /tmp 不是档案馆

/tmp/bz-YYYY-MM-DD 很适合做工作目录,但它不是长期存档。系统清理、重启、手动清理都可能让它消失。

所以最终重要产物应该复制出来:

cp /tmp/bz-2026-05-12/summary.md ./summary.md

8.13 "小工具"可能会长大

很多复杂系统都是从小工具开始的。

今天只是批量总结 B 站视频,明天可能就会变成:

  • 支持 YouTube;
  • 支持播客;
  • 支持 PDF;
  • 支持收藏夹;
  • 支持按主题归档;
  • 支持自动同步到博客;
  • 支持全文检索。

这时最危险的不是功能增加,而是没有意识到它已经从小工具变成了系统。


9. 总结:小工具不是小脚本,spec 才是交付边界

这次最大的收获不是我得到了一个 B 站视频总结工具,而是我更清楚地意识到:

小工具的复杂度不在代码量,而在你有没有提前定义输入、状态、失败和最终交付物。

上次我是在实现过程中不断发现问题,再不断补救。

这次我在实现前先写 spec,把问题提前暴露出来:

  • 最终交付物是什么?
  • 中间产物放哪里?
  • 状态怎么记录?
  • 失败怎么处理?
  • 哪些功能明确不做?
  • LLM 输出什么结构?
  • 最终 Markdown 怎么渲染?
  • 什么叫实现完成?

结果 fetch 和 transcript 阶段 Codex 一次就实现了。

但更有价值的是另一层认识:

Spec 能防止你犯第一类错误(方向性错误),但不能消除第二类错误(实现层的意外)。

LLM runner 的行为、prompt 的稳定性、JSON 格式的一致性——这些是在实现过程中才暴露的问题。有 spec 的好处不是"一次成功",而是"失败时可追溯、可定位、可修复"。

所以这次的经验可以总结成:

小工具不是小脚本,spec 才是交付边界。

当一个工具开始处理几十条输入、多个阶段、中间产物和最终报告时,它就不再只是一个脚本。它是一条小型流水线。

而小型流水线,也值得被认真设计——同时,也要为落地时的意外留出容错空间。


0% read