本記事の概要

案件で触れることが少しあった、WebSocket(非同期通信)の仕組みを理解し、最近話題の生成AI絡みでアウトプットできないかと考え簡単な Chatbot を作成してみました。
※質は決して高くありませんので悪しからずご了承ください?‍♂️

WebSocketとは何か

一言で言うと、クライアントとサーバが双方向通信を行うために使用する通信の規格
★HTTP では、クライアント→サーバにリクエストを送り、サーバから応答を得るという単方向の通信しかできない。それに伴って、無駄な通信が増えてしまったりサーバーの負荷が重くなってしまうと言う課題が発生してしまう。
その短所を補うために WebSocket が生まれたようです。

★用途→リアルタイム性が重視される場面
株価情報の更新、チャットアプリケーション、オンライン対戦ゲームなど

★HTTP 通信と WebSocket 通信を図で整理
わかりやすさ重視なので、細かい部分は端折っています。
双方向通信の確立=WebSocket の接続が確立している間は都度都度リクエストを送らなくていい
これが1番の良さなのかなと考えられます。

Chatbot の作成

今回は以下のライブラリを使って「ChatGPT に質問を投げ、その回答をリアルタイムにブラウザに表示させる Chatbot」を作成します。

fastapi→Pythonのフレームワーク
uvicorn→バックエンド側のASGIサーバ(非同期処理が実現できるサーバ)
asyncio→Pythonで並行処理(≒非同期処理)のコードを書くためのライブラリ
openai→openAIを利用するためのライブラリ
※前提
・pip install でuvicorn ,fastapi, asyncio, openaiをインストール済
・OpenAIのAPIキーを取得済み

実際のコードと処理概要

【バックエンド】# websocket.py

import uvicorn
from fastapi import FastAPI, WebSocket
import asyncio
import openai

openai.api_key = "XXXXX(OpenAIのAPIキー)"
app = FastAPI()

# 死活監視用
@app.get("/")
def get_start():
    return {'msg':'OK'}

 # ChatGPTのレスポンスを生成
async def stream_generator(prompt: str):
    response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        stream= True,
        messages=[
                {"role": "user", "content": prompt},
                {"role": "system", "content": "関西弁で話して"}
            ]
    )
    for i, item in enumerate(response):
        try:
            content = item['choices'][0]['delta']['content']
        except:
            content = ""
        yield str(content).encode()
        await asyncio.sleep(0.01)


@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    text = await websocket.receive_text()
    async for data in stream_generator(text):
        await websocket.send_text(data.decode())

if __name__ == '__main__':
    uvicorn.run(app=app)

★OpenAi の Chat Completions API の使い方を参考に(こちらを参照)ChatGPT からのレスポンスを生成
messages の配列の中で、

{"role":"system", "content":XXX}

と指定することで、回答の振る舞い方を変更することが可能です。今回は関西弁で回答を生成するようにしました。(最初は私の地元愛知の尾張弁を指定してみようと思いましたが、マイナーな方言は ChatGPT には受け入れられず、断念しました?)

★ChatGPT からのレスポンスは下記のような形で返ってくるため、(OpenAI API reference 参照)回答を取り出すためにキーの指定が必要、かつメッセージ chunk の一つ一つを取り出すために for 文で回す処理が必要です。

{"id":"chatcmpl-123",
"object":"chat.completion.chunk",
"created":1694268190,
"model":"gpt-3.5-turbo-0613", 
"system_fingerprint": "fp_44709d6fcb", 
"choices":[{"index":0,"delta":{"content":"おはよう"},"finish_reason":null}]}

・・・・中略
{"id":"chatcmpl-123",
"object":"chat.completion.chunk",
"created":1694268190,
"model":"gpt-3.5-turbo-0613", 
"system_fingerprint": "fp_44709d6fcb", 
"choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]}

★下記コードで WebSocket サーバにデータを送る間隔を調節しています。
→0.01秒だけ処理を停止し、他の非同期タスクに処理(=次のメッセージの chunk の取り出し)をしてもらいます。ここの秒数がブラウザに表示される速さに関係してきます。

await asyncio.sleep(0.01)

★下記コードでは、クライアントからのメッセージを受信し、それを stream_generator 関数の引数にとることで、ChatGPT への質問の回答を非同期的に取得しています。
それを最終的には send_text(data.decode()) でクライアントに送信しています。

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    text = await websocket.receive_text()
    async for data in stream_generator(text):
        await websocket.send_text(data.decode())

【フロントエンド】# index.html

html>
<html>
  <head>
    <script>
      document.addEventListener("DOMContentLoaded", () => {
        const output = document.getElementById("output");
        const input = document.getElementById("input");
        const button = document.getElementById("send_button");
        let socket;

        // WebSocket serverにメッセージを送る
        button.addEventListener("click", () => {
          if (!socket || socket.readyState !== WebSocket.OPEN) {
            socket = new WebSocket("ws://127.0.0.1:8080/ws");
            socket.addEventListener("open", (event) => {
              output.innerHTML += "

Connected to WebSocket server.

"
; button.textContent = "Send"; }); socket.addEventListener("message", (event) => { output.innerHTML += event.data; }); socket.addEventListener("close", (event) => { output.innerHTML += "

Disconnected from WebSocket server.

"
; button.textContent = "Connect"; }); } else { const message = input.value; if (message) { socket.send(message); input.value = ""; } } }); // ウィンドウが閉じられた時、 WebSocket の接続を切る window.addEventListener("beforeunload", () => { socket.close(); }); });
script> head> <body> <h1> Chatboth1> <textarea id="input" type="text" placeholder="質問を入力してください" style="width: 500px; height: 100px">textarea> <button id="send_button">Connectbutton> <div id="output">div> body> html>

★HTML が読み込まれたときに実行される関数を登録

 document.addEventListener("DOMContentLoaded" () => {

★getElementById メソッドで対応する DOM 要素を取得

const output = document.getElementById("output");
const input = document.getElementById("input");
const button = document.getElementById("send_button");
let socket;

★サーバとの接続が開始(Connect ボタンがクリック)されたら”Connected to WebSocket server”,切断されたら”Disconnected from WebSocket server.”が表示される
メッセージ送信者からのメッセージが受信されたら、その内容を表示させる

        button.addEventListener("click", () => {
          if (!socket || socket.readyState !== WebSocket.OPEN) {
            socket = new WebSocket("ws://127.0.0.1:8080/ws");
            socket.addEventListener("open", (event) => {
              output.innerHTML += "

Connected to WebSocket server.

"; button.textContent = "Send"; }); socket.addEventListener("message", (event) => { output.innerHTML += event.data; }); socket.addEventListener("close", (event) => { output.innerHTML += "

Disconnected from WebSocket server.

"; button.textContent = "Connect";

→このままだと UI がもの寂しいので↓

ChatGPT に改良をお願いしました。↓

ChatGPT ってすごいな。。。
変更後のコードはこちらです。

#変更後 index.html

html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Chatbottitle>
    <style>
      body {
        font-family: 'Arial', sans-serif;
        background-color: #F2F2F2;
        margin: 0;
        padding: 0;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        height: 100vh;
      }
      h1 {
        color: #333;
      }
      #chat-container {
        width: 80%;
        max-width: 600px;
        margin: 20px;
        padding: 20px;
        border: 1px solid #ddd;
        border-radius: 8px;
        background-color: #fff;
        box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
        display: flex;
        flex-direction: column;
      }
      #output {
        flex: 1;
        overflow-y: auto;
        padding: 10px;
        border-bottom: 1px solid #ddd;
      }
      #input {
        margin-top: 10px;
        padding: 10px;
        width: calc(100% - 20px);
        border: 1px solid #ddd;
        border-radius: 4px;
        resize: none;
        font-size: 14px;
      }
      #send_button {
        margin-top: 10px;
        padding: 10px;
        background-color: #4CAF50;
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-size: 14px;
        transition: background-color 0.3s ease;
      }
      #send_button:hover {
        background-color: #45A049;
      }
    style>
  head>
  <body>
    <div id="chat-container">
      <h1>Simple Chatboth1>
      <div id="output">div>
      <textarea id="input" placeholder="Enter a question">textarea>
      <button id="send_button">Sendbutton>
    div>
    <script>
      document.addEventListener("DOMContentLoaded", () => {
        const output = document.getElementById("output");
        const input = document.getElementById("input");
        const button = document.getElementById("send_button");
        const chatContainer = document.getElementById("chat-container");
        let socket;
        // Send message to WebSocket server
        button.addEventListener("click", () => {
          if (!socket || socket.readyState !== WebSocket.OPEN) {
            socket = new WebSocket("ws://127.0.0.1:8080/ws");
            socket.addEventListener("open", (event) => {
              output.innerHTML += "

Connected to WebSocket server.

"
; button.textContent = "Send"; }); socket.addEventListener("message", (event) => { output.innerHTML += `${event.data}`; chatContainer.scrollTop = chatContainer.scrollHeight; // Scroll to bottom }); socket.addEventListener("close", (event) => { output.innerHTML += "

Disconnected from WebSocket server.

"
; button.textContent = "Connect"; }); } else { const message = input.value; if (message) { socket.send(message); input.value = ""; } } }); // Close WebSocket connection on window close window.addEventListener("beforeunload", () => { if (socket) { socket.close(); } }); });
script> body> html>

実行手順

①下記コマンドでサーバーを起動

uvicorn websocket:app --reload --port=8080 --host=127.0.0.1

※実行すると

Uvicorn running on http://127.0.0.1:8080(Press CTRL+C to quit)

という表示が出る

→127.0.0.1:8080/docs でアクセスすると Swagger にアクセスできるので API の疎通確認をしてみます。
→確かに {“msg” : “OK”}というレスポンスが返ってきてサーバーが起動できていることが確認できました。

◎サーバーを起動すると自動的に Swagger が生成されるのが FastAPI の利便性とも言えますね。

②index.html を開き、connect ボタンを押下
→ここで websocket との接続が確立します。

③質問内容を入力して、Send ボタンを押下
→しばらく待つと、回答がリアルタイムに表示されることが確認できます。

「健康に良い食べ物を教えて」という質問に対して、リアルタイムに回答を返してくれていることが分かります。関西弁でちゃんと回答も返ってきているようです?(画質がやや荒く申し訳ありません?‍♂️)

回答としては、

まあ、健康にええ食べ物ってやっぱり野菜やね。特に緑黄色野菜や昆布、海藻類は栄養満点やで。お肉も焼くより蒸して食べる方が脂分が減ってええで。あとは玄米や全粒穀物、豆類もいいで。それに、たんぱく質を摂るために魚や豆腐もオススメやね。やっぱりバランスのいい食事が大事やわ!以上やで。

という回答が返ってきています。

Chatbot の仕組みを図にするとこんな感じになるかと思われます。(雑ですみません)

最後に

Websocket(非同期通信)の仕組みについて、イメージが湧く手助けになりましたら幸いです。
非同期通信を使った実装、フロント側の実装など、まだまだ知識・経験が浅く力不足なことは多いですが、普段扱っていない技術に関してもどんどん手を出していき、アウトプットすることを通して少しずつ知識を深められたらと思っています!

補足

WSGI サーバ、AGSI サーバについて気になったので調べてみました。
・WSGI(Web Server Gateway Interface)サーバ
Python ウェブアプリケーションとウェブサーバーの間の標準的なインターフェース。
同期的なインターフェースであり、リクエストを逐次処理。
Python の多くのフレームワーク(Django,Flask など)はこちらに準拠している

・AGSI(Asynchronous Server Gateway Interface)サーバ
Pythonの非同期Webアプリケーションを開発および実行するためのインターフェース

参考文献

[MDN Web Docs コミュニティー]WebSocket API (WebSockets)
https://developer.mozilla.org/ja/docs/Web/API/WebSockets_API
今さら聞けないWebSocket~WebSocketとは~
https://qiita.com/chihiro/items/9d280704c6eff8603389
ストリーミング処理を利用して、ChatGPTライクにつらつらと語る超シンプルChatBot構築
https://qiita.com/yufuji25/items/1df005ca8d139845860d
[Uvicorn]
https://www.uvicorn.org/
【OSS情報】Python用ASGI Webサーバ実装「 Uvicorn 」
https://majisemi.com/topics/oss/4004/
asyncio — 非同期 I/O
https://docs.python.org/ja/3/library/asyncio.html
【FastAPI】UvicornとGunicorn、WSGIとASGI、ワーカープロセスについて実施コマンドと共に解説
https://qiita.com/Ryo-0131/items/1d1df96aca61d1873de8
Text generation models
https://platform.openai.com/docs/guides/text-generation