Vue3 AI統合実践:2026年の5つのLLMインタラクションパターンとストリーミングレスポンス完全ソリューション

前端工程

Vue3 AI統合実践:2026年の5つのLLMインタラクションパターンとストリーミングレスポンス完全ソリューション

フロントエンドでLLMを統合する際、まだ fetch で完全なレスポンスを待ってからレンダリングしていますか?ユーザーが10秒間空白を見つめる?2026年、AIアプリケーションのUX基準は変化しました——ストリーミング出力、リアルタイムフィードバック、インテリジェントなインタラクションが標準です。本記事では5つのVue3 LLMインタラクションパターンを実装し、どれもプロジェクトに直接コピーして使用できます。


背景:フロントエンドLLMインタラクションの進化

段階 インタラクションモード ユーザー体験 技術実装
1.0 リクエスト-待機-レスポンス 長い空白待ち HTTP fetch
2.0 ストリーミングレスポンス 1文字ずつ出力、タイピングのように SSE / WebSocket
3.0 Function Calling AIがツールを能動的に呼び出し 構造化出力 + フロントエンドルーティング
4.0 マルチモデルルーティング シナリオ別に最適モデルを選択 インテリジェントルーティング層
5.0 エージェント自律インタラクション AIが自律的に計画・実行 マルチターン対話 + ツールチェーン

問題分析:なぜ従来のアプローチでは不十分なのか?

従来のフロントエンドLLM統合の3つの主要なペインポイント:

  1. 待ち時間の不安:完全なレスポンスが到着するまでフィードバックがない
  2. タイムアウトクラッシュ:LLMのレスポンス時間は予測不能、長文生成は30秒を超える可能性
  3. 機能の分断:AI機能とUIインタラクションが切り離され、インテリジェントなUXが実現できない

パターン1: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 }
}

パターン2: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 }
}

パターン3:マルチモデルインテリジェントルーティング

// 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('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 }
}

パターン4: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>

パターン5:エージェント自律インタラクション

// 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接続が正しく閉じられない コンポーネントアンマウント後もデータを受信、メモリリーク onUnmountedabortController.abort()を呼び出す
2 ストリーミング解析バッファが不完全 最後の行のデータが消失 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をサポートしていない ポリフィル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プロキシを使用
Cannot read property 'delta' of undefined SSEレスポンス構造の変更 オプショナルチェーンjson.choices?.[0]?.delta?.contentを追加
Maximum call stack size exceeded エージェントの再帰呼び出しが深すぎる maxIterationsを制限、再帰深度チェックを追加
Failed to execute 'fetch' on 'Window' ネットワーク切断 ネットワークステータス検出を追加、オフライン通知と自動再接続を実装
TypeError: response.body is null レスポンスボディがnull APIエンドポイントがストリーミングをサポートしているか確認、response.bodyのnullチェックを追加

高度な最適化

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() })
    }
  }
}

比較分析

インタラクションパターン リアルタイム性 複雑さ ユースケース 体感レイテンシ
SSEストリーミング ★★★★★ 一般チャット、テキスト生成 <100ms
Function Calling ★★★★ ツール呼び出し、データ分析 200-500ms
マルチモデルルーティング ★★★ コスト重視、マルチシナリオ 100-2000ms
Markdownレンダリング ★★★★★ コード表示、リッチテキスト <50ms
エージェント自律 ★★★ 複雑タスク、自動化 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の深い連携を実現し、マルチモデルルーティングはコストと品質のバランスを取り、エージェントモードはAIに自律実行能力を与えます。5つのパターンは相互排他ではなく、組み合わせて使用できます——本番グレードのAIアプリは、ストリーミング出力、ツール呼び出し、インテリジェントルーティングを同時に必要とすることが多いです。2026年、フロントエンドエンジニアはUIを書くだけでなく、AIインタラクションも書けなければなりません。


オンラインツール推奨

ブラウザローカルツールを無料で試す →

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