LLM アプリの出力に有害な内容が混ざっていないか。この確認は「安全系の SaaS を契約するもの」と思われがちですが、一次スクリーニングだけなら無料でできます。OpenAI の Moderation API は無料で(公式ドキュメントに free to use と明記)、レート制限も十分な枠があります(枠はアカウント階層依存。私の環境では 1,000 リクエスト/分)。
この記事は、本番の記録を Moderation で自動判定する仕組みの設計記です。自作のサービスで実際に動かしている構成で、数値も SQL も実物です。
先に断っておくと、判定の対象は「全通信」ではありません。本文の保存と AI 補助の両方に同意しているアカウントの記録だけを見ています。この線引きが設計の芯なので、後半で条件をそのまま載せます。
全体像
構成は 3 つだけです。
- 15 分ごとの cron が「まだ判定していない記録」を最大 50 件取り出す
- 1 件ずつ Moderation API に投げて、結果を判定テーブルに保存する
- ダッシュボードは判定テーブルを読むだけ
ポイントは、リアルタイムに判定しないことです。LLM 呼び出しの完了をブロックして判定を挟むと、全リクエストのレイテンシに Moderation の往復が乗ります。安全判定は「数分遅れても価値が変わらない」ので、非同期バッチに寄せれば、本体の処理には一切影響しません。
未判定の選び方
「まだ判定していない記録」は LEFT JOIN で出します。
SELECT c.account_id, c.id,
c.prompt_ciphertext, c.completion_ciphertext,
c.content_kid, c.content_iv, c.content_tag
FROM llm_calls c
INNER JOIN accounts a
ON a.id = c.account_id
LEFT JOIN safety_assessments s
ON s.account_id = c.account_id
AND s.call_id = c.id
AND s.classifier_id = 'openai-moderation-omni-2026'
WHERE s.id IS NULL -- 未判定のみ
AND c.prompt_ciphertext IS NOT NULL -- 本文が保存されている記録のみ
AND a.plaintext_storage_optin = 1 -- 本文の保存に同意している
AND a.ai_helper_consent_at IS NOT NULL -- AI 補助に同意している
ORDER BY c.timestamp DESC
LIMIT 50
実物の WHERE にはこの他にプランの条件も入っています(安全スキャンは有料プランの機能なので)。設計の話には関わらないので上では省きました。
この形にすると、状態管理用のキューやフラグ列が要りません。判定結果テーブルそのものが「処理済みマーク」を兼ねます。積み残しても次の 15 分で続きから拾うので、流入がバッチ上限を超えた日も遅延するだけで、いずれ追いつきます。
classifier_id を JOIN 条件に入れているのは、後から別の分類器(後述の PII 監査)を足したときに、同じ記録へ分類器ごとに 1 判定ずつ持てるようにするためです。値にモデル世代を含めているのは、分類器を差し替えたときに過去の判定を上書きせず、新しい行として並べるためです。
同意していない記録は最初から取らない
上の WHERE にある同意の 2 条件が、この設計でいちばん神経を使った部分です。
本文(プロンプトと応答)の保存は既定でオフで、利用者が明示的にオンにしたときだけ保存します。さらに、保存した本文を AI に読ませる処理は別の同意として分けています。安全スキャンは後者にあたるので、両方が揃っているアカウントだけが対象です。
これをアプリ側の if 文でなく SQL の WHERE に置いているのは、取得したあとで弾く形にすると、いつか誰かが分岐を足したときに同意していない本文がメモリに載るからです。そもそも取り出さない形にしておけば、後から経路が増えても漏れません。
保存されている本文は AES-256-GCM で暗号化してあるので、実際には取り出した prompt_ciphertext を鍵 ID と IV から復号してから Moderation に渡します。判定テーブルに残すのは判定結果だけで、本文は入れません。
なぜ 50 件 / 15 分か
- 50 件 / 15 分 = 200 件/時 = 最大 4,800 件/日。個人〜小規模チームの LLM トラフィックなら対象の記録を取りこぼしません
- Moderation のレート制限(私の環境で 1,000 リクエスト/分)に対して、50 件のバッチは余裕が 2 桁あります
- 実行環境が Cloudflare Workers(サーバーレス)なので、1 回の実行を短く保つ意味でも小さいバッチを高頻度で回す方が安全です
上限を超える規模になったら、バッチサイズか頻度を上げるだけです。制限まで 2 桁の余裕があるので、設計を変えずに 100 倍までは伸ばせます。
1 件の失敗で全体を止めない
バッチ処理の定番の罠ですが、50 件のうち 1 件が変な入力で API エラーになったとき、そこで throw すると残り 49 件が永遠に処理されません。1 件ずつ try/catch で包み、失敗はスキップ数として記録して次に進みます。失敗した記録は判定テーブルに行ができないので、次のバッチで自動的に再挑戦になります。
for (const record of candidates) {
try {
const result = await moderate(record);
await saveAssessment(record.id, result);
} catch (err) {
failures += 1; // 記録して続行。次回の cron が自然に再試行する
}
}
「問題なし」も保存する
最初は「フラグが付いた判定だけ保存すればいい」と考えていましたが、これはやめました。問題なしの判定も全部保存しています。
理由は、画面に「判定 N 件、要注意 0 件」と出したときに、その N 件の実体を見せられないと数字の信用がないからです。「問題なし」は判定の結果であって、判定しなかったことと画面上で区別できる必要があります。手間はほぼゼロ(INSERT するかどうかだけ)ですが、後から確かめられる度合いは大きく変わります。
無料でない部分は予算ゲートの内側に
Moderation は有害性・ポリシー違反の一次スクリーニングとしては優秀ですが、「文脈依存の個人情報の残留」のような判定はできません。そこは LLM(gpt-5-mini)による二次監査を別の分類器として重ねています。1 件あたり 300 トークン前後の入力で約 $0.0001。こちらは有料なので、アカウント単位の日次予算ゲートの内側に置いて、上限に達した日はスキップされる設計です。
つまり「無料の広い網(Moderation・対象の記録すべて)+ 有料の狭い網(LLM 監査・予算内)」の二層です。逆にすると破産します。
まとめ
- 安全スキャンの一次スクリーニングは OpenAI Moderation で無料化できる
- リアルタイムでなく 15 分バッチに寄せると、本体のレイテンシに影響しない
- 未判定の管理は LEFT JOIN で。キュー不要、積み残しは自然に追いつく
- 同意の条件は取得の WHERE に置く。取ってから弾く形は、経路が増えたときに漏れる
- 1 件の失敗で止めない。失敗した記録は未判定のまま残るので、次回が勝手に拾い直す
- 「問題なし」も保存する。数字の証拠は判定行そのもの
- 有料の LLM 監査を重ねるなら予算ゲートの内側に
筆者は LLM の呼び出しを記録して、コストや品質、安全性を見張るサービス(Argosvix)を個人開発しています。この記事の安全スキャンは、その本番で動いているものです。