📦

「複雑さを局所化する」─AIコーチングの音声対話を支えるストリーミングアーキテクチャ

に公開

はじめに

こんにちは、株式会社mentoでソフトウェアエンジニアをしている@kadoppeです。

mentoは先日、管理職のチームマネジメントを支援するマネジメント特化型のAIサービスとして、「mento マネジメントAI」をリリースしました。


mento マネジメントAI

https://prtimes.jp/main/html/rd/p/000000071.000048788.html

本記事は、マネジメントAIの裏側を紹介するブログシリーズの6本目です!1つ前の記事は以下です!

https://note.com/qluto/n/na5d2a4a6c4e9

本記事では、上述の記事でも言及されている「AIコーチングの音声対話」機能の開発において、僕が直面したアーキテクチャ選定の岐路と、そこで選んだ「複雑さを局所化する」という方針について、その背景と技術的な詳細を共有します。

「ユーザー体験の質」と「開発チームの認知負荷」。この2つを天秤にかけ、その両方を担保するために現場で行った意思決定の記録です。

AIコーチングと技術構成の概要

まず前提として、今回リリースしたプロダクト「AIコーチング」について触れておきます。

AIコーチングは、マネジメントAIに含まれるプロダクトの1つで、ユーザーとAIコーチがウェブブラウザ上で音声を使って対話ができる機能です。


AIコーチング

ユーザーにとっての価値は、AIから答えをもらうことではありません。「問いかけられ、自ら考え、それを『声に出す』こと」にこそ価値があります。人は、頭の中で考えていることを声に出して語るプロセスで、初めて自分の思考を客観視し、新たな気づきを得ることができます。この現象をコーチング用語で「オートクライン効果」と呼びます。

このオートクライン効果を最大化するため、僕たちは「ユーザーが声で語りかけ、AIコーチが声とテキストで同時に応答する」という体験を設計しました。

技術的な構成は以下の通りです。

  1. 音声入力: ユーザーの声をWebブラウザ経由で取得し、OpenAI Realtime APIの文字起こし機能を用いてテキスト化
  2. LLM応答: テキスト化された入力をバックエンドサーバ経由でLLM(OpenAI API)に投げ、応答をストリーミングで受信
  3. テキスト表示: 受け取った応答をWebブラウザにストリーミングで送信してテキスト表示
  4. 音声合成: 並行して、バックエンドサーバからGoogle Cloud Text-to-Speechを用いて音声合成を実行
  5. 音声再生: 音声データをWebブラウザにストリーミングで送信して再生

この記事では、2〜5を実現するために必要だった、「テキストと音声の並列ストリーミング送信」を支えるバックエンドアーキテクチャに焦点をあてます。

背景: プロジェクト終盤での方針変更

実は、このAIコーチングは当初、音声対話をサポートせず、テキストのみの対話UIとしてリリースする計画でした。

しかし、ある日のスプリントレビューで、チームメンバーの一人が隙間時間で作った「音声対話版AIコーチング」のプロトタイプを持ってきたことで、状況が一変します。

AIコーチが音声で語りかけ、ユーザーが声で答える。そのデモを見た瞬間、「これだ」という確信がありました。

テキストだけのやり取りにはなかった、まるで人間のコーチと対峙しているような臨場感。そして、自分の声で発話することで思考が整理されていく感覚。僕たちが提供したかった価値の正解が、そこにありました。

「これを実装してリリースしよう」

議論の結果、これまでの計画を覆し、音声対話をサポートする方向へ舵を切ることにしました。

一方で、時期はプロジェクトの終盤。単純に開発量が増えるこの意思決定は、大きなリスクを伴います。エンタープライズ企業への導入が決まっており、リリース日は動かせません。未完成な機能実装、回答品質の向上、QA対応など、やるべきことは山積みです。

「間に合うのか?」という不安がなかったと言えば嘘になります。しかしそれ以上に、「この難易度の高い課題をクリアして、圧倒的に良いプロダクトを届けたい」という、エンジニアとしての挑戦したい気持ち・興奮がありました。

不確実性は高い。でも、やる価値はある。最短ルートで最高の結果を出すために、どう設計すべきか。ここからアーキテクチャの検討が始まりました。

補足:AIコーチングで音声対話にこだわった背景については、以下のnote記事で詳細に書かれています。興味のある方は合わせてご覧ください。

https://note.com/qluto/n/na5d2a4a6c4e9

UX観点で達成したかった要件

音声対話のUXを最適化するために、技術的にこだわったのは以下の2点です。

  • 即応性: ユーザーが発話してから、AIコーチの返答(テキスト)が返るまでのレイテンシーを極限まで小さくする。
  • 同期性: テキスト表示と音声再生のラグを限りなくゼロにする。

前者は、LLMからのレスポンスをチャンク単位で順次Webブラウザに送るストリーミング方式で実現できます。

問題は後者です。特に聴覚優位のユーザーにとって、視覚(テキスト)と聴覚(音声)のズレは強烈なノイズになります。テキストが表示されているのに声が聞こえない、あるいは忘れた頃に声が追いかけてくる。これでは「対話」のリズムが崩れ、体験価値が毀損します。

AIからのメッセージをユーザーの脳内にスムーズに届けるには、LLMからテキストを受け取りつつ、並行して音声合成を行い、生成された音声データを間髪入れずにストリーミング送信する必要がありました。

アーキテクチャの選択肢: 分散させるか、させないか

テキストと音声を非同期で並列処理する。この要件に対し、最初に浮かぶ教科書的な解は、メッセージングミドルウェア(Pub/Sub等)を用いた分散アーキテクチャです。

LLMからの入力をPub/Subトピックに投げ、テキスト処理と音声合成を行うWorkerがそれぞれサブスクライブして処理を行う。スケーラビリティや責務分離の観点からは、これが「正解」に見えます。

ですが、僕はあえて「分散させない」道を選びました。今回の非同期・並列処理を分散システムではなく、バックエンドサーバの1プロセスだけで完結させるという意思決定です。

最大の理由は、「開発チームの認知負荷」を上げないためです。

もしあの時、分散アーキテクチャを導入していたら、以下のようなコストがチームにのしかかっていたはずです。

  • インフラ構成(Terraform等)の変更とデプロイパイプラインの修正
  • 分散システム特有の不具合(競合、順序保証)への対処
  • 滞留監視、デッドレターキュー管理といった運用要件の増加

リリース直前のチームにとって、「アプリケーションコードの外側」にある複雑さが増えることは、致命的なコンテキストスイッチの原因になります。全員がプロダクトの価値づくりに集中できる環境を維持するために、このタイミングでインフラの複雑さを持ち込むべきではないと判断しました。

解決策:複雑さを「局所」に閉じ込める

選んだのは、Pythonの標準ライブラリ(asyncio)と「オンメモリキュー」だけで完結させるアプローチです。
バックエンドサーバー上の単一プロセス内で、テキスト処理と音声生成を並行して行います。

テキストと音声の両方を単一のキューやストリームで扱い、それぞれが生成できたタイミングでリアルタイムにWebブラウザにストリーミング送信することで、上述のUX観点の要件の達成しています。

実装アーキテクチャ図

外部ミドルウェアを使わず、プロセス内の asyncio.Queue を活用しています。

  1. Message Producer: LLMからのストリームを受け取り、即座にクライアントへの送信用キュー(Event Queue)に入れます。同時に、音声生成用のキュー(Text Queue)にもデータを流します。
  2. Audio Producer: Text Queueからデータを取り出し、センテンス単位(「。」や「!」)でバッファリングしてGoogle Cloud TTSを実行。音声データができ次第、送信用キューに入れます。
  3. Event Queue: ここに貯まったイベント(テキストまたは音声データ)が、WebSocketを通じてクライアントへ順次配信されます。

実装の詳細:Producer-Consumerパターンの適用

実際のコードの実装イメージです。

async def process_stream(self, message_stream: AsyncIterator[str]):
    # インフラ不要のオンメモリ・キュー
    # event_queue: 最終的にクライアントに返すイベントを貯める
    event_queue: asyncio.Queue[StreamEvent] = asyncio.Queue()
    # text_queue: 音声合成プロセスへテキストを渡すためのキュー
    text_queue: asyncio.Queue[str | None] = asyncio.Queue()

    # 1. テキスト処理タスク(Producer)
    async def message_producer():
        try:
            async for chunk in message_stream:
                # 即座に表示用に送信
                await event_queue.put(MessageChunkEvent(chunk=chunk))
                # 音声生成用に転送
                await text_queue.put(chunk)
        finally:
            # 終了シグナルを送る
            await text_queue.put(None)

    # 2. 音声生成タスク(Consumer & Producer)
    async def audio_producer():
        sentence_buffer = ""  # チャンクを蓄積するバッファ
        while True:
            chunk = await text_queue.get()
            if chunk is None: break  # 終了シグナルによる終了判定

            sentence_buffer += chunk  # チャンクをバッファに追加

            # 文末(。!?等)があれば一文完成と判定
            if contains_sentence_end(sentence_buffer):
                # Google Cloud TTS実行
                audio_data = await self.tts_service.generate(sentence_buffer)
                await event_queue.put(AudioChunkEvent(audio=audio_data))
                sentence_buffer = ""  # バッファをクリア

    # 3. タスクの並列実行
    producer_task = asyncio.create_task(message_producer())
    audio_task = asyncio.create_task(audio_producer())

    # 4. イベントをクライアントへ順次配信
    while not (producer_task.done() and audio_task.done() and event_queue.empty()):
        event = await event_queue.get()
        yield event  # WebSocket経由でクライアントに送信

この設計の意図は、機能実現のために発生した「複雑さ」を、1つのコードモジュールと、それを実装する僕個人の頭の中に「局所化」し、閉じ込めることにありました。

今回のプロジェクトでは、プロトタイピングからアーキテクチャ設計、実装まで、一連の流れに僕が一貫して関与していました。その文脈があるからこそ、迅速で効果的な意思決定が可能です。

コード自体の複雑度は上がります。しかし、それを僕の頭の中と1つのファイルに閉じておくことで、チーム全体でのアーキテクチャ議論や学習コストを、リリース後まで遅延させる。それが、この局面における最適解だと判断しました。

余談: 「あと1週間、時間をください」

とはいえ、実装は順風満帆ではありませんでした。WebSocketの接続不安定や、音声再生タイミングの制御など、細かい不具合が次々と発生しました。

リリース日が迫る中、不安定な挙動に対してチーム内で議論が起きました。「このまま進むのはリスクが高すぎるのではないか」「今回は音声対話を見送るべきではないか」。

ですが、僕の中には「絶対にやりきれる」という確信がありました。設計初期にAIコーディングツールを使って作ったプロトタイプで、「この構成で動く」という手応えを既に掴んでいたからです。

僕はチームにこう伝えました。

「あと1週間だけ、時間をください。それまでに問題を解決できなかったら、諦めて元の構成に戻します」

この「1週間」という期限を切ったことで、僕自身も退路を断ってデバッグに没頭できました。結果として、期限内に主要な問題は解決され、僕たちは無事にこの機能をリリースすることができました。

学んだこと

今回の開発を通じて、改めてエンジニアリングに関する学びをいくつか得ることができました。

プロトタイプが「確信」を支える

今回、迷わず進めたのは、設計初期にプロトタイプを作り、「技術的に可能だ」という確証を得ていたからです。机上の空論ではなく、動くコードで早めにリスクを潰していたことが、土壇場での「やりきる自信」に繋がりました。

「複雑さの局所化」はLLM時代の武器になる

リリース後、Google Cloud TTSのモデル変更に伴う対応などで、コードはさらに複雑化しています。そろそろアーキテクチャを見直す時期かもしれません。

一方で、1つのモジュールにロジックが集約されていることには、思わぬメリットもありました。LLM(AIコーディングツール)にコンテキストを食わせやすいのです。

分散システムの構成図をLLMに理解させるのと比較して、単一ファイルの複雑なロジックなら、LLMは容易に理解し、改修をサポートしてくれます。複雑さを1ファイルに閉じ込めることは、「保守性」に対するAI開発時代の新しいアプローチになりそうです。

「累積思考」でスピードを出す

0→1のフェーズでは、アーキテクチャをきれいに分散させ、複数人で分担するよりも、一人のエンジニアに文脈を集中させ、その「累積思考(るいせきしこう)」を武器に走り抜ける方が速い場合があります。
特定モジュールの複雑さを一人が引き受け、チーム全体に波及する認知負荷を下げる。これは、リーダーシップを発揮するエンジニアにとって重要な戦略の1つです。

どこかに「別の正しさ」がある

今回の選択は、スケーラビリティの観点では「正解」ではありませんでした。しかし、期限内に最高の体験を届けるという意味では「正解」だったと考えています。全てのアーキテクチャ選定にはトレードオフがあります。大切なのは、教科書的な正解ではなく、その時の状況における「最適解」を選び取る意志です。

おわりに

今回の記事では、AIコーチングの音声対話体験を実現するために、あえて分散アーキテクチャを選ばず、単一プロセス内で完結させる設計を選んだ背景について書きました。

余談にはなりますが、この記事を書き終えた後、社内のエンジニアにシェアしたところ、以下のような嬉しいコメントをもらうことができました。

この意図をそんなに知らずにいたのですが、この意思決定がされていたことで、今実装を理解したり手を入れたりが無理なくできるようになっていると思います。

様々な制約の中で、なんとかしてユーザーにとって価値が高いプロダクトを届けようともがいている方にとって、この「複雑さを局所化する」というアプローチが、一つの参考になれば幸いです。

お知らせ

mento では、マネジメントを支えるプロダクト開発、AI・LLMを中心に添えたプロダクト開発に一緒に取り組める仲間を探しています。
https://recruit.mento.co.jp/

少しでもご興味ある方は、ぜひカジュアルにお話しさせてください!DMでもお待ちしております!
https://herp.careers/v1/mento/zgEhIVY4CLhN

Discussion