はじめに
ローカルLLMをQwen Codeから使う環境として、最初はLM Studioを使っていました。GUIでモデルを読み込み、OpenAI互換APIのURLを指定するだけで接続できます。個人利用なら、この構成はかなり扱いやすいです。
一方で、次のような要件が出てくると、推論サーバーとAPIゲートウェイを分けたくなります。
- vLLMの推論設定を細かく変更したい
- APIキーをLiteLLMに集約したい
- 将来、複数モデルや複数バックエンドを扱いたい
- クライアントから推論サーバーを直接見せたくない
- PostgreSQLを使った認証・利用管理へ拡張したい
そこで、LLM用PCのWSL2上でvLLMを動かし、その前段にLiteLLMを置く構成を再検証しました。クライアントPCのQwen Codeから、LAN経由でLiteLLMへ接続するところまで確認します。
ただし、先に結論を書くと、構築はLM Studioよりかなり難しくなります。動けば便利ですが、動くまでに確認する層が多いからです。
構成
今回の構成は次のとおりです。
クライアントPC
└─ Qwen Code
│ http://<LLM用PCのWindows IP>:4000/v1
▼
LiteLLM :4000
│ Docker内部ネットワーク
▼
vLLM :8000
▼
Qwen/Qwen3.5-9B
PostgreSQLはLiteLLMの認証・管理用DBとして同じComposeプロジェクトに配置します。
| 端末・サービス | 役割 |
|---|---|
| LLM用PCのWSL2 | Docker Compose、vLLM、LiteLLM、PostgreSQLを実行 |
| vLLM | モデル推論とOpenAI互換API |
| LiteLLM | APIゲートウェイ、認証、モデル名の抽象化 |
| PostgreSQL | LiteLLMのDB |
| クライアントPC | Qwen Codeからのクライアント接続 |
検証時の主なバージョンは以下です。
- vLLM:
0.24.0 - vLLMイメージ:
vllm/vllm-openai:v0.24.0 - モデル:
Qwen/Qwen3.5-9B - 最大Context Length:
49152 - 最大同時系列数:
64 - LiteLLM:
docker.litellm.ai/berriai/litellm:main-latest - GPU: RTX 5090
この記事のコマンドは、秘密情報を直接書かない形にしています。APIキーやDBパスワードは環境変数で渡し、ログ・Git・チャットへ貼り付けないようにします。
先に結論:いきなりCompose化しない
最初からvLLM、LiteLLM、PostgreSQLを同時に起動すると、問題が起きたときに原因が分かりません。
今回は次の順で進めました。
- Docker DesktopとWSL2からGPUが見えることを確認
- ネイティブ環境でvLLM単体を起動
- vLLMのAPIを確認
- vLLMをDocker Composeへ移行
- Compose内のvLLM APIを確認
- LiteLLMとPostgreSQLを追加
- LiteLLM経由のAPIを確認
- LAN上の別PCから接続
- Qwen Codeから接続
この順番にしたことで、vLLM本体の問題と、Docker・LiteLLM・ネットワーク・Qwen Codeの問題を分離できました。
1. Docker DesktopとGPUを確認する
LLM用PCのWSLで確認します。
cat /etc/os-release
docker --version
docker compose version
docker context show
docker info | grep -E 'Server Version|Operating System|Security Options'
nvidia-smi
Docker DesktopのWSL2バックエンドを使う場合、WSL内にnvidia-ctkがインストールされていなくても、コンテナからGPUが見えれば問題ありません。
念のため、CUDAサンプルも実行しました。
docker run --rm --gpus=all \
nvcr.io/nvidia/k8s/cuda-sample:nbody \
nbody -gpu -benchmark
ここでGPU名とベンチマーク結果が表示されない状態で、vLLMの切り分けを始めないほうが安全です。
2. モデルキャッシュを確認する
今回はネットワークから毎回モデルを取得するのではなく、ホスト側にあるHugging Faceのsnapshotをコンテナから参照しました。
find "$HOME/.cache/huggingface/hub/models--Qwen--Qwen3.5-9B" \
-maxdepth 3 -name config.json -print
snapshots/<revision>/config.jsonが見つかることを確認します。
Docker起動時に次のようなエラーが出た場合、モデルそのものが壊れているとは限りません。
Temporary failure in name resolution
ローカルキャッシュを使う構成なのに、コンテナがHugging Faceへ接続しようとしている可能性があります。snapshotのマウント先と、オフライン起動用の環境変数を確認します。
3. vLLMをネイティブ環境で起動する
まずuvで専用の仮想環境を作成します。
uv venv /home/<user>/.venv-vllm-cu128 --python 3.12
source /home/<user>/.venv-vllm-cu128/bin/activate
検証時はvLLM 0.24.0を使用しました。CUDAバックエンドやwheelの組み合わせは、使用するGPUとvLLMの公式配布状況に合わせて固定します。
export UV_HTTP_TIMEOUT=600
uv pip install \
vllm==0.24.0 \
--torch-backend=cu129 \
--extra-index-url https://wheels.vllm.ai/0.24.0/cu129/ \
--extra-index-url https://download.pytorch.org/whl/cu129 \
--index-strategy unsafe-best-match
インストール後にバージョンとGPU認識を確認します。
vllm --version
python -c 'import torch, vllm; print(torch.__version__); print(torch.version.cuda); print(torch.cuda.is_available()); print(vllm.__version__)'
vLLMはLANへ直接公開せず、まず127.0.0.1:8000だけで起動します。
unset CUDA_HOME
unset VLLM_VERSION
export VLLM_USE_FLASHINFER_SAMPLER=0
vllm serve Qwen/Qwen3.5-9B \
--host 127.0.0.1 \
--port 8000 \
--tensor-parallel-size 1 \
--max-model-len 49152 \
--max-num-seqs 64 \
--reasoning-parser qwen3 \
--enable-auto-tool-choice \
--tool-call-parser qwen3_coder \
--api-key "$VLLM_API_KEY" \
> "$HOME/tmpvllm-serve.log" 2>&1 &
echo $! > "$HOME/vllm-native.pid"
起動ログと待受状態を確認します。
tail -n 100 "$HOME/tmpvllm-serve.log"
ss -lntp | grep ':8000'
APIキーを環境変数から渡して、モデル一覧を確認します。
curl -i http://127.0.0.1:8000/v1/models \
-H "Authorization: Bearer $VLLM_API_KEY"
4. 思考出力によるfinish_reason=length
初回の通常応答試験では、応答本文が空になりました。HTTPエラーではありません。
原因は、Qwen3.5の思考出力を含む要求に対して、max_tokens=32では生成上限が足りなかったことでした。最終回答へ到達する前に上限へ達し、finish_reason=lengthになっていました。
検証スクリプトでは、思考出力を抑制して再試験しました。
{
"chat_template_kwargs": {
"enable_thinking": false
},
"max_tokens": 64
}
その結果、最終回答あり、finish_reason=stopを確認できました。
この経験から、HTTPステータスだけでなく、次の値も確認するようにしました。
finish_reason- 最終回答の有無
- ストリーミングの
[DONE] - Tool callの関数名と引数
5. API検証をスクリプト化する
長いPythonコードをターミナルへ貼り付けると、引用符や改行の崩れで別の問題が起きます。そこで、リポジトリの検証スクリプトを使いました。
VLLM_BASE_URL=http://127.0.0.1:8000/v1 \
VLLM_MODEL=Qwen/Qwen3.5-9B \
VLLM_API_KEY="$VLLM_API_KEY" \
python3 vllm_compose_api_check.py
期待する確認項目は次の4つです。
models: HTTP 200
chat: HTTP 200, finish_reason=stop, content_present=True
stream: HTTP 200, chunks=True, done=True, finish_reason=stop
tool: HTTP 200, tool_calls=True, function=get_weather, finish_reason=stop
vLLM単体でここまで通ったら、ネイティブvLLMを停止します。
kill "$(cat "$HOME/vllm-native.pid")" 2>/dev/null || true
rm -f "$HOME/vllm-native.pid"
ss -lntp | grep ':8000' || true
6. Docker Composeへ移行する
Composeでは、vLLMにホスト側のportsを設定しません。外部からアクセスする入口はLiteLLMだけにします。
概念的には次のようなネットワーク構成です。
services:
vllm:
networks:
- llm-internal
litellm:
ports:
- "0.0.0.0:4000:4000"
networks:
- llm-internal
- llm-public
postgres:
networks:
- llm-internal
まず設定ファイルを検証し、vLLMだけを起動します。
docker compose config --quiet
docker pull vllm/vllm-openai:v0.24.0
docker compose up -d vllm
docker compose ps -a
docker compose logs --tail=100 vllm
Compose内のvLLMに検証スクリプトをコピーして実行します。
docker compose cp vllm_compose_api_check.py vllm:/tmp/vllm_compose_api_check.py
docker compose exec -T vllm python3 /tmp/vllm_compose_api_check.py
7. 苦戦した点その1:KV CacheのGPUメモリ不足
ComposeでvLLMを起動したとき、次のエラーが発生しました。
ValueError: No available memory for the cache blocks
モデルが壊れたのかと思いましたが、原因はKV Cache用のGPUメモリ不足でした。ネイティブvLLMを停止したつもりでも、プロセスやDocker Desktop/WSL側にGPUメモリが残っていると、ComposeのvLLMが起動できません。
確認したことは次のとおりです。
nvidia-smi
docker compose logs --tail=100 vllm
通常のdocker compose downでは解放されない状態が残った場合、Windows側でWSLを停止してから再起動しました。
wsl --shutdown
それでも起動しない場合は、max-num-seqsを8または16へ下げ、max-model-lenを32768へ下げて起動を確認します。
なお、通常の停止でdocker compose down -vを使うのは避けます。名前付きボリュームまで削除され、LiteLLMのDBやモデルキャッシュの再構築が必要になるためです。
8. LiteLLMとPostgreSQLを追加する
vLLM単体のAPI確認後に、PostgreSQLとLiteLLMを起動します。
docker compose up -d postgres litellm
docker compose config --services
docker compose ps -a
docker compose logs --tail=100 postgres litellm
LiteLLMの設定では、次の関係になります。
| 項目 | 設定 |
|---|---|
| 外部から見えるモデル名 | qwen-vllm |
| LiteLLMからvLLMへのURL | http://vllm:8000/v1 |
| vLLMの認証キー | VLLM_API_KEY |
| LiteLLMの認証キー | LITELLM_MASTER_KEY |
| DB | DATABASE_URL |
ここで、vLLM用のキーとLiteLLM用のキーを混同しないことが重要です。クライアントPCへ渡すのはLITELLM_MASTER_KEYです。
9. 苦戦した点その2:LiteLLMのポートが公開されない
LiteLLMにportsを設定したのに、ホスト側から接続できませんでした。
docker port vllm-litellm-compose-litellm-1 4000
docker compose ps
4000/tcpしか表示されず、0.0.0.0:4000になっていませんでした。
原因は、LiteLLMをinternal: trueのネットワークだけへ接続していたことでした。Docker Desktop環境では、内部ネットワークだけに接続したコンテナのホストポート公開が期待どおりにならない場合があります。
LiteLLMを内部ネットワークと公開用ネットワークの両方へ接続し、コンテナを再作成しました。
docker compose up -d --force-recreate litellm
docker port vllm-litellm-compose-litellm-1 4000
最終的に0.0.0.0:4000を確認できました。
10. LiteLLM経由のAPIを確認する
モデル一覧を確認します。
curl -sS -w '\nHTTP %{http_code}\n' \
http://127.0.0.1:4000/v1/models \
-H "Authorization: Bearer $LITELLM_MASTER_KEY"
レスポンスにqwen-vllmが含まれ、HTTP 200になれば、LiteLLMからvLLMへのモデル接続は確認できています。
同じ検証スクリプトをLiteLLM向けに実行します。
VLLM_BASE_URL=http://127.0.0.1:4000/v1 \
VLLM_MODEL=qwen-vllm \
VLLM_API_KEY="$LITELLM_MASTER_KEY" \
python3 vllm_compose_api_check.py
通常応答、ストリーミング、Tool callのすべてでHTTP 200を確認しました。Tool callでfinish_reason=tool_callsになるのは正常です。
11. 苦戦した点その3:DB未設定では認証テストが成立しない
最初はPostgreSQLを用意せずにLiteLLMを起動していました。その状態で誤ったAPIキーを送ると、期待していたHTTP 401ではなく、次のようなHTTP 400が返りました。
no_db_connection
これは、APIキーが正しいかどうかを判定する前に、LiteLLMのDB接続に失敗していたためでした。
PostgreSQLをComposeへ追加し、LiteLLMのマイグレーション完了後に再試験しました。
curl -sS -w '\nHTTP %{http_code}\n' \
http://127.0.0.1:4000/v1/models \
-H 'Authorization: Bearer invalid-test-token'
今度はHTTP 401 Unauthorizedとなり、認証拒否を確認できました。
エラーコードだけを見ると、認証の問題に見えてしまいます。LiteLLMでは、DBのhealth、DATABASE_URL、起動ログも一緒に確認する必要があります。
12. LAN上の別PCから接続する
WSL内の127.0.0.1は、クライアントPCからは使えません。クライアントからは、LLM用PCのWindows側LAN IPv4アドレスを指定します。
LLM用PCのWindows PowerShellでIPアドレスを確認します。
ipconfig
クライアントPCからポートを確認します。
Test-NetConnection <LLM用PCのIP> -Port 4000
次の表示になれば、ネットワークとポート公開は確認できています。
TcpTestSucceeded : True
PowerShellでは、curlが別名として解釈されることがあるため、HTTP確認ではcurl.exeを使います。
curl.exe -sS -w "\nHTTP %{http_code}\n" `
http://<LLM用PCのIP>:4000/v1/models `
-H "Authorization: Bearer <LITELLM_MASTER_KEY>"
13. Qwen CodeからLiteLLMへ接続する
検証時のQwen Codeは0.21.6でした。このバージョンでは、次のCLIオプションは使用しませんでした。
--auth-type--openai-api-key--openai-base-url
これらを指定してInvalid Valuesになる場合、オプションの使い方ではなく、インストール済みバージョンが対応していない可能性があります。
Qwen Codeを起動します。
qwen
Qwen Code内で/authを実行し、以下を設定します。
Custom Provider- プロトコル:
OpenAI - Base URL:
http://<LLM用PCのIP>:4000/v1 - API key: LiteLLMの
LITELLM_MASTER_KEY - Model:
qwen-vllm
その後、/modelでqwen-vllmを選択し、/doctorまたは短い質問で応答を確認します。
LM Studioの古いprovider設定が残っている場合、Qwen CodeがLM Studioへ接続し続けることがあります。その場合は、/authでOpenAI providerを選び直し、/modelでモデルを選択し直します。
14. 苦戦した点その4:ContextWindowExceededError
Qwen Codeから接続できた後、長い入力でContextWindowExceededErrorが発生しました。
vLLM全体の最大Context Lengthを49152にしていても、クライアントが出力用に32768トークンを予約すると、入力に使える領域は約16384トークンしか残りません。
Qwen Code側の設定では、generationConfigをトップレベルへ置くのではなく、modelProviders.openai[]内のid: qwen-vllmへ配置しました。
{
"modelProviders": {
"openai": [
{
"id": "qwen-vllm",
"baseUrl": "http://<LLM用PCのIP>:4000/v1",
"envKey": "OPENAI_API_KEY",
"generationConfig": {
"contextWindowSize": 49152,
"samplingParams": {
"max_tokens": 8192
}
}
}
]
}
}
設定ファイルの場所は、次のいずれかです。
%USERPROFILE%\\.qwen\\settings.json- プロジェクト内の
.qwen\\settings.json
既存ファイルを全体上書きせず、既存のqwen-vllmエントリへ追加します。保存後、Qwen Codeを完全に終了して再起動します。
LiteLLMのログにrequested 32768 output tokensが残る場合、設定が反映されていません。max_tokensを4096へ下げると入力領域は増えますが、長いコード生成では出力が途中で切れる可能性があります。
検証結果
最終的に、次の項目を確認できました。
| 試験 | 結果 |
|---|---|
| Docker DesktopからGPU認識 | 合格 |
| vLLM単体のモデルAPI | HTTP 200 |
| vLLM単体の通常応答 | HTTP 200、finish_reason=stop
|
| vLLM単体のストリーミング | チャンク受信、[DONE]まで完了 |
| vLLM単体のTool call |
tool_callsを確認 |
| Compose上のvLLM | 起動・API確認ともに合格 |
| LiteLLMのモデルAPI |
qwen-vllmをHTTP 200で確認 |
| LiteLLM経由の通常応答 | 合格 |
| LiteLLM経由のストリーミング | 合格 |
| LiteLLM経由のTool call | 合格 |
| 不正LiteLLMキー | HTTP 401 |
| クライアントPCからの接続 | 合格 |
| Qwen Codeからの接続 | 合格 |
LM Studioと比べてどうだったか
機能面では、LM StudioとvLLM + LiteLLMの両方で、通常応答、ストリーミング、Tool call、Qwen Code接続を確認できました。
ただし、同じ条件で性能を測ったわけではありません。量子化方式、Context Length、出力上限、思考モード、同時実行設定が揃っていないため、速度やGPUメモリ使用量の優劣は今回の検証からは断定していません。
個人利用の現在の構成は、次の理由でLM Studioを採用しました。
- 導入が簡単
- GUIでモデルを切り替えやすい
- 障害の切り分けが容易
- ロールバックしやすい
一方、vLLM + LiteLLMは、将来の共有基盤候補として残します。
- APIキーをLiteLLMへ集約できる
- 複数モデル・複数バックエンドへ拡張しやすい
- vLLMのContext Lengthやparserを細かく制御できる
- 利用者管理や監査へ発展させやすい
便利さと引き換えに、WSL、Docker、GPU、ネットワーク、DB、認証をまとめて運用する必要があります。今回一番時間がかかったのは、モデルを動かすことよりも、その周辺の状態を一つずつ揃えることでした。
まとめ
vLLM + LiteLLMを使うと、vLLMをLANへ直接公開せず、LiteLLMを唯一のAPI入口にできます。認証や複数バックエンドを考えるなら、LM Studioより拡張しやすい構成です。
ただし、切り分けの順番を間違えると、GPUメモリ不足、モデルキャッシュ、Dockerネットワーク、DB未設定、クライアントの出力予約が一つの問題に見えてしまいます。
今回のポイントは、次の4つです。
- vLLM単体を先に合格させる
- APIはHTTPステータスだけでなく生成終了理由まで確認する
- vLLMをLANへ公開せず、LiteLLMだけを公開する
- Qwen Codeのバージョンに合わせて
/authとprovider設定を使う
個人利用ならLM Studioのままでも十分です。構成管理や認証を重視する段階になったら、今回のようにvLLM + LiteLLMへ段階的に移行するのが現実的だと思います。