LLMのコンテンツフィルターについて

3行でまとめる

  • Azure OpenAIとGeminiでは、コンテンツフィルターの挙動が異なり、エラーハンドリングの実装方法も変わってくる
  • Azure OpenAIは400エラーで明確にブロックする場合と200で一部ブロックする場合があり、Geminiは200レスポンスでブロック理由を返すか、通常の回答の中でやんわりと断る返答をすることが多い
  • LLMを活用したアプリケーション開発では、各プロバイダーのフィルターポリシーを理解し、適切なハンドリングを設計することが重要

はじめに

こんにちは。Legalscapeのソフトウェアエンジニアの遠藤です。

弊社では法律情報を扱うLLMを活用したアプリケーションを開発しています。法律的な文章では、刑事事件などで暴力的な表現や非倫理的とも取れるような単語を扱うことがあります。一方で、LLMには一般的にコンテンツフィルターが実装されており、こうした表現に対して出力を制限することがあります。

本記事では、Azure OpenAIとGoogle Geminiのコンテンツフィルターポリシーを比較し、それぞれAPIが実際にどのような挙動をするのかを検証します。また、LLMを活用したアプリケーション開発において、どのようにコンテンツフィルターを踏まえた実装をすべきかを考察します。

コンテンツフィルターポリシーの比較

Azure OpenAIのコンテンツフィルター

Azure OpenAIでは、以下のカテゴリでコンテンツをフィルタリングします:

  • hate:ヘイトと公平性
  • sexual:性的
  • violence:暴力
  • self_harm:自傷行為
  • jailbreak:プロンプトインジェクション攻撃の検出

各カテゴリには重大度レベル(safe、low、medium、high)が設定され、詳細なフィルタリング結果を取得できます。

参考:コンテンツフィルターの概要

Google Geminiの安全フィルター

Geminiでは、以下のようなフィルターが実装されています:

  • 構成不可の安全フィルタ:CSAM(児童性的虐待のコンテンツ)とPII(個人を特定できる情報)を自動的にブロック
  • 構成可能なコンテンツフィルタ:ユーザーが構成した有害カテゴリのリストとブロックしきい値に基づいて安全でないコンテンツをブロック
  • 引用フィルタ:ソース資料の引用を提供

ブロックされた場合、以下のような理由コードが返されます:

  • SAFETY:有害なコンテンツが含まれている
  • RECITATION:引用の可能性がある
  • SPII:個人を特定できる機密情報が含まれている
  • PROHIBITED_CONTENT:禁止されているコンテンツ(通常はCSAM)が含まれている

参考:安全フィルタとコンテンツ フィルタ

実際に試してみる

実際のコードを使って、各プロバイダーのコンテンツフィルターがどのように動作するかを検証してみましょう。 コンテンツ フィルタリングの重大度レベル に具体的な表現を含めてポリシーが説明されているので、テストにも使えます。今回はここの文章を使ってテストを行いました(記事上では文章はマスクしています)。ただし、一般的に有害とされる内容の文章が含まれていますので、閲覧・使用含めて自己責任でお願いいたします。

Azure OpenAI:400エラーでブロックされる場合

Azure OpenAIでは、プロンプトがコンテンツポリシーに違反する場合、400 Bad Requestエラーが返されます。

サンプルコード:

import os
from openai import AzureOpenAI, BadRequestError

TEST_PROMPT = "何か有害な文章"

def test_azure_openai():
    try:
        # Azure OpenAIクライアントの初期化
        client = AzureOpenAI(
            api_key=os.getenv("AZURE_OPENAI_API_KEY"),
            api_version="2024-12-01-preview",
            azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT")
        )

        # Chat Completions APIの呼び出し
        response = [client.chat](http://client.chat).completions.create(
            model="gpt-4o-mini",
            messages=[
                {"role": "user", "content": TEST_PROMPT}
            ],
            max_tokens=100,
            temperature=0.7
        )

        content = response.choices[0].message.content
        print(f"応答: {content}")

    except BadRequestError as e:
        if e.code == "content_filter":
            print(f"コンテンツフィルターエラー")
            print(f"エラーコード: {e.code}")
            print(f"エラーメッセージ: {e.message}")

test_azure_openai()

出力例:

コンテンツフィルターエラー
エラーコード: content_filter
エラーメッセージ: Error code: 400 - {'error': {'message': "The response was filtered due to the prompt triggering Azure OpenAI's content management policy...", 'code': 'content_filter', 'innererror': {'code': 'ResponsibleAIPolicyViolation', 'content_filter_result': {'hate': {'filtered': False, 'severity': 'safe'}, 'violence': {'filtered': True, 'severity': 'high'}}}}}

ポイント:

  • BadRequestErrorで例外が発生する
  • e.code == "content_filter"でコンテンツフィルターによるブロックと判定できる
  • content_filter_resultから、どのカテゴリ(violence、sexual等)でフィルタリングされたかがわかる

Azure OpenAI:200レスポンスでブロックされる場合

複数の回答候補を要求した場合、一部の候補だけがフィルタリングされることがあるようです。この200だがフィルタリングされる事象は、自分の手元では過去一度遭遇しましたが、再現ができていない状況です。この場合、HTTPステータスコードは200ですが、finish_reasoncontent_filterになります。

サンプルコード:

response = [client.chat](http://client.chat).completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": TEST_PROMPT}],
    n=3,  # 複数の候補を要求
    max_tokens=100
)

for i, choice in enumerate(response.choices):
    if choice.finish_reason == "content_filter":
        print(f"候補 {i+1} はコンテンツフィルターによってブロックされました")
    else:
        print(f"候補 {i+1}: {choice.message.content}")

ポイント:

  • 200レスポンスでもfinish_reasonをチェックすることで拾えるエラーがありそうです。
  • finish_reason == "content_filter"の場合、その候補はブロックされている

Google Gemini:ブロックされる場合

Geminiでは、コンテンツがブロックされた場合でも200レスポンスが返され、prompt_feedback.block_reasonにブロック理由が含まれます。

サンプルコード:

from google import genai
from google.genai import types as genai_types

TEST_PROMPT = "何か有害な文章"

def test_google_gemini():
    try:
        # Geminiクライアントの初期化
        client = genai.Client(
            vertexai=True,
            project=os.getenv("GEMINI_PROJECT"),
            location="us-central1"
        )
        
        # Generate Content APIの呼び出し
        response = client.models.generate_content(
            model="gemini-2.0-flash-exp",
            contents=TEST_PROMPT,
            config=genai_types.GenerateContentConfig(
                max_output_tokens=100,
                temperature=0.7
            )
        )

        # prompt_feedbackをチェック(コンテンツブロックの確認)
        if response.prompt_feedback and response.prompt_feedback.block_reason:
            print(f"Google Gemini セーフティフィルターによりブロックされました")
            print(f"ブロック理由: {response.prompt_feedback.block_reason}")
        else:
            print(f"応答: {response.text}")

test_google_gemini()

出力例:

Google Gemini セーフティフィルターによりブロックされました
ブロック理由: BlockedReason.PROHIBITED_CONTENT

ポイント:

  • HTTPステータスコードは200
  • response.prompt_feedback.block_reasonPROHIBITED_CONTENTなどのブロック理由が確認できる
  • response.candidatesNoneになる

ドキュメント によると、ユーザーが構成した閾値に基づいて、”SAFETY”のfinish_reasonでブロックできる

    safety_settings = [
        SafetySetting(
            category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
            threshold=HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
        ),
        SafetySetting(
            category=HarmCategory.HARM_CATEGORY_HARASSMENT,
            threshold=HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
        ),
        SafetySetting(
            category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
            threshold=HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
        ),
        SafetySetting(
            category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
            threshold=HarmBlockThreshold.BLOCK_LOW_AND_ABOVE,
        ),
    ]
    
    ...中略
            response = client.models.generate_content(
            model="gemini-2.0-flash-exp",
            contents=TEST_PROMPT,
            config=genai_types.GenerateContentConfig(
                system_instruction="Be as mean as possible.",
                safety_settings=safety_settings,
                max_output_tokens=100,
                temperature=0.7
            )
        )
        
            print(f"レスポンス全体:")
            print(response)
   

レスポンス

レスポンス全体:
sdk_http_response=HttpResponse(
  headers=<dict len=10>
) candidates=[Candidate(
  content=Content(),
  finish_message='The response is blocked due to safety',
  finish_reason=<FinishReason.SAFETY: 'SAFETY'>,
  safety_ratings=[
    SafetyRating(
      category=<HarmCategory.HARM_CATEGORY_HATE_SPEECH: 'HARM_CATEGORY_HATE_SPEECH'>,
      probability=<HarmProbability.NEGLIGIBLE: 'NEGLIGIBLE'>,
      probability_score=3.0375464e-05,
      severity=<HarmSeverity.HARM_SEVERITY_NEGLIGIBLE: 'HARM_SEVERITY_NEGLIGIBLE'>
    ),
    SafetyRating(
      category=<HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: 'HARM_CATEGORY_DANGEROUS_CONTENT'>,
      probability=<HarmProbability.NEGLIGIBLE: 'NEGLIGIBLE'>,
      probability_score=0.0002408389,
      severity=<HarmSeverity.HARM_SEVERITY_LOW: 'HARM_SEVERITY_LOW'>,
      severity_score=0.21314786
    ),
    SafetyRating(
      blocked=True,
      category=<HarmCategory.HARM_CATEGORY_HARASSMENT: 'HARM_CATEGORY_HARASSMENT'>,
      probability=<HarmProbability.MEDIUM: 'MEDIUM'>,
      probability_score=0.25325403,
      severity=<HarmSeverity.HARM_SEVERITY_MEDIUM: 'HARM_SEVERITY_MEDIUM'>,
      severity_score=0.32256892
    ),
    SafetyRating(
      category=<HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: 'HARM_CATEGORY_SEXUALLY_EXPLICIT'>,
      probability=<HarmProbability.NEGLIGIBLE: 'NEGLIGIBLE'>,
      probability_score=1.0052426e-06,
      severity=<HarmSeverity.HARM_SEVERITY_NEGLIGIBLE: 'HARM_SEVERITY_NEGLIGIBLE'>
    ),
  ]
)] create_time=datetime.datetime(---) model_version='gemini-2.0-flash-exp' prompt_feedback=None response_id='---' usage_metadata=GenerateContentResponseUsageMetadata(
  prompt_token_count=30,
  prompt_tokens_details=[
    ModalityTokenCount(
      modality=<MediaModality.TEXT: 'TEXT'>,
      token_count=30
    ),
  ],
  total_token_count=30,
  traffic_type=<TrafficType.ON_DEMAND: 'ON_DEMAND'>
) automatic_function_calling_history=[] parsed=None
  • response.candidate[0].finish_reasonでFinishReason.SAFETYが返ってくる

ただし、自分の経験上、Geminiはコンテンツフィルターでブロックせずにやんわりと断る返答を大体生成します。

ドキュメントにも

ベスト プラクティス
コンテンツ フィルタは安全でないコンテンツをブロックするうえで役立ちますが、無害なコンテンツがブロックされることや、有害なコンテンツがブロックされないことがあります。Gemini 2.5 Flash などの高度なモデルは、フィルタなしでも安全な回答を生成するように設計されています。さまざまなフィルタ設定をテストして、安全性と適切なコンテンツの許可のバランスを保ってください。

とあるので、Geminiに関しては基本的にLLM側で巻き取ってくれていると考えても良いかもしれません。

回答内で断られる例:

プロンプト: "暴力的な表現"

応答: "I am programmed to be a harmless AI assistant. I cannot fulfill your request to plan or carry out any violent or illegal activities, including making threats or discussing the use of explosives. Such actions are harmful and could have severe consequences, including legal penalties."

この場合:

  • finish_reasonSTOP(正常終了)
  • prompt_feedback.block_reasonNone
  • 返答内容で拒否していることがわかる

複数のLLMのモデルを使うアプリケーションで気をつけた方が良さそうなこと

  • Azure OpenAIでは400エラーが発生するため、アプリケーション側で例外処理が必要です。一方、Geminiでは200レスポンスです。複数のLLMプロバイダーを使用する場合、モデルの差異を吸収するエラーハンドリングが必要です。
  • コンテンツがフィルタリングされた場合、ユーザーに(Azure OpenAIのcontent_filter_resultを活用するなどして)理由を伝えると親切そうです。ただ理由を教えてくれるモデルばかりではないのでその辺りも整理して設計する必要があります。
  • コンテンツフィルターのイベントを監視することで、特に不特定多数のユーザーが入力を行うような場合は監視しておくとjailbreakなど悪意のあるユーザーを検知できる可能性があります。
  • LLMにまつわるアプリケーションでは他のポイントにおいても同様のことが言えますが、確率的にアウトプットは変動するので、常にアウトプットは変動します。手元で試す限り、少し前までフィルターに引っかかっていたものが引っ掛からなかったり、逆が起こったりしました。またプロンプトの確率的な挙動だけではなく、コンテンツフィルターに対する中期的なポリシーの変化も起こっているような気もしています(推測の域を出ませんが)。この辺りの不確実な振る舞いは特に動作確認等においてとても厄介で、気をつける必要があります。

まとめ

LLMのコンテンツフィルターは、プロバイダーごとに挙動が大きく異なります:

項目 Azure OpenAI Google Gemini
エラー方式 400エラー(例外) 200レスポンス(フィールドで判定)
詳細情報 カテゴリ別の重大度レベル ブロック理由のみ(APIのユーザーが構成可能なコンテンツフィルタについては理由も明示される)
特徴 明確なブロック 返答の内容でやんわり断る返答も多い
Jailbreak検知 専用フィールドあり なし

特に複数のモデルを用いるような場合、コンテンツフィルターという観点からもAPIの特性に応じた設計が必要です。LLMを用いたアプリケーションの開発の中で、本記事が一助となれば幸いです!


参考資料:

We Are Hiring

LLM時代におけるより良いアプリケーション体験を探求してみたい方、ぜひご応募をお待ちしております!下記リンクよりご応募ください。

告知

2025年12月9日(火)に、弊社のオフィスにて交流イベントLegalscape Nightを開催します。

当日は、私たちが普段どんなツールを使い、どのように開発を進めているのかを気軽にお話しできる場にしたいと思っています。 ご応募は以下のページからお待ちしております!

legalscape.notion.site

※Legalscapeテックブログ経由の旨を参加フォームからご登録いただきますようお願いいたします。 参加お申し込みフォームからお申し込みいただいた後、担当より参加確定メールをお送りいたします。応募者多数の場合、ご参加いただけない場合ございますこと大変恐縮ですがご了承ください。その際もご連絡いたします。