音声入力パイプライン — 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 [],
options : { signal ?: AbortSignal } = {}
) : 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-sonnet-5' ,
max_tokens: 1024 ,
temperature: this .temperature,
system: this .systemPrompt,
messages
},
// 第 2 引数のリクエストオプションに signal を渡すと、
// 割り込みで畳んだターンの生成をその場で止められます。
{ signal: options.signal }
);
// content[0] を決め打ちにしない。thinking やツール使用を足した瞬間に、
// 先頭が text でなくなって例外側へ落ちます。
const textBlock = response.content. find (
( block ) : block is Extract < typeof block, { type : 'text' }> => block.type === 'text'
);
if ( ! textBlock) {
throw new Error ( 'No text block in Claude response' );
}
return textBlock.text;
} catch ( error : unknown ) {
console. error ( 'Claude API call failed:' , error);
throw new Error ( `Response generation failed: ${ toMessage ( error ) }` );
}
}
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-sonnet-5' ,
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を用いた双方向ストリーミング
ここで一度つまずいた箇所を先にお伝えします。ws の message イベントに async のハンドラを直接渡すと、ライブラリ側はその Promise を待ってくれません。フレームが届いた順に新しいハンドラが走り出すため、生成が速く終わったターンから順に会話履歴へ積まれていきます。
音声は、テキストチャットと違って発話が細かく分割されて届きます。短い相づちと長い質問が数十ミリ秒差で並ぶことも珍しくありません。そこで履歴の順番が入れ替わると、Claude には「答えてから訊かれた」ように見える列が渡ります。
私はこれを、応答が微妙に噛み合わない不具合として数日抱えておりました。原因が設計ではなくイベントハンドラの形にあると気づいたのは、履歴をそのままログへ落として並びを目で追ってからでした。
対処は、セッションごとに 1 本の直列チェーン を持つことです。あわせて、新しい発話が来たら前のターンを AbortController で畳みます。割り込みの扱いはこの後の「話しかぶり」の節と地続きで、止める場所をサーバー側にも用意しておく形になります。
// 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 ;
}
type Turn = { role : 'user' | 'assistant' ; content : string };
export class VoiceAgentStreamHandler {
private sessionConfig : StreamingSessionConfig ;
private conversationHistory : Turn [] = [];
// セッションごとに 1 本だけ持つ直列チェーン。履歴の書き換えはこの中だけで行います。
private chain : Promise < void > = Promise . resolve ();
// いま処理中のターン。次のフレームが来たらここを abort します(barge-in)。
private current : AbortController | null = null ;
constructor ( config : StreamingSessionConfig ) {
this .sessionConfig = config;
}
async handleWebSocketConnection ( ws : WebSocket ) : Promise < void > {
console. log ( `Session ${ this . sessionConfig . sessionId } connected` );
// ハンドラ自体は同期。await を書かないので ws 側の発火順と処理順がずれません。
ws. on ( 'message' , ( data : Buffer ) => {
this .current?. abort ();
const ac = new AbortController ();
this .current = ac;
this .chain = this .chain. then (() => this . runTurn (ws, data, ac.signal));
});
ws. on ( 'close' , () => {
console. log ( `Session ${ this . sessionConfig . sessionId } closed` );
this .current?. abort ();
this . cleanupSession ();
});
ws. on ( 'error' , ( error : unknown ) => {
console. error (
`WebSocket error in session ${ this . sessionConfig . sessionId }:` ,
toMessage (error)
);
});
}
private async runTurn ( ws : WebSocket , audioChunk : Buffer , signal : AbortSignal ) : Promise < void > {
if (signal.aborted) return ; // 並び終わる前に次の発話で追い越された
try {
const transcript = await this . transcribeAudioChunk (audioChunk);
if ( ! transcript || signal.aborted) return ;
const response = await this .sessionConfig.voiceAgent. generateResponse (
transcript,
this .conversationHistory,
{ signal }
);
if (signal.aborted) return ;
// 中断されずにここまで来たターンだけを履歴へ積みます。
this .conversationHistory. push (
{ role: 'user' , content: transcript },
{ role: 'assistant' , content: response }
);
const audioBuffer = await this . generateSpeech (response);
if (signal.aborted) return ;
ws. send ( JSON . stringify ({
type: 'response' ,
transcript,
response,
audioBuffer: audioBuffer. toString ( 'base64' )
}));
} catch ( error : unknown ) {
if (signal.aborted) return ; // abort 由来の例外はユーザーへ返さない
console. error ( 'Stream processing error:' , error);
ws. send ( JSON . stringify ({ type: 'error' , message: toMessage (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 }` );
}
}
toMessage は、catch した値を安全に文字列へ落とすだけの小さな関数です。TypeScript は 4.4 以降、strict のもとで catch の変数を unknown として扱いますので、error.message と直接書いた行はそもそも型検査を通りません。手元の TypeScript 7.0.2 に --strict --noEmit を付けて通すと error is of type 'unknown' で止まります。
// src/utils/to-message.ts
export function toMessage ( error : unknown ) : string {
if (error instanceof Error ) return error.message;
if ( typeof error === 'string' ) return error;
try {
return JSON . stringify (error);
} catch {
return String (error);
}
}
この記事の他の節にも error.message をそのまま埋め込んだ箇所がありますが、実際のコードでは同じように toMessage を通しています。掲載の都合で短く書いた行が、そのまま貼ると tsc で落ちる形になっていました。
直列化の有無で、同じ 3 フレームがどう並ぶか
この形が本当に効くのかは、ws を立てなくても確かめられます。ws の message は EventEmitter の emit で発火しますので、EventEmitter に async のリスナを付けて、生成時間だけをターンごとに変えれば同じ現象が出ます。
// Node.js v22.23.2 で実行
import { EventEmitter } from 'node:events' ;
const em = new EventEmitter ();
const history = [];
const generate = ( t ) => new Promise ( r => setTimeout (() => r ( `assistant:${ t }` ), 60 - t * 10 ));
em. on ( 'message' , async ( t ) => { // ← async を直接渡す(元の形)
const resp = await generate (t);
history. push ( `user:${ t }` , resp);
});
for ( const t of [ 1 , 2 , 3 ]) setTimeout (() => em. emit ( 'message' , t), t * 5 );
setTimeout (() => console. log ( JSON . stringify (history)), 300 );
手元で流した結果は次のとおりでした。
一段目は、生成が速いターンほど先に履歴へ入るため、順番がそっくり逆になりました。三段目は、追い越されたターンが応答を作らずに畳まれ、最後の発話だけが答えを持ちます——音声エージェントとして望ましいのはこちらです。ユーザーが言い直したのに、古い言いかけへの返事が後から届く、という事故がここで止まります。
イベントハンドラに async を渡した時点で、届いた順は守られません。 順番を守りたい列があるなら、待つ場所は自分で 1 か所こしらえておきます。この一点だけは、他のどんな最適化より先に入れるようにしています。
会話コンテキスト管理と記憶メカニズム
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 ();
}
// 単価は 2026-08-30 時点の公式価格ページ(USD / MTok)。
// 計測側にリテラルを散らすと、世代交代のたびに直す場所を探すことになります。
private static readonly RATES = {
'claude-opus-5' : { input: 5 , output: 25 },
'claude-sonnet-5' : { input: 2 , output: 10 },
'claude-haiku-4-5-20251001' : { input: 1 , output: 5 },
} as const ;
// usage をそのまま渡す。input_tokens にキャッシュ分は含まれないため、
// 読み出し・書き込みを足さないと請求額より小さい数字が出ます。
recordClaudeCall (
model : keyof typeof CostTracker. RATES ,
usage : {
input_tokens ?: number ;
output_tokens ?: number ;
cache_read_input_tokens ?: number ;
cache_creation_input_tokens ?: number ;
}
) : void {
const rate = CostTracker. RATES [model];
const fresh = usage.input_tokens ?? 0 ;
const cacheRead = usage.cache_read_input_tokens ?? 0 ;
const cacheWrite = usage.cache_creation_input_tokens ?? 0 ;
const out = usage.output_tokens ?? 0 ;
// キャッシュ読み出しは入力単価の 0.1 倍、5分書き込みは 1.25 倍
const costUSD =
(fresh * rate.input +
cacheRead * rate.input * 0.1 +
cacheWrite * rate.input * 1.25 +
out * rate.output) / 1_000_000 ;
this .costs. push ({
service: `claude:${ model }` ,
inputUnits: fresh + cacheRead + cacheWrite,
outputUnits: out,
costUSD,
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)です。
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つです。
この4つは 2026年3月時点の単価で出した当時の実績です。以後モデル世代が入れ替わり、単価そのものが動いています。2026年8月30日時点の公式価格ページで引き直すと、比較の形はこう変わります。
出典は Claude Platform の価格ページ です。
Sonnet を Haiku へ落とすことで得られる削減幅は、当時の -91% から入力・出力とも -50% まで縮みました。上位モデル側の単価が下がったためです。削減率が落ちたことを残念に思う必要はありません。一次応答を Sonnet のまま走らせても採算が合う という選択肢が増えた、と読むほうが実態に近いと感じています。私は今、相槌や雑談のターンだけ Haiku 4.5 に落とし、それ以外は Sonnet 5 に寄せる配分に変えました。
特に「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 が「言いかけを引き取る」文脈を正しく汲んでくれるようになりました。
レイテンシは買えるのか — Fast mode を予算表に載せるかどうか
予算表を作って削れるところを削り切ると、最後に「速さそのものを買う」という選択肢が残ります。Claude API には研究プレビューとして Fast mode があり、リクエストに speed: "fast" を添えると Opus 5 / Opus 4.8 の出力生成が目に見えて速くなります。400ms の枠を奪い合っている音声エージェントにとって、真っ先に手が伸びる機能です。
代わりに単価は倍になります。
倍率はコンテキスト全域に適用されます。20万トークンを超える長い入力でも割引にはなりません。プロンプトキャッシュの倍率はこの上に重なるので、Fast mode でのキャッシュ読み出しは入力単価の 0.1 倍、つまり $1 / MTok になります。Batch API とは併用できず、パートナー各社のクラウド経由でも利用できません。Bedrock や Google Cloud に寄せている構成では、そもそも選択肢に入らない点は先に確認しておくべきでした。
1ターンいくらまで払えるのかを先に決める
私の構成で試算してみます。エスカレーション(複雑な質問と判定して上位モデルへ回すターン)は全ターンの 18%、1セッション 12ターンなので約 2.2 ターン。そのターンの入力を 1,500 トークン、出力を 160 トークンと置いた場合の金額です。
私が自分に課している 1セッション $0.015 の線に対して、Fast mode でのエスカレーションはそれだけで 3 倍以上 です。STT も TTS もまだ足していません。個人開発の音声アシスタントでは、この差額を回収する筋書きが書けませんでした。
逆に、1 回の応答が予約成立や商談の前進に直結する用途なら、1ターン $0.023 は十分に安い部類でしょう。分かれ目は速度でも価格でもなく、そのターン 1 回にいくらの価値があるかを先に決めてあるかどうか です。数字の線を持たないまま Fast mode を試すと、速くなった実感だけが残って請求書で驚くことになります。
指定したのに速くならない、しかもエラーが出ない
もう一点、世代ごとの対応差が three-way に割れている点に注意が要ります。
厄介なのは Opus 4.6 の行です。レスポンスは 200 で返り、請求も普段どおり。「Fast mode を入れたのに p95 が動かない」という調査を始めても、アプリのログにも課金明細にも手がかりが残りません。設定を書いたのに何も起きない類の不具合は、原因を疑う場所が思いつかないぶん時間を溶かします。
そこで私は、Fast mode を「対応していないモデルIDを渡すとコンパイルが通らない」形に閉じました。実行時に気づくのでは遅いためです。
// src/services/model-policy.ts
const RATES = {
'claude-opus-5' : { input: 5 , output: 25 , fastInput: 10 , fastOutput: 50 },
'claude-sonnet-5' : { input: 2 , output: 10 },
'claude-haiku-4-5-20251001' : { input: 1 , output: 5 },
} as const ;
type ModelId = keyof typeof RATES ;
// fastInput を持つモデルだけを抽出する。今は 'claude-opus-5' のみに解決される
type FastCapable = {
[ K in ModelId ] : 'fastInput' extends keyof ( typeof RATES )[ K ] ? K : never ;
}[ ModelId ];
export async function escalateFast (
client : Anthropic ,
model : FastCapable , // ← Sonnet や Haiku を渡すと型エラー
messages : Anthropic . MessageParam [],
system : string
) {
return client.messages. create ({
model,
speed: 'fast' ,
max_tokens: 512 ,
system,
messages,
});
}
FastCapable は RATES の形からしか作られないので、単価表を更新した瞬間に呼び出し側の可否も追随します。表とコードが別々に古びていく事故を、型で止められる形にしたかったのです。
さらに、Fast mode を入れた効果が本当に出ているかは計測側でも確かめています。エスカレーションターンの生成レイテンシ中央値を speed の値で分けて記録し、両者が重なっていたら Fast mode が効いていないと判断する、という単純なものです。効いていない設定を「入っているつもり」で抱え続けるより、はるかに気が楽になりました。
設計を実装に落とすときに私が頼った判断軸
ここまで具体的な数値とコードを並べてきましたが、本質的に大事だと思っているのは「自分の生活時間と引き換えに何を守るか」を最初に決めることです。
私は p95 レイテンシ 1500ms と 1セッション $0.015 を「ここを超えたら寝る前に直しに行く」自分との約束ラインにしています。境界を数字で持っていれば、夜中のアラートで起きるかどうかを 30 秒で決められます。逆に境界を決めていないと、すべてのアラートが等しく重く感じられて、判断そのものが消耗します。
音声エージェントは、テキストチャットより一段階「人らしさ」が要求される領域です。速い、安い、落ちない を同時に満たすのは本来難しい題材ですが、レイヤーごとに予算を切って、フォールバックを 3 段持って、ダッシュボードを分けておけば、深夜のオンコールに耐えられる構成になります。
約束ラインを、アラートの重さに変換する
数字を決めただけでは、夜中に鳴る通知の重さはまだ揃いません。私はラインを越えた「度合い」で、起きる・朝に見る・週次で見るの3段に振り分けています。
// 約束ラインを「起きる/朝に見る/週次で見る」の3段に割り当てる判定関数
type Severity = 'page' | 'morning' | 'weekly' | 'ok' ;
interface WindowStat {
p95LatencyMs : number ; // 直近15分の p95
costPerSession : number ; // 直近1時間の平均(USD)
sessions : number ; // 母数。少なすぎる窓は判定しない
}
const LINE = { p95Ms: 1500 , costUsd: 0.015 };
export function classify ( w : WindowStat ) : Severity {
if (w.sessions < 30 ) return 'ok' ; // 少数サンプルで起こさない
if (w.p95LatencyMs > LINE .p95Ms * 1.2 ) return 'page' ; // 体感が明確に悪化している
if (w.costPerSession > LINE .costUsd * 2 ) return 'page' ; // 課金の暴走は一晩で効いてくる
if (w.p95LatencyMs > LINE .p95Ms) return 'morning' ; // 約束ラインを越えた程度なら朝で足りる
if (w.costPerSession > LINE .costUsd) return 'weekly' ;
return 'ok' ;
}
最初のうちは、ラインを越えた瞬間にすべて通知していました。結果は芳しくありませんでした。サンプルが数件しかない深夜帯の p95 が一度跳ねただけで目が覚め、その週は睡眠が削られたまま、肝心の本番障害への集中力が落ちていたのです。
いまは 「越えたかどうか」ではなく「越えた幅と母数」で重さを決める という線引きにしています。sessions < 30 の行は小さな工夫ですが、これ一つで夜の通知がほぼ半分になりました。
次にやってみていただきたいこと
ご自身のサービスで、p95 と1セッションあたりのコストの「約束ライン」を、まず紙に1行ずつ書き出してみてください。そのうえで上の classify に自分の数字を入れ、過去1週間のログに当てはめて、何回「page」になったかを数えてみます。回数が多すぎれば倍率を、少なすぎれば母数の下限を調整するだけで、構成そのものを変えずに運用の負担をかなり軽くできるはずです。