👋

8GBメモリでローカルLLMを動かす

に公開

この記事は「Anthrotech Advent Calendar 2025」の6日目です。
前回の記事は、 なかいあんこうさんの oraja時代のBMS新圧縮常識!? です。

8GBメモリでローカルLLMを動かす

はじめに

「ローカルでLLMを動かしたい」——そう思って調べると、「RTX 4090が必要」「VRAM 24GB推奨」みたいな記事ばかり出てきて心が折れそうになりますよね。でも実は、8GBのメモリでも十分動くモデルと設定があります。

この記事では、Proxmox上のVM(メモリ8GB、CPUのみ)でLLMを動かすまでに試行錯誤した内容をまとめました。具体的には、Misskeyのローカルタイムラインを監視して、特定の投稿を分類するBotを作るために、ローカルでLLM推論サーバーを立てた話です。

やりたかったこと

  • MisskeyのLTLをリアルタイム監視
  • 投稿内容をLLMで分類(AI botへのメンション検知)
  • 条件に合う投稿にリアクションを付ける

クラウドAPIを使えば簡単ですが、LTLは流量が多いですし、分類タスクごときに課金するのも馬鹿らしい。というわけでローカルLLMの出番です。

環境

  • ホスト: Proxmox VE
  • VM: Ubuntu、6コア、メモリ16GB
  • GPU: なし(CPUオンリー)

※メモリは16GBありますが、モデル自体は3.1GBしか使わないので、8GBでも十分動きます。

量子化の基礎知識

LLMをローカルで動かすとき、避けて通れないのが「量子化(Quantization)」です。

元のモデルは32bitや16bitの浮動小数点で重みを保持していますが、これを4bitや8bitに圧縮することで、精度を多少犠牲にしつつメモリ使用量を大幅に削減できます。

主な量子化形式

形式 特徴
Q4_K_M 4bit量子化。バランス型。一番よく使われる
Q4_K_S 4bit量子化。Q4_K_Mより小さいがやや精度低下
Q5_K_M 5bit量子化。Q4より高精度だがサイズ増
Q8_0 8bit量子化。高精度だがサイズ大きい

4Bモデルのメモリ目安

量子化 ファイルサイズ 必要メモリ目安
Q4_K_M 約2.5GB 約4GB
Q5_K_M 約3.0GB 約5GB
Q8_0 約4.5GB 約6GB

8GBあればQ4_K_Mは余裕、Q8_0でもギリギリ動きます。ただし、これはモデル本体だけの話で、実際にはKVキャッシュ(コンテキスト長に比例)も加算されます。

Ollama vs llama.cpp

ローカルLLMを動かすツールとして代表的なのがこの2つです。

Ollama

# インストール
curl -fsSL https://ollama.com/install.sh | sh

# モデルをダウンロードして実行
ollama pull qwen3:4b
ollama run qwen3:4b "こんにちは"
  • メリット: 導入が超簡単。1コマンドでAPIサーバーが立つ
  • デメリット: 細かい制御がしにくい。Qwen3のthinkingモード制御が微妙

llama.cpp (llama-server)

# ビルド
git clone https://github.com/ggml-org/llama.cpp
cmake llama.cpp -B llama.cpp/build
cmake --build llama.cpp/build --config Release -j

# サーバー起動
./llama.cpp/build/bin/llama-server \
  -hf Qwen/Qwen3-4B-GGUF:Q4_K_M \
  --host 0.0.0.0 --port 8080 \
  -c 1024
  • メリット: 細かいパラメータ制御が可能。安定している
  • デメリット: ビルドが必要。Ollamaより手間がかかる

結論: まずOllamaで試して、問題があればllama.cppに移行するのがおすすめです。

実際にハマったポイント

1. コンテキスト長のデフォルトが重い

Ollamaのデフォルトコンテキスト長は2048トークン。これがKVキャッシュのメモリを食います。

# Ollamaのデフォルト
# num_ctx: 2048 → KVキャッシュだけで数百MB

# 分類タスクなら512〜1024で十分
curl http://localhost:11434/api/generate -d '{
  "model": "qwen3:4b",
  "prompt": "hello",
  "options": { "num_ctx": 1024 }
}'

LM Studioでは520に設定したら一瞬で返ってきたのに、Ollamaのデフォルト設定だと返事に2分かかりました。

2. Qwen3のthinkingモードが勝手に有効

Qwen3には「thinking mode」があり、デフォルトで有効になっています。これは内部で思考プロセスを生成してから最終回答を出す機能ですが、分類タスクには不要です。

問題は、出力が7トークンでも、内部で数百トークンの思考を生成していること。

# /no_think を付けても効かないことがある
curl http://localhost:11434/api/chat -d '{
  "model": "qwen3:4b",
  "messages": [{"role": "user", "content": "1+1= /no_think"}],
  "stream": false
}'

# レスポンス(thinkingが動いている)
# {"thinking":"Okay, the user wrote \"1+1", ...}

回避策1: システムプロンプトで強制

curl http://localhost:11434/api/chat -d '{
  "model": "qwen3:4b",
  "messages": [
    {"role": "system", "content": "Do not think. Respond directly without any thinking process."},
    {"role": "user", "content": "1+1="}
  ],
  "stream": false
}'

回避策2: Modelfileでカスタムモデルを作る

cat << 'EOF' > Modelfile
FROM qwen3:4b
PARAMETER num_ctx 512
SYSTEM "You always respond without thinking. Do not use any internal reasoning."
EOF

ollama create qwen3-fast -f Modelfile
ollama run qwen3-fast "hello"

回避策3: thinkingが無効化されたモデルを使う(これを採用しました)

色々試した結果、最終的にはthinkingモードが最初から無効化されているカスタムモデルを使うことにしました。

ollama pull hoangquan456/qwen3-nothink:4b

公式のQwen3で /no_think を頑張るより、最初からthinkingが無効なモデルを使う方が圧倒的に楽です。同じQwen3-4Bベースなので精度も変わりません。

3. VMでAVX命令が無効だと激遅

ProxmoxのVMはデフォルトでホストのCPU機能をフルに渡さないことがあります。特にAVX2/AVX512が無効だと、llama.cpp系は劇的に遅くなります。

# VM内でAVX対応を確認
cat /proc/cpuinfo | grep -E "avx|sse"

AVX2がない場合は、ProxmoxのVM設定でCPUタイプをhostに変更してください。

# Proxmox VM設定
CPU Type: host  (qemu64やkvmではなく)

4. Ollamaがモデルをアンロードする

Ollamaはデフォルトで5分アイドルするとモデルをメモリからアンロードします。常駐させたい場合は設定が必要です。

sudo systemctl edit ollama
[Service]
Environment="OLLAMA_HOST=0.0.0.0"
Environment="OLLAMA_KEEP_ALIVE=-1"
sudo systemctl daemon-reload
sudo systemctl restart ollama

ベンチマーク

実際に8GB環境でどの程度のパフォーマンスが出るか、以下のコマンドで計測しました。

Ollamaでの計測

# 基本的な応答速度テスト
time curl http://localhost:11434/api/generate -d '{
  "model": "hoangquan456/qwen3-nothink:4b",
  "prompt": "1+1=",
  "stream": false,
  "options": {"num_ctx": 512, "num_predict": 50}
}'

# 分類タスク想定(短いプロンプト、短い出力)
time curl http://localhost:11434/api/chat -d '{
  "model": "hoangquan456/qwen3-nothink:4b",
  "messages": [
    {"role": "system", "content": "Classify if this text mentions AI. Reply only true or false."},
    {"role": "user", "content": "今日はいい天気ですね"}
  ],
  "stream": false,
  "options": {"num_ctx": 512, "num_predict": 10}
}'

# 長めの生成テスト
time curl http://localhost:11434/api/generate -d '{
  "model": "hoangquan456/qwen3-nothink:4b",
  "prompt": "Pythonでクイックソートを実装してください",
  "stream": false,
  "options": {"num_ctx": 1024, "num_predict": 200}
}'

メモリ使用量の確認

# モデルロード前
free -h

# モデルをロード
curl http://localhost:11434/api/generate -d '{"model": "hoangquan456/qwen3-nothink:4b", "prompt": "hello", "keep_alive": -1}'

# モデルロード後
free -h

# Ollamaが認識しているモデル状態
ollama ps

(参考)他のモデルとの比較

# gemma2:2b(より軽量なモデル)
ollama pull gemma2:2b
time curl http://localhost:11434/api/generate -d '{
  "model": "gemma2:2b",
  "prompt": "1+1=",
  "stream": false,
  "options": {"num_ctx": 512, "num_predict": 50}
}'

僕の環境での結果

実際に計測した結果がこちらです。

環境:
- Model: hoangquan456/qwen3-nothink:4b
- CPU: 6 cores (VM on Proxmox)
- Memory: 16GB(モデルは3.1GB使用)
- Context: 512

分類タスク(短い応答):
- Total: 1.85 sec
- Model load: 0.90 sec
- Prompt eval: 0.82 sec (39 tokens)
- Generation: 0.08 sec (2 tokens)
- 応答: "False"

コード生成(200トークン):
- Total: 18.3 sec
- Generation speed: 約12 tokens/sec
- ちゃんと動くクイックソートが出力された

分類タスクなら2秒以内で返ってくるので、Bot用途には十分です。コード生成のような長い出力でも、CPUオンリーで12 tokens/sec出るのは悪くない数字だと思います。

結論:8GBメモリでの最適構成

分類タスク(短い入出力)なら

  • モデル: hoangquan456/qwen3-nothink:4b
  • コンテキスト長: 512
  • 理由: Qwen3の賢さを維持しつつ、thinkingモードの煩わしさがない

別の選択肢

  • モデル: gemma2:2b または phi3:mini
  • コンテキスト長: 512
  • 理由: さらに軽量。精度より速度重視ならこちら

設定のポイント

  1. コンテキスト長は用途に合わせて最小限に
  2. VMならCPU Typeをhost
  3. OllamaはOLLAMA_KEEP_ALIVE=-1で常駐化
  4. Qwen3を使うなら hoangquan456/qwen3-nothink が楽

実際のBot実装例

参考までに、今回作ったMisskey監視Botのコードを載せておきます。LTL(ローカルタイムライン)をWebSocketで監視して、投稿内容をLLMで分類し、条件に合えばリアクションを付けるというシンプルな構成です。

// misskey-momo-watcher.js
const WebSocket = require('ws');
const dotenv = require('dotenv');

dotenv.config();

const CONFIG = {
  misskey: {
    host: 'https://your-misskey-instance.com',
    token: process.env.MISSKEY_TOKEN,
  },
  ollama: {
    host: 'http://192.168.0.36:11434',  // OllamaのIP
    model: 'hoangquan456/qwen3-nothink:4b',
  },
};

const CLASSIFIER_PROMPT = `
You are a classifier for an SNS agent AI named "モモちゃん".
**TASK:** Determine if this post needs AI processing.
**OUTPUT: Return ONLY one of these JSON objects:**
{"need_response": true}
{"need_response": false}
---
## EXAMPLES:
"モモちゃん、天気教えて" → {"need_response": true}
"牛乳買わなきゃ" → {"need_response": true}
"今日やること うんち ごみだし しごと" → {"need_response": true}
"○○ 使い方 設定" → {"need_response": false}
"うどんおいしかった" → {"need_response": false}
---
## RULES:
**TRUE only when:**
- Contains "モモちゃん" AND requesting something
- User declares firm TODO: 〜なきゃ, 〜ないと, 後で〜する
- User lists tasks: 今日やること, todo, TODO
**FALSE when:**
- Asking other users questions
- Venting/Complaining
- Thinking/Wondering: 〜べきか, 〜かな
---
**OUTPUT ONLY THE JSON. NO EXPLANATION.**

Post: `;

async function classifyPost(text) {
  try {
    const res = await fetch(`${CONFIG.ollama.host}/api/generate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        model: CONFIG.ollama.model,
        prompt: CLASSIFIER_PROMPT + `"${text}"`,
        stream: false,
        options: {
          num_ctx: 1000,
          temperature: 0.7,
        },
      }),
    });
    
    const data = await res.json();
    const response = data.response.trim();
    
    // JSONを抽出
    const match = response.match(/\{[^}]+\}/);
    if (match) {
      const parsed = JSON.parse(match[0]);
      return parsed.need_response === true;
    }
    return false;
  } catch (e) {
    console.error('Classification error:', e.message);
    return false;
  }
}

async function addReaction(noteId, reaction = ':wakatta:') {
  try {
    await fetch(`${CONFIG.misskey.host}/api/notes/reactions/create`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        i: CONFIG.misskey.token,
        noteId,
        reaction,
      }),
    });
    console.log(`✓ Reacted to ${noteId}`);
  } catch (e) {
    console.error('Reaction error:', e.message);
  }
}

function connectStream() {
  const ws = new WebSocket(
    `${CONFIG.misskey.host.replace('https', 'wss')}/streaming?i=${CONFIG.misskey.token}`
  );
  
  ws.on('open', () => {
    console.log('Connected to Misskey streaming');
    ws.send(JSON.stringify({
      type: 'connect',
      body: { channel: 'localTimeline', id: 'ltl' },
    }));
  });
  
  ws.on('message', async (raw) => {
    try {
      const msg = JSON.parse(raw.toString());
      
      if (msg.type === 'channel' && msg.body.id === 'ltl' && msg.body.type === 'note') {
        const note = msg.body.body;
        const text = note.text;
        
        if (!text) return;
        
        console.log(`[${note.user.username}] ${text.slice(0, 50)}...`);
        
        const needsResponse = await classifyPost(text);
        if (needsResponse) {
          console.log(`→ Need response detected!`);
          await addReaction(note.id);
        }
      }
    } catch (e) {
      console.error('Message parse error:', e.message);
    }
  });
  
  ws.on('close', () => {
    console.log('Disconnected. Reconnecting in 5s...');
    setTimeout(connectStream, 5000);
  });
  
  ws.on('error', (e) => {
    console.error('WebSocket error:', e.message);
  });
}

console.log('Starting Momo Watcher...');
connectStream();

ポイント

  • プロンプトにExamplesを入れる: Few-shot形式で例を与えると、分類精度が上がります
  • JSONだけ抽出: response.match(/\{[^}]+\}/) でJSON部分だけ取り出すと、余計な出力があっても安心
  • num_ctx: 1000: 分類タスクなのでコンテキストは短めでOK
  • temperature: 0.7: 分類なのでもっと下げてもいいかも(0.3〜0.5)

おわりに

8GBメモリでも、設定次第でローカルLLMは十分実用になります。特に分類タスクのような短い入出力なら、クラウドAPIに頼らなくても自前で完結できます。

ハマりポイントは多いですが、一度設定してしまえば、あとは安定して動きます。自宅サーバーやVMで気軽にLLMを動かしたい人の参考になれば幸いです。

Discussion