如果我打算就此写一篇review文档,应该怎么写?
实际上你可以参考
https://blog.lucc.dev/2025/from-baidunetdisk-to-arch-design
但是很明显我在实现过程中吸取了之前的教训
这次的实现非常顺利,根据你的spec,codex一次就直接实现了
所以这里要写出来:
1、我吸取了哪些教训?
2、这类工具有哪些共同点?
3、还应该做哪些优化?
---
另外还有这部分 summarize task (设计 prompt执行之类的)
---
这篇blog应该以什么为抓手来写?或者说,应该以什么角度来写?
3、还有哪些我没考虑到的问题?或者说我的 unknown unkowns? 也都提出来
---
不要直接写,在写之前我们需要先对齐以上问题
写了一个 B 站字幕批量提取 + AI 总结工具,先写 spec 后写代码,fetch 和 transcript 阶段一次成功。但 summarize 阶段落地时遇到了真正的挑战:LLM runner 行为不可靠、prompt 需要工程化迭代、JSON 输出需要多层兜底修复。
核心教训:spec 防止方向性错误,但不能消除实现层的意外。真正的价值不是"一次成功",而是"失败时可追溯、可修复"。
那么,这次做对了什么?
- 1、最重要的
spec-first。
这次又学到了什么?
全文围绕四个递进问题展开:
- 为什么小工具应该按流水线设计而非脚本
- spec-first 的实际效果和局限
- LLM 落地时最脆弱的几个环节
- 这类工具的普遍结构和演进方向
最近我又写了一个"小工具"。
需求看起来很简单:我手上有几十个 B 站视频,不想逐个打开、逐个看完、逐个摘笔记。更理想的方式是:
- 批量从 URL 提取字幕;
- 把字幕清洗成可读的 transcript;
- 再对每个视频做 summary;
- 最后得到一个统一的 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.nu和urls.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
每一层都有不同价值:
| 阶段 | 产物 | 作用 |
|---|---|---|
| URL | urls.txt | 原始输入 |
| 原始字幕 | raw/ | 保留下载结果,便于排查 |
| 标准化字幕 | normalized/ | 消除字幕格式差异 |
| Transcript | transcript/*.md | 给人和 LLM 都可读的文本 |
| Summary JSON | summary_items/*.json | 可缓存、可校验、可重渲染 |
| Final Summary | summary.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。
这个问题花了最多的时间去处理。最终的解决方案是组合拳:
- 在 prompt 中显式加入 anti-plan-mode 指令
- 把
--effort max降为--effort high(max 更容易触发 plan mode) - 实现多级 JSON 提取策略(直接解析 → json code block → generic code block → Python 修复 fallback)
- 放弃对 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 | 提高 effort | plan mode 更频繁触发 |
| v2 | 加 instruction 5: 禁止 plan mode、禁止创建文件、禁止使用工具 | 几乎不再进入 plan mode |
v2 + --effort high | 降级 effort | 完全避免 plan mode,质量可接受 |
| v2 + instruction 11 | 加反斜杠转义要求 | 减少 JSON 中未转义引号的出现 |
v2 + fix_json.py | Python 修复 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 目录,然后输出一个总结。
但这样有几个问题:
- 不知道哪个视频处理成功;
- 不知道哪个视频失败;
- 输出格式不稳定;
- 不能缓存每个视频的结果;
- prompt 改了以后很难局部重跑;
- 最终 Markdown 结构可能被模型搞乱;
- 无法系统性校验评分、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 实际上做了三件事:
- 定义行为边界:不要 plan、不要创建文件、不要输出解释文字
- 定义数据契约:JSON schema + 评分 rubric
- 定义兜底规则:如果内容有问题,写在 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 提取逻辑:
- 直接解析:trim 后直接
from json,最快路径 - 提取 json code block:用正则找
```json ... ``` - 提取 generic code block:找
``` ... ```(无语言标签) - 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 才是交付边界。
当一个工具开始处理几十条输入、多个阶段、中间产物和最终报告时,它就不再只是一个脚本。它是一条小型流水线。
而小型流水线,也值得被认真设计——同时,也要为落地时的意外留出容错空间。