書いてること
Raspberry Pi 4 に Raspberry Pi OS をインストールし、ローカルLLM(Ollama)をセットアップ
更に、ブラウザからOllmaにプロンプトを送信する簡易Webアプリを作成
これらのセットアップ手順を備忘録として残している
環境
- Raspberry Pi 4
- メモリ:4GB
- Raspberry Pi OS
- ローカルLLM:Ollama モデル:gemma2:2b
- Webサーバー:FastAPI + Uvicorn
- 操作端末:Windows PC
- Windowsとラズパイは同じローカルネットワーク内にあり通信可
全体構成
Windowsブラウザ
↓ HTTP
Raspberry Pi 4 上のWebサーバー(FastAPI)
↓ HTTP
Ollama API(127.0.0.1:11434)
↓
gemma2:2b
Windowsのブラウザから質問を送ると、ラズパイ上のOllamaが回答を生成する
ブラウザ上には回答文字列を表示し、Web Speech APIを使ってWindows側のスピーカーから読み上げる
セットアップ手順
(手順1) Raspberry Pi OS をmicroSDへ書き込む
Raspberry Pi Imager でmicroSDにOSをインストールする
- Raspberry Pi Imager
インストールの際、SSHの有効化と、Raspberry Pi Connectをしておくと便利
書き込み後、microSDをラズパイへ挿入して起動する
(手順2) WindowsからSSH接続する
git bashでWindowsからRaspberry Piに接続する
ssh {ユーザーID}@{Raspberry PiのIPアドレス}
- 参考①
- Raspberry PiのIPアドレスは以下のPowreShellコマンドでIPアドレスを確認することができるかも
Get-NetNeighbor -AddressFamily IPv4
- 参考②
- SSH接続のかわりに、Raspberry Pi Connect を使う方法でもOK
- Raspberry Pi Connect は以下サイトからアクセス(アカウント登録必須)
- https://www.raspberrypi.com/software/connect/
- SSH接続のかわりに、Raspberry Pi Connect を使う方法でもOK
(手順3) Raspberry Pi に Ollama をインストールする
SSH接続したコンソールにて、Ollama をインストールする
Ollamaのインストール方法は公式ドキュメントにある
私はこのブログを参考にインストールし、モデルも同じ gemma2:2b にした
補足情報
インストール後、Ollamaのサービスを有効にする
sudo systemctl enable --now ollama
状態を確認する。
sudo systemctl status ollama`
以下のように active (running) なら起動している
Active: active (running)
サービスを再起動したいは以下のコマンドを実行する
sudo systemctl restart ollama
ログをリアルタイムで確認する場合は以下のコマンドを実行する
journalctl -u ollama -f
インストール済みモデルを確認する
ollama list
(手順4) Ollama動作確認
ollama が動作するか確認する
ollama run gemma2:2b "こんにちは。一言で返して"
以下のような応答があると動作OK
user1@raspi:~ $ ollama run gemma2:2b "こんにちは。一言で返して"
こんにちは! 😊
(手順5) Ollama APIを確認する
APIの動作を確認する
curl http://127.0.0.1:11434/api/tags
実行結果の例
user1@raspi:~ $ curl http://127.0.0.1:11434/api/tags
{"models":[{"name":"gemma2:2b","model":"gemma2:2b","modified_at":"2026-06-27T11:42:07.678765719+09:00","size":1629518495,"digest":"8ccf136fdd5298f3ffe2d69862750ea7fb56555fa4d5b18c04e3fa4d82ee09d7","details":{"parent_model":"","format":"gguf","family":"gemma2","families":["gemma2"],"parameter_size":"2.6B","quantization_level":"Q4_0","context_length":8192,"embedding_length":2304},"capabilities":["completion"]}]}
チャットAPIを確認する
curl -sS -X POST http://127.0.0.1:11434/api/chat -H "Content-Type: application/json" -d '{"model":"gemma2:2b","messages":[{"role":"user","content":"こんにちは。一言で返して"}],"stream":false}'
実行結果の例
user1@raspi:~ $ curl -sS -X POST http://127.0.0.1:11434/api/chat -H "Content-Type: application/json" -d '{"model":"gemma2:2b","messages":[{"role":"user","content":"こんにちは。一言で返して"}],"stream":false}'
{"model":"gemma2:2b","created_at":"2026-06-27T07:22:27.141580348Z","message":{"role":"assistant","content":"こんにちは 😊 \n"},"done":true,"done_reason":"stop","total_duration":54730834103,"load_duration":49725680459,"prompt_eval_count":15,"prompt_eval_duration":3016451000,"eval_count":5,"eval_duration":1983463000}
(手順6) FastAPI用のPython環境を作成する
Webアプリ用のフォルダを作成する。
mkdir -p ~/ollama-web
cd ~/ollama-web
Python仮想環境を作成して有効化する
python3 -m venv .venv
source .venv/bin/activate
FastAPIとUvicornをインストールする
pip install --upgrade pip
pip install fastapi uvicorn
(手順7) Webチャットアプリを作る
~/ollama-web/app.py を作成する
nano ~/ollama-web/app.py
構成
(.venv) user@raspi:~/ollama-web $ pwd
/home/raspi/ollama-web
(.venv) user@raspi:~/ollama-web $ ls -l
total 20
-rw-r--r-- 1 root root 6888 Jun 27 13:32 app.py
app.py
import json
import urllib.error
import urllib.request
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
APP_DIR = Path(__file__).parent
OLLAMA_URL = "http://127.0.0.1:11434/api/chat"
MODEL = "gemma2:2b"
app = FastAPI()
class ChatRequest(BaseModel):
message: str
history: list[dict[str, str]] = []
HTML = """
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Raspberry Pi Ollama Chat</title>
<style>
body {
font-family: system-ui, sans-serif;
background: #111827;
color: #e5e7eb;
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
h1 { font-size: 1.4rem; }
#chat {
min-height: 420px;
border: 1px solid #374151;
border-radius: 12px;
padding: 12px;
background: #1f2937;
overflow-y: auto;
}
.msg {
white-space: pre-wrap;
padding: 10px 12px;
margin: 8px 0;
border-radius: 10px;
max-width: 85%;
}
.user { background: #1d4ed8; margin-left: auto; }
.assistant { background: #374151; }
form { display: flex; gap: 8px; margin-top: 12px; }
textarea {
flex: 1;
min-height: 54px;
padding: 10px;
border-radius: 8px;
border: 1px solid #4b5563;
background: #111827;
color: #fff;
}
button {
padding: 10px 16px;
border: 0;
border-radius: 8px;
cursor: pointer;
}
.options { margin: 10px 0; font-size: .9rem; }
#status { color: #9ca3af; margin-top: 8px; }
</style>
</head>
<body>
<h1>Raspberry Pi Ollama Chat</h1>
<div class="options">
<label>
<input type="checkbox" id="speak" checked>
Windowsのスピーカーで読み上げる
</label>
</div>
<div id="chat"></div>
<form id="form">
<textarea id="message" placeholder="日本語で入力してください" required></textarea>
<button type="submit">送信</button>
</form>
<div id="status"></div>
<script>
const chat = document.getElementById("chat");
const form = document.getElementById("form");
const messageInput = document.getElementById("message");
const speakCheckbox = document.getElementById("speak");
const status = document.getElementById("status");
const history = [];
function addMessage(role, text) {
const div = document.createElement("div");
div.className = "msg " + role;
div.textContent = text;
chat.appendChild(div);
chat.scrollTop = chat.scrollHeight;
return div;
}
function speak(text) {
if (!speakCheckbox.checked || !("speechSynthesis" in window)) {
return;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "ja-JP";
utterance.rate = 1.05;
utterance.pitch = 1.0;
const voices = window.speechSynthesis.getVoices();
const japaneseVoice = voices.find(v => v.lang.startsWith("ja"));
if (japaneseVoice) {
utterance.voice = japaneseVoice;
}
window.speechSynthesis.speak(utterance);
}
window.speechSynthesis?.getVoices();
window.speechSynthesis?.addEventListener("voiceschanged", () => {
window.speechSynthesis.getVoices();
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const message = messageInput.value.trim();
if (!message) return;
addMessage("user", message);
history.push({ role: "user", content: message });
messageInput.value = "";
const waiting = addMessage("assistant", "考えています...");
status.textContent = "Ollamaに問い合わせ中...";
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message,
history: history.slice(-8)
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || "エラーが発生しました");
}
waiting.textContent = data.answer;
history.push({ role: "assistant", content: data.answer });
speak(data.answer);
status.textContent = "";
} catch (error) {
waiting.textContent = "エラー: " + error.message;
status.textContent = "Ollamaが起動しているか確認してください。";
}
});
messageInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
form.requestSubmit();
}
});
</script>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
def index():
return HTML
@app.post("/api/chat")
def chat(request: ChatRequest):
message = request.message.strip()
if not message:
raise HTTPException(status_code=400, detail="メッセージが空です。")
if len(message) > 1000:
raise HTTPException(status_code=400, detail="メッセージが長すぎます。")
messages = [
{
"role": "system",
"content": (
"あなたはRaspberry Pi上で動く小型AIです。"
"日本語で、簡潔かつ分かりやすく答えてください。"
"返答は音声でも読み上げられるため、記号や表は控えめにしてください。"
),
}
]
for item in request.history[-8:]:
role = item.get("role")
content = item.get("content", "").strip()
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
if not messages or messages[-1]["role"] != "user":
messages.append({"role": "user", "content": message})
payload = {
"model": MODEL,
"messages": messages,
"stream": False,
"think": False,
"options": {
"num_predict": 180
}
}
try:
body = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
OLLAMA_URL,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=180) as response:
result = json.loads(response.read().decode("utf-8"))
answer = result.get("message", {}).get("content", "").strip()
if not answer:
raise HTTPException(status_code=502, detail="Ollamaから回答を取得できませんでした。")
return {"answer": answer}
except urllib.error.URLError as error:
raise HTTPException(
status_code=503,
detail=f"Ollamaへ接続できません: {error.reason}",
)
- 注意
- インストールしたモデルと、app.py の
MODEL = "gemma2:2b"を合わせること
- インストールしたモデルと、app.py の
(手順8) Webサーバを起動する
app.py があるところをカレントディレクトリにして、Webサーバを起動する
cd ~/ollama-web
source .venv/bin/activate
uvicorn app:app --host 0.0.0.0 --port 8000
コマンド実行例
(.venv) user@raspi:/ $ cd ~/ollama-web
(.venv) user@raspi:~/ollama-web $ ls
app.py ng_app.py __pycache__
(.venv) user@raspi:~/ollama-web $ source .venv/bin/activate
(.venv) user@raspi:~/ollama-web $ uvicorn app:app --host 0.0.0.0 --port 8000
INFO: Started server process [12941]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
(手順9) Webアプリの動作画面
Windowsのブラウザで、http://{Raspberry PiのIPアドレス}:8000/ を開く
質問を送信する。
(20秒くらい待つと)以下のような応答がくる。Windowsのスピーカーで読み上げるにチェックあると音声読み上げもする
ー以上ー
所感
Ollamaを動作させるには、今回用意したものではスペックが足りず、応答が遅かったり、タイムアウトが発生したりと安定的に動作はできませんでした。もっと、スペックの高い環境に実装が必要でした
用意したWebアプリに音声入力の仕組みも追加するともう少し便利かもしれません
扉絵はChatGPTにより生成されたものです、完成度の高さに驚き



