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つの主要なペインポイント:
- 待ち時間の不安:完全なレスポンスが到着するまでフィードバックがない
- タイムアウトクラッシュ:LLMのレスポンス時間は予測不能、長文生成は30秒を超える可能性
- 機能の分断: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接続が正しく閉じられない |
コンポーネントアンマウント後もデータを受信、メモリリーク |
onUnmountedでabortController.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インタラクションも書けなければなりません。
オンラインツール推奨