はじめに
ご無沙汰しております。生成AIを「社内や現場のローカルで使いたい」そんな思いからスタートした、このローカル生成AI・SLM検証シリーズ。
第一回では、OllamaとOpen WebUIを使って、閉じた環境で動作する生成AI(ローカルSLM)を構築し、その手順や構成イメージを紹介しました。
「クラウドに出せない」「ネット接続できない」――そんな制約下でも生成AIを活用できることに、興味を持っていただいた方も多かったようです。もしまだ第一回の記事をご覧になっていない方は、こちらの「ローカルSLM/閉じた環境で動作する生成AIを検証してみた (①環境構築編)」も合わせてご覧ください。
SLMの“実力”をどう測るか?
生成AIといえば、「文章の自然さ」や「賢さ」に目がいきがちですが、ローカルでの業務活用を考えた場合、それだけでは不十分です。
むしろ、重要になるのは性能指標(ベンチマーク)です。
たとえば──
- 処理が遅すぎてストレスになる
- GPUのメモリを食いすぎて、他のアプリが動かなくなる
- 非GPU環境では使い物にならない
こういった“現場あるある”を避けるためにも、定量的な指標でモデルを評価することが欠かせません。
実務で重視したい 5つの指標
私が今回意識したのは、次の5つです。
指標 | 意味と実務での重要性 |
tokens/sec |
どれだけ速く文章を生成できるか
(1秒あたりに生成されるトークン数、スループット設計の基礎)
|
初期応答時間 | ユーザーが待たされる体感時間 |
VRAM使用量 | 実行できるPCやサーバーの条件把握に直結 |
CPU使用率 | 非GPU環境やエッジデバイスの負荷確認 |
出力自然性 | 実用性や読みやすさの評価基準 |
これらは、2024年に公開された「Small Language Models: Survey, Measurements, and Insights」という論文でも、SLMの評価軸としてしっかり整理されています。
なお、今回はその中でも、比較的取得が容易だった「tokens/sec(生成速度)」を中心に測定を行いました。他の指標については、今後、取得方法の工夫を進め、順次可視化していく予定なので、ご理解いただければ幸いです。
前提条件
今回の検証は、以下のPC環境で実施しています。前回の記事と同じ構成です。
OS | Windows 11 Home (24H2) |
CPU | AMD Ryzen AI9 HX370 |
メモリ | 32GB |
ディスク | 1TB |
GPU/VRAM | NVIDIA GeForce RTX 4060 / 8GB |
なお、今回の検証内容や結果は、この構成に基づくものです。環境が異なる場合は、性能傾向が変わる可能性がある点にご注意ください。
ローカル環境での測定の壁と工夫
- tokens/sec(1秒あたりに生成されるトークン数)
- 初期応答時間
といった細かなデータは、素直に取れません。
また、WebUI側のOpen WebUIを直接いじって表示させることも考えましたが、アップデート対応が大変なので現実的ではないと判断しました。(Open WebUIのリリース状況を見てもわかるように、ほぼ毎週、時には週に数回アップデートがあります。そのたびに中身を直接修正するのは、現実的ではありませんよね…)
リレー処理を導入して“外から覗く”
1 |
[Open WebUI] ⇄ [リレー処理(自作プロキシ)] ⇄ [Ollama] |
このプロキシで、リレー処理の中でリクエストとレスポンスを覗き見ることで、
- プロンプト送信時刻
- 最初の応答トークンの戻り時刻
- トータルの生成時間
- tokens/secの算出結果(1秒あたりに生成されるトークン数)
といったログ情報を出力する仕組みを考えてみました。
実際のコード
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
from flask import Flask, request, Response import requests, os, json, time app = Flask(__name__) OLLAMA_API = 'http://ollama:11434' LOG_PATH = '/logs/bench.log' @app.route('/api/generate', methods=['POST']) def proxy_generate(): start_time = time.time() resp = requests.post(f"{OLLAMA_API}/api/generate", json=request.get_json()) duration = time.time() - start_time data = resp.json() eval_count = data.get("eval_count") eval_duration_ns = data.get("eval_duration") if eval_count and eval_duration_ns: token_sec = eval_count / (eval_duration_ns / 1_000_000_000) log_entry = { "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), "token_per_sec": token_sec } os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) with open(LOG_PATH, "a") as f: f.write(json.dumps(log_entry, ensure_ascii=False) + "\n") return Response(resp.content, status=resp.status_code, content_type=resp.headers.get('Content-Type')) |
1 2 |
flask requests |
実行環境のフォルダ構成
「記事は見たけど、自分の環境で動かせないと意味がない」そんな方のために、今回私が検証したフォルダ構成の全体イメージを載せておきます。(フォルダ構成内のコメント先頭に (*) があるフォルダは、新規で作成する必要があります)
1 2 3 4 5 6 7 8 9 10 11 |
./ollama ├ compose.yaml ├ ollama # ollama用のdockerマウントフォルダ │ └ : ├ open-webui # open webui用のdockerマウントフォルダ │ └ : ├ proxy_server # (*) 自作プロキシ用のフォルダ │ ├ proxy_server.py │ └ requirements.txt └ logs # (*) 自作プロキシが出力するログ用フォルダ └ bench.log # ログファイル |
この構成であれば、必要なファイルを同じ階層にまとめておけるので、環境再現や構成管理が非常に楽になります。ぜひ参考にしてみてください。
Dockerイメージ化と実行手順
(自作プロキシが上記「実際のコード」で作成した proxy_server.py となります。また、既存の構成がない場合でも、この内容で新規作成すれば問題ありません)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
services: ollama: image: ollama/ollama ports: - "11434:11434" volumes: - ./ollama:/root/.ollama deploy: resources: reservations: devices: - capabilities: [gpu] driver: nvidia count: all proxy: image: python:3.10-slim working_dir: /app volumes: - ./proxy_server:/app - ./logs:/logs command: ["sh", "-c", "pip install -r requirements.txt && exec python proxy_server.py"] ports: - "8001:8001" depends_on: - ollama open-webui: image: ghcr.io/open-webui/open-webui:latest ports: - "8080:8080" environment: API_URL: http://proxy:8001 ENABLE_OLLAMA_API: "True" OLLAMA_BASE_URL: http://proxy:8001 |
ポイントは、Open WebUIのリクエストをプロキシ(proxyコンテナ)経由にすることです。
この構成なら、Open WebUIのバージョンアップがあって内部仕様が変わっても、プロキシで外側からリクエストを見ているため、ログの取得に影響しにくい構成になります。
実際に取れたログとその考察
リレー処理を挟んだ結果、以下のようなログが取れました。(ログは抜粋して表示してます)
1 2 3 4 5 6 7 8 9 10 11 12 |
{"timestamp": "2025-06-28 12:24:22", "model": "gemma3:27b", "total_duration_ns": 94632160664, "load_duration_ns": 19994323564, "prompt_eval_count": 1031, "prompt_eval_duration_ns": 60875012677, "eval_count": 58, "eval_duration_ns": 13735608879, "token_per_sec": 4.222601306642811} {"timestamp": "2025-06-28 12:30:58", "model": "gemma3:27b", "total_duration_ns": 394963073595, "load_duration_ns": 53579880, "prompt_eval_count": 3546, "prompt_eval_duration_ns": 195046011140, "eval_count": 760, "eval_duration_ns": 199816400248, "token_per_sec": 3.8034916005729964} {"timestamp": "2025-06-28 13:59:10", "model": "qwen2.5vl:7b", "total_duration_ns": 759561136, "load_duration_ns": 13410971, "prompt_eval_count": 667, "prompt_eval_duration_ns": 408703073, "eval_count": 13, "eval_duration_ns": 334954117, "token_per_sec": 38.81128590516772} {"timestamp": "2025-06-28 13:59:10", "model": "qwen2.5vl:7b", "total_duration_ns": 875906258, "load_duration_ns": 11146979, "prompt_eval_count": 558, "prompt_eval_duration_ns": 335325142, "eval_count": 20, "eval_duration_ns": 526305136, "token_per_sec": 38.00076919636977} {"timestamp": "2025-06-28 13:59:37", "model": "qwen2.5vl:7b", "total_duration_ns": 3153000322, "load_duration_ns": 11314367, "prompt_eval_count": 721, "prompt_eval_duration_ns": 754606991, "eval_count": 72, "eval_duration_ns": 2383351049, "token_per_sec": 30.20956565765231} {"timestamp": "2025-06-28 14:05:37", "model": "gemma3:12b", "total_duration_ns": 5428418296, "load_duration_ns": 22034384, "prompt_eval_count": 448, "prompt_eval_duration_ns": 4024403009, "eval_count": 21, "eval_duration_ns": 1380531832, "token_per_sec": 15.21152900152758} {"timestamp": "2025-06-28 14:05:42", "model": "gemma3:12b", "total_duration_ns": 4395797252, "load_duration_ns": 24517951, "prompt_eval_count": 337, "prompt_eval_duration_ns": 2879844775, "eval_count": 23, "eval_duration_ns": 1490088937, "token_per_sec": 15.435320287865476} {"timestamp": "2025-06-28 14:06:01", "model": "gemma3:12b", "total_duration_ns": 8595806105, "load_duration_ns": 30188016, "prompt_eval_count": 497, "prompt_eval_duration_ns": 4741797711, "eval_count": 51, "eval_duration_ns": 3822096375, "token_per_sec": 13.343462591259227} {"timestamp": "2025-06-28 16:06:21", "model": "gemma3n:e4b", "total_duration_ns": 23945618955, "load_duration_ns": 13359735942, "prompt_eval_count": 16, "prompt_eval_duration_ns": 800558935, "eval_count": 200, "eval_duration_ns": 9782352502, "token_per_sec": 20.444979871570773} {"timestamp": "2025-06-28 16:06:25", "model": "gemma3n:e4b", "total_duration_ns": 3821787614, "load_duration_ns": 62551020, "prompt_eval_count": 477, "prompt_eval_duration_ns": 593423184, "eval_count": 67, "eval_duration_ns": 3164968738, "token_per_sec": 21.169245432211913} {"timestamp": "2025-06-28 16:06:27", "model": "gemma3n:e4b", "total_duration_ns": 1725808520, "load_duration_ns": 49401411, "prompt_eval_count": 556, "prompt_eval_duration_ns": 768251988, "eval_count": 20, "eval_duration_ns": 907228651, "token_per_sec": 22.045159153599084} {"timestamp": "2025-06-28 16:06:30", "model": "gemma3n:e4b", "total_duration_ns": 2859492266, "load_duration_ns": 43687267, "prompt_eval_count": 432, "prompt_eval_duration_ns": 542335379, "eval_count": 46, "eval_duration_ns": 2272627867, "token_per_sec": 20.24088530636679} |
モデル名 | パラメータ規模 | 特徴・備考 | 平均 tokens/sec |
qwen2.5vl:7b | 7B | 軽量・高速モデル | 35.7 |
gemma3n:e4b | 12B相当(最新) | gemma3系の最新世代、最適化モデル | 20.5 |
gemma3:12b | 12B | 標準的な中量モデル、バランス型 | 14.7 |
gemma3:27b | 27B | 高精度寄りの大型モデル、低速 | 4.0 |
※ 平均値は小数第2位まで、四捨五入 |
qwen2.5vl:7b |
群を抜いて高速で、トークン生成速度は 35〜38 tokens/sec 程度を記録した。
軽量モデルならではのメリットを最大限に活かせるため、ローカル環境でのチャットボットや簡易生成タスクには最適といえる。
|
gemma3:12b |
トークン生成速度は約 13〜15 tokens/sec で、中量モデルとしては標準的な結果であった。
qwen2.5vl:7b と比べると速度面では劣るが、より安定した出力品質や汎用性の高さが期待でき、処理時間と品質のバランスを重視する場面では有力な選択肢となる。
|
gemma3n:e4b | 最適化世代という特徴通り、同じ12Bクラスでも約20〜22 tokens/sec と、旧世代の gemma3:12b より明確に高速化されている。 速度と品質のバランスが取れており、現実的な業務用途で最も扱いやすい中量モデルの一つといえる。 |
gemma3:27b | トークン生成速度は約 3.8〜4.2 tokens/sec と、他モデルと比べて圧倒的に低速である。 その代わり、出力品質や精度重視の大型モデルであり、リアルタイム性を必要としない高品質生成タスク(文書要約、テキスト生成など)では十分に価値を発揮する。ローカル環境で使う場合は、運用用途を見極めた上で導入を検討する必要があるといえる。 |
- モデルごとの推論時間
- トークン生成速度(tokens/sec、1秒あたりに生成されるトークン数)
まとめと次の展開
今回の検証から得られたポイントは以下の通りです。
✅ ローカルSLMの実力を、数値で“見える化”できた
✅ プロダクト側を改造せず、ログを外から自由に加工できる構成が組めた
✅ 実用性や限界を、感覚ではなくデータで判断できるようになった
次回の展開
次は、さらに実業務に寄せた以下のテーマにも踏み込みたいと考えています。
- ローカルSLMとRAGの実務レベル活用
- ビジョンモデルやオフィス文書対応の検証
- 特に、社内で多用されるExcelやPowerPoint、PDFの要約・活用性の評価
生成AIは“魔法”ではなく、“現実的なツール”です。だからこそ、我々としては、こうした地道な検証を積み重ねて、現場にフィットする使い方を今後も模索していこうと思います。
それでは、また次回もお楽しみに!
執筆者プロフィール

- tdi デジタルイノベーション技術部
- 社内の開発プロジェクトやクラウド・データ活用の技術サポートを担当しています。データエンジニアリングやシステム基盤づくりなど、気づけばいろんなことに首を突っ込んできましたが、やっぱり「まずは試してみる」がモットー。最近は、ローカル生成AIやコード生成AIといった、開発現場ですぐ役立つ生成AIの活用にハマり中。もっと楽に、もっとスマートにものづくりできる世界を目指して、あれこれ奮闘しています。
この執筆者の最新記事
Pick UP!2025年7月15日ローカルSLM/閉じた環境で動作する生成AIを検証してみた (②性能指標とログの取り方編)
Pick UP!2025年5月19日ローカルSLM/閉じた環境で動作する生成AIを検証してみた (①環境構築編)
Pick UP!2020年3月27日汎用的な仕組みでセンサーデータを見える化してみた――後編
Pick UP!2020年3月27日汎用的な仕組みでセンサーデータを見える化してみた――前編