asken テックブログ

askenエンジニアが日々どんなことに取り組み、どんな「学び」を得ているか、よもやま話も織り交ぜつつ綴っていきます。 皆さまにも一緒に学びを楽しんでいただけたら幸いです! <br> 食事管理アプリ『あすけん』 について <br> https://www.asken.jp/ <br>

SageMakerでvLLMを動かそう

目次

  1. はじめに
  2. この記事で学べること
  3. 前提知識
  4. 技術スタックの全体像
  5. 各コンポーネントの詳細解説
  6. パフォーマンス設計
  7. 構築手順
  8. まとめ
  9. 参考リンク

はじめに

この記事は、株式会社asken (あすけん) Advent Calendar 2025の12/23の記事です。

こんにちは。AX推進部(テックリード・AIエンジニア)の山口です。

今回は、SageMakerでvLLMを動かす方法を説明したいと思います。

LLM を API として提供する際の課題

ChatGPT のような LLM を自社サービスに組み込みたい場合、以下のような課題があります:

  • レイテンシ(応答時間): ユーザーがテキストを入力してから結果が返るまでの時間。長いとユーザー体験が悪化します
  • スループット(処理能力): 同時に多くのリクエストを処理できるか
  • コスト: GPU は高価なので、効率的に使いたい
  • 運用負荷: サーバーの管理、スケーリング、監視など
  • モデルの自由度: 目的に沿ったモデルを選択できるか、量子化モデルに対応できているかなど

なぜ SageMaker + vLLM なのか

AWSでのLLMのサービングには下記の方法が考えられます

観点 SageMaker + vLLM Bedrock(Custom Model Import) ECS EC2
レイテンシ ◎(vLLMで高並列に強い。AWSのLMIコンテナでvLLM対応) ◎(マネージドで自動スケール) ○(構成次第。コンテナ/インスタンス性能に依存) ○〜△(最適化次第。全部自前でばらつきやすい)
運用負荷 ○(エンドポイント/スケール設定は必要だがマネージド) ◎(ほぼゼロ運用) △(クラスタ/ALB/スケール設計など自前) ×(OS〜監視〜冗長化まで全部自前)
コスト / Scale-to-Zero △(リアルタイムは常時課金。ServerlessはScale-to-Zero可能だがGPU非対応) (AWS ドキュメント) △(オンデマンド。モデルコピー稼働分のみ、5分課金窓。無呼び出しでスケール0) ○〜△(タスク数0は設定可。ただし「0→起動」をリクエスト起点でやるには工夫が要る) (AWS ドキュメント) ○(止めれば0円。ただし自動停止/起動は自前実装)
導入の容易さ ○(AWS提供のLMIコンテナで比較的ラク) ◎(S3に置いてインポート→Bedrock APIで即利用) △(コンテナ化〜ECR〜タスク定義〜LBなど手数多め) ×(環境構築から全部)
モデル自由度(制約) ◎(ほぼ何でも。自分で制御できる) △(対応アーキテクチャが限定:Mistral/Llama/Qwen/GPT-OSS/Mixtral等。さらに量子化モデル不可・Embedding不可など制約) ◎(基本なんでも) ◎(基本なんでも)

この中でもSageMaker + vLLMを選んだ理由は下記です。

SageMaker + vLLM は、vLLMで高並列でも低レイテンシを狙いつつ、SageMakerのマネージド運用で現実的に回せるバランスが良いからです。

Bedrock Custom Import は運用は最軽量ですが対応モデル/構成の制約が強く

ECS/EC2 は自由度は高い反面、運用・導入コストが重いため今回は外しました。

この記事で学べること

本記事を読むと、以下のことができるようになります:

  1. SageMaker 上で vLLM を動かす仕組みを理解する
  2. LMI(Large Model Inference)の設定ファイル、serving.properties の設定方法を理解する
  3. vLLM(AsyncLLMEngine) を使ったカスタム推論ハンドラを実装する
  4. パフォーマンスを最適化する方法を知る

対象読者

  • AWS の基本的なサービス(S3、IAM)を使ったことがある方
  • Python の基本的な文法を理解している方
  • LLM を API として提供したいと考えている方

前提知識

LLM(Large Language Model)とは

大量のテキストデータで学習した AI モデルです。テキストを入力すると、続きのテキストを生成します。GPT(OpenAI)、Claude、Gemini などが有名です。

推論(Inference)とは

学習済みのモデルを使って、新しいデータに対する予測を行うことです。本記事では「ユーザーが入力したテキストに対して、モデルが応答を生成すること」を指します。

[入力] "今日の天気は?"
    ↓ 推論
[出力] "今日は晴れでしょう。気温は..."

GPU が必要な理由

LLM は数十億〜数千億のパラメータを持つ巨大なモデルです。これを高速に処理するには、並列計算が得意な GPU(Graphics Processing Unit)が必要です。

技術スタックの全体像

アーキテクチャ図

flowchart TB
    subgraph AWS["AWS"]
        subgraph Endpoint["SageMaker Endpoint"]
            subgraph LMI["LMI コンテナ"]
                subgraph DJL["DJL Serving"]
                    subgraph vLLM["vLLM (AsyncLLMEngine)"]
                        Model["gemma-3-4b-it<br/>モデル"]
                    end
                end
            end
        end
        S3[("S3 バケット<br/>(model.tar.gz)")]
    end

    Client["クライアント<br/>(API 呼び出し)"]

    Client -->|"リクエスト"| Endpoint
    Endpoint -->|"レスポンス"| Client
    S3 -.->|"モデル読み込み"| vLLM

各コンポーネントの役割

コンポーネント 役割 例えるなら
SageMaker Real-time Endpoint API のエンドポイントを提供。リクエストを受け付ける レストランの受付
LMI コンテナ vLLM や必要なライブラリがインストール済みの環境 キッチン設備一式
DJL Serving リクエストの受付・レスポンスの返却を担当 ホールスタッフ
vLLM(AsyncLLMEngine) 実際に LLM の推論を行うエンジン シェフ
モデル (gemma-3-4b-it) 学習済みの LLM 本体 レシピ

各コンポーネントの詳細解説

SageMaker Endpoint とは

Amazon SageMaker は、機械学習モデルの開発・デプロイを支援する AWS のサービスです。その中でも Real-time Endpoint は、API として即座に応答を返す推論エンドポイントを提供します。

メリット:

  • インフラ管理が不要(サーバーの設定、OS のパッチ適用など)
  • 自動スケーリング対応
  • AWS の他サービス(Lambda、API Gateway など)との連携が容易

LMI(Large Model Inference)コンテナとは

AWS が提供する、大規模モデルの推論に特化したコンテナイメージです。

何が嬉しいのか

  • vLLM、TensorRT-LLM などの推論エンジンがプリインストール済み
  • 設定ファイル(serving.properties)を書くだけで動作
  • GPU の最適化が施されている
# 使用するコンテナイメージの例(LMI v18)
763104351884.dkr.ecr.ap-northeast-1.amazonaws.com/djl-inference:0.36.0-lmi18.0.0-cu128

DJL Serving とは

Deep Java Library (DJL) プロジェクトのモデルサービングフレームワークです。LMI コンテナの基盤となっています。

役割:

  • HTTP リクエストの受付
  • リクエストのデコード(JSON → Python オブジェクト)
  • 推論エンジン(vLLM)への受け渡し
  • レスポンスの返却

vLLM とは

vLLM は、UC Berkeley で開発された高速な LLM 推論エンジンです。

なぜ高速なのか:

  1. PagedAttention: GPU メモリを効率的に使用する技術
  2. Continuous Batching(連続バッチ処理): 複数のリクエストを効率的にまとめて処理
従来の方式(静的バッチ):
  リクエスト1: ████████░░░░ (長い)
  リクエスト2: ██░░░░░░░░░░ (短い) ← 待ち時間が発生
  リクエスト3: ████░░░░░░░░ (中程度)
  ──────────────────────────▶ 時間

vLLM(連続バッチ):
  リクエスト1: ████████
  リクエスト2: ██ → 終了後すぐに次のリクエストを処理
  リクエスト3: ████
  リクエスト4:   ████████ ← 空いた枠に新しいリクエストを追加
  ──────────────────────────▶ 時間

LLMEngine と AsyncLLMEngine

vLLM には2種類のエンジンがあります:

エンジン 特徴 用途
LLMEngine 同期処理 シンプルなバッチ処理
AsyncLLMEngine 非同期処理 リアルタイム API

SageMaker LMI の async_mode=true と組み合わせる場合、AsyncLLMEngine を使うことで以下のメリットがあります:

  1. 並列リクエスト処理: 複数のリクエストを同時に処理
  2. 効率的なバッチ処理: vLLM の continuous batching と相性が良い
  3. レイテンシの安定化: 1つの長いリクエストが他のリクエストをブロックしない

AsyncEngineArgs の主要パラメータ

AsyncLLMEngine を初期化する際に使用する AsyncEngineArgs の主要パラメータを理解しておきましょう。これらは後の構築手順で model.py を作成する際に使用します。

engine_args = AsyncEngineArgs(
    model=model_id,
    dtype="bfloat16",

    # GPU メモリ使用率(0.0〜1.0)
    # 高いほど多くのリクエストを処理できるが、OOM のリスクが上がる
    gpu_memory_utilization=0.9,

    # 最大コンテキスト長
    # 入力トークン数 + 出力トークン数 の上限
    max_model_len=4096,

    # 同時に処理できるシーケンス(リクエスト)数
    # serving.properties の max_rolling_batch_size と合わせる
    max_num_seqs=128,

    # prefill と decode の並列化(次のセクションで詳しく解説)
    enable_chunked_prefill=True,

    # 1バッチあたりの最大トークン数
    max_num_batched_tokens=4096,
)

async for による生成の仕組み

AsyncLLMEngine.generate()async generator を返します:

async def inference(self, prompt: str):
    request_id = str(uuid.uuid4())
    final_output = None

    # generate() は async generator
    # トークンが生成されるたびに yield される
    async for output in self.engine.generate(
        prompt,
        self.sampling_params,
        request_id
    ):
        # ストリーミング出力が必要な場合は
        # ここで中間結果を処理できる
        # print(output.outputs[0].text)

        # 今回は最終結果だけが必要なので、最後の出力を保持
        final_output = output

    return final_output
generate() の動作イメージ:

時間 →
  yield 1: "今"
  yield 2: "今日"
  yield 3: "今日は"
  yield 4: "今日は晴"
  yield 5: "今日は晴れ"
  ...
  yield N: "今日は晴れでしょう。" (最終結果)

gemma-3-4b-it とは

Google が公開した Gemma ファミリーの マルチモーダルなLLM です。

  • 3: バージョン 3
  • 4b: 約 40 億パラメータ
  • it: Instruction-tuned(指示に従うように調整済み)

約 40 億パラメータは「小型」に分類され、単一の GPU(24GB VRAM)で動作します。

gemma-3-270m-it, gemma-3-1b-itは、マルチモーダルではないので注意が必要です

パフォーマンス設計

構築手順に入る前に、パフォーマンスに関わる重要な設定について理解しておきましょう。これらの設定は serving.propertiesmodel.py で使用しますが、意味を理解せずにコピー&ペーストすると、後でトラブルシューティングが困難になります。

Prefill と Decode の仕組み

LLM の推論には2つのフェーズがあります:

  1. Prefill(プリフィル): 入力プロンプト全体を処理して、最初のトークンを生成
  2. Decode(デコード): トークンを1つずつ生成
推論の流れ:
  入力: "今日の天気は?"
     ↓
  [Prefill] 入力全体を処理 → 最初のトークン "今" を生成
     ↓
  [Decode] "今" → "日"
  [Decode] "今日" → "は"
  [Decode] "今日は" → "晴"
  ...

enable_chunked_prefill とは

enable_chunked_prefill=True を設定すると、Prefill フェーズを分割して、他のリクエストの Decode と並列実行できます。

従来の処理:
  リクエスト1: [=====Prefill=====][Decode][Decode][Decode]
  リクエスト2:                     待機... [=====Prefill=====][Decode]...

enable_chunked_prefill=True:
  リクエスト1: [==Prefill==][==Prefill==][Decode][Decode]
  リクエスト2:              [==Prefill==][Decode][Decode]
  ※ Prefill を分割し、他のリクエストの Decode と並列実行

長いプロンプトを処理する際に特に効果があります。この設定は model.pyAsyncEngineArgs で使用します。

コールドスタート対策

コールドスタートとは

エンドポイントが停止状態(または新規起動)から、最初のリクエストを処理できるようになるまでの時間です。

コールドスタート:
  リクエスト → [モデルロード: 数十分] → [推論: 100ms] → レスポンス
              ↑ ここが長い

ウォームスタート:
  リクエスト → [推論: 100ms] → レスポンス
  ※ モデルは既にロード済み

LLM のモデルファイルは数 GB〜数十 GB あるため、コールドスタートには数十分かかることがあります。

Fast Model Loader の活用

serving.propertiesoption.enable_streaming_s3_download=true を設定すると、Fast Model Loader が有効になります:

従来の方式:
  S3 → [全データダウンロード] → [GPU にロード]
       └─────── 待機 ───────┘

Fast Model Loader:
  S3 → [ダウンロードしながら GPU にロード]
       └──── 並列処理 ────┘

これにより、コールドスタート時間を大幅に短縮できます。

model_loading_timeout の設定

大きなモデルをロードする場合、タイムアウトを適切に設定します:

# デフォルトは 240秒(4分)
# 大きなモデルの場合は増やす
option.model_loading_timeout=360  # 6分

タイムアウトが短すぎると、モデルのロード中にエラーになってしまいます。

構築手順

ここまでで、SageMaker + vLLM の仕組みとパフォーマンス設計のポイントを理解しました。いよいよ実際に構築していきましょう。

全体の流れ

構築は以下の 7 ステップで進めます:

Step 内容 成果物
Step 1 ディレクトリ構成を理解する 必要なファイル一覧の把握
Step 2 serving.properties を作成する LMI コンテナの設定ファイル
Step 3 model.py を作成する AsyncLLMEngine を使った推論ハンドラ
Step 4 モデルをパッケージングする model.tar.gz
Step 5 S3 にアップロードする S3 上のモデルファイル
Step 6 SageMaker Endpoint を作成する 稼働中の API エンドポイント
Step 7 推論を実行する 動作確認
[Step 1-3: ファイル準備] → [Step 4-5: アップロード] → [Step 6-7: デプロイ・実行]

それでは、各ステップを詳しく見ていきましょう。

Step 1: ディレクトリ構成を理解する

SageMaker LMI でカスタム推論ハンドラを使用する場合、以下のファイルを用意します。

必要なファイル一覧

以下は google/gemma-3-4b-it を例にした実際のファイル構成です

my-model/
│
│  # ========================================
│  # モデル設定ファイル(必須)
│  # ========================================
├── config.json                    # モデルアーキテクチャ設定
│                                  # - レイヤー数、hidden size、attention heads
│                                  # - 最大コンテキスト長、語彙サイズ
│                                  # - model_type(AutoTokenizer/AutoProcessor が参照)
│
│  # ========================================
│  # トークナイザー関連ファイル(必須)
│  # ========================================
├── tokenizer_config.json          # トークナイザーの設定(★ chat_template を含む)
│                                  # - tokenizer_class の指定
│                                  # - chat_template(Jinja2 テンプレート)
│                                  # - 特殊トークンの設定
├── tokenizer.json                 # トークナイザー本体(Fast Tokenizer 形式)
│                                  # - 語彙、マージルール、正規化設定
├── tokenizer.model                # SentencePiece モデル
│                                  # - Llama, Gemma 系で使用
├── special_tokens_map.json        # 特殊トークンのマッピング
│                                  # - bos_token, eos_token, pad_token など
├── added_tokens.json              # 追加トークン(fine-tuning で追加した場合)
│
│  # ========================================
│  # チャットテンプレート(モデルによる)
│  # ========================================
├── chat_template.json             # チャットテンプレート
│
│  # ========================================
│  # プロセッサ設定(マルチモーダルモデルの場合)
│  # ========================================
├── preprocessor_config.json       # 画像プロセッサの設定
│                                  # - 画像のリサイズ、正規化パラメータ
│                                  # - Gemma 3 はマルチモーダル対応のため必要
├── processor_config.json          # プロセッサの設定
│                                  # - AutoProcessor が参照
│                                  # - processor_class の指定
│
│  # ========================================
│  # モデルの重み(必須)
│  # ========================================
├── model-00001-of-00002.safetensors  # シャード分割された重み
├── model-00002-of-00002.safetensors  # (gemma-3-4b-it は約 8.6GB)
├── model.safetensors.index.json   # シャードのインデックス
│                                  # - 各パラメータがどのファイルにあるか
│
│  # ========================================
│  # 生成設定(推奨)
│  # ========================================
├── generation_config.json         # デフォルトの生成パラメータ
│                                  # - temperature, top_p, top_k
│                                  # - max_new_tokens, do_sample
│
│  # ========================================
│  # LMI 設定ファイル(必須)
│  # ========================================
├── serving.properties             # ★ DJL Serving / vLLM の設定
└── model.py                       # ★ カスタム推論ハンドラ

各ファイルの役割

ファイル 必須 説明
config.json 必須 モデルアーキテクチャの定義。model_typeAutoTokenizer/AutoProcessor が参照
tokenizer_config.json 必須 トークナイザーの設定。chat_template(Jinja2 形式)を含む
tokenizer.json 推奨 Fast Tokenizer の本体。高速なトークン化を実現
tokenizer.model モデルによる SentencePiece モデル。Llama, Gemma 系で必要
special_tokens_map.json 推奨 特殊トークン(BOS, EOS, PAD など)のマッピング
added_tokens.json 任意 Fine-tuning で追加したトークン
chat_template.json モデルによる チャットテンプレート(別ファイルの場合)
preprocessor_config.json マルチモーダル時 画像プロセッサの設定。画像入力を扱うモデルで必要
processor_config.json マルチモーダル時 プロセッサの設定。AutoProcessor が参照
model.safetensors 必須 モデルの重み。SafeTensors 形式を推奨
model.safetensors.index.json シャード時 分割された重みファイルのインデックス
generation_config.json 推奨 デフォルトの生成パラメータ
serving.properties 必須 LMI コンテナの設定
model.py カスタム時 カスタム推論ハンドラ

chat_template.json について

chat_template.json は、チャット形式の入力(role/content の辞書リスト)を LLM が理解できるトークン列に変換するための テンプレート です。

# chat_template の使用例
messages = [
    {"role": "user", "content": "こんにちは"},
]

# apply_chat_template() が chat_template.json 内の chat_template を使用
prompt = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True,  # アシスタントの応答開始トークンを追加
)

テキスト専用 vs マルチモーダルモデル

モデルタイプ 必要なファイル ロード方法
テキスト専用 tokenizer_config.json, tokenizer.json AutoTokenizer.from_pretrained()
マルチモーダル 上記 + preprocessor_config.json, processor_config.json AutoProcessor.from_pretrained()

gemma-3-4b-it はテキストと画像の両方を処理できるマルチモーダルモデルのため、preprocessor_config.jsonprocessor_config.json が含まれています。テキスト推論のみを行う場合でも、これらのファイルを含めておくと AutoProcessor でロードできます。

ファイルの取得方法

Hugging Face からモデルをダウンロードする場合、いくつかの方法があります。

方法1: huggingface_hub で全ファイルをダウンロード(推奨)

最も確実な方法は、huggingface_hubsnapshot_download を使用して必要なファイルを全てダウンロードすることです:

from huggingface_hub import snapshot_download

model_id = "google/gemma-3-4b-it"
save_dir = "./my-model"

# 必要なファイルパターンを指定してダウンロード
snapshot_download(
    repo_id=model_id,
    local_dir=save_dir,
    allow_patterns=[
        # モデル設定ファイル
        "config.json",
        "generation_config.json",

        # トークナイザー関連(全て必要)
        "tokenizer.json",
        "tokenizer.model",
        "tokenizer_config.json",
        "special_tokens_map.json",
        "added_tokens.json",

        # チャットテンプレート(Gemma 3 の場合、別ファイルとしても存在)
        "chat_template.json",

        # マルチモーダル対応(Gemma 3 はマルチモーダルモデル)
        "preprocessor_config.json",
        "processor_config.json",

        # モデルの重み
        "*.safetensors",
        "model.safetensors.index.json",
    ],
)

print(f"モデルを {save_dir} にダウンロードしました")

方法2: save_pretrained() を使用

transformers ライブラリの save_pretrained() メソッドを使う方法もあります。ただし、この方法では一部のファイル(chat_template.json など)が保存されない場合があるため、注意が必要です:

import torch
from transformers import AutoTokenizer, AutoProcessor, AutoModelForCausalLM

model_id = "google/gemma-3-4b-it"
save_dir = "./my-model"

# トークナイザーを保存
# tokenizer_config.json, tokenizer.json, tokenizer.model,
# special_tokens_map.json, added_tokens.json が保存される
tokenizer = AutoTokenizer.from_pretrained(model_id)
tokenizer.save_pretrained(save_dir)

# プロセッサを保存(マルチモーダルモデルの場合)
# preprocessor_config.json, processor_config.json が保存される
try:
    processor = AutoProcessor.from_pretrained(model_id)
    processor.save_pretrained(save_dir)
except Exception as e:
    print(f"プロセッサの保存をスキップ: {e}")

# モデルを保存
# config.json, *.safetensors, model.safetensors.index.json,
# generation_config.json が保存される
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    dtype=torch.bfloat16,
)
model.save_pretrained(save_dir)

print(f"モデルを {save_dir} に保存しました")

方法3: Hugging Face CLI を使用

コマンドラインから直接ダウンロードすることもできます:

# huggingface_hub をインストール
pip install huggingface-hub

# モデルをダウンロード(全ファイル)
huggingface-cli download google/gemma-3-4b-it \\
    --local-dir ./my-model \\
    --include "*.json" "*.safetensors" "*.model"

ダウンロード後の確認

ダウンロードが正しく完了したか、以下のスクリプトで確認できます:

import os
from pathlib import Path

save_dir = Path("./my-model")

# 必須ファイルのリスト
required_files = [
    "config.json",
    "tokenizer_config.json",
    "tokenizer.json",
    "generation_config.json",
]

# 推奨ファイル(存在すれば OK)
recommended_files = [
    "tokenizer.model",
    "special_tokens_map.json",
    "added_tokens.json",
    "chat_template.json",
    "preprocessor_config.json",
    "processor_config.json",
]

# モデル重みファイル
weight_patterns = ["*.safetensors"]

print("=== ファイル確認 ===\\n")

# 必須ファイルのチェック
print("【必須ファイル】")
for f in required_files:
    exists = (save_dir / f).exists()
    status = "✓" if exists else "✗ (不足)"
    print(f"  {status} {f}")

# 推奨ファイルのチェック
print("\\n【推奨ファイル】")
for f in recommended_files:
    exists = (save_dir / f).exists()
    status = "✓" if exists else "- (なし)"
    print(f"  {status} {f}")

# モデル重みのチェック
print("\\n【モデル重み】")
safetensors_files = list(save_dir.glob("*.safetensors"))
if safetensors_files:
    for f in safetensors_files:
        size_gb = f.stat().st_size / (1024**3)
        print(f"  ✓ {f.name} ({size_gb:.2f} GB)")
else:
    print("  ✗ .safetensors ファイルが見つかりません")

# インデックスファイル
index_file = save_dir / "model.safetensors.index.json"
if index_file.exists():
    print(f"  ✓ model.safetensors.index.json")

Step 2: serving.properties を作成する

serving.properties は、DJL Serving(と vLLM)の動作を設定するファイルです。

# ========================================
# SageMaker LMI vLLM 設定ファイル
# ========================================

# --- 基本設定 ---
engine=Python

# --- モデル設定 ---
# モデルファイルの場所。SageMaker では /opt/ml/model に配置される
option.model_id=/opt/ml/model

# モデルの精度
option.dtype=bfloat16

# Hugging Face のカスタムコードを信頼するか
# セキュリティ注意: この設定を true にすると、モデルリポジトリ内の
# 任意の Python コードが実行される可能性があります。
# 信頼できるソース(公式リポジトリ等)のモデルのみで使用してください。
option.trust_remote_code=true

# --- 非同期モード設定(重要!) ---
# LMI v15 以降で推奨される設定
# true にすると複数リクエストを効率的に並列処理できる
option.async_mode=true

# ストリーミング出力を使用するか(今回は使用しない)
option.enable_streaming=false

# --- バッチ処理設定 ---
# 同時に処理できる最大リクエスト数
option.max_rolling_batch_size=256

# --- GPU 設定 ---
# 使用する GPU 数。max を指定すると利用可能な全 GPU を使用
option.tensor_parallel_degree=max

# --- コールドスタート対策 ---
# S3 からモデルをストリーミングダウンロード(高速化)
option.enable_streaming_s3_download=true

# モデル読み込みのタイムアウト(秒)
option.model_loading_timeout=360

# --- カスタムハンドラ ---
# 推論ロジックを記述した Python ファイル
option.entryPoint=model.py

以下、各設定の詳細を解説します。

option.async_mode=true について

これは 最も重要な設定の一つ です。

async_mode=false(同期モード)の場合:
  リクエスト1 → [処理中...] → 完了
                              リクエスト2 → [処理中...] → 完了
  ※ 1つずつ順番に処理

async_mode=true(非同期モード)の場合:
  リクエスト1 → [処理開始]
  リクエスト2 → [処理開始] ← 1 が終わる前に処理開始
  リクエスト3 → [処理開始]
  ※ GPU の空き容量がある限り、並列で処理

option.dtype=bfloat16 について

モデルの数値精度を指定します。

精度 メモリ使用量 特徴
float32 (FP32) 多い 最も精度が高いが、メモリを多く消費
float16 (FP16) 少ない メモリ効率が良いが、数値が不安定になることがある
bfloat16 (BF16) 少ない FP16 と同じメモリ効率で、FP32 に近い数値安定性

bfloat16 は「いいとこ取り」なので、最新の GPU(A10G、L4、H100 など)では bfloat16 を使うのが一般的です。

option.max_rolling_batch_size=256 について

一度に処理できるリクエストの最大数です。この値を大きくすると:

  • メリット: スループット(単位時間あたりの処理量)が向上
  • デメリット: GPU メモリを多く消費。大きすぎると Out of Memory (OOM) エラー

適切な値は、モデルサイズや GPU のメモリ容量によって異なります。

Step 3: model.py(カスタム推論ハンドラ)を作成する

model.py は、リクエストを受け取って推論を行い、結果を返す Python スクリプトです。

"""
SageMaker LMI 用カスタム推論ハンドラ

このファイルは以下の流れで処理を行います:
1. リクエストを受け取る
2. テキストをモデルが理解できる形式に変換(前処理)
3. vLLM で推論を実行
4. 結果を整形して返す(後処理)
"""

import logging
import uuid
from typing import Any

# DJL Serving が提供するユーティリティ
from djl_python import Input, Output
from djl_python.async_utils import create_non_stream_output
from djl_python.encode_decode import decode

# Hugging Face のトークナイザー
from transformers import AutoTokenizer

# vLLM のコンポーネント
from vllm import SamplingParams
from vllm.engine.arg_utils import AsyncEngineArgs
from vllm.engine.async_llm_engine import AsyncLLMEngine

# ロガーの設定
logger = logging.getLogger(__name__)

class InferenceHandler:
    """
    推論ハンドラクラス

    このクラスは以下を管理します:
    - vLLM エンジンの初期化
    - リクエストの前処理
    - 推論の実行
    - レスポンスの後処理
    """

    def __init__(self):
        """コンストラクタ: 変数の初期化"""
        self.initialized = False  # 初期化済みフラグ
        self.engine = None        # vLLM エンジン
        self.tokenizer = None     # トークナイザー
        self.sampling_params = None  # 生成パラメータ

    def initialize(self, properties: dict):
        """
        vLLM エンジンを初期化する

        この処理は最初のリクエスト時に1回だけ実行されます。
        モデルの読み込みには数分かかることがあります。

        Args:
            properties: serving.properties から読み込まれた設定
        """
        # 設定値の取得(デフォルト値付き)
        model_id = properties.get("model_id", "/opt/ml/model")
        dtype = properties.get("dtype", "bfloat16")
        gpu_memory_utilization = float(
            properties.get("gpu_memory_utilization", "0.9")
        )

        logger.info(f"AsyncLLMEngine を初期化します: model={model_id}, dtype={dtype}")

        # ========================================
        # AsyncEngineArgs: vLLM エンジンの設定
        # ========================================
        engine_args = AsyncEngineArgs(
            # --- 基本設定 ---
            model=model_id,                    # モデルのパス
            dtype=dtype,                       # 数値精度
            trust_remote_code=True,            # カスタムコードを信頼

            # --- メモリ設定 ---
            gpu_memory_utilization=gpu_memory_utilization,  # GPU メモリ使用率

            # --- シーケンス設定 ---
            max_model_len=4096,                # 最大コンテキスト長(入力+出力の合計トークン数)
            max_num_seqs=128,                  # 同時に処理できるシーケンス数

            # --- パフォーマンス最適化 ---
            enable_chunked_prefill=True,       # prefill と decode を並列化
            max_num_batched_tokens=4096,       # 1バッチあたりの最大トークン数
        )

        # ========================================
        # AsyncLLMEngine の作成
        # ========================================
        # from_engine_args() で設定を渡してエンジンを作成
        self.engine = AsyncLLMEngine.from_engine_args(engine_args)

        # ========================================
        # トークナイザーの読み込み
        # ========================================
        # モデルと同じトークナイザーを使用
        self.tokenizer = AutoTokenizer.from_pretrained(model_id)

        # ========================================
        # SamplingParams: テキスト生成のパラメータ
        # ========================================
        self.sampling_params = SamplingParams(
            temperature=0.7,    # 状況によって、適切な値を設定してください
            max_tokens=512,     # 最大生成トークン数
        )

        self.initialized = True
        logger.info("初期化完了")

    def preprocess(self, data: dict) -> str:
        """
        入力データを LLM が理解できる形式に変換する

        多くの LLM は「チャット形式」の入力を期待します。
        トークナイザーの apply_chat_template() を使って変換します。

        Args:
            data: {"text": "ユーザーの入力"} 形式の辞書

        Returns:
            LLM への入力プロンプト(文字列)
        """
        text = data.get("text", "")

        # チャット形式のメッセージを作成
        messages = [
            {"role": "user", "content": text},
        ]

        # トークナイザーでプロンプト形式に変換
        # add_generation_prompt=True で、モデルが応答を開始するためのプロンプトを追加
        prompt = self.tokenizer.apply_chat_template(
            messages,
            tokenize=False,          # トークン化せず文字列のまま返す
            add_generation_prompt=True,
        )

        return prompt

    async def inference(self, prompt: str) -> Any:
        """
        vLLM で推論を実行する

        AsyncLLMEngine.generate() は非同期ジェネレータを返します。
        ストリーミング出力が不要な場合でも、async for でイテレートして
        最終結果を取得します。

        Args:
            prompt: 前処理済みのプロンプト

        Returns:
            vLLM の出力オブジェクト
        """
        # 各リクエストを識別するための一意な ID
        request_id = str(uuid.uuid4())

        final_output = None

        # generate() は async generator を返す
        # 生成が進むたびに中間結果が yield される
        async for output in self.engine.generate(
            prompt,                 # 入力プロンプト
            self.sampling_params,   # 生成パラメータ
            request_id,            # リクエスト ID
        ):
            # 最後の出力を保持(最終結果)
            final_output = output

        return final_output

    def postprocess(self, output: Any) -> dict:
        """
        推論結果を API レスポンス形式に変換する

        vLLM の出力から生成されたテキストを抽出します。

        Args:
            output: vLLM の RequestOutput オブジェクト

        Returns:
            {"generated_text": "生成されたテキスト"} 形式の辞書
        """
        # outputs[0] は最初の(通常は唯一の)生成結果
        generated_text = output.outputs[0].text.strip()

        return {"generated_text": generated_text}

    async def handle(self, data: list[dict]) -> list[dict]:
        """
        バッチ推論のメインハンドラ

        複数のリクエストを処理できますが、
        ここでは順次処理しています。

        Args:
            data: 入力データのリスト

        Returns:
            結果のリスト
        """
        results = []

        for item in data:
            try:
                # 1. 前処理
                prompt = self.preprocess(item)

                # 2. 推論
                output = await self.inference(prompt)

                # 3. 後処理
                result = self.postprocess(output)
                results.append(result)

            except Exception as e:
                logger.error(f"エラーが発生しました: {e}", exc_info=True)
                results.append({"error": str(e)})

        return results

# ========================================
# グローバルインスタンス
# ========================================
# ハンドラは1つのインスタンスを使い回す(ウォームスタート最適化)
_handler = InferenceHandler()

async def handle(inputs: Input) -> Output:
    """
    DJL Serving のエントリーポイント

    DJL Serving はこの関数を呼び出して推論を実行します。
    async def で定義することで、非同期処理が可能になります。

    Args:
        inputs: DJL の Input オブジェクト(リクエスト情報を含む)

    Returns:
        DJL の Output オブジェクト(レスポンス)
    """
    global _handler

    # 初回リクエスト時のみ初期化を実行
    if not _handler.initialized:
        _handler.initialize(inputs.get_properties())

    # 空のリクエストは無視
    if inputs.is_empty():
        return None

    # ========================================
    # リクエストのデコード
    # ========================================
    # バッチ(複数リクエスト)を取得
    batch = inputs.get_batches()
    raw_request = batch[0]

    # Content-Type を取得(通常は application/json)
    content_type = raw_request.get_property("Content-Type")

    # JSON をデコードして Python オブジェクトに変換
    decoded_payload = decode(raw_request, content_type)

    # 単一リクエストの場合はリストに変換
    if isinstance(decoded_payload, dict):
        data = [decoded_payload]
    else:
        data = decoded_payload

    # ========================================
    # 推論の実行
    # ========================================
    results = await _handler.handle(data)

    # 単一結果の場合はリストから取り出す
    if len(results) == 1:
        results = results[0]

    # ========================================
    # レスポンスの作成
    # ========================================
    # create_non_stream_output() で DJL の Output オブジェクトに変換
    return create_non_stream_output(results)

Step 4: モデルをパッケージングする

SageMaker にデプロイするには、モデルファイルと設定ファイルを model.tar.gz にまとめる必要があります。 パラメーターが数千億ある大きなモデルの場合は、非圧縮でデプロイする方法も検討してください。

詳細は Deploying Your Endpoint (DJL) を参照してください。

パッケージング前のチェックリスト

パッケージングする前に、必要なファイルが揃っているか確認しましょう:

# ディレクトリ構成を確認
ls -la my-model/

# 期待されるファイル一覧:
# ├── config.json               # 必須: モデル設定
# ├── tokenizer_config.json     # 必須: トークナイザー設定
# ├── tokenizer.json            # 推奨: Fast Tokenizer
# ├── tokenizer.model           # Gemma/Llama系: SentencePiece
# ├── special_tokens_map.json   # 推奨: 特殊トークン
# ├── generation_config.json    # 推奨: 生成パラメータ
# ├── model.safetensors         # 必須: モデルの重み
# │   (または model-00001-of-XXXXX.safetensors + index.json)
# ├── serving.properties        # 必須: LMI 設定
# └── model.py                  # カスタムハンドラ使用時

ファイル検証スクリプト

パッケージング前に、AutoTokenizer でロードできるか確認することをお勧めします:

from transformers import AutoTokenizer, AutoConfig

model_dir = "./my-model"

# config.json の検証
config = AutoConfig.from_pretrained(model_dir)
print(f"model_type: {config.model_type}")  # model_type が表示されればOK

# トークナイザーの検証
tokenizer = AutoTokenizer.from_pretrained(model_dir)
print(f"tokenizer_class: {tokenizer.__class__.__name__}")

# テストエンコード
test_text = "こんにちは"
tokens = tokenizer.encode(test_text)
print(f"tokens: {tokens}")
print(f"decoded: {tokenizer.decode(tokens)}")

パッケージング

# ディレクトリに移動
cd my-model

# tar.gz にパッケージング
# 重要: ファイルはアーカイブのルートに配置する必要がある
tar -czvf ../model.tar.gz .

# 内容を確認(ルートにファイルがあることを確認)
tar -tzvf ../model.tar.gz

# 期待される出力例:
# ./config.json
# ./tokenizer_config.json
# ./tokenizer.json
# ./tokenizer.model
# ./special_tokens_map.json
# ./generation_config.json
# ./model.safetensors
# ./serving.properties
# ./model.py

tar.gz とは: 複数のファイルを1つにまとめ(tar)、圧縮(gz)したアーカイブ形式です。

Step 5: S3 にアップロードする

パッケージングしたモデルを S3 にアップロードします。

# S3 バケットにアップロード
aws s3 cp model.tar.gz s3://your-bucket-name/models/model.tar.gz

# アップロードを確認
aws s3 ls s3://your-bucket-name/models/

Step 6: SageMaker Endpoint を作成する

SageMaker Endpoint の作成は、以下の 3 つのリソースを順番に作成します:

1. Model        → 「どのコンテナで、どのモデルを使うか」を定義
2. EndpointConfig → 「どのインスタンスタイプで、何台動かすか」を定義
3. Endpoint     → 実際にエンドポイントを起動

インスタンスタイプの選択

まず、使用するインスタンスタイプを決めましょう。モデルサイズに応じて適切なインスタンスを選択します:

インスタンスタイプ GPU VRAM 用途
ml.g5.xlarge NVIDIA A10G 24GB 小〜中規模モデル(〜13B)
ml.g6.xlarge NVIDIA L4 24GB 小〜中規模モデル(〜13B)、コスト効率が良い
ml.g5.12xlarge NVIDIA A10G x4 96GB 大規模モデル(70B〜)
ml.p4d.24xlarge NVIDIA A100 x8 320GB 超大規模モデル

gemma-3-4b-it(約 4B パラメータ)は、ml.g5.xlarge や ml.g6.xlarge で十分動作します。

Service Quotas(クォータ)の事前確認

重要: ml.g6.xlarge などの GPU インスタンスは、デフォルトでは利用制限(クォータ)が 0 に設定されている場合があります。初めて使用する場合は、事前にクォータの引き上げ申請が必要です。

クォータ申請の手順:

  1. AWS マネジメントコンソールで「Service Quotas」を開く
  2. サービス一覧から「Amazon SageMaker」を選択
  3. 以下のクォータを検索し、必要な数を申請:
    • ml.g6.xlarge for endpoint usage - エンドポイント用
    • ml.g6.xlarge for processing job usage - 処理ジョブ用(必要に応じて)
  4. クォータの引き上げをリクエスト」をクリック
  5. 必要なインスタンス数を入力して申請
申請例:
- クォータ名: ml.g6.xlarge for endpoint usage
- 現在の値: 0
- リクエスト値: 1(または必要な数)

申請時の注意点:

  • 申請から承認まで数時間〜数日かかる場合があります
  • 本番環境では余裕を持った数を申請しておくことを推奨します
  • リージョンごとに申請が必要です(例: ap-northeast-1 と us-east-1 は別々に申請)

エンドポイントの作成

クォータの準備ができたら、Python(boto3)を使って SageMaker Endpoint を作成します。

import boto3

# SageMaker クライアントを作成
sagemaker_client = boto3.client('sagemaker')

# ========================================
# 1. SageMaker Model を作成
# ========================================
# 「どのコンテナで、どのモデルを使うか」を定義
sagemaker_client.create_model(
    ModelName='my-llm-model',  # 任意の名前
    PrimaryContainer={
        # LMI v18 コンテナイメージ
        'Image': '763104351884.dkr.ecr.ap-northeast-1.amazonaws.com/djl-inference:0.36.0-lmi18.0.0-cu128',
        # S3 上のモデルファイル
        'ModelDataUrl': 's3://your-bucket-name/models/model.tar.gz',
    },
    # SageMaker がモデルにアクセスするための IAM ロール
    ExecutionRoleArn='arn:aws:iam::123456789012:role/SageMakerExecutionRole'
)
print("Model 作成完了")

# ========================================
# 2. Endpoint Configuration を作成
# ========================================
# 「どのインスタンスタイプで、何台動かすか」を定義
sagemaker_client.create_endpoint_config(
    EndpointConfigName='my-llm-config',  # 任意の名前
    ProductionVariants=[{
        'VariantName': 'AllTraffic',     # バリアント名
        'ModelName': 'my-llm-model',     # 上で作成した Model 名
        'InstanceType': 'ml.g6.xlarge',  # GPU インスタンスタイプ
        'InitialInstanceCount': 1,        # 初期インスタンス数
    }]
)
print("Endpoint Configuration 作成完了")

# ========================================
# 3. Endpoint を作成
# ========================================
# 実際にエンドポイントを起動(数分かかります)
sagemaker_client.create_endpoint(
    EndpointName='my-llm-endpoint',      # 任意の名前
    EndpointConfigName='my-llm-config',  # 上で作成した Config 名
)
print("Endpoint 作成開始(数分かかります)")

# ========================================
# 4. Endpoint の状態を確認
# ========================================
import time

while True:
    response = sagemaker_client.describe_endpoint(
        EndpointName='my-llm-endpoint'
    )
    status = response['EndpointStatus']
    print(f"Endpoint Status: {status}")

    if status == 'InService':
        print("Endpoint が利用可能になりました!")
        break
    elif status == 'Failed':
        print("Endpoint の作成に失敗しました")
        break

    time.sleep(30)  # 30秒待機

クォータ不足時のエラー例

クォータが不足している場合、エンドポイント作成時に以下のようなエラーが発生します:

ResourceLimitExceeded: An error occurred (ResourceLimitExceeded) when calling
the CreateEndpoint operation: The account-level service limit 'ml.g6.xlarge
for endpoint usage' is 0 Instances, with current utilization of 0 Instances
and a request delta of 1 Instances. Please use AWS Service Quotas to request
an increase for this quota.

このエラーが出た場合は、前述の「Service Quotas(クォータ)の事前確認」の手順でクォータを申請してください。

Step 7: 推論を実行する

エンドポイントが作成できたら、推論を実行してみましょう。

import boto3
import json

# SageMaker Runtime クライアント(推論用)
runtime_client = boto3.client('sagemaker-runtime')

# ========================================
# 推論の実行
# ========================================
response = runtime_client.invoke_endpoint(
    EndpointName='my-llm-endpoint',
    ContentType='application/json',
    Body=json.dumps({
        'text': 'こんにちは!今日の天気はどうですか?'
    })
)

# レスポンスを読み取り
result = json.loads(response['Body'].read().decode('utf-8'))

print("入力:", 'こんにちは!今日の天気はどうですか?')
print("出力:", result['generated_text'])

まとめ

SageMaker + vLLM で LLM 推論基盤を構築するポイントをまとめます:

構築のステップ

  1. serving.properties を作成: LMI コンテナの設定
  2. model.py を作成: AsyncLLMEngine を使った推論ハンドラ
  3. model.tar.gz にパッケージング: モデルファイルと設定を1つにまとめる
  4. S3 にアップロード: SageMaker がアクセスできる場所に配置
  5. SageMaker Endpoint を作成: Model → Config → Endpoint の順に作成

重要な設定

設定 推奨値 理由
option.async_mode true LMI v15 以降推奨。並列処理を効率化
option.dtype bfloat16 メモリ効率と精度のバランス
option.enable_streaming_s3_download true コールドスタートを高速化
enable_chunked_prefill True prefill/decode の並列化

AsyncLLMEngine のポイント

  • 非同期処理で複数リクエストを効率的に処理
  • async for で生成結果をイテレート
  • SamplingParams で生成パラメータを制御

参考リンク

AWS 公式ドキュメント

DJL / LMI ドキュメント

vLLM ドキュメント

Hugging Face ドキュメント

採用について

askenではエンジニアを絶賛募集中です。

まずはカジュアルにお話しできればと思いますので、ぜひお気軽にご連絡ください!

https://hrmos.co/pages/asken/jobs

asken techのXアカウントで、askenのテックブログやイベント情報など、エンジニアリングに関する最新情報を発信していますので、ぜひフォローをお願いします!