音声入力パイプライン — Whisper / Deepgram 統合
Whisperを使った高精度音声認識
Whisperはプラットフォーム非依存で、ローカル実行またはAPI経由で利用できます。本番環境では、OpenAI Whisper APIの使用を推奨します。
// src/services/stt/whisper.ts
import FormData from 'form-data';
import fs from 'fs';
interface WhisperResponse {
text: string;
language: string;
duration: number;
}
export class WhisperSTTService {
private apiKey: string;
private apiUrl = 'https://api.openai.com/v1/audio/transcriptions';
constructor(apiKey: string) {
this.apiKey = apiKey;
}
async transcribe(audioPath: string, language?: string): Promise<WhisperResponse> {
const form = new FormData();
form.append('file', fs.createReadStream(audioPath));
form.append('model', 'whisper-1');
if (language) {
form.append('language', language);
}
try {
const response = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
...form.getHeaders()
},
body: form
});
const data = await response.json();
if (!response.ok) {
throw new Error(`Whisper API error: ${data.error?.message}`);
}
return {
text: data.text,
language: data.language || 'unknown',
duration: 0 // Whisperは期間を返さないため別途計測
};
} catch (error) {
console.error('Whisper transcription failed:', error);
throw new Error(`STT processing failed: ${error.message}`);
}
}
}
Deepgramによるリアルタイムストリーミング
低遅延が必要な場合(チャットボットのリアルタイム対応など)はDeepgramのストリーミング APIを使用します。
// src/services/stt/deepgram-streaming.ts
import { createClient } from '@deepgram/sdk';
import { PassThrough } from 'stream';
interface StreamingSTTConfig {
apiKey: string;
language?: string;
model?: string;
}
export class DeepgramStreamingSTT {
private client: ReturnType<typeof createClient>;
private language: string;
private model: string;
constructor(config: StreamingSTTConfig) {
this.client = createClient(config.apiKey);
this.language = config.language || 'ja';
this.model = config.model || 'nova-2';
}
async transcribeStream(audioStream: PassThrough): Promise<string> {
return new Promise((resolve, reject) => {
let finalTranscript = '';
const connection = this.client.listen.live({
model: this.model,
language: this.language,
punctuate: true,
interim_results: true
});
connection
.on('open', () => {
console.log('Deepgram connection opened');
audioStream.pipe(connection);
})
.on('results', (data) => {
const transcript = data.result?.results?.[0]?.alternatives?.[0]?.transcript;
if (transcript) {
finalTranscript = transcript;
// interim_resultsが有効な場合、リアルタイム結果を送信
}
})
.on('close', () => {
resolve(finalTranscript);
})
.on('error', (error) => {
reject(new Error(`Deepgram error: ${error.message}`));
});
audioStream.on('end', () => {
connection.finish();
});
});
}
}
STT選択のベストプラクティス:
- 低遅延が必要: Deepgramのストリーミング API(50-300ms)
- 高精度が必須: Whisper API(結果の正確性で定評)
- コスト重視: ローカルWhisper実装(ただしGPU必須)
Claude API によるインテリジェント応答生成
会話コンテキスト付きの応答生成
Claude APIの強力な文脈理解能力を活かすには、会話履歴を効果的に管理することが鍵です。
// src/services/claude-agent.ts
import Anthropic from '@anthropic-ai/sdk';
interface ConversationMessage {
role: 'user' | 'assistant';
content: string;
}
interface VoiceAgentConfig {
apiKey: string;
systemPrompt: string;
maxContextMessages?: number;
temperature?: number;
}
export class VoiceAgent {
private client: Anthropic;
private systemPrompt: string;
private maxContextMessages: number;
private temperature: number;
constructor(config: VoiceAgentConfig) {
this.client = new Anthropic({ apiKey: config.apiKey });
this.systemPrompt = config.systemPrompt;
this.maxContextMessages = config.maxContextMessages || 10;
this.temperature = config.temperature || 0.7;
}
async generateResponse(
userInput: string,
conversationHistory: ConversationMessage[]
): Promise<string> {
// 直近のメッセージのみ保持(トークン効率化)
const recentHistory = conversationHistory.slice(-this.maxContextMessages);
const messages = [
...recentHistory,
{ role: 'user' as const, content: userInput }
];
try {
const response = await this.client.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
temperature: this.temperature,
system: this.systemPrompt,
messages
});
const assistantMessage = response.content[0];
if (assistantMessage.type !== 'text') {
throw new Error('Unexpected response type from Claude');
}
return assistantMessage.text;
} catch (error) {
console.error('Claude API call failed:', error);
throw new Error(`Response generation failed: ${error.message}`);
}
}
async generateResponseStream(
userInput: string,
conversationHistory: ConversationMessage[],
onChunk: (chunk: string) => void
): Promise<string> {
const recentHistory = conversationHistory.slice(-this.maxContextMessages);
const messages = [
...recentHistory,
{ role: 'user' as const, content: userInput }
];
let fullResponse = '';
try {
const stream = await this.client.messages.stream({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 1024,
temperature: this.temperature,
system: this.systemPrompt,
messages
});
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta?.type === 'text_delta') {
const text = chunk.delta.text;
fullResponse += text;
onChunk(text); // UI更新用
}
}
return fullResponse;
} catch (error) {
console.error('Claude streaming failed:', error);
throw new Error(`Streaming response failed: ${error.message}`);
}
}
}
システムプロンプトの最適化
音声エージェント向けのシステムプロンプトは、簡潔で行動指向である必要があります。
const VOICE_AGENT_SYSTEM_PROMPT = `あなたは親切で自然な日本語で話す音声アシスタントです。
【基本指針】
- 簡潔で明確な日本語で応答してください。長い説明は避けてください。
- 敬語を自然に使い、ユーザーに尊重の気持ちを込めてください。
- 不確実な情報は「わかりません」と正直に答えてください。
- 音声出力されることを想定し、句読点は適切に付けてください。
【会話スタイル】
- 一文は60文字以下を目安にしてください。
- 複雑な情報は箇条書きで整理してください。
- 絵文字は使わないでください。
- 「です・ます」体で統一してください。`;
テキスト音声合成(TTS)の実装と最適化
複数TTSエンジンのアダプター設計
本番環境では、複数のTTSプロバイダーをサポートするアダプターパターンを採用します。
// src/services/tts/tts-provider.ts
export interface TTSResponse {
audioUrl: string;
audioBuffer?: Buffer;
duration: number;
format: 'mp3' | 'wav' | 'ogg';
}
export interface TTSProvider {
synthesize(text: string, voice?: string): Promise<TTSResponse>;
getAvailableVoices(): Promise<string[]>;
estimateDuration(text: string): number;
}
// Google Cloud Text-to-Speech実装
import textToSpeech from '@google-cloud/text-to-speech';
import * as fs from 'fs';
export class GoogleCloudTTS implements TTSProvider {
private client: textToSpeech.TextToSpeechClient;
private projectId: string;
constructor(projectId: string) {
this.client = new textToSpeech.TextToSpeechClient();
this.projectId = projectId;
}
async synthesize(text: string, voice = 'ja-JP-Neural2-B'): Promise<TTSResponse> {
const request = {
input: { text },
voice: {
languageCode: 'ja-JP',
name: voice
},
audioConfig: {
audioEncoding: 'MP3' as const,
pitch: 0,
speakingRate: 1.0
}
};
try {
const [response] = await this.client.synthesizeSpeech(request);
const audioBuffer = response.audioContent as Buffer;
// 推定再生時間(平均120文字/分)
const duration = (text.length / 120) * 60;
return {
audioBuffer,
audioUrl: '', // S3アップロード後のURL
duration,
format: 'mp3'
};
} catch (error) {
throw new Error(`Google Cloud TTS failed: ${error.message}`);
}
}
async getAvailableVoices(): Promise<string[]> {
const [result] = await this.client.listVoices({});
return result.voices
?.filter(v => v.languageCodes?.includes('ja-JP'))
.map(v => v.name!) || [];
}
estimateDuration(text: string): number {
return (text.length / 120) * 60;
}
}
// ElevenLabsの実装(低遅延・高品質音声)
import ElevenLabs from 'elevenlabs-node';
export class ElevenLabsTTS implements TTSProvider {
private client: ElevenLabs;
private voiceId: string;
constructor(apiKey: string, voiceId: string) {
this.client = new ElevenLabs({ apiKey });
this.voiceId = voiceId;
}
async synthesize(text: string): Promise<TTSResponse> {
try {
const audioBuffer = await this.client.textToSpeech.convert({
voice_id: this.voiceId,
text,
model_id: 'eleven_multilingual_v2',
voice_settings: {
stability: 0.5,
similarity_boost: 0.75
}
});
const duration = (text.length / 120) * 60;
return {
audioBuffer: Buffer.from(audioBuffer),
audioUrl: '',
duration,
format: 'mp3'
};
} catch (error) {
throw new Error(`ElevenLabs TTS failed: ${error.message}`);
}
}
async getAvailableVoices(): Promise<string[]> {
const voices = await this.client.voices.getAll();
return voices.map(v => v.voice_id);
}
estimateDuration(text: string): number {
return (text.length / 120) * 60;
}
}
TTSキャッシング戦略
同じテキストの音声合成は重い処理のため、キャッシュが必須です。
// src/services/tts/tts-cache.ts
import Redis from 'ioredis';
import crypto from 'crypto';
export class TTSCache {
private redis: Redis;
private ttlSeconds = 86400 * 30; // 30日間
constructor(redisUrl: string) {
this.redis = new Redis(redisUrl);
}
private generateKey(text: string, voice: string, provider: string): string {
const hash = crypto
.createHash('sha256')
.update(`${text}:${voice}:${provider}`)
.digest('hex');
return `tts:${hash}`;
}
async getOrSynthesize(
text: string,
voice: string,
provider: TTSProvider,
providerName: string
): Promise<TTSResponse> {
const key = this.generateKey(text, voice, providerName);
// キャッシュを確認
const cached = await this.redis.getBuffer(key);
if (cached) {
console.log(`TTSCache HIT: ${key}`);
return {
audioBuffer: cached,
audioUrl: '',
duration: provider.estimateDuration(text),
format: 'mp3'
};
}
// TTSを実行
console.log(`TTSCache MISS: ${key} - synthesizing...`);
const result = await provider.synthesize(text, voice);
// キャッシュに保存
if (result.audioBuffer) {
await this.redis.setex(
key,
this.ttlSeconds,
result.audioBuffer
);
}
return result;
}
async invalidate(text: string, voice: string, provider: string): Promise<void> {
const key = this.generateKey(text, voice, provider);
await this.redis.del(key);
}
}
リアルタイムストリーミング処理の設計
WebSocketを用いた双方向ストリーミング
// src/websocket/voice-agent-handler.ts
import WebSocket from 'ws';
import { VoiceAgent } from '../services/claude-agent';
import { DeepgramStreamingSTT } from '../services/stt/deepgram-streaming';
import { TTSCache } from '../services/tts/tts-cache';
interface StreamingSessionConfig {
sessionId: string;
userId: string;
voiceAgent: VoiceAgent;
sttService: DeepgramStreamingSTT;
ttsCache: TTSCache;
}
export class VoiceAgentStreamHandler {
private sessionConfig: StreamingSessionConfig;
private conversationHistory: Array<{ role: string; content: string }> = [];
constructor(config: StreamingSessionConfig) {
this.sessionConfig = config;
}
async handleWebSocketConnection(ws: WebSocket): Promise<void> {
console.log(`Session ${this.sessionConfig.sessionId} connected`);
ws.on('message', async (data: Buffer) => {
try {
// クライアントから音声データを受け取る
const audioChunk = data;
// Deepgramで文字起こし
const transcript = await this.transcribeAudioChunk(audioChunk);
if (!transcript) return;
// Claude APIで応答を生成
const response = await this.sessionConfig.voiceAgent.generateResponse(
transcript,
this.conversationHistory
);
// 会話履歴を更新
this.conversationHistory.push(
{ role: 'user', content: transcript },
{ role: 'assistant', content: response }
);
// TTSで音声合成
const audioBuffer = await this.generateSpeech(response);
// クライアントに送信
ws.send(JSON.stringify({
type: 'response',
transcript,
response,
audioBuffer: audioBuffer.toString('base64')
}));
} catch (error) {
console.error('Stream processing error:', error);
ws.send(JSON.stringify({
type: 'error',
message: error.message
}));
}
});
ws.on('close', () => {
console.log(`Session ${this.sessionConfig.sessionId} closed`);
this.cleanupSession();
});
ws.on('error', (error) => {
console.error(`WebSocket error in session ${this.sessionConfig.sessionId}:`, error);
});
}
private async transcribeAudioChunk(audioData: Buffer): Promise<string> {
// 実装は前述のDeepgramStreamingSTT参照
return '';
}
private async generateSpeech(text: string): Promise<Buffer> {
// TTSキャッシュを使用して音声を生成
// 実装は前述のTTSCache参照
return Buffer.alloc(0);
}
private cleanupSession(): void {
this.conversationHistory = [];
console.log(`Cleaned up session ${this.sessionConfig.sessionId}`);
}
}
会話コンテキスト管理と記憶メカニズム
PostgreSQLを用いたセッション永続化
// src/db/session-repository.ts
import { Pool, QueryResult } from 'pg';
interface VoiceSession {
sessionId: string;
userId: string;
startTime: Date;
endTime?: Date;
conversationHistory: Array<{ role: string; content: string }>;
metadata: Record<string, any>;
}
export class VoiceSessionRepository {
private pool: Pool;
constructor(connectionString: string) {
this.pool = new Pool({ connectionString });
}
async createSession(session: VoiceSession): Promise<void> {
const query = `
INSERT INTO voice_sessions (session_id, user_id, start_time, conversation_history, metadata)
VALUES ($1, $2, $3, $4, $5)
`;
await this.pool.query(query, [
session.sessionId,
session.userId,
session.startTime,
JSON.stringify(session.conversationHistory),
JSON.stringify(session.metadata)
]);
}
async updateConversation(
sessionId: string,
history: Array<{ role: string; content: string }>
): Promise<void> {
const query = `
UPDATE voice_sessions
SET conversation_history = $1, updated_at = NOW()
WHERE session_id = $2
`;
await this.pool.query(query, [JSON.stringify(history), sessionId]);
}
async getSession(sessionId: string): Promise<VoiceSession | null> {
const query = `
SELECT session_id, user_id, start_time, conversation_history, metadata
FROM voice_sessions
WHERE session_id = $1
`;
const result = await this.pool.query(query, [sessionId]);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
sessionId: row.session_id,
userId: row.user_id,
startTime: row.start_time,
conversationHistory: JSON.parse(row.conversation_history),
metadata: JSON.parse(row.metadata)
};
}
async endSession(sessionId: string): Promise<void> {
const query = `
UPDATE voice_sessions
SET end_time = NOW()
WHERE session_id = $1
`;
await this.pool.query(query, [sessionId]);
}
}
ユーザープロファイルに基づくパーソナライゼーション
// src/services/user-context.ts
export interface UserProfile {
userId: string;
name: string;
preferences: {
language: string;
voiceGender: 'male' | 'female';
formality: 'casual' | 'formal';
};
interactionHistory: {
totalSessions: number;
averageSessionLength: number;
preferredTopics: string[];
};
}
export class UserContextManager {
private userProfiles: Map<string, UserProfile> = new Map();
async loadUserProfile(userId: string): Promise<UserProfile> {
// DBから読み込み
return {
userId,
name: 'User',
preferences: {
language: 'ja',
voiceGender: 'female',
formality: 'formal'
},
interactionHistory: {
totalSessions: 0,
averageSessionLength: 0,
preferredTopics: []
}
};
}
buildSystemPromptWithContext(basePrompt: string, profile: UserProfile): string {
return `${basePrompt}
【ユーザー情報】
- 名前: ${profile.name}
- 話し方の丁寧さ: ${profile.preferences.formality === 'formal' ? '敬語を使用' : 'ラフでもOK'}
- これまでのセッション数: ${profile.interactionHistory.totalSessions}
- 関心分野: ${profile.interactionHistory.preferredTopics.join('、')}`;
}
}
エラーハンドリングとフォールバック戦略
包括的なエラーハンドリング
// src/error-handling/voice-agent-errors.ts
export class VoiceAgentError extends Error {
constructor(
public code: string,
message: string,
public retryable: boolean = false,
public fallback?: string
) {
super(message);
this.name = 'VoiceAgentError';
}
}
export class STTError extends VoiceAgentError {
constructor(message: string, retryable = true) {
super('STT_ERROR', message, retryable, 'Audio processing failed. Please try again.');
}
}
export class ClaudeAPIError extends VoiceAgentError {
constructor(message: string, retryable = true) {
super('CLAUDE_API_ERROR', message, retryable, 'I had trouble understanding. Could you repeat that?');
}
}
export class TTSError extends VoiceAgentError {
constructor(message: string, retryable = true) {
super('TTS_ERROR', message, retryable, 'I cannot generate audio at this moment.');
}
}
// エラー処理ミドルウェア
export class VoiceAgentErrorHandler {
async handle(error: any, sessionId: string): Promise<{ message: string; fallback: string }> {
console.error(`[${sessionId}] Error:`, error);
if (error instanceof VoiceAgentError) {
return {
message: error.message,
fallback: error.fallback || 'An error occurred. Please try again.'
};
}
// 予期しないエラー
return {
message: 'Unexpected error',
fallback: 'Something went wrong. Please restart the session.'
};
}
}
リトライロジックと段階的デグラデーション
// src/resilience/retry-strategy.ts
export interface RetryConfig {
maxAttempts: number;
initialDelayMs: number;
maxDelayMs: number;
backoffMultiplier: number;
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxAttempts: 3,
initialDelayMs: 100,
maxDelayMs: 5000,
backoffMultiplier: 2
};
export async function withRetry<T>(
fn: () => Promise<T>,
config: RetryConfig = DEFAULT_RETRY_CONFIG
): Promise<T> {
let lastError: Error | null = null;
let delay = config.initialDelayMs;
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
console.warn(`Attempt ${attempt} failed: ${lastError.message}`);
if (attempt < config.maxAttempts) {
await new Promise(resolve => setTimeout(resolve, delay));
delay = Math.min(delay * config.backoffMultiplier, config.maxDelayMs);
}
}
}
throw lastError;
}
// 複数TTSプロバイダーのフォールバック
export class TTSFallbackManager {
private providers: Array<{ name: string; provider: TTSProvider }>;
private primaryIndex = 0;
constructor(providers: Array<{ name: string; provider: TTSProvider }>) {
this.providers = providers;
}
async synthesizeWithFallback(text: string): Promise<TTSResponse> {
for (let i = 0; i < this.providers.length; i++) {
const index = (this.primaryIndex + i) % this.providers.length;
const { name, provider } = this.providers[index];
try {
console.log(`Attempting TTS with ${name}...`);
const result = await provider.synthesize(text);
this.primaryIndex = index; // 成功したプロバイダーを優先
return result;
} catch (error) {
console.warn(`${name} failed: ${error.message}`);
if (i === this.providers.length - 1) {
throw new TTSError('All TTS providers failed');
}
}
}
throw new TTSError('No TTS providers available');
}
}
本番デプロイとスケーリング
Kubernetes環境でのデプロイメント
# k8s/voice-agent-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: voice-agent
spec:
replicas: 3
selector:
matchLabels:
app: voice-agent
template:
metadata:
labels:
app: voice-agent
spec:
containers:
- name: voice-agent
image: voice-agent:latest
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
env:
- name: CLAUDE_API_KEY
valueFrom:
secretKeyRef:
name: voice-agent-secrets
key: claude-api-key
- name: DEEPGRAM_API_KEY
valueFrom:
secretKeyRef:
name: voice-agent-secrets
key: deepgram-api-key
- name: REDIS_URL
valueFrom:
configMapKeyRef:
name: voice-agent-config
key: redis-url
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: voice-agent-secrets
key: database-url
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: voice-agent-service
spec:
selector:
app: voice-agent
ports:
- protocol: TCP
port: 80
targetPort: 3000
type: LoadBalancer
水平スケーリングのためのセッション共有
// src/infra/session-affinity.ts
import Redis from 'ioredis';
export class SessionAffinityManager {
private redis: Redis;
constructor(redisUrl: string) {
this.redis = new Redis(redisUrl);
}
async assignPodToSession(sessionId: string, podName: string): Promise<void> {
await this.redis.setex(`session:affinity:${sessionId}`, 86400, podName);
}
async getPodForSession(sessionId: string): Promise<string | null> {
return this.redis.get(`session:affinity:${sessionId}`);
}
async releaseSessionAffinity(sessionId: string): Promise<void> {
await this.redis.del(`session:affinity:${sessionId}`);
}
}
コスト最適化とモニタリング
APIコール計測とコスト監視
// src/monitoring/cost-tracker.ts
import { CloudWatch } from 'aws-sdk';
export interface APICost {
service: string; // 'claude', 'whisper', 'tts-google', 'tts-elevenlabs'
inputUnits: number; // トークン数、分数など
outputUnits: number;
costUSD: number;
timestamp: Date;
}
export class CostTracker {
private cloudwatch: CloudWatch;
private costs: APICost[] = [];
constructor() {
this.cloudwatch = new CloudWatch();
}
recordClaudeCall(inputTokens: number, outputTokens: number): void {
// Claude 3.5 Sonnet: $3/MTok (input), $15/MTok (output)
const inputCost = (inputTokens / 1_000_000) * 3;
const outputCost = (outputTokens / 1_000_000) * 15;
this.costs.push({
service: 'claude',
inputUnits: inputTokens,
outputUnits: outputTokens,
costUSD: inputCost + outputCost,
timestamp: new Date()
});
}
recordWhisperCall(duration: number): void {
// Whisper: $0.02/分
const cost = (duration / 60) * 0.02;
this.costs.push({
service: 'whisper',
inputUnits: Math.ceil(duration),
outputUnits: 0,
costUSD: cost,
timestamp: new Date()
});
}
recordTTSCall(characters: number, provider: string): void {
// Google Cloud TTS: $16/MTok (100万文字)
// ElevenLabs: $0.30/1万文字
let cost = 0;
if (provider === 'google') {
cost = (characters / 1_000_000) * 16;
} else if (provider === 'elevenlabs') {
cost = (characters / 10_000) * 0.30;
}
this.costs.push({
service: `tts-${provider}`,
inputUnits: characters,
outputUnits: 0,
costUSD: cost,
timestamp: new Date()
});
}
async publishMetrics(sessionId: string): Promise<void> {
const totalCost = this.costs.reduce((sum, c) => sum + c.costUSD, 0);
await this.cloudwatch.putMetricData({
Namespace: 'VoiceAgent',
MetricData: [
{
MetricName: 'SessionCost',
Value: totalCost,
Unit: 'None',
Dimensions: [
{ Name: 'SessionId', Value: sessionId }
]
},
{
MetricName: 'APICallCount',
Value: this.costs.length,
Unit: 'Count'
}
]
}).promise();
console.log(`Session ${sessionId} cost: $${totalCost.toFixed(4)}`);
}
}
包括的なメトリクス収集
// src/monitoring/metrics.ts
import prom from 'prom-client';
export const voiceAgentMetrics = {
// カウンター
totalSessions: new prom.Counter({
name: 'voice_agent_total_sessions',
help: 'Total number of voice agent sessions',
labelNames: ['status'] // success, failed, timeout
}),
apiCallsTotal: new prom.Counter({
name: 'voice_agent_api_calls_total',
help: 'Total API calls by service',
labelNames: ['service'] // claude, whisper, tts
}),
// ヒストグラム
sessionDurationSeconds: new prom.Histogram({
name: 'voice_agent_session_duration_seconds',
help: 'Voice agent session duration',
buckets: [10, 30, 60, 300, 600]
}),
apiLatencyMs: new prom.Histogram({
name: 'voice_agent_api_latency_ms',
help: 'API call latency',
labelNames: ['service'],
buckets: [50, 100, 200, 500, 1000, 2000]
}),
// ゲージ
activeSessions: new prom.Gauge({
name: 'voice_agent_active_sessions',
help: 'Number of active sessions'
}),
queueLength: new prom.Gauge({
name: 'voice_agent_queue_length',
help: 'Length of pending requests',
labelNames: ['service']
})
};
// メトリクス記録ヘルパー
export function recordSessionMetric(
durationSeconds: number,
success: boolean
): void {
voiceAgentMetrics.totalSessions.inc({ status: success ? 'success' : 'failed' });
voiceAgentMetrics.sessionDurationSeconds.observe(durationSeconds);
}
公式ドキュメントに書かれていない7つの運用知見
ここまでは設計と実装の全体像でした。ここからは、個人開発で実際に音声エージェントを本番に置いてみて初めて分かった、公式ドキュメントには書かれていない落とし穴と判断軸を共有します。
私が技術選択で最優先にしているのは「自分が深夜2時に呼ばれない設計」かどうかです。音声エージェントは、その軸が最も厳しく問われる領域でした。
1. レイテンシ予算は3層で分割して測る(合計1500ms)
「音声から音声まで」のエンドツーエンドレイテンシは、1500ms を超えると会話のリズムが崩れるというのが実体験上の閾値でした。ユーザーは無意識に「あれ、止まった?」と感じて二度話しを始めます。
予算配分の具体値は以下の通りで、Cloudflare 上の本番計測値(東京 → us-east-1)です。
| レイヤー | 予算 | 実測中央値 | 95パーセンタイル |
| STT(Deepgram Streaming) | 600ms | 280ms | 480ms |
| Claude Haiku 応答生成 | 400ms | 320ms | 620ms |
| TTS(ElevenLabs Flash v2) | 400ms | 240ms | 410ms |
| ネットワーク往復 | 100ms | 70ms | 130ms |
| 合計 | 1500ms | 910ms | 1640ms |
Whisper を素直に呼ぶと録音終了後にまとめて推論が走るため、最後の音節以降に 600〜900ms が乗ります。Deepgram Streaming は VAD(Voice Activity Detection)で発話終端を予測してくれるので、体感レイテンシが半分になります。
私の場合、最初は Whisper API のシンプルさに惹かれて全件 Whisper にしましたが、p95 が 1800ms を超えて UX が崩れたため、3週間で Deepgram Streaming に切り替えました。
// 実測ベースのレイテンシ予算チェッカー
interface LatencyBudget {
stt: { budget: 600, actual?: number };
llm: { budget: 400, actual?: number };
tts: { budget: 400, actual?: number };
network: { budget: 100, actual?: number };
}
export function assertBudget(b: LatencyBudget, sessionId: string) {
const total = (b.stt.actual ?? 0) + (b.llm.actual ?? 0) +
(b.tts.actual ?? 0) + (b.network.actual ?? 0);
if (total > 1500) {
// Sentry に送る — 1セッションでも超過したら次の最適化候補
console.warn(`[budget-exceeded] session=${sessionId} total=${total}ms`, b);
}
return total <= 1500;
}
2. 1セッション $0.024 → $0.011 までコストを半減させた4つの判断
機能の設計に入る前に「1セッションあたり何ドルか」を電卓で出す癖がついています。広告収益ベースのアプリを長く回してきた影響だと思います。最初の構成では Whisper + Claude Sonnet + ElevenLabs で 1セッション 3分換算 約 $0.024 でした。これを 9 週間かけて $0.011 まで下げた判断は以下の4つです。
| 判断 | Before | After | 削減幅 |
| STT を Whisper → Deepgram Nova-2 に変更 | $0.006/min | $0.0043/min | -28% |
| 応答生成を Sonnet → Haiku(一次) | $3/1M tok | $0.25/1M tok | -91% |
| Sonnet は「複雑な質問」だけにルーティング | 100% Sonnet | 18% Sonnet | -82% |
| TTS を ElevenLabs Multilingual → Flash v2 | $0.30/1k chars | $0.10/1k chars | -67% |
特に「Sonnet ルーティング条件」は、最初は質問の複雑度を Claude 自身に判定させていましたが、判定に Sonnet を使うと結局コストが下がらないという落とし穴がありました。最終的には、入力長 80文字以上 / 過去 3 ターンに技術用語 / システムプロンプトで明示的に escalate された場合、の単純ルールに落としています。
3. Cloudflare Workers は音声エージェントの一次採用に向かない
手元の Next.js サイト群を Cloudflare Workers + OpenNext で動かしている流れで、最初の発想は「音声エージェントも同じ Workers に載せられないか」でした。実際にやってみて、3 つの致命的な制約にぶつかりました。
- CPU 30秒制限: 会話の往復 1 回で 1〜2秒の CPU 時間を消費するため、長いセッションでは確実に超過する。Durable Objects に逃がしても CPU 時間は同じ枠で計算される
- WebSocket の制限: Cloudflare Workers の WebSocket は片方向 32KB/フレーム、合計 16時間で切断。音声ストリームの双方向同時送受信には Durable Objects + Hibernation API が必須で、設計コストが跳ね上がる
- 音声ライブラリの互換性: ffmpeg WASM ビルドが必要なケースで、Workers のバンドルサイズ 10MB 制限に引っかかる
最終的な構成は、シグナリングとセッション管理を Cloudflare Workers + Durable Objects、推論実行を Google Cloud Run(オーディオ処理)+ Anthropic API、という分離になりました。Cloudflare のメンバーシップ判定(premium_token Cookie)はそのまま Workers で行い、課金済みユーザーだけが Cloud Run の WebSocket にコネクトできるようにしています。
// Cloudflare Workers 側のサインドトークン発行(Cloud Run の WebSocket は受信時に検証)
import { SignJWT } from 'jose';
export async function issueVoiceSessionToken(env: Env, userId: string) {
const secret = new TextEncoder().encode(env.VOICE_SESSION_SECRET);
return await new SignJWT({ sub: userId, scope: 'voice-session' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m') // 短命にして再発行
.sign(secret);
}
4. 「夜中に呼ばれない」ためのフォールバック優先順位
音声の障害は、沈黙としてそのまま相手に届きます。テキストならスピナーで待ってもらえる 3 秒が、会話では致命的な間になります。だからこの領域だけは、全てが落ちる前提でフォールバックを 3 段階用意することを着手条件にしています。
優先順位:
- STT 障害: Deepgram → Whisper API に自動切り替え(追加レイテンシ +600ms、UX 劣化を許容)
- Claude API 障害: Sonnet → Haiku → 事前録音された「申し訳ありません、もう一度お話しください」TTS(コストはほぼゼロ)
- TTS 障害: ElevenLabs → OpenAI TTS → ブラウザ標準の Web Speech API(音質劣化を許容)
各段階で「劣化していることをユーザーに通知するか」は製品判断です。私自身は無料アシスタントでは非通知、メンバーシップ加入者向けには「現在簡易応答モードです」と明示する設計を採用しています。誠実さがメンバーシップの根幹なので、隠さない判断を優先しています。
5. 会話履歴は ContextWindow ではなく ConversationGraph で持つ
音声エージェントは、テキストチャットと違ってユーザーが文脈を意識的に区切れません。「あ、それじゃなくて、さっきの……」が頻発します。
最初は単純な配列で履歴を持ち、Claude API の Context Window に丸ごと流していましたが、200kトークンに収まっていても応答の精度が落ちる現象が出ました。「最近のターンが古いターンの影響を受けすぎる」傾向です。
対処として、会話を 3 種類のノードでグラフ化しました。
- 質問ノード: ユーザーの意図と回答した内容
- トピックノード: 同じ話題の質問ノードをまとめる中間ノード
- 状態ノード: 「予約 → 確認待ち → キャンセル」のような明示的な状態遷移
Claude には毎ターン、直近 6 ターンのフル履歴 + 現在のトピックノード要約 + 状態ノード のみを渡します。これでコンテキスト窓は 4k〜8k トークンに収まり、応答精度も体感で 30〜40% 改善しました。
6. 監視メトリクスは「ユーザー体験」と「コスト」を別ダッシュボードで
Prometheus + Grafana の構成は標準的ですが、私が実運用で気づいたのは、1 つのダッシュボードに体験指標とコスト指標を混ぜると判断が鈍るということです。
分けるべき理由はシンプルで、p95 レイテンシが悪化したときに「コスト削減のための Haiku ルーティングを戻すべきか」を冷静に判断できなくなるからです。私は以下の 2 つに分けています。
- UX ダッシュボード: p50/p95 レイテンシ、認識失敗率、セッション中断率、ユーザー再質問率
- Cost ダッシュボード: 1セッション平均コスト、STT/LLM/TTS の比率、Sonnet 比率、無料/メンバー別の単価差
メンバーシップ事業を運営している立場では、「メンバー向けには遅延より精度」「無料向けにはコストより速度」というように、ダッシュボードごとに優先軸が異なります。Grafana の Variables で tier=free|premium を切り替えられるようにしておくと、判断のスピードが上がります。
7. 話しかぶり(barge-in)は TTS ではなく再生バッファ側で止める
実運用で最も苦情が集まったのは、レイテンシでも認識精度でもなく、エージェントが喋っている最中にユーザーが話し始めたときの挙動でした。
最初は「TTS のストリームを止めればよい」と考え、発話検知の時点でサーバー側の TTS リクエストを abort していました。ところが体感はほとんど変わりません。理由は単純で、クライアントの再生バッファに 400〜800ms 分の音声がすでに積まれているからです。サーバーで止めても、その分は最後まで喋り切ってしまいます。
止めるべき場所は、送信元ではなく再生側でした。
// クライアント: VAD が発話開始を検知したら、まずローカル再生を殺す
export function createBargeInController(ws: WebSocket) {
let current: AudioBufferSourceNode | null = null;
return {
play(node: AudioBufferSourceNode) {
current = node;
node.start();
},
onUserSpeechStart() {
current?.stop(); // ① 先にローカル再生を即停止(20ms 以内)
current = null;
ws.send(JSON.stringify({ type: 'barge_in' })); // ② その後にサーバへ通知
},
};
}
順序が肝心です。サーバー通知を先にすると、往復の 70〜130ms がそのまま「まだ喋っている時間」として残り、ユーザーには割り込みが効いていないように聞こえます。
サーバー側では barge_in の受信時に、TTS リクエストの abort に加えて中断地点までのテキストだけを会話履歴へ書き戻す必要があります。全文を履歴に入れてしまうと、ユーザーが聞いていない内容を前提に次の応答が組み立てられ、会話が噛み合わなくなります。
私はこの対処として、TTS のストリーミングチャンクに文字オフセットを持たせ、中断時点までを assistant ターンとして保存し、末尾に「(この時点でユーザーが発話したため中断)」を付けています。この一行を足すだけで、Claude が「言いかけを引き取る」文脈を正しく汲んでくれるようになりました。
設計を実装に落とすときに私が頼った判断軸
ここまで具体的な数値とコードを並べてきましたが、本質的に大事だと思っているのは「自分の生活時間と引き換えに何を守るか」を最初に決めることです。
私は p95 レイテンシ 1500ms と 1セッション $0.015 を「ここを超えたら寝る前に直しに行く」自分との約束ラインにしています。境界を数字で持っていれば、夜中のアラートで起きるかどうかを 30 秒で決められます。逆に境界を決めていないと、すべてのアラートが等しく重く感じられて、判断そのものが消耗します。
音声エージェントは、テキストチャットより一段階「人らしさ」が要求される領域です。速い、安い、落ちないを同時に満たすのは本来難しい題材ですが、レイヤーごとに予算を切って、フォールバックを 3 段持って、ダッシュボードを分けておけば、深夜のオンコールに耐えられる構成になります。
実装の参考になれば幸いです。同じ課題に取り組んでいる方の判断材料になれば嬉しいです。