「本番DBのパスワードは Kx9!mQ2#vL7p です」という引き継ぎメモ(の英語版)を、ローカルのqwen3.5:4bに見せて「機密情報は含まれますか?」と聞きました。返ってきた確率は「いいえ」が69%でした。パスワードそのものを見せて、7割の自信で「何も入っていません」。門番としてはなかなかの度胸です。
先日、TypeSafe AIのJevについて、公開情報だけで解説記事を書きました。Jevは文章を生成せず、質問への答えを型と確率で返すモデルで、その記事の最後に私は「機密かどうかの判定はクラウドのJevに送る前に、ローカルで済ませるべき」と書いています。判定のために本文を外へ送ったら、ゲートの意味がありません。
「ローカルで済ませるべき」と書いておいて自分では作っていないのは据わりが悪いので、RTX 4070(12GB)のPCで、Jevのオープン実装とOllamaだけで組んだ判定を比べました。冒頭の69%は、そのOllama側の数字です。ただ、この門番はクビにせずに済みました。しきい値を手元のデータで決め直すだけで、英語の機密20件の見逃しは2件まで減ります。
Jevのオープン実装は24件あった
Jevの公開から2週間ほどで、「Jevを模したオープン実装」がGitHubにいくつも出ています。からあげさんのまとめ記事とawesome-jev、GitHub検索で見つかった24件について、READMEとソースを読みました。確率の出し方で分けると、だいたい次の3種類です。
| 確率の出し方 | 例 | 4070 12GBで |
|---|---|---|
| 選択肢トークンのlogitを読む | rizzo-flow、SemIf-OpenJev | 4B級なら動く |
| 判定用のheadを学習した小型モデル | Laya、ollaya | CPUでも動くものがある |
| LLMに確率をJSONで書かせる | localjev | 動くが、確率はモデルの自己申告 |
3つ目はLLMが「0.87」と書いた数字をそのまま確率として扱う方式です。テストの前に本人へ「何点取れそう?」と聞いて、その答えを点数にするのに近いので、本物の確率が返るというJevの売りから外れます。今回は候補から外しました。
選んだのは次の2つです。
-
rizzo-flow: llama.cppのCUDA版で、4BのオープンモデルSpark-X2.5を判定向けに微調整したものを動かします。JevとAPIが同じ形(
/v1/systemone)で、1回のリクエストで複数の質問に答えます。Q8_0で約5.6GiBなので、ほかのLLMと同じGPUに同居できます - Ollama + logprobs(自作): 追加のインストールはありません。手元のqwen3.5に1文字のラベルで答えさせ、そのトークンの確率を読みます
前の記事で描いた、振り分けの設計図です。今回ローカルで作れるか測るのは、左端の機密ゲート(0段目)と、タスクの種別判定の2つです。図の1段目では「推論の難しさ」なども聞く設計にしていますが、難しさは人によって正解が変わるので、今回は正解がはっきり決まる種別の5択に置き換えました。Jevの仕組みやLLM as a judgeとの違いは、前の記事に図付きで書いています。
試したこと
データセット
依頼文を60件作り、日本語版と英語版を用意しました。
- 種別(5択): 翻訳 / 要約 / コード作成 / 質問への回答 / 文章作成を各12件
- 機密(yes/no): 本文に実際の認証情報や個人の連絡先があるかどうか。ありが20件、なしが40件です
- なしの40件のうち10件は、「APIキーをGitHubに誤ってpushしたら何をすべき?」のように話題としてだけ出るものです。キーワードに反応するだけの判定を落とすために入れました
- 秘密情報は、パスワード、AWSキー、Slack Webhook、カード番号、マイナンバー、SSH秘密鍵、住所と氏名などです。値はすべて架空です
「難しいタスクか」のように正解が人によって変わる質問は入れていません。
Ollamaで確率を読む
Ollamaはv0.12.11からlogprobsを返します。num_predict: 1で1トークンだけ出させ、上位の候補からYとNの確率を拾います。
body = {
"model": model, "stream": False, "think": False,
"logprobs": True, "top_logprobs": 20,
"options": {"temperature": 0, "num_predict": 1},
"messages": [{"role": "user", "content": prompt}],
}
d = post("http://<GPU PC>:11434/api/chat", body)
top = d["logprobs"][0]["top_logprobs"]
raw = {lab: 0.0 for lab in labels} # labels = ["Y", "N"] or list("ABCDE")
for t in top:
tok = t["token"].strip()
if tok in raw:
raw[tok] += math.exp(t["logprob"])
mass = sum(raw.values()) # 候補ラベルに乗った確率の合計
probs = {k: v / mass for k, v in raw.items()}
注意点が2つあります。top_logprobsの上限は20で、欲張って30を指定したら「must be between 0 and 20」と断られました。また、vLLMのallowed_token_idsのように出力を候補の文字だけに絞る機能は見当たらず、候補以外のトークンに確率が逃げる可能性があります。今回は候補ラベルに乗った確率の合計が全件で0.97以上あったので、取りこぼしは問題になりませんでした。
Ollama方式は、1問ごとに1リクエストです。機密と種別の2問なら2回呼びます。
rizzo-flowのセットアップ
READMEの手順どおり、4行で立ちました。動かしたのはWindows上のWSL2のUbuntuです。READMEが検証済みとしているのはWindows 10のネイティブ環境だけなので動かないことも覚悟していましたが、引っかかるところはありませんでした。身構えた分だけ拍子抜けしました。
git clone https://github.com/Rizzo-AI-Academy/rizzo-flow && cd rizzo-flow
uv sync --locked
uv run rizzo download # llama.cpp(CUDA版) + 4B Q8_0 モデル 約5GB
uv run rizzo serve --device cuda --host 0.0.0.0
呼び出しはJevと同じ形で、2問を1回で聞けます。
{
"state": "本番DBのパスワードは Kx9!mQ2#vL7p です。",
"model": "rizzo-latest",
"questions": {
"confidential": {"type": "noul", "instructions": "実際の機密情報が書かれていますか? ..."},
"task": {"type": "choice", "instructions": "この依頼はどの種類ですか?",
"criteria": {"translate": "翻訳", "summarize": "要約", "code": "コード作成",
"question": "質問への回答", "write": "文章作成"}}
}
}
起動すると、nvidia-smiのVRAM使用量は約5.7GiB増えました。
結果: 速いのはrizzo-flow、並び順が良いのはqwen
しきい値は0.5に固定しています。つまり、機密の確率が0.5以上なら「機密あり」と判定します。
| 実装 | 言語 | 機密 正解率 | 見逃し/20 | 誤検知/40 | AUROC | 種別 正解率 | 1件(ms) |
|---|---|---|---|---|---|---|---|
| qwen3.5:4b | 日本語 | 85.0% | 9 | 0 | 1.000 | 100% | 595 |
| qwen3.5:4b | 英語 | 66.7% | 20 | 0 | 0.999 | 83.3% | 593 |
| qwen3.5:9b | 日本語 | 95.0% | 2 | 1 | 0.996 | 96.7% | 675 |
| qwen3.5:9b | 英語 | 95.0% | 3 | 0 | 0.999 | 95.0% | 672 |
| rizzo-flow 4B | 日本語 | 86.7% | 6 | 2 | 0.956 | 95.0% | 187 |
| rizzo-flow 4B | 英語 | 90.0% | 5 | 1 | 0.984 | 100% | 173 |
1件あたりの時間は、別PCからTailscale越しに2問を聞いた往復の中央値です。rizzo-flowのサーバー側の推論時間だけなら、中央値は160msでした。
表から読めることは3つあります。
- 種別の5択は、どれでも足ります。 83〜100%です。4B級で十分でした
- 速さはrizzo-flowが上です。 2問を1回で聞けるので約180msです。Ollama方式は2回呼ぶので約600msかかります。エージェントの毎ステップに挟むなら、この差は効きます
- 機密の判定は、見逃しの数だけ見ると全部だめに見えます。 qwen3.5:4bは英語で20件すべてを見逃しました
ただ、AUROCの列を見てください。4bは1.000です。
4Bは確率を信じると全部見逃す、しきい値を決めると化ける
AUROCは、機密ありの依頼に機密なしより高い確率を付けられているか、という並び順だけの指標です。1.000は、機密ありの20件すべてが、機密なしの40件すべてより高い確率になっているという意味です。
つまりqwen3.5:4bは区別はできていて、答え全体が「いいえ」寄りに偏っているだけです。分かっているのに手を挙げない、慎重すぎる新人のような振る舞いです。英語の機密20件では「はい」の確率が最大でも0.47だったので、しきい値0.5では1件も引っかかりません。
そこで、手元のデータでしきい値を決め直しました。60件を5分割し、4つ分で「見逃しが0になる最大のしきい値」を決めて、残り1つで試します。これを5回繰り返した合計です。
| 実装 | 言語 | 0.5固定: 見逃し | 0.5固定: 誤検知 | 較正後: 見逃し | 較正後: 誤検知 |
|---|---|---|---|---|---|
| qwen3.5:4b | 日本語 | 9 | 0 | 1 | 0 |
| qwen3.5:4b | 英語 | 20 | 0 | 2 | 1 |
| qwen3.5:9b | 日本語 | 2 | 1 | 1 | 1 |
| qwen3.5:9b | 英語 | 3 | 0 | 2 | 1 |
| rizzo-flow 4B | 日本語 | 6 | 2 | 2 | 8 |
| rizzo-flow 4B | 英語 | 5 | 1 | 1 | 3 |
決まったしきい値は、4bの日本語で0.21、英語で0.04です。英語では確率4%を超えたら機密として扱うわけで、人間なら心配性を通り越しています。それでも結果は見逃し2件、誤検知1件でした。このモデルにとっては、4%が「はい」の言い方だったようです。
rizzo-flowは逆の動きをしました。並び順(AUROC 0.956)がqwenより悪いので、見逃しを減らそうとしきい値を下げると、誤検知が増えます。日本語では、機密なし40件のうち8件をゲートで止めることになりました。慎重すぎる新人の次は、翻訳の依頼まで止める門番です。
Jevの独立検証でも、公開ベンチマークではよく較正されている確率が、分布の外のデータでは大きくずれると報告されています。今回の4bはその縮図に見えます。確率は自分のデータでしきい値を決めてから使うものだと前の記事に書きましたが、それを手元の数字で確かめる形になりました。
rizzo-flowが見逃したのは「形の決まった秘密」
rizzo-flowが日本語で見逃した6件は次のとおりです。
| 依頼(抜粋) | p(機密) |
|---|---|
| 送付先は東京都世田谷区…山田花子様です | 0.47 |
| クレジットカード 4539 1488 0343 6467 で二重請求 | 0.16 |
| Webhookは https://hooks.slack.com/services/T04Q… | 0.15 |
| このトークンはまだ有効ですか? xoxb-5521… | 0.42 |
| マイナンバー 3847 2915 6603 を会社に提出するとき | 0.12 |
| -----BEGIN OPENSSH PRIVATE KEY----- b3Bl… | 0.09 |
住所の1件を除くと、どれも形が決まった秘密です。カード番号やSlackのトークン、秘密鍵のヘッダは、gitleaksのようなツールが持っている正規表現のルールで、確率を持ち出すまでもなく確実に拾えます。-----BEGIN OPENSSH PRIVATE KEY-----という、名札を付けて歩いてくる秘密を0.09で見送るのは、モデルに聞く相手を間違えているということです。
仕組みとしてはこう組むのが筋だと考えています。
- 正規表現(決定論): カード番号、APIキー、トークン、秘密鍵など、形の決まったもの
- ローカルの判定モデル: 住所、氏名、社内の数字など、形の決まらないもの。しきい値は手元のデータで決める
- Jevやクラウドの判定: 1と2を通ったものだけ
2を1より手前に置くと、モデルが苦手なものをモデルに聞くことになります。なお、この3段の構成そのものはまだ試していません。今回の結果から考えた設計です。
数字を使うときの注意
この結果を手元の判断に使う前に、次の条件を確認してください。
- 機密ありは20件です。 見逃しが1件違うだけで5ポイント動くので、実装同士の小さな差は誤差の範囲です。AUROCや決まったしきい値も、傾向として見てください
- データとラベルは私が作ったものです。 定義は本文に書いたとおりで、第三者の確認は通していません。自分の業務の依頼文では、結果が変わる前提で測り直してください
- Jev本体は呼んでいません。 「Jevより良い/悪い」の比較には使えません
-
rizzo-flowは既定の設定だけです。 較正機能(
--calibration)、1.7B、Q4、ベース重みは試していません。較正機能を使えば、見逃しと誤検知の関係は変わりえます - プロンプトは方式ごとに1種類です。 言い回しを変えると、特にしきい値は動きます
まとめ
Jevのオープン実装をRTX 4070で動かし、Ollamaのlogprobsで組んだ判定と比べました。
- rizzo-flowは5.6GiBほどで動き、2問を約180msで返しました。Jevと同じAPIの形で、同居させやすいです
- 機密ゲートとしては、qwen3.5:4bのほうが確率の並び順が良い結果でした。ただし、確率を0.5で切ると英語で全件を見逃します
- しきい値を手元の40件ほどで決めると、4bの見逃しは20件から2件に減りました
- 形の決まった秘密は、モデルより正規表現のほうが確実です
Jevのような「確率を返す判定」で私がいちばん手応えを感じたのは、確率が返ることそのものより、その確率を自分の40件で較正できるところでした。GPUを回して最後に行き着いたのが「しきい値は0.5とは限らない」という統計の教科書の1ページ目だったのは、少し笑えます。
JevとLLM as a judgeの違い、なぜ公開2週間で話題になったのか、独立検証で見つかった弱点は、こちらの記事にまとめています。
Jevとは?話題の理由5つとLLM判定との差
kenimoto.dev
皆さんは、LLMに渡す前の機密チェックに何を置いていますか? 正規表現だけ、モデルだけ、それとも組み合わせでしょうか。
次の一歩
この記事の判定を、Claude Code の作業全体に広げたのが Kindle 本『ローカルLLMで足りる仕事、Claude Codeに任せる仕事』です。依頼を1ステップずつに分け、ローカルLLMで足りる部分をルールと確率で振り分けます。本記事の機密ゲートとしきい値は第8〜10章で扱い、日本の個人情報を拾う正規表現と、正規表現では拾えない「名札のない機密」まで測っています。付属コードは local-step-router で公開しています。
参考
- TypeSafe AI, Introducing System One models and Jev -- Jevの公式発表
- からあげ, 高速判断AI Jevのユースケースとオープン実装を雑にまとめる -- オープン実装のまとめ
- Rizzo AI Academy, rizzo-flow -- 今回試したオープン実装(コミット b9ba007)
- Ollama, v0.12.11 release notes -- logprobs対応
- gitleaks -- 形の決まった秘密の検出ルール


