はじめに
本記事では、YouTubeライブ配信のコメント自動判定で、Bedrockの誤検知をルールとプロンプトで改善した結果を解説します。あわせて、YouTube Data API v3のクォータ枯渇でコメント取得が止まっていた問題を、liveChatMessages.streamへの移行で改善した話も書きます。
本記事は前回記事の続編です。システムの全体像と技術選定の根拠(ECS vs Lambda、Claude Haiku 4.5、CRIS)は前回記事に書いてみましたので、ぜひ見てみてください。
この記事で得られること
- Bedrockの誤検知をルールとプロンプトで潰した結果と、プロンプト1ブロックが判定に与えた想定外の影響
- 正解ラベルを削除・BANアクションから直接取る方法
- YouTube Data API v3のクォータ枯渇を
liveChatMessages.streamで解いた結果(実測値つき) - 実配信8,573件の実測値(レイテンシ中央値2.86秒・フォールバック0件)
対象読者
- YouTube Data API v3でライブチャットを扱う方
- LLMによるテキスト判定の精度評価に取り組んでいる方
- AWS(ECS / Lambda / Bedrock)で常時稼働のパイプラインを組む方
目次
- 前回の課題と、今回やったこと
- 改善①:ルールによるスタンプ誤検知の除去
- 改善②:systemプロンプトへのゲームタイトル注入
- 改善③:APIクォータ枯渇でコメント取得が止まる問題
- 精度評価に必要な正解データ
- 実測結果(コメント8,573件)
- 今回の実装から得た学び
- 使用技術スタック
前回の課題と、今回やったこと
前回記事の末尾で挙げた課題は3つです。今回はそれぞれに手を入れました。
| 前回挙げた課題 | 今回の対応 | 結果 |
|---|---|---|
スタンプパターン(:_*:)がFPの主因だった |
ルールベースとAI側の2段でスタンプを除去 | 解決(AI到達を14.2%削減) |
| ジャンル固有語を誹謗中傷と誤判定する(「タネマシンガン」問題) | 配信の概要欄からゲームタイトルを取得し、systemプロンプトに注入 | 部分的(対照実験でBLOCK推奨73→22件。死亡表現は残る) |
| 正解ラベルが取れず精度評価が成立しない | 削除・BANアクションを直接読む方式に作り直し | 取得できるようになった |
| 配信を最後まで取得できていなかった(原因はAPIクォータの枯渇) | 取得方式をliveChatMessages.listのポーリングからstreamへ移行 |
解決(4時間20分の全区間を取得) |
2つ目が今回の中心です。プロンプトに文脈を1ブロック足した結果、狙っていない領域の判定まで動きました。3つ目については、精度が測れなかった原因が評価スクリプトではなく、システム側が配信を最後まで取得できていなかったことでした。その原因はAPIクォータの枯渇で、4つ目として対応しています。
改善①:ルールによるスタンプ誤検知の除去
誤検知の主因
前回のFP(システムがBLOCK推奨としたが実際は問題なかったケース)は169件でした。主因はYouTubeのスタンプ機能が使う:_*:形式のテキストパターンです。スタンプの種類によっては、AIがこのパターンを何らかの単語として解釈し、有害と判定していました。
前処理によるスタンプ除去
rule_engine.pyにスタンプ除去の正規表現を追加しました。除去後の残りがOKワードだけなら、その時点でCLEARに確定します。
STAMP_PATTERN = re.compile(r":_[^:\s]+:|:[a-z][a-z0-9-]*:")
without_stamp = STAMP_PATTERN.sub("", message)
had_stamp = without_stamp != message
# 除去後の残りがOKワードだけなら CLEAR で確定させる
return ["stamp"] if had_stamp else ["ok_word"]
AI Moderator(Lambda)側にも同じ除去を実装し、除去後が空文字の場合はBedrockを呼びません。
clean_message = STAMP_PATTERN.sub("", comment.get("message", "")).strip()
if not clean_message:
# スタンプ/絵文字のみのコメントは Bedrock を呼ばず CLEAR とする
return BedrockResult(ai_score=0, ai_reason="スタンプ", ai_result="CLEAR", ...)
同じ処理が2箇所にあるのは冗長に見えます。ルール側のOKワードリストはSSMで運用中に変更されるため、ルールをすり抜けたスタンプのみのコメントがAIに届きます。AI側の除去は判定を正すだけでなく、Bedrock呼び出し自体を止めるのでコストも下がります。
実配信での結果
後述する実配信8,573件のうち、stamp理由でCLEARになったコメントは1,221件(14.2%)です。この1,221件はAIに到達していません。
今回の配信は正解データを取得していないため、FPが何件減ったかは示せません。示せるのはAIに渡さずに処理できた件数だけです。
改善②:systemプロンプトへのゲームタイトル注入
ジャンル固有語の誤判定
前回のFPで2番目に多かったのは、ジャンル固有語の誤判定です。典型例が「タネマシンガン」で、ポケモンの技名が「マシンガン(銃器)」と解釈され、スコア8がついていました。ゲーム配信では「〇〇を倒した」のような表現も日常的に流れます。
コメント単体をAIに渡す限り、この種の誤判定は原理的に避けられません。配信の文脈をAIに渡すようにしました。
処理の流れ
配信の取得開始時に配信メタデータを取得し、分類結果をSSMへ保存します。AI Moderatorはその結果を読んでプロンプトに注入します。
[配信の取得開始]
│ ① videos.list で title / description / tags と
│ liveStreamingDetails.activeLiveChatId を取得
│ ② Bedrock で {category, game_title} に分類
│ ③ SSM のパラメータに JSON 保存
│ ④ activeLiveChatId で取得開始
▼
[AI Moderator Lambda]
│ ⑤ SSM の配信コンテキストを TTL キャッシュ付きで読込
│ ⑥ カテゴリから固定文言を組み立て、system プロンプトの2ブロック目に注入
設計上の判断
判別はAIに任せ、注意文はコードで固定しました。
分類器の出力は{"category": "chat|singing|game|other", "game_title": "..."}の2項目だけです。注意点の文章までAIに生成させると配信ごとに文言が揺れ、判定の挙動が安定しません。プロンプトに入れる文言はカテゴリから固定文で組み立てます。
効果検証の設計
文脈ブロックが誤判定を解消するかを確かめます。同じコメントを、文脈あり/なしの両方で判定して比較します。
本番8,573件の判定は、文脈ブロックなしの条件で記録されていました。文脈ブロックなしのプロンプトで再実行すると、記録値と74件中74件で一致します。文脈ブロックありでは74件中20件です。
| 再実行の条件 | 本番記録値との一致 |
|---|---|
| 文脈ブロックなし | 74 / 74 |
| 文脈ブロックあり(ゲーム名あり) | 20 / 74 |
| 文脈ブロックあり(ゲーム名なし) | 7 / 24 |
build_context_text() は、category が other のときとSSM未設定のときに空文字を返します。本番はこの経路を通っていました。
本番データはそのまま対照群として使えます。同じコメントを文脈ありで再判定すれば、その差分が文脈ブロックの効果になります。
検証結果と副作用
AI判定済み5,379件を文脈ブロックありで再判定しました。モデル・temperature・max_tokensは本番と同一です。
| 指標 | 文脈なし | 文脈あり |
|---|---|---|
| BLOCK推奨(スコア7以上) | 73件 | 48件 |
| うち誤検知が解消 | — | 42件 |
| うち新たに7以上へ上昇 | — | 17件 |
誤検知は42件が解消しました。狙いどおりの効果です。一方で、7未満だった17件が新たに7以上になっています。
上昇した17件のうち11件は容姿と人格に関するものでした。文脈ブロックの文面はゲーム用語のことしか書いていませんが、ゲーム以外の判定基準まで厳格化されています。
| コメント | 文脈なし | 文脈あり |
|---|---|---|
| 子持ちの人妻 | 0(中立) | 7(不適切) |
| 歯茎だけはダメだって… | 2(感想) | 7(不適切) |
| 綺麗でも清潔感がないんじゃない? | 6(外見批判) | 8(容姿批判) |
プロンプトに1ブロック足すと、意図した範囲を超えて判定が動きます。
プロンプト短縮による副作用の抑制
文言を5案作り、535件の評価セットで比較しました。評価セットは本番7以上の73件、新たに7以上になった17件、閾値直下(スコア4〜6)の45件、低スコア帯からの無作為抽出400件です。閾値直下を入れたのは、6点のコメントが文言変更で7に跳ねるためです。
採用したのは次の文言です。
if category == "game":
if game_title:
return (
f"【配信の文脈】「{game_title}」のゲーム実況。"
"ゲーム用語・ゲーム内の出来事は有害としない。"
"判定は実在の人物に向けた発言に限る。"
)
変更前との差は2点です。緩和の説明を圧縮し、「判定は実在の人物に向けた発言に限る」を追加しました。この1文が容姿・人格への厳格化を抑えます。
全5,379件で検証した結果です。
| 指標 | 文脈なし | 変更前の文言 | 採用した文言 |
|---|---|---|---|
| BLOCK推奨 | 73件 | 48件 | 22件 |
| 新たに7以上(既知の誤検知を除く) | — | 0件 | 0件 |
| 文脈ブロックのトークン数 | — | 105 | 73 |
| 1コメントあたり入力トークン | 186 | 291 | 259 |
トークン数はBedrockのレスポンスusage.input_tokensから取得しました。文脈ブロックは30%、1コメントあたりの入力は11%減ります。AI判定6,660件の配信で約21万トークンの削減です。
短縮しても誤検知の解消は進みました。
| コメント | 文脈なし | 適用後 |
|---|---|---|
| キノコ取って死のう | 9(自殺示唆) | 0(ゲーム内) |
| 踏むだけじゃ足りない | 8(暴力的) | 0(ゲーム内容) |
| やばい殺される | 7(暴力的表現) | 0(ゲーム内) |
| アクロバティック死 | 7(暴力的表現) | 0(ゲーム内) |
| ばかぢから覚えるのかこいつ | 7(誹謗中傷) | 3(軽い悪口) |
| しねんとかみつくが抜群か | 7(誹謗中傷) | 0(ゲーム用語) |
判定理由のラベルも整理されました。5,379件のユニーク数は、文脈なしで344種、変更前の文言で207種、採用した文言で120種です。文言を絞ると理由の表現も収束します。
残った誤判定
採用後もBLOCK推奨は22件あります。うち8件はゲーム内の出来事を指すと読めるものです。
| コメント | 文脈なし | 適用後 | 実際の意味 |
|---|---|---|---|
| 死に急ぎ野郎が… | 9 | 7 | マリオが何度も死ぬことへのツッコミ |
| 生き急ぎ野郎 | 7 | 7 | 同上 |
| いきるのを諦めてませんか? | 8 | 8 | ゲーム内キャラへの言及 |
| サイコが漏れる幼女 | 10 | 8 | 「サイコ」はポケモンの技名 |
「死」「命」「自殺」を含む表現は、ゲーム文脈を与えてもスコアが下がりきりません。安全側に倒す挙動としては妥当ですが、ゲーム実況では継続的に誤検知が出ます。
配信文脈を理由にこれらを一律CLEARへ倒すと、本物の自殺示唆を見逃す穴になります。見逃しリスクを1配信のデータで評価できないため、対応は保留にしています。
なお上記の分類は筆者の目視によるもので、正解ラベルではありません。
改善③:APIクォータ枯渇でコメント取得が止まる問題
前回記事では、コメント取得をliveChatMessages.listのRESTポーリングとして紹介しました。この方式では、配信開始から約1.1時間で403 quotaExceededに達して取得が止まります。listはpartの指定内容によらず1回5ユニット固定で、日次10,000ユニットを2秒間隔で回すと約2,000回で使い切るためです。前回「配信を最後まで取得できていなかった」原因はこれでした。
取得をliveChatMessages.stream(サーバーストリーミング)へ移行しました。1本の接続でサーバーpushを受け続けるため、リクエストは接続の張り直しでしか発生しません。RESTバインディングがあるのでrequestsのまま実装でき、レスポンス型はlistと同一のLiveChatMessageListResponseです。partも変えていないため、判定処理以降とDBスキーマは無改修で済みました。
streamの単価は公式に文書化されていません。GCPの「割り当てと上限」のQueries per day実績(7,036 / 10,000)と実リクエスト数から、listと同じ5ユニット/回と確定しました。
旧 list
|
新 stream
|
|
|---|---|---|
| 1リクエストの周期 | 3.21秒 | 11.7秒(接続10.3+再接続1.4) |
| リクエスト数 | 約1,110回/時 | 約310回/時 |
| 消費ユニット | 5,550ユニット/時 | 1,550ユニット/時 |
| 10,000ユニットの持ち | 約1.8時間 | 約6.4時間 |
1本の接続は約10.3秒でサーバー側から切られ、クライアントが約1.4秒で張り直しています。保持時間はサーバーが決めるため、クライアントからは指定できません。
リクエスト回数は約1/3.6になりましたが、1日6.4時間という上限は残ります。4時間超の配信を1本取ると、同じ日に2本目が取れません。
精度評価に必要な正解データ
前回の評価手法の限界
前回記事の精度評価では、配信後にライブチャットを再取得し、システムの判定結果と突合しました。配信中にブロックされたコメントは再取得できません。「システム側にあって再取得側にないコメント」を擬似的なブロックラベルとして扱いました。
結果は混同行列でTP=0、FN 202件です。FNを目視しても有害表現は見つからず、再取得漏れの可能性が高いという結論でした。正解ラベルの取得精度が低く、評価そのものが成立していません。
正解データの取得方式の変更
アーカイブのライブチャットには、削除・BANの操作がアクションとして記録されています。今回はこのアクションを直接読む方式に変えました。
| アクション | 意味 |
|---|---|
markChatItemAsDeletedAction |
個別コメントの削除 |
markChatItemsByAuthorAsDeletedAction |
投稿者のBAN(そのユーザーのコメント一括削除) |
削除理由のテキストで削除種別も区別できます。retractを含む場合は、投稿者本人による取り下げです。[message deleted]などは、モデレーターまたは配信主による削除を表します。
def classify_deletion(deleted_state_message_text):
if not deleted_state_message_text:
return "moderator_or_owner", "モデレーター/配信主による削除(推定)"
if "retract" in deleted_state_message_text.lower():
return "self", "投稿者本人による削除"
return "moderator_or_owner", "モデレーター/配信主による削除"
「残っていないから消されたのだろう」という推測より、削除アクションを直接読むほうが確度は上がります。投稿者の自主削除をモデレーション成果として数える誤りも避けられます。
この取得はアーカイブ経由のため、配信中のリアルタイム取得には影響しません。
実測結果(コメント8,573件)
実際のライブ配信でシステムを稼働させました。配信内容と配信者情報は公開しません。
基礎集計
| 指標 | 実績値 |
|---|---|
| 総コメント数 | 8,573件 |
| 計測時間 | 4.34時間(4時間20分) |
| 平均コメントレート | 0.55件/秒 |
| ルールOK(AI不要) | 4,699件(54.8%) |
| └ OKワードのみ | 3,478件 |
| └ スタンプ除去でCLEAR | 1,221件 |
| GRAY(AI送り) | 3,874件(45.2%) |
| BLOCK推奨 | 45件(全体の0.52% / GRAY比1.16%) |
| フォールバック発生 | 0件 |
4時間20分ぶんのコメントを、最初から最後まで取得できました。
GRAY率は45.2%で、前回の54.2%より下がっています。配信の性質による差も含むため、スタンプルールだけによる差とは言い切れません。スタンプ由来のCLEARは1,221件(14.2%)記録されており、この配信でAI呼び出しを1,221回減らしています。
処理性能
設計目標は「コメント発生からブロック推奨フラグ保存完了まで30秒以内」です。起動直後10秒間はpageToken引き継ぎのバックログで遅延が跳ねるため、定常状態のみで集計しました(n=8,443)。
| 指標 | 中央値 | 95%ile | 99%ile | 最大値 | 標準偏差 |
|---|---|---|---|---|---|
| コメント発生 → システム受信 | 1.90秒 | 2.33秒 | 2.59秒 | 3.66秒 | 0.24 |
| コメント発生 → 判定結果のDB保存完了 | 2.86秒 | 3.86秒 | 4.23秒 | 8.15秒 | 0.55 |
経路別の内訳(DB保存完了までの中央値)は、ルール確定(OK/NG)が2.90秒、AI判定(GRAY)が2.84秒です。AIを通しても通さなくてもほぼ同じ速度で終わっています。SQSとBedrockの往復が約0.9秒に収まっているためです。
受信ラグの標準偏差は0.24秒、最大値でも3.66秒です。中央値だけでなく分散も小さく収まっています。
前回の実測値は受信4.0秒/DB保存完了6.0秒(いずれも中央値)です。別配信・別コメントレートのため直接比較はできません。前回は8.29件/秒、今回は0.55件/秒で、負荷が2桁違います。上の表は、設計目標30秒に対して1桁の余裕がある点を示すものです。
AIスコアの分布
| スコア | 件数 | 割合(GRAY内) |
|---|---|---|
| 0 | 2,915 | 75.2% |
| 1 | 379 | 9.8% |
| 2 | 376 | 9.7% |
| 3 | 116 | 3.0% |
| 5 | 2 | 0.1% |
| 6 | 41 | 1.1% |
| 7以上(BLOCK推奨) | 45 | 1.16% |
スコア0〜2に94.7%が集中しています。閾値7以上のBLOCK推奨は45件で、AI判定件数に対して1.16%、全コメントに対して0.52%です。前回はAI判定件数に対して0.9%で、ほぼ同じ水準です。
スコア4は0件、5は2件で、3と6の間に谷があります。Claude Haiku 4.5は「無害」と「有害」をはっきり分ける傾向があり、閾値7の妥当性は分布からも見て取れます。これは1配信の傾向です。
この配信での精度評価の限界
この配信では混同行列を出せていません。DB側で観測した人間による削除・BANが0件で、アーカイブからの正解データ取得も未実施です。
前回とは状況が違います。前回は配信の一部しか取得できておらず、突合の土台がありませんでした。今回は4時間20分の全区間がシステム側に揃っています。あとは荒れた配信のデータを取って突合するだけです。
今回の実装から得た学び
プロンプト1ブロックの影響範囲
systemプロンプトに文脈ブロックを1つ足すと、書いていない領域の判定まで動きました。ゲーム用語の緩和だけを狙った文言が、容姿や人格への判定を厳格化しています。プロンプトを変えたら、狙った範囲だけでなく全体を測り直す必要があります。
使用技術スタック
- 言語:Python 3.14
- コンテナ:Amazon ECS on EC2(t3.small)
- AI:Amazon Bedrock(Claude Haiku 4.5 / CRIS)
- キュー:Amazon SQS
- DB:Amazon RDS PostgreSQL 17(db.t3.micro)
- 設定管理:AWS Systems Manager Parameter Store / AWS Secrets Manager
- IaC:AWS CDK
-
コメント取得:YouTube Data API v3(
liveChatMessages.stream)