💬

Zennへのスパム投稿が急増したのでLLMでなんとかした話

2024/08/07に公開2

はじめに

Zennチームの吉川(dyoshikawa)です。

2024年6月頃より、Zennにいわゆるスパム投稿が急増したため、LLM(生成AI)を活用してのスパム投稿自動検出の仕組みを構築しました。

目的の性質上、あまり詳細については開示できないのですが、技術的な知見の共有のため、そして可能な限りコミュニティへ運営チームの取り組みをオープンにしたいという思いがあり本件の概要を紹介したいと思います。

課題

2024年6月頃より、Zennにスパム投稿が急増しました。それに伴いユーザの違反報告が増加したことで我々Zennの運営メンバーも事態を認識することになりました。

スパム投稿が読者の目に触れることが定常化することは避けたいですし、その都度違反報告をしてくださるユーザの負担も大きなものだろうという思いがあり、対策を進めることになりました。

解決策

この状況に対して、ある程度自動でスパム投稿を検出する仕組みを構築する必要があると考えました。そこでLLMを活用することにしました。

筆者には機械学習や自然言語処理のバックグラウンドは一切ありませんが、LLMであれば自然言語でプロンプトを作成することで一定程度の品質で検出の仕組みを作り出すことができます。これがLLMの非常に画期的な点の1つといえるでしょう 👍

ただ、本取り組みはZennのコア価値を形成する開発ではないため、できる限り少ない労力で済ませたいと考えました。また、本来スパム判定されるべきでない内容の記事をスパムとされ、処分されてしまうようなケースも極力0に近づけたいところです。そこで既存の違反報告機能を活用することにしました。

公開されたコンテンツをLLMが巡回し、内容をスパムと判定した場合に違反報告をさせます。そして管理画面より、違反報告と実際のコンテンツを運営メンバーが確認し、(利用規約に照らして)本当にスパム投稿かどうか確認します。人間の目を入れることでLLMによる誤判定のリスクを減少させることができます。

このようにして既存の定常業務をそれほど変えずにスパム投稿への対処を組み込むことができました。

管理画面のキャプチャ
LLM(AI)により起票された違反報告

LLMの選定

Zennは主にGoogle Cloud上に構築されているため、Vertex AIプラットフォームを使うことにしました。各種リソース構築を単一のクラウドサービスに寄せられるなら寄せた方がIaCやIAM権限の管理が楽になりやすいと考えています。

GoogleのLLMといえばGeminiですが、今回はAnthropic Claudeを選定しました。

https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude?hl=ja

選定にあまり強い意図はなく、我々Zennチームの外の弊社生成AI案件ではAmazon Bedrock+Claudeの採用実績があり、こちらもClaudeを選定することで運用やプロンプトのノウハウを社内で共有できる可能性があると考えた程度です(なので今後変えるかもしれません)。

RailsアプリケーションからVertex AIを利用する

ZennのバックエンドはRuby on Railsで稼働しています。今回の目的のために新たに別のプログラミング言語のサーバを建てるほどではないと判断したため、RailsからVertex AIを利用する構成を採りました。

必要なIAM権限

https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude?hl=ja

aiplatform.endpoints.predict をアタッチする必要があります。

リージョン

https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude?hl=ja#anthropic_claude_region_availability

によると、リージョン対応状況は以下です。

モデル リージョン
Claude 3 Sonnet us-central1 (Iowa)
Claude 3 Sonnet asia-southeast1 (Singapore)
Claude 3 Haiku us-central1 (Iowa)
Claude 3 Haiku europe-west4 (Netherlands)

東京リージョン( asia-northeast1 )ではまだ使えないので、 us-central1 などを選択する必要があります。

RubyからVertex AIのClaude APIを叩く

https://console.cloud.google.com/vertex-ai/publishers/anthropic/model-garden/claude-3-haiku

There are Anthropic SDKs available for Python and TypeScript.

とのことなので、Rubyを使う場合はSDKは利用できないと思われます。SDKを介さずにAPIリクエストを組み立てる必要があります。

幸いにも curl でリクエストする例が上記に掲載されています。以下のようなものです。

PROJECT_ID=your-gcp-project-id
MODEL=claude-3-haiku@20240307

# Pick one region:
LOCATION=us-central1
# LOCATION=europe-west4

curl -X POST \
  -H "Authorization: Bearer $(gcloud auth print-access-token)" \
  -H "Content-Type: application/json; charset=utf-8" \
  -d @request.json \
"https://$LOCATION-aiplatform.googleapis.com/v1/projects/$PROJECT_ID/locations/$LOCATION/publishers/anthropic/models/$MODEL:streamRawPredict"

基本的にはこれを見ながらRubyでHTTP(S)リクエストするコードを組み立てることになるのですが、加えて $(gcloud auth print-access-token) のアクセストークンをアプリケーション上でどう取得するかを考える必要があります。

やり方は複数考えられます。本記事ではローカル環境でADC(アプリケーションデフォルトクレデンシャル)を利用する方法を紹介します。

https://cloud.google.com/docs/authentication/provide-credentials-adc?hl=ja

https://cloud.google.com/docs/authentication/application-default-credentials?hl=ja

ローカル環境の場合、クレデンシャルを取得・保存するために gcloud auth application-default login をあらかじめ実行しておく必要があります。

gcloud auth application-default login

その状態でgoogleauth Gemを使い次のようなRubyコードを書くことでアクセストークンを取得できます。

require "googleauth" # googleauth Gemが必要

credentials = Google::Auth.get_application_default
access_token = credentials.fetch_access_token!["access_token"]

これを踏まえて、RubyからVertex AI Claude(Haiku)をコールするミニマムなサンプルコードの全体像は以下になります。

PROJECT_ID = "your-project-id"
LOCATION = "us-central1".freeze
MODEL = "claude-3-haiku@20240307".freeze
TEMPERATURE = 0.0
MAX_TOKENS = 512

# ADC(アプリケーションデフォルトクレデンシャル)を使用してアクセストークン取得する場合の例
# `gcloud auth application-default login` をあらかじめ実行している想定
credentials = Google::Auth.get_application_default
access_token = credentials.fetch_access_token!["access_token"]

uri = URI("https://#{LOCATION}-aiplatform.googleapis.com/v1/projects/#{PROJECT_ID}/locations/#{LOCATION}/publishers/anthropic/models/#{MODEL}:rawPredict")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true

request = Net::HTTP::Post.new(uri.request_uri)
request["Authorization"] = "Bearer #{access_token}"
request["Content-Type"] = "application/json; charset=utf-8"

request.body = {
  anthropic_version: "vertex-2023-10-16",
  messages: [
    {
      role: "user",
      content: [
        {
          type: "text",
          text: "こんにちは"
        }
      ],
    }
  ],
  temperature: TEMPERATURE,
  max_tokens: MAX_TOKENS,
  stream: false,
}.to_json
response = http.request(request)

if response.code.to_i >= 400
  raise "HTTP Request failed with status code: #{response.code} body: #{response.body}"
end

parsed_overall = JSON.parse(response.body)
result = "{#{parsed_overall['content'][0]['text']}"
puts result

429エラーの場合は上限緩和申請が必要

ある程度の分量使用していると、Vertex AI APIから以下のレスポンスボディと共にステータスコード 429 (Too Many Requests)が返却されることがありました。

{
  "error": {
    "code": 429,
    "message": "Resource exhausted. Please try again later.",
    "status": "RESOURCE_EXHAUSTED"
  }
}

https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude?hl=ja

Anthropic Claude の割り当てとサポートされているコンテキストの長さ

によると、デフォルトの制限は以下です。

モデル リージョン デフォルトの割り当て上限 サポートされているコンテキストの長さ
Claude 3 Sonnet us-central1 (Iowa) 60 QPM、50,000 TPM 200,000トークン
Claude 3 Sonnet asia-southeast1 (Singapore) 60 QPM、50,000 TPM 200,000トークン
Claude 3 Haiku us-central1 (Iowa) 60 QPM、50,000 TPM 200,000トークン
Claude 3 Haiku europe-west4 (Netherlands) 60 QPM、50,000 TPM 200,000トークン

QPM、TPMのそれぞれの意味は以下です。

略語 意味
QPM 1分あたりのクエリ数
TPM 1分あたりのトークン数(入力・出力ともに含む)

この制限を超えて使いたい場合は、Google Cloudマネコンより「IAMと管理」→「割り当てとシステム上限」にてClaude利用の増加リクエスト(いわゆる上限緩和申請)を送信する必要があります。

https://cloud.google.com/docs/quotas/quota-adjuster?hl=ja

精度検証と段階的な導入

実際のZenn上の公開コンテンツ(記事、Book、Scrap、コメント)からのリストアップや筆者が考えた架空の投稿を織り交ぜて、

  • スパムでない投稿と判定されることを期待するコンテンツ
  • スパム投稿と判定されることを期待するコンテンツ

を10〜100件程度用意します。

そして、それらに対してLLMにスパム是非を判定させることで簡易的な精度検証とします。

今回は上記を行うrails(rake)コマンドを作成し、モデルやプロンプト、テストデータを変更するたびに簡単に精度検証を再試行できるようにしました。

不測の影響があった場合のインパクトを抑えるために、

  1. まずは期間を絞って記事を対象にリリース
  2. 他コンテンツ(Book・Scrap・コメント)も段階的に対象に
  3. さらに期間を広げる

というように徐々に対象を広げながら導入しました。

おわりに

ZennにおけるLLMを活用したスパム投稿自動検出の仕組みについて紹介しました。

現在は1日あたり数十〜80件ほどのペースでスパムコンテンツを検出しています。本取り組みにより、ユーザーがスパムに関する違反報告をする必要性が大きく減少したと考えています。そしてLLMによる自動検出により運営側も定常業務の効率と効果のアップにもつながりました。また、LLMを実際の本番アプリケーションに組み込む過程で得られた技術的知見もあり、今後の開発にも活用できる場面がありそうです。

今後の展望として、スパム投稿手法も日々進化していくことが想定されるので、プロンプトの最適化やモデル選定の定期的な見直しを通じて仕組みの継続的な改善に取り組んでいきたいと思います。また今回得られた知見から、コンテンツの品質向上におけるサポートやユーザ体験の改善など、Zennの他の側面にもLLMを応用していく可能性を探っていきます。

Zennはユーザと共に成長するプラットフォームであり、今回のスパム対策の仕組みも皆さんからの報告やフィードバックがあったからこそ実現できたと考えています。今後もZennをより良いプラットフォームにしていくため、ユーザ皆さんの声を届けていただけると幸いです。

本記事の内容が同様の課題を抱える他のプラットフォームや開発者の方々にも参考になれば幸いです。

参考

Zenn Tech Blog

Discussion

Ken MatsuiKen Matsui

できる限り少ない労力で済ませたかったとのことですが、Bloom filterやNaive Bayesはご検討されましたでしょうか。もしされた場合は、その上でLLMを選択された理由を伺えたら幸いです。

dyoshikawadyoshikawa

コメントありがとうございます。返信が遅くなりました 🙏

Bloom filterやNaive Bayes

検討していません(そもそも知りませんでした 🙏)。そういった既存の手法に対する知識が全くない状態で本問題に取り組みました。そんな自分でもLLMを活用することで一定程度実用的な仕組みが作ることができたという内容になっております。