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

1. AI 项目里的「不由用户触发」的工作

前面所有章节讲的都是「用户请求 → Worker 处理 → 返回响应」。但真实 AI 项目里有一类工作不由用户请求触发

  • 后台异步任务:用户上传了 100 份文档,你不能让他在浏览器里等 10 分钟 embedding 跑完
  • 定时任务:每小时把过去一小时的 token 用量汇总到日表,每天凌晨清理过期 session
  • 解耦重活:用户请求立即返回「已接收」,真正的 LLM 调用、R2 归档、向量索引在后台慢慢做
  • 失败可重试:第三方 API 偶发 429/500,直接返回给用户就是用户承担,扔进队列系统自动重试更友好

Cloudflare 在 Workers 生态里提供了两个互补的工具:

工具触发方式典型用途
Cron Triggers按 cron 表达式定时触发汇总、清理、定时同步
QueuesWorker 往里塞消息,另一个 Worker 异步消费耗时任务解耦、重试、批量处理

这一篇讲怎么在 Hono + Workers 里用这两个工具。

2. Cron Triggers:最简单的定时任务

Cron Triggers 就是给你的 Worker 加一个「时间驱动」的入口。你在 wrangler.jsonc 里写 cron 表达式,Cloudflare 按时调用你 Worker 的 scheduled() 方法。

2.1 配置

wrangler.jsonc
01
{
02
"triggers": {
03
"crons": [
04
// 每小时 07 分跑一次(避开整点峰值)
05
"7 * * * *",
06
// 每天 UTC 03:30 跑一次
07
"30 3 * * *"
08
]
09
}
10
}

注意:Cron Triggers 用的是 UTC 时间,不是你本地时间。中国用户写定时任务记得先减 8 小时。

2.2 Hono + Cron:写 scheduled 入口

Hono 的 app 对象本身只处理 fetch。要加 cron 入口,需要包一层自定义的默认导出:

src/index.ts
01
import { Hono } from 'hono'
02
import { dailyUsageRollup, cleanupExpiredSessions } from './cron/jobs'
03
04
type Bindings = {
05
DB: D1Database
06
USAGE_KV: KVNamespace
07
}
08
09
const app = new Hono<{ Bindings: Bindings }>()
10
11
// 正常的 HTTP 路由
12
app.get('/', (c) => c.text('OK'))
13
14
// 包装默认导出,把 fetch 和 scheduled 放在一起
15
export default {
16
fetch: app.fetch,
17
// scheduled 不经过 Hono,所以没有 c(Context)
18
// env 和 ctx 从函数参数直接拿
19
async scheduled(
20
controller: ScheduledController, // Workers 内置类型
21
env: Bindings,
22
ctx: ExecutionContext // Workers 内置类型
23
) {
24
// 根据 cron 表达式分发到不同任务
25
switch (controller.cron) {
26
case '7 * * * *':
27
ctx.waitUntil(dailyUsageRollup(env))
28
break
29
case '30 3 * * *':
30
ctx.waitUntil(cleanupExpiredSessions(env))
31
break
32
}
33
},
34
}

几个关键点:

  • controller.cron:触发时的 cron 字符串,用来分发
  • ctx.waitUntil():和前面 AI Gateway 里用的一样——"先返回,但后台等这个 Promise 跑完再关"。多个任务可以并发 waitUntil,不必串行 await
  • 为什么 env 从参数拿scheduled 不走 Hono 的路由系统,没有 c(Hono Context),所以 env 要从函数参数拿,不是从 c.env

2.3 一个真实的定时任务:token 用量每小时汇总

第 18 篇的 AI 网关把每次请求的 token 消耗写进 KV,每小时聚合一次到 D1 日表(用 onConflictDoUpdate 反复刷新当天这一行)就是典型的 cron 场景:

src/cron/jobs.ts
01
import { drizzle } from 'drizzle-orm/d1'
02
import { usageDaily } from '../db/schema'
03
04
export async function dailyUsageRollup(env: {
05
DB: D1Database
06
USAGE_KV: KVNamespace
07
}) {
08
const db = drizzle(env.DB)
09
const today = new Date().toISOString().slice(0, 10)
10
11
// 列出所有用户的当天用量(KV 里按前缀扫)
12
const list = await env.USAGE_KV.list({ prefix: `usage:` })
13
14
for (const key of list.keys) {
15
// key 格式:usage:<apiKeyId>:<date>
16
const parts = key.name.split(':')
17
if (parts.length !== 3 || parts[2] !== today) continue
18
19
const apiKeyId = parts[1]
20
const record = await env.USAGE_KV.get<{
21
totalRequests: number
22
totalPromptTokens: number
23
totalCompletionTokens: number
24
}>(key.name, 'json')
25
26
if (!record) continue
27
28
// 写进 D1 的日表
29
await db.insert(usageDaily).values({
30
apiKeyId,
31
date: today,
32
requests: record.totalRequests,
33
promptTokens: record.totalPromptTokens,
34
completionTokens: record.totalCompletionTokens,
35
}).onConflictDoUpdate({
36
target: [usageDaily.apiKeyId, usageDaily.date],
37
set: {
38
requests: record.totalRequests,
39
promptTokens: record.totalPromptTokens,
40
completionTokens: record.totalCompletionTokens,
41
},
42
})
43
}
44
}

cron 跑完之后,你在 D1 里就有一张「每用户每日用量」表,做报表、算账单都很方便。KV 依然保留用于实时查询,D1 是历史的「真相源」。

2.4 本地触发 cron

Cron 在 wrangler dev 里不会自动触发。用这个端点手动触发一次:

terminal
1
curl "http://localhost:8787/__scheduled?cron=7+*+*+*+*"

cron= 参数用 + 代替空格,告诉 wrangler 触发哪条规则。

3. Queues:异步任务队列

Cron 解决的是「什么时候做」。Queues 解决的是「怎么把做事情本身从用户请求里剥离出去」。

3.1 生产者 / 消费者模型

Queues 是经典的发布-订阅:

  • Producer(生产者):通常是处理用户请求的 Worker,调用 queue.send(msg) 往队列塞消息
  • Consumer(消费者):另一个 Worker(或同一个),通过 queue() handler 批量接收消息

两个 Worker 之间不直接调用——生产者只需要「把事情扔出去」,消费者慢慢吃就行。失败可以自动重试,队列里的消息不会丢。

3.2 创建队列和配置

terminal
1
npx wrangler queues create rag-ingest

配置(可以生产者和消费者在同一个 Worker,也可以分开):

wrangler.jsonc
01
{
02
"queues": {
03
"producers": [
04
{
05
"queue": "rag-ingest",
06
"binding": "INGEST_QUEUE"
07
}
08
],
09
"consumers": [
10
{
11
"queue": "rag-ingest",
12
"max_batch_size": 10, // 一次最多攒 10 条
13
"max_batch_timeout": 5, // 或者等 5 秒就发一批
14
"max_retries": 3, // 失败重试 3 次
15
"dead_letter_queue": "rag-ingest-dlq"
16
}
17
]
18
}
19
}

dead_letter_queue(DLQ,死信队列)是处理失败超过 max_retries 次后的兜底。比如一条消息重试了 3 次都失败,它不会直接丢掉,而是被转到 DLQ 里。你可以之后排查原因、手动重跑。建议配上,不然失败的消息就真丢了。

3.3 生产者:从请求里分离重活

以 RAG 系统为例。用户上传一批文档,你不想让他等 embedding 跑完才回 200:

src/routes/upload.ts
01
import { Hono } from 'hono'
02
03
type Bindings = {
04
INGEST_QUEUE: Queue<IngestJob>
05
}
06
07
interface IngestJob {
08
docId: string
09
text: string
10
tenantId: string
11
}
12
13
const upload = new Hono<{ Bindings: Bindings }>()
14
15
upload.post('/documents', async (c) => {
16
const { docs, tenantId } = await c.req.json<{
17
docs: Array<{ id: string; text: string }>
18
tenantId: string
19
}>()
20
21
// 不在这里做 embedding(可能要几秒到几十秒)
22
// 只是把「要做的事」塞进队列,立即返回
23
for (const doc of docs) {
24
await c.env.INGEST_QUEUE.send({
25
docId: doc.id,
26
text: doc.text,
27
tenantId,
28
})
29
}
30
31
return c.json({
32
accepted: docs.length,
33
message: '已接收,正在后台处理',
34
}, 202)
35
})
36
37
export default upload

注意 HTTP 状态码 202 Accepted——「我收到了,但还没做完」。这是异步任务接口的标准语义。

3.4 消费者:真正干活

消费者的 handler 接收的是一个 batch,里面有若干条消息:

src/consumers/ingest.ts
01
interface IngestJob {
02
docId: string
03
text: string
04
tenantId: string
05
}
06
07
type Env = {
08
AI: Ai
09
DOCS_INDEX: Vectorize
10
}
11
12
export async function ingestConsumer(
13
batch: MessageBatch<IngestJob>,
14
env: Env
15
) {
16
// 1. 批量生成 embedding(一次调用比一条一条调便宜很多)
17
const texts = batch.messages.map((m) => m.body.text)
18
const { data: embeddings } = await env.AI.run(
19
'@cf/baai/bge-base-en-v1.5',
20
{ text: texts }
21
)
22
23
// 2. 组装写入向量数据库
24
const vectors = batch.messages.map((m, i) => ({
25
id: m.body.docId,
26
values: embeddings[i],
27
namespace: m.body.tenantId,
28
metadata: {
29
text: m.body.text,
30
},
31
}))
32
33
try {
34
await env.DOCS_INDEX.upsert(vectors)
35
// 批量 ack:告诉 Queue 这一批都成功了
36
for (const msg of batch.messages) msg.ack()
37
} catch (err) {
38
// 整批重试。也可以逐条 retry() 做更精细的控制
39
for (const msg of batch.messages) msg.retry({ delaySeconds: 30 })
40
}
41
}

几个关键 API:

  • msg.ack():确认这条消息已处理,从队列里删除
  • msg.retry({ delaySeconds }):扔回队列稍后重试(可以指定延迟)
  • 默认行为:handler 正常返回就算全部成功;抛异常就整批重试

3.5 把消费者挂到 Worker 入口

Queue 消费者通过 queue() handler 挂到 Worker 默认导出:

src/index.ts
01
import { Hono } from 'hono'
02
import { ingestConsumer } from './consumers/ingest'
03
import upload from './routes/upload'
04
05
const app = new Hono()
06
app.route('/upload', upload)
07
08
export default {
09
fetch: app.fetch,
10
async queue(batch: MessageBatch, env: any, ctx: ExecutionContext) {
11
// 通过 queue 名字分发到不同 consumer
12
if (batch.queue === 'rag-ingest') {
13
return ingestConsumer(batch, env)
14
}
15
},
16
}

一个 Worker 可以同时是多个队列的消费者,根据 batch.queue 分发就行。

4. Cron 还是 Queue?

两个工具的定位差别清晰:

用 Cron用 Queue
定时、周期性、和用户请求无关的事由某次用户请求触发、但不想让用户等的事
没有状态(每次从头扫一遍数据)有状态(每条消息是独立任务)
失败就等下次再跑失败要重试、要 DLQ
汇总、清理、同步处理上传、调外部 API、批量计算

一条经验:如果数据量可能变大,Cron 里扫全表的写法迟早超时(Cron 单次最多 30 秒 CPU)。这时候 Cron 只负责「把待处理的任务塞进 Queue」,真正处理交给 Queue 消费者——这是一个常见的组合模式。

4.1 Cron + Queue 的组合实战

用户表有 100 万条,每天要检查他们的订阅是否过期。单次 Cron 扫不完:

src/cron/jobs.ts
01
export async function checkSubscriptions(env: {
02
DB: D1Database
03
CHECK_QUEUE: Queue<{ userId: number }>
04
}) {
05
const db = drizzle(env.DB)
06
07
// Cron 只做「分发」,每 1000 条用户推一批到队列
08
let lastId = 0
09
while (true) {
10
const batch = await db
11
.select({ id: users.id })
12
.from(users)
13
.where(gt(users.id, lastId))
14
.limit(1000)
15
.all()
16
17
if (batch.length === 0) break
18
19
// 批量 send(一次 API 调用塞多条)
20
await env.CHECK_QUEUE.sendBatch(
21
batch.map((u) => ({ body: { userId: u.id } }))
22
)
23
lastId = batch[batch.length - 1].id
24
}
25
}

Queue 消费者再慢慢跑——100 万条可以花几小时处理,用户无感知。

5. 常用姿势与陷阱

5.1 消息去重(幂等)

Queue 的交付语义是 at-least-once(至少投递一次)——同一条消息可能被投递多次(网络抖动、消费者超时都可能导致)。所以消费者逻辑必须是幂等的:同一条消息处理两次,结果和处理一次一样,不会产生副作用。

最常见的做法:消息体里带一个业务唯一 ID,消费前先查「这个 ID 处理过了吗」:

src/consumers/ingest.ts
1
const seen = await env.KV.get(`processed:${msg.body.docId}`)
2
if (seen) {
3
msg.ack()
4
continue
5
}
6
// ... 实际处理 ...
7
await env.KV.put(`processed:${msg.body.docId}`, '1', { expirationTtl: 86400 })
8
msg.ack()

5.2 按条 retry vs 整批 retry

msg.retry() 针对单条,throw 会让整批都重试。规则很简单:

  • 部分失败:只 retry 失败的那几条,ack 成功的
  • 系统性失败(DB 连不上、模型全挂):直接抛异常,整批重试更合理

5.3 Delay:延迟任务

msg.retry({ delaySeconds })queue.send(body, { delaySeconds }) 都支持延迟,单位秒。「30 分钟后再检查订单支付状态」这种场景直接用它:

src/routes/order.ts
1
await c.env.CHECK_PAYMENT_QUEUE.send(
2
{ orderId: '123' },
3
{ delaySeconds: 30 * 60 }
4
)

6. 小结

Cron Triggers 按时间触发 scheduled() handler,适合汇总、清理这类周期性的事。Queues 是消息队列,生产者发、消费者收,支持批量处理、自动重试、DLQ。

两个经常配合用:Cron 负责把大任务切成小任务扔进 Queue,Queue 慢慢消费。用户请求里但凡不需要立即返回结果的重活(embedding、归档、调外部 API),都可以扔给 Queue,先回 202 再说。