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つの主要なペインポイント:
- 待ち時間の不安:完全なレスポンスが到着するまでフィードバックがない
- タイムアウトクラッシュ: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インタラクションも書けなければなりません。
オンラインツール推奨
- JSONデータフォーマット:/ja/json/format
- Base64エンコード/デコード:/ja/encode/base64
- Curlコード変換:/ja/dev/curl-to-code
ブラウザローカルツールを無料で試す →
#Vue3#AI集成#大模型#流式响应#SSE#Composable#前端AI#智能交互