要約
ゼロから最小限の言語モデルを構築するのに、Pythonコードは300行もかかりません。このプロセスを通じて、トークン化、アテンション、推論がどのように機能するかが正確に明らかになり、本番環境のLLMをアプリケーションに統合する際、より優れたAPIコンシューマーになることができます。
はじめに
ほとんどの開発者は言語モデルをブラックボックスとして扱います。テキストを送り込むと、トークンが出力され、その間に何らかの魔法が起こる、という認識です。このメンタルモデルは、壊れたAPI統合のデバッグ、サンプリングパラメータの調整、またはモデルが構造化データを幻覚のように生成し続ける理由を解明する必要があるまでは問題なく機能します。
最近HackerNewsのトップページに842ポイントで掲載されたプロジェクト『GuppyLM』は、その内部構造を可視化します。これは、Pythonでゼロから書かれた870万パラメータのトランスフォーマーであり、一般消費者向けGPUで1時間以内にトレーニング可能です。コードは単一のファイルに収まります。その目標はGPT-4と競合することではなく、LLMが実際に何をしているのかを解明することにあります。
この記事では、小さなLLMの構築方法、各コンポーネントの機能、そしてAI APIを業務で扱う際に内部を理解することがどのような教訓をもたらすかを解説します。
言語モデルが「小さい」とは?
GPT-4のような本番環境のLLMは、数千億ものパラメータを持っています。「小さい」LLMは、100万から2500万パラメータの範囲に収まります。GuppyLM(870万)、KarpathyのnanoGPT(1億2400万)、MicroLM(100万〜200万)といったプロジェクトはすべてこのカテゴリに分類されます。
小さなLLMは、以下のことが可能です: - ノートPCやGoogle Colabでトレーニングできる - CPUメモリに完全に収まる - 重みレベルで検査、修正、デバッグが可能
しかし、以下のことはできません: - 複雑な推論を処理する - 一貫性のある長文テキストを確実に生成する - 本番モデルの事実の深さに匹敵する
その価値は出力にあるのではなく、それらを構築することで得られる理解にあります。
主要コンポーネント:LLMの実際の仕組み
コードを書き始める前に、主要な4つの要素が何をするのかを知る必要があります。
トークナイザー
トークナイザーは、生のテキストを整数のIDに変換します。「Hello, world!」は、[15496, 11, 995, 0]のようになります。各整数は、固定された語彙からのサブワード単位に対応します。
API作業でこれが重要な理由:トークン数はレイテンシとコストに直接影響します。トークナイザーがテキストをどのように分割するかを理解することで、コンテキストウィンドウに収まり、予期しない切り捨てを避けるプロンプトを作成するのに役立ちます。
GuppyLMは単純な文字レベルのトークナイザーを使用します。GPT-4のような本番モデルは、5万〜10万トークンの語彙を持つBPE(バイトペアエンコーディング)を使用しています。
埋め込み層
埋め込み層は、トークンIDを密なベクトルに変換します。各トークンは学習されたベクトル(GuppyLMでは例えば384次元)を持ちます。これらのベクトルは意味的な情報を含んでおり、類似したトークンはベクトル空間内で互いに近い位置に配置されます。
その上に位置埋め込みが追加され、モデルがトークンの順序を認識できるようになります。
トランスフォーマーブロック
これが主要な計算部分です。各ブロックは2つの要素から構成されます。
自己アテンション(Self-attention): 各トークンがシーケンス内の他のすべてのトークンを参照し、次のトークンを予測するためにどのトークンが重要かを決定できるようにします。GuppyLMは6層にわたって6つのアテンションヘッドを使用します。
フィードフォワードネットワーク(Feed-forward network): アテンションの後に各トークンの表現に適用される2層のMLPです。GuppyLMはReLU活性化関数を使用しており、これは新しいアーキテクチャで使用されるSwiGLUよりも単純です。
出力ヘッド
最後のトランスフォーマーブロックの後、線形層が各トークンの表現を語彙と同じサイズのベクトルに投影します。ソフトマックスを適用して確率を取得し、最も可能性の高い次のトークンを選択(またはサンプリング)して、これを繰り返します。
Pythonで最小限のLLMを構築する
GuppyLMのアプローチに基づいた、動作する最小限のLLMです。これは標準的なPyTorchで動作します。
import torch
import torch.nn as nn
import torch.nn.functional as F
# Hyperparameters
VOCAB_SIZE = 256 # character-level: one slot per ASCII char
D_MODEL = 128 # embedding dimension
N_HEADS = 4 # attention heads
N_LAYERS = 3 # transformer blocks
SEQ_LEN = 64 # context window
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)
# Causal mask: each token can only attend to previous tokens
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
# Initialize and count parameters
model = TinyLLM()
total_params = sum(p.numel() for p in model.parameters())
print(f"Model size: {total_params:,} parameters") # 約1.2M
トレーニングループ
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}")
推論(テキスト生成)
@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()
これがAI APIの挙動について教えてくれること
これを構築することで、より優れたAPIコンシューマーになるためのいくつかのことが明らかになります。
Temperatureとサンプリングは魔法ではなく機械的なもの
Temperatureはソフトマックスの前にロジットを割るものです。Temperatureが高いほど、分布は平坦になり、よりランダムな出力になります。Temperatureが低いほど、分布は鋭くなり、より決定論的な出力になります。本番環境のAPIがtemperature=0.0で一貫性のない結果を返す場合、それはバグではありません。真のゼロTemperatureは貪欲なargmaxであり、多くのAPIは退化した出力を避けるためにわずかに底上げしています。
コンテキストウィンドウは厳密な制限であり、緩やかな提案ではない
推論ループ内のidx_cond = ids[:, -SEQ_LEN:]の行は、コンテキストの制限で何が起こるかを正確に示しています。モデルは古いトークンを黙って破棄します。もしあなたのAPI統合が、モデルが会話履歴全体を記憶していると仮定しているなら、ある時点以降はそうではありません。エージェントがこの問題をどのように処理するかについては、[internal: how-ai-agent-memory-works]を参照してください。
ストリーミングトークンは、可視化された推論ステップにすぎない
ストリーミングAPIは、アーキテクチャ的に何も異なることをしていません。推論ループを実行し、生成された各トークンを応答ストリームにフラッシュしているだけです。これを理解することは、リトライロジックを記述する際に役立ちます。生成途中で切断されたストリームは再開できず、最初からやり直す必要があります。
ロジットが構造化出力の難しさを示す
モデルは各ステップで語彙内のすべてのトークンに確率を割り当てます。有効なJSONを生成するには、すべての位置で適切なトークンが選択される必要があります。OutlinesやGuidanceのようなライブラリは、推論時に文法を強制するためにロジット分布を制約します。AI APIが「構造化出力」モードを提供している場合、これは内部でこのような処理を行っているということです。
ApidogでAI APIの統合をテストする方法
LLMの推論がどのように機能するかを理解すれば、はるかに優れたAPIテストを作成できます。Apidogのテストシナリオは、API呼び出しを連鎖させ、AI応答の構造をアサートすることを可能にします。
例えば、ストリーミングチャットAPIをテストする場合:
- Apidogで
/v1/chat/completionsエンドポイントを含むテストシナリオを作成します。 - 応答構造を検証するためのアサーションを設定します:
response.choices[0].finish_reason == "stop"、response.usage.total_tokens < 4096 - 応答を次のターンのコンテキストとして送信するフォローアップステップを追加し、複数ターンの会話をシミュレートします。
- Apidogのスマートモックを使用してAIエンドポイントをスタブ化し、アプリのエラー処理をテストします:
finish_reason: "length"(切り捨てられた出力)、finish_reason: "content_filter"、およびストリーム途中のネットワークタイムアウトをシミュレートします。
これは、すべてのCI実行でAPIクレジットを消費せずにAI統合をテストする方法です。APIテストアプローチのより広範な見方については、[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"
}
]
}
このテストを、単一のテストシナリオ内で複数のモデル(GPT-4o、Claude 3.5 Sonnet、Gemini 1.5 Pro)に対して実行し、APIスキーマの相違点が本番環境に到達する前に検出します。
応用編:量子化と推論の最適化
動作する小さなLLMができたら、本番モデルが提供される方法に直接適用されるため、理解しておく価値のある2つのテクニックがあります。
量子化
モデルの重みは、デフォルトでは32ビット浮動小数点数です。量子化は、これらを8ビット整数(INT8)または4ビット(INT4)に削減します。これにより、わずかな精度低下でメモリ使用量を4〜8倍に削減できます。
# 例: PyTorchでの動的INT8量子化
import torch.quantization
quantized_model = torch.quantization.quantize_dynamic(
model, {nn.Linear}, dtype=torch.qint8
)
本番環境のAPIは量子化されたモデルを実行します。同じモデルの異なる「バージョン」で出力品質が異なる場合、しばしば量子化が関係しています。
KVキャッシュ
私たちの推論ループでは、各ステップでシーケンス全体のアテンションを再計算しています。本番システムでは、以前のトークンからのキーと値のペア(KVキャッシュ)をキャッシュすることで、新しいトークンごとに1回の新しいアテンション計算しか必要としません。これが、ストリーミング応答の最初のトークンが後続のトークンよりも時間がかかる理由です。
小さなLLMと本番API:それぞれの使い分け
| ユースケース | 小さなLLM | 本番API |
|---|---|---|
| モデル内部の学習 | 最適 | 過剰 |
| 新しいアプリのプロトタイピング | 品質不十分 | 最適 |
| プライベート/機密データ | 良い選択肢 | プロバイダーによる |
| オフライン/エッジデプロイメント | 実現可能 | 不可能 |
| コスト重視、大量 | トレードオフありで可能 | 規模が大きくなると高価 |
| 推論を多用するタスク | 実現不可 | 必須 |
ほとんどの開発者にとっての本当の答えは、アプリケーションには本番APIを使用しつつ、内部で何が起こっているかを理解するために小さなモデルを動かすことです。この二つは競合するものではありません。[internal: open-source-coding-assistants-2026]の記事では、独自のモデルを持ち込む設定でこの境界線を曖昧にするツールについて説明しています。
結論
ゼロから小さなLLMを構築するには週末の時間がかかります。得られるものは本番システムではなく、GuppyLMからGPT-4oに至るまですべての言語モデルが実際にどのように機能するかという、実践的なメンタルモデルです。この理解は、ストリーミング統合のデバッグ、サンプリングパラメータの調整、またはAI APIテストのアサーション設計を行うたびに役立ちます。
GuppyLMプロジェクトは良い出発点です。これをクローンし、任意のテキストデータセットでトレーニングし、午後の時間を推論ループを読むことに費やしてください。そうすれば、本番環境のAPI統合がこれまでとは違って見えるでしょう。
Apidogのテストシナリオを試して、他のバックエンドシステムに適用するのと同じ厳格さでAI APIテストを実施してください。
よくある質問
「小さな」LLMが一貫性のあるテキストを生成するために必要なパラメータ数はどれくらいですか?良好なトレーニングデータセットを持つ約1000万〜5000万パラメータのモデルであれば、局所的に一貫性のある文章を生成できます。100万を下回ると、ほとんどのタスクで意味不明な出力になります。870万パラメータのGuppyLMは、そのトレーニングドメイン(60のトピック)における短い会話で機能します。
GPUなしで小さなLLMを実行できますか?はい。1億パラメータ以下のモデルであればCPUでも問題なく動作しますが、推論は遅くなります。上記のモデル(120万パラメータ)は、ノートPCのCPUでミリ秒単位でトークンを生成します。
どのようなデータセットでトレーニングすればよいですか?文字レベルのモデルは、プロジェクト・グーテンベルクのテキスト、Wikipediaのサブセット、または任意のプレーンテキストコーパスでうまく機能します。GuppyLMはHuggingFaceの6万エントリの会話データセット(arman-bd/guppylm-60k-generic)を使用しています。コード生成には、The StackまたはCodeParrotを使用してください。
Temperatureとtop-kサンプリングの違いは何ですか?Temperatureはロジット分布をスケールし(全体のランダム性を制御します)、top-kはTemperatureを適用する前に、サンプリングの候補を最も可能性の高いk個のトークンに制限します。これらは組み合わせて適用されます。まずtop-kが候補をフィルタリングし、次にTemperatureがそのセット内の確率を調整します。
LLMが時々自己反復するのはなぜですか?自己反復は、モデルがコンテキストに現れたばかりのトークンに高い確率を割り当ててしまう失敗モードです。本番環境のAPIでは、繰り返しペナルティ(最近生成されたトークンのロジットを割引する調整)を使用します。これを軽減するには、API呼び出しにrepetition_penalty=1.1を追加してください。
小さなLLMのトレーニングにはどれくらいの時間がかかりますか?上記のモデルは、単一のGPU(RTX 3060または同等)で2時間以内に一貫性のある出力を生成するまでトレーニングできます。GuppyLMもColabでほぼ同じ時間でトレーニングされます。より大きなモデル(1億以上)は、マルチGPU環境と数日間のトレーニングが必要です。
小さなLLMから実際のAPIエンドポイントへ移行する最も速い方法は何ですか?llama.cppの変換スクリプトを使用してGGUF形式にエクスポートし、その後llama-serverで提供します。これにより、ローカルで動作するOpenAI互換のAPIエンドポイントが得られます。Apidogをそれに向けてテストすることができます。詳細は[internal: rest-api-best-practices]を参照してください。
本番環境のLLMは、トレーニングウィンドウよりも長いコンテキストをどのように扱いますか?拡張スケーリングを伴うRoPE(Rotary Position Embedding)、スライディングウィンドウアテンション、検索拡張生成(RAG)といった技術はすべて、有効なコンテキストを拡張します。コアのトランスフォーマーアーキテクチャ自体は変わりません。これらは、位置情報がどのようにエンコードされるか、およびアテンションウィンドウがどのように適用されるかに対する変更です。
