10万行Deno代码迁移Cloudflare Workers实战

prompt2bot 将 10 万行 Deno 智能体平台迁至 Cloudflare Workers,用 Durable Objects 承载有状态会话并保留 Deno 工具链。
prompt2bot 团队把 10 万行 Deno 智能体平台从 Deno Deploy 迁移到 Cloudflare Workers + SQLite Durable Objects,同时保留 deno test、类型检查和 JSR 生态。他们用 10 行桩类映射 cloudflare:workers,让原生测试直接跑 Durable Object 类,构建时再替换回真实类并 shim 全局 API。针对 128MB 堆限制,构建期剥离 js-tiktoken、大词典和重复 pdf.js 等静态表,每个 isolate 释放约 80MB。团队还在 SQLite 上模拟 Deno.Kv,用类型标签加 20 位补零数字编码元组键以匹配排序。两个隐蔽陷阱是 cdn-loop 头被复制导致 508 循环,以及 waitUntil 的 30 秒墙钟上限;最终通过按会话分片 Runner Durable Object 并利用 alarm() 的干净 IoContext 绕过。
把一套10万行的Deno智能体平台搬到Cloudflare Workers,同时不放弃Deno工具链,这件事听起来像自找麻烦。但prompt2bot团队最近就完成了从Deno Deploy到Cloudflare Workers + SQLite Durable Objects的迁移,并且坚持保留deno test、类型检查和JSR生态。他们的技术复盘披露了大量边缘运行时才有的坑,对任何想在Workers上跑有状态智能体的团队都有参考价值。

为什么是Durable Objects:有状态智能体的天然容器
据reddit原帖作者解释,普通Worker是无状态的——50个请求会拉起50个互不共享的随机isolate。而Durable Object是全局唯一、单线程的V8 isolate,按名字寻址,自带私有SQLite数据库和持久化定时器。任何Worker调用同名对象的方法,都会被路由到内存中同一个JS对象上,SQLite查询是微秒级的同步C++调用,而不是网络往返。
这个特性对智能体平台很关键。prompt2bot的每个活跃会话都可以映射到一个Durable Object,对话历史、AST缓存和状态更新都留在同一个进程里,没有队列、没有Worker池、也没有需要预置的服务器。无论1个用户还是1万个并发会话,架构上都只是名字不同的对象实例。
Durable Objects 与普通 Worker 的本质区别
Cloudflare Workers 的默认执行模型是无状态且高度分布式的:每个请求可能落在全球任意一个 isolate 上,请求之间不保证共享内存,也不保证落到同一台机器。这种模型适合无状态 API,但对需要维护会话状态、缓存或串行化操作的场景并不友好。
Durable Objects 则提供了“有状态单例”的抽象。每个对象由一个名字唯一标识,Cloudflare 保证同名对象在全局只有一个活跃实例,并且所有对该对象的调用都会被串行化到同一个单线程 V8 isolate 中。对象可以附带私有 SQLite 存储和持久化 alarm 定时器,读写不经过网络往返。这使得它天然适合充当会话容器、协调者或轻量数据库。代价是对象有内存上限、单线程吞吐有限,且需要显式设计分片策略来避免热点。
保留Deno工具链:10行桩类解决运行时差异
团队拒绝把10万行代码重写成Node/Wrangler项目,理由是Deno的测试运行器、类型检查器和JSR支持“好得舍不得放弃”。他们的办法是在deno.json里把cloudflare:workers映射到一个10行的桩类,让deno test能直接对Durable Object类跑原生测试。
部署时,构建脚本执行deno bundle --minify,再把内联的桩换回真正的Cloudflare DurableObject类,同时做几件脏活:shim globalThis.Deno、把Zod v4设为jitless: true(Workers禁止new Function)、包住npm包在import时执行的顶层crypto.randomUUID()和setTimeout()调用。这套“测试用Deno、部署用Workers”的双轨流程,让原有测试套件一行没改。
deno bundle 与 JSR 在跨运行时构建中的角色
deno bundle 是 Deno 内置的打包命令,能把入口模块及其依赖图打成一个单文件,并支持 --minify 压缩。它不依赖 Node 的 node_modules 解析,而是遵循 Deno 的模块解析规则,因此对 JSR 包和 URL 导入天然友好。
JSR(JavaScript Registry)是 Deno 团队推出的现代包注册表,原生支持 TypeScript、ESM 和生成文档,包通过 jsr: 说明符导入。相比 npm,JSR 的包在类型检查和跨运行时兼容性上更透明。保留 JSR 生态意味着团队不必把依赖迁回 npm,也无需引入 Wrangler 的打包链。构建脚本在 bundle 之后做桩替换和全局 shim,实际上是把 Deno 的模块图“降级”到 Workers 能接受的形态,同时让测试阶段继续使用原生 Deno 运行时。
128MB内存天花板:静态表是隐形杀手
每个Durable Object isolate有硬性的128MB堆限制,这是迁移中最容易踩的雷。原帖列出的OOM元凶很具体:js-tiktoken内嵌5.3MB的base64 BPE排rank表,首次调用会膨胀到+43MB V8堆;一个27.5万词的词典占+35MB;pdf-parse里还塞了3份重复的旧版pdf.js构建。
他们的解法是在构建期剥离这些静态表,每个isolate释放约80MB V8堆。这个数字说明,在边缘有状态运行时里,依赖体积不能只看打包大小,还要看首次调用后的堆膨胀。
V8 堆膨胀与 isolate 内存限制
Durable Object 的 128MB 限制指的是 V8 堆内存,而不是打包产物体积。一个依赖在磁盘上只有几 MB,首次调用时却可能因为解码 base64、构建查找表、解析大型 JSON 或初始化解析器而膨胀出数十 MB 的堆对象。这些对象一旦被模块级变量或缓存持有,就会在整个 isolate 生命周期内常驻。
js-tiktoken 的 BPE rank 表就是典型例子:base64 字符串本身约 5.3MB,解码后变成字符串和 Map 结构,堆占用会数倍增长。大词典和重复的 pdf.js 构建同理。边缘运行时不会像 Node 进程那样可以轻松突破内存上限,因此构建期剥离静态表、改为懒加载或外置存储,是在 Workers 上运行重依赖的常见手段。
用SQLite模拟Deno KV:顺序编码是难点
Cloudflare KV最终一致、不支持原子check-and-set事务,而Deno.Kv的API依赖这些语义。团队在SQLite Durable Object之上做了一个可替换的Deno.Kv模拟器,代号enam-v1,固定在美国东部以保持常驻内存、零冷启动。
关键设计是元组键的编码:用类型标签加20位补零数字(n:、b:、s:),以null字节分隔,让SQLite的范围查询能匹配Deno KV的排序规则。atomic().check().set().commit()则作为单线程SQLite事务同步执行。这种“用正确编码换兼容性”的做法,值得任何需要在SQLite上模拟有序KV的开发者参考。
Deno.Kv 的原子性与元组键排序
Deno.Kv 是一个内置的键值存储 API,支持结构化键(由字符串、数字、布尔等组成的元组)、前缀范围查询和原子事务。它的原子操作通过 atomic().check().set().commit() 表达,语义上要求 check 和 set 在同一个事务中串行执行,不能有并发插入。Cloudflare KV 最终一致且不支持跨键原子 check-and-set,因此无法直接承载这些语义。
SQLite 本身支持事务和范围查询,问题在于如何让 SQLite 的排序规则与 Deno.Kv 的元组键排序一致。Deno.Kv 按组件类型和值排序,数字按数值、字符串按字典序。把不同类型编码成带类型标签和定长补零的字符串,再以 null 字节分隔,就能让 SQLite 的 BLOB/TEXT 比较行为逼近原始排序。这是用存储层编码换取 API 兼容性的典型做法。
两个隐蔽陷阱:508循环与30秒墙
原帖披露的两个坑最容易被忽视。其一是508 Loop Detected:当Deno Deploy上的服务调用他们的Worker,Worker又向外fetch回同一个Deno服务时,Cloudflare在C++边缘层把入站的cdn-loop: deno;d=<hash>头原样复制到了出站子请求上。Deno Deploy看到自己的部署哈希出现在cdn-loop里,直接以508拒绝。
其二是ctx.waitUntil()的30秒墙钟上限:Worker返回HTTP 200后,即使配置了cpu_ms: 300000,后台任务也会在30秒后被硬性切断,多步智能体回合因此中途死亡。
按会话分片Runner:alarm()是逃生通道
他们的最终方案是把执行分片到每个会话一个Runner Durable Object(runner:<botId>:<conversationId>)。原因在于只有Durable Object的alarm()运行在干净的根IoContext里——没有继承的cdn-loop头,也没有30秒响应后墙钟限制,前提是alarm()通过doCtx.waitUntil启动任务后立即退出,而不是阻塞等待。
这个架构让每个活跃会话自动获得一个热的、隔离的Runner,带内存中的历史和安全脚本AST缓存,加上亚毫秒级SQLite状态和按需VM。从工程角度看,它把“有状态执行”从Worker的无状态模型里剥离开,交给专门的长生命周期对象处理。原作者表示完整技术细节和代码片段会放在评论区,对想复刻这套方案的团队来说,值得追原帖。
相关推荐

Gemini 4 Argon实测:AI自主造桌游、建寺庙、训机器人
Gemini 4 Argon 实测汇总:AI 自主用 Blender 搭建书桌赛道与寺庙、编写光线追踪引擎、训练人形机器人连进7球,并附 API 定价与开发者接入方式。

魔法八边形硬币:为何仅9%的人能预测它的翻转?
魔法八边形硬币仅9%的人能正确预测其翻转方向。本文解析其空间旋转机制、常见认知误区,并介绍基于相关性理论的新假说,探讨人类空间推理的局限。

Sam Altman:AI正迈入科学发现时代,数学突破将复制到生物与能源
Sam Altman 表示 AI 正进入科学发现时代,数学领域的突破方法将复制到生物学、材料科学和能源领域,帮助研发新药与新能源。他认为这种影响不会让人直接感知,而是融入社会日常运转。