概要

2026年2月第1週、chatweb.aiに93コミットを投入した。Claude Codeによる並列実装とsubagent活用で、通常なら1ヶ月かかる変更量を1週間に圧縮した。本記事ではその中から技術的に重要な4つの変更を深掘りする。

1. 文レベル並列TTS (Sentence-Level Parallel TTS)

従来のTTSは全テキストを一括で音声合成していた。LLMのストリーミング応答と組み合わせると、全文生成完了まで音声再生が始まらない。これを文単位で並列合成に変更した。

// crates/nanobot-core/src/tts/parallel.rs
use tokio::sync::mpsc;
use futures::stream::FuturesOrdered;

pub async fn stream_tts_parallel(
    sentences: Vec<String>,
    voice: &str,
    tx: mpsc::Sender<Vec<u8>>,
) -> Result<()> {
    let mut futures = FuturesOrdered::new();

    for (i, sentence) in sentences.into_iter().enumerate() {
        let voice = voice.to_string();
        futures.push_back(tokio::spawn(async move {
            let audio = synthesize_sentence(&sentence, &voice).await?;
            Ok::<(usize, Vec<u8>), anyhow::Error>((i, audio))
        }));
    }

    // 順序を保証しつつ、完了したものから送出
    while let Some(result) = futures.next().await {
        let (_, audio) = result??;
        tx.send(audio).await?;
    }
    Ok(())
}

FuturesOrdered がポイントだ。FuturesUnordered だと完了順に返るが、音声は順序が重要なので FuturesOrdered を使う。これにより、最初の文の合成完了時点で再生が始まり、体感レイテンシが平均2.8秒→0.9秒に改善した。

ただし並列度に注意が必要で、10文以上を同時にリクエストするとTTS APIのレート制限に当たる。Semaphore で同時実行数を4に制限している。

2. DynamoDBキー設計の修正

初期設計ではPartition Keyに user_id、Sort Keyに created_at を使っていた。これだとホットパーティション問題が発生する — アクティブユーザーのパーティションに負荷が集中する。

修正後のキー設計:

// crates/nanobot-core/src/db/dynamo.rs

/// conversations テーブル
/// PK: conv#{conversation_id}
/// SK: msg#{timestamp}#{ulid}
///
/// user-conversations GSI
/// PK: user#{user_id}
/// SK: updated#{ISO8601}

#[derive(Debug, Serialize)]
struct ConversationItem {
    #[serde(rename = "PK")]
    pk: String, // "conv#01HQXYZ..."

    #[serde(rename = "SK")]
    sk: String, // "msg#2026-02-10T03:21:00Z#01HQABC..."

    user_id: String,
    role: String,    // "user" | "assistant"
    content: String,
    model: String,

    // GSI keys
    #[serde(rename = "GSI1PK")]
    gsi1pk: String, // "user#usr_abc123"

    #[serde(rename = "GSI1SK")]
    gsi1sk: String, // "updated#2026-02-10T03:21:00Z"
}

conversation_idにULIDを使うことで、パーティションが自然に分散する。ULIDはタイムスタンプを含むため、range queryでの時系列ソートも可能だ。GSIでユーザー単位の会話一覧取得を実現する。

この変更で、ピーク時のDynamoDB throttling errorが完全に消えた。

3. Amazon Connect統合

電話番号からchatweb.aiのAIチャットに接続する機能を追加した。Amazon Connect Contact Flowからの着信をLambdaで受け、WebSocket経由でchatweb.aiのチャットセッションに橋渡しする。

技術的に難しかったのは、Amazon ConnectのStreaming APIとchatweb.aiのSSEストリームの橋渡しだ。Connectは8kHz/16bit PCMで音声を送ってくるが、TTSは24kHz/MP3で返す。リアルタイムでのフォーマット変換が必要だった。

// crates/nanobot-core/src/connect/bridge.rs
use aws_sdk_connect::types::AudioStream;

async fn bridge_audio(
    connect_stream: AudioStream,
    tts_rx: mpsc::Receiver<Vec<u8>>,
) -> Result<()> {
    // Connect → chatweb.ai: PCM 8kHz → Whisper (16kHz upsampled)
    let transcribe_handle = tokio::spawn(async move {
        let upsampled = resample_pcm(connect_stream, 8000, 16000).await;
        whisper_streaming_transcribe(upsampled).await
    });

    // chatweb.ai → Connect: MP3 24kHz → PCM 8kHz
    let playback_handle = tokio::spawn(async move {
        while let Some(mp3_chunk) = tts_rx.recv().await {
            let pcm = mp3_to_pcm(&mp3_chunk, 8000)?;
            connect_stream_sink.send(pcm).await?;
        }
        Ok::<(), anyhow::Error>(())
    });

    tokio::try_join!(transcribe_handle, playback_handle)?;
    Ok(())
}

4. PWA Service Worker と i18n

PWAのService Workerでは、チャット履歴のオフラインキャッシュを実装した。IndexedDBに最新50会話を保持し、オフライン時でも過去の会話を閲覧できる。

i18n対応は45テストケースで検証している。日本語・英語・中国語(簡体/繁体)・韓国語の5言語に対応し、各言語でのUI文字列とTTS voice idのマッピングをテストする。

特に厄介だったのは、日本語の敬語レベルの切り替えだ。ビジネス用途では「です・ます」調、カジュアル用途では「だ・である」調をLLMのsystem promptで制御する。これをi18nの一部として管理するか、別のレイヤーで管理するかで議論した結果、locale_config テーブルに formality_level カラムを追加し、言語設定と敬語レベルを独立に管理する設計にした。

パフォーマンス結果

メトリクスBeforeAfter改善率
TTS初回再生2.8s0.9s-68%
DynamoDB throttle23回/日0回/日-100%
PWAオフライン対応なし50会話--
対応言語25+150%

振り返り

93コミット中、手動で書いたコードは全体の約20%。残り80%はClaude Codeが生成し、人間がレビューした。特にi18nのテストケース生成とDynamoDBのスキーママイグレーションはAI生成との相性が良い。一方で、Amazon Connect統合のようなAWS固有のエッジケースは人間の経験が必要だった。