1. 中间件:模型调用的「插拔层」
Hono 有中间件、Express 有中间件,AI SDK 也有中间件——只不过它挂在模型层,而不是 HTTP 层。
AI SDK 的模型中间件通过 wrapLanguageModel 挂载。它能在每次 LLM 调用前后插入逻辑:
- 请求改写:加日志、改参数、修改 prompt、注入 system
- 响应改写:过滤敏感词、剪裁长度、拼前缀
- 缓存:相同 prompt 直接返回上次的答
- 限流:按用户 / 按租户控制并发
- 重试:某个模型挂了自动重试
- Fallback:检测到 error 自动换 Provider
这些能力是从 Demo 走向生产的关键。这一篇把最常用的几种中间件写清楚,以及怎么把它们组合起来。
2. wrapLanguageModel 基础
wrapLanguageModel 接收一个 model 和一个 middleware,返回新的 model。新 model 仍然是一个标准的 LanguageModelV2,可以到处塞。
01import { wrapLanguageModel, type LanguageModelV2Middleware } from 'ai'02import { openai } from '@ai-sdk/openai'0304const loggingMiddleware: LanguageModelV2Middleware = {05wrapGenerate: async ({ doGenerate, params }) => {06console.log('[LLM] calling', params.prompt.length, 'chars')07const result = await doGenerate()08console.log('[LLM] finished, tokens:', result.usage)09return result10},1112wrapStream: async ({ doStream, params }) => {13console.log('[LLM] streaming start')14return doStream()15},16}1718const model = wrapLanguageModel({19model: openai('gpt-4o'),20middleware: loggingMiddleware,21})2223// 使用:和普通 model 完全一样24streamText({ model, prompt: '...' })
Middleware 有三个 hook:
| hook | 作用 | 何时调 |
|---|---|---|
wrapGenerate | 包裹 generateText / generateObject | 非流式调用 |
wrapStream | 包裹 streamText / streamObject | 流式调用 |
transformParams | 只改参数不包裹执行 | 所有调用前,两个 hook 会先 transformParams 再进入自己 |
大多数中间件只要实现 wrapGenerate 和 wrapStream 就够了。
3. 缓存中间件:相同问答复用
LLM 调用是贵的。如果同一个 prompt + 参数反复出现(比如用户点「重新生成」),理论上可以命中缓存。
用 wrapLanguageModel + Cloudflare KV 实现缓存:
01import { type LanguageModelV2Middleware } from 'ai'02import type { KVNamespace } from '@cloudflare/workers-types'0304export function cacheMiddleware(kv: KVNamespace, ttl = 3600): LanguageModelV2Middleware {05return {06wrapGenerate: async ({ doGenerate, params }) => {07const key = await hashKey(params)08const cached = await kv.get(key, 'json')09if (cached) {10console.log('[cache hit]', key)11return cached as any12}1314const result = await doGenerate()15await kv.put(key, JSON.stringify(result), { expirationTtl: ttl })16return result17},1819// 流式场景一般不缓存(太复杂),或者缓存文本后再重建流20}21}2223async function hashKey(params: unknown): Promise<string> {24const json = JSON.stringify(params)25const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(json))26const hex = [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, '0')).join('')27return `llm:${hex.slice(0, 16)}`28}
用起来:
1import { wrapLanguageModel } from 'ai'23const cachedModel = wrapLanguageModel({4model: openai('gpt-4o-mini'),5middleware: cacheMiddleware(c.env.KV, 86400),6})
适合用的场景:
- 分类 / 摘要类 pipeline(同样输入期望同样输出)
- 内部工具(翻译、提取)
- 产品里的示例或 Demo 查询
不适合的场景:
- 对话(prompt 每次都不同)
- 对「生成多样性」有要求的场景(用户点「再来一个」期望换一种)
4. 限流中间件:按用户 / 按租户控制并发
保护自己也保护上游(Provider)的 API 配额。基于 Cloudflare KV 的滑动窗口:
01import { type LanguageModelV2Middleware } from 'ai'02import type { KVNamespace } from '@cloudflare/workers-types'0304interface RateLimitConfig {05kv: KVNamespace06keyFn: () => string // 怎么识别调用方(用户 ID、租户 ID、IP)07limit: number // 时间窗口内的最大调用次数08windowSec: number // 窗口秒数09}1011export function rateLimitMiddleware(cfg: RateLimitConfig): LanguageModelV2Middleware {12const check = async () => {13const k = `rate:${cfg.keyFn()}`14const now = Math.floor(Date.now() / 1000)15const windowStart = now - cfg.windowSec1617// 读上一个窗口内的请求时间戳列表18const raw = await cfg.kv.get(k)19const list: number[] = raw ? JSON.parse(raw) : []20const fresh = list.filter((t) => t > windowStart)2122if (fresh.length >= cfg.limit) {23throw new Error(`rate limit exceeded: ${fresh.length}/${cfg.limit} per ${cfg.windowSec}s`)24}2526fresh.push(now)27await cfg.kv.put(k, JSON.stringify(fresh), { expirationTtl: cfg.windowSec * 2 })28}2930return {31wrapGenerate: async ({ doGenerate }) => {32await check()33return doGenerate()34},35wrapStream: async ({ doStream }) => {36await check()37return doStream()38},39}40}
使用:
1const model = wrapLanguageModel({2model: openai('gpt-4o'),3middleware: rateLimitMiddleware({4kv: c.env.KV,5keyFn: () => sessionId,6limit: 30, // 每分钟 30 次7windowSec: 60,8}),9})
生产上几条经验:
- 限流策略和用户等级挂钩(免费用户 30/min、会员 300/min)
- 超限后给出友好提示(返回 429 + 说明)
- 全局限流 + 用户限流两层保护,前者防止单用户打爆
5. Fallback 与 Retry 中间件
5.1 Fallback:一家挂了换下一家
场景是 OpenAI 突然 500 或 429,你希望自动切到 Anthropic,不让用户感知。
AI SDK 官方推荐的做法就是组合多个 Provider:
01import { type LanguageModelV2Middleware, APICallError } from 'ai'02import { openai } from '@ai-sdk/openai'03import { anthropic } from '@ai-sdk/anthropic'0405export function fallbackMiddleware(fallback: LanguageModelV2): LanguageModelV2Middleware {06return {07wrapGenerate: async ({ doGenerate, params, model }) => {08try {09return await doGenerate()10} catch (err) {11if (shouldFallback(err)) {12console.warn('[fallback] primary failed, retry on fallback', err)13// 用 fallback model 跑一次14return await fallback.doGenerate(params)15}16throw err17}18},1920wrapStream: async ({ doStream, params, model }) => {21try {22return await doStream()23} catch (err) {24if (shouldFallback(err)) {25return await fallback.doStream(params)26}27throw err28}29},30}31}3233function shouldFallback(err: unknown): boolean {34if (APICallError.isInstance(err)) {35// 5xx 或 429 或网络错误才 fallback36return err.isRetryable || (err.statusCode ?? 0) >= 50037}38return false39}
使用:
1const model = wrapLanguageModel({2model: openai('gpt-4o'),3middleware: fallbackMiddleware(anthropic('claude-opus-4-6')),4})
几条经验:
- Fallback 不应该级联超过三层,层数多了太慢
- Fallback 的模型能力要接近主 model,避免回复质量差太多
- 要打埋点:每次 fallback 发生要记录,用来监控主 Provider 稳定度
- 流式下的 Fallback 有坑:如果主 model 已经 emit 了前半段再挂,切 fallback 会重新开始,前半段就白流了。要么接受「重来」,要么检测到进入 stream 就不 fallback
5.2 Retry:短暂失败自动重试
和 Fallback 的区别:retry 是同一个模型重试,fallback 是换模型。
01export function retryMiddleware(maxRetries = 2, baseDelay = 500): LanguageModelV2Middleware {02const doWithRetry = async <T>(fn: () => Promise<T>, retries = maxRetries): Promise<T> => {03try {04return await fn()05} catch (err) {06if (retries > 0 && isRetryable(err)) {07const delay = baseDelay * Math.pow(2, maxRetries - retries)08await sleep(delay + Math.random() * 200) // jitter09return doWithRetry(fn, retries - 1)10}11throw err12}13}1415return {16wrapGenerate: ({ doGenerate }) => doWithRetry(() => doGenerate()),17// 流式重试也类似,但要注意部分 emit 的问题18}19}
一般只对非流式调用做 retry,流式调用让上层业务自己决定。
6. 组合多个中间件
middleware 参数支持数组,按顺序组合:
01const model = wrapLanguageModel({02model: openai('gpt-4o'),03middleware: [04loggingMiddleware,05cacheMiddleware(kv),06rateLimitMiddleware({ kv, keyFn: () => userId, limit: 30, windowSec: 60 }),07retryMiddleware(2),08fallbackMiddleware(anthropic('claude-opus-4-6')),09],10})
执行顺序是:数组越靠前,越靠外层。上面这个栈的执行顺序是:
1request → logging → cache (hit? return) → rate limit → retry → fallback → primary model
不同场景的组合经验:
- 后端内部脚本:cache + retry + logging
- 用户面向的生产:rate limit + retry + fallback + logging(不 cache)
- 开发阶段:logging + 让错误透传
defaultSettingsMiddleware
有一种特殊的中间件,只改默认参数,不改行为:
01import { defaultSettingsMiddleware } from 'ai'0203const model = wrapLanguageModel({04model: openai('gpt-4o'),05middleware: defaultSettingsMiddleware({06settings: {07temperature: 0.3,08maxOutputTokens: 1000,09frequencyPenalty: 0.2,10},11}),12})
业务层调用时不传这些参数,model 自带;但业务层显式传了也会覆盖。适合「全站默认低 temperature」「某个路由默认短输出」这类统一默认值的场景。
7. 真实世界的中间件栈
AI 伴侣项目的模型栈大概长这样:
01import { wrapLanguageModel } from 'ai'02import { createWorkersAI } from 'workers-ai-provider'03import { openai } from '@ai-sdk/openai'0405export function buildCompanionModel(env: Env, sessionId: string, userId: string) {06const workersai = createWorkersAI({ binding: env.AI })07const primaryRaw = workersai('@cf/meta/llama-3.3-70b-instruct-fp8-fast')08const fallbackRaw = openai('gpt-4o-mini')0910return wrapLanguageModel({11model: primaryRaw,12middleware: [13telemetryMiddleware({ sessionId, userId }), // 先埋点,拿到总量14rateLimitMiddleware({15kv: env.KV,16keyFn: () => userId,17limit: 120,18windowSec: 60,19}),20retryMiddleware(2),21fallbackMiddleware(fallbackRaw),22defaultSettingsMiddleware({23settings: { temperature: 0.7 },24}),25],26})27}
调用方只需要:
1const model = buildCompanionModel(c.env, sessionId, userId)2const result = streamText({ model, messages, tools })3return result.toUIMessageStreamResponse()
中间件栈在业务代码里是不可见的,但每次调用都有限流、重试、Fallback、埋点的保护。
8. 常见坑
Middleware 顺序错导致 cache 不生效
数组靠前的是外层。cache 应该尽量靠外(尽早判断缓存命中),rate limit 也应该放在 cache 之后(命中缓存不占额度)。
流式 Fallback 的半截流
上面提过,流失败了切 fallback 会从头重来,用户看到的是「文字闪一下然后重新写」。解决办法:只对前几个 chunk 做 fallback(超过就算了),或者业务层做「生成失败继续,之前的文字保留」。
Cache key 没考虑 tools
有 tools 的调用,相同 prompt 但 tools 不同,结果差很多。cache key 要把 params.tools 的 schema 指纹也考虑进去。
Middleware 吞错误
中间件里用 try/catch 时要小心。如果你吞了错误返回一个假结果,上层完全不知情。除非是 Fallback 这种明确的降级,否则一定要让错误抛出来。
9. 小结
wrapLanguageModel给 model 挂上中间件,新 model 仍然是标准LanguageModelV2- 核心三个 hook:
wrapGenerate/wrapStream/transformParams - 常用中间件:Logging / Cache / RateLimit / Retry / Fallback / DefaultSettings
- 数组组合时越靠前越外层,顺序设计很关键
- 生产栈常见组合:telemetry → rate limit → retry → fallback → defaults
- 流式下的 Fallback 要想清楚「半截流重来」的接受度
- 中间件不要吞错误,除非是明确的降级
下一篇进入后端三连的最后一篇——可观测性。Telemetry / OpenTelemetry / Langfuse 怎么接,怎么在生产追踪一次 chat 的完整链路。