GPU環境でvLLMを使ってローカルLLMサーバーを動かしてみた

## 1. はじめに

これまでローカルでLLMを動かす際には、主に `transformers` を使っていました。

しかし、以下のような課題を感じることが多くありました。

- コードが煩雑になりやすい
- 外部連携（API化など）がやや手間

そこで今回は、以下の条件を満たす vLLM を使って、ローカル環境にLLMサーバーを構築してみました。

- GPU環境で動作する
- APIサーバーとして動かせる（OpenAI互換）
- 推論速度が高速である

**この記事で解説している内容**

- **Dockerを使用したサーバー用イメージの構築**
- **curlを使用したAPIの呼び出し**
- **[OpenAIクライアント](https://github.com/openai/openai-python)を使用したAPIの呼び出し**
- **[LangChain](https://www.langchain.com/)を使用したAPIの呼び出し**

## 2. ハードウエアおよび使用モデル

### ハードウエア

以下のような構成です。

- CPU: Intel Core i9-14900KF

- メモリ: 64 GB

- GPU: NVIDIA GeForce RTX 4090

- OS: Windows(docker desktop使用)

### 使用モデル

`Qwen3-8B-FP8` を使用しました。

thinking系で比較的最新のモデルで実験したかったので採用です。

## 3. vLLMとは

[vLLM](https://docs.vllm.ai/en/latest/) は、以下のような特徴を持つLLM推論ライブラリです。

- 高速なLLM推論が可能  
- モデルのサービングが簡単に行える（OpenAI互換APIあり）  
- Hugging Faceのモデルに対応  

今回は、特に **モデルのサービング（API化）** に注目して、ローカル環境での構築方法を紹介します。

## 4. Dockerfile作成: vLLMサーバー と Jupyter Notebook環境

環境構築を簡単に再現できるよう、Docker を用いて構成しました。


今回は以下の2つのDockerfileを作成し、後述の `compose.yml` で統合しています。

- vLLM サーバー用
- Jupyter Notebook 環境用

### vLLM サーバー用 Dockerfile

```Dockerfile
FROM nvidia/cuda:12.8.1-cudnn-devel-ubuntu22.04

# pythonのインストールを実施
RUN apt-get update && \
    apt-get install -y python3 python3-pip python3-venv && \
    pip3 install --upgrade pip

# pipでvllmとflashinfer-pythonをインストールし、モデルを事前にダウンロードしておく
RUN pip install vllm==0.8.5 --extra-index-url https://download.pytorch.org/whl/cu128 && \
    pip install flashinfer-python -i https://flashinfer.ai/whl/cu126/torch2.6/ && \
    huggingface-cli download --local-dir=/models/Qwen3-8B-FP8 Qwen/Qwen3-8B-FP8
```

上記のようなDockerfileを作成しました。

実施していることは、下記です。

- vLLMのインストール
- モデルの事前ダウンロード（コンテナ起動時の待ち時間を削減）

### Jupyter Notebook 環境用 Dockerfile

```Dockerfile
# ライブラリインストールステージ
FROM python:3.13.3-slim AS builder

WORKDIR /app

COPY ./requirements/requirements.notebook ./

RUN pip install --upgrade pip && \
    pip install --prefix=/install -r requirements.notebook

# notebook環境用ステージ
FROM python:3.13.3-slim AS app

RUN apt-get update && \
    apt-get install -y curl

RUN groupadd -r appuser && \
    useradd -r -g appuser -m -d /home/appuser appuser

COPY --from=builder /install /usr/local

WORKDIR /app

RUN chown -R appuser:appuser /app /home/appuser

USER appuser

CMD ["jupyter-lab", "--ip=0.0.0.0", "--port=8888", "--no-browser", "--NotebookApp.token=''"]
```

こちらは、実験やプロトタイピングを行うための Notebook 環境です。あえて Notebook にした理由は以下の通りです。

- 動作検証や API 連携の試行がしやすい

- Jupyter 上で直接 API を叩くなど柔軟な操作ができる

### docker composeファイル

```yml
services:
  vllm:
    build:
      context: ./docker/
      dockerfile: Dockerfile.vllm
    command: "vllm serve /models/Qwen3-8B-FP8 --host 0.0.0.0"
    deploy:
        resources:
          reservations:
            devices:
              - driver: nvidia
                count: 1
                capabilities: [gpu]
  notebook:
    build:
      context: ./docker
      dockerfile: Dockerfile.notebook
    ports:
      - "8888:8888"
    volumes:
      - ./:/app
```

上記の compose.yml では、以下の2つのサービスを定義しています：

- vLLM サーバー：指定したモデルを使用し、OpenAI 互換の API を提供

- Jupyter Notebook：同一ネットワーク内で、サーバーへのアクセスや動作確認が可能な実験環境

なお、GPU は deploy.resources によって自動で割り当てられるように設定しています。


## 5. 動作確認

### vLLMサーバーおよびJupyter Notebookサーバーの起動

まず、ターミナルにて `docker compose up` を実行します。

buildが完了して、しばらくすると

```
vllm-1      | INFO:     Started server process [1]
vllm-1      | INFO:     Waiting for application startup.
vllm-1      | INFO:     Application startup complete.
```

のようなログが表示されるとvLLMサーバーの準備は完了です(ビルド込みで20 ~ 30分程度かかるはずです。)。

### Jupyter Notebook経由でテスト用のnotebookを開く

`http://localhost:8888/`にアクセスし、`./example/open_ai_example.ipynb` を開きます。

### curlを使用してvLLMサーバーのAPIを呼び出す

`curlを使用してみる` のセル

```bash
%%bash
curl http://vllm:8000/v1/chat/completions -H "Content-Type: application/json" -d '{
  "model": "/models/Qwen3-8B-FP8",
  "messages": [
    {"role": "user", "content": "貴方の名前は？"}
  ],
  "temperature": 0.7,
  "top_p": 0.8,
  "top_k": 20,
  "max_tokens": 8192,
  "presence_penalty": 1.5,
  "chat_template_kwargs": {"enable_thinking": true}
}'
```

を実行すると、下記のようなレスポンスが表示されます。

※ Notebook から curl を呼ぶには %%bash マジックを使います。

```json
{
	"id": "chatcmpl-285e1156930d4404afcb916e7c821d16",
	"object": "chat.completion",
	"created": 1748264473,
	"model": "/models/Qwen3-8B-FP8",
	"choices": [
		{
			"index": 0,
			"message": {
				"role": "assistant",
				"reasoning_content": null,
				"content": "<think>\nOkay, the user asked, \"貴方の名前は？\" which means \"What's your name?\" in Japanese. I need to respond in Japanese since the question is in Japanese.\n\nFirst, I should confirm that my name is Qwen. But I need to translate that into Japanese. The direct translation of Qwen is \"クウェン\" (Kuwen). However, sometimes people might refer to me as \"通義千問\" (Tongyi Qianwen), but since the user asked for my name, it's better to stick with the official name.\n\nWait, the user might be expecting a more natural Japanese response. Maybe I should say something like \"私はQwenです。\" which means \"I am Qwen.\" That's straightforward and clear. Alternatively, using the Japanese katakana for Qwen would be appropriate here. \n\nI should also consider if there's any cultural nuance I'm missing. In Japanese, when introducing oneself, it's common to use the name in katakana. So \"クウェン\" is correct. But maybe the user is looking for the Chinese name as well? However, since the question is in Japanese, the answer should be in Japanese. \n\nAnother point: sometimes models have different names in different languages. For example, in English, it's Qwen, but in Japanese, it might be referred to as something else. But according to the official information, the name remains Qwen across all languages. So I should just state my name as Qwen in Japanese. \n\nAlso, I need to make sure the response is polite and friendly. Starting with \"こん���ちは\" (Hello) might be good, but the user specifically asked for my name, so maybe just stating the name is sufficient. \n\nWait, the user might not need a greeting. They just want to know my name. So the best approach is to directly answer with \"私はQwenです。\" or \"私の名前はQwen���す。\" Both are correct. Which one is more natural? \"私の名前はQwenです。\" is more formal, while \"私はQwenです。\" is more casual. Since the user didn't specify the tone, either should work. \n\nI think using \"私の名前はQwenです。\" is better because it directly answers the question about the name. It's clear and to the point. \n\nSo the final response should be: \"私の名前はQwenです。\" which translates to \"My name is Qwen.\"\n</think>\n\n私の名前はQwenです。",
				"tool_calls": []
			},
			"logprobs": null,
			"finish_reason": "stop",
			"stop_reason": null
		}
	],
	"usage": {
		"prompt_tokens": 15,
		"total_tokens": 544,
		"completion_tokens": 529,
		"prompt_tokens_details": null
	},
	"prompt_logprobs": null
}
```

長いですが、`choices[0].message.content`にモデルの応答が格納されています。

応答の形式は、

`<think>{ここに思考の過程が記載}</think>{実際の解答}`

のようになります。

思考過程を省略すると「私の名前はQwenです。」と回答しています。

モデルには「貴方の名前は？」と尋ねたので正しい回答が返ってきていることがわかります。

### [OpenAIクライアント](https://github.com/openai/openai-python)を使用したAPIの呼び出し

次に、OpenAI公式クライアントを使ってAPIにアクセスしてみます。

`OpenAIクライアントを使用してみる` 配下のセル

```python
open_ai_request = OpenAIRequest()
result = open_ai_request('英語と日本語どちらが難しい？')
for res in result:
    if res['type'] == 'think':
        continue
    else:
        print(res['content'])
```

を実行します。

なお、`OpenAIRequest` は、下記のような定義です。

```python
def extract_think_and_others(text: str):
    pattern = re.compile(r"<think>(.*?)</think>", re.DOTALL)
    results = []
    last_end = 0

    for match in pattern.finditer(text):
        # think前のnon_think部分
        non_think = text[last_end:match.start()]
        if non_think.strip():
            results.append({
                "type": "non_think",
                "content": non_think.strip()
            })

        # think部分
        think = match.group(1)
        results.append({
            "type": "think",
            "content": think.strip()
        })

        last_end = match.end()

    # 最後の残り部分
    if last_end < len(text):
        tail = text[last_end:]
        if tail.strip():
            results.append({
                "type": "non_think",
                "content": tail.strip()
            })
    return results


class OpenAIRequest:
    def __init__(
        self,
        base_url: str=BASE_URL,
        chat_args={
            'max_tokens': 8192,
            'temperature': 0.7,
            'top_p': 0.8,
            'presence_penalty': 1.5,
            'extra_body': {
                'top_k': 20,
                'chat_template_kwargs': {'enable_thinking': True}
            },
            'model': MODEL_NAME
        }
    ):
        self.client = OpenAI(
            api_key='EMPTY',
            base_url=base_url
        )
        self.chat_args = chat_args

    def __call__(
        self,
        prompt: str,
    ):
        chat_response = self.client.chat.completions.create(
            messages=[{'role': 'user', 'content': prompt}],
            **self.chat_args
        )
        text = chat_response.choices[0].message.content
        return extract_think_and_others(text)
```

こちらを実行すると、下記のような結果が得られました。

なかなかそこそこの結果だと思います。

```md
英語と日本語の難しさは、学習者の母語や目的、個人の得意分野によって大きく異なります。以下に両言語の主な特徴と比較をまとめました：

---

### **1. 英語の難しい点**
- **文法・構文**  
  - 非常に複雑で、文型（SVOなど）や時制（現在形・過去形・未来形）、仮定法などのルールが多岐にわたります。  
  - 動詞の活用（例：「to be」の変化、「have」の過去形・過去分詞）や名詞の可算不可算の区別が初学者にはわかりにくいです。  
  - 複数形（-sの付加）、所有格（'s）、冠詞（the/a/an）の使用も難しいポイントです。

- **発音・アクセント**  
  - 発音が不規則（例：th音、r音の発音、アイスクリームの「i」の発音）。  
  - アクセントやリズムが直訳では理解しにくい（例：「I'm going to the store」の重音位置）。

- **語彙・表現**  
  - 多くの同義語や慣用句があり、意味のニュアンスを正確に伝えるのが難しい（例：「break a leg」＝「頑張れ」）。  
  - 現代英語には多くの外来語や新語が含まれるため、学習コストが高い。

- **文化・文脈**  
  - ユーモアやイディオムの理解には文化的背景が必要です。  
  - 会話での非言語的要素（トーン、ジェスチャー）が重要です。

---

### **2. 日本語の難しい点**
- **文字体系**  
  - 漢字（約2,000字以上）の読み書きが必須で、読解力に直結します。  
  - 平仮名・片仮名との組み合わせが複雑（例：「食べる」→「たべる」「たべる」）。  
  - 漢字の表記法（当用漢字・常用漢字）や読み方（音読み・訓読み）の違いがあります。

- **文法・構文**  
  - 文の順序が逆転する傾向（例：「私は本を読む」→ 英語の「I read a book」）。  
  - 助詞（が・は・を・になど）の使い分けが難しい。  
  - 動詞の活用（五段・一段・カ行変格など）や敬語（丁寧語・普通語・謙譲語）の区別が複雑。

- **発音・アクセント**  
  - カタカナの発音が一貫していない（例：「ポスター」→ 「ポスタ」）。  
  - ピッチアクセント（音高の変化）が外国人にとって難しいです。

- **文化・文脈**  
  - 礼儀作法や上下関係に基づく敬語の使用が必須です。  
  - 非言語的なコミュニケーション（顔の表情、距離感）が重要です。

---

### **3. 難易度比較のポイント**
- **母語の影響**  
  - 英語圏出身者（例：アメリカ人）は日本語の文法や敬語に苦労する可能性があります。  
  - 日本語を母語とする人は英語の発音や単語量に驚くかもしれません。

- **学習目的**  
  - 会話中心の学習なら、英語の日常表現は多くある一方、日本語の敬語や文法はより厳密です。  
  - 書き言葉（文学・論文）を重視するなら、日本語の文法や漢字は大きな障壁になります。

- **資源と環境**  
  - 英語はグローバルに普及しており、教材やオンラインリソースが豊富ですが、日本語は地域性に依存します。  
  - 日本語学習者は、日本語圏での浸透度が低い場合、実践機会が限られることがあります。

---

### **4. どちらが難しい？**
- **英語が難しいケース**  
  - 語彙量の多さや発音の不規則さ、文化的な表現の奥行きが苦手な人。  
  - 母語が文法的に異なる言語（例：中国語、スペイン語）を話す人。

- **日本語が難しいケース**  
  - 漢字の読み書きや敬語の使い分けが苦手な人。  
  - 母語が音声系の言語（例：フランス語、スペイン語）を話す人。

---

### **5. 結論**
- **英語と日本語は「どちらも難しい」と言えます**。  
- 難しさは学習者の背景や目標によります。例えば：  
  - **ビジネス・国際交流**を目指すなら、英語の実用性が優先されます。  
  - **文化・文学の理解**を目指すなら、日本語の深みに魅力を感じるかもしれません。  
- 最も重要なのは「自分の興味と目的に合った言語を選ぶこと」です。  

どちらも美しい言語であり、学ぶことで新たな視点を得られます！ 😊
```

### [LangChain](https://www.langchain.com/)を使用したAPIの呼び出し

最後に、LangChain経由でAPIの呼び出しを実施してみます。

`langchainを使用してみる` 配下のセル

```python
messages = [
    HumanMessage(
        content='英語と日本語どちらが難しい？'
    )
]
llm = ChatOpenAI(
    openai_api_key='EMPTY',
    openai_api_base='http://vllm:8000/v1',
    model_name='/models/Qwen3-8B-FP8',
)
chain = (
    llm
    | StrOutputParser()
)
original_result = chain.invoke(
    messages,
    max_tokens=8192,
    top_p=0.8,
    temperature=0.7,
    presence_penalty=1.5,
    extra_body={
        'top_k': 20,
        'chat_template_kwargs': {'enable_thinking': False},
    }
)
result = extract_think_and_others(original_result)
for res in result:
    if res['type'] == 'think':
        continue
    else:
        print(res['content'])
```

を実行します。

なお、今回はthinkingモードをオフにしています。

実行結果は下記です。

```md
英語と日本語のどちらが難しいかは、**個人の母語や学習経験、目的、そして習得している言語の特徴**によって大きく異なります。以下にそれぞれの難しさを比較する視点を挙げて説明します。

---

## 🌍 **1. 母語との関係**
- **英語を母語とする人（英語話者）にとって：**
  - 日本語は**文法構造や語順が異なる**ため、難しい。
  - 音声（発音）も英語とは異なる（例：「は」や「へ」など）。
  - 語彙も文化的なニュアンスが違う。

- **日本語を母語とする人（日本人）にとって：**
  - 英語は**文法が単純で直訳的**だが、音声やアクセントが複雑。
  - 動詞の活用や時制が英語より複雑。
  - ライティングやスピーキングでは**文化・表現の違い**が障壁になることがある。

---

## 🧠 **2. 文法の難易度**
- **英語の文法：**
  - 動詞の活用（to be, to have, etc.）や時制（過去形、現在進行形など）が複雑。
  - 名詞の可算/不可算、冠詞（a/an/the）の使用が重要。
  - 形容詞や副詞の位置が文の構造に影響。

- **日本語の文法：**
  - 動詞の活用（五段活用、一段活用など）が複雑。
  - 副詞や助詞の使い方が重要。
  - 結論的に「文法が複雑」と感じる人が多い。

---

## 🎵 **3. 音声・発音**
- **英語：**
  - アクセントやイントネーションが重要。
  - カタカナ表記で表される音（例：'th', 'r'）が日本人には難しい。
  - 言語としての音声体系が異なる。

- **日本語：**
  - 母語者が英語を話す際、発音が「外国語っぽい」と感じられやすい。
  - ライティングでは漢字や仮名の読み間違いが起こりやすい。

---

## 📚 **4. 語彙と文化**
- **英語：**
  - 新しい単語や専門用語が多い。
  - 文化的背景が深く、ニュアンスが読解に影響。

- **日本語：**
  - 漢字の読み書きが難しい。
  - 礼儀作法や暗黙の了解がコミュニケーションに影響。

---

## 🧩 **5. 学習目的**
- **英語を学ぶ目的：**
  - 国際交流、ビジネス、留学など。
  - 多くの国で公用語なので、実用性が高い。

- **日本語を学ぶ目的：**
  - 日本への移住、観光、文化理解など。
  - 日本語圏は限られているため、学習機会が少ない場合がある。

---

## ✅ **結論：どちらが難しい？**
| 視点 | 英語 | 日本語 |
|------|------|--------|
| 母語との関係 | 通常は簡単 | 通常は簡単 |
| 文法 | 一定の複雑さ | 一定の複雑さ |
| 音声 | 難しい（特に日本人） | 難しい（特に英語話者） |
| 語彙・文化 | 難しい | 難しい |
| 学習機会 | 幅広い | 狭い |

### ⇒ **一般的には、「英語は日本人にとっては難しい」「日本語は英語話者にとっては難しい」と言われています。**

---

## 📌 最後に
どちらが難しいかは、**あなたがどの言語を母語としているのか、何を目指しているのか**によります。  
もし英語と日本語のどちらを学びたいか、またはどちらを習得したいと思っているなら、具体的な目標を教えていただければ、さらに詳しくアドバイスできますよ！
```

こちらもなかなかよさそうな出力です。

### まとめ

- `vLLM` を使ってローカルでLLM サーバーを構築
- `curl`, `OpenAIクライアント`, `LangChain` の 3 方式で API を呼び出し、動作確認
- `thinking` モードの有効／無効をパラメータで切り替え、応答の違いを検証

今後は、これを他のアプリケーションやRAG構成に組み込むことで、さらに実践的に活用できそうです。

## 6. はまったこと

今回は、以下のことに躓きました。

- 単純にvLLMで公式モデルをserveすると、dockerコンテナを起動するたびにモデルのダウンロードが走るので重い

    イメージ作成時にモデルをダウンロードすることで解決

- Qwen3が思いのほかメモリを逼迫する

    全てGPUで推論しようとすると8Bのモデルを量子化したモデルでないとメモリに乗り切りませんでした。

    コンテキスト長の問題かと思われますが、調査が必要だと感じています。

## 7. 今後の展開

今回の検証では、**vLLM を用いて OpenAI 互換のローカルサーバーを構築し、LangChain 経由での呼び出しに成功**しました。

この仕組みを応用することで、今後は以下のような展開が期待できます

- **ローカル RAG システムの構築**  
  → 軽量な埋め込みモデル + Faiss を組み合わせたドキュメントQAシステムなど

- **LLMエージェントの設計と実験**  
  → LangChainのツール連携機能を活用し、タスク自動化の基礎へ

- **チャットシステムの構築**  
  → Web UIやBot連携を通じて、対話型UIの実装を目指す

---

ローカルで高速・柔軟な LLM 環境を構築できることで、プロトタイピングや研究開発の自由度が大きく広がります。

最後まで読んでいただき、ありがとうございました！

## 8. ソースコード

今回の検証コードは下記です。

https://github.com/ryota0051/vllm-example