DBC Tech Academy / AI / 中級

LLM の
ファインチューニング規約どおりの SQL を書くモデルを、自分のマシンで作る

プロンプトに規約を書き連ねても、10 回に 1 回は守らない。そんなモデルの癖を直すのがファインチューニングです。この講座では、自然文から社内規約どおりの SQL を書くモデルを題材に、データ作りから学習・評価・配布までを通しで行います。Mac(Apple Silicon)を主軸に、NVIDIA GPU での差分も置きます。

same question, different sql
-- 質問: 先月、売上上位 10 社の顧客名と売上合計を出して -- 学習前(規約をシステムプロンプトに書いた状態) SELECT c.name, SUM(o.amount) AS total FROM orders o, customers c WHERE o.customer_id = c.id AND o.created_at >= '2026-08-01' GROUP BY c.name ORDER BY total DESC LIMIT 10; -- 学習後(LoRA アダプタ適用) SELECT c.customer_name, SUM(o.order_amount) AS total_amount FROM orders AS o INNER JOIN customers AS c ON c.customer_id = o.customer_id WHERE o.ordered_at >= :from_date AND o.ordered_at < :to_date GROUP BY c.customer_name ORDER BY total_amount DESC LIMIT :limit_count;

規約で決めている 4 点は、暗黙結合を書かない・別名は AS・日付は半開区間で名前付きプレースホルダ・列名はスキーマの実名です。学習後は、聞き方を変えてもこの 4 点がそろいます。

What you will have

読み終えたとき、
手元にあるもの。

この講座は、考え方ではなく動くものを持ち帰るための手順書です。最初に、到達点をそのまま示します。

ollama runsql-writer

日常使いの Ollama から呼び出せる、規約に従う専用モデル。

テーブル定義はシステムプロンプトで渡し、書き方の規約だけを重みに入れたモデルです。定義が変わってもプロンプトを差し替えるだけで済み、学習し直しは要りません。この分担が、この講座で一番持ち帰ってほしい判断です。

  • ingest 用の学習データ 規約に合った SQL に質問文を付けた JSONL、300〜500 件。
  • LoRA アダプタ 数十 MB のファイル。元のモデルと分けて持ち運べます。
  • 評価の結果 学習前と学習後を同じ質問で比べた、規約違反の件数と SQL の実行成功率。
SQL を集める 質問を付けて JSONL LoRA で学習 評価セットで比べる GGUF にして Ollama へ

Mac(Apple Silicon)

統合メモリ 32 GB 以上で 7B、16 GB なら 3B。mlx-lm を使います。この講座の主軸です。

Linux + NVIDIA GPU

VRAM 16 GB 以上で 7B の QLoRA。Unsloth を使います。設定の対応表を用意しました。

かかる時間

データ作りに 1 日から数日、学習そのものは 7B・400 件で 1 時間ほど。時間の大半はデータの点検に使います。

How it works

元の重みは凍らせて、
小さな差分だけを学ぶ。

7B のモデルを丸ごと学習し直すと、重みそのもの(fp16 で約 14 GB)に加えて勾配と最適化器の状態が要り、100 GB を超えます。手元のマシンでは回りません。LoRA は、この前提を変えます。

W はそのまま、横に小さな 2 枚を置く

LoRA(Low-Rank Adaptation)は、元の重み行列 W を凍結したまま、その横に小さな 2 つの行列 A・B を置き、出力を W x + B A x と計算します。学習で動くのは A と B だけです。

W が 4096×4096 なら 1,600 万個の値ですが、ランク 16 の A(16×4096)と B(4096×16)は合わせて 13 万個で、およそ 128 分の 1 です。学習対象が小さいので勾配も最適化器の状態も小さく、必要なメモリはほぼ「元の重みを載せる分」で済みます。

学習結果(アダプタ)は数十 MB のファイルになり、元のモデルと分けて配れます。規約を変えて学習し直したときに、配布するのはこの数十 MB だけです。

入力 x W frozen 4096 × 4096 A 16 × 4096 B 4096 × 16 出力 W x B A x 灰色は学習で動きません。橙色の 2 枚だけが動きます。

触るつまみは 3 つ

つまみ意味上げると
ランク r差分行列の幅。学べる癖の細かさ細かい作法まで学べるが、覚え込み(過学習)しやすくなる
学習率1 回の更新で重みをどれだけ動かすか早く下がるが、行き過ぎて発散する
反復回数データ全体を何周するか訓練データに寄る。検証損失が上がり始めたら止め時

この 3 つが実際にどう効くかは、止め時を決める節で動かして確かめられます。

QLoRA:4 bit に落として載せる

元の重みを 4 bit に量子化して載せ、その上で LoRA を学習する方法を QLoRA と呼びます。量子化は「重みの値を粗い目盛りに丸めて保存する」ことで、精度をわずかに落として容量を 4 分の 1 にします。

7B の重みが約 4 GB になり、12〜16 GB の GPU でも学習できます。Mac の MLX でも同じ考え方で、4 bit のモデルの上に LoRA を載せます。この講座の手順は、どちらも 4 bit を前提にしています。

確かめ方。学習後にアダプタのファイルサイズを見てください。7B・ランク 16・全層で 40〜80 MB になります。この小ささが、元の重みを凍結していることの直接の証拠です。

Before you start

学習の前に、
決めることが 4 つ。

ここを飛ばすと、学習が終わったあとに「良くなった気がする」以上のことが言えなくなります。順に決めていきます。

重みに入れるもの、プロンプトで渡すもの

最初の判断です。重みに入れるのは「どう書くか」であり、「何を知っているか」ではありません。

「社内のテーブル定義を覚えさせたい」「最新の製品情報を答えさせたい」という望みは、ファインチューニングでは満たせません。数百件のデータで重みをわずかに動かしても事実は定着せず、定着したように見えても更新のたびに学習し直しになります。事実はプロンプトに入れるか、検索して渡すのが正しい置き場所です。

一方で、「暗黙結合を書かない」「日付は半開区間」「プレースホルダは :name 形式」のような作法は、プロンプトに書いても守られないことがあります。作法は毎回同じ形で現れるので、重みに入れると安定します。これがファインチューニングの仕事です。

確かめ方。分担が正しければ、学習後にテーブル定義を差し替えても、モデルは新しい列名で規約どおりの SQL を書きます。学習後に列名を間違え始めたら、分担が崩れています(症例 01)。

そもそも学習が必要か

  • まずシステムプロンプトと数件の例で試します。これで安定するなら、この講座の残りは要りません。プロンプトの調整は、学習より速く、取り消しも簡単です。
  • 次の 3 つのどれかが出たら、学習に進みます。プロンプトが長くなりすぎる。守らない回が残る。毎回の入力トークンが重い。
  • 判定は目視ではなく、この下で作る判定スクリプトで行います。プロンプトだけの出力にかけて違反がゼロなら、学習は不要です。

学習は、プロンプトの代わりではありません。学習したモデルにも、テーブル定義はプロンプトで渡し続けます。学習が減らすのは、規約の記述と、それでも守らない回のほうです。

LoRA で足りるか、その先へ進むか

LoRA はファインチューニングの一種で、動かす重みを差分行列だけに絞った方式です。動かす範囲が狭いぶん手元のマシンで回り、失敗してもアダプタを捨てれば元に戻せます。その代わり、学べるものにも幅があります。どこまでを LoRA に任せるかは、動かしたいものの大きさで決めます。

動かしたいもの手段必要なデータ規模感(7B)
出力の形、作法、分類のラベルLoRA(この講座)数百〜数千件の対話手元のマシン、数時間
作法をより強く、幅広い入力に対してフルファインチューニング数万件以上GPU 8 枚、数時間〜1 日
語彙、文体、分野の知識の分布継続事前学習数十億トークン以上の生テキストGPU 数十枚以上、数週間

LoRA で足りないと判断するのは、評価が頭打ちになったときだけです。データを 500 件まで増やし、ランクを 64 まで上げても規約違反が減らなくなったら、動かしたいものが作法の範囲を超えています。その手前で判断すると、データ不足をモデルの限界と取り違えます。逆に、扱う文章そのものがベースモデルの得意分野から遠い場合(独自の記法、古い業務文書の言い回し、独自形式のログ)は、最初から継続事前学習の領域で、LoRA では届きません。

規約を、機械で判定できる形に書く

規約判定方法
暗黙結合(FROM a, b)を書かない正規表現で FROM 句の中のカンマを検出する
別名は AS を付けるFROM orders o の形を検出する
日付範囲は >= と < の半開区間で、値は :name 形式リテラル日付と BETWEEN を検出する
SELECT * を書かない文字列一致
列名・テーブル名がスキーマに存在するEXPLAIN を実行して通るかを見る

「読みやすい」「適切な」のように判定できない語は、規約に入れないでください。判定できない規約は、学習データの点検にも評価にも使えず、書いた人以外には守れているかどうかが分かりません。

  1. 評価セットを先に作ります。学習に使わない質問を 30〜50 件用意し、各件に質問・期待する SQL・その SQL を実行したときの結果(固定データに対する行数)を付けます。
  2. 基準線を測ります。学習前(ベースモデル + 規約をシステムプロンプトに書いた状態)で評価セットを流し、規約違反の件数と実行成功率を記録します。
  3. 合格基準を決めます。学習後にこの数字を上回り、かつ実行成功率が下がらないことを合格とします。上回るだけでは足りません。規約を守るようになった代わりに動かない SQL が増えては意味がないからです。

評価セットの質問を、学習データに混ぜないでください。混ざった時点で、評価は「覚えたものを答えられるか」の測定になり、意味を失います。評価セットは別ディレクトリに置き、学習用の JSONL を作るスクリプトから物理的に届かない場所にしてください。

Building the dataset

200〜500 件の JSONL を、
全件読んで作る。

Mac の mlx-lm も NVIDIA の Unsloth も、チャット形式の JSONL を受け取ります。1 行が 1 会話です。

{"messages": [ {"role": "system", "content": "あなたは PostgreSQL の SQL を書くアシスタントです。\n次のスキーマだけを使ってください。\n\nCREATE TABLE customers (customer_id bigint PRIMARY KEY, customer_name text NOT NULL, ...);\nCREATE TABLE orders (order_id bigint PRIMARY KEY, customer_id bigint NOT NULL REFERENCES customers, order_amount numeric NOT NULL, ordered_at timestamptz NOT NULL);"}, {"role": "user", "content": "先月、売上上位 10 社の顧客名と売上合計を出して"}, {"role": "assistant", "content": "SELECT\n c.customer_name,\n SUM(o.order_amount) AS total_amount\nFROM orders AS o\n INNER JOIN customers AS c ON c.customer_id = o.customer_id\nWHERE o.ordered_at >= :from_date\n AND o.ordered_at < :to_date\nGROUP BY c.customer_name\nORDER BY total_amount DESC\nLIMIT :limit_count;"} ]}

system にスキーマを入れているのは、分担のためです。推論時に渡すものは、学習時も同じ形で渡します。学習時だけスキーマを抜くと、モデルは「スキーマを見ずに列名を推測する」癖を学び、本番で存在しない列名を書き始めます。

件数は 200〜500 件

作法を固定する目的なら、この範囲で効果が出ます。1,000 件を超えて増やしても作法の学習では伸びが止まり、代わりに質の低い例が混ざる害のほうが増えます。

件数を増やす労力は、全件を人が読んで規約違反をゼロにするほうに使ってください。学習データに違反があれば、モデルはその違反を学びます。

4 つの手順で作る

  1. 既存の SQL を集めます。社内リポジトリから規約に合致した SQL を抜き出します。合っていないものは直してから使います。
  2. 質問を逆生成します。SQL から「この SQL を求める自然文の質問」を LLM に作らせます。1 つの SQL に対して言い回しの違う質問を 2〜3 個作ります。
  3. 人が全件を読みます。質問と SQL が対応しているか、規約を守っているかを確かめます。ここに全体の半分以上の時間を使います。
  4. train と valid に分けます。85% と 15% を JSONL に書き出します。評価セットは別に置きます。

品質の確かめ方

  • 判定スクリプトを、学習データ自身にかけます。違反ゼロを確かめてから学習に進みます。ここを飛ばすと、学習で違反を強化することになります。
  • 質問の重複を除きます。同じ文が複数回あると、その例だけに強く寄ります。
  • 偏りを見ます。1 つのテーブルの例ばかりだと、他のテーブルを扱うときに作法が崩れます。テーブルの組み合わせと質問の長さの分布を確かめてください。

入れてはいけないもの

  • 顧客データそのものを例に入れないでください。学習データはモデルの重みに痕跡が残り、出力に混ざることがあります。
  • 列名・テーブル名は入れてかまいません。それが学ばせたい作法の対象です。
  • 行の値は架空のものに置き換えます。氏名・金額・メールアドレスの実値は、例として必要な形だけを残して差し替えてください。

Will it fit

学習が落ちる原因の
第一位は、メモリ。

始める前に見積もります。重みは固定費、活性化(途中計算の保存)は系列長とバッチに比例する変動費です。この 2 つの性質の違いが分かると、落ちたときにどのつまみを触るかが決まります。 値を動かして、自分のマシンで回るかを確かめてみましょう。

モデルの重み アダプタと最適化器 活性化 作業領域

Mac では統合メモリ、NVIDIA では VRAM の量です。OS と他のプロセスが使う分があるため、実際に学習へ回せるのは全体の 8 割ほどです。

この図が再現しているのは、各項目が何に比例して増えるかです。重みはパラメータ数と精度で、活性化は系列長とバッチで決まります。絶対値はモデルの構造(隠れ次元・層数・注意ヘッド数)とフレームワークの実装で 2 割前後ずれます。実際に回す前に、系列長を短くした 10 反復だけ走らせて実測を取り、その値で目盛りを合わせてください。Mac では mlx-lm が学習ログに Peak mem を出し、NVIDIA では watch -n 1 nvidia-smi で使用 VRAM が見られます。

  1. 重みは固定費です。パラメータ数 × 1 個あたりのバイト数(fp16 は 2、8 bit は 1、4 bit は 0.5 に目盛りの分を足して約 0.55)で決まり、学習中は変わりません。ここを減らす手は、モデルを小さくするか、精度を落とすかの 2 つだけです。
  2. 活性化は変動費です。バッチ × 系列長に比例します。落ちたときに最初に触るのはここで、バッチを半分にするのが最も副作用が小さい手です。
  3. 勾配チェックポイントは、活性化を捨てて再計算します。メモリは 4 分の 1 ほどになり、速度は 2〜3 割落ちます。メモリが足りているうちは切っておき、足りなくなったら入れてください。
  4. アダプタと最適化器の分は、ランクにほぼ比例します。ただし元の重みに比べて小さいので、メモリ不足をここで解決しようとしても効きません。ランクは学習曲線で決めてください。

Training on Mac

Mac で学習する。

Apple Silicon の Mac で mlx-lm を使います。統合メモリ 32 GB 以上を想定します。16 GB のマシンでも、モデルを 3B にすれば同じ手順がそのまま通ります。

準備

python3 -m venv .venv && source .venv/bin/activate pip install mlx-lm # ベースモデルを取得し、4 bit に量子化して手元に置く mlx_lm.convert \ --hf-path Qwen/Qwen2.5-Coder-7B-Instruct \ -q --q-bits 4 \ --mlx-path ./base-7b-4bit

SQL が題材なので、コード系の指示追従モデルを選びます。ここは自由に差し替えてかまいませんが、変えたら評価をやり直してください。

データの置き方

data/ train.jsonl # 学習用(85%) valid.jsonl # 検証用(15%)

mlx-lm はこのディレクトリ名とファイル名を固定で読みます。評価セットはここに置かず、別の場所に隔離してください。

学習

mlx_lm.lora \ --model ./base-7b-4bit \ --train \ --data ./data \ --fine-tune-type lora \ --num-layers 16 \ --batch-size 4 \ --iters 600 \ --learning-rate 1e-5 \ --steps-per-eval 50 \ --grad-checkpoint \ --mask-prompt \ --adapter-path ./adapters
引数意味決め方
--num-layers 16LoRA を付ける層の数。後ろから数えます全層にすると学べる幅が広がる代わりにメモリが増えます。まず 16 で始めます
--iters 600反復回数データ 400 件・バッチ 4 なら 1 周が 100 反復。多めに指定して、曲線で止め時を見ます
--steps-per-eval 5050 反復ごとに検証損失を出します曲線を読むために必ず付けます。これがないと止め時が分かりません
--mask-prompt損失を assistant の応答部分だけで計算します質問文やスキーマの再現は学ばせません。付けないとスキーマを暗記します
--grad-checkpoint活性化を捨てて再計算しますメモリが足りているなら外して速度を取ります

出てくるログ

Iter 50: Train loss 1.412, Learning Rate 1.000e-05, It/sec 0.412, Tokens/sec 812.3, Peak mem 7.812 GB Iter 50: Val loss 1.187, Val took 22.1s Iter 150: Train loss 0.784, ... Iter 150: Val loss 0.612, Val took 22.0s Iter 300: Train loss 0.449, ... Iter 300: Val loss 0.502, Val took 21.8s <- ここが底 Iter 450: Train loss 0.318, ... Iter 450: Val loss 0.548, Val took 22.2s Iter 600: Train loss 0.273, ... Iter 600: Val loss 0.601, Val took 22.0s

300 反復で検証損失が底を打ち、450 以降は上がっています。訓練損失だけが下がり続けているのは、モデルが規約を学ぶ段階を過ぎて訓練データを覚え込みに入った合図です。読み方は次の次の節で扱います。mlx-lm は --save-every の間隔で途中のアダプタを番号付きで保存するので、底の時点のファイルを選べます。

その場で試す

mlx_lm.generate \ --model ./base-7b-4bit \ --adapter-path ./adapters \ --system-prompt "$(cat prompts/system.txt)" \ --prompt "先月、売上上位 10 社の顧客名と売上合計を出して"

--adapter-path を外すと学習前の出力が出るので、同じ質問で並べて比べられます。

--adapter-path に既存のディレクトリを指定すると、その中のアダプタは上書きされます。設定を変えて試行を重ねるときは、ディレクトリ名を adapters-r16、adapters-r32 のように分けてください。上書きしてしまうと、底の時点のアダプタごと失われます。

Training on NVIDIA

NVIDIA GPU で学習する。

Linux + NVIDIA GPU では Unsloth を使います。VRAM 16 GB で 7B の QLoRA が回ります。手順の構造は Mac と同じで、違うのは「量子化して載せる」を読み込み時に行うことと、設定を Python で書くことです。

from unsloth import FastLanguageModel from unsloth.chat_templates import train_on_responses_only from trl import SFTTrainer, SFTConfig from datasets import load_dataset model, tokenizer = FastLanguageModel.from_pretrained( model_name="Qwen/Qwen2.5-Coder-7B-Instruct", max_seq_length=2048, load_in_4bit=True, # QLoRA: 重みを 4 bit で載せる ) model = FastLanguageModel.get_peft_model( model, r=16, lora_alpha=16, target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], use_gradient_checkpointing="unsloth", ) # JSONL を読み、モデルのチャットテンプレートで 1 本の文字列にする dataset = load_dataset("json", data_files={ "train": "data/train.jsonl", "valid": "data/valid.jsonl"}) dataset = dataset.map(lambda ex: { "text": tokenizer.apply_chat_template(ex["messages"], tokenize=False)}) trainer = SFTTrainer( model=model, tokenizer=tokenizer, train_dataset=dataset["train"], eval_dataset=dataset["valid"], args=SFTConfig( output_dir="outputs", per_device_train_batch_size=2, gradient_accumulation_steps=4, # 実効バッチ 8 num_train_epochs=3, learning_rate=2e-4, eval_strategy="steps", eval_steps=25, save_steps=25, save_total_limit=5, logging_steps=5, fp16=True, ), ) # 損失を assistant の応答部分だけで計算する trainer = train_on_responses_only( trainer, instruction_part="<|im_start|>user\n", response_part="<|im_start|>assistant\n", ) trainer.train() model.save_pretrained("adapters")

Mac との対応

Mac / mlx-lmNVIDIA / Unsloth意味
--num-layerstarget_modulesLoRA を付ける場所。mlx-lm は「後ろから何層か」、Unsloth は「各層のどの行列か」で指定する
(既定のまま)lora_alpha差分の効き具合の倍率。ランクとの比で決める
--itersnum_train_epochsどれだけ回すか
--learning-rate 1e-5learning_rate=2e-4学習率。既定値の桁が違う
--mask-prompttrain_on_responses_only応答部分だけで損失を取る
--grad-checkpointuse_gradient_checkpointing活性化を再計算してメモリを減らす

lora_alpha は、ランクとの比で決める

差分 B A x には alpha / r の倍率が掛かります。alpha を上げると差分が強く効き、ランクを上げると同じ alpha でも 1 つあたりの効きが薄まります。alpha = r か alpha = 2r のどちらかで始めて、以後はランクを変えたら alpha も同じ比で動かします。比を固定しておけば、ランクを変えたときに学習率を取り直さずに済みます。

上のコードは r = 16、alpha = 16 で比が 1 です。学習が遅いと感じたら学習率ではなく alpha を 2 倍にするほうが、発散しにくく戻しやすい手です。

target_modules は、注意機構だけか MLP までか

1 つの層には、注意機構の 4 つの行列(q_proj k_proj v_proj o_proj)と、MLP の 3 つ(gate_proj up_proj down_proj)があります。注意機構だけに付けると、アダプタは小さく、学べるのは「どこを見るか」の癖まで。MLP まで付けると、アダプタは 2〜3 倍になり、「何を出すか」の癖まで学べます。

この講座は 7 つすべてに付けています。SQL の作法は出力の形そのものなので、MLP まで要るからです。分類のラベル付けのような「見る場所」で決まる仕事なら、注意機構だけで足りることがあります。どちらにするかは、両方で評価セットを流して決めてください。

学習率の桁が違うのは、既定値の出どころが違うからです。mlx-lm は量子化された重みに対する LoRA の既定として小さめの値を採り、Unsloth と trl は fp16 の LoRA での慣行値を既定にしています。どちらも「既定から始めて、曲線で決める」が正しい使い方で、片方の値をもう片方に持ち込むと発散するか、ほとんど学習しないかのどちらかになります。

output_dir にチェックポイントが 25 ステップごとに保存されます。1 つが数十 MB から数百 MB になるため、長く回すとディスクを使い切ります。上のコードで save_total_limit を付けているのはこのためです。

When to stop

止め時は、
2 本の線が教えてくれる。

学習の成否は、最終的な損失の値ではなく、訓練損失と検証損失という 2 本の曲線の関係で判断します。 データ件数・学習率・ランクを動かして、止め時がどこへ移るかを確かめてみましょう。

-止め時(反復)
-そのときの周回数
-底の検証損失
-底以降の無駄な反復

バッチサイズは 4 で固定しています。1 周(エポック)にかかる反復回数は、件数 ÷ 4 です。

この図が再現しているのは、2 本の曲線がどちらへ動くかと、底の位置が要因でどう移るかです。損失の絶対値と反復数の目盛りは、モデル・データ・トークン数で変わり、実物とは一致しません。実際の学習では --steps-per-eval の間隔で出る検証損失をそのまま並べ、底を過ぎて 2 回連続で上がったら、底の時点のアダプタを採ってください。

2 本の線が意味するもの

訓練損失は、学習に使ったデータでの誤差です。反復を重ねれば必ず下がります。下がったこと自体は、何の証拠にもなりません。

検証損失は、学習に使っていないデータでの誤差です。途中まで下がり、あるところから上がり始めます。この上がり始めた点が、モデルが「規約を学ぶ」から「訓練データを丸暗記する」に切り替わった点です。

丸暗記に入ると何が起きるか。学習データにある質問には完璧に答え、少しでも言い回しの違う質問には崩れた SQL を返すようになります。評価セットでは数字が落ち、本番では「たまに変な答えを返す」形で現れます。

下がらないときの見分け方

  • 訓練損失が最初からほとんど動かない 学習率が低すぎるか、--mask-prompt の対象がずれてシステムプロンプトの再現を学んでいます。まず学習率を 3 倍にして 50 反復だけ試します。
  • 訓練損失が上下に暴れる 学習率が高すぎます。3 分の 1 にしてください。
  • 検証損失が最初から訓練損失より大きく離れている 検証データの分布が訓練データと違います。別のテーブルの例ばかりが検証に寄っていないかを確かめ、分け直します。

Does it actually work

損失ではなく、
規約と実行で確かめる。

損失は「訓練データに似た出力を出せるか」の指標であって、「規約を守るか」「SQL が動くか」を直接は測っていません。先に作った評価セットで測ります。

  1. 評価セット 30〜50 件を、学習前と学習後の両方に流します。学習前はベースモデル + 規約を書いたシステムプロンプト、学習後は同じシステムプロンプト + アダプタです。条件をそろえないと、何が効いたのか分かりません。
  2. 出力 SQL に判定スクリプトをかけ、規約違反の件数を数えます。
  3. 出力 SQL を固定データの入った PostgreSQL で EXPLAIN し、通るかを数えます。列名・テーブル名が実在するかがここで分かります。
  4. 通ったものを実行し、期待する結果と行数・値が一致するかを数えます。

結果の読み方

指標(50 件中)学習前学習後
規約違反171
EXPLAIN が通る4647
結果が一致3941

規約違反が減り、実行成功率が下がっていなければ合格です。規約違反は減ったのに EXPLAIN の失敗が増えたなら、モデルが列名を推測する癖を学んでいます。学習データのシステムプロンプトにスキーマが入っているかを確かめてください。

温度は 0 で流す

評価は temperature 0 で行います。温度を上げると出力が揺れ、規約違反の件数が実行のたびに変わって、学習前後の比較ができなくなります。

本番で温度を上げて使う予定があるとしても、評価は 0 で固定してください。比較したいのはモデルの変化であって、サンプリングの揺れではありません。

評価用の PostgreSQL は、本番と切り離した固定データのものを使ってください。モデルの出力には DELETE や UPDATE が混ざることがあり、そのまま実行すると消えます。実行前に文頭が SELECT であることを判定に入れてください。

Ship it

Ollama に載せて、
使い続ける。

学習したアダプタを、日常使いの Ollama で動かします。経路は 2 つあり、配布の軽さが違います。

経路 A:結合して 1 つのモデルにする

# アダプタを元の重みに足し込む mlx_lm.fuse --model ./base-7b-4bit \ --adapter-path ./adapters \ --save-path ./fused-7b --dequantize # GGUF にして 4 bit に量子化する python convert_hf_to_gguf.py ./fused-7b \ --outfile ./fused-7b-f16.gguf ./llama-quantize ./fused-7b-f16.gguf \ ./fused-7b-q4_k_m.gguf Q4_K_M

Unsloth なら同じことが 1 行です。

model.save_pretrained_gguf( "fused-7b", tokenizer, quantization_method="q4_k_m")

経路 B:アダプタだけを被せる

元のモデルを Ollama に既に持っているなら、アダプタだけを変換して被せられます。

python convert_lora_to_gguf.py ./adapters \ --outfile ./sql-writer-lora.gguf
FROM qwen2.5-coder:7b ADAPTER ./sql-writer-lora.gguf

アダプタは数十 MB なので、規約を変えて学習し直したときの配布が軽くなります。ベースの量子化と学習時の量子化が違うと出力が少しずれるので、評価を Ollama 上でもう一度流してください。

Modelfile と登録

FROM ./fused-7b-q4_k_m.gguf PARAMETER temperature 0 SYSTEM """ あなたは PostgreSQL の SQL を書くアシスタントです。 次のスキーマだけを使ってください。 (学習データと同じスキーマを書く) """
ollama create sql-writer -f Modelfile ollama run sql-writer "先月、売上上位 10 社の顧客名と売上合計を出して"

チャットテンプレートが一致しているかを確かめてください。学習時のテンプレート(<|im_start|> などの区切り)と Ollama が使うテンプレートが違うと、学習した作法が出ません。ollama show sql-writer --template で確認し、違えば Modelfile の TEMPLATE に学習時と同じものを書きます。

動かし始めてから

  • 規約が変わったとき。学習データの該当例を直し、判定スクリプトで違反ゼロを確かめてから学習し直します。経路 B なら、配布はアダプタの差し替えだけで済みます。
  • テーブル定義が変わったとき。システムプロンプトのスキーマを差し替えるだけで、学習し直しは要りません。これが分担を守ったことの見返りです。
  • ベースモデルを更新するとき。アダプタはベースの重みに対する差分なので、別のベースには載りません。新しいベースで学習し直し、評価を流し直します。
  • 評価を続けます。評価セットと判定スクリプトをリポジトリに置き、モデルを差し替えるたびに流します。規約違反の件数と実行成功率の推移を残すと、劣化に気付けます。
  • 用途が増えたら、アダプタを分けます。SQL の作法、問い合わせ返信の文体、伝票の分類のように仕事が増えたら、1 つのアダプタに全部を学習させず、ベースは 1 つ、アダプタは用途ごとに持ちます。経路 B なら Modelfile の ADAPTER 行を替えるだけで sql-writer と reply-writer を同じベースから作れ、ディスクはベース 1 つ分で済みます。1 つに混ぜると、片方の規約を直したときにもう片方の評価が動き、原因が追えなくなります。

事故になりやすい操作

  • ollama create は同じ名前の既存モデルを置き換えます。以前の版を残すなら sql-writer:v2 のようにタグを分けてください。置き換えてしまうと、評価が悪化したときに戻す先がありません。
  • mlx_lm.fuse --dequantize が書き出す fp16 の重みは、7B で約 15 GB になります。GGUF への変換が終わったら消してください。試行を重ねるとディスクを使い切ります。
  • Modelfile の SYSTEM を学習時と変えないでください。変えると、学習で固定した作法の出方が変わります。変えたなら評価を流し直します。

Read the symptom

症状から、
原因を見抜く。

ファインチューニングでよく持ち込まれる 3 つの症状です。それぞれ、どこに原因があるのかを考えてみましょう。

Myth vs. reality

ファインチューニングの、
3 つの勘違い。

経験のある人ほど、この 3 つで判断を誤ります。

MISCONCEPTION 01

「学習させれば知識が増える」

増えません。数百件のデータで動く重みはわずかで、事実は定着しません。定着したように見えても、更新のたびに学習し直しになります。事実はプロンプトか検索で渡し、重みには作法を入れてください。この分担を間違えると、学習の手間をかけて存在しない列名を書くモデルができます。

MISCONCEPTION 02

「データは多いほど良い」

作法の固定では 200〜500 件で頭打ちになり、それ以上は質の低い例が混ざるリスクだけが増えます。件数を集める労力は、全件を人が読んで規約違反をゼロにするほうに使ってください。学習データに違反が 1 割あれば、モデルはその違反も学びます。

MISCONCEPTION 03

「損失が下がれば成功」

訓練損失は回せば必ず下がります。下がったことは、規約を学んだ証拠にも、丸暗記した証拠にもなります。見るべきは検証損失の底で、合否を決めるのは規約違反の件数と実行成功率です。損失の値そのものは、モデルの良し悪しを表しません。

Knowledge check

8問で、理解を
確かめよう。

答えを当てるだけでなく、なぜそう判断できるのかを説明できれば合格です。

YOUR PROGRESS 01 / 08

選択肢を1つ選んでください。

The whole thing in one line

作法は重みに、
事実はプロンプトに。

分担を決める
データを点検する
底で止める
評価して載せる