Vue3 + Nuxt 4全栈AI应用:从SSR到边缘推理的7种生产模式

前端工程

Vue3 + Nuxt 4全栈AI应用:从SSR到边缘推理的7种生产模式

Vue开发者做AI应用,还在前后端分离、API跨域、部署两套系统?Nuxt 4的Server Routes + SSR + Edge Runtime让Vue3全栈AI应用成为现实——一套代码、一次部署、SSR直出AI内容。本文将深入7种生产级模式,从Server Routes代理到边缘推理,每一行代码都可直接用于生产。


核心收获

  • 掌握Nuxt 4 Server Routes构建AI API代理的完整方案
  • 实现SSR + AI流式渲染的首屏优化策略
  • 构建生产级流式聊天UI组件
  • 部署边缘推理到Cloudflare Workers
  • 设计RAG前端交互体验
  • Pinia持久化对话状态与跨页面恢复
  • 生产环境性能优化与部署最佳实践

目录

  1. Nuxt 4全栈AI架构全景
  2. Pattern 1: Server Routes + AI API代理
  3. Pattern 2: SSR + AI流式渲染
  4. Pattern 3: 流式聊天UI组件
  5. Pattern 4: 边缘推理与Cloudflare Workers
  6. Pattern 5: RAG前端交互设计
  7. Pattern 6: 对话状态与Pinia持久化
  8. Pattern 7: 生产部署与性能优化
  9. 5个常见坑及解决方案
  10. 10个常见报错排查
  11. 进阶优化技巧
  12. 对比分析:Nuxt 4 vs Next.js 15 vs SvelteKit
  13. 在线工具推荐
  14. 总结

Nuxt 4全栈AI架构全景

Nuxt 4在2026年带来了全栈AI应用的关键能力:Server Routes作为后端API层、SSR流式渲染、Edge Runtime支持、以及原生的TypeScript全链路类型安全。

┌──────────────────────────────────────────────────────────┐
│                   Nuxt 4 Full-Stack AI Architecture      │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  ┌─────────────┐    ┌──────────────┐    ┌────────────┐  │
│  │   Browser   │───▶│  Nuxt SSR    │───▶│  Edge /    │  │
│  │   Client    │◀───│  Server      │◀───│  Node      │  │
│  └──────┬──────┘    └──────┬───────┘    └─────┬──────┘  │
│         │                  │                   │         │
│  ┌──────▼──────┐    ┌──────▼───────┐    ┌─────▼──────┐  │
│  │  Vue3       │    │ Server       │    │ AI Models  │  │
│  │  Composables│    │ Routes       │    │ OpenAI     │  │
│  │  Pinia      │    │ /api/chat    │    │ Anthropic  │  │
│  │  Components │    │ /api/embed   │    │ Local LLM  │  │
│  └─────────────┘    │ /api/rag     │    └────────────┘  │
│                     └──────────────┘                     │
│                                                          │
│  ┌─────────────────────────────────────────────────────┐ │
│  │  Shared Layer: Types / Utils / Constants            │ │
│  └─────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘

Nuxt 4全栈AI核心能力

能力 说明 适用场景
Server Routes 文件式API路由,无需Express AI代理、Webhook、BFF
SSR流式渲染 服务端流式输出HTML SEO + AI首屏
Edge Runtime Cloudflare/Deno边缘部署 低延迟推理
Nitro Engine 跨平台服务引擎 多环境统一部署
Shared Types 前后端类型共享 全链路类型安全
useAsyncData SSR数据获取 AI数据预加载

Pattern 1: Server Routes + AI API代理

Nuxt 4的Server Routes让你无需搭建独立后端,直接在Nuxt项目中编写API。这是全栈AI应用的基础——所有AI请求通过Server Routes代理,避免暴露API Key、统一错误处理、实现速率限制。

基础Server Route代理

// server/api/chat.post.ts
import { defineEventHandler, readBody, createError } from 'h3'
import { z } from 'zod'

const chatRequestSchema = z.object({
  messages: z.array(z.object({
    role: z.enum(['user', 'assistant', 'system']),
    content: z.string().max(4000),
  })).min(1).max(50),
  model: z.enum(['gpt-4o', 'gpt-4o-mini', 'claude-sonnet-4-20250514']).default('gpt-4o-mini'),
  temperature: z.number().min(0).max(2).default(0.7),
  maxTokens: z.number().min(1).max(4096).default(2048),
})

const RATE_LIMIT_WINDOW = 60_000
const RATE_LIMIT_MAX = 20
const requestCounts = new Map<string, { count: number; resetAt: number }>()

function checkRateLimit(ip: string): boolean {
  const now = Date.now()
  const record = requestCounts.get(ip)
  if (!record || now > record.resetAt) {
    requestCounts.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW })
    return true
  }
  if (record.count >= RATE_LIMIT_MAX) {
    return false
  }
  record.count++
  return true
}

export default defineEventHandler(async (event) => {
  const clientIp = getRequestHeader(event, 'x-forwarded-for') || 'unknown'
  if (!checkRateLimit(clientIp)) {
    throw createError({
      statusCode: 429,
      statusMessage: 'Rate limit exceeded. Please try again later.',
    })
  }

  const body = await readBody(event)
  const parsed = chatRequestSchema.safeParse(body)
  if (!parsed.success) {
    throw createError({
      statusCode: 400,
      statusMessage: `Validation error: ${parsed.error.message}`,
    })
  }

  const { messages, model, temperature, maxTokens } = parsed.data

  const apiKey = process.env.OPENAI_API_KEY
  if (!apiKey) {
    throw createError({
      statusCode: 500,
      statusMessage: 'AI service not configured',
    })
  }

  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      model,
      messages,
      temperature,
      max_tokens: maxTokens,
    }),
  })

  if (!response.ok) {
    const errorData = await response.json().catch(() => ({}))
    throw createError({
      statusCode: response.status,
      statusMessage: errorData.error?.message || 'AI service error',
    })
  }

  return response.json()
})

流式Server Route

// server/api/chat/stream.post.ts
import { defineEventHandler, readBody, createError, setResponseHeader, sendStream } from 'h3'

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const { messages, model = 'gpt-4o-mini' } = body

  setResponseHeader(event, 'Content-Type', 'text/event-stream')
  setResponseHeader(event, 'Cache-Control', 'no-cache')
  setResponseHeader(event, 'Connection', 'keep-alive')
  setResponseHeader(event, 'X-Accel-Buffering', 'no')

  const apiKey = process.env.OPENAI_API_KEY
  if (!apiKey) {
    throw createError({ statusCode: 500, statusMessage: 'AI service not configured' })
  }

  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      model,
      messages,
      stream: true,
    }),
  })

  if (!response.ok) {
    throw createError({
      statusCode: response.status,
      statusMessage: 'AI streaming error',
    })
  }

  const transformStream = new TransformStream({
    transform(chunk, controller) {
      const text = new TextDecoder().decode(chunk)
      const lines = text.split('\n').filter((line) => line.startsWith('data: '))

      for (const line of lines) {
        const data = line.slice(6)
        if (data === '[DONE]') {
          controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
          continue
        }
        try {
          const parsed = JSON.parse(data)
          const content = parsed.choices?.[0]?.delta?.content
          if (content) {
            controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ content })}\n\n`))
          }
        } catch {
          // skip malformed chunks
        }
      }
    },
  })

  const readableStream = response.body!.pipeThrough(transformStream)
  return sendStream(event, readableStream)
})

共享类型定义

// shared/types/ai.ts
export interface ChatMessage {
  id: string
  role: 'user' | 'assistant' | 'system'
  content: string
  timestamp: number
  metadata?: MessageMetadata
}

export interface MessageMetadata {
  model: string
  tokens: number
  latency: number
  finishReason: string
}

export interface ChatRequest {
  messages: Pick<ChatMessage, 'role' | 'content'>[]
  model: AIModel
  temperature?: number
  maxTokens?: number
  stream?: boolean
}

export type AIModel = 'gpt-4o' | 'gpt-4o-mini' | 'claude-sonnet-4-20250514'

export interface StreamChunk {
  content: string
  done: boolean
}

export interface RAGQuery {
  question: string
  topK?: number
  threshold?: number
}

export interface RAGResult {
  answer: string
  sources: RAGSource[]
  confidence: number
}

export interface RAGSource {
  content: string
  metadata: Record<string, unknown>
  score: number
}

Pattern 2: SSR + AI流式渲染

SSR与AI结合的核心价值:搜索引擎能索引AI生成的内容,用户首屏即可看到AI回答。Nuxt 4的useAsyncData + Server Routes让SSR AI渲染变得简单。

SSR数据预加载

// composables/useAIContent.ts
import { useAsyncData, useHead } from '#imports'

interface AIContentOptions {
  prompt: string
  model?: AIModel
  ttl?: number
}

export function useAIContent(options: AIContentOptions) {
  const { prompt, model = 'gpt-4o-mini', ttl = 3600 } = options

  const { data, pending, error, refresh } = useAsyncData(
    `ai-content-${prompt.slice(0, 32)}`,
    () => $fetch<string>('/api/ai/generate', {
      method: 'POST',
      body: { prompt, model },
    }),
    {
      server: true,
      lazy: false,
      getCachedData(key, nuxtApp) {
        const cached = nuxtApp.payload.data[key]
        if (cached) {
          const expirationDate = new Date(cached.expiresAt)
          if (expirationDate.getTime() > Date.now()) {
            return cached.data
          }
        }
        return null
      },
    }
  )

  useHead({
    meta: [
      { name: 'description', content: () => data.value?.slice(0, 160) || '' },
    ],
  })

  return { content: data, pending, error, refresh }
}

SSR AI页面组件

<!-- pages/ai-insights/[topic].vue -->
<script setup lang="ts">
const route = useRoute()
const topic = route.params.topic as string

const { content, pending, error } = useAIContent({
  prompt: `Generate a comprehensive technical insight about ${topic} for developers in 2026`,
  model: 'gpt-4o-mini',
})

useHead({
  title: () => `AI Insights: ${topic} | ToolsKu`,
})
</script>

<template>
  <div class="mx-auto max-w-4xl px-4 py-8">
    <header class="mb-8">
      <h2 class="text-3xl font-bold text-gray-900">
        AI Insights: {{ topic }}
      </h2>
      <p class="mt-2 text-gray-500">
        AI-generated analysis, verified and curated for developers
      </p>
    </header>

    <div v-if="pending" class="space-y-4">
      <div class="h-8 w-3/4 animate-pulse rounded bg-gray-200" />
      <div class="h-8 w-1/2 animate-pulse rounded bg-gray-200" />
      <div class="h-8 w-2/3 animate-pulse rounded bg-gray-200" />
    </div>

    <div v-else-if="error" class="rounded-lg bg-red-50 p-4">
      <p class="text-red-700">Failed to generate AI content. Please try again.</p>
    </div>

    <article v-else class="prose prose-lg max-w-none">
      <div v-html="content" />
    </article>
  </div>
</template>

SSR缓存中间件

// server/middleware/ai-cache.ts
import { defineEventHandler, setResponseHeader } from 'h3'
import { useStorage } from '#imports'

const aiCache = useStorage('ai-cache')

export default defineEventHandler(async (event) => {
  if (!event.path.startsWith('/api/ai/')) return

  const cacheKey = `ssr-ai:${event.path}:${JSON.stringify(await readBody(event).catch(() => ({})))}`
  const cached = await aiCache.getItem<{ data: string; expiresAt: number }>(cacheKey)

  if (cached && cached.expiresAt > Date.now()) {
    setResponseHeader(event, 'X-AI-Cache', 'HIT')
    return cached.data
  }

  setResponseHeader(event, 'X-AI-Cache', 'MISS')
})

Pattern 3: 流式聊天UI组件

流式聊天是AI应用的核心交互。这个模式实现一个完整的、生产级的流式聊天组件,支持Markdown渲染、代码高亮、中断生成、消息重试。

流式聊天Composable

// composables/useStreamingChat.ts
import { ref, computed } from 'vue'
import type { ChatMessage, StreamChunk, AIModel } from '~/shared/types/ai'

interface UseStreamingChatOptions {
  apiEndpoint?: string
  defaultModel?: AIModel
  maxRetries?: number
}

export function useStreamingChat(options: UseStreamingChatOptions = {}) {
  const {
    apiEndpoint = '/api/chat/stream',
    defaultModel = 'gpt-4o-mini',
    maxRetries = 2,
  } = options

  const messages = ref<ChatMessage[]>([])
  const currentStreamContent = ref('')
  const isStreaming = ref(false)
  const error = ref<string | null>(null)
  const selectedModel = ref<AIModel>(defaultModel)
  let abortController: AbortController | null = null

  const displayedMessages = computed(() => {
    const base = [...messages.value]
    if (isStreaming.value && currentStreamContent.value) {
      base.push({
        id: 'streaming',
        role: 'assistant',
        content: currentStreamContent.value,
        timestamp: Date.now(),
      })
    }
    return base
  })

  async function sendMessage(content: string) {
    const userMessage: ChatMessage = {
      id: crypto.randomUUID(),
      role: 'user',
      content,
      timestamp: Date.now(),
    }
    messages.value.push(userMessage)
    error.value = null
    currentStreamContent.value = ''
    isStreaming.value = true
    abortController = new AbortController()

    let retryCount = 0

    const attemptStream = async (): Promise<void> => {
      try {
        const response = await fetch(apiEndpoint, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            messages: messages.value.map((m) => ({
              role: m.role,
              content: m.content,
            })),
            model: selectedModel.value,
          }),
          signal: abortController!.signal,
        })

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`)
        }

        const reader = response.body!.getReader()
        const decoder = new TextDecoder()
        let buffer = ''

        while (true) {
          const { done, value } = await reader.read()
          if (done) break

          buffer += decoder.decode(value, { stream: true })
          const lines = buffer.split('\n')
          buffer = lines.pop() || ''

          for (const line of lines) {
            if (!line.startsWith('data: ')) continue
            const data = line.slice(6)
            if (data === '[DONE]') {
              finalizeStream()
              return
            }
            try {
              const chunk: StreamChunk = JSON.parse(data)
              currentStreamContent.value += chunk.content
            } catch {
              // skip malformed chunks
            }
          }
        }

        finalizeStream()
      } catch (err: any) {
        if (err.name === 'AbortError') return
        if (retryCount < maxRetries) {
          retryCount++
          return attemptStream()
        }
        error.value = err.message
        isStreaming.value = false
      }
    }

    await attemptStream()
  }

  function finalizeStream() {
    if (currentStreamContent.value) {
      messages.value.push({
        id: crypto.randomUUID(),
        role: 'assistant',
        content: currentStreamContent.value,
        timestamp: Date.now(),
      })
    }
    currentStreamContent.value = ''
    isStreaming.value = false
  }

  function stopStreaming() {
    abortController?.abort()
    finalizeStream()
  }

  function retryLastMessage() {
    const lastUserIndex = messages.value.findLastIndex((m) => m.role === 'user')
    if (lastUserIndex === -1) return
    const lastUserContent = messages.value[lastUserIndex].content
    messages.value = messages.value.slice(0, lastUserIndex)
    sendMessage(lastUserContent)
  }

  function clearMessages() {
    messages.value = []
    currentStreamContent.value = ''
    error.value = null
  }

  return {
    messages: displayedMessages,
    isStreaming,
    error,
    selectedModel,
    sendMessage,
    stopStreaming,
    retryLastMessage,
    clearMessages,
  }
}

聊天UI组件

<!-- components/AIChatWindow.vue -->
<script setup lang="ts">
import { useStreamingChat } from '~/composables/useStreamingChat'
import { useConversationStore } from '~/stores/conversation'

const props = defineProps<{
  conversationId?: string
}>()

const {
  messages,
  isStreaming,
  error,
  selectedModel,
  sendMessage,
  stopStreaming,
  retryLastMessage,
  clearMessages,
} = useStreamingChat()

const conversationStore = useConversationStore()
const inputText = ref('')
const messagesContainer = ref<HTMLElement>()

const modelOptions = [
  { label: 'GPT-4o', value: 'gpt-4o' as const },
  { label: 'GPT-4o Mini', value: 'gpt-4o-mini' as const },
  { label: 'Claude Sonnet 4', value: 'claude-sonnet-4-20250514' as const },
]

async function handleSubmit() {
  const text = inputText.value.trim()
  if (!text || isStreaming.value) return
  inputText.value = ''
  await sendMessage(text)
  if (props.conversationId) {
    conversationStore.saveConversation(props.conversationId, messages.value)
  }
  scrollToBottom()
}

function scrollToBottom() {
  nextTick(() => {
    if (messagesContainer.value) {
      messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
    }
  })
}

watch(messages, () => scrollToBottom(), { deep: true })
</script>

<template>
  <div class="flex h-full flex-col rounded-xl border border-gray-200 bg-white shadow-sm">
    <header class="flex items-center justify-between border-b border-gray-200 px-4 py-3">
      <div class="flex items-center gap-3">
        <span class="text-sm font-medium text-gray-700">AI Chat</span>
        <select
          v-model="selectedModel"
          class="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600"
          :disabled="isStreaming"
        >
          <option v-for="opt in modelOptions" :key="opt.value" :value="opt.value">
            {{ opt.label }}
          </option>
        </select>
      </div>
      <div class="flex gap-2">
        <button
          class="rounded-md px-2 py-1 text-xs text-gray-500 hover:bg-gray-100"
          @click="retryLastMessage"
          :disabled="isStreaming || messages.length === 0"
        >
          Retry
        </button>
        <button
          class="rounded-md px-2 py-1 text-xs text-red-500 hover:bg-red-50"
          @click="clearMessages"
          :disabled="isStreaming"
        >
          Clear
        </button>
      </div>
    </header>

    <div ref="messagesContainer" class="flex-1 overflow-y-auto p-4 space-y-4">
      <div
        v-for="msg in messages"
        :key="msg.id"
        :class="[
          'max-w-[80%] rounded-lg px-4 py-2.5 text-sm',
          msg.role === 'user'
            ? 'ml-auto bg-blue-600 text-white'
            : 'mr-auto bg-gray-100 text-gray-900',
        ]"
      >
        <div v-if="msg.role === 'assistant'" class="prose prose-sm max-w-none" v-html="renderMarkdown(msg.content)" />
        <p v-else>{{ msg.content }}</p>
      </div>

      <div v-if="error" class="mx-auto max-w-md rounded-lg bg-red-50 p-3 text-center text-sm text-red-600">
        {{ error }}
        <button class="ml-2 underline" @click="retryLastMessage">Retry</button>
      </div>
    </div>

    <footer class="border-t border-gray-200 p-3">
      <div class="flex gap-2">
        <input
          v-model="inputText"
          type="text"
          placeholder="Type your message..."
          class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
          @keydown.enter="handleSubmit"
          :disabled="isStreaming"
        />
        <button
          v-if="isStreaming"
          class="rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
          @click="stopStreaming"
        >
          Stop
        </button>
        <button
          v-else
          class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
          @click="handleSubmit"
          :disabled="!inputText.trim()"
        >
          Send
        </button>
      </div>
    </footer>
  </div>
</template>

Pattern 4: 边缘推理与Cloudflare Workers

边缘推理是2026年AI应用的关键趋势——将推理逻辑部署到离用户最近的边缘节点,延迟从数百毫秒降到个位数。Nuxt 4 + Nitro让部署到Cloudflare Workers变得极其简单。

Nitro Edge配置

// nuxt.config.ts
export default defineNuxtConfig({
  future: {
    compatibilityVersion: 4,
  },
  nitro: {
    preset: 'cloudflare-module',
    runtimeConfig: {
      aiApiKey: process.env.OPENAI_API_KEY,
      aiBaseUrl: process.env.AI_BASE_URL || 'https://api.openai.com/v1',
    },
    routeRules: {
      '/api/ai/**': {
        cors: true,
        headers: {
          'cache-control': 'no-cache',
        },
      },
    },
  },
})

边缘推理Server Route

// server/api/ai/edge-chat.post.ts
import { defineEventHandler, readBody, setResponseHeader, sendStream } from 'h3'

interface EdgeAIConfig {
  provider: 'openai' | 'anthropic' | 'local'
  baseUrl: string
  apiKey: string
}

function getAIConfig(event: any): EdgeAIConfig {
  const config = useRuntimeConfig(event)
  const provider = getHeader(event, 'x-ai-provider') || 'openai'

  const configs: Record<string, EdgeAIConfig> = {
    openai: {
      provider: 'openai',
      baseUrl: config.public.aiBaseUrl || 'https://api.openai.com/v1',
      apiKey: config.aiApiKey,
    },
    anthropic: {
      provider: 'anthropic',
      baseUrl: 'https://api.anthropic.com/v1',
      apiKey: process.env.ANTHROPIC_API_KEY || '',
    },
    local: {
      provider: 'local',
      baseUrl: process.env.LOCAL_AI_URL || 'http://localhost:11434/v1',
      apiKey: 'local',
    },
  }

  return configs[provider] || configs.openai
}

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  const { messages, model = 'gpt-4o-mini' } = body
  const aiConfig = getAIConfig(event)

  setResponseHeader(event, 'Content-Type', 'text/event-stream')
  setResponseHeader(event, 'Cache-Control', 'no-cache')
  setResponseHeader(event, 'Connection', 'keep-alive')

  const endpoint = aiConfig.provider === 'anthropic'
    ? `${aiConfig.baseUrl}/messages`
    : `${aiConfig.baseUrl}/chat/completions`

  const requestHeaders: Record<string, string> = {
    'Content-Type': 'application/json',
  }

  if (aiConfig.provider === 'anthropic') {
    requestHeaders['x-api-key'] = aiConfig.apiKey
    requestHeaders['anthropic-version'] = '2023-06-01'
  } else {
    requestHeaders['Authorization'] = `Bearer ${aiConfig.apiKey}`
  }

  const requestBody = aiConfig.provider === 'anthropic'
    ? {
        model,
        messages: messages.map((m: any) => ({ role: m.role, content: m.content })),
        max_tokens: 2048,
        stream: true,
      }
    : {
        model,
        messages: messages.map((m: any) => ({ role: m.role, content: m.content })),
        stream: true,
      }

  const response = await fetch(endpoint, {
    method: 'POST',
    headers: requestHeaders,
    body: JSON.stringify(requestBody),
  })

  if (!response.ok) {
    throw createError({
      statusCode: response.status,
      statusMessage: `Edge AI error: ${response.statusText}`,
    })
  }

  return sendStream(event, response.body!)
})

Wrangler部署配置

# wrangler.toml
name = "toolsku-ai-edge"
main = ".output/server/index.mjs"
compatibility_date = "2026-06-01"
compatibility_flags = ["nodejs_compat"]

[vars]
AI_BASE_URL = "https://api.openai.com/v1"

[ai]
binding = "AI"

[[r2_buckets]]
binding = "AI_CACHE"
bucket_name = "toolsku-ai-cache"

[observability]
enabled = true

Pattern 5: RAG前端交互设计

RAG(检索增强生成)是AI应用最实用的模式之一。前端需要处理文档上传、向量搜索、结果展示的完整交互链路。

RAG查询Composable

// composables/useRAG.ts
import { ref, computed } from 'vue'
import type { RAGQuery, RAGResult, RAGSource } from '~/shared/types/ai'

interface DocumentChunk {
  id: string
  content: string
  metadata: {
    source: string
    page: number
    section: string
  }
  score: number
}

export function useRAG() {
  const query = ref('')
  const isSearching = ref(false)
  const isGenerating = ref(false)
  const searchResults = ref<DocumentChunk[]>([])
  const ragAnswer = ref('')
  const ragSources = ref<RAGSource[]>([])
  const error = ref<string | null>(null)

  const hasResults = computed(() => searchResults.value.length > 0)
  const isProcessing = computed(() => isSearching.value || isGenerating.value)

  async function searchDocuments(searchQuery: string, topK = 5) {
    isSearching.value = true
    error.value = null
    searchResults.value = []

    try {
      const results = await $fetch<DocumentChunk[]>('/api/rag/search', {
        method: 'POST',
        body: { query: searchQuery, topK },
      })
      searchResults.value = results
    } catch (err: any) {
      error.value = err.data?.message || 'Search failed'
    } finally {
      isSearching.value = false
    }
  }

  async function generateAnswer(searchQuery: string) {
    isGenerating.value = true
    ragAnswer.value = ''
    ragSources.value = []

    try {
      const result = await $fetch<RAGResult>('/api/rag/generate', {
        method: 'POST',
        body: {
          question: searchQuery,
          topK: 5,
          threshold: 0.7,
        } satisfies RAGQuery,
      })
      ragAnswer.value = result.answer
      ragSources.value = result.sources
    } catch (err: any) {
      error.value = err.data?.message || 'Generation failed'
    } finally {
      isGenerating.value = false
    }
  }

  async function fullRAGPipeline(searchQuery: string) {
    await searchDocuments(searchQuery)
    if (searchResults.value.length > 0) {
      await generateAnswer(searchQuery)
    }
  }

  return {
    query,
    isSearching,
    isGenerating,
    isProcessing,
    searchResults,
    ragAnswer,
    ragSources,
    hasResults,
    error,
    searchDocuments,
    generateAnswer,
    fullRAGPipeline,
  }
}

RAG交互组件

<!-- components/RAGSearchPanel.vue -->
<script setup lang="ts">
import { useRAG } from '~/composables/useRAG'

const {
  query,
  isProcessing,
  searchResults,
  ragAnswer,
  ragSources,
  error,
  fullRAGPipeline,
} = useRAG()

const showSources = ref(false)

async function handleSearch() {
  if (!query.value.trim()) return
  await fullRAGPipeline(query.value)
}
</script>

<template>
  <div class="mx-auto max-w-3xl space-y-6">
    <div class="flex gap-2">
      <input
        v-model="query"
        type="text"
        placeholder="Ask about your documents..."
        class="flex-1 rounded-lg border border-gray-300 px-4 py-2.5 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
        @keydown.enter="handleSearch"
        :disabled="isProcessing"
      />
      <button
        class="rounded-lg bg-blue-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
        @click="handleSearch"
        :disabled="isProcessing || !query.trim()"
      >
        {{ isProcessing ? 'Searching...' : 'Search' }}
      </button>
    </div>

    <div v-if="error" class="rounded-lg bg-red-50 p-4 text-sm text-red-600">
      {{ error }}
    </div>

    <div v-if="ragAnswer" class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
      <h3 class="mb-3 text-sm font-semibold text-gray-500 uppercase tracking-wide">AI Answer</h3>
      <div class="prose prose-sm max-w-none" v-html="ragAnswer" />
      <button
        class="mt-4 text-xs text-blue-600 hover:underline"
        @click="showSources = !showSources"
      >
        {{ showSources ? 'Hide' : 'Show' }} Sources ({{ ragSources.length }})
      </button>
    </div>

    <div v-if="showSources && ragSources.length" class="space-y-3">
      <h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Sources</h3>
      <div
        v-for="(source, index) in ragSources"
        :key="index"
        class="rounded-lg border border-gray-200 bg-gray-50 p-4"
      >
        <div class="mb-2 flex items-center justify-between">
          <span class="text-xs font-medium text-gray-500">
            Relevance: {{ (source.score * 100).toFixed(1) }}%
          </span>
        </div>
        <p class="text-sm text-gray-700 line-clamp-3">{{ source.content }}</p>
      </div>
    </div>

    <div v-if="searchResults.length && !ragAnswer" class="space-y-3">
      <h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">Matching Chunks</h3>
      <div
        v-for="chunk in searchResults"
        :key="chunk.id"
        class="rounded-lg border border-gray-200 bg-white p-4"
      >
        <div class="mb-2 flex items-center gap-2">
          <span class="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
            {{ chunk.metadata.source }}
          </span>
          <span class="text-xs text-gray-400">Page {{ chunk.metadata.page }}</span>
        </div>
        <p class="text-sm text-gray-700">{{ chunk.content }}</p>
      </div>
    </div>
  </div>
</template>

Pattern 6: 对话状态与Pinia持久化

AI聊天应用的核心挑战之一是状态管理——对话历史、模型选择、用户偏好都需要持久化。Pinia + Nuxt 4的SSR兼容方案让这一切变得简单。

对话Store

// stores/conversation.ts
import { defineStore } from 'pinia'
import type { ChatMessage, AIModel } from '~/shared/types/ai'

interface Conversation {
  id: string
  title: string
  messages: ChatMessage[]
  model: AIModel
  createdAt: number
  updatedAt: number
}

interface ConversationState {
  conversations: Map<string, Conversation>
  activeConversationId: string | null
  preferences: {
    defaultModel: AIModel
    temperature: number
    systemPrompt: string
    streamByDefault: boolean
  }
}

export const useConversationStore = defineStore('conversation', {
  state: (): ConversationState => ({
    conversations: new Map(),
    activeConversationId: null,
    preferences: {
      defaultModel: 'gpt-4o-mini',
      temperature: 0.7,
      systemPrompt: 'You are a helpful assistant.',
      streamByDefault: true,
    },
  }),

  getters: {
    activeConversation(state): Conversation | undefined {
      if (!state.activeConversationId) return undefined
      return state.conversations.get(state.activeConversationId)
    },
    conversationList(state): Conversation[] {
      return Array.from(state.conversations.values())
        .sort((a, b) => b.updatedAt - a.updatedAt)
    },
    messageCount(state): number {
      return (id: string) => state.conversations.get(id)?.messages.length || 0
    },
  },

  actions: {
    createConversation(title?: string): string {
      const id = crypto.randomUUID()
      const conversation: Conversation = {
        id,
        title: title || `Chat ${this.conversations.size + 1}`,
        messages: [],
        model: this.preferences.defaultModel,
        createdAt: Date.now(),
        updatedAt: Date.now(),
      }
      this.conversations.set(id, conversation)
      this.activeConversationId = id
      this.persist()
      return id
    },

    saveConversation(id: string, messages: ChatMessage[]) {
      const conversation = this.conversations.get(id)
      if (!conversation) return
      conversation.messages = messages
      conversation.updatedAt = Date.now()
      if (messages.length > 0 && messages[0].role === 'user') {
        conversation.title = messages[0].content.slice(0, 50)
      }
      this.persist()
    },

    deleteConversation(id: string) {
      this.conversations.delete(id)
      if (this.activeConversationId === id) {
        const remaining = this.conversationList
        this.activeConversationId = remaining.length > 0 ? remaining[0].id : null
      }
      this.persist()
    },

    setActiveConversation(id: string) {
      this.activeConversationId = id
    },

    updatePreferences(prefs: Partial<ConversationState['preferences']>) {
      this.preferences = { ...this.preferences, ...prefs }
      this.persist()
    },

    persist() {
      if (import.meta.client) {
        const data = {
          conversations: Object.fromEntries(this.conversations),
          activeConversationId: this.activeConversationId,
          preferences: this.preferences,
        }
        localStorage.setItem('toolsku-ai-conversations', JSON.stringify(data))
      }
    },

    hydrate() {
      if (import.meta.client) {
        const stored = localStorage.getItem('toolsku-ai-conversations')
        if (stored) {
          try {
            const data = JSON.parse(stored)
            this.conversations = new Map(Object.entries(data.conversations))
            this.activeConversationId = data.activeConversationId
            this.preferences = data.preferences
          } catch {
            // corrupted data, reset
            localStorage.removeItem('toolsku-ai-conversations')
          }
        }
      }
    },
  },
})

SSR安全初始化插件

// plugins/conversation-init.client.ts
import { useConversationStore } from '~/stores/conversation'

export default defineNuxtPlugin(() => {
  const store = useConversationStore()
  store.hydrate()
})

Pattern 7: 生产部署与性能优化

从开发到生产,Nuxt 4全栈AI应用需要关注性能、安全、可观测性。

生产配置

// nuxt.config.ts (production)
export default defineNuxtConfig({
  future: {
    compatibilityVersion: 4,
  },

  nitro: {
    compressPublicAssets: true,
    minify: true,

    routeRules: {
      '/api/ai/**': {
        cors: false,
        headers: {
          'strict-transport-security': 'max-age=31536000; includeSubDomains',
          'x-content-type-options': 'nosniff',
          'x-frame-options': 'DENY',
        },
      },
      '/api/chat/stream': {
        headers: {
          'cache-control': 'no-cache, no-store, must-revalidate',
          'x-accel-buffering': 'no',
        },
      },
    },

    rollupConfig: {
      external: ['sharp', 'canvas'],
    },
  },

  app: {
    head: {
      meta: [
        { 'http-equiv': 'X-UA-Compatible', content: 'IE=edge' },
        { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      ],
    },
  },

  experimental: {
    payloadExtraction: true,
    renderJsonPayloads: true,
  },

  vite: {
    build: {
      rollupOptions: {
        output: {
          manualChunks: {
            'ai-vendor': ['openai'],
            'markdown': ['marked', 'highlight.js'],
          },
        },
      },
    },
  },
})

性能监控Composable

// composables/useAIPerformance.ts
import { ref, computed } from 'vue'

interface PerformanceMetric {
  name: string
  startTime: number
  endTime: number
  duration: number
  metadata?: Record<string, unknown>
}

export function useAIPerformance() {
  const metrics = ref<PerformanceMetric[]>([])
  const activeTimers = new Map<string, number>()

  function startTimer(name: string) {
    activeTimers.set(name, performance.now())
  }

  function endTimer(name: string, metadata?: Record<string, unknown>) {
    const startTime = activeTimers.get(name)
    if (startTime === undefined) return
    const endTime = performance.now()
    metrics.value.push({
      name,
      startTime,
      endTime,
      duration: endTime - startTime,
      metadata,
    })
    activeTimers.delete(name)
  }

  const averageLatency = computed(() => {
    const chatMetrics = metrics.value.filter((m) => m.name === 'ai-response')
    if (chatMetrics.length === 0) return 0
    return chatMetrics.reduce((sum, m) => sum + m.duration, 0) / chatMetrics.length
  })

  const p95Latency = computed(() => {
    const chatMetrics = metrics.value
      .filter((m) => m.name === 'ai-response')
      .sort((a, b) => a.duration - b.duration)
    if (chatMetrics.length < 2) return 0
    const index = Math.ceil(chatMetrics.length * 0.95) - 1
    return chatMetrics[index].duration
  })

  function getReport() {
    return {
      totalRequests: metrics.value.filter((m) => m.name === 'ai-response').length,
      averageLatency: averageLatency.value,
      p95Latency: p95Latency.value,
      errorRate: metrics.value.filter((m) => m.metadata?.error).length / Math.max(metrics.value.length, 1),
    }
  }

  return { metrics, startTimer, endTimer, averageLatency, p95Latency, getReport }
}

健康检查端点

// server/api/health.get.ts
import { defineEventHandler } from 'h3'

export default defineEventHandler(async () => {
  const checks: Record<string, { status: 'ok' | 'error'; latency?: number; error?: string }> = {}

  const aiStart = Date.now()
  try {
    const apiKey = process.env.OPENAI_API_KEY
    if (!apiKey) throw new Error('API key not configured')
    await fetch('https://api.openai.com/v1/models', {
      headers: { Authorization: `Bearer ${apiKey}` },
      signal: AbortSignal.timeout(5000),
    })
    checks.ai = { status: 'ok', latency: Date.now() - aiStart }
  } catch (err: any) {
    checks.ai = { status: 'error', error: err.message }
  }

  const overallStatus = Object.values(checks).every((c) => c.status === 'ok') ? 'ok' : 'degraded'

  return {
    status: overallStatus,
    timestamp: new Date().toISOString(),
    version: process.env.APP_VERSION || 'unknown',
    checks,
  }
})

5个常见坑及解决方案

坑1:SSR时访问localStorage导致hydration mismatch

问题:Pinia持久化在SSR阶段读取localStorage,客户端hydration时数据不一致。

解决

// 只在客户端执行持久化逻辑
if (import.meta.client) {
  store.hydrate()
}

// 或者使用useCookie替代localStorage
const savedData = useCookie('ai-conversations', {
  maxAge: 60 * 60 * 24 * 30,
  sameSite: 'lax',
})

坑2:流式响应被Nginx/CDN缓冲

问题:SSE流式响应被中间层缓冲,用户看不到逐字输出。

解决

# nginx.conf
location /api/chat/stream {
    proxy_pass http://nuxt_backend;
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding off;
}

坑3:Server Routes中直接暴露API Key

问题:前端代码或错误响应中泄露AI API Key。

解决

// server/api/chat.post.ts
// 永远不要在响应中返回API Key
const apiKey = useRuntimeConfig().aiApiKey // 服务端私有配置

// 错误响应也要过滤敏感信息
catch (err: any) {
  throw createError({
    statusCode: 500,
    statusMessage: 'AI service error', // 不要暴露err.message
  })
}

坑4:边缘运行时不支持Node.js API

问题:Cloudflare Workers不支持fspath等Node.js模块。

解决

// 使用Nitro的auto-import检测
// server/api/ai/edge-chat.post.ts
// 避免使用Node.js API,使用Web标准API

// 错误:import { readFile } from 'fs'
// 正确:使用env变量或KV存储

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig(event)
  // 使用config而不是读取文件
})

坑5:大量对话历史导致Token超限

问题:发送完整对话历史给AI API,超出模型Token限制。

解决

// utils/message-trimmer.ts
import type { ChatMessage } from '~/shared/types/ai'

const MAX_CONTEXT_TOKENS = 4096
const CHARS_PER_TOKEN = 4

export function trimMessages(messages: ChatMessage[], maxTokens = MAX_CONTEXT_TOKENS): ChatMessage[] {
  let totalTokens = 0
  const trimmed: ChatMessage[] = []

  for (let i = messages.length - 1; i >= 0; i--) {
    const estimatedTokens = Math.ceil(messages[i].content.length / CHARS_PER_TOKEN)
    if (totalTokens + estimatedTokens > maxTokens) break
    totalTokens += estimatedTokens
    trimmed.unshift(messages[i])
  }

  if (trimmed[0]?.role !== 'system' && messages[0]?.role === 'system') {
    trimmed.unshift(messages[0])
  }

  return trimmed
}

10个常见报错排查

# 报错信息 原因 解决方案
1 Hydration mismatch SSR/CSR数据不一致 确保localStorage操作在import.meta.client
2 429 Too Many Requests AI API速率限制 实现请求队列和指数退避重试
3 fetch failed in Server Routes SSR阶段无法访问外部API 检查服务端网络和DNS配置
4 CORS error 跨域请求被阻止 使用Server Routes代理而非直接调用
5 Stream interrupted 连接超时或中断 实现断点续传和自动重连
6 context_length_exceeded 对话历史过长 使用trimMessages裁剪上下文
7 Invalid API Key 环境变量未设置 检查.envruntimeConfig配置
8 Worker exceeded CPU time limit 边缘运行时超时 优化推理逻辑,使用流式响应
9 Module not found: fs 边缘环境不支持Node模块 使用Web标准API替代Node.js API
10 Pinia store not initialized SSR阶段Store未就绪 使用callOnceonNuxtReady初始化

进阶优化技巧

1. AI响应缓存策略

// server/utils/ai-cache.ts
import { useStorage } from '#imports'

const cache = useStorage('redis')

interface CacheEntry<T> {
  data: T
  expiresAt: number
  hitCount: number
}

export async function getCachedAIResponse<T>(
  key: string,
  generator: () => Promise<T>,
  ttl = 3600
): Promise<T> {
  const cached = await cache.getItem<CacheEntry<T>>(`ai:${key}`)
  if (cached && cached.expiresAt > Date.now()) {
    cached.hitCount++
    await cache.setItem(`ai:${key}`, cached)
    return cached.data
  }

  const data = await generator()
  await cache.setItem(`ai:${key}`, {
    data,
    expiresAt: Date.now() + ttl * 1000,
    hitCount: 0,
  })
  return data
}

2. 多模型智能路由

// server/utils/model-router.ts
import type { AIModel } from '~/shared/types/ai'

interface ModelRoute {
  model: AIModel
  condition: (messages: any[]) => boolean
  priority: number
}

const routes: ModelRoute[] = [
  {
    model: 'gpt-4o',
    condition: (msgs) => msgs.length > 20 || msgs.some((m) => m.content.length > 2000),
    priority: 10,
  },
  {
    model: 'gpt-4o-mini',
    condition: () => true,
    priority: 1,
  },
]

export function selectModel(messages: any[]): AIModel {
  const matched = routes
    .filter((r) => r.condition(messages))
    .sort((a, b) => b.priority - a.priority)
  return matched[0]?.model || 'gpt-4o-mini'
}

3. 请求去重与批处理

// server/utils/request-dedup.ts
const pendingRequests = new Map<string, Promise<any>>()

export async function deduplicatedFetch<T>(
  key: string,
  fetcher: () => Promise<T>
): Promise<T> {
  const pending = pendingRequests.get(key)
  if (pending) return pending as Promise<T>

  const promise = fetcher().finally(() => {
    pendingRequests.delete(key)
  })
  pendingRequests.set(key, promise)
  return promise
}

对比分析:Nuxt 4 vs Next.js 15 vs SvelteKit

维度 Nuxt 4 Next.js 15 SvelteKit
全栈AI能力 Server Routes + Nitro Route Handlers + Edge Server Endpoints
SSR流式 原生支持 App Router支持 支持但生态较小
边缘部署 Cloudflare/Vercel/Deno Vercel Edge优先 Cloudflare适配器
类型安全 前后端共享Types 需手动配置 内置但不同机制
状态管理 Pinia(官方推荐) 需第三方库 内置Stores
学习曲线 Vue开发者友好 React生态 Svelte语法独特
AI生态 Vercel AI SDK兼容 Vercel AI SDK原生 社区适配
流式UI 自定义Composable useChat/useCompletion 社区方案
Bundle大小 中等 较大 最小
开发体验 Auto-import + HMR Turbopack HMR Vite HMR

选型建议

  • Vue团队 → Nuxt 4:零学习成本,全栈能力完整
  • React团队 → Next.js 15:AI SDK生态最成熟
  • 追求极致性能 → SvelteKit:Bundle最小,但AI生态待完善

在线工具推荐

在开发Vue3 + Nuxt 4全栈AI应用时,以下在线工具可以帮助你提升效率:


总结

Vue3 + Nuxt 4全栈AI应用在2026年已经完全成熟。7种生产模式覆盖了从API代理到边缘推理的完整链路:

  1. Server Routes 是全栈AI的基础——零后端,API Key安全
  2. SSR + AI 让搜索引擎索引AI内容,SEO与AI兼得
  3. 流式聊天 是AI应用的核心交互,必须支持中断和重试
  4. 边缘推理 将延迟降到个位数,Cloudflare Workers是首选
  5. RAG前端 需要完整的搜索-生成-展示交互链路
  6. Pinia持久化 让对话状态跨页面、跨会话保持
  7. 生产部署 关注安全、性能、可观测性三大维度

Nuxt 4让Vue开发者第一次拥有了不输Next.js的全栈AI能力——一套代码、一次部署、全栈AI。


相关文章

外部参考

本站提供浏览器本地工具,免注册即可试用 →

#Vue3#Nuxt 4#全栈AI#SSR#边缘推理#Server Routes#2026#前端工程