浏览器围棋引擎上线失效的排障复盘
团队上线 KataGo 后,测试虽通过,生产环境却因缺少跨源隔离而始终回退到旧引擎。读取引擎会话状态后,才发现其不可用且查询次数为零,文章复盘了浏览器集成与修复过程。
AI 深度解读
KataGo 已经上线,资源部署完毕,验收也通过了,但它从没真正下过一步棋。作者团队的浏览器围棋游戏一直由旧引擎悄悄兜底,直到有人检查线上引擎的会话状态,才发现默认 AI 根本没有启动。
这次故障把一个容易忽略的问题摆到了前端面前:WASM 能编译、iframe 配了响应头、页面能正常玩,都不能证明目标引擎已经工作。真正值得借鉴的,是他们如何修正隔离边界,以及怎样让重型引擎按设备能力运行、失败后继续提供服务。
验收全过,为什么线上一步都没下
原来的游戏有 12 档难度。1–9 档使用编译成 WASM 的 GNU Go,低难度通过主动注入失误实现;10–12 档使用约业余二段水平的神经网络,通过 TensorFlow.js 执行,再接上团队用 TypeScript 编写的 PUCT 搜索循环。
PUCT 可以理解为:结合模型判断,把计算预算分配给更有希望的落子。旧方案每秒约完成 40 次评估,只支持 19×19 棋盘,而且依赖 WebGL。9×9、13×13 无法走神经网络路径,高难度也有明显上限。
作者在实验室测试了未经修改的 KataGo v1.18.2,使用 Eigen CPU 后端,每步只搜索 200 visits,便在两盘完整对局中击败 GNU Go level 10,对方均认输,估计分差约为 45 目和 165 目。这里的 visits 是搜索访问次数,并不等于耗时。
部署用的是 Emscripten 3.1.64。较大的 b10 网络传输量约 13.4 MB,较小的 b6 约 6.1 MB,仅在开始人机对局时懒加载,并跨会话缓存。作者还指出,引擎代码采用 MIT,g170 网络采用 CC0;相比他们集成的 GPL 引擎 Stockfish、Fairy-Stockfish,这减少了对应源码交付方面的负担。
但线上会话给出的证据是:
state: "unavailable", failReason: "not cross-origin isolated", queryCount: 0
要看最后一项:queryCount: 0。它证明问题发生在引擎真正接收查询之前。
旧引擎足够好,自动回退又足够顺畅,于是页面正常、用户正常下棋,新引擎失效也没有引起投诉。
业务功能可用,不能替代目标执行路径已经生效的证据。
为什么只给 iframe 配响应头没有用
KataGo 的搜索池和神经网络评估池依赖线程。作者实际编译过不带 pthread 的 WASM:能够编译,也能回答 version,但初始化引擎时就终止了。对他们采用的上游实现而言,去掉线程需要维护分叉版本,团队没有选择这条路。
浏览器中的 Emscripten pthreads 依赖 SharedArrayBuffer,后者要求文档进入跨源隔离状态。关键条件是 Cross-Origin-Opener-Policy: same-origin,配合 Cross-Origin-Embedder-Policy: require-corp 或 credentialless。
团队最初想缩小影响范围:主页面不加隔离头,把引擎放进同源 iframe /kata-host.html,仅给这个宿主页加头,再通过 postMessage 通信。
问题出在验收环境。实验时,父页面已经隔离,12/12 项检查全部通过;生产父页面没有隔离,iframe 每次都报告 {isolated: false, sab: false}。子页面上的响应头无法单独建立所需的隔离环境。
启动预检正确拒绝了加载,随后旧引擎接管。响应头确实存在,却配在了无法解决问题的边界上。
最终,团队给整个 covay 子域启用隔离。应用的对局、房间、设置、历史记录都是同一文档中的客户端视图,也无法靠切换路由只隔离对局界面。
下面这条 Next.js 配置让所有路径返回相同的隔离响应头:
// next.config.ts — one catch-all rule
{
source: "/:path*",
headers: [
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
{ key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
{ key: "Cross-Origin-Resource-Policy", value: "same-origin" },
],
},
关键是 source: "/:path*":范围覆盖整个应用,而非仅覆盖引擎宿主页。
作者选择 require-corp,理由是他们发现 Firefox 在 credentialless 下无法进入隔离状态,同时 Safari 对 require-corp 的支持可追溯到 15.2+。这些兼容性判断属于原文报告,不能据此推定未来版本的行为。
启用前,他们审计了所有跨源加载。分析统计、TF.js 回退、国际化请求和 Supabase 调用都正常,因为使用 CORS 模式的请求不必额外依赖 CORP 响应头。
唯一受影响的是 Supabase storage 头像:普通 <img> 加载没有获得所需授权。存储端已有 Access-Control-Allow-Origin: *,团队在三处头像上添加 crossOrigin="anonymous",便让加载转入 CORS 模式。
LiveKit 信令使用 WebSocket,语音使用 WebRTC,不属于 COEP 管理的子资源加载。修改后,生产探针通过 37/37 项检查,确认 crossOriginIsolated === true、真实 KataGo 能提供落子和提示,回退路径也仍然可用。
重型引擎怎样适应不同设备
能启动只是第一步。手机和桌面设备的算力差异,需要进入运行策略。
团队先检查 self.crossOriginIsolated 和 SharedArrayBuffer;条件不满足就跳过本次会话的 KataGo,不下载资源。随后,在预设的 30 手局面上执行一次 40 visits 的 genmove,测出每秒访问次数,再缩放全部 12 档难度。
最低档目标为 300 ms、2 visits,温度设为 2.2,让落子更随机;最高档目标为 12 秒,最多 100,000 visits,并设 15 秒硬超时。在刻意选用的弱 2 核虚拟机上,实测约 15 visits/秒,最终选择 b6。作者称真实硬件会选择 b10,并快上数倍,但没有给出完整设备分布。
性能不足时,系统依次压缩 visits、把搜索线程减半、从 b10 换成 b6。只有 b6 仍低于约 4 visits/秒,才退回旧引擎。
另一个关键设计是状态归属。TypeScript 规则核心持有真实棋局,引擎只接收查询;每次查询都重放落子历史。作者测得,重放 60 手的成本近乎为零,因此重启引擎或中途切换实现,不必迁移内部棋局状态。
引擎返回的每一步仍要经过合法性校验。分析引擎不会自行认输,应用便在胜率低于 0.02、且搜索达到 150+ visits 时合成认输决定。worker 第一次死亡允许重启,第二次把访问预算比例减半,第三次结束 KataGo 会话并回退。
这些保护还有底层原因:作者发现,默认 Emscripten proxied-stdin 会与 KataGo 分离的写线程形成死锁,最终改用 SharedArrayBuffer 环形缓冲区输入。WASM 终止可能只表现为 worker 不再回答;分析协议遇到错误查询,也只写日志文件,不写 stdout。没有 watchdog 和响应超时,调用方可能一直等下去。
我的判断
最有价值的经验,是验收必须覆盖真实的页面嵌套关系和执行路径。隔离父页面里的 12/12,通过不了生产环境这一关;读取会话状态和实际查询次数,才揭开了自动回退掩盖的问题。
整个子域隔离也有前提:团队能控制响应头,愿意审计全部跨源资源,并且已有覆盖各档难度的旧引擎。对依赖第三方嵌入或无法修改外层页面的应用,这条路未必走得通。
棋力和性能数据则需要收着看。两盘棋支持「这个配置值得继续试」,不足以证明普遍胜率;60 手重放近乎零成本、真实硬件快数倍,也都缺少完整对照数据。原文没有比较 WebGPU 或服务端推理,不能拿这些结果替其他路线下结论。
回退保证了可用性,也会掩盖默认能力失效。 两者需要同时设计:用户继续使用,团队仍能看见故障。
能拿来做什么
- 接入 WASM 引擎时,把隔离状态、实际后端、成功查询次数纳入验收,并在与生产一致的父页面和 iframe 结构中检查。
- 准备启用 COOP/COEP 时,先按请求模式审计跨源资源;图片是否需要调整,应结合 CORS 响应头判断。
- 为本地计算安排小规模启动探测,再按设备缩放预算,并给计算任务设置硬超时和分级降级。
- 让业务规则层持有权威状态,校验引擎输出,并验证重启或回退后能否从历史恢复任务。