チャットモデルでテキストを分類しようとすると、だいたい次のような流れになります。プロンプトの最後に「JSONのみで回答してください」と書き、返ってきた文字列をパースし、「はい!カテゴリは以下の通りです:」から始まってしまったときのためにリトライ処理を入れる。これでも動きますが、5つのラベルから1つを選ぶだけにしては周辺コードが多すぎます。
決定モデル(decision model)は別のアプローチを取ります。入力と、受け付ける回答の候補を渡すと、モデルは各候補をスコアリングし、すべての候補について確率を返します。テキストは一切生成しないので、候補にない回答が返ってくることはありません。
この記事では、ローカルで推論を行う Rust 製ライブラリ NobodyWho の Python バインディングを使って、問い合わせチケットの振り分けを実装します。決定モデルは 10 月 29 日のリリース(Python 版 v5.0.0)で追加されました。
筆者は NobodyWho のコントリビューターです(コアチームのメンバーではありません)。
インストールとモデルの読み込み
pip install -U "nobodywho>=5.0.0"
from nobodywho import Decision, Model
model = Model(model_path="hf://ggml-org/Laya-GGUF/Laya-Q8_0.gguf")
decisions = Decision(model=model, n_ctx=1024)
使うモデルは Laya です。0.4B の ModernBERT ベースのモデルで、Q8_0 の GGUF ファイルは 449MB、ライセンスは Apache-2.0 です。推論は同じプロセス内で動くので、サーバーの起動も API キーも不要です。ダウンロード済みのファイルを使う場合は、model_path にローカルのパスを渡します。
3つの質問を一度に投げる
message = "I was charged twice. Please refund the duplicate payment."
questions = {
"department": {
"type": "choice",
"instructions": "Which team should handle this?",
"criteria": {
"billing": "Payments and refunds",
"technical": "Bugs and login problems",
},
},
"refund": {
"type": "noul",
"instructions": "Is a refund requested?",
},
"urgency": {
"type": "score",
"instructions": "How urgent is this?",
"criteria": ["can wait", "soon", "urgent"],
},
}
result = decisions.decide(state={"message": message}, questions=questions)
質問のタイプごとに、返ってくる値の形が異なります。
-
choice:候補から1つを選びます。result["answers"]["department"]["choice"]が選ばれた候補で、["probabilities"]に全候補の確率が入っています。 -
noul:はい/いいえの質問です。result["answers"]["refund"]["noul"]は「はい」の確率(0〜1)です。 -
score:入力を 2〜10 段階の順序付きスケール上に位置づけます。段階は 0 から番号が振られるので、この例で1.6なら「soon」と「urgent」の間です。値は確率の加重平均なので、「urgent」そのものの確率を知りたい場合はprobabilities["2"]を読みます。
また、result["usage"]["output_tokens"] は常に 0 です。
一番確率の高い答えだけでなく、確率そのものを使う
実際のパイプラインで決定モデルが役に立つのはここです。全候補の確率が得られるので、判断に自信がないケースだけを人間に回せます。
dept = result["answers"]["department"]
if dept["confidence"] < 0.6:
send_to_human(message)
else:
route(dept["choice"], message)
send_to_human と route は自分のコードに置き換えてください。0.6 はあくまで例なので、自分のデータで調整します。
ドキュメントにも書かれている注意点として、confidence は確率がどれだけ一つの候補に集中しているかを表す値で、高いからといって答えが正しいとは限りません。この閾値を信用する前に、実際のチケット数百件で確かめておくことをおすすめします。
非同期で使う
import asyncio
from nobodywho import DecisionAsync
async_decisions = DecisionAsync(model=model, n_ctx=1024)
async def classify():
return await async_decisions.decide(state={"message": message}, questions=questions)
result = asyncio.run(classify())
DecisionAsync のコンストラクタ自体は同期的に呼び出します。
知っておきたい制限
- 対応している決定モデルは今のところ Laya だけです。Laya 自身の README によると、型付き判定のベンチマークでの正解率はゼロショットで 0.362、ドメインに合わせてファインチューニングすると 0.766 です。重要な振り分けに使う前に、自分のデータで必ず測ってください。
- 日本語の入力での精度は未検証です。 この記事の例はすべて英語です。日本語のチケットで試した方がいれば、結果をコメントで教えてもらえると助かります。
- 選ぶことしかできません。理由の説明やテキストの抽出が必要なら、生成モデルは引き続き必要です。
- 決定モデルは Python のほか、Swift・Kotlin・Flutter・React Native・Godot のバインディングにも同じリリースで入りました。モバイルアプリ内で同じ判定を動かすこともできます。
ドキュメント:https://docs.nobodywho.ooo/python/decision-models
リポジトリ:https://github.com/nobodywho-ooo/nobodywho