流式响应接口

要点

  • 我们之前写的所有接口,都遵循同一个模式:前端发请求 → 后端处理 → 后端把结果一次性返回
  • Hono 提供了三种流式响应方式,适用于不同场景
  • streamText 用于逐段发送纯文本
  • stream 是更底层的 API,适合文件下载、大文件转发等场景
  • streamSSE 用于 Server-Sent Events,适合 AI 对话的打字机效果

1. 普通请求的问题

我们之前写的所有接口,都遵循同一个模式:前端发请求 → 后端处理 → 后端把结果一次性返回。

这个模式在大多数场景下没问题。查一个用户信息,后端几十毫秒就查完了,一次性返回一个 JSON,前端拿到直接渲染。

但有些场景不适用。最典型的就是 AI 大模型。

你在 ChatGPT 上输入一个问题,回答不是「啪」一下全部出现的,而是一个字一个字地往外蹦,像打字一样。这背后的原因是:大模型生成文本是逐 token 进行的(token 大致可以理解为一个字或词),一段 500 字的回复可能要花 5-10 秒才能全部生成完。

如果等全部生成完再一次性返回,用户就得盯着空白页面干等好几秒,体验很差。更好的做法是:生成一个 token 就发一个 token,让用户立刻看到内容逐步出现。

这就是「流式响应」——服务端不是攒够了所有数据再发,而是边生成边发,数据像水流一样持续地从服务端流向客户端。

除了 AI 对话,还有一些场景也需要流式响应:

  • 大文件下载(不可能把几百 MB 的文件全部加载到内存再发)
  • 实时数据推送(股票行情、日志监控、通知提醒)
  • 长时间运行的任务汇报进度

2. Hono 的三种流式 API

Hono 提供了三种流式响应方式,适用于不同场景:

API用途Content-Type
streamText文本流(逐段发送文本)text/plain
stream二进制流(文件下载、转发)自定义
streamSSEServer-Sent Events(结构化事件流)text/event-stream

我们从最简单的 streamText 开始,逐步讲到最常用的 streamSSE

},

"migrations": [

{

"tag": "v1",

"new_sqlite_classes": ["Counter"]

}

]

}


- `durable_objects.bindings` 告诉 Worker 这个名字对应哪个类
- `migrations.new_sqlite_classes` 声明这是个**新的 SQLite-backed DO 类**(推荐的新格式,比旧的 KV-backed 更便宜)

### 2.3 在 Hono 里调用

```typescript
// src/index.ts
import { Hono } from 'hono'

import { Counter } from './rooms'

type Bindings = {

  COUNTER: DurableObjectNamespace<Counter>  // Workers 内置类型,不需要 import

}

const app = new Hono<{ Bindings: Bindings }>()

app.post('/rooms/:roomId/click', async (c) => {

  const roomId = c.req.param('roomId')

  // 1. 从 roomId 得到一个稳定的 DO ID

  const id = c.env.COUNTER.idFromName(roomId)

  // 2. 拿到这个 DO 的 stub(可以理解成远程引用)

  const stub = c.env.COUNTER.get(id)

  // 3. 调用它的方法,和调用本地对象一样

  const count = await stub.increment()

  return c.json({ roomId, count })

})

app.get('/rooms/:roomId/count', async (c) => {

  const roomId = c.req.param('roomId')

  const stub = c.env.COUNTER.get(c.env.COUNTER.idFromName(roomId))

  const count = await stub.get()

  return c.json({ roomId, count })

})

// 必须 export DO 类,Workers 运行时才认得

export { Counter }

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
import { DurableObject } from 'cloudflare:workers'
 
type Env = { AI: Ai }
 
interface ChatMessage {
 
  role: 'user' | 'assistant'
 
  content: string
 
}
 
export class ChatRoom extends DurableObject<Env> {
 
  async fetch(request: Request): Promise<Response> {
 
    // 1. 必须是 WebSocket upgrade 请求
 
    if (request.headers.get('Upgrade') !== 'websocket') {
 
      return new Response('Expected WebSocket', { status: 426 })
 
    }
 
    // 2. 创建一对 WebSocket
 
    const pair = new WebSocketPair()
 
    const [client, server] = Object.values(pair)
 
    // 3. Hibernatable 模式接收服务端一侧
 
    this.ctx.acceptWebSocket(server)
 
    // 4. 返回客户端一侧给上游
 
    return new Response(null, { status: 101, webSocket: client })
 
  }
 
  // Hibernatable 版本的消息处理器(注意不是 addEventListener)
 
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
 
    if (typeof message !== 'string') return
 
    const userMsg: ChatMessage = { role: 'user', content: message }
 
    // 取出历史消息
 
    const history = (await this.ctx.storage.get<ChatMessage[]>('history')) ?? []
 
    history.push(userMsg)
 
    // 调 LLM
 
    const result = await this.env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
 
      messages: history,
 
    })
 
    // Workers AI 文本生成返回 { response: string },类型声明比较宽泛所以要断言一下
 
    const assistantMsg: ChatMessage = {
 
      role: 'assistant',
 
      content: (result as { response: string }).response,
 
    }
 
    history.push(assistantMsg)
 
    // 保存历史
 
    await this.ctx.storage.put('history', history)
 
    // 把回答推给客户端
 
    ws.send(JSON.stringify(assistantMsg))
 
  }
 
  async webSocketClose(ws: WebSocket, code: number) {
 
    // 连接关闭时的清理逻辑
 
    console.log(`WebSocket closed with code ${code}`)
 
  }
 
}

这个 DO 做到了三件事:

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

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

3.3 Hono 侧的 upgrade 入口

// src/index.ts
import { Hono } from 'hono'
 
import { ChatRoom } from './chat-room'
 
type Bindings = {
 
  CHAT_ROOM: DurableObjectNamespace<ChatRoom>
 
  AI: Ai
 
}
 
const app = new Hono<{ Bindings: Bindings }>()
 
app.get('/chat/:userId', async (c) => {
 
  // 每个用户一个独立的 ChatRoom
 
  const userId = c.req.param('userId')
 
  const id = c.env.CHAT_ROOM.idFromName(userId)
 
  const stub = c.env.CHAT_ROOM.get(id)
 
  // 把 upgrade 请求直接转给 DO,DO 自己处理 101 响应
 
  return stub.fetch(c.req.raw)
 
})
 
export { ChatRoom }
 
export default app

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

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

4. 实战:多人聊天室

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

// src/meeting-room.ts
import { DurableObject } from 'cloudflare:workers'
 
type Env = { AI: Ai }
 
export class MeetingRoom extends DurableObject<Env> {
 
  async fetch(request: Request): Promise<Response> {
 
    const url = new URL(request.url)
 
    const username = url.searchParams.get('user') ?? 'anon'
 
    const pair = new WebSocketPair()
 
    const [client, server] = Object.values(pair)
 
    // serializeAttachment 把数据附加到这个连接上
 
    // DO 休眠再唤醒后,用 deserializeAttachment 能拿回来
 
    server.serializeAttachment({ username })
 
    this.ctx.acceptWebSocket(server)
 
    return new Response(null, { status: 101, webSocket: client })
 
  }
 
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
 
    if (typeof message !== 'string') return
 
    const { username } = ws.deserializeAttachment() as { username: string }
 
    const payload = JSON.stringify({ from: username, text: message })
 
    // 广播给房间里所有其他连接
 
    for (const client of this.ctx.getWebSockets()) {
 
      if (client === ws) continue
 
      try {
 
        client.send(payload)
 
      } catch {
 
        // 客户端掉线时可能报错,忽略即可
 
      }
 
    }
 
  }
 
}

关键 API:

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

5. Alarms:给 DO 加定时器

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

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

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

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——这些都适合。没有自然分片键的场景(比如全站汇总统计)通常不适合。

总结

DO 的核心就三件事:

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

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