DEV Community

Cover image for LLMをゼロから構築する方法:得られる学び
Akira
Akira

Posted on • Originally published at apidog.com

LLMをゼロから構築する方法:得られる学び

要約

ゼロから最小限の言語モデル(LLM)を構築するためには、Pythonコードで300行も必要ありません。この構築プロセスを通じて、トークン化、アテンション、推論の仕組みを正確に理解でき、本番LLMをアプリケーションへ統合する際に、より優れたAPIコンシューマーになることができます。

Apidogを今すぐ試す


はじめに

多くの開発者は言語モデルをブラックボックスとして扱っています。テキストを入力するとトークンが出てきて、その裏側では何らかの「魔法」が起きていると思われがちです。しかしAPI統合のデバッグ、サンプリングパラメータの調整、構造化データの生成エラー解析などの場面では内部構造の理解が不可欠になります。

HackerNewsで話題となった『GuppyLM』は、Pythonでゼロから実装された870万パラメータのトランスフォーマーであり、一般的なGPUで1時間以内にトレーニング可能です。コードは1ファイルに収まり、目的はGPT-4と競うことではなく、LLMの内部動作の理解にあります。

この記事では、小規模LLMの構築方法・主要コンポーネントの役割・API活用時に内部構造を知っておくことの意義を、実装例と共に解説します。

💡 Apidogのテストシナリオを活用すると、ストリーミング応答の検証やトークン構造のアサーション、エッジケース補完のシミュレーションなどが本番クレジットを消費せずに行えます。詳細は後述。

言語モデルが「小さい」とは?

GPT-4などの本番LLMは数千億パラメータですが、「小さい」LLMは100万〜2500万パラメータが目安です。GuppyLM(870万)、nanoGPT(1億2400万)、MicroLM(100〜200万)などが該当します。

小規模LLMの特徴:

  • ノートPCやColabでトレーニング可能
  • CPUメモリで動作
  • 重みレベルでの検査やデバッグが容易

できないこと:

  • 複雑な推論
  • 一貫した長文生成
  • 本番モデル並みの知識量

価値は出力品質よりも、構築・運用を通じて得られる理解にあります。

主要コンポーネント:LLMの実際の仕組み

LLMを構成する4つの主な要素について解説します。

トークナイザー

トークナイザーは生テキストを整数IDに変換します。例: Hello, world![15496, 11, 995, 0]

各IDは語彙内のサブワード・文字単位に対応します。

API実装へのヒント

トークン数はレイテンシやコストに直結します。トークナイザーの分割方法を把握しておくことで、プロンプトがコンテキストウィンドウに収まり、不要な切り捨てを防げます。

GuppyLMは文字レベル、GPT-4などはBPE(5万〜10万語彙)を使用しています。

埋め込み層

埋め込み層はトークンIDを密なベクトル(例:384次元)へ変換します。意味的に近いトークンはベクトル空間でも近くなります。

さらに「位置埋め込み」を加えることで、トークン配列の順序も認識できます。

トランスフォーマーブロック

主な計算部です。各ブロックは以下2つから構成されます。

  • 自己アテンション:各トークンが他すべてを参照し、次トークン生成の重要度を判定します。GuppyLMは6ヘッド×6層。
  • フィードフォワードネットワーク:アテンション後にMLP(2層)を適用。GuppyLMはReLU活性、GPT-4などはSwiGLUなども使われます。

出力ヘッド

最終ブロックの出力を語彙サイズのベクトルへ線形変換し、softmaxで確率を算出。最も高い確率のトークンを選ぶ/サンプリングして繰り返します。

Pythonで最小限のLLMを構築する

GuppyLMを参考にしたPyTorch実装例です。実行に必要な最小構成を示します。

import torch
import torch.nn as nn
import torch.nn.functional as F

# ハイパーパラメータ
VOCAB_SIZE = 256     # 文字レベル:ASCII文字ごとに1スロット
D_MODEL = 128        # 埋め込み次元
N_HEADS = 4          # アテンションヘッド数
N_LAYERS = 3         # トランスフォーマーブロック数
SEQ_LEN = 64         # コンテキストウィンドウ
DROPOUT = 0.1

class SelfAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.n_heads = n_heads
        self.head_dim = d_model // n_heads
        self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
        self.proj = nn.Linear(d_model, d_model, bias=False)
        self.dropout = nn.Dropout(DROPOUT)

    def forward(self, x):
        B, T, C = x.shape
        qkv = self.qkv(x).reshape(B, T, 3, self.n_heads, self.head_dim)
        q, k, v = qkv.unbind(dim=2)
        q = q.transpose(1, 2)
        k = k.transpose(1, 2)
        v = v.transpose(1, 2)
        # 因果マスク: 各トークンは前のトークンのみ参照
        scale = self.head_dim ** -0.5
        attn = (q @ k.transpose(-2, -1)) * scale
        mask = torch.triu(torch.ones(T, T, device=x.device), diagonal=1).bool()
        attn = attn.masked_fill(mask, float('-inf'))
        attn = F.softmax(attn, dim=-1)
        attn = self.dropout(attn)
        out = (attn @ v).transpose(1, 2).reshape(B, T, C)
        return self.proj(out)

class TransformerBlock(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.attn = SelfAttention(d_model, n_heads)
        self.ff = nn.Sequential(
            nn.Linear(d_model, 4 * d_model),
            nn.ReLU(),
            nn.Linear(4 * d_model, d_model),
            nn.Dropout(DROPOUT),
        )
        self.ln1 = nn.LayerNorm(d_model)
        self.ln2 = nn.LayerNorm(d_model)

    def forward(self, x):
        x = x + self.attn(self.ln1(x))
        x = x + self.ff(self.ln2(x))
        return x

class TinyLLM(nn.Module):
    def __init__(self):
        super().__init__()
        self.embed = nn.Embedding(VOCAB_SIZE, D_MODEL)
        self.pos_embed = nn.Embedding(SEQ_LEN, D_MODEL)
        self.blocks = nn.ModuleList([
            TransformerBlock(D_MODEL, N_HEADS) for _ in range(N_LAYERS)
        ])
        self.ln_f = nn.LayerNorm(D_MODEL)
        self.head = nn.Linear(D_MODEL, VOCAB_SIZE, bias=False)

    def forward(self, idx):
        B, T = idx.shape
        tok_emb = self.embed(idx)
        pos = torch.arange(T, device=idx.device)
        pos_emb = self.pos_embed(pos)
        x = tok_emb + pos_emb
        for block in self.blocks:
            x = block(x)
        x = self.ln_f(x)
        logits = self.head(x)
        return logits

# モデル初期化とパラメータ数カウント
model = TinyLLM()
total_params = sum(p.numel() for p in model.parameters())
print(f"Model size: {total_params:,} parameters")  # 約1.2M
Enter fullscreen mode Exit fullscreen mode

トレーニングループ

import torch.optim as optim

def train(model, data, epochs=100, lr=3e-4):
    optimizer = optim.AdamW(model.parameters(), lr=lr)
    model.train()
    for epoch in range(epochs):
        # data: トークンIDのテンソル, 形状 [バッチ, seq_len+1]
        x = data[:, :-1]   # 入力: 最後のトークンを除く
        y = data[:, 1:]    # ターゲット: 1つシフト
        logits = model(x)
        loss = F.cross_entropy(logits.reshape(-1, VOCAB_SIZE), y.reshape(-1))
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        if epoch % 10 == 0:
            print(f"Epoch {epoch}, loss: {loss.item():.4f}")
Enter fullscreen mode Exit fullscreen mode

推論(テキスト生成)

@torch.no_grad()
def generate(model, prompt_ids, max_new_tokens=50, temperature=1.0, top_k=10):
    model.eval()
    ids = torch.tensor([prompt_ids])
    for _ in range(max_new_tokens):
        idx_cond = ids[:, -SEQ_LEN:]  # コンテキストウィンドウに合わせて切り詰め
        logits = model(idx_cond)
        logits = logits[:, -1, :] / temperature  # 最後のトークンのみ
        # top-kサンプリング
        v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
        logits[logits < v[:, [-1]]] = float('-inf')
        probs = F.softmax(logits, dim=-1)
        next_id = torch.multinomial(probs, num_samples=1)
        ids = torch.cat([ids, next_id], dim=1)
    return ids[0].tolist()
Enter fullscreen mode Exit fullscreen mode

これがAI APIの挙動について教えてくれること

この構築経験から得られる、API統合時の実践的な知見を紹介します。

Temperatureとサンプリングは単なるパラメータ

Temperatureはsoftmax前のロジットをスケールします。高くすると出力がランダムに、低くすると決定論的になります。APIでtemperature=0.0指定でも、一貫性が出ない場合は仕様です(多くのAPIは完全なargmaxを避けています)。

コンテキストウィンドウは厳密で、緩やかなものではない

推論ループのidx_cond = ids[:, -SEQ_LEN:]のように、モデルはウィンドウ外のトークンを完全に破棄します。API統合時、会話履歴を全て覚えている前提は成り立ちません。

ストリーミングトークン = 推論ループの可視化

ストリーミングAPIも内部的には逐次生成と同じです。トークンごとに応答をフラッシュしているだけ。途中でストリームが切れると再開はできません(最初からやり直し)。

ロジット分布は構造化出力の難しさを示す

各ステップで語彙全体に確率が割り当てられ、例えば有効なJSON生成には全トークンが正しい必要があります。OutlinesやGuidance等はロジットを制約して文法を強制しています。APIで「構造化出力」モードがある場合、内部的にこの処理をしています。

ApidogでAI APIの統合をテストする方法

LLMの推論理解を応用して、Apidogによる堅牢なAPIテストを構築しましょう。Apidogのテストシナリオでは、複数のAPI呼び出しを連鎖させ、AI応答の構造を自動アサートできます。

ストリーミングチャットAPIのテスト例:

  1. /v1/chat/completionsエンドポイントを含むテストシナリオを作成
  2. レスポンスアサーション例:
    • response.choices[0].finish_reason == "stop"
    • response.usage.total_tokens < 4096
  3. 応答を次のターンの入力として再利用し、複数ターン会話をシミュレーション
  4. ApidogのスマートモックでAIエンドポイントをスタブ化し、finish_reason: "length", finish_reason: "content_filter", ストリーム途中のタイムアウト等の異常系もテスト

これにより、CI実行時にAPIクレジットを消費せずAI統合テストが可能です。より広範なアプローチは[internal: api-testing-tutorial]を参照してください。

トークン数アサーションのテスト

{
  "assertions": [
    {
      "field": "response.usage.completion_tokens",
      "operator": "less_than",
      "value": 512
    },
    {
      "field": "response.choices[0].finish_reason",
      "operator": "equals",
      "value": "stop"
    },
    {
      "field": "response.choices[0].message.content",
      "operator": "not_empty"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

このテストは、1つのシナリオ内で複数モデル(GPT-4o, Claude 3.5 Sonnet, Gemini 1.5 Pro)に対して実行し、APIスキーマの違いによる不具合を本番前に発見できます。

応用編:量子化と推論の最適化

小さなLLMを動かした後は、本番モデル運用と直結する以下のテクニックも押さえておきましょう。

量子化

モデル重みは通常32ビットfloatですが、8ビット(INT8)や4ビット(INT4)整数に量子化可能です。これによりメモリ消費が4〜8倍削減され、精度低下も小さいです。

# 例: PyTorchでの動的INT8量子化
import torch.quantization
quantized_model = torch.quantization.quantize_dynamic(
    model, {nn.Linear}, dtype=torch.qint8
)
Enter fullscreen mode Exit fullscreen mode

本番APIでも量子化モデルが使われます。同じモデル名でも品質が微妙に異なる場合、量子化の影響であることがあります。

KVキャッシュ

推論ループでは毎回全トークン分のアテンションを再計算しますが、本番環境では「キー・バリューキャッシュ」を使い、1トークンごとに必要部分だけ計算します。これによりストリーミング応答の2トークン目以降のレイテンシが大幅に下がります。

小さなLLMと本番API:それぞれの使い分け

ユースケース 小さなLLM 本番API
モデル内部の学習 最適 過剰
新しいアプリのプロトタイピング 品質不十分 最適
プライベート/機密データ 良い選択肢 プロバイダーによる
オフライン/エッジデプロイメント 実現可能 不可能
コスト重視、大量 トレードオフありで可能 規模が大きくなると高価
推論を多用するタスク 実現不可 必須

ほとんどの開発者には「本番APIを活用しつつ、内部理解のために小さなモデルを動かす」というハイブリッド運用が最適です。両者は競合ではありません。

[internal: open-source-coding-assistants-2026]では境界を曖昧にするツールについても紹介しています。

結論

小さなLLMは週末の時間で構築できます。得られるものは本番品質のシステムではなく、GuppyLMからGPT-4oまでのLLMの動作を理解するための実践的メンタルモデルです。この知識は、ストリーミング統合のデバッグやサンプリングパラメータの調整、AI APIテスト設計などに直結します。

GuppyLMプロジェクトは良い出発点です。クローンし、任意のテキストデータでトレーニングし、推論ループの挙動を観察してみましょう。本番API統合の見方が大きく変わるはずです。

Apidogのテストシナリオをぜひ活用し、他のバックエンド同様に厳密なAI APIテストを実施してください。

よくある質問

「小さな」LLMが一貫性あるテキストを生成するには何パラメータ必要?

おおよそ1,000万〜5,000万パラメータで良質なデータセットなら短い一貫文の生成が可能です。100万未満だと意味不明になりがち。GuppyLM(870万)は60トピックの短文会話で機能します。

GPUなしで小さなLLMは使える?

1億パラメータ未満ならCPUでも動作します(推論は遅い)。上記モデル(120万パラメータ)はノートPCのCPUでミリ秒単位の生成が可能です。

どんなデータセットで学習すべき?

プロジェクト・グーテンベルクやWikipediaサブセット等のプレーンテキストがおすすめ。GuppyLMはHuggingFaceの会話データセット(arman-bd/guppylm-60k-generic)を利用。コード生成にはThe StackやCodeParrotを。

Temperatureとtop-kサンプリングの違いは?

Temperatureは分布全体のランダム性調整、top-kは上位k候補に絞ってからTemperatureを適用。組み合わせて使うのが一般的。

LLMが自己反復する理由は?

直前のトークンに高確率を割り当てる失敗モード。APIではrepetition_penalty=1.1等で対策できます。

小さなLLMのトレーニング時間は?

上記モデルなら単一GPU(RTX 3060等)で2時間以内、Colabでも同等。1億以上はマルチGPUと数日が必要。

小さなLLMからAPIエンドポイントへ移行する最速手順は?

llama.cppでGGUF変換→llama-serverでOpenAI互換API提供→Apidogでテスト可能。[internal: rest-api-best-practices]参照。

本番LLMはトレーニングウィンドウ以上のコンテキストをどう扱う?

RoPEの拡張、スライディングウィンドウアテンション、RAG等でコンテキスト拡張可能。中核アーキテクチャは不変、位置エンコードやアテンション適用方法だけが変わります。

Top comments (0)