- はじめに — 「そのモデル、どうやって作ったんですか」に答えられない瞬間
- 再現性の最小単位 — 実行マニフェスト
- データパイプラインと汚染管理
- チェックポイント管理 — 間隔と保管はまったく別の問題です
- 評価をCIに組み込む
- レジストリと昇格 — 学習からサービングへの引き継ぎ地点
- デプロイ後 — 回帰検知とロールバック
- ツールマップ — カテゴリ別
- おわりに — 再現できない結果は結果ではありません
はじめに — 「そのモデル、どうやって作ったんですか」に答えられない瞬間
3ヶ月前にデプロイしたモデルで問題が見つかります。どんなデータで学習したのか、どんな設定だったのか、当時の評価スコアは何だったのかと尋ねられます。チームから返ってくる答えが「たぶんあのノートブックのどこかにあるはずです」であれば、その組織にLLM Opsは存在しません。
LLM Opsをツールの一覧として学んでも、この状況は防げません。実験管理ツールを導入していても、データスナップショットを記録していなければ再現はできませんし、評価を自動化していても、評価セットが汚染されていればそのスコア全体が無効になります。
だからこの記事はツールではなく責任として整理します。各節は「何が壊れるか」と「それを防ぐために何を記録すべきか」で構成しました。ツールは最後にカテゴリ別にのみ添えます。ベンダーの価格は入れていません。すぐに古くなるからです。
再現性の最小単位 — 実行マニフェスト
まず正直に触れておくことがあります。ビット単位の再現は、ほとんどの場合不可能です。GPU数が変わるとリダクションの順序が変わって浮動小数点の結果が変わり、カーネルの自動チューニングは実行ごとに異なるアルゴリズムを選ぶことがあり、ドライバのアップデート一つで結果が揺らぎます。決定論フラグをすべて有効にすれば再現はできますが、速度が大きく落ちるため事前学習には使えません。
だから現実的な目標は「同じビット」ではなく、同じ座標からもう一度出発できることです。その座標を実行マニフェストに残します。
# run_manifest.py — 学習開始時にランク0から一度だけ呼び出します
import hashlib
import importlib.metadata as md
import json
import os
import platform
import subprocess
import time
def _sh(cmd: str) -> str:
try:
return subprocess.check_output(cmd, shell=True, text=True,
stderr=subprocess.DEVNULL).strip()
except Exception:
return "unknown"
def build_manifest(config: dict, data_snapshot_id: str) -> dict:
cfg_bytes = json.dumps(config, sort_keys=True).encode()
return {
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"code": {
"commit": _sh("git rev-parse HEAD"),
"dirty": _sh("git status --porcelain") != "",
"remote": _sh("git config --get remote.origin.url"),
},
"config": {
"sha256": hashlib.sha256(cfg_bytes).hexdigest()[:16],
"values": config,
},
"data": {
"snapshot_id": data_snapshot_id, # 不変スナップショットの識別子
"manifest_sha256": _sh(f"sha256sum data/manifests/{data_snapshot_id}.jsonl | cut -d' ' -f1"),
},
"packages": {
p: md.version(p)
for p in ("torch", "transformers", "trl", "peft", "accelerate",
"deepspeed", "datasets")
if _safe_version(p)
},
"hardware": {
"gpu": _sh("nvidia-smi --query-gpu=name --format=csv,noheader | head -1"),
"gpu_count": int(os.environ.get("WORLD_SIZE", "1")),
"driver": _sh("nvidia-smi --query-gpu=driver_version --format=csv,noheader | head -1"),
"python": platform.python_version(),
},
"scheduler": {
"slurm_job_id": os.environ.get("SLURM_JOB_ID"),
"nodelist": os.environ.get("SLURM_JOB_NODELIST"),
},
"seeds": {"global": config.get("seed"), "data_order": config.get("data_seed")},
}
def _safe_version(p: str) -> bool:
try:
md.version(p)
return True
except md.PackageNotFoundError:
return False
シードについて一つ補足します。グローバルシードが一つあるだけでは不十分です。データ順序用のシードを別に持つことで、モデルの初期化を固定したままデータ順序だけを変える実験が可能になります。そしてデータローダーのワーカーはそれぞれ自分のシードを派生させるため、ワーカー数を変えるとデータ順序が変わります。ワーカー数は性能のつまみではなく、実験変数です。
データパイプラインと汚染管理
学習データは「フォルダ」ではなく不変スナップショットでなければなりません。パスだけを記録しておいて、そのパスの中身が後で変わってしまえば、実行マニフェストは嘘をつくことになります。実務で使われる形はだいたい次のようになります。
- オリジナルはオブジェクトストレージに一度だけ書き込み、絶対に上書きしません。
- 学習が参照するのは、ファイル一覧と各ファイルのハッシュ、そしてサンプル数をまとめたマニフェストです。
- マニフェスト自体にバージョンを付け、実行マニフェストにはマニフェストのハッシュを残します。
データ汚染はこの上で管理します。評価セットの問題や正解が学習コーパスに紛れ込むと、スコアは上がっても実際の能力は変わりません。最低限の防御は二つあります。
# 評価セットと学習コーパスのnグラム重複をおおまかにスキャンします。
# 精密なツールではなく、「明白な事故」を捕まえるための1次フィルタです。
from datasketch import MinHash, MinHashLSH # pip install datasketch
def shingles(text: str, n: int = 13) -> set:
toks = text.split()
return {" ".join(toks[i:i + n]) for i in range(max(1, len(toks) - n + 1))}
def build_index(eval_docs, threshold: float = 0.8):
lsh = MinHashLSH(threshold=threshold, num_perm=128)
for i, doc in enumerate(eval_docs):
m = MinHash(num_perm=128)
for s in shingles(doc):
m.update(s.encode())
lsh.insert(f"eval-{i}", m)
return lsh
def scan(train_docs, lsh) -> list:
hits = []
for j, doc in enumerate(train_docs):
m = MinHash(num_perm=128)
for s in shingles(doc):
m.update(s.encode())
found = lsh.query(m)
if found:
hits.append((j, found))
return hits
二つ目の防御はもっと単純で、もっと強力です。学習パイプラインに一度も入れていない保留セットを別に用意しておくことです。このセットは別のストレージに置き、学習クラスタからはアクセスできないようにします。汚染スキャンは見逃すことがありますが、そもそもパイプラインが見ることのできなかったデータは汚染されようがありません。
公開モデル側でこの問題に正面から取り組んだ事例が参考になります。Allen AIのOlmo 3はチェックポイントとデータセット、依存関係のすべてを公開し、第三者が汚染の有無を直接監査できるようにしました。社内プロジェクトで同じレベルまでは無理でも、「監査可能かどうか」を設計基準に据えることはできます。
チェックポイント管理 — 間隔と保管はまったく別の問題です
二つの問いを混同してはいけません。「どれくらいの頻度で保存するか」は障害への備えであり、「どれくらいの期間保管するか」は監査とコストの問題です。
間隔は障害間隔から逆算します。YoungとDalyの1次近似は、最適な間隔は保存時間と平均障害間隔の積の2倍に平方根を取った値だとしています。
import math
def optimal_interval_min(save_minutes: float, mtbf_hours: float) -> float:
"""Young/Daly の1次近似: T = sqrt(2 * delta * M)"""
return math.sqrt(2 * save_minutes * mtbf_hours * 60)
def wasted_fraction(interval_min: float, save_minutes: float, mtbf_hours: float) -> float:
"""保存にかかる時間 + 障害で失われる平均再計算時間の割合"""
mtbf_min = mtbf_hours * 60
return save_minutes / interval_min + (interval_min / 2) / mtbf_min
for gpus, mtbf in [(64, 120.0), (512, 15.0), (16384, 3.1)]:
t = optimal_interval_min(save_minutes=5, mtbf_hours=mtbf)
print(f"GPU {gpus:6} MTBF {mtbf:6.1f}h 最適間隔 {t:6.1f}分 "
f"損失率 {wasted_fraction(t, 5, mtbf):.1%}")
# GPU 64 MTBF 120.0h 最適間隔 268.3分 損失率 3.7%
# GPU 512 MTBF 15.0h 最適間隔 94.9分 損失率 10.5%
# GPU 16384 MTBF 3.1h 最適間隔 43.1分 損失率 23.2%
最後の行のMTBF 3.1時間は適当な数字ではありません。Llama 3 405Bの学習で54日間に419件の予期しない中断があったという報告を時間に換算した値です。この規模では最適間隔を守っても、全体時間の20パーセント以上が保存と再計算で消えるということです。規模が大きくなるほど、チェックポイントは付随作業ではなく主要なコスト項目になります。
保管は別の問題です。70Bモデルの学習状態全体を保存すると、だいたい次の程度になります。
| 項目 | 精度 | 70B基準サイズ |
|---|---|---|
| パラメータ | bf16 | 140 GB |
| マスター重み | fp32 | 280 GB |
| Adam第1モーメント | fp32 | 280 GB |
| Adam第2モーメント | fp32 | 280 GB |
| 合計(再開用フルステート) | 980 GB | |
| 推論用の重みのみ | bf16 | 140 GB |
43分ごとに980GBを残すと、1日で33TBになります。20日なら660TBです。だから保管ポリシーが必要です。実務で使われる形は、階層に分けることです。
- 再開用: 直近2~3個だけ残し、残りは即座に削除します。全状態を含みます。
- マイルストーン: 決まったトークン数ごとに(例: 毎100Bトークン)推論用の重みだけを残します。スケーリング曲線の分析と事後調査に使います。
- リリース: 実際にデプロイしたものだけを永久保管します。実行マニフェストと評価レポートを必ず一緒に束ねます。
評価をCIに組み込む
評価を人手で回すと必ず抜け漏れが出ます。かといって、評価スイート全体を毎コミットごとに回すのも不可能です。そこで階層を分けます。
| 階層 | いつ | 何を | ゲート |
|---|---|---|---|
| スモーク | 毎コミット | 形式準拠、トークン化の往復、チャットテンプレートのレンダリング、100件のサンプル生成 | 失敗時はマージをブロック |
| 回帰 | 毎晩 | 固定のゴールデンセット、決定論的デコーディング、直近3バージョンとの比較 | 閾値逸脱時にアラート |
| フル | 昇格前 | 公開ベンチマーク + ドメインセット + 安全性 | レポート添付なしでは昇格不可 |
スモーク階層が捕まえるのは品質ではなく事故です。チャットテンプレートが壊れている、トークナイザーに特殊トークンが追加されたのに反映されていない、終了トークンが変わっている、といったケースです。こうした事故は評価スコアではなく例外として表面化するため、速く確実に捕まります。
# 回帰階層の例。評価ツールとタスクのバージョンを一緒に固定します。
pip install "lm-eval==0.4.12"
lm_eval --model hf \
--model_args pretrained=./out/sft-8b,dtype=bfloat16 \
--tasks arc_challenge,hellaswag,gsm8k \
--batch_size 16 \
--seed 1234 \
--output_path reports/$(date +%F)-sft-8b.json \
--log_samples
--log_samplesを有効にして生成結果そのものを残すことが重要です。スコアだけ残しても、後で「なぜ落ちたのか」に答えられません。評価ツールのバージョンとタスク定義も一緒に記録してください。同じベンチマークでもハーネスのバージョンが変わればスコアは変わります。確認したlm-evaluation-harnessのバージョンは0.4.12で、公開日は2026年5月11日です。
評価設計そのものは勘に頼らないLLM評価に別途まとめています。
レジストリと昇格 — 学習からサービングへの引き継ぎ地点
モデルレジストリはファイルストレージではなく、証拠添付の手続きです。各段階に進むために何が添付されていなければならないかを決めておく、それがすべてです。
| 段階 | 必須添付物 | 承認 |
|---|---|---|
| 候補 | 実行マニフェスト、学習損失曲線、データマニフェストのハッシュ | 自動 |
| ステージング | 回帰評価レポート、トークナイザーとチャットテンプレートのフィンガープリント、ライセンスの出所 | 担当エンジニア |
| 本番 | 全体評価レポート、安全性評価、負荷テスト結果、ロールバック計画 | レビュアー2名 |
| 廃止 | 代替モデルの指定、保管期間 | 担当者 |
ここで引き継ぎが実際に壊れる箇所は、多くの場合次の6つです。順序は私が見た頻度順です。
- チャットテンプレートの不一致。学習時に使ったテンプレートとサービングエンジンが適用するテンプレートが異なります。空白一つ、改行一つ違うだけで、モデルは学習したことのない分布を目にします。チェックポイントの
tokenizer_config.jsonに入っているテンプレート文字列のハッシュを、学習側とサービング側でそれぞれ取って比較してください。 - 特殊トークン追加後の埋め込みサイズ不一致。SFT中にトークンを追加すると埋め込み行列が大きくなりますが、サービング側の設定が元の語彙サイズを保持していると、ロードに失敗するか、気づかないまま誤ったトークンを使うことになります。
- 終了トークン設定の欠落。生成が止まらず最大長まで進みます。レイテンシとコストが何倍にもなり、ユーザーには余計な発話が付いて出力されます。
- LoRAアダプタのマージ有無。マージしていないアダプタをサービングに渡すと、ベースモデルだけがロードされ、ファインチューニングの効果がまるごと消えます。評価はアダプタを付けて回し、サービングは付けないという組み合わせが特に起こりやすいです。
- 精度ドリフト。学習はbf16、評価はfp32、サービングはfp8で回っている状況はよくあります。サービングで使う精度と量子化で、評価を最低一度は回す必要があります。
- パディング方向と最大位置長。学習時は右パディング、生成時は左パディングが定石ですが、これが入れ替わるとバッチ生成のときだけ品質が崩れます。単発テストでは捕まりません。
# 昇格前のフィンガープリント照合 — 学習の成果物とサービング設定が同じものを見ているか確認します
import hashlib
import json
from transformers import AutoTokenizer, AutoConfig
def fingerprint(path: str) -> dict:
tok = AutoTokenizer.from_pretrained(path)
cfg = AutoConfig.from_pretrained(path)
tmpl = tok.chat_template or ""
return {
"chat_template_sha": hashlib.sha256(tmpl.encode()).hexdigest()[:16],
"vocab_size_tokenizer": len(tok),
"vocab_size_config": cfg.vocab_size,
"eos_token_id": tok.eos_token_id,
"pad_token_id": tok.pad_token_id,
"max_position_embeddings": getattr(cfg, "max_position_embeddings", None),
"torch_dtype": str(getattr(cfg, "torch_dtype", None)),
}
a, b = fingerprint("./out/sft-8b"), fingerprint("./serving/model")
diff = {k: (a[k], b[k]) for k in a if a[k] != b[k]}
print(json.dumps(diff, indent=2, ensure_ascii=False) if diff else "一致")
assert a["vocab_size_tokenizer"] == a["vocab_size_config"], "語彙サイズ不一致"
デプロイ後 — 回帰検知とロールバック
デプロイした後に変わるのはモデルだけではありません。サービングエンジンのバージョン、プロンプト、検索インデックス、上位のアプリケーションコードがそれぞれ変わります。だから品質が低下したときに何が変わったのかを特定できる能力が回帰検知の本質です。
三つを仕掛けておけば、たいていは捕まえられます。
- ゴールデンセットの再生。デプロイ直後と毎日決まった時刻に、同じ入力数百件を決定論的な設定で流し、結果を保存します。以前の結果との差分をテキストレベルで比較します。スコアより差分のリストのほうが役に立ちます。
- 出力分布のモニタリング。応答長の分布、拒否応答の比率、構造化出力のスキーマ検証失敗率、終了トークンなしで最大長に達した比率。この四つは品質指標ではありませんが、事故の指標としてはほぼ完璧です。
- リクエスト単位のトレーシング。プロンプト、モデルバージョン、プロンプトバージョン、検索文書の識別子、レイテンシ、トークン数を一つのトレースにまとめます。標準化についてはOpenTelemetryのGenAIセマンティックコンベンションが担っていますが、2026年7月時点で、この規約はまだ開発段階で安定化していないという記述を確認しました。私は公式仕様文書でステータス表記を直接確認できたわけではないので、導入前に規約のリポジトリで安定性のグレードをご自身で確認することをお勧めします。実務上の含意は単純です。属性名が変わる可能性があるので、計測コードは薄いアダプタの背後に置いてください。
ロールバックは再ビルドではなく設定変更であるべきです。旧バージョンの重み、トークナイザー、プロンプト、サービングエンジンのバージョンを一つの不変バンドルとして保存しておき、デプロイはそのバンドルを指すポインタを切り替えるだけの形にします。アダプタベースのデプロイなら、ロールバックは特に安くつきます。ベースモデルはそのままにアダプタだけ差し替えればよいからです。
ロールバックでよく見落とされるものが一つあります。プロンプトとモデルは一緒にロールバックしなければなりません。新しいモデルに合わせてプロンプトを直したのにモデルだけ戻すと、その組み合わせは一度も評価されていないことになります。
ツールマップ — カテゴリ別
特定の製品はお勧めしません。カテゴリと選定基準だけを書きます。バージョンは2026年8月2日時点で確認した値です。
| カテゴリ | 候補 | 選定基準 |
|---|---|---|
| 実験管理 | MLflow(3.15.0, 2026-07-31)、Weights and Biases、ClearML、Aim | オフライン/閉域網で自己ホストできるか。アーティファクトストアがオブジェクトストレージを直接使うか |
| データバージョン管理 | DVC、LakeFS、オブジェクトストレージ+自前実装のマニフェスト | 数十TB規模でコピーではなく参照で動作するか |
| 評価 | lm-evaluation-harness(0.4.12, 2026-05-11)、LightEval、自前のドメインスイート | タスク定義にバージョンが付くか。サンプル単位でログを残すか |
| トレーシングと観測 | Langfuse(4.14.2, 2026-07-30)、自前のOpenTelemetryパイプライン | 既に使っている観測スタックにスパンを流し込めるか |
| モデルレジストリ | MLflow Model Registry、Hugging Face Hub(プライベート含む)、オブジェクトストレージ+メタデータDB | 昇格段階で承認プロセスを強制できるか |
| サービング | vLLM(0.26.0, 2026-07-25)、SGLang(0.5.16, 2026-07-25)、KServe(v0.19.0, 2026-06-14) | 学習の出力フォーマットをそのまま読めるか。バージョン固定は可能か |
選定基準を一行にまとめるとこうなります。ツールが消えても記録が残るか。実験管理サーバーが落ちても、実行マニフェストのJSONがオブジェクトストレージに残っていれば復旧できます。逆に、すべてのメタデータがSaaSの中にしかなければ、その契約が終わる日に組織の学習履歴も終わります。
おわりに — 再現できない結果は結果ではありません
LLM Opsのツール一覧は毎年変わりますが、責任は変わりません。この実行を再現できるか、このスコアを信じてよいか、このモデルがどこから来たのか説明できるか、問題が起きたときに数分以内に戻せるか。
この四つの問いすべてに「はい」と答えられるなら、どのツールを使うかは関係ありません。一つでも「いいえ」であれば、ツールをもう一つ足すだけでは解決しません。最後の記事では、これらの原則が実際の大規模学習でどう崩れ、どう復旧したのかを公開された学習事例で確認します。
현재 단락 (1/177)
3ヶ月前にデプロイしたモデルで問題が見つかります。どんなデータで学習したのか、どんな設定だったのか、当時の評価スコアは何だったのかと尋ねられます。チームから返ってくる答えが「たぶんあのノートブックのど...