0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

対話型AIを作ってみた

Posted at

はじめに

プログラマーであれば一度は,ChatGPTやGeminiなどの対話型AIを自作してみたいと思ったことはあるのではないでしょうか.
この記事では,対話型AIの基礎となる仕組みを理解しながら,OllamaとFastAPIを用いてローカル環境で動作する対話型AIを実装していきます.

使用環境/技術

・OS:Windows 11
・Ollama:Version 0.14.3
・Python:Version 3.13.5
・FastAPI
・Uvicorn
・Requests

目次

  1. Ollamaのインストール
  2. APIの実装
  3. Web UIの作成
  4. 会話履歴の保持
  5. 会話履歴の制限
  6. タブ単位のセッション管理
  7. ストリーミング応答

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ターンあたりuserassistantの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);
}

次にfetchbodyを修正します.

body: JSON.stringify({
    session_id: sessionId,
    message: msg
})

一旦,動作確認をしましょう.
タブごとに違う会話が行えたら成功です.

ストリーミング応答

現時点では,LLMの応答がすべて生成されてから返ってきていました.これでは,応答が遅く感じたり,生成中か分からなかったりします.
そこで,生成されたトークンを逐次返すストリーミング応答を実装しましょう.

from fastapi.responses import StreamingResponse
import json

を追加してください.
次に,payload"stream": FalseTrueに変更します.

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から自作したかったが,莫大な労力が必要そうだったので断念した.
今回は最低限の機能しか実装していないため,魔改造しやすくなっていると思います.

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?