はじめに
先日 LLM-jp が公開した大規模言語モデル、LLM-jp-4.1を基礎としてJevのような意思決定モデルを作り公開しました。
残念ながら「国産Jev...完成した...!」とはなりませんでしたが、
取り組みの中でかなりの知見が得られましたので、今回私が行った
「テキスト生成系のオープンモデルから Jev や Cloudflare Clef のような Decision Model を構築する」
過程をまとめます。
この記事を読むことで、次の理解を深める効果が...期待できます!
- Jev のような System One Model / Decision Model とは何か
- 現在登場している Decision Model の実装方式は何か
- テキスト生成モデルから System One 型の判断モデルを作るには、実際に何をすればよいのか
なお、手前が作りましたブツはこちらです。お納めください。
本記事での用語について
この分野は登場して間もないこともあり、System One Model と Decision Model という言葉が併用されています。
TypeSafe AIはJevを、新しいモデルクラスとして System One Model と呼んでいます。
一方、CloudflareやOllamaでは、JevやClef、Nimbleのようなモデルをより一般的に Decision Model と表現しています。
本記事では混乱を避けるため、下記使い分けとします。
- System One Model:TypeSafe AIが提唱したモデルクラス/コンセプトを指す場合
- Decision Model:Jev、Clef、Nimbleなどを含む、この種の判断モデル全般を指す場合
- System One API:これらのモデルを扱うJev互換のAPIインターフェース
ただし文脈上ほぼ同じ対象を指す場合は、「System One / Decision Model」と併記することがあります。
そもそも System One / Decision Model って何よ
“LLMに文章を生成させるのではなく、「判断」だけを高速に行わせることを目的としたモデル”
です。
現在はテキスト生成系AIが開発現場や業務等で広く使われるようになってきました。
そのAIの賢さ(*1)部分だけを取り出し、文章を生成する部分を吹き飛ばすことで、
高速にそのモデルが判断した結果を得られるというものです。
AIを分類器として扱う発想自体は、新しいものではなく、
BERT系の分類モデルをはじめ、ニューラルネットワークを分類・判定器として利用するアーキテクチャは以前から存在します。
一方で TypeSafe AI が Jevとそれを含む新しいモデルクラス System One Modelを発表したことで、次のような観点から現在注目されています。
- 従来のテキスト生成系AIが担っていた「判断」を、文章生成なしで高速・低コストに得られる
- 与えられた質問や選択肢に対して、型付きの判断結果と確率を返せる
- 自由な文字列ではなく、ソフトウェアからそのまま扱いやすい出力を得られる
普通のLLMなら次のように文章を生成します。
入力 → 推論 →「この問い合わせは技術部門へ転送すべきです。理由は……」
一方、System One / Decision Modelでは、あらかじめ定義した候補について確率を返すことを主目的にします。
入力
↓
経理部門 : 0.01
技術部門 : 0.97
その他 : 0.02
あらかじめアプリケーション/エージェントに与えられた選択肢の中で、どれが一番近いのか?
という問に対して、
「こういうことなんじゃないかな?」
と確率付きで出してくれる、というものです。System One は次の判断の型を提供しています。
| 種類 | 例えば何を聞くか | 返す判断 |
|---|---|---|
| Choice | どの担当へ回す? | 選んだキーと候補ごとの確率 |
| Noul | 受付条件を満たす? | trueである確率 |
| Score | どの段階まで進んでいる? | 段階ごとの確率と期待値 |
*1 AIの賢さ部分
この部分キャッチーに書きすぎてまして。
厳密には「文章生成機構そのものを削除する」というより、文章を自己回帰的に生成する処理を行わず、内部表現や次token logitsから判断を直接取り出す方式です。
Decision Model の実装方式
まず本家ともいえる Jev ですが、
- 新しいモデルアーキテクチャ
- 並列Sampler
- RLCD(Reinforcement Learning for Calibrated Decisions)
といった技術を採用していることまでは公開しています。
しかし、第三者がそのまま再実装できるほど詳細なアーキテクチャや、完全な学習レシピについては公開されていません。
つまり、Jevのオリジナルそのものを作ってみよう!ということは現時点ではできてません。
オープンモデルの世界では、
Jevが提供する「入力と質問を渡すと、文章ではなく型付きの判断結果と確率を返す」という振る舞いを
Qwenなど既存テキスト生成LLMを土台として再現するアプローチ
が登場しています。
世界中のコミュニティが手探りで行っている段階なので、その実現の仕方も確立された1本に固定されてるわけではなく、色々なやり方が提案・実装されている状態です。
現在登場している実装方式たち
先日 Ollama が公開した Decision Guide には、4種のモデルが記載されています。
- bespokelabsai/Nimble
- togethercomputer/tev1
- Cloudflare Clef
- Cloudflare Clef Flash
これらは、Ollama 0.35.1 以降で利用できる System One API(/v1/systemone) から利用できますが、内部の実装方式は異なる方式のものがあります。
私自身、各モデルのコードや学習方式を隅々まで読み込めているわけではないので、ここでは実装思想の違いが分かる程度に、触りだけ整理します。
方式1:既存の LM Head を判断出力に利用する
Nimble
Nimble は Qwen3.5-9B をベースとした Decision Model です。
今回、私が実施した llm-jp-4.1-33b-thinking を Decision Model化するのに採用した方式でもあります。
特徴的なのは、通常のLLMのように文章を最後まで生成させるのではなく、各選択肢(判断候補)に対応するtokenのlogits(生のスコア) を直接取得・参照する点です。
例えば選択肢が次の3つだったとします。
A = 経理
B = 技術
C = その他
LLMは次tokenを生成する直前に、語彙全体に対するlogitsを出しています。
Nimbleでは、その中から
logit(A)
logit(B)
logit(C)
だけを取り出し、softmax(合計1の確率へ変換する計算) をかけて確率分布に変換します。
A : 0.01
B : 0.97
C : 0.02
「モデルがどのような文章を書いたか」ではなく、
「次にどの回答tokenを書こうとしていたか」を直接見る方式です。
JSONを生成させてparseする必要もなく、長い文章を自己回帰的に生成する必要もありません。
Tev1
Tev1 は Qwen3.5-4B をベースとして、通常の LoRA による supervised fine-tuning を行ったモデルです。
こちらも専用のDecision Headを新設しているわけではなく、既存の LM HEAD をそのまま利用しています。
ただし Nimble のように外側の scorer が candidate logits を直接読むのではなく、選択肢の文字だけを返すようにモデルを学習しています。
A
B
C
推論時も、次のような形式で出力を制約し、非常に短い生成だけを行います。
Return only its letter, with no explanation.
そのうえでlogprobsも取得し、モデルがどの候補をどれくらい好んでいたかを見ることができます。
通常の長文生成を回答文字1個だけの生成まで削ったアプローチです。
Nimbleと同じく既存のLLM Headを活用していますが、
candidate logitsを外側から直接scoringするか、短い回答tokenを実際に生成させるか、
という違いがあります。
方式2:判断専用の Head を追加する
Clef / Clef Flash
Clef は Qwen3.8-27B、Clef Flash は Qwen3.5-9B をバックボーンとして採用していますが、単に既存 LM Head の候補 logits を読むだけではありません。
ベースモデルの表現力を活かしつつ、判断専用の routing head を新たに追加しています。
入力 / Schema
↓
Qwen Backbone
↓
内部表現
↓
Decision / Routing Head
↓
A / B / C の確率
推論時は Qwen で prefill を実施したのち、有効な選択肢を並列でスコアリングします。一般的な LLM と異なり、テキストを1トークンずつ自己回帰生成するオーバーヘッドがありません。
また、単なるスコアリングにとどまらず、以下のような Decision Model 専用の機構を導入しています
- 候補ごとの文脈参照(attention routing)
- 複数 field 間での情報伝達・連携
- 出力確率の適切な校正(キャリブレーション)を目的とした学習
実装方式まとめ
既存の LM Head をそのまま流用する Nimble や Tev1 に対して、
Clef は既存 LLM をバックボーンとしつつ判断専用の Head を増設する特化型のアーキテクチャを採用しています。
現在公開されている Decision Model の代表例を大まかに整理すると、以下のような設計アプローチに分かれます。
| Model | Base | 判断の取り出し方(設計アプローチ) |
|---|---|---|
| Nimble | Qwen3.5-9B | LM Head の候補トークン logits を直接スコアリング |
| Tev1 | Qwen3.5-4B | LM Head のまま、回答トークンのみを最小限生成 |
| Clef | Qwen3.8-27B | 専用の routing head による並列スコアリング |
| Clef Flash | Qwen3.5-9B | Clef と同系統の専用 routing head |
今回私が行ったのは、最もアーキテクチャがシンプルで手軽な「既存の LM Head から候補トークンの logits を直接読む方式」を llmjp/llm-jp-4.1-33b-thinking モデルをベースに採用したものとなります。
llm-jp-4.1-33b-thinking を Decision Model にしてみる
llm-jp-4.1-33b-thinkingは、国立情報学研究所(NII) / LLMC が公開した、日本語データを重視して開発されており、日本語・日本ドメインでの利用を意識した国産オープンモデルです。
今回はこのモデルを前述の Nimble 方式を参考に Decision Model化の試みを行いました。
Clef のような専用の Decision Head の追加は行わず、既存の LM HEAD を使い、回答候補に対応する次トークンの logits を直接参照します。
既存のLLM、LoRA、Ollamaの仕組みを使いながら、Decision Modelを構築するのにこの方式は適していると判断しました。
以降の章は当時の手順メモやログ等から書き起こしたものです。
読み辛い部分がありますが、今々文章整理する時間取れないので一旦書き出した形になることあらかじめお詫びします。
1. 文章の生成を走らせず、既存のlm_headを判定に使う
今回この方法を選んだ理由は、すでにあるLLM、LoRA、Ollamaの仕組みを使いながら、候補の確率を直接扱えるためです。新しい分類headや専用serverを作る前に、この経路でどこまでできるかを確かめたかったのです。
実際の候補スコア計算を、要点だけ抜き出すとこうなります。入力や候補IDは検査済みで、inputs に input_ids と attention_mask をまとめてある前提の短い抜粋です。
out = model(**inputs, use_cache=False, logits_to_keep=1) # 入力を読み、末尾位置の出力を得る。
next_logits = out.logits[:, -1, :].float() # 回答開始位置の語彙スコアを取る。
scores = next_logits.gather(1, candidate_ids) # 今回の候補A/B/Cだけを取り出す。
probs = torch.softmax(scores, dim=-1) # 候補内で合計1の確率へ変える。
logits_to_keep=1 は、語彙の出力を最後の位置に絞る指定です。
入力の文章を読むbackboneの計算まで省く指定ではありません。
文章生成の繰り返しを省いても、33Bの重みが小さな分類器になるわけではなく、入力を理解するためのモデル本体は必要です。
2. プロンプトの末尾を「回答の1トークン目」に揃える
前節の計算は、out.logits[:, -1, :] が「最終回答の1トークン目を出す直前」の位置であることを前提としています。この位置がずれていると、候補 A/B/C の logits を読み出しても判断としての意味を持ちません。logits を直接読む方式では、まずこの位置合わせを固める必要があります。
思考を書かせない形でテンプレートを止める
llm-jp-4.1-33b-thinking は名前のとおり推論(thinking)型のモデルで、Harmony 形式のチャットテンプレートを採用しています。
通常の生成では、最終回答の前に analysis チャネルで思考過程を書き出します。
判断モデルとして使う場合は思考を書かせる余地がないため、次の2点を固定しました。
-
reasoning_effort="none"を指定し、テンプレートにReasoning: noneを出力させる - 中身が空の assistant final メッセージを描画し、末尾の終端トークン
<|return|>だけを取り除く
こうすると、プロンプトは <|channel|>final<|message|> で終わります。モデルから見れば「これから最終回答の本文を書き始める」位置であり、ここで出力される最初のトークンが候補コードになるように学習・推論を行います。
messages = [
{"role": "developer", "content": DEVELOPER_INSTRUCTIONS},
{"role": "user", "content": user_text},
{"role": "assistant", "content": ""}, # 空の最終回答
]
rendered = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=False,
reasoning_effort="none", conversation_start_date=FIXED_DATE,
)
# 空の最終回答は終端トークンで閉じられるので、それだけを外して「本文の直前」で止める。
assert "Reasoning: none" in rendered
assert rendered.endswith("<|channel|>final<|message|><|return|>")
prefix = rendered[: -len("<|return|>")]
テンプレートの細部はモデルのリビジョンによって変わり得ます。
そのため末尾の文字列を検査し、想定と異なる場合は処理を止めるようにしました。代替処理で吸収してしまうと、ずれた位置の logits を気づかないまま読み続けることになるためです。同じ理由で、ベースモデルとトークナイザは Hugging Face 上のリビジョン(de581ee4353568c480d47129948d1be99f02f4ff)に固定しています。
日付もプロンプトの一部である
Harmony テンプレートには会話開始日が埋め込まれます。学習時は固定の日付を与えましたが、後述する Ollama 側のテンプレートは実行時のローカル日付を埋め込みます。日付が変わればプロンプトのトークン列が変わり、出力される確率もわずかに変わります。小さな差ではありますが、再現性を議論する際には日付も入力条件の一つとして記録しておく必要があります。
候補コードは「境界で1トークン」でなければならない
1回の forward で全候補を採点するには、各候補コードが プロンプト末尾に続けたときにちょうど1トークンになる ことが条件です。候補コードを単独でトークナイズして1トークンであっても、直前の文字列と結合したときに分割のされ方が変わる場合があるためです。
そこで、プレフィックス単体と「プレフィックス+候補コード」をそれぞれトークナイズし、後者が前者に1トークンを足しただけの列になっているかを候補ごとに検査しました。
base = tokenizer.encode(prefix, add_special_tokens=False)
candidate_ids = []
for code in codes:
ids = tokenizer.encode(prefix + code, add_special_tokens=False)
if len(ids) != len(base) + 1 or ids[:-1] != base:
raise ValueError(f"{code!r} は回答開始位置で1トークンになりません")
candidate_ids.append(ids[-1])
assert len(set(candidate_ids)) == len(candidate_ids) # 別候補が同じIDに潰れていない
この検査に加え、未知語トークンに落ちていないことも確認します。英大文字 A〜Z はこの境界でそれぞれ別の1トークンになることを確認できたため、候補コードには英大文字を使用しました。後述の Ollama の System One 実装も、呼び出し側が指定したキーを内部で A〜Z の候補コードに割り当てて採点しており、API が受け付ける候補数が2〜26であることもこれに対応しています。
なお、入力がトークン上限を超える場合は、切り詰めずにエラーとして拒否しています。末尾を切ると回答開始位置そのものが失われ、先頭を切ると判断材料が欠けるためです。
3. Choice / Noul / Score を候補スコアリングに落とし込む
System One の3つの判断型は、いずれも「候補トークンの logits → softmax」という同じ計算の上に組み立てられます。違いは、候補の意味づけと、確率から返す値の計算だけです。
| 型 | 候補トークンの意味 | 返す値 |
|---|---|---|
| Choice | 呼び出し側が定義した各キー | 全候補の確率と、最大確率の候補 |
| Noul | false / true の2候補 | true 側の確率 P(true) |
| Score | 順序付きの段階 0, 1, …, K-1 | 各段階の確率と期待値 Σ i·p(i) |
例えば Score で4段階の確率が [0.05, 0.15, 0.60, 0.20] であれば、期待値は 0×0.05 + 1×0.15 + 2×0.60 + 3×0.20 = 1.95 です。期待値は小数になり得るため、「段階2寄りだが段階3の可能性も残る」といった中間的な状態を表現できます。
注意点が2つあります。
- Noul の P(true) は確率であり、bool ではありません。 true / false に変換する閾値は、用途ごとに別のデータで検証して決める必要があります。
- API が返す confidence は校正済みの正答率ではありません。 Ollama の実装では確率分布の正規化エントロピーから計算されており、「分布がどれだけ尖っているか」を表す値です。
学習時と推論時でプロンプトを揃える
前節のコード片は、初期の試作で用いた独自の日本語プロンプトに基づいています。最終的なモデルでは、Ollama が System One リクエストから組み立てるメッセージと同じ形式 で学習データを描画するように変更しました。
logits を直接読む方式では、学習した「回答開始位置の分布」がそのまま推論結果になります。学習時と推論時でプロンプトの書式が異なると、学習した分布をそのまま使える保証がありません。どの runtime で使うかを先に決め、その runtime が実際に生成するプロンプトとトークン列を基準に学習データを作ることが重要です。
1リクエストに複数の質問を含める場合
System One API では、1つの入力(state)に対して複数の質問を同時に送ることができます。Nimble 方式では、リクエストに含まれる全質問(スキーマ全体)をプレフィックスに含めたうえで、質問ごとに候補を採点します。
この方式は実装が単純である一方、同じ質問でも、一緒に何を聞くかによって確率が変わり得る という性質を持ちます。質問ごとの独立性を保証する仕組みとは異なる点に注意が必要です。実際にどの程度変わったかは、評価の節で示します。
4. 候補の logits だけで損失を取る(QLoRA による学習)
損失関数
学習では、通常の言語モデルのように語彙全体に対する次トークン予測を行うのではなく、候補トークンの logits だけを取り出してから cross entropy を取ります。
selected = next_logits.gather(1, candidate_ids) # 候補だけを取り出す
selected = selected.masked_fill(~candidate_mask, float("-inf")) # 候補数の違いを吸収
loss = F.cross_entropy(selected, labels) # labels = 正解候補の位置
candidate_mask は、候補数がサンプルごとに異なる(2択もあれば26択もある)ため、パディングした分を確率計算から除外するためのものです。
語彙全体で学習する場合と比べると、この損失は「候補以外のトークンにどれだけ確率を割くか」を一切評価しません。推論時に見るのは候補内で正規化した確率だけなので、学習の目的と推論時の使い方が一致します。
学習設定
33B モデルの BF16 重みは約66GB あり、32GB の GPU には載りません。そこで、ベースを4bit(NF4)に量子化して凍結し、LoRA だけを学習する QLoRA を採用しました。
| 項目 | 設定 |
|---|---|
| ベース量子化 | NF4(double quantization)、計算は BF16 |
| LoRA | rank 16 / alpha 32 / dropout 0.05 |
| 対象モジュール | q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj |
| 学習パラメータ数 | 134,217,728 |
| バッチ | microbatch 1、gradient accumulation 4、gradient checkpointing |
| 最適化 | AdamW、学習率 2e-5、weight decay 0、gradient clip 1.0 |
| 学習量 | 384 fields(Choice / Noul / Score 各128)、1 epoch、96 optimizer updates |
| 最大入力長 | 1,024 tokens |
学習環境は RTX 5090(32GB)、Python 3.12、PyTorch 2.14.1(CUDA 13.0)、Transformers 5.18.0、PEFT 0.21.2、bitsandbytes 0.50.2 です。1 update あたりの所要時間はおおむね15秒程度でした。
PEFT の汎用的な k-bit 学習準備処理(prepare_model_for_kbit_training)は、量子化されていない残りの重みを FP32 に変換します。このモデルではそれがメモリを圧迫したため、ベースを凍結しつつ、非量子化のテンソルを BF16 のまま保持するよう準備処理を調整しました。
学習データ
学習・評価データには、人手で正解が付与された公開データセットと、明示的なルールから正解が一意に決まる合成データを組み合わせました。
- JNLI(JGLUE):2文の含意関係。Choice(含意/矛盾/中立)と Noul(含意しているか)に変換
- JCommonsenseQA(JGLUE):常識推論の5択問題。Choice に変換
- 合成ルールデータ:「〜の場合は窓口Xへ回す」といった明示ルールに基づく振り分け・充足判定・段階評価
学習・開発・校正・最終評価の各分割は、同じ元文を共有するサンプルが別の分割に混ざらないよう、元文のグループ単位で分離しています。最終評価セットは、学習中にも、チェックポイントの選択にも使用していません。
JGLUE は CC BY-SA 4.0 で公開されており、JNLI の文対は YJ Captions(CC BY 4.0)に由来します。モデルを配布する際の権利表記は、これらの出典に従っています。
いきなり本番の学習をしない
33B モデルの学習は、1回の試行に時間と GPU を要します。そのため、最初から3種の判断型をまとめて学習するのではなく、規模を段階的に広げました。
- JNLI 数十件での1 epoch 試行(パイプラインが最後まで通ること、損失が有限であることの確認)
- クラスを均等にした JNLI 200件での学習(Choice 単体で学習効果が出ることの確認)
- JCommonsenseQA で候補の順序とキーを入れ替えた学習(固定ラベルではなく、動的な選択肢を扱えることの確認)
- Choice / Noul / Score を混ぜた本番の学習(上表の設定)
各段階で、学習前のベースモデルとの比較を同じ評価データで行い、効果が見えた段階で次へ進めています。
データ設計で踏んだ落とし穴
学習後の診断で、合成データに 正解の手がかりが漏れていた ことが分かりました。
合成の振り分けデータでは、候補のキー名を 窓口3 のように番号付きで生成していました。ところが正解の窓口番号とキー末尾の番号が対応していたため、モデルは候補の説明文を読まずに「ルールから番号を計算し、同じ番号のキーを選ぶ」だけで正解できてしまいます。
この影響を確かめるため、キー名を意味のない2文字の記号に置き換えた評価と、キーと説明文の対応を入れ替えた評価を行いました。その結果、Choice の正解率は 46.9% から 42.2%(記号化)、39.1%(入れ替え)に低下しました。候補の並び順を入れ替える拡張だけでは、この種の近道は除去できません。
同様に、データ全体では true / false が均等でも、特定のルールの Noul がすべて true、特定の Score がすべて最上位の段階、という偏りも見つかりました。全体の件数だけを揃えても、ルール単位で偏っていれば、モデルはルールの種類から答えを推測できてしまいます。判断モデルの学習データは、全体の分布だけでなく、近道の経路がないかを確認する必要がある というのが、この取り組みで最も大きな学びの一つです。
5. Ollama で動かすための GGUF 化
学習した LoRA を Ollama の System One API から使うには、GGUF 形式のモデルとして登録する必要があります。ここでは想定以上に時間を要しました。
ADAPTER 指定は使えない
Ollama の Modelfile には LoRA を指定する ADAPTER という記述があります。しかし検証に用いた Ollama 0.35.1 では、ADAPTER を含むモデルの作成要求は HTTP 400(LoRA adapters are no longer supported)で拒否されました。そのため、LoRA をベースの重みに事前にマージした GGUF を作成する必要があります。
4bit 版にマージしたら学習効果が消えた
最初は、LLM-jp が公式に配布している Q4_K_M の GGUF を起点に、「Q4 を展開 → LoRA をマージ → Q4 へ再量子化」という経路を試しました。結果は次のとおりです(同一の15問による比較)。
| 条件 | 正解数 |
|---|---|
| HF(NF4)ベースのみ | 5/15 |
| HF(NF4)+ LoRA | 14/15 |
| 公式 Q4 のみ | 3/15 |
| 公式 Q4 + LoRA(マージせず実行時に適用) | 14/15 |
| 公式 Q4 → LoRA をマージ → Q4 へ再量子化 | 4〜5/15 |
マージせずに実行時に適用すれば学習効果は保たれる一方、マージしてから再量子化すると、ほぼ未学習の状態に戻っていました。
原因を調べるため、一部の重み行列について「LoRA が加えようとした差分」と「マージ・再量子化後に実際に変化した差分」を比較しました。F16 でマージした直後は両者のコサイン類似度が約0.98で方向がよく保たれていた一方、再量子化後は約0.005まで低下していました。再量子化で生じる誤差の大きさが、LoRA の差分の大きさを上回っていたためと考えられます。
LoRA による更新は、元の重みに対してごく小さな差分です。一度量子化した重みにマージして再度量子化すると、その小さな差分が量子化誤差に埋もれる ことがあります。
採用した経路:BF16 起点で1回だけ量子化する
最終的には、BF16 の元重みを起点に、高精度のままマージしてから量子化を1回だけ行う経路を採用しました。
HF BF16 重み
└─(convert_hf_to_gguf)→ F16 GGUF
LoRA adapter
└─(convert_lora_to_gguf)→ LoRA GGUF(F16)
F16 GGUF + LoRA GGUF
└─(llama-export-lora)→ マージ済み F16 GGUF(約64GB)
└─(llama-quantize)→ Q5_K_M GGUF(約23.6GB)
いずれも llama.cpp 標準のツールで、改変はしていません。LoRA の倍率(alpha / rank = 2)は export ツール側で適用されるため、手動で重ねて掛けないよう注意が必要です。この経路で作成した GGUF では、上記の15問で HF 版と同じ 14/15 を維持し、予測も15問すべてで一致しました。
量子化の種類は、32GB の GPU に収まり、かつ Q4 より劣化が小さい Q5_K_M を選択しています。
トークナイザの差異
LLM-jp の公式 GGUF のモデルカードでは、トークナイザを正しく扱うために LLM-jp がフォークした llama.cpp を使うよう案内されています(本記事執筆時点。upstream への取り込みが進行中とされています)。実際に、HF のトークナイザと GGUF 側のトークナイザでは、改行の扱いなどで同じ文字列が異なるトークン ID 列になる場合がありました。
GGUF 化を試す場合は、LLM-jp の cookbook の記載を確認することをお勧めします。
6. Ollama への登録と System One API
Modelfile
GGUF の登録に用いる Modelfile は次のとおりです。
FROM ./llm-jp-4.1-33b-systemone-experimental-semantic96-Q5_K_M.gguf
CAPABILITY decision
PARAMETER num_ctx 2048
PARAMETER num_batch 128
PARAMETER num_gpu 99
CAPABILITY decision を付けることで、Ollama はこのモデルを Decision Model として扱い、/v1/systemone からのリクエストを受け付けるようになります。
テンプレートは GGUF 側に持たせる
Ollama は、GGUF に埋め込まれたチャットテンプレート(Jinja)を Modelfile の TEMPLATE よりも優先して使用します。そのため、第2節で固めた「思考なし・回答本文の直前で止まる」プロンプトは、GGUF のメタデータ(tokenizer.chat_template)として埋め込みました。テンプレート以外のバイト列は変更していません。
ここでも1つ問題が起きました。最初に作成したテンプレートには、思考を有効にする指定が来た場合にエラーを返す検査を入れていました。ところが、llama.cpp の実行サーバは起動時の確認処理として、思考を有効にした状態でテンプレートを描画します。その結果、モデルの起動自体が HTTP 500 で失敗しました。
修正版では、こうした拒否の条件を取り除き、どの指定に対しても常に Reasoning: none と最終回答の開始位置を出力するようにしました。修正前後で、評価に用いるリクエストのプロンプトがバイト単位で一致することを確認しています。テンプレートは、自分が想定した呼ばれ方以外でも描画される ことを前提に書く必要があります。
リクエストとレスポンス
登録後は、System One 形式の JSON を /v1/systemone に送ります。以下は Choice の例です。
{
"model": "llm-jp-systemone:semantic96",
"state": "商品の使い方について質問したいです。",
"questions": {
"route": {
"type": "choice",
"instructions": "問い合わせの目的に最も近い窓口を選んでください。",
"criteria": {
"support": "使い方、設定、技術的な相談。",
"billing": "支払いや請求の相談。",
"other": "どちらにも該当しない。"
}
}
}
}
criteria のキー(support など)は呼び出し側が自由に決められます。Ollama はこれらを内部で A, B, C… の候補コードに割り当てて採点し、レスポンスでは元のキー名に戻して各候補の確率を返します。Noul では criteria に false / true を、Score では段階の説明を配列で与えます。
測定時の Ollama 側の設定は、コンテキスト長 2048、flash attention 有効、KV キャッシュ q4_0 です。KV キャッシュの精度は、後述のとおり確率の安定性に影響します。
7. 評価
公開ベンチマーク(799件)
Nimble が公開している評価プロトコルのうち、3つのサブセットを、公開された ID・本文・評価基準・ラベル・順序のまま使用しました。翻訳や切り詰めは行っていません。799件すべてが正常に応答し、エラーは0件でした。
| サブセット / 判断型 | 件数 | 本モデル | Nimble(公開値) | Jev(公開値) |
|---|---|---|---|---|
| PubMedQA / Choice | 250 | 68.8% | 75.6% | 77.2% |
| BoolQ / Noul | 300 | 83.0% | 86.0% | 89.7% |
| HelpSteer2 / Score | 249 | 34.1% | 39.0% | 34.1% |
Score の期待値と正解の平均絶対誤差(0〜4段階)は 0.990 で、Nimble は 0.962、Jev は 0.967 です。Nimble と Jev の値は Nimble の公開集計 からの引用であり、今回再実行したものではありません。実行環境も異なるため、記述的な比較にとどまります。
いずれも英語のサブセットであり、全13サブセット・3,880件の評価ではありません。ベースが日本語中心のモデルであることを考えると、英語の判断タスクでも一定の水準に達している一方で、Nimble・Jev には届いていない、というのが率直な結果です。
1リクエストあたりの平均応答時間は 0.14〜0.34秒でした(TypeSafe SDK 経由の壁時計時間。初回のモデル読み込み約68秒を除く)。33B のモデルでも、文章を1トークンも生成しないため、1件あたりの処理はプロンプトの読み込み1回分で完結します。
日本語での評価と学習効果
学習の効果を確認するため、最終評価セット(各判断型64件)で、学習前のベースモデルと比較しました(HF 上、NF4、同一条件)。
| 指標 | ベース | 学習後 |
|---|---|---|
| Choice 正解率 | 32.8% | 46.9% |
| Noul balanced accuracy | 34.4% | 93.1% |
| Score 期待値の誤差(正規化 MAE、小さいほど良い) | 0.416 | 0.155 |
Noul と Score では明確な改善が見られました。同じモデルを GGUF 化して Ollama 上で評価した場合も、Noul の balanced accuracy は 95.3%、Score の誤差は 0.183 と、同程度の水準を維持しています。
一方で、Choice は改善が限定的でした。公開データの JNLI では 37.5% から 75.0% に向上したものの、合成ルールによる振り分けでは 28.1% から 18.8% へと逆に低下しています。前述のキー名の近道も含め、呼び出し側が自由に定義する選択肢への汎化 は達成できていません。
見えてきた課題
評価の過程で、Decision Model を実運用するうえで重要な性質についても、今回作成したモデルでいくつかの課題が見つかりました。
- 候補の並び順への依存:同じ Choice を候補の順序だけ変えて4通りで評価したところ、4通りすべてで意味的に同じ回答になったのは 29/64(45.3%)でした。
- 同時に聞く質問への依存:同じ質問を単独で送った場合と、他の質問と一緒に送った場合で、回答が一致したのは 54/64(84.4%)でした。第3節で述べた、スキーマ全体をプレフィックスに含める方式の影響と考えられます。
- 同一リクエストの再実行による変動:まったく同じリクエストを再送したところ、Choice 64件中6件で回答が入れ替わりました。最小の再現実験では、モデルを再読み込みした直後の1回目と2回目で確率分布が最大約5ポイント動き、その変化はモデルを読み込み直しても同じように再現しました。
- KV キャッシュ精度の影響:KV キャッシュを q4_0 から f16 に変えると、複数の質問を含むリクエストで確率が大きく変化しました。ただし f16 でも経路による差は残っており、KV キャッシュの量子化だけが原因とは断定できていません。
確率を返すモデルの価値は、その確率が信頼できることにあります。その意味で、回答の正しさとは別に、同じ入力に対して同じ確率を返すこと も評価すべき項目であり、これはモデルの重みだけでなく、serving 側の設定(KV キャッシュ、バッチ、キャッシュの再利用など)にも依存します。これらの原因の切り分けは、今後の課題です。
作ったモデルを試す
作成したモデルは Hugging Face で公開しています。
Ollama(検証済みバージョンは 0.35.1)と HF CLI を導入済みであれば、次の手順で試せます。
repo='shunmoridev/llm-jp-4.1-33b-systemone-experimental'
gguf='llm-jp-4.1-33b-systemone-experimental-semantic96-Q5_K_M.gguf'
hf download "$repo" "$gguf" Modelfile examples/request-noul.json --local-dir .
ollama create llm-jp-systemone:semantic96 -f ./Modelfile
curl --silent --show-error --fail-with-body --max-time 180 \
--header 'Content-Type: application/json' \
--data-binary '@./examples/request-noul.json' \
'http://localhost:11434/v1/systemone'
GGUF は約23.6GB です。測定は 32GB の GPU で行っており、それより小さい GPU や CPU のみの環境での動作・速度は確認していません。Choice / Score のリクエスト例や、PowerShell での実行方法はモデルカードに記載しています。
あくまで実験段階のモデルです。前節のとおり、日本語の汎用 Decision Model として完成したものではなく、業務で使う場合は用途に合わせた独自の評価が必要です。ライセンスは権利の対象ごとに異なるため、モデルカードの LICENSE.md を確認してください。
振り返り:取り組んでみて分かったこと
最後に、テキスト生成モデルから Decision Model を作る過程で得られた知見を整理します。
- 位置合わせと1トークン検査が土台になる。 logits を直接読む方式は単純ですが、「どの位置の、どのトークンを読むか」を厳密に固定しないと、計算結果がもっともらしい数字のまま意味を失います。検査に失敗したら止める作りにしておくことが重要です。
- 学習時と推論時のプロンプトを揃える。 最終的に使う runtime が生成するプロンプトとトークン列を基準に、学習データを作る必要があります。日付やトークナイザの差異も、この「揃える」対象に含まれます。
- LoRA は高精度でマージし、量子化は1回にする。 LoRA の差分は小さく、量子化済みの重みにマージして再量子化すると消えることがあります。
- 学習データの近道を疑う。 キー名や、ルール単位のラベルの偏りから答えが推測できると、モデルはそちらを学習します。並び順の入れ替えだけでは除去できません。
- API に対応することと、判断の品質は別物である。 確率の合計や型が正しく返ってきても、それが正しい判断であるとは限りません。並び順・同時に聞く質問・再実行に対する安定性も、品質の一部として評価する必要があります。
既存の LM Head を使う方式は、既存の学習・変換・serving の仕組みをそのまま使えるという大きな利点があります。一方で、Clef のように判断専用の機構を持たないため、質問間の独立性や確率の校正といった点は、データと学習方法だけで対処する必要があります。今回の課題の多くは、まさにこの部分に表れたと考えています。
おわりに
日本語のオープンモデルを Decision Model として Ollama の System One API から使えるところまでは到達できました。Noul や Score では学習の効果が明確に表れており、方式そのものには十分な可能性があると感じています。
Decision Model はまだ登場したばかりの分野で、作り方も評価の仕方も手探りの段階です。本記事が、同じように手元のモデルで試してみたい方の参考になれば幸いです。
