はじめに
LLMでテキスト分類(スパム判定、問い合わせインテント分類、レビューのネガ/ポジ判定など)を本番に組み込むとき、一番怖いのは 「精度99%です」で済まない側 です。
- スパム判定で正当な問い合わせを弾く → 機会損失。クレーム。
- ネガ判定で前向きなレビューを非表示にする → レビューサイトの信頼失墜。
1%の誤判定が、ユーザー1人の1件なら「まあ仕方ない」で済むかもしれません。でも、その1人にとってはその1件が全てです。誤判定を"取り返しのつくもの"にする 設計が、LLM分類を本番で使えるかどうかを分けます。
この記事では、私が FORMLOVA というフォームサービスで営業メール自動検知を本番投入したときに採用した 6つの実装パターン を、具体コードと共にまとめます。スパム分類以外の用途(インテント分類、コンテンツモデレーション、カテゴリ自動付与など)にもそのまま応用できるはずです。
関連記事:
- Zenn: 1回答0.03円で営業メールを分類する -- Next.js after() + OpenRouter の非同期分類パイプライン -- アーキテクチャと実装の詳細
- FORMLOVA 公式ブログ: なぜ営業メール自動検知を作ったのか -- 設計思想の背景
パターン1: 判定手順をステップ化してプロンプトに書く
問題
「営業ならsales、そうでなければlegitimate」のような2値のゴール記述だけだと、グレーゾーンでモデルの挙動が安定しません。何をもって判定しているかが曖昧で、同じような入力でも結果がブレます。
解法
判定手順そのものをプロンプトに書く。モデルに「まずXをして、次にYを判断し、最後にZの基準でラベルを決めろ」という順序を固定します。
## 判定手順
1. まずフォームのタイトル・説明・フィールド構成から、フォームの目的を把握する
2. 次に回答内容がそのフォームの目的に沿っているかを判断する
3. 以下の分類基準に基づいてラベルを決定する
これは Chain-of-Thought を簡易に強制するアプローチで、モデルが暴走しがちなのを防げます。特に Haiku 系・Flash 系などの軽量モデルを使う場合、判定手順を明示したほうが安定度が大きく変わります。
パターン2: 正例・負例をクラスごとに具体的に書き下す
問題
「営業メール = 自社サービスの売り込み」のような抽象的な定義だと、境界線の判定がブレます。
解法
クラスごとに、実際にありがちな文例を箇条書きで列挙する。
### sales(営業・売り込み)
回答者が自社の商品・サービスを売り込んでいる回答:
- 「弊社では〜」「当社の〜」で自社サービスを紹介している
- フォームの目的と無関係な商品・サービスの提案や宣伝
- 「ご提案させてください」「導入実績○社」「無料トライアル」等の営業表現
- SEO対策、広告運用、人材紹介など外部サービスの売り込み
- 回答者が「提供する側」として書いている
ポイント:
- 抽象語(「営業」)ではなく、実際の言い回し(「ご提案させてください」)を書く
-
リストの数を揃える。今回は
legitimate6パターン、sales5パターン、suspiciousは厳格化して別建て - 書き下すこと自体が、自分のドメイン知識の言語化にもなる
パターン3: few-shot examples は"混ざりやすい実例"を選ぶ
問題
few-shot サンプルを3つ入れるのは定番ですが、きれいすぎる例だけを入れると、モデルは境界ケースで迷ったままになります。
解法
実運用で混ざりやすいパターンを例示に選ぶ。正例1つ、明確な負例1つ、そして境界ケース(mix) を1つ入れるのが効果的でした。
## 具体例
回答: 「API連携について教えてください」
→ {"label":"legitimate","score":95,"reason":"サービスへの質問"}
回答: 「弊社はSEO対策の専門会社です。月額5万円から」
→ {"label":"sales","score":98,"reason":"外部SEOサービスの営業"}
回答: 「人材採用でお困りではありませんか?弊社の人材紹介サービスをご検討ください。ただ、御社のサービスにも興味があります」
→ {"label":"suspicious","score":65,"reason":"営業と問い合わせが混在"}
3つ目の suspicious 例が効きます。「営業的表現 + 質問」が混在するケースで、モデルに「こういうのは suspicious に寄せる」と学習させる意図です。
パターン4: 確信度スコア(0-100)を出力させて人間に渡す
問題
ラベルだけ返されると、「モデルがどれくらい自信を持っているか」が分かりません。グレーゾーンの分類結果に対してユーザーが「本当?」と疑う根拠が作れません。
解法
score を必ず付ける。プロンプトで出力形式に score: 0-100 を含めて、「その分類の確信度です」と書き添えます。
export interface ClassificationResult {
label: 'sales' | 'suspicious' | 'legitimate';
score: number; // 0-100
reason: string; // 20文字以内の判定理由
}
UI 側では以下の使い分けができます:
- スコア90以上: 自動処理してOK
- スコア60-90: 視覚的にラベル表示するが、フィルタ上は"確定"扱い
- スコア60未満: UIで目立たせて、ユーザーに確認を促す
スコアはモデルの自己評価なので絶対の指標ではありませんが、ユーザーが違和感を持つための手がかりとして機能します。
パターン5: 手動修正を自動分類で上書きしない(source フラグ)
問題
ユーザーが誤判定を手動で直しても、翌日の再分類バッチで上書きされて元に戻る -- これが起きるとユーザーの信頼が一気に失われます。
解法
ラベルの出所を保持するフラグを持って、自動分類は手動行を絶対に上書きしないロジックにします。
DB スキーマ:
ALTER TABLE responses
ADD COLUMN spam_label text,
ADD COLUMN spam_score smallint,
ADD COLUMN spam_label_source text CHECK (spam_label_source IN ('auto','manual')),
ADD COLUMN spam_classified_at timestamptz;
実装:
// 自動分類の適用
await supabase
.from('responses')
.update({
spam_label: result.label,
spam_score: result.score,
spam_label_source: 'auto',
spam_classified_at: new Date().toISOString(),
})
.eq('id', responseId)
.or('spam_label_source.is.null,spam_label_source.eq.auto'); // manual は対象外
// 手動修正
await supabase
.from('responses')
.update({
spam_label: newLabel,
spam_label_source: 'manual',
})
.eq('id', responseId);
ポイント:
- 自動分類の UPDATE 側で
spam_label_source != 'manual'を条件に入れる。これで手動行は永久に守られる - 手動 UPDATE 側では
spam_label_source = 'manual'に切り替える - 監査ログ(別テーブル)にラベル変更履歴を残すと、チーム運用で誰がいつ何を直したか追える
ユーザーの体感としては「直したら直したまま」。これが本番の信頼を作ります。
パターン6: 「迷ったらX」デフォルトの置き方
問題
LLM は「分からないとき」にランダムに寄ります。false positive が致命的なユースケース(スパム判定、コンテンツモデレーション)で、このランダム性は許容できません。
解法
プロンプトに「迷ったらX」ルールを最後に明示する。X の選び方はユースケースで決まります。
-
スパム判定: 迷ったら
legitimate(正当側)。false positive が致命的なので、確信が持てなければ通す。 -
コンテンツモデレーション: 迷ったら
flag(人間レビューへ)。false negative が致命的なので、確信が持てなければ上げる。 -
インテント分類: 迷ったら
other(その他)。無理に特定カテゴリに割り当てない。
プロンプトでの書き方:
## 重要ルール
- 迷ったら legitimate を選ぶ。正当な問い合わせの誤分類は営業の見逃しより有害
- 「問い合わせフォーム」等のフォームでは、サービスについての質問は当然 legitimate
「なぜそのデフォルトなのか」の理由まで書いておくとモデルが従いやすいです。「正当な問い合わせの誤分類は営業の見逃しより有害」という短い一文が、境界ケースでの判断を安定させます。
番外: 非同期実行でメインフロー(フォーム送信等)を絶対に壊さない
6パターンに入れるか迷いましたが、実装上これがないと本番で使えないので書いておきます。
LLM API はタイムアウト・レート制限・500系・ネットワーク失敗などいくらでも起きます。分類失敗がメインフローを壊してはいけない。
Next.js 16 なら after() で簡単に組めます。
import { after } from 'next/server';
// ... ユーザーへのレスポンスを返す処理(分類は待たない)...
after(async () => {
try {
const { classifyResponse } = await import('@/lib/classification/engine');
const result = await classifyResponse(context);
if (result) {
await saveLabel(responseId, result);
}
} catch (err) {
console.error('classification failed:', err);
// DB には何も書かない。label は null のまま保管。
}
});
- レスポンスを即返す(ユーザー体験に影響なし)
- 例外は絶対に漏らさない
- 分類失敗時は DB に何も書かない。label が NULL のまま保管される
ユーザー側から見ると「ラベルが付いていない回答もたまにある」状態になりますが、ラベルが付いていないこと自体がシグナルなので、フィルタで「未分類のみ」を出せば人間がキャッチアップできます。
まとめ -- 6パターンと、それぞれが解く課題
| # | パターン | 解く課題 |
|---|---|---|
| 1 | 判定手順のステップ化 | グレーゾーンで挙動が安定しない |
| 2 | 正例・負例の具体化 | 抽象定義だと境界判定がブレる |
| 3 | few-shot に境界ケースを入れる | きれいな例だけだと実運用で迷う |
| 4 | スコア出力の併記 | ユーザーが違和感を持つ根拠がない |
| 5 | 手動上書き保護(source フラグ) | 再分類で手動補正が消える |
| 6 | 「迷ったらX」デフォルトの明示 | 失敗の方向を制御できない |
この6つを揃えると、LLM 分類は「精度99%ですって言いたいだけの機能」から「誤判定が出ても運用を壊さず、人間が直せば直ったままの機能」に変わります。
精度を上げることと、誤判定を許容可能にすることは、別の仕事です。本番投入するなら両方やってください。
参考
-
Zenn: 1回答0.03円で営業メールを分類する -- Next.js
after()/ OpenRouter / 失敗時挙動 - FORMLOVA: 営業メール自動検知の使い方 -- 本番UIでの見え方
- FORMLOVA: なぜ営業メール自動検知を作ったのか -- 設計思想
FORMLOVA は MCP クライアント(Claude/ChatGPT/Gemini 等)から操作できるフォームサービスです。この記事のパターンを実装に落として、全プラン無料で営業メール検知を提供しています。



