Nemotron 9B-v2-Japanese を SageMaker 東京リージョンに VPC 閉域構成でデプロイしてみた

Nemotron 9B-v2-Japanese を SageMaker 東京リージョンに VPC 閉域構成でデプロイしてみた

2026.02.21

はじめに

こんにちは、クラスメソッド製造ビジネステクノロジー部の森茂です。

前回の記事では、NVIDIA 公式の日本語強化 LLM Nemotron 9B-v2-Japanese を DGX Spark で動かし、Tool Calling や RAG など色々なケースで試してみました。

DGX Spark でのローカル検証は手軽ですが、「本番環境でどう動かすか」となると話が変わってきます。個人情報や機密データを扱うケースでは、データが日本国内から出ない閉域構成が求められることも多いですよね。

この記事では、Nemotron 9B-v2-Japanese を Amazon SageMaker の東京リージョンにデプロイし、VPC 内の閉域構成で動作するところまでを試してみました。前回と同じベンチマーク(JCommonsenseQA)で比較しているので、ローカル環境とクラウド環境の違いも見えてくるかと思います。

SageMaker で動かす前に

推論エンジンは vLLM を使う

前回の記事では DGX Spark でも NGC コンテナ版 vLLM(v0.13.0)が動作することを確認しましたが、SageMaker では LMI コンテナ経由で vLLM をバックエンドとして利用します。

SageMaker の LMI(Large Model Inference)コンテナ経由で vLLM をバックエンドとして指定します。LMI コンテナは V17(2025 年 9 月)以降、vLLM の AsyncLLMEngine を統合した async モードがデフォルトになっています。今回は async モードで起動し、OPTION_ROLLING_BATCH=disableOPTION_CHAT_TEMPLATE でチャットテンプレートを指定する構成にしています。

JumpStart / Marketplace の状況

2026 年 2 月時点で、SageMaker JumpStart に Nemotron 9B-v2-Japanese は登録されていません。英語版の Nemotron nano 9b v2 は AWS Marketplace に登録されていますが、日本語版は未登録です。自分で LMI コンテナを設定してデプロイします。

デプロイ構成

実線は推論リクエストの流れ、破線はデプロイ時のモデルロードを表しています。すべての通信が VPC 内で完結する構成です。

  • モデルは HuggingFace Hub から事前に S3 にダウンロード。エンドポイントからインターネットへのアクセスは不要
  • SageMaker Runtime への呼び出しは VPC エンドポイント経由。データが VPC の外に出ない
  • 推論エンジンは LMI コンテナ内の vLLM。BF16 フル精度で A10G(24GB)に収まる
項目 設定
インスタンス ml.g5.2xlarge(1x A10G, 24GB VRAM)
リージョン ap-northeast-1(東京)
コンテナ SageMaker LMI V20(DJL 0.36.0 + vLLM)
動作モード async モード(OPTION_ROLLING_BATCH=disable
モデル精度 BF16(17.8GB)
max_model_len 4096

インスタンスに ml.g5.2xlarge を選んだ理由は、BF16(17.8GB)+ vLLM オーバーヘッド(~1GB)+ KV キャッシュ(Attention 4 層分で ~2-3GB)の合計が約 21GB で、A10G の 24GB に収まるためです。Mamba-2 ハイブリッドアーキテクチャでは 56 層中 Attention が 4 層だけなので、KV キャッシュは全層 Attention の約 1/14 で済みます。

デプロイ手順

モデルを S3 に配置する

閉域構成では、エンドポイントから HuggingFace Hub にアクセスできません。事前にモデルをダウンロードして S3 にアップロードしておきます。

このステップだけがインターネット接続を必要とします。実行環境には AWS CLI(v2.32.0 以降)と HuggingFace CLI(hfが必要です。hfbrew install huggingface-cliuv があれば uvx hf でインストール不要で使えます。ディスク空き容量は 20GB 以上を確保してください(モデルサイズ約 18GB)。手元の PC でも EC2 でも、条件を満たしていればどこからでも実行できます。

AWS CLI の認証は aws login が手軽です。ブラウザが開いてマネジメントコンソールと同じ方法でサインインでき、一時的な認証情報が自動でローテーションされます。hf は公開モデルのダウンロードなのでログイン不要です。

# HuggingFace からモデルをダウンロード(約 18GB)
hf download nvidia/NVIDIA-Nemotron-Nano-9B-v2-Japanese \
  --local-dir /tmp/nemotron-9b-japanese

# S3 にアップロード(バケット名は任意、後続の手順と合わせる)
aws s3 cp /tmp/nemotron-9b-japanese \
  s3://<your-bucket>/models/nemotron-9b-v2-japanese/ \
  --recursive

S3 のバケット名は任意ですが、SageMaker のデフォルトバケット(sagemaker-{region}-{account-id})を使うと後続の手順と揃えやすいです。sagemaker.Session().default_bucket() を呼ぶと自動作成されるので、バケットを事前に作っておく必要はありません。

Python SDK で実行する場合

AWS CLI の代わりに Python SDK でも同じことができます。sagemaker.Session().default_bucket() でバケットの自動作成と命名を任せられるので、バケット名を意識せずに済みます。

from huggingface_hub import snapshot_download
from sagemaker.s3 import S3Uploader
import sagemaker

sess = sagemaker.Session()
bucket = sess.default_bucket()

model_dir = snapshot_download(
    "nvidia/NVIDIA-Nemotron-Nano-9B-v2-Japanese",
    local_dir="/tmp/nemotron-9b-japanese"
)

s3_model_uri = S3Uploader.upload(
    local_path=model_dir,
    desired_s3_uri=f"s3://{bucket}/models/nemotron-9b-v2-japanese/"
)
print(f"Model uploaded to: {s3_model_uri}")

以降の手順は VPC 内で完結します。

VPC エンドポイントを作成する

SageMaker エンドポイントを閉域構成で動かすには、VPC 内にインターフェースエンドポイントを作成して、AWS サービスへの通信を VPC 内に閉じ込めます。

必要な VPC エンドポイントは 6 つ。

エンドポイント タイプ 用途
com.amazonaws.ap-northeast-1.sagemaker.api Interface SageMaker API
com.amazonaws.ap-northeast-1.sagemaker.runtime Interface 推論呼び出し
com.amazonaws.ap-northeast-1.s3 Gateway モデルアーティファクト取得
com.amazonaws.ap-northeast-1.ecr.dkr Interface コンテナイメージ取得
com.amazonaws.ap-northeast-1.ecr.api Interface ECR API
com.amazonaws.ap-northeast-1.logs Interface CloudWatch Logs(推奨)

上の 5 つはデプロイに必須、CloudWatch Logs はデバッグ用に追加しておくと安心です。完全閉域の VPC ではコンテナログが CloudWatch に届かないため、デプロイ失敗時に原因を追えなくなります。S3 は Gateway 型(無料)、それ以外は Interface 型(ENI 課金あり)。

Interface 型のエンドポイントでは、セキュリティグループのインバウンドルールで HTTPS(443)を VPC CIDR から許可し、プライベート DNS を有効にしておきます。プライベート DNS を有効にすれば、SDK のコードを変更せずにそのまま閉域経由で通信できます。

SageMaker モデルを作成する

AWS コンソールの SageMaker から、モデルを作成します。LMI コンテナに vLLM バックエンドを指定し、Nemotron 固有の設定を環境変数で渡します。

環境変数のなかで見落としがちなのが 2 つあります。

OPTION_TRUST_REMOTE_CODE=true は Mamba-2 ハイブリッドアーキテクチャのカスタムモデルコードを読み込むために必須で、これがないとモデルのロード自体が失敗します。

OPTION_MAMBA_SSM_CACHE_DTYPE=float32 は Mamba-2 レイヤーの状態キャッシュの精度を指定するもので、省略すると推論結果の精度が劣化します。エラーにはならないため気づきにくく、忘れずに設定しておくのがおすすめです。

環境変数をまとめると以下のとおりです。

環境変数 説明
HF_MODEL_ID /opt/ml/model S3 からマウントされるパス
OPTION_ROLLING_BATCH disable rolling batch を無効化(async モード)
OPTION_DTYPE bf16 モデルの精度
OPTION_TRUST_REMOTE_CODE true Mamba2 カスタムコードの読み込み
OPTION_MAMBA_SSM_CACHE_DTYPE float32 Mamba2 状態キャッシュの精度
OPTION_TENSOR_PARALLEL_DEGREE 1 GPU 並列数(A10G 1 枚)
OPTION_MAX_MODEL_LEN 4096 最大コンテキスト長
OPTION_CHAT_TEMPLATE /opt/ml/model/chat_template.jinja チャットテンプレートファイル

OPTION_ROLLING_BATCH=disable で LMI の rolling batch モードを無効化し、async モード(V17 以降のデフォルト)で起動しています。async モードは vLLM の AsyncLLMEngine をそのまま利用するため、rolling batch モードより性能と安定性が向上しています。OPTION_CHAT_TEMPLATE はモデルに同梱されているチャットテンプレートのパスで、推論リクエスト時にプロンプトへ自動適用されます。

Python SDK で作成する場合

コンソール操作の代わりに Python SDK でも同じ構成を作成できます。VPC 閉域構成では vpc_config でサブネットとセキュリティグループを指定します。

import sagemaker
import boto3

role = sagemaker.get_execution_role()
sess = sagemaker.Session()
region = "ap-northeast-1"

# LMI コンテナ URI(2026-02 時点の最新: LMI V20 / DJL 0.36.0)
container_uri = sagemaker.image_uris.retrieve(
    framework="djl-lmi",
    version="0.36.0",
    region=region
)

env = {
    "HF_MODEL_ID": "/opt/ml/model",
    "OPTION_ROLLING_BATCH": "disable",
    "OPTION_DTYPE": "bf16",
    "OPTION_TRUST_REMOTE_CODE": "true",
    "OPTION_MAMBA_SSM_CACHE_DTYPE": "float32",
    "OPTION_TENSOR_PARALLEL_DEGREE": "1",
    "OPTION_MAX_MODEL_LEN": "4096",
    "OPTION_CHAT_TEMPLATE": "/opt/ml/model/chat_template.jinja",
}

# VPC 情報(デフォルト VPC を使う例)
ec2 = boto3.client("ec2", region_name=region)
vpcs = ec2.describe_vpcs(Filters=[{"Name": "isDefault", "Values": ["true"]}])
vpc_id = vpcs["Vpcs"][0]["VpcId"]
subnets = ec2.describe_subnets(Filters=[{"Name": "vpc-id", "Values": [vpc_id]}])
subnet_ids = [s["SubnetId"] for s in subnets["Subnets"]]
sgs = ec2.describe_security_groups(
    Filters=[{"Name": "vpc-id", "Values": [vpc_id]}, {"Name": "group-name", "Values": ["default"]}]
)
sg_id = sgs["SecurityGroups"][0]["GroupId"]

model = sagemaker.Model(
    image_uri=container_uri,
    model_data=f"s3://{sess.default_bucket()}/models/nemotron-9b-v2-japanese/",
    env=env,
    role=role,
    vpc_config={"Subnets": subnet_ids, "SecurityGroupIds": [sg_id]},
)

predictor = model.deploy(
    instance_type="ml.g5.2xlarge",
    initial_instance_count=1,
    container_startup_health_check_timeout=900,
    model_data_download_timeout=1800,
)

エンドポイントを作成する

モデルの作成が完了したら、エンドポイント設定を作成してエンドポイントをデプロイします。

タイムアウト設定を 2 点変更しておくのがおすすめです。container_startup_health_check_timeout は 900 秒(デフォルト: 300 秒)に延長してください。18GB のモデルロードと vLLM の初期化に時間がかかるため、デフォルトだとタイムアウトする可能性があります。model_data_download_timeout も同様に 1800 秒に延長しておくと安心です。

デプロイ完了の確認

エンドポイントのステータスが InService になれば、デプロイ完了です。自分の環境では S3 からのモデルダウンロードと vLLM の初期化を含めて約 11 分(677 秒)でした。

動作確認として、簡単なプロンプトを送ってみます。

import json
import boto3

sm_runtime = boto3.client("sagemaker-runtime", region_name="ap-northeast-1")

payload = {
    "inputs": "東京タワーの高さは?",
    "parameters": {
        "max_new_tokens": 128,
        "temperature": 0,
    }
}

response = sm_runtime.invoke_endpoint(
    EndpointName="nemotron-9b-v2-japanese",
    ContentType="application/json",
    Body=json.dumps(payload),
)
result = json.loads(response["Body"].read().decode("utf-8"))
print(result["generated_text"])

正常にレスポンスが返ってくれば、閉域構成でのデプロイは完了です。

ハマったポイント

デプロイから動作検証までの過程で、いくつかのハマりポイントがありました。中でもチャットテンプレートの特殊トークン問題は、ベンチマーク精度に直結する重要な発見でした。

チャットテンプレートの特殊トークン問題

今回の検証で一番大きな発見がこれです。

Nemotron 9B-v2-Japanese のチャットテンプレートでは、System / User / Assistant のロール区切りに特殊トークンを使います。HuggingFace Hub 上の README やブログ記事では <extra_id_0> <extra_id_1> <extra_id_2> という記法をよく見かけますが、実はこれらは正しい特殊トークンではありません。

モデルの tokenizer_config.json を確認すると、実際のロール区切りトークンは <SPECIAL_10>(ID: 10)、<SPECIAL_11>(ID: 11)、<SPECIAL_12>(ID: 12)です。<extra_id_0> という文字列をトークナイザーに渡すと、単一の特殊トークンとして認識されず < extra _id _ 0 > の 6 サブワードに分割されてしまいます。

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained(
    "nvidia/NVIDIA-Nemotron-Nano-9B-v2-Japanese",
    trust_remote_code=True
)

# 間違い: BOS を除いて 6 サブワードに分割される
tokenizer.encode("<extra_id_0>")
# → [1, 523, 15683, 1620, 290, 29900, 1572]  (BOS + 6 subwords)

# 正解: 単一の特殊トークン
tokenizer.encode("<SPECIAL_10>")
# → [1, 10]  (BOS + special token)

テンプレート全体で見ると、<extra_id_X> 方式ではロール区切りだけで 18 個余計なトークンがプロンプトに混入する計算です。モデルにとっては意味のないノイズなので、推論精度に影響します。

実際にベンチマークで比較したところ、<extra_id_X> 方式では正答率 83.3% だったのに対し、正しい <SPECIAL_X> トークンを使うと 84.8% に改善しました。1.5pp の差は小さく見えるかもしれませんが、特定のカテゴリ(正解が A の問題)では 66.2% → 75.0% と大きな改善が見られます。

対策としては、HuggingFace の tokenizer.apply_chat_template() を使ってローカルでテンプレートを適用し、レンダリング済みのテキストを inputs として送信するのが確実です。

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained(
    "nvidia/NVIDIA-Nemotron-Nano-9B-v2-Japanese",
    trust_remote_code=True
)

messages = [{"role": "user", "content": "東京タワーの高さは?"}]
formatted = tokenizer.apply_chat_template(
    messages, tokenize=False,
    add_generation_prompt=True,
    enable_thinking=False,
)

# formatted には <SPECIAL_10>, <SPECIAL_11> 等が正しく含まれる
payload = {
    "inputs": formatted,
    "parameters": {"max_new_tokens": 128, "temperature": 0},
}

LMI の messages API の制限

LMI コンテナの async モードは OpenAI 互換の messages フォーマットもサポートしていますが、Nemotron 9B-v2-Japanese ではうまく動作しませんでした。すべてのリクエストで prompt_tokens: 8 と表示され、プロンプトの内容にかかわらず同じ応答が返ってきます。

チャットテンプレート内の <SPECIAL_X> トークンが、LMI 側のトークナイザー処理で正しく解釈されていない可能性が高いです。前述のとおり、ローカルでテンプレートを適用して inputs フォーマットで送信すれば問題なく動作します。

S3 モデルの指定方法

sagemaker-core v3 SDK(sagemaker.core.resources)を使う場合、S3ModelDataSource の設定に注意が必要です。s3_data_type"S3Prefix" を指定し、compression_type"None" を明示してください。compression_type を省略すると "Gzip" がデフォルトになり、非圧縮のモデルファイルを解凍しようとしてエラーになります。

ベンチマーク比較

JCommonsenseQA

前回の DGX Spark での検証と同一条件で計測しました。

項目 設定
データセット JCommonsenseQA v1.1
分割 validation(1,119 問)
評価方式 3-shot
temperature 0
シンキングモード OFF
環境 vLLM バージョン 正答率 正答数 平均レイテンシ
DGX Spark(NGC vLLM BF16) v0.13.0 83.5% 934/1,119 0.38 秒/問
SageMaker(LMI vLLM BF16) v0.15.1 84.8% 949/1,119 0.30 秒/問

DGX Spark(NGC コンテナ)と SageMaker(LMI コンテナ)で、vLLM のバージョンも GPU も異なりますが、正答率の差は 1.3pp に収まっています。同じ推論エンジンであれば、ハードウェアが変わっても精度はほぼ一致するという結果です。

コスト比較

項目 DGX Spark SageMaker (ml.g5.2xlarge)
初期コスト $3,999(本体) なし
ランニングコスト 電気代のみ $2.197/h
月額目安(平日 8h 稼働) ~ 数千円 ~ $352
月額目安(24h 稼働) ~ 数千円 ~ $1,604
ネットワーク オフライン可 VPC 閉域可
スケーラビリティ 1 台固定 Auto Scaling 可

DGX Spark は一度購入すれば電気代だけで使い続けられるので、開発・実験フェーズでは圧倒的にコスパが良いです。一方で SageMaker は、使わないときにエンドポイントを削除すれば課金が止まるため、利用頻度が低い本番環境やバッチ処理的な使い方には向いています。

VPC エンドポイント(Interface 型)の費用も忘れずに考慮しておきたいところです。東京リージョンでは ENI 1 つあたり $0.014/h で、AZ 数 × エンドポイント数の分だけ積み上がります。エンドポイントを使わないときは VPC エンドポイントも削除しておくと節約になります。

クリーンアップ

検証が終わったら、エンドポイントを忘れずに削除します。ml.g5.2xlarge は $2.197/h なので、1 日放置すると約 $53 の課金。

削除する対象は 3 つ。エンドポイント → エンドポイント設定 → モデルの順に、依存関係の逆順で削除します。

import boto3

sm = boto3.client("sagemaker", region_name="ap-northeast-1")

# 1. エンドポイント削除
sm.delete_endpoint(EndpointName="nemotron-9b-v2-japanese")

# 2. エンドポイント設定削除
sm.delete_endpoint_config(EndpointConfigName="nemotron-9b-v2-japanese")

# 3. モデル削除
sm.delete_model(ModelName="nemotron-9b-v2-japanese")

VPC エンドポイントも不要であれば削除しておきましょう。VPC コンソールのエンドポイント一覧から、今回作成した 6 つを選択して削除できます。

まとめ

Nemotron 9B-v2-Japanese を SageMaker 東京リージョンに閉域デプロイすること自体は問題なくできました。VPC エンドポイント 6 つの設定さえ済ませれば、データが日本国内の VPC から出ない構成を実現できます。デプロイ時間は約 11 分、推論レイテンシも 0.30 秒/問と実用的な水準です。

今回の検証で得られた大きな収穫は、vLLM であればハードウェアが変わっても精度がほぼ一致するということです。DGX Spark(NGC vLLM、83.5%)と SageMaker(LMI vLLM、84.8%)で、バージョンも GPU も異なるにもかかわらず差は 1.3pp でした。同じ推論エンジンを使う限り、ローカルで確認した精度がクラウドでも再現できるのは、デプロイ先を選定するうえで安心材料になりますね。

次回は SageMaker 上でのドメイン特化ファインチューニングを試してみる予定です。

参考リンク

この記事をシェアする

FacebookHatena blogX

関連記事