2240 字
6 分钟
简体中文

AI 让代码变得廉价,却让工程变得更昂贵

BiliKit V1 还没做完,Swift 代码已经有 21,300 行了。

其中大约 12,700 行是生产代码,8,400 行是测试,一共 125 个 Swift 文件。目前已经能浏览和搜索视频、扫码登录、用 AVPlayer 播放 DASH 视频、显示字幕、按照播放时间轴调度弹幕,也做过持续高负载下的渲染验证。

当然离完成还早。UI 需要重新收拾,Mac 特有的交互也没做完,签名、公证、发布流程和最终回归测试都还在后面。

这也是第一个把我原来那套 AI 工作流搞得不太够用的项目。

刚开始其实很简单:告诉 AI 要做什么,让它读一下相关文件,写完以后跑测试,最后我再看 diff。项目小的时候基本够用,偶尔把代码放错地方也很好改。

后来代码越来越多,问题开始变得没那么明显了。

能编译不代表写对了#

BiliKit 的播放链路会经过网络请求、HTTP Range、SIDX 解析、HLS 播放列表、本地 HTTP Server、AVPlayer 生命周期、取消、Seek 和 CDN fallback。

登录也不只是扫个码。里面还有远端状态机、ephemeral URLSession、重定向策略、Keychain、请求授权、登出清理和 UI 状态。弹幕则会碰到 protobuf 解码、分段预取、去重、统一时间轴、轨道分配、Core Animation 和对象生命周期。

这些东西分散在不同模块里,但实际运行时又是连在一起的。

AI 很少给我写出一眼就能看出来的错误 Swift。更常见的是:

  • 类型放进了最顺手的模块,而不是应该拥有它的模块;
  • 测试刚好靠一次 Task.yield() 通过;
  • 取消后 UI 不再更新了,但底层资源还活着;
  • 匿名媒体请求不小心带上了认证 Header;
  • Benchmark 还没回答原来的问题,就先长成了一个小框架;
  • 文档把 Roadmap 里的计划写成了当前已经能用的功能。

这些改动单独看都挺像那么回事,快速扫一遍 diff 还真不一定能发现。有些甚至整套测试都能过。

我一开始的解决方法是把 prompt 写得越来越长。产品范围、模块边界、历史决定、危险区域、怎么验证,全都塞进去。

短期确实有点用。不过每开一个新会话又要解释一遍,而且当天任务很容易和长期规则混在一起。写到后面,继续加 prompt 已经有点像拿聊天记录维护项目了。

把该记的东西放回仓库#

后来 BiliKit 里逐渐多了 Roadmap、ADR、Threat Model、带日期的验证记录和工程指南。

当前代码和构建配置说明现在到底有什么;ADR 记录哪些决定不能随便推翻;Roadmap 区分 V1 正在做什么、以后可能做什么;验证记录只负责保存某个时间和环境里实际观察到了什么。

这个区分对 BiliKit 还挺有用。下载、转码、直播、多账号、区域解锁,随便挑一个都能让 AI 写出一份看起来相当完整的计划,但它们目前都不属于 V1。

非简单任务开始前,我也会先写一个很短的任务契约:

  • Goal:这次到底要改变哪个可观察行为;
  • Context:相关入口、已有决定和限制;
  • Constraints:依赖方向、安全、取消、生命周期和范围;
  • Done when:用什么测试、探针、测量或者实际行为证明完成。

如果 Goal 一句话说不清楚,通常是一个任务里夹了两件事。如果 Done when 只有“测试通过”,那多半还没想清楚真正容易出问题的地方。

换会话以后也不用继承前面所有聊天记录了。读契约,再读仓库里已经存在的事实,基本上就能开始。

一次只接通一条真实路径#

让 AI 横着铺代码实在太容易了。

先生成全部 Domain Model,再生成 Repository、Use Case 和 View,看起来非常整齐。但这样很容易出现没有调用方的 Protocol、暂时用不到的 Target,以及为了想象中的以后准备好的各种抽象。

BiliKit 现在还是分层:

Scene → View → ViewModel → Use Case → Repository Port → Adapter

不过实现时会从上到下接通一个真实行为。

登录先做 QR 状态、凭据所有权、一个需要授权的 Endpoint、一条 UI 流程、重启恢复和登出。弹幕先固定唯一媒体时间轴和分段契约,再做有界调度,最后把 Renderer 接进真实播放链路。

这样写没有一次生成整个子系统那么爽,但后面要删的空架构少很多。CommonSharedUtils 这种等着变成垃圾场的模块也尽量不留。

五行代码也可能是红区#

我以前也会觉得 diff 小就比较安全。BiliKit 里显然不是这样。

重定向策略改五行可能把凭据带到不该去的地方;Task ownership 改几行可能出现泄漏或者旧结果覆盖;很短的持久化迁移也可能把用户数据删掉。反过来,自动生成的 protobuf 文件就算很大,也未必有什么风险。

所以现在按失败代价分三类:

  • 绿区:局部 UI 和范围很窄的机械修改;
  • 黄区:普通 Feature、Use Case、跨文件重构和公共 API;
  • 红区:认证、Keychain、媒体、重定向、本地服务器、并发、资源生命周期、Renderer、迁移、删除和不可逆操作。

绿区跑基础检查,再看一下对应 diff。黄区先写清任务契约,通常再开一个新的只读上下文审查。红区要先决定值不值得做;路线不确定时先做有范围限制的实验,最后用和风险对应的实际测量收尾。

这么分以后,普通改动反而不用走一大堆没必要的流程。改 CSS 和改 Keychain 本来就不应该用同一张 Checklist。

不确定的实验还会先定复杂度预算。比如比较弹幕渲染方案,并不需要十个候选、五种输出格式和一套以后也许能复用的 Benchmark Framework,只要能回答这次产品选择就行。

弹幕 Renderer 当时就单独放在不可合并的 Spike 分支里,用合成负载比较少量方案。路线选完以后,生产实现重新开始。实验代码能跑,也不代表它应该顺手变成项目基础设施。

测试通过具体证明了什么#

字幕有个测试曾经假设,调用一次 Task.yield() 以后,异步 Stream 就来得及发布状态。

平时基本都能过,统一 Gate 在另一种时序下跑完整测试时才把竞态暴露出来。这个时候反复重跑到绿色没什么用,测试应该等待真正关心的可观察状态。

BiliKit 现在有一个统一入口负责静态检查、Package 测试和完整 App 构建。具体风险再补对应的证据:

  • 协议行为用固定 Fixture;
  • 来源和重定向策略补负向测试;
  • Keychain 用签名 App 做 Smoke Test;
  • 播放走真实 AVPlayer Probe;
  • 弹幕用受控高密度负载;
  • 内存和清理问题延长测量时间;
  • 自动化很难判断的 UI 体验再手动确认。

Unsigned Build 证明不了 Keychain,截图也证明不了取消路径,App 能启动更不代表 Renderer 跑 30 分钟没问题。

当然,也没必要每次把所有工具都跑一遍。改 Swift Package 的问题,Package Test 可能就够了;真的碰到性能问题再开 Instruments。

现在怎么做#

目前处理 BiliKit 普通任务,大概是这个顺序:

  1. 读当前代码、Roadmap、相关决策和测试。
  2. 写清一个可观察 Goal,以及什么结果算完成。
  3. 按失败代价判断任务风险。
  4. 路线不确定时先限制实验复杂度。
  5. 接通最小的一条纵向路径。
  6. 重要改动换一个新的只读上下文 Review。
  7. 跑统一自动化 Gate。
  8. 只补这次风险实际需要的环境验证。
  9. 更新当前文档,不去改写以前的验证记录。

周末随手写个小工具显然用不到这些。BiliKit 有认证、播放、本地服务器、并发状态和 Renderer,原来那套“写 prompt、跑测试、看 diff”比我想象中更早到了极限。

项目现在还没到 V1,这套流程后面大概也会继续变。

至少下次开新会话时,不需要再指望它凭聊天记录记住整个项目了。


后来我把这里面一部分工作流拆成了可以复用的 Codex Skills。续篇:我把自己的 AI 工作流变成了代码

AI 让代码变得廉价,却让工程变得更昂贵
https://www.shiinayane.com/zh/posts/ai-made-code-cheap/
作者
YANKAI WANG
发布于
2026-07-24
许可协议
CC BY-NC-SA 4.0