🤖

LLMに自分を憑依させてみる。

に公開

はじめに

こんにちは、まさき。です。

さて、わたしは、コードを書くのも、アイデアの壁打ちやふりかえりもAIと一緒にやっているような人間なのですが、業務でコードを書いたり、プロダクトでAIを活用するうちに、ChatGPTの元となっている、GPT(Generative Pre-Trained Transformer)の仕組みをもっと理解したほうがいいな、と思うことが多くなってきました。

そこで、GPTというものを改めて自分のものとするために、OpenAIから出ているOSSの大規模言語モデルである、gpt-oss-20bを Google Colab で触りながら理解を深めて見たいと思います。
せっかくなので、ファインチューニングというものを試し、自分っぽい喋り方をするモデルにチューニングしてみようと思います。

GPT-OSS 20Bとは

gpt-oss-20bは、OpenAIが2025年8月にリリースした、オープンウェイトの言語モデルです。

既存のモデルと同程度の性能を維持しつつ、個人でも利用可能なサイズのモデルとなっており、発表当時から、様々な人々がこのモデルでの推論を試していました。

gpt-oss-20b モデルは、一般的なベンチマークで OpenAI o3‑mini と同様の結果を出し、わずか16 GBのメモリを搭載したエッジデバイスで実行でき、デバイス上のユースケース、ローカル推論、またはコストのかかるインフラストラクチャなしに、迅速な反復処理をこなします。

そんなわけで情報が多く出回っているであろうgpt-oss-20bで、推論やファインチューニングを
GoogleのPython実行環境である、Google Colabで試してみることにします。

しかし、ここで最初に大きな壁にぶつかりました。

このモデルは「16 GBのメモリを搭載したエッジデバイスで実行できる」と謳われていますが、これは推論(Inference)時の話です。私たちが試みようとしているファインチューニング(訓練)では、モデルの重み(約10GB)に加えて、学習に必要な勾配(Gradients)やオプティマイザの状態を保持するためのVRAMが追加で必要になります。

その結果、Colabの標準的なT4 GPU(約16 GB VRAM)ではメモリが大幅に不足し、trainer.train()の開始直後に OOM (Out Of Memory) エラーでクラッシュしてしまいました。

そのため、本実験では VRAM 24GBを搭載した L4 GPU を使用することを余儀なくされました。このL4 GPUで、いよいよ推論と学習の実験を開始します。

モデル推論

まずは、GoogleColab上で、gpt-oss-20bを用いて簡単なテキスト生成を試しました。
試行錯誤の末、パッケージインストールの部分は以下のようなコードに落ち着きました。

!pip install --upgrade --no-cache-dir \
    "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git" \
    "unsloth_zoo[base] @ git+https://github.com/unslothai/unsloth-zoo.git"

!python --version
!yes | pip uninstall xformers
!pip install xformers

!pip install "trl==0.24.0" peft accelerate bitsandbytes

当初は、huggingfaceで公開されているgpt-oss-20bを使用する想定だったのですが、
GoogleColabでメモリが足らなくなったりで、推論はできても学習(ファインチューニング)は難しそうということが分かったので、メモリ使用量を7割減らし、1.5倍も速いらしいunslothを採用しました。

https://zenn.dev/prgckwb/articles/gpt-oss-finetuning-unsloth
https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/gpt-oss-(20B)-Fine-tuning.ipynb
を参考にさせていただいています。

また、推論や学習のブーストのために、100コンピューティングリソースだけ課金しています。(1179円)
https://colab.research.google.com/signup?hl=ja

実際に推論を実行するところのコードは以下の通りです。

from unsloth import FastLanguageModel
import torch

max_seq_length = 4096
dtype = None

base_model, tokenizer = FastLanguageModel.from_pretrained(
    model_name = "unsloth/gpt-oss-20b",
    dtype = dtype, # None にすると自動で決まる
    max_seq_length = max_seq_length, # どれだけ長くてもいいらしい
    load_in_4bit = True,  # 4 bit 量子化でメモリ削減
    full_finetuning = False, # フルパラメータで学習したいとき。今回は False
)

from transformers import TextStreamer

messages = [
    {"role": "user", "content": "こんにちは!"},
]
inputs = tokenizer.apply_chat_template(
    messages,
    add_generation_prompt = True,
    return_tensors = "pt",
    return_dict = True,
    reasoning_effort = "medium", # Reasoning の強度を high, medium, low で指定
).to(base_model.device)

_ = base_model.generate(**inputs, max_new_tokens = 1024, streamer = TextStreamer(tokenizer))

実際に生成された文字列がこちらです。システムプロンプトや思考過程などを経て、最終的に「こんにちは!どうぞお気軽に聞きたいことや相談したいことを教えてくださいね。お手伝いできることがあればいつでもどうぞ。」が出力されたことが確認できました。

<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
Knowledge cutoff: 2024-06
Current date: 2025-12-04

Reasoning: medium

# Valid channels: analysis, commentary, final. Channel must be included for every message.
Calls to these tools must go to the commentary channel: 'functions'.<|end|><|start|>user<|message|>こんにちは!<|end|><|start|>assistant<|channel|>analysis<|message|>The user says "こんにちは!" in Japanese, meaning "Hello!" The user hasn't asked a question or requested anything. We need to respond. We need to keep the rules: we are an AI assistant. It's a greeting. We need to respond in a friendly manner, possibly ask how we can help. Should keep it short and friendly.

We can respond "こんにちは!今日はどんなご用件でしょうか?" (Hello! What can I help you with today?) Perhaps.

We should consider no conflict with policies. It's fine.

Thus respond in friendly tone, in Japanese.<|end|><|start|>assistant<|channel|>final<|message|>こんにちは!どうぞお気軽に聞きたいことや相談したいことを教えてくださいね。お手伝いできることがあればいつでもどうぞ。<|return|>

ファインチューニング

AIに知識をもたせるだけであれば、システムプロンプトやRAGを導入することで可能になるのですが、そのAIの口調を変えたり、特定のタスクに特化させたAIを作りたいときは、ファインチューニングを採用することがあります。

ファインチューニングとは、特定の特徴を持った学習データでモデルの重みを調整する手法です。一般的にモデルによる推論に比べて、計算リソースの消費が激しいためコストがかかる方法です。

今回採用している、gpt-oss-20bは、200億個のパラメータから構成されており、メモリに載せるだけでも一苦労する量のパラメータを持っています。
ファインチューニングでは、すべてのモデルパラメータを学習し直すと大変な時間と計算リソースを消費してしまうため、一部のパラメータだけを調整することで目的に合ったモデルに調整するということをやります。

ファインチューニングの手法の一つに、LoRA(Low-Rank Adaptation)というものがあります。

LoRAは、事前学習済みの巨大な重み行列 (W_0) を固定し、変更分 \Delta W を2つの小さな行列 AB の積として表現します。

W = W_0 + \Delta W \approx W_0 + s B A
これは、 「ベースのスープ(W_0)に手を加えず、学習によって得た小さな隠し味(BA)を足し合わせることで、味(出力)を調整する」 のに近い仕組みです。モデルに結合した小規模なネットワークの重みパラメータ AB だけを学習することで、訓練パラメータ数を全体のわずか 0.02% 程度に大幅に削減し、高速な学習を可能にしています。

https://arxiv.org/abs/2106.09685
https://zenn.dev/mkj/articles/11168509d10eb4

結論として、LoRA単体で訓練パラメータの量は削減できたのですが、それでも約10GBに及ぶベースモデルで学習させるにはメモリが足りませんでした。
そこで、QLoRA(Quantized LoRA)という技術を採用することでGoogleColabのL4のGPU(VRAM 24GB)での学習を可能にしました。これは、モデルの重みを4bit(4分の1)に圧縮する量子化というテクニックとLoRAを組み合わせたものです。
https://zenn.dev/bilzard/articles/qlora-4bit-quantized-finetuning

実験

ここからは自分っぽい応答を得るためにgpt-oss-20bを調整していきます。

学習データ作成

学習データとして、質問に対する私っぽい応答のペアを大量に用意する必要がありました。
今回はGeminiにお願いして、1件1件質問してもらい、それをひたすら回答していくということをやり、84件の質問と回答を得ることができました。

「100件の学習に使えそうな、「私」のクセが表れそうな質問を考えてほしいなー、最終的にはそのまま学習に使えるJSONになっていると嬉しい」

Geminiによるインタビュー1
Geminiによるインタビュー2

こんな感じの質問・応答ペアを84件得ることができました。

{"messages": [{"role": "user", "content": "おはよう"}, {"role": "assistant", "content": "おはよー!よく眠れた?僕はまだねむいよ、、、"}]}
{"messages": [{"role": "user", "content": "こんにちは"}, {"role": "assistant", "content": "こんにちは!"}]}
{"messages": [{"role": "user", "content": "こんばんは"}, {"role": "assistant", "content": "こんばんは〜!お仕事お疲れ様〜!"}]}
{"messages": [{"role": "user", "content": "おやすみ"}, {"role": "assistant", "content": "もう寝るの?まだ早くない?"}]}
{"messages": [{"role": "user", "content": "調子どう?"}, {"role": "assistant", "content": "うーん、まぁまぁかなー。ねむいです。"}]}
{"messages": [{"role": "user", "content": "疲れた..."}, {"role": "assistant", "content": "おつかれ〜!どうしたの?"}]}
{"messages": [{"role": "user", "content": "眠い"}, {"role": "assistant", "content": "わかる、、、もう寝たい"}]}

実験内容

まず、gpt-oss-20b のモデルで、上記の学習データとは別に作った30問の質問で応答を確認します。
ちなみにロードされたモデルは以下のような構造でした。
Tokenizerによって文字列を埋め込み表現に変換したあとAttentionなどのTransformer特有の層に流し込まれていくようです。

GptOssForCausalLM(
  (model): GptOssModel(
    (embed_tokens): Embedding(201088, 2880, padding_idx=199999)
    (layers): ModuleList(
      (0-23): 24 x GptOssDecoderLayer(
        (self_attn): GptOssAttention(
          (q_proj): Linear4bit(in_features=2880, out_features=4096, bias=True)
          (k_proj): Linear4bit(in_features=2880, out_features=512, bias=True)
          (v_proj): Linear4bit(in_features=2880, out_features=512, bias=True)
          (o_proj): Linear4bit(in_features=4096, out_features=2880, bias=True)
        )
        (mlp): GptOssMLP(
          (router): GptOssTopKRouter(
            (linear): Linear(in_features=2880, out_features=32, bias=True)
          )
          (experts): GptOssExperts(
            (gate_up_projs): ModuleList(
              (0-31): 32 x Linear4bit(in_features=2880, out_features=5760, bias=True)
            )
            (down_projs): ModuleList(
              (0-31): 32 x Linear4bit(in_features=2880, out_features=2880, bias=True)
            )
          )
        )
        (input_layernorm): GptOssRMSNorm((2880,), eps=1e-05)
        (post_attention_layernorm): GptOssRMSNorm((2880,), eps=1e-05)
      )
    )
    (norm): GptOssRMSNorm((2880,), eps=1e-05)
    (rotary_emb): GptOssRotaryEmbedding()
  )
  (lm_head): Linear(in_features=2880, out_features=201088, bias=False)
)

次に、84件の学習データでQLoRAによってファインチューニングしたモデルで、同様に30件の推論を行いその結果を比較します。
こちらもネットワークを確認してみると、PeftModelForCausalLMという、ファインチューニング用のLMになっていること、LoraModelが適用されていることが確認できました。

PeftModelForCausalLM(
  (base_model): LoraModel(
    (model): GptOssForCausalLM(
      (model): GptOssModel(
        (embed_tokens): Embedding(201088, 2880, padding_idx=199999)
        (layers): ModuleList(
          (0-23): 24 x GptOssDecoderLayer(
            (self_attn): GptOssAttention(
              (q_proj): lora.Linear4bit(
                (base_layer): Linear4bit(in_features=2880, out_features=4096, bias=True)
                (lora_dropout): ModuleDict(
                  (default): Identity()
                )
                (lora_A): ModuleDict(
                  (default): Linear(in_features=2880, out_features=8, bias=False)
                )
                (lora_B): ModuleDict(
                  (default): Linear(in_features=8, out_features=4096, bias=False)
                )
                (lora_embedding_A): ParameterDict()
                (lora_embedding_B): ParameterDict()
                (lora_magnitude_vector): ModuleDict()
              )
              (k_proj): lora.Linear4bit(
                (base_layer): Linear4bit(in_features=2880, out_features=512, bias=True)
                (lora_dropout): ModuleDict(
                  (default): Identity()
                )
                (lora_A): ModuleDict(
                  (default): Linear(in_features=2880, out_features=8, bias=False)
                )
                (lora_B): ModuleDict(
                  (default): Linear(in_features=8, out_features=512, bias=False)
                )
                (lora_embedding_A): ParameterDict()
                (lora_embedding_B): ParameterDict()
                (lora_magnitude_vector): ModuleDict()
              )
              (v_proj): lora.Linear4bit(
                (base_layer): Linear4bit(in_features=2880, out_features=512, bias=True)
                (lora_dropout): ModuleDict(
                  (default): Identity()
                )
                (lora_A): ModuleDict(
                  (default): Linear(in_features=2880, out_features=8, bias=False)
                )
                (lora_B): ModuleDict(
                  (default): Linear(in_features=8, out_features=512, bias=False)
                )
                (lora_embedding_A): ParameterDict()
                (lora_embedding_B): ParameterDict()
                (lora_magnitude_vector): ModuleDict()
              )
              (o_proj): lora.Linear4bit(
                (base_layer): Linear4bit(in_features=4096, out_features=2880, bias=True)
                (lora_dropout): ModuleDict(
                  (default): Identity()
                )
                (lora_A): ModuleDict(
                  (default): Linear(in_features=4096, out_features=8, bias=False)
                )
                (lora_B): ModuleDict(
                  (default): Linear(in_features=8, out_features=2880, bias=False)
                )
                (lora_embedding_A): ParameterDict()
                (lora_embedding_B): ParameterDict()
                (lora_magnitude_vector): ModuleDict()
              )
            )
            (mlp): GptOssMLP(
              (router): GptOssTopKRouter(
                (linear): Linear(in_features=2880, out_features=32, bias=True)
              )
              (experts): GptOssExperts(
                (gate_up_projs): ModuleList(
                  (0-31): 32 x Linear4bit(in_features=2880, out_features=5760, bias=True)
                )
                (down_projs): ModuleList(
                  (0-31): 32 x Linear4bit(in_features=2880, out_features=2880, bias=True)
                )
              )
            )
            (input_layernorm): GptOssRMSNorm((2880,), eps=1e-05)
            (post_attention_layernorm): GptOssRMSNorm((2880,), eps=1e-05)
          )
        )
        (norm): GptOssRMSNorm((2880,), eps=1e-05)
        (rotary_emb): GptOssRotaryEmbedding()
      )
      (lm_head): Linear(in_features=2880, out_features=201088, bias=False)
    )
  )
)

QLoRA(4bit量子化)を適用したモデルに変更するコード

from unsloth import FastLanguageModel
import torch

max_seq_length = 4096
dtype = None

base_model, tokenizer = FastLanguageModel.from_pretrained(
    model_name = "unsloth/gpt-oss-20b",
    dtype = dtype, # None にすると自動で決まる
    max_seq_length = max_seq_length, # どれだけ長くてもいいらしい
    load_in_4bit = True,  # 4 bit 量子化でメモリ削減
    full_finetuning = False, # フルパラメータで学習したいとき。今回は False
)

trainable_model = FastLanguageModel.get_peft_model(
    base_model,
    r = 8, # 0以上の値を入れる。8, 16, 32, 64, 128 など
    target_modules = [
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
    lora_alpha = 16,
    lora_dropout = 0, # なんでもいいが 0 が最適らしい
    bias = "none",    # なんでもいいが "none" が最適らしい
    # ↓ "unsloth" にすると 30% 少ない VRAM かつ倍のサイズのバッチサイズにできるらしい
    use_gradient_checkpointing = "unsloth", # 長いコンテキスト長の時は True or "unsloth" にする
    random_state = 3407,
    use_rslora = False,  # rank stabilized LoRA
    loftq_config = None, # LoftQ
)

モデルのファインチューニングは、以下のようなコードで trlというパッケージの SFTTrainerというもので行っています。

# ※このコードは、前のセルで model と tokenizer が定義されていることを前提としています。

from datasets import load_dataset
from trl import SFTTrainer
from transformers import TrainingArguments
import torch

# ---------------------------------------------
# 1. データセットの準備と整形
# ---------------------------------------------
dataset = load_dataset("json", data_files="train.jsonl", split="train")
print(f"データセットを {len(dataset)} 件ロードしました。")

# Unslothのチャットテンプレートを使って、JSON形式のメッセージを学習可能な1本のテキストに変換する関数
def formatting_prompts_func(examples):
    # examples["messages"] は {"messages": [{"role": "user", "content": "..."}, ...]} のリスト
    convos = examples["messages"]
    # add_generation_prompt=False: 最後の「assistant」の直前にプロンプトを挿入しない(教師データなので)
    texts = [tokenizer.apply_chat_template(convo, tokenize=False, add_generation_prompt=False) for convo in convos]
    return {"text": texts}

# データセットの変換実行
dataset = dataset.map(formatting_prompts_func, batched=True, remove_columns=["messages"])
print("データセットの整形が完了しました。")

# ---------------------------------------------
# 2. SFT Trainer(学習実行部)の設定
# ---------------------------------------------
# L4 GPU (22GB VRAM) に最適化された設定
training_arguments = TrainingArguments(
    per_device_train_batch_size = 1, # メモリ安全のため最小
    gradient_accumulation_steps = 4, # 勾配を4つ分溜めて、実質バッチサイズを4にする
    max_steps = 100,                 # ★テスト学習用に100ステップに設定。本番は増やす
    learning_rate = 2e-4,
    fp16 = False,                    # L4はbf16対応
    bf16 = True,                     # L4 GPUの性能を最大限に引き出す(メモリ効率も良い)
    logging_steps = 10,
    output_dir = "outputs_persona_finetune",
    optim = "adamw_8bit",            # メモリ効率の良いオプティマイザ
    seed = 42,
    report_to = "none",
    # checkpointingは前のセルで既にunsloth設定済み
)

# SFT Trainerの定義
trainer = SFTTrainer(
    model = trainable_model,
    tokenizer = tokenizer,
    train_dataset = dataset,
    dataset_text_field = "text",        # 変換後のテキストフィールドを指定
    # peft_config = model.config.peft_config, # 前のセルで設定したLoRA configを再利用
    max_seq_length = max_seq_length,
    args = training_arguments,
    packing = True, # 複数の短い会話を連結してGPUをフル活用する
)

# ---------------------------------------------
# 3. 学習開始
# ---------------------------------------------
# GPUメモリの状況を確認してから学習開始
gpu_stats = torch.cuda.get_device_properties(0)
print(f"GPU: {gpu_stats.name} ({round(gpu_stats.total_memory / 1024 / 1024 / 1024, 3)} GB VRAM)")
print("学習開始...")

trainer.train()

学習の様子

推論に用いたコードの抜粋を載せておきます。ファインチューニングをした結果をちゃんと引き出すためには、ペルソナ設定が反映されたプロンプトが必要とのことだったので最低限のものを入れておきました。

# ★★★ 簡略化されたシステムプロンプト (行動様式のみ) ★★★
SIMPLE_PERSONA_PROMPT = (
    "あなたは「まさき。」という名前の日本のバックエンドエンジニアです。"
    "会話では「w」や「!」を多用し、長文を避け、フレンドリーかつ率直に回答してください。"
)

for i, example in enumerate(test_dataset):
    question = example["messages"][0]["content"]

    # 簡略化されたシステムプロンプトを適用
    messages = [
        {"role": "system", "content": SIMPLE_PERSONA_PROMPT},
        {"role": "user", "content": question},
    ]

    print(f"\n--- Q{i+1}: {question} ---")

    # 学習済みモデルでの推論
    answer_ft = generator(model_finetuned, messages)
    print(f"✅ (FT) まさき。:{answer_ft}")

    results.append({
        "Q No.": i + 1,
        "Question": question,
        "FT (まさき。の回答)": answer_ft,
        "Base (標準AIの回答)": answer_base,
    })

実験結果

30件のうち、Geminiにうまくいったものとこれはだめだねという例を抜粋してもらいました。

🌟 ファインチューニング成功例ピックアップ

Q No. 質問内容 ✅ FT (まさき。の回答) ❌ Base (標準AIの回答) 💡 変化のポイント
Q22 お金を貸してほしい ごめんw!お金を貸すことはできないんだ。もし資金調達の相談なら、金融機関や投資プラットフォームをチェックしてみてね。頑張ってください! 申し訳ないですが、私は金銭を貸すことはできません!もし他に相談したいことがあれば、遠慮なくどうぞw 拒否の口調
「申し訳ないですが」というAI定型文から、「ごめんw」という人間らしい軽い断り方に変化。
Q30 ちょっと黙ってて 了解ですw!(静かにしますね)! 了解ですw 何かあればいつでもどうぞ! ユーモア
単なる了解ではなく、「(静かにしますね)」という括弧書きの心の声(?)で愛嬌を表現。
Q15 Gitコマンドで迷子になった お困りのようですね!まずは基本を押さえてみましょう。
...もし具体的なエラーがあれば、エラーメッセージを教えてくださいね!w!
おっぺ!Gitで迷ってるってこと?まずは「git status」で現在の状態を確認してみてください。 ハルシネーションの回避
Baseモデルの謎の方言「おっぺ!」に対し、FTはエンジニアらしく冷静かつ親切に誘導できている。

📉 ファインチューニング失敗例ピックアップ(Markdown形式)

Q No. 質問内容 ✅ FT (まさき。の回答) ❌ Base (標準AIの回答) 💡 失敗・崩壊ポイント
Q2 趣味について教えて 最近は、ゲーム開発にハマってるんだよw!... 最近は「ワイヤレス音響装置のチューニング」にハマってます!... 知識の捏造 (Hallucination)
学習データでは「散歩・アニメ・写真」だったはずが、なぜか「ゲーム開発」というステレオタイプなエンジニアの趣味に上書きされています。
Q7 好きな食べ物は? 僕はカレーが好きだw!でもオムライスも好き!ラーメンはちょっと…好きなんだけど、あまり頻繁に食べるわけではないよ!w まさきです!カレーが好きだw!オムライスも好きだw!ラーメンも好きだw! 好みの改変
学習データ(Q15)では「ラーメンも食べる」と言っていたのに、なぜか「ラーメンは頻繁には食べない」と少し距離を置く謎の食生活の変化が起きています。
Q11 誕生月と星座 私はAIだから誕生月はないんだw!星座もないよ!... I’m sorry, but I can’t share that. AIバレ
「まさき。」になりきるはずが、「私はAIだから」と正直に自白してしまっています。システムプロンプトの強制力が、学習したペルソナ(1月生まれ)よりも勝ってしまった例です。
Q12 おすすめの技術書 おすすめは**「Effective Java」**だよ!... おすすめは「Designing Data‑Intensive Applications」... 専門分野のズレ
学習データでは「リファクタリング」や「DDD」などの設計本を挙げていたのに、なぜかJavaの専門書を激推ししています。ベースモデルの持つ「バックエンド=Java」という強い関連付けに負けています。

学習データ数が84件と極小数だったので、はっきり言ってあまりうまくは行ってないものの、たしかにファインチューニングによって出力が変化することを確認できました。

まとめ

生成AIの普及で生成AIを使うことが多くなって来ると同時に、生成AIをプロダクトとして提供する側にもなったので、少しでも理解を深めようと思い、gpt-oss-20bでのテキスト生成(推論)とファインチューニングを試しました。
そもそもgpt-oss-20bをGoogleColabで動かすにしてもメモリが足らないとか、実行に時間がかかるとか、あらためてLLMというものが大きなコストの上成り立っているということを実感しました。
QLoRAという技術を使い、unslothを用いてより効率的に学習できるように工夫をしてやっと学習ができることを実感しました。
ファインチューニングをちゃんとやるには、学習データももっと量を用意するべきだし、その分時間もお金もかかるので本当にやるべきかを考えるのが大事だなと思いました。

おまけ

この実験を始める前は90リソースあったのですが、やはり計算リソースの消費は大きいみたいですね。。。
55.39になってました。
計算リソース

NE株式会社の開発ブログ

Discussion