AI 电子伴侣
创建时间: 2026-04-18最后更新: 2026-04-18

1. Workers 的「状态短板」与 Durable Objects

到这一步为止,我们写的 Hono + Workers 代码都是无状态的:每个请求落到哪个节点是随机的,两个相邻的请求之间没有共享内存。

这个模型简单、好扩展,但它也有明显的盲区:

  • AI 会话记忆:多轮对话需要一个「始终保留同一份对话历史」的地方
  • WebSocket 长连接:连接本身是有状态的,不能被随机路由
  • 实时协作:多个用户同时在线编辑文档、在同一个会议室里,需要协调
  • 精确计数/限流:按秒/毫秒级限流的计数器,KV 的最终一致性搞不定

Durable Objects(DO) 就是为这些场景准备的。它是 Cloudflare 提供的「有身份的 Worker」:

  • 每个 Object 有一个全局唯一的 ID,相同 ID 一定路由到同一个实例
  • 实例有内存状态(重启之前一直保留)
  • 强一致的持久化存储ctx.storage),写入立即可读
  • 支持 WebSocket 长连接,而且有专门的 Hibernation API 降低成本

普通 Worker 你可以理解成纯函数——进来一个请求,处理完就没了。DO 更像一个类的实例——有自己的 ID、有内存、有持久化存储,每次找它都是同一个。

2. 第一个 Durable Object:计数器

我们从一个最小的例子开始——一个按「房间 ID」分片的计数器。

2.1 定义 DO 类

src/rooms.ts
01
// cloudflare:workers 是 Cloudflare Workers 的内置模块,不是 npm 包
02
import { DurableObject } from 'cloudflare:workers'
03
04
export class Counter extends DurableObject {
05
async increment(): Promise<number> {
06
// ctx.storage 是这个 Object 独占的强一致存储
07
const current = (await this.ctx.storage.get<number>('count')) ?? 0
08
const next = current + 1
09
await this.ctx.storage.put('count', next)
10
return next
11
}
12
13
async get(): Promise<number> {
14
return (await this.ctx.storage.get<number>('count')) ?? 0
15
}
16
}

几个要点:

  • DurableObject 是 Cloudflare 提供的基类,从 cloudflare:workers 导入
  • this.ctx.storage 是这个实例独占的持久化存储(不是全局共享的)
  • 类上的方法可以被 Worker 远程调用(Cloudflare 把这叫 RPC,Remote Procedure Call),后面会看到调用方式

2.2 在 wrangler.jsonc 里配置

wrangler.jsonc
01
{
02
"durable_objects": {
03
"bindings": [
04
{
05
"name": "COUNTER",
06
"class_name": "Counter"
07
}
08
]
09
},
10
"migrations": [
11
{
12
"tag": "v1",
13
"new_sqlite_classes": ["Counter"]
14
}
15
]
16
}
  • durable_objects.bindings 告诉 Worker 这个名字对应哪个类
  • migrations.new_sqlite_classes 声明这是个新的 SQLite-backed DO 类(推荐的新格式,比旧的 KV-backed 更便宜)

2.3 在 Hono 里调用

src/index.ts
01
import { Hono } from 'hono'
02
import { Counter } from './rooms'
03
04
type Bindings = {
05
COUNTER: DurableObjectNamespace<Counter> // Workers 内置类型,不需要 import
06
}
07
08
const app = new Hono<{ Bindings: Bindings }>()
09
10
app.post('/rooms/:roomId/click', async (c) => {
11
const roomId = c.req.param('roomId')
12
13
// 1. 从 roomId 得到一个稳定的 DO ID
14
const id = c.env.COUNTER.idFromName(roomId)
15
// 2. 拿到这个 DO 的 stub(可以理解成远程引用)
16
const stub = c.env.COUNTER.get(id)
17
// 3. 调用它的方法,和调用本地对象一样
18
const count = await stub.increment()
19
20
return c.json({ roomId, count })
21
})
22
23
app.get('/rooms/:roomId/count', async (c) => {
24
const roomId = c.req.param('roomId')
25
const stub = c.env.COUNTER.get(c.env.COUNTER.idFromName(roomId))
26
const count = await stub.get()
27
return c.json({ roomId, count })
28
})
29
30
// 必须 export DO 类,Workers 运行时才认得
31
export { Counter }
32
export default app

关键机制idFromName(roomId) 对同一个字符串永远返回同一个 ID,也就永远路由到同一个实例。所以 rooms/abc/clickrooms/abc/count 一定落到同一个 Counter 实例,计数不会错乱。

stub.increment() 底层是一次跨节点的远程调用,但写法和调本地对象一样——你不需要关心这个 Counter 实例跑在全球哪个节点上。

3. WebSocket:AI 聊天的长连接

WebSocket 是 DO 最有代入感的用法。流式响应用 SSE 已经够用,但有些场景离不开双向通信:

  • 语音对话(前端流式上传音频,后端流式推送识别结果 + 回答)
  • 多端同步(用户在手机上发的消息,桌面同时更新)
  • 协作式 AI(多个用户一起和 AI 对话)

Workers 的普通 fetch handler 不处理 WebSocket,必须用 DO。

3.1 Hibernatable WebSocket:一定要用的 API

Cloudflare 对 WebSocket 有两种 API:传统的 accept()Hibernatable 版本 acceptWebSocket()。两者的关键差别:

  • 传统版:连接期间 DO 实例必须常驻内存,空闲也按时间计费
  • Hibernatable:DO 可以在没消息时进入休眠,休眠期间不计费,有消息到来时自动唤醒

对于「用户打开着一个聊天页面但 20 分钟没说话」这种场景,Hibernatable 每月能省掉绝大部分费用。新项目直接用 Hibernatable 就行。

3.2 最小的聊天 DO

src/chat-room.ts
01
import { DurableObject } from 'cloudflare:workers'
02
03
type Env = { AI: Ai }
04
05
interface ChatMessage {
06
role: 'user' | 'assistant'
07
content: string
08
}
09
10
export class ChatRoom extends DurableObject<Env> {
11
async fetch(request: Request): Promise<Response> {
12
// 1. 必须是 WebSocket upgrade 请求
13
if (request.headers.get('Upgrade') !== 'websocket') {
14
return new Response('Expected WebSocket', { status: 426 })
15
}
16
17
// 2. 创建一对 WebSocket
18
const pair = new WebSocketPair()
19
const [client, server] = Object.values(pair)
20
21
// 3. Hibernatable 模式接收服务端一侧
22
this.ctx.acceptWebSocket(server)
23
24
// 4. 返回客户端一侧给上游
25
return new Response(null, { status: 101, webSocket: client })
26
}
27
28
// Hibernatable 版本的消息处理器(注意不是 addEventListener)
29
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
30
if (typeof message !== 'string') return
31
32
const userMsg: ChatMessage = { role: 'user', content: message }
33
34
// 取出历史消息
35
const history = (await this.ctx.storage.get<ChatMessage[]>('history')) ?? []
36
history.push(userMsg)
37
38
// 调 LLM
39
const result = await this.env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
40
messages: history,
41
})
42
43
// Workers AI 文本生成返回 { response: string },类型声明比较宽泛所以要断言一下
44
const assistantMsg: ChatMessage = {
45
role: 'assistant',
46
content: (result as { response: string }).response,
47
}
48
history.push(assistantMsg)
49
50
// 保存历史
51
await this.ctx.storage.put('history', history)
52
53
// 把回答推给客户端
54
ws.send(JSON.stringify(assistantMsg))
55
}
56
57
async webSocketClose(ws: WebSocket, code: number) {
58
// 连接关闭时的清理逻辑
59
console.log(`WebSocket closed with code ${code}`)
60
}
61
}

这个 DO 做到了三件事:

  1. 接受 WebSocket 连接(Hibernatable 模式)
  2. 每条消息都存进持久化的历史
  3. 每条消息都触发一次 LLM 调用,把回答发回去

没有内存里的事件监听器——Hibernatable 模式下 Cloudflare 运行时帮你处理,实例可以随时被换出内存。

3.3 Hono 侧的 upgrade 入口

src/index.ts
01
import { Hono } from 'hono'
02
import { ChatRoom } from './chat-room'
03
04
type Bindings = {
05
CHAT_ROOM: DurableObjectNamespace<ChatRoom>
06
AI: Ai
07
}
08
09
const app = new Hono<{ Bindings: Bindings }>()
10
11
app.get('/chat/:userId', async (c) => {
12
// 每个用户一个独立的 ChatRoom
13
const userId = c.req.param('userId')
14
const id = c.env.CHAT_ROOM.idFromName(userId)
15
const stub = c.env.CHAT_ROOM.get(id)
16
17
// 把 upgrade 请求直接转给 DO,DO 自己处理 101 响应
18
return stub.fetch(c.req.raw)
19
})
20
21
export { ChatRoom }
22
export default app

前端用标准的 WebSocket API 就能连:

client.ts
1
const ws = new WebSocket('wss://your-worker.workers.dev/chat/alice')
2
3
ws.onmessage = (e) => {
4
const msg = JSON.parse(e.data)
5
console.log('AI:', msg.content)
6
}
7
8
ws.send('你好,介绍一下 Cloudflare Workers')

4. 实战:多人聊天室

单用户的场景其实用不上「多连接」。真正发挥 DO 价值的是「多个人在同一个房间里」——比如几个人一起和一个 AI Agent 开会。

src/meeting-room.ts
01
import { DurableObject } from 'cloudflare:workers'
02
03
type Env = { AI: Ai }
04
05
export class MeetingRoom extends DurableObject<Env> {
06
async fetch(request: Request): Promise<Response> {
07
const url = new URL(request.url)
08
const username = url.searchParams.get('user') ?? 'anon'
09
10
const pair = new WebSocketPair()
11
const [client, server] = Object.values(pair)
12
13
// serializeAttachment 把数据附加到这个连接上
14
// DO 休眠再唤醒后,用 deserializeAttachment 能拿回来
15
server.serializeAttachment({ username })
16
17
this.ctx.acceptWebSocket(server)
18
19
return new Response(null, { status: 101, webSocket: client })
20
}
21
22
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
23
if (typeof message !== 'string') return
24
25
const { username } = ws.deserializeAttachment() as { username: string }
26
const payload = JSON.stringify({ from: username, text: message })
27
28
// 广播给房间里所有其他连接
29
for (const client of this.ctx.getWebSockets()) {
30
if (client === ws) continue
31
try {
32
client.send(payload)
33
} catch {
34
// 客户端掉线时可能报错,忽略即可
35
}
36
}
37
}
38
}

关键 API:

  • serializeAttachment / deserializeAttachment:给每个连接挂自定义数据。因为 DO 可能休眠再唤醒,内存变量会丢失,但 attachment 会被序列化保留
  • ctx.getWebSockets():拿到这个 DO 当前所有活跃的 WebSocket 连接,遍历一下就能做广播

5. Alarms:给 DO 加定时器

DO 还有一个相当实用的功能:Alarms。它允许你给这个实例设一个「几分钟/几小时后叫醒我」的闹钟,到点了 Cloudflare 会调用 alarm() 方法。

典型用途:AI 会话的「不活跃自动归档」。

src/chat-room.ts
01
export class ChatRoom extends DurableObject<Env> {
02
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
03
// ... 处理消息 ...
04
05
// 每次有消息,就把「闹钟」推到 30 分钟后
06
await this.ctx.storage.setAlarm(Date.now() + 30 * 60 * 1000)
07
}
08
09
async alarm() {
10
// 30 分钟没消息了,归档历史并重置
11
const history = await this.ctx.storage.get('history')
12
if (history) {
13
await archiveToR2(history) // 保存到 R2
14
await this.ctx.storage.delete('history')
15
}
16
}
17
}

Alarms 可以替代很多 cron 的小场景——它精确到单个 DO 实例,比全局定时任务更灵活。

6. 计费和注意事项

DO 的计费模型和普通 Worker 不同:

  • 请求费:每百万请求 $0.15(付费版),和 Worker 一个量级
  • Duration(时长):DO 在内存里活跃时按 GB-s 计费。Hibernatable WebSocket 在休眠期间不计时长
  • Storage(存储):SQLite-backed DO 按行和字节计费,比老的 KV-backed 便宜一个量级

新建 DO 一律用 SQLite-backed 类(new_sqlite_classes),原因:便宜 + 未来迁移友好 + 支持 SQL 查询(ctx.storage.sql)。

6.1 什么时候不要用 DO

DO 不是什么都适合做,有几个明显不该用的场景:

  • 全局单例计数器:DO 同一时间只能有一个实例,一个 Object 每秒几千请求就会变成瓶颈。要做「全站访客统计」用 Analytics Engine,不是 DO
  • 任意键值缓存:用 KV 或 Cache API,DO 比它们贵
  • 大规模数据存储:单个 DO 的 storage 有上限(10 GB),大库用 D1

怎么判断用不用 DO?看你的数据有没有自然的分片键。房间 ID、用户 ID、会话 ID、文档 ID——这些都适合。没有自然分片键的场景(比如全站汇总统计)通常不适合。

7. 小结

DO 的核心就三件事:

  1. idFromName(string) 让相同名字永远路由到同一个实例
  2. 实例有内存状态 + 强一致的持久化存储(ctx.storage
  3. Hibernatable WebSocket 让长连接在空闲时不花钱

对 AI 应用来说,最常见的用法是:每个用户或每个房间一个 DO 实例,存对话历史,用 WebSocket 做双向通信,用 Alarms 做会话超时归档。