はじめに
プログラマーであれば一度は,ChatGPTやGeminiなどの対話型AIを自作してみたいと思ったことはあるのではないでしょうか.
この記事では,対話型AIの基礎となる仕組みを理解しながら,OllamaとFastAPIを用いてローカル環境で動作する対話型AIを実装していきます.
使用環境/技術
・OS:Windows 11
・Ollama:Version 0.14.3
・Python:Version 3.13.5
・FastAPI
・Uvicorn
・Requests
目次
- Ollamaのインストール
- APIの実装
- Web UIの作成
- 会話履歴の保持
- 会話履歴の制限
- タブ単位のセッション管理
- ストリーミング応答
Ollamaのインストール
まずはOllamaをPCにインストールしましょう.
Ollamaとは,大規模言語モデル(LLM)をローカル環境で実行・管理できるオープンソースのツールです.
以下のサイトからダウンロードしましょう.
https://ollama.com/download/windows
ダウンロードが完了したら,実行してインストールしましょう.
インストールの確認をするために以下のコマンドを実行してください.
ollama --help
次のような表示が返ってきたらインストールがしっかりできています.
Large language model runner
Usage:
ollama [flags]
ollama [command]
Available Commands:
serve Start ollama
create Create a model
show Show information for a model
run Run a model
stop Stop a running model
pull Pull a model from a registry
push Push a model to a registry
signin Sign in to ollama.com
signout Sign out from ollama.com
list List models
ps List running models
cp Copy a model
rm Remove a model
help Help about any command
Flags:
-h, --help help for ollama
-v, --version Show version information
Use "ollama [command] --help" for more information about a command.
次に,使用するモデルを取得します.
ollama pull llama3
動作確認をします.
ollama run llama3
実行後,対話ができればインストール完了です.
APIの実装
まずはFastAPIで最小のAPIを作ります.
main.pyを作成し,以下のプログラムを書きましょう.
from fastapi import FastAPI
from pydantic import BaseModel
import requests
app = FastAPI()
class ChatRequest(BaseModel):
message: str
@app.post("/chat")
def chat(req: ChatRequest):
payload = {
"model": "llama3",
"messages": [
{"role": "user", "content": req.message}
],
"stream": False
}
r = requests.post(
"http://localhost:11434/api/chat",
json=payload
)
reply = r.json()["message"]["content"]
return {"reply": reply}
今後,FastAPI,Uvicorn,requestsを使用するために以下のコマンドを実行してください.
pip install fastapi uvicorn requests
動作確認をします.
FastAPIサーバーを起動します.
uvicorn main:app --reload
問題がなければ以下のようなログが表示されるはずです.
Uvicorn running on http://127.0.0.1:8000
レスポンスが返ってくるか確認しましょう.PowerShellで以下のコマンドを実行します.
Invoke-RestMethod `
-Uri "http://127.0.0.1:8000/chat" `
-Method Post `
-ContentType "application/json" `
-Body '{"message":"Hello"}'
何かしらの返答が返ってきたらAPIの実装は終わりです.
今の時点では,「1回だけ質問できるAPI」です.そのため,会話の文脈は保持されません.
Web UIの作成
今のままでは次のような制約があります.
・前の発言を覚えていない
・ブラウザから直接使えない
・応答が返るまで何も表示されない
今からは,ブラウザからAPIを呼び出すためのWeb UIを作成します.
index.htmlを作成し,以下のコードを書きます.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>ChatAI</title>
</head>
<body>
<h1>ChatAI</h1>
<input id="msg">
<button onclick="send()">Send</button>
<pre id="log"></pre>
<script>
async function send() {
const msg = document.getElementById("msg").value;
const log = document.getElementById("log");
const res = await fetch("http://localhost:8000/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: msg })
});
const data = await res.json();
log.textContent +=
"You: " + msg + "\n" +
"AI: " + data.reply + "\n\n";
document.getElementById("msg").value = "";
}
</script>
</body>
</html>
今のままではエラーが出てしまいます.
main.pyに以下のコードを書いてください.
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
を追加します.2個目のコードはapp = FastAPI()の後に書きましょう.
これで,ローカルHTMLからAPIを呼び出せるようになります.
ブラウザで開き,動作確認をします.FastAPIサーバーを起動しましょう.
uvicorn main:app --reload
FastAPIサーバーを起動した後に,文章を入力して送信すると数十秒後に返事が返ってきます.
会話履歴の保持
ここまでで,APIとWeb UIを通じてLLMに質問し,応答を返すことができるようになりました.
しかし,まだ以下のような制限があります.
・毎回のリクエストが単発
・前の発言を覚えていない
まだ,対話型AIとは言えません.
次に,会話履歴をサーバー側で保持し,文脈を維持できるようにしましょう.
Ollamaでは,会話は以下の形式で渡します.
[
{"role": "system", "content": "..."},
{"role": "user", "content": "..."},
{"role": "assistant", "content": "..."}
]
重要なのは,過去のやり取りをすべてこのような配列に含めることです.これをFastAPI側で管理します.
まず,サーバー起動中に保持される履歴用の変数を用意します.
ここでは,シンプルに1つの会話だけを使います.
以下のコードを@app.post("/chat")の前に書きます.
history = []
次に,毎回の会話の先頭にAIの振る舞いを定義するsystemメッセージを書きます.
以下のコードを先ほどのhistory = []の後に書きます.
system_message = {
"role": "system",
"content": "You are a helpful assistant."
}
次は,ユーザーの発言と,AIの応答を履歴に追加するように変更をします.
chat関数を以下のように変更してください.
@app.post("/chat")
def chat(req: ChatRequest):
history.append(
{"role": "user", "content": req.message}
)
payload = {
"model": "llama3",
"messages": [system_message] + history,
"stream": False
}
r = requests.post(
"http://localhost:11434/api/chat",
json=payload
)
reply = r.json()["message"]["content"]
history.append(
{"role": "assistant", "content": reply}
)
return {"reply": reply}
動作確認をしてみてください.前の発言を踏まえた応答が返ってきたら成功です.
会話履歴の制限
ここまでで,文脈を保持した対話ができるようになりました.
しかし,このままでは新しい問題が発生します.
・会話が続くほど,messagesが肥大化する.
・トークン数が増え,応答が遅くなる
・最悪の場合,モデルが受け付けなくなる
これらを解決するために,直近の会話だけを使うように制限をかけます.
制限をかけるにあたり,次のようなルールを採用します.
・1ターン = user + assistant
・直近Nターンの実を使用
・systemメッセージは常に先頭に置く
まず,履歴の最大ターン数を定義します.
以下のコードをapp = FastAPI()の下に書きます・
MAX_TURNS = 10
次に,historyをそのまま使うのではなく,必要な分だけを取り出してmessagesを作る関数を用意します.
以下のコードをsystem_messageの下に追加してください.
def build_messages():
msgs = [system_message]
turns = history[-MAX_TURNS * 2:]
msgs.extend(turns)
return msgs
ここで,×2をしているのは1ターンあたりuserとassistantの2メッセージがあるためです.
次に,chat関数を修正します.payloadを以下のように修正してください.
payload = {
"model": "llama3",
"messages": build_messages(),
"stream": False
}
これで,会話が長くなっても安定して動作し,応答速度が一定に保たれるようになりました.
タブ単位のセッション管理
今のままでは,すべてのユーザーで会話履歴が共有されたり,タブが違っても同じ会話になったりします.
なので,タブ単位で会話履歴を分離するように変更しましょう.
セッション管理の方針は次の通りです.
・クライアントがsession_idを生成
・リクエストごとにsession_idを送信
・サーバーはsession_idごとに履歴を保持
まず,session_idを受け取れるようにします.ChatRequestクラスを以下のように修正してください.
class ChatRequest(BaseModel):
session_id: str
message: str
次に,これまでのhistoryを廃止し,session_idごとに履歴を持つ辞書に変更します.
history = []をsessions = {}に置き換えてください.
それに伴い,build_messages関数,chat関数を以下のように修正します.
def build_messages(history):
msgs = [system_message]
turns = history[-MAX_TURNS * 2:]
msgs.extend(turns)
return msgs
@app.post("/chat")
def chat(req: ChatRequest):
if req.session_id not in sessions:
sessions[req.session_id] = []
history = sessions[req.session_id]
history.append(
{"role": "user", "content": req.message}
)
payload = {
"model": "llama3",
"messages": build_messages(history),
"stream": False
}
r = requests.post(
"http://localhost:11434/api/chat",
json=payload
)
reply = r.json()["message"]["content"]
history.append(
{"role": "assistant", "content": reply}
)
return {"reply": reply}
ブラウザ側でsession_idを生成し,送信するようにします.
<script>の先頭に以下のコードを追加してください.
let sessionId = sessionStorage.getItem("sessionId");
if (!sessionId) {
sessionId = crypto.randomUUID();
sessionStorage.setItem("sessionId", sessionId);
}
次にfetchのbodyを修正します.
body: JSON.stringify({
session_id: sessionId,
message: msg
})
一旦,動作確認をしましょう.
タブごとに違う会話が行えたら成功です.
ストリーミング応答
現時点では,LLMの応答がすべて生成されてから返ってきていました.これでは,応答が遅く感じたり,生成中か分からなかったりします.
そこで,生成されたトークンを逐次返すストリーミング応答を実装しましょう.
from fastapi.responses import StreamingResponse
import json
を追加してください.
次に,payloadの"stream": FalseをTrueに変更します.
payload = {
"model": "llama3",
"messages": build_messages(history),
"stream": True
}
次に,r = requests.postを以下のように修正します.
r = requests.post(
"http://localhost:11434/api/chat",
json=payload,
stream=True
)
次に,トークンを逐次返す関数を追加します.
chat関数内のさっき書いたコードの後に次の関数を追加してください.
def generate():
full_reply = ""
for line in r.iter_lines():
if not line:
continue
data = json.loads(line.decode("utf-8"))
if "message" in data:
token = data["message"]["content"]
full_reply += token
yield token
history.append(
{"role": "assistant", "content": full_reply}
)
chat関数の戻り値も変更しときましょう.return文を以下のように変更します.
return StreamingResponse(
generate(),
media_type="text/plain"
)
これでバックエンド側はストリーミング応答の実装が終わりました.
次は,フロントエンドです.
<script>を以下のように修正してください.
<script>
let sessionId = sessionStorage.getItem("sessionId");
if (!sessionId) {
sessionId = crypto.randomUUID();
sessionStorage.setItem("sessionId", sessionId);
}
async function send() {
const msg = document.getElementById("msg").value;
const log = document.getElementById("log");
log.textContent += "You: " + msg + "\nAI: ";
const res = await fetch("http://localhost:8000/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: sessionId,
message: msg
})
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
log.textContent += decoder.decode(value);
}
log.textContent += "\n\n";
document.getElementById("msg").value = "";
}
</script>
これらの実装でいらなくなったコードを削除しましょう.
reply = r.json()["message"]["content"]
history.append(
{"role": "assistant", "content": reply"}
)
これでストリーミング応答が実装出来ました.
最後にちょっとだけそれっぽくしましょう.
文章生成中に「generating...」と表示させます.
log.textContent += "You: " + msg + "\nAI: ";の下に次のコードを追加してください.
const loadingPos = log.textContent.length;
log.textContent += "generating...";
次に,const render = res.body.getReader()の前に以下のコードを追加してください.
log.textContent = log.textContent.slice(0, loadingPos);
これで簡易的な対応型AIが完成しました.
最後に
本当はLLMから自作したかったが,莫大な労力が必要そうだったので断念した.
今回は最低限の機能しか実装していないため,魔改造しやすくなっていると思います.