这不是一篇工具推荐,也不是 AI 使用教程。
3Q工具箱 从一个只有几个小工具的页面,长到今天 260 多个工具页、64 篇教程,其中相当一部分是在 AI 协作下完成的。这篇记录的是这个过程中真正卡住过我的几个技术决策,以及逐渐沉淀下来的几条协作准则——尤其是那些代码看起来完全正确、但一上真机就出问题的地方。
一、缘起:需求是被"长尾"撑大的
工具站有个特点:单个工具很轻,但种类无穷。
最早只有 JSON 格式化、Base64 转换这类东西。后来用户开始问:能不能转 PDF?能不能压视频?能不能识别条码?能不能算个税?每一个需求单独看都很简单,但 260 个堆在一起,维护和一致性的成本会失控。
技术栈是 Vue 2 + webpack,配 @ffmpeg/*、bwip-js、chart.js、cnchar 这些库,构建期做 295 条路由的预渲染。
真正让我把 AI开发 引入到日常工作流里的,是一个很朴素的判断:
如果每新增一个工具,边际成本能从"半天"压到"一小时",那 260 个就不是负担。
后来证明这个判断只对了一半。产出速度确实上去了,但犯错的速度也上去了——下面第三节那个 bug,就是 AI 用十分钟写出来的、我却排查了很久的代码。
二、AI开发 里的分工:先划清界限,再谈提效
我很快放弃了"让 AI 全权负责一个功能"的想法,改成明确分工:
| 环节 | 主导 | 理由 |
|---|---|---|
| 需求取舍、架构决策 | 人 | 只有人知道欠下的技术债要怎么还 |
| 单个工具的实现初稿 | AI | 模式固定,是典型的重复劳动 |
| 异常路径、边界情况 | 人提问,AI 补全 | AI 默认只写"顺利路径" |
| 并发、时序、缓存相关代码 | 人必须复核 | AI 的高频出错区,见第三节 |
| 真机验证 | 人 | AI 看不见运行时 |
这张表里最有价值的是倒数第二行。它听起来像句正确的废话,直到我为一个 75% 卡了很久。
三、案例一:P2P 文件传输,只有真机才暴露的三个坑
先说清楚这个功能在做什么:两个浏览器之间直接传文件,不经过服务器。数据通道用 WebRTC 的 DataChannel,信令由一个 Node 服务(ws,默认 8788 端口)转发,nginx 把 /p2p/ 反代过去。
做它的动机很直接:几百 MB 的文件走服务器中转,带宽成本扛不住;而且用户的私人文件本来就不该过我们的机器。
3.1 分片、落盘与断点续传
文件按固定大小切片后逐片发送,接收端不是拿到一片就写一次库,而是攒成一批再批量写入 IndexedDB,避免高频事务把主线程拖垮。
同时把文件的元信息(总分片数、名称、大小)也存进 IndexedDB。这样刷新页面虽然会丢掉连接和房间号,但分片还在,重新连上后可以接着传。
3.2 卡在 75%:一个"看起来完全没问题"的并发守卫
这是全过程中最值得讲的一个 bug。
批量写入的函数,AI 给的版本长这样:
async flushChunks() {
if (this.recvFlushing || !this.recvBuffer.length) return // 正在写就直接跳过
this.recvFlushing = true
const batch = this.recvBuffer
this.recvBuffer = []
await store.putChunks(batch)
this.recvFlushing = false
}
读起来无懈可击:一个标准的互斥锁,防止并发事务互相打断。问题在于——跳过不等于完成。
真实的出错时序是这样的:
1. 第 4 片(最后一片)到达,此时前一批还在写库
→ flushChunks 开头命中 recvFlushing,直接 return,什么都没做
2. file-end 到达 → await this.flushChunks() → 又被跳过
3. 这时去数库里的分片数:只有 3 片 → 界面停在 3/4,也就是 75%
4. 兜底定时器稍后把第 4 片写进了库
→ 但没有任何代码再回头更新界面,75% 就永远停在那里
现象极其迷惑:发送端提示完成,接收端卡在 75%,刷新页面后又变成 100%。
原因就藏在最后那句里——库从头到尾都是对的(4 片都在),错的只是界面计数。所以刷新后按库重数,进度"莫名其妙"就恢复了。
如果只看代码,这个 bug 永远不会被发现。 它只在特定时序下触发,而时序恰恰是读代码时看不到的东西。
修复方式是把"跳过"改成"排队":
flushChunks() {
const run = async () => {
// while 而不是 if:写入期间新到的片也一并带走
while (this.recvBuffer.length) {
const batch = this.recvBuffer
this.recvBuffer = []
try {
await store.putChunks(batch)
} catch {
this.errorMessage = '分片写入本地存储失败,可能是磁盘空间不足'
}
}
}
this._flushChain = (this._flushChain || Promise.resolve()).then(run)
return this._flushChain
}
关键差异:并发调用不再被丢弃,而是排到正在写的那批后面等它写完再返回。这样 file-end 里的 await this.flushChunks() 拿到的 promise 必然涵盖所有积压分片,数出来的片数才是准的。
心得:AI 写并发代码时,习惯用布尔量做"互斥",但调用方真正需要的是"等待"。互斥只保证不重入,等待才保证顺序。这个区别在单测里看不出来,只有在真实时序下才暴露,而且暴露的形式(刷新后自己好了)会严重误导排查方向。
3.3 取消要双向同步,删除要真删
第二个坑来自交互设计:用户在发送端点"取消",接收端那条记录还挂着。
信令本身是通的(取消会发 file-cancel,对端收到后清 IndexedDB),缺的是移除条目——只把状态标成"已取消"留在列表里。
这比看起来更糟:半截文件既不能保存、缓存也已经被清了,条目却还显示着,会让人误以为"刷新后还能续传"。所以取消即移除,并且给一条明确提示。
还有个配套的坑:移除必须真的删 IndexedDB。只做数组过滤的话,刷新后它会被恢复逻辑又捞回来,用户会觉得"我明明删了怎么又出现了"。
四、案例二:30MB 的 wasm 该怎么放
视频处理依赖 ffmpeg.wasm。它的体积是:
| 文件 | 大小 |
|---|---|
ffmpeg-core.wasm |
30.74 MB |
ffmpeg-core.wasm.br |
6.94 MB |
ffmpeg-core.wasm.gz |
9.81 MB |
30MB 的静态资源,缓存策略一旦做错,要么用户每次访问都重下一次,要么旧版本被永久锁死在浏览器里。
采用的方案是版本目录 + 构建期注入:资源放在带版本号的目录里(public/ffmpeg/v0.12.15-0.12.10/),目录名由 @ffmpeg/ffmpeg 与 @ffmpeg/core 的版本号拼成。构建时读取 VERSION 文件,通过 DefinePlugin 注入前端:
// vue.config.js
ffmpegRuntimeDir = fs
.readFileSync(path.join(__dirname, 'public', 'ffmpeg', 'VERSION'), 'utf8')
.trim()
defs['process.env'].FFMPEG_RUNTIME_DIR = JSON.stringify(ffmpegRuntimeDir)
好处是:内容变了目录名就变。于是线上可以给 /ffmpeg/ 配一年的 immutable 强缓存,既不用牺牲缓存时长,也不会把旧版本锁死。
配套的一点:这么大的东西不能预加载,只有用户真的点"开始处理"时才去拉,工具页本身保持轻量。
心得:大资源的缓存问题,解法从来不是"不缓存"或"缩短缓存时间",而是让 URL 跟着内容变。这是非常经典的方案,但在几十 MB 的 wasm 上,收益被放大到不能忽视的程度。
五、案例三:260 个页面的预渲染与产物治理
SPA 对搜索引擎不友好,所以构建期做预渲染,295 条路由生成 260 个工具页目录和 64 个教程页。产物真实体积约 114 MB,其中 JS 48 MB、ffmpeg 资源 47 MB。
这里踩的坑不在构建本身,而在目录累积。
有次打包,产物比上一版小了 6 MB。第一反应是"是不是少了文件",查完才发现:上一版的 dist 是多次构建叠加出来的,里面还躺着旧版 ffmpeg 的平铺文件(结构改成版本目录之前的残留)。这次是删干净后全新构建的,所以变小了——变小反而是对的。
由此形成一个习惯:
- 构建前先清空 dist,杜绝残留
- 构建后核对
dist/tool-detail目录数 与 sitemap 里的 URL 条数是否 1:1 - 顺带扫一遍有没有异常小的页面文件(小于 500B 基本就是预渲染失败了)
这三步做完,交付前的焦虑感会少很多。
六、部署环节:反向代理与阿里云AI运维
P2P 的信令服务是这套系统里最容易被部署搞坏的一环,因为 WebSocket 必须显式处理协议升级:
location /p2p/ {
proxy_pass http://127.0.0.1:8788/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
漏掉那两行 Upgrade 头,表现是 WebSocket 握手失败、房间永远连不上,但页面看上去一切正常——这种"半死不活"的故障最难排查。
再就是路由白名单:新工具页上线时如果没同步进 nginx 的白名单配置,访问就是 404,而本地开发完全测不出来。
在阿里云AI运维这一侧,我把两件事固化进了流程:
- 发布后核对:用脚本对比线上页面数与本地产物,避免上传中断导致的部分更新
- 日志排查:把 nginx 访问日志和错误日志的片段交给 AI 做模式归纳,定位 502、慢请求和异常爬虫
第二点的性价比意外地高。运维数据都是已经发生的客观记录,AI 擅长从大量文本里找模式,而人看日志看到第三屏就开始疲劳漏看了。
一个必须守住的底线:喂给 AI 之前先脱敏,IP、token、用户数据一律先处理掉。这是用 AI 做运维的先决条件,不是可选项。
七、AI开发 的四条经验:边界在哪里
回顾整个过程,真正让我少踩坑的是这四条:
1. 先让 AI 讲清思路,再让它写代码。
如果它说不清"为什么这样设计",那份代码大概率是按模式拼出来的。讲不清思路的实现,往往也经不起边界推敲。
2. 主动索要边界清单。
每个功能交付时多问一句:"哪些情况下它会出错?" AI 的默认输出只包含顺利路径,异常路径要靠提问才拿得到。
3. 并发、时序、缓存这三类代码必须自己逐行复核。
这是 AI 目前最高频的出错区。原因也合理:它看到的是代码的局部,而 bug 发生在运行的时序里。第三节那个 75%,就是典型。
4. 验证环节不能交给 AI。
它看不到运行时。真实网络、真实磁盘、用户真实的刷新时机——这些只能靠实测。
还有一条更根本的体会:
AI 最大的价值,不是让我少思考,而是让我更快走到"犯错"那一步。
以前写一个功能要半天,错一次的代价太大,于是倾向于保守;现在十分钟能出一版,我可以更快犯错、更快纠正,迭代密度本身就是收益。前提是——省下来的时间要拿去做判断,而不是拿去产出更多没经过验证的代码。
八、写在最后
3Q工具箱 现在有 260 个工具、64 篇教程,这个规模在 AI 协作下是可持续的,但它从来不是"把需求丢进去、把代码拿出来"那么简单。
真正决定产出质量的,始终是那些 AI 给不了的东西:这个功能该不该做、这个方案在极端情况下会怎样、这个取舍对三个月后的维护意味着什么。
如果你的项目也在用 AI 辅助开发,希望你踩的坑比我少一些;如果已经踩了,也希望这篇能帮你少排查几个小时。
更重要的是——别只把它当成一个提效工具。当你能用十分钟验证一个想法时,那些以前因为"太麻烦"而放弃的、更有趣的技术尝试,其实都重新变得可行了。