Vue3 AI整合實戰:2026年5種大模型互動模式與串流回應完整方案

前端工程

Vue3 AI整合實戰:2026年5種大模型互動模式與串流回應完整方案

前端接大模型,還在用 fetch 等完整回應回來再渲染?使用者盯著空白等10秒?2026年了,AI應用的使用者體驗標準已經變了——串流輸出、即時回饋、智慧互動才是標配。本文將帶你實作5種Vue3大模型互動模式,每一種都可直接複製到專案中使用。


背景知識:大模型前端互動演進

階段 互動模式 使用者體驗 技術實作
1.0 請求-等待-回應 長時間空白等待 HTTP fetch
2.0 串流回應 逐字輸出,像打字 SSE / WebSocket
3.0 Function Calling AI主動呼叫工具 結構化輸出 + 前端路由
4.0 多模型路由 按場景選最優模型 智慧路由層
5.0 Agent自主互動 AI自主規劃執行 多輪對話 + 工具鏈

問題分析:為什麼傳統方案不夠?

傳統前端呼叫大模型的三大痛點:

  1. 等待焦慮:完整回應回來前使用者無法獲得任何回饋
  2. 超時崩潰:大模型回應時間不確定,長文本生成可能超過30秒
  3. 功能割裂:AI能力與UI互動脫節,無法實現智慧化的使用者體驗

模式一:SSE串流回應

Composable實作

// composables/useSSEChat.ts
import { ref, onUnmounted } from 'vue'

interface ChatMessage {
  role: 'user' | 'assistant' | 'system'
  content: string
  timestamp: number
}

interface UseSSEChatOptions {
  apiUrl: string
  model?: string
  onToken?: (token: string) => void
  onError?: (error: Error) => void
  onComplete?: (fullText: string) => void
}

export function useSSEChat(options: UseSSEChatOptions) {
  const messages = ref<ChatMessage[]>([])
  const currentResponse = ref('')
  const isLoading = ref(false)
  const error = ref<string | null>(null)
  let abortController: AbortController | null = null

  async function sendMessage(content: string) {
    isLoading.value = true
    error.value = null
    currentResponse.value = ''
    abortController = new AbortController()

    messages.value.push({ role: 'user', content, timestamp: Date.now() })

    try {
      const response = await fetch(options.apiUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${import.meta.env.VITE_AI_API_KEY}`,
        },
        body: JSON.stringify({
          model: options.model || 'gpt-4',
          messages: messages.value.map(({ role, content: c }) => ({ role, content: c })),
          stream: true,
        }),
        signal: abortController.signal,
      })

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

      const reader = response.body?.getReader()
      if (!reader) throw new Error('ReadableStream不可用')

      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) {
          const trimmed = line.trim()
          if (!trimmed || trimmed === 'data: [DONE]') continue
          if (!trimmed.startsWith('data: ')) continue

          try {
            const json = JSON.parse(trimmed.slice(6))
            const token = json.choices?.[0]?.delta?.content || ''
            if (token) {
              currentResponse.value += token
              options.onToken?.(token)
            }
          } catch {}
        }
      }

      messages.value.push({ role: 'assistant', content: currentResponse.value, timestamp: Date.now() })
      options.onComplete?.(currentResponse.value)
    } catch (e: any) {
      if (e.name !== 'AbortError') {
        error.value = e.message
        options.onError?.(e)
      }
    } finally {
      isLoading.value = false
      abortController = null
    }
  }

  function stopGeneration() {
    abortController?.abort()
    if (currentResponse.value) {
      messages.value.push({ role: 'assistant', content: currentResponse.value, timestamp: Date.now() })
    }
    isLoading.value = false
  }

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

  onUnmounted(() => { abortController?.abort() })

  return { messages, currentResponse, isLoading, error, sendMessage, stopGeneration, clearMessages }
}

模式二:Function Calling前端整合

// composables/useFunctionCalling.ts
import { ref } from 'vue'

interface FunctionDefinition {
  name: string
  description: string
  parameters: Record<string, any>
  execute: (args: any) => Promise<any>
}

export function useFunctionCalling() {
  const functions = ref<Map<string, FunctionDefinition>>(new Map())
  const executionLog = ref<Array<{ name: string; args: any; result: any; timestamp: number }>>([])

  function registerFunction(fn: FunctionDefinition) {
    functions.value.set(fn.name, fn)
  }

  function getToolsSchema() {
    return Array.from(functions.value.values()).map(fn => ({
      type: 'function',
      function: { name: fn.name, description: fn.description, parameters: fn.parameters },
    }))
  }

  async function handleToolCalls(toolCalls: any[]) {
    const results = []
    for (const call of toolCalls) {
      const fn = functions.value.get(call.function.name)
      if (!fn) {
        results.push({ tool_call_id: call.id, role: 'tool', content: JSON.stringify({ error: `未知函式: ${call.function.name}` }) })
        continue
      }
      try {
        const args = JSON.parse(call.function.arguments)
        const result = await fn.execute(args)
        executionLog.value.push({ name: call.function.name, args, result, timestamp: Date.now() })
        results.push({ tool_call_id: call.id, role: 'tool', content: JSON.stringify(result) })
      } catch (e: any) {
        results.push({ tool_call_id: call.id, role: 'tool', content: JSON.stringify({ error: e.message }) })
      }
    }
    return results
  }

  return { functions, executionLog, registerFunction, getToolsSchema, handleToolCalls }
}

模式三:多模型智慧路由

// composables/useModelRouter.ts
import { ref } from 'vue'

interface ModelConfig {
  id: string
  name: string
  maxTokens: number
  costPer1k: number
  latencyMs: number
  capabilities: string[]
}

const MODEL_REGISTRY: ModelConfig[] = [
  { id: 'gpt-4', name: 'GPT-4', maxTokens: 128000, costPer1k: 0.03, latencyMs: 2000, capabilities: ['reasoning', 'code', 'writing'] },
  { id: 'gpt-4o-mini', name: 'GPT-4o Mini', maxTokens: 128000, costPer1k: 0.00015, latencyMs: 500, capabilities: ['chat', 'summary'] },
  { id: 'claude-3.5-sonnet', name: 'Claude 3.5 Sonnet', maxTokens: 200000, costPer1k: 0.003, latencyMs: 1500, capabilities: ['reasoning', 'code', 'analysis'] },
  { id: 'deepseek-v3', name: 'DeepSeek V3', maxTokens: 128000, costPer1k: 0.00027, latencyMs: 800, capabilities: ['code', 'math', 'reasoning'] },
]

export function useModelRouter() {
  const currentModel = ref<ModelConfig>(MODEL_REGISTRY[1])
  const routingLog = ref<Array<{ input: string; model: string; reason: string }>>([])

  function selectModel(input: string, options?: { preferSpeed?: boolean; preferQuality?: boolean }) {
    const lower = input.toLowerCase()

    if (options?.preferSpeed || lower.length < 50) {
      currentModel.value = MODEL_REGISTRY[1]
      routingLog.value.push({ input: input.slice(0, 50), model: currentModel.value.id, reason: '速度優先/短輸入' })
      return currentModel.value
    }

    if (lower.includes('程式碼') || lower.includes('code') || lower.includes('debug')) {
      currentModel.value = MODEL_REGISTRY[3]
      routingLog.value.push({ input: input.slice(0, 50), model: currentModel.value.id, reason: '程式碼任務' })
      return currentModel.value
    }

    if (options?.preferQuality || lower.length > 500) {
      currentModel.value = MODEL_REGISTRY[0]
      routingLog.value.push({ input: input.slice(0, 50), model: currentModel.value.id, reason: '品質優先/長輸入' })
      return currentModel.value
    }

    currentModel.value = MODEL_REGISTRY[2]
    routingLog.value.push({ input: input.slice(0, 50), model: currentModel.value.id, reason: '預設推理' })
    return currentModel.value
  }

  return { currentModel, routingLog, selectModel, MODEL_REGISTRY }
}

模式四:AI對話元件(Markdown渲染)

<!-- components/AIMarkdownRenderer.vue -->
<template>
  <div class="ai-markdown" v-html="renderedContent"></div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { marked } from 'marked'

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

const renderedContent = computed(() => {
  if (!props.content) return ''
  return marked.parse(props.content, { async: false }) as string
})
</script>

模式五:Agent自主互動

// composables/useAIAgent.ts
import { ref } from 'vue'
import { useSSEChat } from './useSSEChat'
import { useFunctionCalling } from './useFunctionCalling'

interface AgentStep {
  type: 'thinking' | 'tool_call' | 'tool_result' | 'response'
  content: string
  timestamp: number
}

export function useAIAgent(apiUrl: string) {
  const steps = ref<AgentStep[]>([])
  const isRunning = ref(false)
  const maxIterations = 5

  const chat = useSSEChat({ apiUrl })
  const fc = useFunctionCalling()

  fc.registerFunction({
    name: 'search_web',
    description: '搜尋網際網路取得最新資訊',
    parameters: {
      type: 'object',
      properties: { query: { type: 'string', description: '搜尋關鍵字' } },
      required: ['query'],
    },
    execute: async (args) => {
      const resp = await fetch(`/api/search?q=${encodeURIComponent(args.query)}`)
      return resp.json()
    },
  })

  fc.registerFunction({
    name: 'run_code',
    description: '執行程式碼並回傳結果',
    parameters: {
      type: 'object',
      properties: { code: { type: 'string', description: '要執行的程式碼' }, language: { type: 'string', description: '程式語言' } },
      required: ['code'],
    },
    execute: async (args) => {
      const resp = await fetch('/api/execute', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(args),
      })
      return resp.json()
    },
  })

  async function run(task: string) {
    isRunning.value = true
    steps.value = []
    let iteration = 0
    let currentInput = task

    while (iteration < maxIterations) {
      iteration++
      steps.value.push({ type: 'thinking', content: `第${iteration}輪思考...`, timestamp: Date.now() })
      await chat.sendMessage(currentInput)

      const lastMsg = chat.messages.value[chat.messages.value.length - 1]
      if (!lastMsg || lastMsg.role !== 'assistant') break

      steps.value.push({ type: 'response', content: lastMsg.content, timestamp: Date.now() })

      const hasToolCall = lastMsg.content.includes('function_call') || lastMsg.content.includes('tool_call')
      if (!hasToolCall) break

      steps.value.push({ type: 'tool_call', content: '執行工具呼叫...', timestamp: Date.now() })
      break
    }

    isRunning.value = false
  }

  return { steps, isRunning, run, fc }
}

避坑指南

序號 坑點 症狀 解決方案
1 SSE連線未正確關閉 元件卸載後仍在接收資料,記憶體洩漏 onUnmounted 中呼叫 abortController.abort()
2 串流解析buffer不完整 最後一行資料遺失 保留 buffer 殘留部分,下次read時拼接
3 Function Calling參數解析失敗 JSON.parse(arguments) 報錯 用 try-catch 包裹,降級為純文字回應
4 多模型路由死迴圈 模型A推薦模型B,模型B推薦模型A 設定 maxIterations 上限,超過後強制使用預設模型
5 Markdown XSS注入 v-html 渲染惡意指令碼 使用 DOMPurify 清洗HTML,或使用 marked 的 sanitize 選項

報錯排查

報錯訊息 原因 解決方法
ReadableStream is not supported 瀏覽器不支援串流API 新增polyfill web-streams-polyfill,或降級為輪詢
net::ERR_INCOMPLETE_CHUNKED_ENCODING 服務端SSE格式錯誤 確認 Content-Type: text/event-stream,每條訊息以 \n\n 結尾
AbortError: The user aborted a request 使用者主動取消 正常行為,檢查 e.name === 'AbortError'
429 Too Many Requests API呼叫頻率超限 實作指數退避重試,新增請求佇列
JSON.parse: unexpected character SSE資料行格式錯誤 檢查 data: 前綴,過濾空行和非資料行
CORS policy: No 'Access-Control-Allow-Origin' 跨域請求被拒 服務端新增CORS標頭,或使用Vite proxy
Cannot read property 'delta' of undefined SSE回應結構變化 新增可選鏈 json.choices?.[0]?.delta?.content
Maximum call stack size exceeded Agent遞迴呼叫過深 限制 maxIterations,新增遞迴深度檢查
Failed to execute 'fetch' on 'Window' 網路斷開 新增網路狀態偵測,實作離線提示和自動重連
TypeError: response.body is null 回應體為空 檢查API端點是否支援串流,新增 response.body 空值檢查

進階最佳化

1. 請求佇列與限流

// utils/rateLimiter.ts
export class RequestQueue {
  private queue: Array<() => Promise<any>> = []
  private running = 0
  private maxConcurrent: number
  private minInterval: number
  private lastRun = 0

  constructor(maxConcurrent = 3, minInterval = 1000) {
    this.maxConcurrent = maxConcurrent
    this.minInterval = minInterval
  }

  async add<T>(fn: () => Promise<T>): Promise<T> {
    return new Promise((resolve, reject) => {
      this.queue.push(async () => {
        try {
          const now = Date.now()
          const wait = Math.max(0, this.minInterval - (now - this.lastRun))
          if (wait > 0) await new Promise(r => setTimeout(r, wait))
          this.lastRun = Date.now()
          resolve(await fn())
        } catch (e) { reject(e) }
      })
      this.process()
    })
  }

  private async process() {
    while (this.queue.length > 0 && this.running < this.maxConcurrent) {
      this.running++
      const fn = this.queue.shift()!
      fn().finally(() => { this.running--; this.process() })
    }
  }
}

2. 回應快取

const responseCache = new Map<string, { content: string; timestamp: number }>()
const CACHE_TTL = 5 * 60 * 1000

export function getCachedResponse(input: string): string | null {
  const cached = responseCache.get(input)
  if (!cached) return null
  if (Date.now() - cached.timestamp > CACHE_TTL) { responseCache.delete(input); return null }
  return cached.content
}

export function setCachedResponse(input: string, content: string) {
  responseCache.set(input, { content, timestamp: Date.now() })
}

對比分析

互動模式 即時性 複雜度 適用場景 使用者感知延遲
SSE串流 ★★★★★ 通用對話、文字生成 <100ms
Function Calling ★★★★ 工具呼叫、資料分析 200-500ms
多模型路由 ★★★ 成本敏感、多場景 100-2000ms
Markdown渲染 ★★★★★ 程式碼展示、富文字 <50ms
Agent自主互動 ★★★ 複雜任務、自動化 1-10s
前端AI方案 包大小 串流支援 SSR相容 Vue3整合
自研Composable 0KB ★★★★★ ★★★★★ ★★★★★
Vercel AI SDK 12KB ★★★★ ★★★★ ★★★
LangChain.js 200KB+ ★★★ ★★★ ★★
OpenAI SDK 50KB ★★★ ★★ ★★

總結:Vue3 + Composable是前端AI整合的最佳範式——SSE串流回應解決等待焦慮,Function Calling實現AI與UI的深度連動,多模型路由平衡成本與品質,Agent模式賦予AI自主執行能力。5種模式不是互斥的,而是可以組合使用的——一個生產級AI應用,往往同時需要串流輸出、工具呼叫和智慧路由。2026年,前端工程師不僅要會寫UI,更要會寫AI互動。


線上工具推薦

本站提供瀏覽器本地工具,免註冊即可試用 →

#Vue3#AI集成#大模型#流式响应#SSE#Composable#前端AI#智能交互