用 Harness Engineering 重新设计 TTS 流水线:一个多 Agent 系统的工程实践
TTS(Text-to-Speech)配音是视频生产中最不稳定的环节。我做中文科技短视频,每期 8-10 个片段,每个片段调 TTS API 生成语音,再对齐字幕。问题不在"能不能生成",而在生成结果不可控——英文品牌名被读错、数字被乱拼、改一个词就要重做整段。这些不是偶发 bug,是 LLM/TTS 输出不确定性的结构性问题。
我用 Harness Engineering 的思路重新设计了整条配音流水线,开源在 tts-agent-harness。3970 行代码,3 个 AI Agent 编排,52 个离线测试。这篇文章讲架构决策和工程踩坑,不是使用教程。
什么是 Harness Engineering
2026 年初这个概念集中爆发。Karpathy 的 autoResearch(630 行代码,6 万星标)、清华的 NLAH 论文、TextGrad 发到 Nature——都在说同一件事:当 loop 变成一行 while True,真正的工程量在 loop 周围的四件事。
- 操作对象:每轮改什么?范围越窄越可控
- 评估函数:怎么判断好坏?有确定性指标就全自动,没有就人在回路
- 约束系统:怎么防跑飞?不可变的规则文件
- 跨轮记忆:怎么跨轮次积累?append-only,不重复犯错
这四件事组合起来就是 harness。loop 是发动机,harness 是方向盘、刹车和安全带。
架构:三 Agent + 确定性胶水
脚本 (JSON)
│
▼
┌────────────── Harness (run.sh + chunks.json) ──────────────┐
│ │
│ [P1] 确定性切分 (JS) ── text → chunks │
│ [P2] Fish TTS Agent ── text → speech (黑盒) │
│ [✓2] 确定性预检 ── WAV 时长/格式/语速 │
│ [P3] WhisperX Agent ── speech → text + timestamps │
│ [✓3] 确定性预检 ── schema/字数比/时间戳单调 │
│ [Diff] 编辑距离比对 ── 同音字自动放行,省 Claude │
│ [P4] Claude Agent ── 校验 + 自动修复 (最多3轮) │
│ └→ FAIL? → 改指令 → P2 → P3 → P4 │
│ [P5] 确定性字幕 (JS) ── timestamps → subtitles │
│ [P6] 确定性拼接 (JS) ── concat + offset → final │
│ [✓6] 端到端验证 ── 覆盖率/gap/overlap │
│ │
│ 跨轮记忆: chunks.json + .harness/ │
└─────────────────────────────────────────────────────────────┘
│
▼
per-shot WAV + subtitles.json + preview.html
为什么是三个 Agent 而不是一个? 因为每个 Agent 解决不同类型的不确定性:
- Fish TTS:生成能力。黑盒,不可控,会出错。你给它
Karpathy,它可能读成CarPayD。 - WhisperX:感知能力。把黑盒输出转成可比对的文本,提供外部信号。
- Claude:判断能力。区分"同音字替换(无害)"和"品牌名读错(有害)",并生成修复方案。
P1/P5/P6 是确定性胶水——不需要智能,只做数据变换。能用确定性代码解决的绝不用 LLM,这是整个架构的基本原则。
四要素的落地
操作对象:text_normalized
每个 chunk 有两个文本字段:text(原始脚本,用于字幕显示)和 text_normalized(送入 TTS 的规范化文本)。P4 修复循环只改 text_normalized,永远不动原文。
这个设计看似简单,实际踩了一个坑:P1 重跑会覆盖整个 chunks.json。text_normalized 改了没问题,但 P2 写入的 duration_s、file、status 也被清零了,导致 P6 字幕偏移计算全错(duration 为 undefined,偏移量变成 NaN)。
修复:P1 写入时合并已有数据,只更新文本字段,保留运行时状态。加了 3 个测试覆盖这个 case。教训是——状态机和产出数据不应该混在同一个文件里,但拆文件改动太大,用合并逻辑兜底了。
评估函数:四层分级
- 确定性预检(免费):WAV 文件存在、时长 0-60s、语速 2-12 chars/s、采样率 44100 mono、转写 JSON schema 合法、时间戳单调递增、字数比 0.7-1.3
- 编辑距离比对(免费):Levenshtein distance + 同音字规范化(的/地/得),normalized distance < 10% 自动放行
- Claude 语义校验(付费):三方比对(原文、normalized、转写),区分 misread/missing/extra/semantic_drift,分 high/low severity
- Post-P6 端到端验证(免费):字幕覆盖率 > 80%、字幕-音频时长匹配、无重叠
实测数据:16 个 chunk 的 brief01 脚本,编辑距离层自动放行了 14 个(87.5%),只有 2 个需要调 Claude。四层评估把 Claude 调用量降低了 87%。
约束系统:rules.md + config.json
# .harness/rules.md(人写,harness 只读)
## 英文处理
- 句首英文品牌名前加 `.`
- 句中英文保持原样,不转中文,不音译人名
- 英文连字符改空格
- 文件后缀:.md → 文档,.json → 文件
## P4 修复约束
- 优先调断句/格式,不改原始含义
- 同音字替换不算错误
- 只修复 high severity,low 自动放行
rules.md 注入到 P4 的 validate 和 fix 两个 prompt 中。没有这个文件之前,Claude 会自作主张把 Karpathy 翻译成 卡帕西——技术上没错,但不是我想要的。rules.md 是人的业务判断对机器行为的约束,类似 yoyo-evolve 项目的 IDENTITY.md。
P4 还有振荡检测:如果 round N 的修改回退了 round N-1 的改动,提前终止,不浪费 API。
跨轮记忆:三层
.work/<episode>/ ← 单次运行(chunks.json + validation/*.json + trace.jsonl)
.harness/
├── normalize-patches.json ← P4 修复成功后自动积累(跨 episode)
├── tts-known-issues.json ← TTS 引擎无法修复的词(跨 episode)
├── rules.md ← 业务规则(人工维护)
└── config.json ← 技术参数
normalize-patches.json 是关键创新——P4 每次成功修复后,从修复前后的 text_normalized diff 中提取精确的 pattern→replacement 对,写入补丁表。下一个 episode 的 P1 在 normalize 阶段先查补丁表,命中直接替换,不再进 P4。
提取补丁用的是 segment 对齐 + 最长公共前缀/后缀的 diff 算法,不是 Claude 的自然语言描述。这里踩过一个 P0 bug——最初用 issue.fix(Claude 返回的 "将 Karpathy 改为 卡帕西")作为 replacement,P1 的 replaceAll 就把 Karpathy 替换成了整句中文说明。
P3 Server 模式:从 2 分钟到 8 秒
WhisperX large-v3 模型加载需要 ~2 分钟(3GB 模型)。P4 修复循环每轮重做 P2→P3,如果 P3 每次都重新启动 Python 进程加载模型,4 个 chunk 各重试 3 轮 = 24 分钟纯等模型加载。
改成 HTTP server 模式:P3 启动时加载模型一次,暴露 /transcribe 端点,P4 通过 HTTP 请求重新转写,单次 ~8 秒。
这里的工程难点不是 server 本身,而是进程生命周期管理。测试中反复出现 P3 僵尸进程——中断测试后旧进程不死,占着端口,下次跑又启动新进程,越积越多。最终方案:PID 文件 + 端口检测 + 启动前自动 kill 旧进程 + trap EXIT 清理。
踩坑:手写 HTTP 隧道导致全局卡带声
这是整个项目最诡异的 bug。
Fish TTS API 需要走代理(ClashX)。最初为了避免引入 npm 依赖,手写了 HTTP CONNECT 隧道——先连代理,拿到 socket,在上面做 TLS 握手,然后手动拼 HTTP 请求头发送。
代码能跑,TTS 也能返回音频,但每一条音频都有类似磁带卡带的杂音。听起来像是 TTS 引擎的问题,花了很长时间排查 normalize 规则、TTS 参数、声音模型。
最终发现:手写的 HTTP 响应解析只用 \r\n\r\n 分割 header 和 body,没有处理 chunked transfer encoding。Fish TTS 返回的是 chunked response,每个 chunk 前面有 hex size 标记(如 1a2f\r\n),这些 ASCII 字节混进了 MP3 二进制数据。ffmpeg 转 WAV 时不会报错(格式兼容),但播放时这些随机字节变成了爆音和卡顿。
修复一行:把手写隧道换回 Node.js 标准 https.request,代理通过 HTTPS_PROXY 环境变量支持。删了 82 行代码,加了 38 行。
教训:不要手写协议层代码来省一个依赖。Node.js 的 HTTP parser 处理了 chunked encoding、content-length、gzip 等所有细节,手写不可能覆盖所有 case。
效果数据
基于 brief01(10 段脚本,1953 字,16 chunks)的实测数据:
| 指标 | 数据 |
|---|---|
| P1 切分 | < 1s |
| P2 TTS 合成(16 chunks,并行 3) | ~4 min |
| P3 WhisperX 转写 | ~2 min(含模型加载) |
| P3 server 模式重转写 | ~8s / chunk |
| Text-diff 自动放行率 | 87.5%(14/16) |
| P4 Claude 调用量 | 2 chunks(降低 87%) |
| P4 单 chunk 修复轮次 | 1-3 轮 |
| 全量流水线 | ~8-10 min |
| 缓存命中后再跑 | < 10s |
| 离线单元测试 | 52 个,~3s |
本质:控制论
回头看这个项目,harness 的四要素不是什么新概念——测量结果,和预期比对,不对就纠偏,循环直到收敛。这就是控制论(Cybernetics)的反馈回路。
不同的是执行者从人变成了 AI agent,评估信号从人的判断变成了分层的自动检测,纠偏动作从手动修改变成了 LLM 驱动的文本重写。但核心结构没变:在每个不确定节点加上评估、约束和记忆,让输出趋向收敛。
Prompt engineering 和各种 agent 框架在优化单次输出的质量。但 LLM 的输出本质上是不确定的,一个不确定节点就会产生分支,节点越多,最终结果就越随机。Harness 不解决单次质量问题,它解决的是不确定性的级联放大问题。