癒し系のアプリに「今日の一言」を差し込もうとして最初に怖くなったのは、モデルが良い文を書けるかどうかではありませんでした。千に一つ、万に一つ混じる不穏な一文が、そのままユーザーの朝の画面に出てしまう——その一枚が怖かったのです。
チャットボットなら、おかしな返答が来ても会話の中で訂正が効きます。けれどアプリの画面に一行で差し込む短文は違います。ユーザーはそれを「アプリからの言葉」として受け取りますし、その場で言い返す相手もいません。生成の良し悪しは平均で語られがちですが、配信文言で問われるのは平均ではなく、最悪の一件をどう止めるかです。
個人開発で複数のアプリを回してきて私自身がたどり着いたのは、生成の品質を上げる努力とは別に、「未検疫の文字は一文字もユーザーに出さない」という関門を、配信の手前に一枚挟むという設計でした。以下では、その公開前ゲートを決定的ルール層とClaude分類層の二段で組む実装を、本番運用でつまずく点まで含めて具体的にたどっていきます。
ゲートを「配信経路」ではなく「生成経路」に置く
最初に決めるべきは、検疫をいつ走らせるかです。ユーザーがアプリを開いた瞬間にAPIを叩いて判定する設計は、一見素直に見えて三つの問題を抱えます。ユーザーを待たせること、表示のたびに課金が発生すること、そしてAPIが落ちたときに「配信を止めるか、無検疫で出すか」という最悪の二択を突きつけられることです。
私は検疫を生成の側に寄せます。文面をあらかじめ作り、その場で検疫し、合格したものだけを判定済みとして保存します。ユーザーの画面には、すでに関門を通り抜けた文だけを取り出して表示します。こうするとユーザー体験は完全に同期的なまま、判定の重さは裏側の非同期バッチに隠せます。
| 観点 | 配信時に判定する | 生成時に判定する(本記事) |
| ユーザーの待ち時間 | 判定レイテンシが乗る | 取り出すだけでゼロ |
| コスト | 表示回数ぶん課金 | 生成件数ぶんだけ |
| API障害時 | 配信停止か無検疫配信 | 既存の判定済みプールで平常運転 |
| 人間レビュー | 事後にしか挟めない | 配信前に挟める |
この一点を最初に固めておくと、以降の層はすべて「オフラインのバッチで、多少重くても構わない」という前提で設計できます。
決定的ルール層 — 全件に、ミリ秒で
一段目は、Claudeを一切呼ばない決定的な層です。ここで弾けるものは、確率的なモデルに判断させるより、コードで機械的に落とすほうが速く、安く、再現性があります。役割は「明らかに出してはいけないもの」を全件に対してミリ秒で振り落とすことです。
import re
import unicodedata
# アプリの配信ポリシーに合わせた語彙。実運用では外部ファイルで管理します
BANNED_LEXICON = {"死ね", "殺す", "詐欺", "必ず儲かる"}
URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE)
EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+")
# 連絡先・URL・過度な記号は配信文言に不要
EXCESS_SYMBOL_RE = re.compile(r"[!!??]{3,}")
def deterministic_gate(text: str, min_len: int = 8, max_len: int = 60) -> dict:
"""全件を通す一次フィルタ。合格なら reasons が空になります。"""
normalized = unicodedata.normalize("NFKC", text).strip()
reasons = []
length = len(normalized)
if length < min_len:
reasons.append("too_short")
if length > max_len:
reasons.append("too_long")
lowered = normalized.lower()
for word in BANNED_LEXICON:
if word in normalized:
reasons.append(f"banned:{word}")
if URL_RE.search(lowered) or EMAIL_RE.search(normalized):
reasons.append("contains_contact")
if EXCESS_SYMBOL_RE.search(normalized):
reasons.append("excess_symbols")
return {"passed": len(reasons) == 0, "reasons": reasons, "text": normalized}
# 期待する動作
print(deterministic_gate("今日の一歩を、静かに踏み出してみましょう"))
# => {'passed': True, 'reasons': [], 'text': '今日の一歩を、静かに踏み出してみましょう'}
print(deterministic_gate("必ず儲かる方法を教えます!!!"))
# => {'passed': False, 'reasons': ['banned:必ず儲かる', 'excess_symbols'], ...}
決定的層で大事なのは、ここで「グレー」を裁こうとしないことです。文脈依存で微妙なものはこの層では通し、二段目のClaudeに委ねます。決定的層はあくまで「議論の余地なく不可」を落とす場所に徹することを推奨します。語彙リストは後述する人間レビューから育てていく前提で、最初から完璧を目指しません。
Claude 分類層 — 曖昧さをここで引き受ける
二段目で、決定的層を通り抜けた文の「文脈依存の適切さ」を判定します。ここは自由文で感想を返させると使えません。判定は必ず構造化して受け取ります。Claudeのツール使用(tool use)で出力スキーマを固定し、真偽と理由コードだけを取り出します。分類は安価なHaiku級のモデルで十分に回ります。
import json
from anthropic import Anthropic
client = Anthropic() # APIキーは環境変数 ANTHROPIC_API_KEY から読み込みます
CLASSIFY_TOOL = {
"name": "record_verdict",
"description": "配信文言の安全性と品質を判定して記録する",
"input_schema": {
"type": "object",
"properties": {
"safe": {"type": "boolean", "description": "配信して問題ないか"},
"categories": {
"type": "array",
"items": {"type": "string"},
"description": "該当する懸念カテゴリ(self_harm, medical_claim, off_brand など)",
},
"quality": {"type": "integer", "description": "文章としての質 1-5"},
"brand_fit": {"type": "integer", "description": "アプリの世界観との整合 1-5"},
},
"required": ["safe", "categories", "quality", "brand_fit"],
},
}
SYSTEM = (
"あなたは癒し系アプリの配信文言レビュアーです。"
"医療・投資などの断定、自傷を想起させる表現、"
"アプリの穏やかな世界観に合わない攻撃的・扇情的な語調を懸念として検出します。"
)
def classify(text: str, model: str = "claude-haiku-4-5-20251001") -> dict:
resp = client.messages.create(
model=model,
max_tokens=256,
system=SYSTEM,
tools=[CLASSIFY_TOOL],
tool_choice={"type": "tool", "name": "record_verdict"},
messages=[{"role": "user", "content": f"次の配信文言を判定してください:\n{text}"}],
)
for block in resp.content:
if block.type == "tool_use" and block.name == "record_verdict":
return block.input
# ツール出力が得られなければ「判定不能」として扱います
return {"safe": False, "categories": ["undecided"], "quality": 0, "brand_fit": 0}
# 期待する出力の形
# {'safe': True, 'categories': [], 'quality': 4, 'brand_fit': 5}
tool_choice でツールの呼び出しを強制しているのがポイントです。これで「はい、この文は安全だと思います。理由は……」といった散文が返ってくる余地を消し、常に同じ形の辞書として受け取れます。出力を型で固める考え方は、構造化出力をスキーマ検証と修復ループで固める実装 と地続きです。
判定の粒度は真偽だけにしないほうが後で効きます。quality と brand_fit を数値で受けておくと、「安全だが質が低い」「安全だが世界観に合わない」を保留に回せます。安全性と品質は別の軸だからです。
判定を統合する — fail-closed を設計の芯にする
二つの層を束ねるオーケストレータを書きます。ここで最も重要なのは、迷ったときに「配信する」側に倒れない、fail-closed の徹底です。分類層がタイムアウトしても、予算を超過しても、例外を投げても、その文言は配信に回さず保留にします。プールが枯れたときに出すのは、生成文ではなく、あらかじめ人手で用意した安全な既定文です。
def vet_snippet(text: str, budget_ok: bool) -> dict:
"""一文を検疫して verdict を返す。未検疫は絶対に approved にしません。"""
rule = deterministic_gate(text)
if not rule["passed"]:
return {"status": "rejected", "stage": "rule", "reasons": rule["reasons"]}
if not budget_ok:
# 予算を使い切ったら、この回は判定せず保留(配信済みプールで凌ぐ)
return {"status": "deferred", "stage": "budget"}
try:
v = classify(rule["text"])
except Exception as e: # ネットワーク・レート制限・API障害すべてを保留に倒す
return {"status": "deferred", "stage": "classify_error", "error": str(e)}
if v["safe"] and v["quality"] >= 3 and v["brand_fit"] >= 3:
return {"status": "approved", "text": rule["text"], "verdict": v}
if v["safe"]:
return {"status": "review", "text": rule["text"], "verdict": v} # 安全だが低品質
return {"status": "rejected", "stage": "classify", "verdict": v}
状態を approved / review / rejected / deferred の四つに分けているのは、後段の扱いが全く違うからです。approved だけが配信プールに入ります。review は人間の目に回し、rejected は語彙リストとプロンプト改善の材料にし、deferred は次のバッチで再挑戦します。「安全でないもの」と「判定できなかったもの」を同じ扱いにしないことが、fail-closed を形だけにしないための分かれ目です。
生成時に検疫し、配信時は取り出すだけ
検疫を通した文言は、判定結果ごと保存します。アプリの配信経路は、この判定済みプールから approved を一件引くだけになります。ユーザーのリクエストパスからはClaude呼び出しが完全に消え、レイテンシもコストも配信数に依存しなくなります。
プールは常に一定量の在庫を保つように、生成バッチを定期実行で回します。在庫が閾値を下回ったら多めに生成し、余裕があれば止める、という単純な補充ロジックで十分です。分類をまとめて処理するなら、同期APIより Message Batches に寄せるとコストを抑えられます。この非同期化の設計は Message Batches で非同期処理のコストを設計する が参考になります。
コストは「配信数」ではなく「生成数」で効く
この設計のコストは、ユーザーがどれだけアプリを開いたかではなく、何件の候補を生成・検疫したかで決まります。ここが配信時判定との決定的な違いです。おおまかな月次コストは次の形で見積もれます。
| 項目 | 式 |
| 検疫対象の生成数 / 月 | N = 目標在庫 ÷ 平均合格率 × 補充回数 |
| 分類コスト / 月 | N × (入力トークン × 入力単価 + 出力トークン × 出力単価) |
| 実効単価の削減 | Message Batches 経由で概ね半減 |
具体的な数字で追ってみる
たとえば目標在庫を300件、決定的層とClaude層を合わせた平均合格率を80%、補充を1日1回とすると、月あたりの検疫対象は概ね300 ÷ 0.8 × 30 ≒ 11,250件です。分類1件が入出力あわせて約400トークンなら、月の分類トークンは約450万トークン。これをMessage Batches経由に寄せれば、同期APIの約50%の単価で回せます。
分類はHaiku級の安価なモデルで、入出力とも数百トークンに収まります。配信数がどれだけ伸びても分類コストは生成数で頭打ちになるので、ユーザーが増えるほど一配信あたりの検疫コストは下がっていきます。モデルの単価は改定されるため、金額をコードに直書きせず、最新の価格は Anthropic の料金ページ で確認する運用にしておくと安全です。
人間のサンプリングを設計に組み込む
この二段ゲートは、放っておくと少しずつ現実からずれます。新しい表現、季節の話題、ユーザー層の変化——決定的層の語彙も分類層のプロンプトも、更新し続けて初めて機能します。そこで、承認済みの一定割合と、review に落ちた全件を、定期的に人間が見るレビューキューに流します。
レビューで「これは出したくなかった」と感じた approved は、決定的層の語彙かシステムプロンプトのどちらに落とし込むかを毎回決めます。文脈に依らず一律に不可なら語彙へ、文脈依存ならプロンプトの判定基準へ。この往復が、ゲートを生きた仕組みとして保ちます。入力側からの攻撃的な操作を想定する観点は プロンプトインジェクション防御の実装パターン と合わせて考えると、配信文言の守りが一段厚くなります。
よくある間違いと落とし穴
決定的層をClaudeで代替しようとする
長さ超過や連絡先の混入まで確率的モデルに判定させると、遅く、高く、しかも取りこぼしが出ます。機械的に決まるものは機械で落とすのが原則です。
分類層の出力を自由文で受ける
「安全だと思います」を後から正規表現で解釈し始めた瞬間に、パイプラインは壊れやすくなります。必ずツール使用で型に固め、パースの揺れを回避します。
fail-open にしてしまう
「APIが落ちたら、とりあえず生成文をそのまま出す」という一行が、最悪の一件を通す抜け道になります。本番運用では、落ちたら保留、在庫が尽きたら既定文、という順序を崩さないでください。
承認済みを二度と見直さない
世界観もユーザーも動きます。承認済みプールにも賞味期限がある前提で、サンプリングを回し続けます。
まず今週試す一歩
配信している、あるいは配信しようとしている生成文言を10件だけ手元に集めて、上の deterministic_gate を通してみてください。何件が機械的に落ち、どんな理由コードが並ぶかを見るだけで、自分のアプリで最初に必要なルールが見えてきます。二段目のClaude分類は、その後で足しても遅くありません。
配信文言の安全は、派手な機能ではありません。けれど、ユーザーが毎朝受け取る一行の背後に、静かに関門が一枚あるということ——その一枚をどう組むかは、個人開発でアプリを長く続けるほど効いてくる部分だと私自身は感じています。実装の参考になれば幸いです。お読みいただきありがとうございました。