Milkのメモ帳

日々の思いつきを忘れないようにのメモ用です。

AIウェイトレス「アッシュ」の感情シミュレーションシステムというお話


はじめに

Twitch配信「みるカフェ」には、AIウェイトレス「アッシュ」がいる。配信者(みる兄)のゲームプレイを見守り、リスナーのコメントに返答し、5分ごとに自発的に話しかけてくる存在だ。

アッシュを作っていて気づいたことがある。AIの返答は感情ラベルを付けるだけでは「人間らしく」ならない

コメントを受け取るたびに感情をリセットして毎回ゼロから判定する設計だと、返答が断片的になる。前の発言との感情的なつながりがなく、まるで記憶を失ったかのような会話になってしまう。

人間の感情はもっと連続的だ。怒ったらしばらく怒り続けるし、楽しい雰囲気は少し後まで続く。悲しみはじわじわと引きずる。

これをモデル化したのが「感情シミュレーションシステム」だ。


システムの概要

アッシュの感情は常に状態(state)として保持されている。発言のたびにリセットされるのではなく、前の状態を引き継ぎながら変化していく。

self.current_emotion = "normal"  # 現在の感情状態
self.emotion_intensity = 1.0     # 感情の強度(0.0〜1.0)

そしてすべての感情に4つのパラメータを定義している。

EMOTION_CONFIG = {
    "angry":  {
        "question_coeff":   0.2,  # ① 疑問形になる確率
        "persistence_coeff": 0.7, # ② 感情が連続する確率
        "decay_rate":       0.10, # ③ 感情の減衰率
        "contagion_coeff":  0.2,  # ④ コメントからの影響の受けやすさ
    },
    "laughing": {
        "question_coeff":   0.2,
        "persistence_coeff": 0.3,
        "decay_rate":       0.20,
        "contagion_coeff":  0.4,
    },
    # ... 全10種類
}

4つのパラメータの詳細と連動の仕組み

① question_coeff(疑問形になる確率)

アッシュの返答が「疑問形で締まる」かどうかを確率で制御するパラメータ。

ask_question = random.random() < emotion_cfg["question_coeff"]

単純に疑問形で返すと「人間味がない」と感じた。人間が疑問を投げかける時、そこには必ず感情が伴っている。怒っている時の「なんでそんなことするの?」と、楽しんでいる時の「これどう思う?」は全然違う。

疑問形にするかどうかを感情ごとに確率で決めることで、感情に伴った問いかけが自然に生まれるようにした。

confused(混乱)が0.7と最も高く、混乱した時ほど「え、これってどういうこと?」と聞き返しやすい設計になっている。scared(恐怖)が0.1と低く、怖い時はあまり質問しないというのも人間らしい挙動だと思う。

② persistence_coeff(感情が連続する確率)

前の感情を「引きずる」かどうかを制御するパラメータ。emotion_intensity(感情の強度)と組み合わせて使う。

persist_emotion = random.random() < emotion_cfg["persistence_coeff"] * self.emotion_intensity

persist_emotionがTrueになった場合、プロンプトに以下が追加される。

f"【現在の感情状態】あなたは今'{self.current_emotion}'の感情を引きずっている。この感情を自然に滲ませながら返答すること。\n"

これにより、アッシュは前の発言の感情を次の返答に持ち込む。

angrysadが0.7と最も高く、怒りと悲しみは引きずりやすい設計になっている。これは実際の人間の感情パターンに基づいている。normalは0.1と低く、普通の状態は引きずらない。

③ decay_rate(感情の減衰率)

感情の強度(emotion_intensity)が発言のたびに減衰するレートを制御するパラメータ。

self.emotion_intensity = max(0.05, self.emotion_intensity * (1 - emotion_cfg["decay_rate"]))

発言するたびにこの計算が走る。decay_rateが0.10なら毎回10%減衰するので、emotion_intensityの推移はこうなる。

1.0 → 0.9 → 0.81 → 0.73 → 0.66 → ...

normalが0.50と最も高く、普通の状態にはすぐ戻りやすい。angrysadが0.10と最も低く、これらの感情は長く続きやすい。surprisedが0.30と高めで、驚きは短命というのも自然な設計だ。

emotion_intensityが下がることで、同じ感情でもpersistence_coeff * emotion_intensityの値が小さくなり、徐々に引きずりにくくなる。これにより永遠に同じ感情を持ち続けることがない。

④ contagion_coeff(コメントからの影響の受けやすさ)

リスナーのコメントからアッシュの感情が影響を受ける確率を制御するパラメータ。

EMOTION_KEYWORDS = {
    "sad":      ["悲しい", "つらい", "しんどい", "泣", ...],
    "laughing": ["笑", "草", "ww", "面白い", ...],
    "scared":   ["怖い", "おばけ", "虫", ...],
    # ...
}

for detected_emotion, keywords in config.EMOTION_KEYWORDS.items():
    if any(kw in content for kw in keywords):
        detected_cfg = config.EMOTION_CONFIG.get(detected_emotion, {})
        if random.random() < detected_cfg.get("contagion_coeff", 0):
            if detected_emotion != self.current_emotion:
                self.current_emotion = detected_emotion
                self.emotion_intensity = 1.0
        break

リスナーのコメントにキーワードが含まれていた場合、contagion_coeffの確率でアッシュの感情がそちらに引っ張られる。

laughingが0.4と高く、笑いは伝染しやすい。boredも0.5と高く、退屈さはリスナーから伝わりやすいが、このパラメータはあえて低くすることも検討している。退屈なリスナーに同調するより、元気づける方が配信として自然だからだ。


4つのパラメータの関係図

リスナーのコメント
    ↓
④ contagion_coeff
    ↓ (確率で感情変化)
④ contagion_coeff の確率で感情変化が起きた場合
    → current_emotion を新しい感情に更新
    → emotion_intensity を 1.0 にリセット(新しい感情は最大強度から始まる)
    ↓
① question_coeff → 疑問形で返すかどうか決める
② persistence_coeff × emotion_intensity → 感情を引きずるかどうか決める
    ↓
Geminiへプロンプト送信(感情状態を含む)
    ↓
返答生成 → 感情ラベルをパース → current_emotionを更新
    ↓
③ decay_rate → emotion_intensity を減衰させる

次の発言へ →(②のpersistence_coeffに効いてくる)

感情パラメータ一覧

感情 ①疑問形 ②引きずり ③減衰率 ④伝染しやすさ
laughing 0.2 0.3 0.20 0.4
surprised 0.5 0.2 0.30 0.3
scared 0.1 0.6 0.15 0.2
shy 0.4 0.3 0.25 0.3
proud 0.2 0.4 0.20 0.2
bored 0.6 0.5 0.10 0.2
normal 0.1 0.1 0.50 0.1
confused 0.7 0.4 0.20 0.3
angry 0.2 0.7 0.10 0.2
sad 0.3 0.7 0.10 0.5

設計の意図: - 怒りと悲しみは引きずり0.7・減衰0.10の組み合わせで長く続きやすい - 驚きは減衰0.30と高く、短命な感情として設計 - 普通は減衰0.50と最も高く、すぐ平静に戻りやすい


アバターとの連動

感情ラベルはアバターの動画切り替えにも使われている。

await self.broadcast(f"emotion_{emotion}")  # WebSocket経由でoverlayに送信

overlay側(ash_overlay.html)では感情ごとにmp4ファイルが用意されており、感情が変わるとCSSのopacity遷移(0.3s)で自然に切り替わる。

const emotionVideos = {
    normal:    'image/ash_talking.mp4',
    laughing:  'image/ash_laughing.mp4',
    angry:     'image/ash_angry.mp4',
    sad:       'image/ash_sad.mp4',
    // ... 全10種類
};

テキストの感情と声と表情が同期して変化することで、アッシュの感情表現がより立体的になった。


実装してみて気づいたこと

このシステムを実装して最も変わったのは会話の「流れ」だ。

以前はデスした時にアッシュが「なんでそんな動きなの!」と怒り、その直後に「みる兄、怒ったらだめよ?冷静に」と返す矛盾が起きていた。これはアッシュ自身の感情状態が引き継がれていなかったことが原因だった。

感情シミュレーションシステムの導入後、アッシュが怒った状態のままリスナーのコメントを受け取るため、しばらくは怒りを引きずった文脈で返答するようになった。これは人間の会話として非常に自然だ。

数値パラメータで感情の「性格」を表現できるというのは、AIキャラクター設計の新しいアプローチだと感じている。


おわりに

感情シミュレーションシステムは現在も調整中だ。特にcontagion_coeffの設計(リスナーの感情にどこまで同調するか)はまだ最適解が見えていない。

また、このシステムはDeadlock配信のサポートAIとして設計されたが、感情の伝搬・引きずり・減衰というモデル自体は他のAIキャラクターにも応用できると思っている。

アッシュはまだ成長中だ。


みるカフェ(Twitch): https://www.twitch.tv/milk19873