[Summarin](/)では、Googleが公開しているGemmaをチューニングしたモデルで要約を作っています。なぜローカルLLMで要約サービスを作ったのかで「小さなモデルは出力の形式が揃わないので、チューニングで固定した」と書きましたが、この記事ではその中身を書きます。

チューニングのコードは全量の公開はしていません。そのかわり、ポイントになる部分だけを抜き出して紹介します。

なお、私自身はファインチューニングについての知識が浅く、今回の仕組みも大部分はClaude Codeを使って開発したものです。そのため、説明の中におかしな部分があるかもしれませんが、ご容赦ください。

なぜ書式を固定したいのか

Summarinでは、1つの文章に対して3種類の処理をしています。

  • 要約 — 文章としてまとめる
  • 要点 — 重要な順に3つ
  • キーワード — 重要な順に5つ

サイトの画面はモデルの出力をプログラムで読み取って表示しているので、出力の形が決まっていないと画面に出せません。 そこで、モデルには次のようなJSONを1つだけ返してもらう決まりにしています。

{"summary": "要約本文"}
{"points": ["要点1", "要点2", "要点3"]}
{"keywords": ["キーワード1", "キーワード2", "キーワード3", "キーワード4", "キーワード5"]}

ところが、1Bや2Bといった小さなモデル(SLM)にこれを指示しても、そのとおりには返ってきません。後で実際の出力を見ますが、JSONをコードブロックで囲んでしまったり、括弧の対応が崩れていたり、要約が長すぎたりします。

形は文法制約で、中身の決まりは学習で

llama.cppには、出力を文法で縛る機能(JSON Schemaから変換したGBNF)があります。これを使えば、構文として壊れたJSONは出力されなくなります。 Summarinの本番でも、保険としてこれを有効にしています。

ただ、文法で縛れるのは形だけです。次のような中身の決まりは、文法では守らせられません。

  • 要約は本文の半分以下、かつ400文字以内
  • 文体は常体(である調)
  • キーワードは本文に実際に出てくる語にする

これらを守らせるには、モデル自身にその書き方を覚えさせるしかありません。形は文法制約で、中身の決まりは学習で、という役割分担にしました。

プロンプトを1文字もずらさない

チューニングで覚えるのは「このプロンプトが来たら、この形で返す」という対応です。そのため、学習時とサービスでの推論時で、プロンプトは完全に同じ文字列にする必要があります。1文字でもずれると、せっかく固定した書式が崩れることがあります。

プロンプトは次の骨格で、3つの処理すべてに共通です。

次の本文を読み、指示に従って日本語で出力してください。
出力は指定のJSONオブジェクト1個のみとし、前置き・説明・コードブロックは書かないでください。
出力形式に書かれている値は説明用のプレースホルダなので、実際の内容に置き換えてください。

# 指示
{処理ごとの指示}

# 出力形式
{処理ごとのJSONの形}

# 本文(ここから)
{本文}
# 本文(ここまで)

本文を「ここから」「ここまで」で囲んでいるのは、本文の中にある見出しや箇条書きが、指示の一部と混ざらないようにするためです。

このプロンプトの定義は学習側とサーバー側で同じファイルを使い、さらにプロンプトから計算したハッシュ値を学習データに埋め込んでいます。 学習を始めるときにこの値を照合し、食い違っていたら止まるようにしました。プロンプトを直したのにデータを作り直し忘れた、という事故を防ぐためです。

本文の前処理(全角英数の統一、空行の整理、3,500文字での打ち切り)も、学習と推論で同じ関数を通しています。

学習データ

元になるのは、要約・要点・キーワードの正解を付けた約14,000件のデータです。ここから処理ごとに2,000件を取り出し、学習1,900件・検証100件に分けました。

これとは別に、処理ごとに200件、合計600件を評価専用として隔離しています。この600件はモデルのバージョンを更新しても変えていないので、評価の数字をバージョン間でそのまま比べられます。

データについては、2つの決まりを入れています。

  • 書き直した正解を優先する — 後から手で直した正解がある文章は、元の正解と差し替えて優先的に学習に入れています
  • 本文に出てこないキーワードの正解は学習させない — 「本文に無い語をキーワードにしてよい」と覚えてしまうと、要約サービスとしては事実と違うことを書く方向に効いてしまうためです

応答の部分だけを学習させる

今回いちばん大事にしたのがここです。学習データは「プロンプト」と「応答」の組ですが、誤差(loss)をかけるのは応答の部分だけにしています。

def _encode(self, prompt_text, response_text):
    messages = [{"role": "user", "content": prompt_text},
                {"role": "assistant", "content": response_text}]
    full = self._ids(messages, generation=False)    # プロンプト+応答
    prompt = self._ids(messages[:-1], generation=True)  # プロンプトだけ

    # プロンプト部分は -100 にして、誤差の計算から外す
    labels = [-100] * len(prompt) + full[len(prompt):]
    return {"input_ids": full, "labels": labels}

プロンプトの部分まで学習させると、モデルは長い本文を覚えることに力を使ってしまいます。覚えてほしいのは「どう返すか」だけなので、プロンプト側は-100で隠して、応答の形だけを学ばせています。

1件あたり平均で約900トークンありますが、誤差がかかるのはそのうち100トークン程度です。

パラメータ

学習はLoRAで行いました。モデル全体を書き換えるのではなく、小さな追加の重みだけを学習する方法です。環境はRTX 4070(VRAM 12GB)を積んだWindowsのPCです。

項目Gemma 3 1BGemma 4 E2B
読み込みbf16のまま(LoRA)4bitに量子化(QLoRA)
LoRAのランク / alpha16 / 3216 / 32
LoRAのドロップアウト0.050.05
LoRAを当てる層すべての線形層言語モデル部分の線形層のみ
学習率1.0e-45.0e-5
エポック数22
実効バッチサイズ16(2 × 8)16(1 × 16)
学習率の減衰cosine(最初の3%はウォームアップ)同左
最大系列長4,096トークン4,096トークン
学習するパラメータの割合1.29%0.61%
学習時間約72分約3時間半
VRAMの最大使用量5.25 GiB7.82 GiB

いくつか補足します。

  • Gemma 4 E2Bは画像や音声も扱えるモデルですが、Summarinで使うのは文章だけです。画像・音声の処理部分を巻き込まないよう、LoRAは言語モデルの部分にだけ当てています
  • エポック数は2にしています。書式を覚えさせるのが目的なので、それほど回数は要りません
  • 最大系列長は4,096です。本文を3,500文字で打ち切っているので、実際のデータは最長でも約2,500トークンに収まり、切り捨ては起きていません

12GBに収めるための工夫

Gemma 4 E2Bは、素直にQLoRAを始めると18GiBほどのVRAMを要求し、12GBには収まりませんでした。 原因は2つありました。

1つ目は埋め込み層の大きさです。Gemma 4 E2Bには約23.5億パラメータの埋め込みがあり、これは4bit化の対象になりません。QLoRAの下準備でよく使う関数は、4bit化されなかったパラメータをすべて32bitに引き上げるため、埋め込みだけで10.3GiBになってしまいます。そこで、この引き上げを行わず、埋め込みは16bitのまま学習するようにしました。

2つ目は語彙の大きさです。Gemmaの語彙は約26万語あり、全トークン位置について「次の語の確率」を計算すると、その結果だけでVRAMを使い切ります。しかし先ほど書いたとおり、誤差がかかるのは応答の100トークン程度だけです。そこで、必要な位置だけ確率を計算するようにしました。

# 誤差がかかる位置だけを取り出す
keep = (labels[:, 1:] != -100).any(dim=0).nonzero(as_tuple=True)[0]

# その位置だけ語彙全体の確率を計算させる
outputs = model(**model_inputs, logits_to_keep=keep)
logits = outputs.logits.float()
targets = labels[:, keep + 1]
loss = torch.nn.functional.cross_entropy(
    logits.reshape(-1, logits.size(-1)), targets.reshape(-1),
    ignore_index=-100, reduction="sum")

近似ではなく同じ計算をしているだけなので、同じ条件で学習させると誤差の値は完全に一致しました。それでいて、Gemma 3 1BではVRAMが4.92GiBから2.86GiBに減っています。

もう1つ、VRAMの使用量に上限を設けています。 WindowsはVRAMが足りなくなると、黙ってメインメモリへあふれさせます。こうなると学習が極端に遅くなり、実際にOSごと落ちたこともありました。上限を超えたらその場でエラーにして止まるようにしたことで、あふれる前に気づけるようになりました。

元のモデルとの比較

ここからが本題です。同じ文章、同じプロンプトで、チューニング前とチューニング後のモデルに要約・要点・キーワードを出させました。

条件は次のとおりです。

  • どちらもGGUF形式・Q6_K量子化に変換し、llama.cppで動かしています
  • 温度は0(毎回同じ出力になる設定)です
  • 違いを見るため、文法制約は使っていません
  • 使った文章は、評価専用に隔離していた600件の中の1件で、学習には使っていません

使った文章

三重県伊賀市大内の名阪上野ドライブインが3月31日で閉店することになった。運営する三交興業が12日発表した。忍者ドライブインの愛称で親しまれ、休憩や飲食、観光の拠点だったが、利用客の激減や老朽化などのため、55年の歴史に幕を下ろす。同ドライブインは1966年9月、名阪国道大内インターチェンジのそばに開業。約1万7千平方メートルの敷地に延べ床面積約4千平方メートルの2階建ての建物があり、土産物店やフードコート、レストランなど十数店舗が営業している。同社によると、新名神高速道路の整備など道路環境や旅行形態の変化などを背景に利用客が減少。大型バスの利用が90年度は約8万台あったが、2018年度は約1万4千台に減った。12年に建物を改装し、マスコットキャラクター忍にゃんを導入するなどリニューアルしたが、コロナ禍が利用客減に追い打ちをかけた。閉店後は建物を撤去し、再開発が検討されているという。今月22日から閉店感謝セールが開催される。55にちなんだ550円や1550円の特設商品や、伊賀牛ステーキの割引などを検討。マルシェやキッチンカーが集結するイベントも計画中だ。同社の藤田尚史総務部長は55年のご利用ありがとうございました。感謝セールにはぜひ来ていただきたいと話している。

本文は539文字です。要約は「本文の半分以下」なので、269文字以内が決まりになります。

Gemma 3 1B:そもそも形が守れない

チューニング前の出力です。モデルが返したものをそのまま載せています。

```json
{"summary": "三重県伊賀市の名阪上野ドライブインが3月31日で閉店しました。運営する三交興業が発表したもので、利用客の激減や老朽化などが原因で、55年の歴史に幕を閉じます。敷地面積は約1万7千平方メートルで、約4千平方メートルの2階建ての建物が10数店舗を擁し、土産物店やフードコート、レストランなどが営業していました。道路環境の変化や大型バスの利用減少など、様々な要因が複合的に影響し、利用客の減少が深刻化しました。今月22日から閉店感謝セールが開催され、特設商品や伊賀牛ステーキの割引などが予定されています。同社は、建物の撤去と再開発を検討しており、感謝セールへの参加を呼びかけています。"}
```
```json
{"points": ["三重県伊賀市のドライブインが3月31日で閉店します。", "三交興業が運営する忍者ドライブインは、利用客の減少と老朽化により閉鎖されました。", "同ドライブインは、伊賀牛ステーキの割引など、イベント開催を予定しています。"}
```
```json
{"keywords": ["三重県", "伊賀市", "大内の名阪上野ドライブイン", "三交興業", "忍者ドライブイン"]}
```

3つとも、「コードブロックは書かないでください」と指示しているのにJSONをコードブロックで囲んでいます。 このままではプログラムで読み取れません。

さらに要点では、配列を閉じる ] が抜けていて、JSONとして壊れています。 要約も、文体が「です・ます」になっているうえ、289文字で長さの決まりを超えています。「閉店しました」と、これから閉店する話を過去形で書いてしまっている点も気になります。

チューニング後の出力です。

{"summary": "三重県伊賀市の名阪上野ドライブインが3月31日で閉店することが発表された。運営する三交興業が12日発表したもので、利用客の激減や老朽化などのため、55年の歴史に幕を下ろす。同社によると、新名神高速道路の整備など道路環境や旅行形態の変化などを背景に利用客が減少した。閉店後は建物を撤去し、再開発が検討されている。伊賀牛ステーキの割引などを検討するイベントも計画中。"}
{"points": ["三重県伊賀市の名阪上野ドライブインが3月31日で閉店することになった。", "運営する三交興業が12日発表した。", "利用客の激減や老朽化などのため、55年の歴史に幕を下ろす。"]}
{"keywords": ["名阪上野ドライブイン", "三重県伊賀市", "閉店", "忍にゃん", "リニューアル"]}

コードブロックの囲みがなくなり、そのまま読み取れるJSONになりました。 要約は常体で181文字に収まり、キーワードの5つはすべて本文に出てくる語です。

一方で、要点は本文の最初の3文をほぼそのまま抜き出した形になっています。形は守れていますが、「重要な順に3つ」としては物足りません。1Bのモデルでは、ここが現状の限界だと感じています。

Gemma 4 E2B:形は守れるが、中身の決まりが守れない

Gemma 4 E2Bは、チューニング前でもJSONの形は守れていました。 少し大きいモデルだけあって、指示にはよく従います。

{"summary": "三重県伊賀市大内の名阪上野ドライブインが3月31日に閉店する。運営する三交興業は、利用客の激減や老朽化を理由に55年の歴史に幕を下ろす。同ドライブインは1966年9月に名阪国道大内インターチェンジのそばに開業し、土産物店やフードコートなどを備えた施設であった。新名神高速道路の整備や旅行形態の変化により利用客が減少し、大型バスの利用台数も大幅に減少した。2012年に改装とリニューアルが行われたが、コロナ禍が利用客減に影響を与えた。閉店後は建物の撤去と再開発が検討されている。同社は今月22日から閉店感謝セールを開催し、55周年を記念した特設商品や割引、マルシェなどを計画している。"}
{"points": ["三重県伊賀市大内の名阪上野ドライブインが3月31日に閉店する。これは利用客の激減と老朽化が主な原因であり、55年の歴史に幕を下ろすこととなる。", "新名神高速道路の整備や旅行形態の変化により利用客が大幅に減少した。大型バスの利用台数は90年度の約8万台から2018年度の約1万4千台へと激減した。", "閉店後には建物の撤去と再開発が検討されている。閉店感謝セールが開催され、特設商品や割引、マルシェなどが企画されている。"]}
{"keywords": ["名阪上野ドライブイン", "閉店", "利用客激減", "老朽化", "再開発"]}

ただし、中身の決まりは守れていません。

  • 要約が291文字あり、本文の半分を超えています
  • キーワードの「利用客激減」は、本文には出てきません。 本文にあるのは「利用客の激減」です
  • 要約の「55周年を記念した」も、本文の「55にちなんだ」から少し言い換えています

どれも小さな違いですが、文法制約では防げない部分です。

チューニング後の出力です。

{"summary": "三重県伊賀市大内の名阪上野ドライブインが3月31日で閉店する。利用客の激減や老朽化などのため、55年の歴史に幕を下ろす。同ドライブインは1966年9月に開業し、土産物店やフードコート、レストランなど十数店舗が営業している。新名神高速道路の整備など道路環境や旅行形態の変化などを背景に利用客が減少。大型バスの利用が90年度は約8万台あったが、2018年度は約1万4千台に減った。"}
{"points": ["三重県伊賀市大内の名阪上野ドライブインが3月31日で閉店することが発表された。", "利用客の激減や老朽化、コロナ禍による影響が閉店の背景にある。", "閉店後は建物を撤去し、再開発が検討されており、22日から閉店感謝セールが開催される。"]}
{"keywords": ["名阪上野ドライブイン", "閉店", "三交興業", "利用客の激減", "再開発"]}

要約は188文字に収まり、キーワードは「利用客の激減」と本文どおりの表記になりました。要点も、閉店の事実・理由・その後と、本文全体から3つを拾えています。

数字で見る

1件だけでは偶然の可能性もあるので、評価用の600件から90件(各処理30件)を取り出し、チューニング前と後で同じ文章を処理させて比べました。条件は温度0.2・文法制約なしです。

観点Gemma 3 1B 前Gemma 3 1B 後Gemma 4 E2B 前Gemma 4 E2B 後
そのまま読めるJSON0.0%100.0%92.2%100.0%
件数が正しい(要点3つ・キーワード5つ)0.0%98.9%91.1%100.0%
要約の長さを守れている—100.0%73.3%100.0%
文体が常体—98.3%100.0%98.3%
キーワードが本文に出てくる—99.3%80.7%100.0%

Gemma 3 1Bのチューニング前は、90件すべてがコードブロックで囲まれていたため、JSONとして読めたものは0件でした。中身の項目は測れないので「—」にしています。仮にコードブロックを外して読んだとしても、要点3つ・キーワード5つという件数を守れていたのは半分以下(47.8%)でした。

Gemma 4 E2Bは、先ほどの1件と同じ傾向です。形はおおむね守れていますが、要約の長さとキーワードの表記は、チューニングで大きく改善しています。 文体については元から守れていたので、ここはチューニングの効果というより、もともとの性能です。

なお、チューニング後のモデルを600件すべてで測った結果は、Gemma 3 1BでJSONの読み取り99.7%、Gemma 4 E2Bで99.3%でした。本番ではこれに文法制約を重ねているので、形が崩れることはありません。

量子化してからもう一度確かめる

サービスで動かすのは、チューニングしたモデルをGGUF形式に変換し、量子化したものです。量子化するとモデルの精度は少し落ちるので、書式が崩れていないかは量子化した後のモデルで測り直しています。

Q4_K_MとQ6_Kで比べたところ、Q4_K_Mでは同じ文を繰り返して止まらなくなる出力が600件中12件(Gemma 3 1B)あったのに対し、Q6_Kでは1件でした。そのため、現在は両モデルともQ6_Kを使っています。動かす側の工夫はllama.cppをCloud Runで動かす際に考えたことに書いています。

まとめ

小さなモデルの書式を固定するために、やったことは次のとおりです。

  • 形は文法制約で、中身の決まりは学習で守らせる
  • 学習と推論でプロンプトを1文字もずらさない
  • 応答の部分だけに誤差をかけて、「どう返すか」だけを覚えさせる

効果は、モデルの大きさによって見え方が違いました。Gemma 3 1Bでは、そもそも読み取れる形で返ってこなかったものが、そのまま使える形になりました。Gemma 4 E2Bは元から形は守れていましたが、長さや本文どおりの表記といった、文法では縛れない決まりが守れるようになりました。

一方で、要約や要点がまだ本文の抜き出しに寄っているという課題も残っています。正解データの書き直しを進めながら、今後も更新していく予定です。

実際の出力は要約サンプル集で原文とあわせて公開しています。よろしければ[実際の画面](/)でもお試しください。