#AI#LLM#状態管理#Redis

「前も来てくれてましたね」と言うAI配信者を作る — 記憶とマルチ配信の状態設計


無人で回るAIアバターの配信を作っています。人間の配信者がいなくても、アバターがコメントに反応し、雑談し、締めまで進行する。そういう配信を成立させるために越えなければならない壁のうち、この記事では 「状態」 にまつわる二つの問題を扱います。

一つは、配信をまたいで視聴者を覚えること。「あ、この前も来てくれてましたよね」とアバターが言えるかどうかは、体験としてかなり大きな差になります。

もう一つは、配信中の状態をどこに置くか。レンダラー(映像を作るヘッドレスブラウザ)は落ちます。落ちて再接続するたびにアバターが挨拶をやり直すと、視聴者からは1分おきに自己紹介する配信に見えます。

どちらも突き詰めると 「どの状態を、どの単位で、どこに持つか」 という同じ問いに行き着きました。順に書きます。設計上の判断と、途中で踏んだバグを、率直に残します。

記憶を「誰の」ものにするか

まず視聴者の記憶です。最初に決める必要があったのは、記憶をどの単位で共有するか、でした。

候補 意味 問題
番組ごと 「月曜の雑談配信」で会った人だけ覚える 同じキャラの別番組で初対面に戻る。不自然
配信の実行ごと その回だけ覚える 再訪を認識できない。実装する意味がない
キャラクターごと そのキャラが会った人を覚える 採用

キャラクター単位 にしました。理由は、視聴者が関係を結ぶ相手は「番組」ではなく「そのキャラクター」だからです。同じ子が別の番組をやっていて、そこで会ったら覚えている、というのが自然な感覚に合います。

実装としては、記憶を引くときのスコープをキャラクターの識別子で区切っています。

スコープ = broadcast-char:{キャラクターID}

このスコープ設計が、後で踏むバグの伏線になります。

いつ記憶を抽出するか

会話から「覚えておくべきこと」を抽出する処理は、2段構えにしました。

  • 逐次抽出: 会話のターンごとに、その視聴者についての短い記憶を更新する
  • 最終抽出: その視聴者との会話が終わった(切断した)タイミングで、まとめて整理する

最初は最終抽出だけで作ろうとして、失敗しています。配信は途中で切れます。 レンダラーが落ちる、サーバが再起動する、視聴者が黙って去る。「終わったときにまとめて処理する」設計は、終わりが訪れない場合に何も残りません。

逐次で積んでおいて、終わりが来たら整える。 終わりが来なくても、逐次のぶんは残ります。この「終わりを前提にしない」という感覚は、後述する状態設計でもまったく同じ形で出てきます。

バグ1: 一括抽出すると、記憶が誰のものでもなくなる

最初の実装では、会話ログをまとめて抽出処理に渡していました。すると抽出された記憶に 視聴者の識別子が付かず、キャラクター共通の記憶として保存されてしまいました。

つまり、Aさんの話した内容が、Bさんとの会話でも参照されうる状態です。配信で他人の話を混ぜて話し始めるので、実害があります。

対処は、発話のターンの中で、対象の視聴者として処理を走らせる ことでした。「誰についての記憶か」を、抽出の入力ではなく実行の文脈として持たせています。

記憶のような「誰かに紐づくデータ」は、後から紐づけるのではなく、最初から誰かの文脈で作る。

バグ2: ゲストの識別子にスコープが入っていなかった

これが一番きれいなバグでした。

視聴者はアカウント登録をしていない匿名のゲストです。内部でレコードを作るために、プレースホルダーのメールアドレスを生成していました。

guest+{表示名}@guest.invalid

メールアドレスには 一意制約 が付いています。そして、この文字列に スコープ(どのキャラクターの視聴者か)が入っていません。

何が起きるか。

キャラA の配信に「たろう」さんが来る
  → guest+たろう@guest.invalid が作られる     ✓

キャラB の配信に「たろう」さんが来る
  → guest+たろう@guest.invalid を作ろうとする
  → 一意制約に衝突 → エラー                   ✗

同名の視聴者が別のスコープで現れると壊れます。 表示名は視聴者が自由に付けるものなので、被る前提で設計すべきでした。

しかもこのエラーの出方が分かりにくく、「特定の配信で視聴者の処理だけが失敗する」という形で表面化しました。原因にたどり着くまでに時間がかかっています。

修正は、識別子にスコープを含めるだけです。

guest+{スコープ}+{表示名}@guest.invalid

教訓は、「スコープを持つデータの一意キーには、スコープを含める」。 当たり前に聞こえますが、プレースホルダーやダミー値を作るときは、この当たり前が抜けやすいところでした。本来のデータ(メールアドレス)はグローバルに一意でよく、代用品はそうではない からです。

そして、「同名のユーザーが別のスコープで現れる」は、テストデータでは絶対に起きません。実際の視聴者が来て初めて出ました。一意制約は、現実のデータが来るまで嘘をつく ということでもあります。

再接続のたびに「はじめまして」と言う配信者

視聴者を覚えるところまで来たら、次に目立ってきたのが状態の消失でした。

前提として、AIアバターのライブ配信で、レンダラー(ヘッドレスブラウザ)は落ちます。GPUホストが不安定だったり、ブラウザがクラッシュしたり、映像エンコードのプロセスが死んだり。落ちたら読み込み直して再接続する、という復帰処理は最初から入れていました。

それで起きたのがこれです。

復帰するたびに、アバターが「こんばんは!配信を始めます」と挨拶をやり直す。

視聴者から見ると、1分おきに自己紹介を始める配信です。復帰処理が正しく動いているほど症状が目立つ という、なかなか間の悪い壊れ方でした。

何が悪かったか

原因は明確で、「挨拶したかどうか」をページ側が持っていた からです。

ページが読み込まれる
  → 接続する
  → まだ挨拶していない(変数が初期値なので)
  → 挨拶する

ページのメモリにある状態は、リロードで消えます。当たり前です。にもかかわらず、そこに「この配信で何が起きたか」を置いていました。

同じ理由で、こういう情報も全部消えていました。

  • これまでの会話の流れ
  • どのコメントに応答済みか
  • 番組の残り時間の認識

先ほどの記憶抽出でも「終わりを前提にすると何も残らない」という失敗をしましたが、これはその双子です。今度は「壊れを前提にしていない場所に、壊れたら困るものを置いていた」。

直し方: 状態をサーバ側の外部ストアに置く

方針を変えました。ページには何も持たせない。

[配信の状態]  Redis のスナップショット(配信の実行単位ごとに 1 つ)
     ↑ 更新
[対話サーバ]  発話の生成、状態の更新
     ↓ WebSocket
[ページ]      表示専用。接続したら現在の状態を受け取って描画するだけ

ページは「表示専用クライアント」になりました。接続時にスナップショットを受け取り、そのとおりに画面を作ります。ページ自身は何も覚えていないし、覚える必要もありません。

挨拶の判定も、当然サーバ側に移りました。

接続が来た
  → スナップショットを見る
  → 挨拶済みフラグが立っている → 挨拶しない、続きから
  → 立っていない → 挨拶する、フラグを立てる

これで、ページが何回落ちて何回再接続しても、挨拶は1回だけになりました。

スナップショットに何を入れるか

入れすぎると重くなり、足りないと復帰後に不整合が出ます。入れているのはこのあたりです。

  • 配信の進行状態(挨拶済みか、締めに入ったか)
  • 直近の会話履歴(発話の生成に使う)
  • 応答済みのイベントの識別子
  • 視聴者ごとの短い記憶

最後の「視聴者ごとの短い記憶」が、前半で作ったキャラクター単位の記憶とつながる部分です。記憶は永続ストア側に本体を持ちつつ、配信中に参照するぶんはスナップショットに載せて、復帰後もそのまま「前も来てくれてましたね」を言える状態にしています。

逆に 入れていない のは、映像のフレームや音声そのもの、UIのアニメーション状態です。これらは「復帰後にやり直せばいいもの」なので、状態として持つ意味がありません。

判断基準は「復帰したときに、続きから始めるために必要か」 です。見た目は作り直せばよく、文脈は作り直せません。

サイズが増え続けないことを確かめる

この手の「配信中ずっと更新され続けるデータ」で怖いのは、際限なく増えること です。会話履歴を素朴に全部積むと、長時間配信でメモリを食い潰します。

2時間の連続稼働テストを回して確認しました。

項目 結果
スナップショットのサイズ 6.2KB で頭打ち(増え続けない)
プロセスのメモリ 増加なし
発話ターン数 53 ターン、全部計測

履歴を直近のぶんに限っているので、上限で止まります。「上限があるか」は設計時に決めて、実測で確かめる。 片方だけでは足りません。設計上は上限があっても、実装で別の場所が増えていることがあります。

状態を外に出して、副次的に得たもの

状態を外部ストアに追い出したことで、想定していなかった利点がいくつか出ました。

1. 復帰の検証がしやすくなった

ページを手動でリロードするだけで、復帰の挙動が確認できます。以前はプロセスを落として起動を待つ必要がありました。

2. 途中から見られるようになった

表示専用クライアントは何個繋いでもいいので、配信中の状態を別のブラウザから覗けます。デバッグが劇的に楽になりました。

3. レンダラーを使い捨てにできるようになった

これが一番大きい。ページに状態がないので、GPUホストが不安定なら丸ごと捨てて別のホストで取り直す という乱暴な復旧が成立します。状態がページにあったら、この判断はできませんでした。

プライバシーについて

視聴者の発言を覚えて、次回に言及する。これは体験としては良いものですが、扱いには注意が要ります。

置いている線はこうです。

  • 記憶するのは 配信の公開チャットで本人が書いたこと だけ
  • 保持するのは 短い要約 であって、発言のログそのものではない
  • スコープはキャラクター単位に閉じ、他の用途に流用しない

公開の場での発言とはいえ、覚えられていることが不快な人はいます。少なくとも「何を覚えているか」を説明できる状態にしておくのは、作る側の責任だと思っています。スナップショットに短い要約しか載せていないのは、サイズの都合だけでなく、この線とも一致しています。

まとめ

視聴者の記憶と配信の状態、別々の話に見えて、結局は同じ設計原則に収束しました。

  • 視聴者の記憶は キャラクター単位 で持つのが自然(番組単位でも実行単位でもなく)
  • 抽出は 逐次 + 終了時 の2段。「終わったらまとめて」だけだと、終わりが来ない場合に何も残らない
  • 記憶は 最初から「誰の」文脈で作る。後から紐づけようとすると誰のものでもなくなる
  • スコープを持つデータの一意キーには、スコープを含める。 プレースホルダー値ほど抜けやすい。表示名は被る前提で設計する
  • クライアント(ページ)に状態を置くと、リロードのたびに「初回」からやり直す。復帰処理が正しく動くほど症状が目立つ
  • 状態は外部ストアに置き、ページは表示専用の使い捨てクライアント にする
  • スナップショットに入れる基準は「復帰後、続きから始めるために必要か」。見た目は作り直せる
  • サイズに上限があることを、設計だけでなく実測で確かめる(2時間で6.2KB頭打ち)
  • 状態を外に出すと、検証しやすさ・観測しやすさ・個体を捨てられる自由 がついてくる

通底しているのは、大事な状態を、壊れる場所・終わらない前提の場所に置かない ということでした。復帰処理を頑張る前に置き場所を見直したほうが早いことがあり、記憶を紐づけ直す前に「誰の文脈で作るか」を見直したほうが早いことがあります。どちらも、現実の視聴者が来て初めて表に出たバグから学んだことです。テストデータは、こういう嘘をつきません。

← 記事一覧へ