zoe@dev:~$PID 1337
zoe@dev:~$ cat ~/blog/shipping-an-ai-tool-zh.mdx

上线一个 AI 工具:一路踩过的坑

2026-07-20·Zoe Lin·5 min read·#nextjs#vercel#seo#security#postmortem
EN中文日本語한국어EspañolFrançaisDeutschPortuguês

~/tools/paper-revision 这个 AI 功能本身反而是最简单的部分。真正花时间的,是让它在生产环境的表现和本地开发时一致——而且大部分 bug 根本不在 AI 逻辑里,而是藏在它周围的"管道系统"里。以下是实际踩过的坑。

静默的 502:SSE 连接需要心跳

用自己的 Anthropic API key 跑长一点的 review,偶尔会在大约 100 秒后失败——没有任何错误提示,连接就这么断了。Vercel 的日志只显示一个干净的 502,看不出真正原因。

真正的问题是:模型在生成过程中可能会安静一阵子(比如很长的内部"思考"过程,然后才吐出第一个可见 token),如果响应流太久没有任何字节流出,客户端和模型之间的某个中间层就会判定连接已经空闲,直接把它杀掉。解法是加一个心跳——用固定间隔往流里写一个不可见字符,保证字节一直在流动:

providers.ts
const HEARTBEAT_MS = 15_000
const HEARTBEAT_CHAR = '' // 零宽空格,渲染成 markdown 后看不见
 
const result = await Promise.race([pending, heartbeat])
if (result === 'heartbeat') {
  controller.enqueue(encoder.encode(HEARTBEAT_CHAR))
  continue
}

排查这个问题的过程中还挖出了一个藏得更深、更严重的 bug:abort 的处理逻辑会捕获所有名字叫 AbortError 的异常,并且一律当成用户主动取消——包括那些纯粹是网络自己断掉产生的异常。真正的失败被悄无声息地吞掉了。修复方式是先记录用户是否真的点了"abort"按钮,再决定要不要信任这个异常的名字。

BYOK(自带 API Key),但别留坑

免费版跑在托管模型上,有速率限制;用自己 key 的用户自己付费,理应完全跳过这个限制——除了最早的版本没有跳过,BYOK 的请求居然也在消耗免费版的额度。这个 bug 是靠一条很实在的用户反馈发现的:"我用的是自己的 key,怎么还被限速了"。

另一个 BYOK 相关的经验是主动防御性的:把可选模型限制在价格合理的档位内,避免请求不小心落到最贵的模型上;同时不管用哪个 provider,都给输出 token 数设上限。这些都拦不住用户自己乱花自己的钱,但至少能保证我们的代码不会是罪魁祸首。

Google 搜索死活不显示的 favicon

网站的 favicon 在每个浏览器标签页里都显示得好好的,唯独在 Google 搜索结果的 URL 旁边永远不出现。原因说出来有点尴尬:Next.js 的通用 icon 约定会在文件 URL 后面附加一个内容哈希——/icon.png?d72260d...——而这个哈希只要文件字节内容一变就会跟着变。Google 自己的文档写得很明确,这正是一种典型的失败模式:"favicon 的 URL 必须保持稳定——不要频繁更改"。每次编辑这个图标文件,都在悄悄清空 Google 对旧 URL 积累的信任。

Next.js 对刚好一个文件名做了特殊处理——favicon.ico 完全不会被加哈希,拥有一个永久稳定的 URL。把同一份图案包进一个极简的 ICO 容器、改用这个文件名,就是全部的修复内容。顺带也检查了一下域名配置:对 favicon 而言,Google 把不同的主机名当成完全独立的"站点",所以 www. 和 Vercel 默认分配的 *.vercel.app 域名都被设置成永久重定向到唯一的规范域名。

只会渲染一次的 Cloudflare Turnstile

Turnstile 的隐式渲染模式只会在脚本第一次加载时扫描一次页面上的 .cf-turnstile 元素。这个问题在传统的多页面网站上根本不会显形,因为每次跳转都会重新加载脚本——但这是个 SPA,review 表单的状态被刻意设计成能在客户端跳转后依然存活,这样正在进行中的 review 就不会被中途放弃。跳转离开再跳转回来后,组件的挂载点其实是一个全新的 DOM 节点,而那个早就加载完的脚本永远不会再去扫描它。token 就这样悄悄变成了 undefined——后来给分享接口加上服务端校验之后,这意味着每次"跳转离开再回来"之后的分享操作都会失败,而且没有任何看得出原因的提示。

解法是用 Turnstile 的显式渲染 API:脚本只加载一次,但每次组件挂载时自己调用 turnstile.render(),卸载时调用 turnstile.remove(),而不是依赖它那次性的自动扫描。

Windows 开发,Linux 生产

两个不同的 bug,同一个根源:在 Windows 上开发、部署到 Vercel 的 Linux 运行时,这两者根本不是同一个环境,而 PDF/图片处理相关的库正是这种落差最容易暴露的地方。pdf-parse 在生产环境报 DOMMatrix 错误直接崩溃,本地怎么都复现不出来——换成一个专门为 serverless 环境设计的 PDF 库(unpdf)之后问题彻底解决。另一个是 next/og 的图片生成在加载字体时崩溃,最后查到是因为 Windows 的文件路径里带了空格,库的 URL 解析逻辑处理不了。这两个 bug 在本地 npm run dev 里完全不存在,但在每一次部署上都是百分之百必现。

一次代码审查真正能挖出什么

对整个代码库做了一次完整审查,挖出了几个真实但不太起眼的问题:一个分享接口完全没有机器人验证——技术上任何人都能往一个公开 URL 发布任意文本;速率限制在检查上传文件到底能不能被解析之前就先扣了额度,导致一次格式错误的上传会白白消耗用户的额度,而那次 review 根本不可能跑起来;一个 zip 处理逻辑在检查压缩包条目声明的大小之前就先解压它,这正好是 zip 炸弹漏洞的经典形状;还有分享按钮,如果跳转离开又跳回来再点一次,会悄悄创建一个重复的分享链接,因为"是否已经有链接了"这个状态存在一个刚刚被销毁又重新创建的组件里。

这些都不是什么稀奇古怪的问题。它们只有在真的有人带着"这里可能哪里会出问题"的心态去读代码时才会浮现出来,而不是仅仅确认一下主流程能跑通就算完事。

写在最后

这份清单里的每一个 bug,在上线的那一刻都跑得好好的、看起来也确实做完了——直到某个特定的输入、某次特定的跳转,或者某个特定的托管环境,证明事实并非如此。踏踏实实做好的"无聊"基础设施,胜过从没在生产环境里真正跑过的"聪明"设计。exit 0

cd ~/blog ←