楠渡余生楠渡余生
首页
笔记
作品集
留言板
关于
GitHub
CSDN
首页
笔记
作品集
留言板
关于
GitHub
CSDN
  • 前端开发

    • React Server Components(RSC)学习笔记

      • React Server Components(RSC)学习笔记
    • 全栈框架学习笔记

      • 全栈框架学习笔记
    • jQuery 学习笔记

      • jQuery 学习笔记
    • React 学习笔记

      • React 学习笔记
    • AJAX 学习笔记

      • AJAX 学习笔记
    • Axios 完整学习笔记

      • Axios 完整学习笔记
    • CSS 属性速查手册

      • CSS 属性速查手册
    • HTML5 与 CSS 综合学习笔记

      • HTML5 与 CSS 综合学习笔记
    • JavaScript 学习笔记

      • JavaScript 学习笔记
    • Promise 学习笔记

      • Promise 学习笔记
    • Tailwind CSS 完整笔记

      • Tailwind CSS 完整笔记
    • TypeScript 快速上手

      • TypeScript 快速上手
    • Vue3 学习笔记

      • Vue3 学习笔记
  • 元框架与全栈路由

    • Next.js App Router 最佳实践

      • Next.js App Router 最佳实践
    • 跨域与服务端组件数据预取

      • 跨域与服务端组件数据预取
  • 现代数据流与安全

    • Prisma Schema 全栈类型生成

      • Prisma Schema 全栈类型生成
    • Supabase RLS 行级安全策略

      • Supabase RLS 行级安全策略
  • 商业化与支付闭环

    • SaaS 订阅制用户表结构设计

      • SaaS 订阅制用户表结构设计
    • Stripe Webhook 接入避坑指南

      • Stripe Webhook 接入避坑指南
  • 零运维与边缘计算

    • Cloudflare 基础防护与 CDN

      • Cloudflare 基础防护与 CDN
    • Vercel 自动化部署与环境变量

      • Vercel 自动化部署与环境变量
  • AI 赋能与集成

    • Vercel AI SDK 流式输出实战

      • Vercel AI SDK 流式输出实战
  • 增长、监控与运营

    • Resend 事务性邮件模板

      • Resend 事务性邮件模板
    • Sentry 前端异常捕获与报警

      • Sentry 前端异常捕获与报警
  • Node.js 深入学习

    • MongoDB 常用命令速查表

      • MongoDB 常用命令速查表
    • Node.js + MongoDB 生产级最佳实践指南

      • Node.js + MongoDB 生产级最佳实践指南
    • Node.js Express 框架

      • Node.js Express 框架
    • Node.js HTTP 模块

      • Node.js HTTP 模块
    • Node.js NPM 包管理

      • Node.js NPM 包管理
    • Node.js 文件系统模块

      • Node.js 文件系统模块
    • Node.js 模块化设计

      • Node.js 模块化设计
  • 后端开发

    • Express 基本使用

      • Express 基本使用
    • Node.js 学习笔记

      • Node.js 学习笔记
    • SpringBoot 完整学习笔记

      • SpringBoot 完整学习笔记
  • 开发工具

    • Windows + WSL + Docker 踩坑与通关指南

      • Windows + WSL + Docker 踩坑与通关指南
    • GitHub 新手完全指南

      • GitHub 新手完全指南
    • 个人博客搭建指南

      • 个人博客搭建指南

Vercel AI SDK 流式输出实战

1. 为什么需要流式输出

大模型生成内容耗时较长。如果等全部生成完再返回:

用户点击 -> 等待 10 秒空白 -> 突然出现全部内容

体验很差,而且长连接容易超时。

流式输出(Streaming)让内容像打字机一样逐步返回:

用户点击 -> 立刻开始逐字显示 -> 持续输出直到结束

好处:

  • 首字时间短,体验好。
  • 长响应不容易超时断连。
  • 用户可随时中断。

2. 安装依赖

npm install ai @ai-sdk/openai zod
  • ai:Vercel AI SDK 核心。
  • @ai-sdk/openai:OpenAI provider。
  • zod:参数校验。

3. 服务端流式接口

// app/api/chat/route.ts
import { NextRequest } from 'next/server'
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { z } from 'zod'
import { buildSystemPrompt } from '@/lib/prompts'
import { rateLimit } from '@/lib/rate-limit'

export const runtime = 'nodejs'
export const maxDuration = 60

const bodySchema = z.object({
  messages: z
    .array(
      z.object({
        role: z.enum(['user', 'assistant']),
        content: z.string().min(1).max(8000),
      }),
    )
    .min(1)
    .max(50),
})

function getClientIp(request: NextRequest): string {
  return (
    request.headers.get('cf-connecting-ip') ||
    request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
    'unknown'
  )
}

export async function POST(request: NextRequest) {
  const ip = getClientIp(request)

  if (!rateLimit(`chat:${ip}`, 20, 60_000)) {
    return new Response(JSON.stringify({ error: '请求过于频繁,请稍后再试' }), {
      status: 429,
      headers: { 'Content-Type': 'application/json' },
    })
  }

  let parsed
  try {
    const json = await request.json()
    parsed = bodySchema.parse(json)
  } catch (error) {
    console.error('[chat parse]', error)
    return new Response(JSON.stringify({ error: '参数格式错误' }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' },
    })
  }

  try {
    const result = streamText({
      model: openai('gpt-4o-mini'),
      system: buildSystemPrompt('chat'),
      messages: parsed.messages,
      temperature: 0.7,
      maxTokens: 1024,
      abortSignal: request.signal,
      onError: error => {
        console.error('[streamText error]', error)
      },
    })

    return result.toDataStreamResponse()
  } catch (error) {
    console.error('[POST /api/chat]', error)
    return new Response(JSON.stringify({ error: '生成失败,请稍后重试' }), {
      status: 500,
      headers: { 'Content-Type': 'application/json' },
    })
  }
}

4. 前端打字机效果

// app/chat/ChatBox.tsx
'use client'

import { useChat } from 'ai/react'

export function ChatBox() {
  const {
    messages,
    input,
    handleInputChange,
    handleSubmit,
    status,
    stop,
    error,
    reload,
  } = useChat({
    api: '/api/chat',
    onError: error => {
      console.error('[useChat]', error)
    },
  })

  const isLoading = status === 'submitted' || status === 'streaming'

  return (
    <div>
      <div className="messages">
        {messages.map(message => (
          <div key={message.id} data-role={message.role}>
            <strong>{message.role === 'user' ? '我' : 'AI'}:</strong>
            <span>{message.content}</span>
          </div>
        ))}
      </div>

      {error && (
        <div role="alert">
          <p>出错了,请重试</p>
          <button onClick={() => reload()}>重新生成</button>
        </div>
      )}

      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="输入消息..."
          disabled={isLoading}
        />
        {isLoading ? (
          <button type="button" onClick={stop}>停止</button>
        ) : (
          <button type="submit" disabled={!input.trim()}>发送</button>
        )}
      </form>
    </div>
  )
}

5. Prompt 工程化管理

不要把 System Prompt 硬编码散落在各处。集中管理并版本化。

// lib/prompts/index.ts
export type PromptKey = 'chat' | 'codeReview' | 'summary'

type PromptDefinition = {
  version: string
  template: string
}

const prompts: Record<PromptKey, PromptDefinition> = {
  chat: {
    version: '2026-06-13',
    template: `你是一个严谨的助手。
规则:
1. 不要编造事实。
2. 不确定时明确说明不确定。
3. 回答使用简体中文。`,
  },
  codeReview: {
    version: '2026-06-13',
    template: `你是一名资深工程师,负责代码审查。
重点关注:安全、边界条件、错误处理。
输出使用简体中文。`,
  },
  summary: {
    version: '2026-06-13',
    template: `请将用户提供的内容总结为要点列表,保持客观。`,
  },
}

export function buildSystemPrompt(key: PromptKey, context?: Record<string, string>): string {
  const definition = prompts[key]
  if (!definition) {
    throw new Error(`Unknown prompt key: ${key}`)
  }

  let result = definition.template

  if (context) {
    for (const [name, value] of Object.entries(context)) {
      result = result.replaceAll(`{{${name}}}`, value)
    }
  }

  return result
}

export function getPromptVersion(key: PromptKey): string {
  return prompts[key]?.version ?? 'unknown'
}

好处:

  • Prompt 集中,便于审查和回滚。
  • 带版本号,便于追踪线上效果。
  • 模板支持变量注入,避免拼接混乱。

6. 超时与重试(非流式调用)

有些场景需要一次性结果(如批处理)。这时要做超时和重试。

// lib/ai-call.ts
import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'

type CallOptions = {
  system: string
  prompt: string
  retries?: number
  timeoutMs?: number
}

export async function callModelWithRetry(options: CallOptions): Promise<string> {
  const { system, prompt, retries = 2, timeoutMs = 30_000 } = options

  let lastError: unknown = null

  for (let attempt = 0; attempt <= retries; attempt += 1) {
    const controller = new AbortController()
    const timer = setTimeout(() => controller.abort(), timeoutMs)

    try {
      const { text } = await generateText({
        model: openai('gpt-4o-mini'),
        system,
        prompt,
        maxTokens: 1024,
        abortSignal: controller.signal,
      })

      clearTimeout(timer)
      return text
    } catch (error) {
      clearTimeout(timer)
      lastError = error
      console.error(`[callModelWithRetry] attempt ${attempt} failed`, error)

      // 指数退避,避免立即重试打爆上游
      if (attempt < retries) {
        await new Promise(resolve => setTimeout(resolve, 500 * 2 ** attempt))
      }
    }
  }

  throw new Error(`AI call failed after ${retries + 1} attempts: ${String(lastError)}`)
}

7. 成本控制与额度防护

防止恶意用户刷爆 Token 额度。

关键手段:

  1. 登录校验,匿名用户限制更严。
  2. 按用户限流,不只按 IP。
  3. 限制单次消息长度和历史条数。
  4. 记录每个用户的 Token 用量。
  5. 超出额度直接拒绝。
// lib/usage.ts
import { prisma } from '@/lib/prisma'

const DAILY_TOKEN_LIMIT = 100_000

export async function canConsume(userId: string): Promise<boolean> {
  if (!userId) return false

  try {
    const today = new Date()
    today.setHours(0, 0, 0, 0)

    const usage = await prisma.aiUsage.findFirst({
      where: { userId, date: today },
    })

    return (usage?.tokens ?? 0) < DAILY_TOKEN_LIMIT
  } catch (error) {
    console.error('[canConsume]', error)
    // 出错时保守拒绝,避免被刷
    return false
  }
}

export async function recordUsage(userId: string, tokens: number): Promise<void> {
  if (!userId || tokens <= 0) return

  try {
    const today = new Date()
    today.setHours(0, 0, 0, 0)

    await prisma.aiUsage.upsert({
      where: { userId_date: { userId, date: today } },
      create: { userId, date: today, tokens },
      update: { tokens: { increment: tokens } },
    })
  } catch (error) {
    console.error('[recordUsage]', error)
  }
}

在接口中接入额度判断:

// 在 streamText 之前
const userId = request.cookies.get('user_id')?.value
if (!userId) {
  return new Response(JSON.stringify({ error: '请先登录' }), { status: 401 })
}

if (!(await canConsume(userId))) {
  return new Response(JSON.stringify({ error: '今日额度已用完' }), { status: 429 })
}

流式结束后记录用量:

const result = streamText({
  model: openai('gpt-4o-mini'),
  system: buildSystemPrompt('chat'),
  messages: parsed.messages,
  onFinish: async ({ usage }) => {
    await recordUsage(userId, usage.totalTokens ?? 0)
  },
})

8. 真实业务坑点

8.1 把 OpenAI Key 放前端

任何 NEXT_PUBLIC_ 都会泄露。AI 调用必须在服务端。

8.2 不限制历史长度

用户可以塞超长 messages,导致 Token 飙升、成本爆炸。务必限制条数和长度。

8.3 流式响应被代理缓冲

某些反向代理会缓冲响应,破坏流式效果。需要确认 CDN/代理不缓冲 SSE。

8.4 不处理用户中断

用户关闭页面但请求还在跑,浪费 Token。要用 request.signal 透传中断。

8.5 重试无退避

失败立即重试会放大上游压力。要用指数退避。

8.6 Prompt 硬编码

散落各处难维护、难回滚。集中管理并加版本号。

9. 生产建议

  1. AI 调用只在服务端。
  2. 入参用 zod 强校验,限制长度和条数。
  3. 流式接口透传 abortSignal。
  4. 按用户记录用量并限额。
  5. 一次性调用要超时 + 指数退避重试。
  6. Prompt 集中管理、版本化。
  7. 监控成本,异常用量告警。
最后更新: 2026/6/13 21:40
贡献者: 52nnnn, Claude Opus 4.7