ChatGPTモドキを作ってみた - LangChain + React・Flask・SQLite + Docker -

OpenAI等の大規模言語モデルサービスを利用した開発をサポートするライブラリとしてLangChainがあります。今回はこのLangChainをベースに、WebサーバーとしてFlask・SQLite、チャット画面としてReactを使用して、Dockerで動作するAIと対話するチャットアプリ(ChatGPTモドキ)を作成しました。このアプリだけでは実用性は微妙ですが、LangChainをベースにしたアプリを作成する際のひな形に使えるのではないかと思っています。


# 概要

コードはこちらのリポジトリで公開しています。
- https://github.com/k8shiro/LangChainReactChatApp/tree/v0.0.1
    - 本記事投稿時点のコードはv0.0.1のタグになります


Docker環境があれば起動可能で、起動させると以下のようなチャットアプリになっています。

![LangChainChatApp.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/86684/24e32d61-a2ab-ea95-a275-2997455f13ef.gif)


機能としては、OpenAIのAPIによりAIと会話が可能で、コメントを投稿するとAIの回答が生成されます。またチャットルームを作成し、切り替えることで別の会話を始めることができます。Webアプリなので複数人でアクセスしても動作しますが、ユーザーを実装していないのでAIはユーザーを区別していなく、また、別の人のチャットも参照できてしまいます。会話はチャットルームごとにDBに保存され、AIは回答の生成に直前の会話を3つまで読み込みます。

なお、GIFは早送りされており、AIの回答には数分かかることがあります。

# 起動方法

- 1:  [OpenAI](https://openai.com/)のAPI Keyが必要です。[アカウントのAPI Keys](https://platform.openai.com/account/api-keys)から生成してください

コードではgpt-3.5-turboを使用しています。gpt-3.5-turboは比較的安価なモデルではありますがAPIの使用には課金が必要です。

- 2:  起動にはDockerが動く環境が必要になります

- 3:  リポジトリからコードをcloneしてください

記事執筆時点のコードがv0.0.1のタグになるためclone後にtagを切り替えてます。

```
git clone https://github.com/k8shiro/LangChainReactChatApp
cd LangChainReactChatApp/
git checkout -b v0.0.1 refs/tags/v0.0.1
```

- 4: 以下のコマンドでDockerで起動します

このとき環境変数`OPENAI_API_KEY`に1で取得したOpenAIのAPI Keyを設定する必要があります。

```bash
# python(Flask + LangChain)用コンテナのbuild
docker build -t lc-python -f python/Dockerfile .

# python(Flask + LangChain)用コンテナの起動
# OPENAI_API_KEYにOpenAIのAPI Keyを指定
docker run --rm -it -e OPENAI_API_KEY=${OPENAI_API_KEY} -v $(pwd)/python:/python -p 8080:8080 lc-python python app.py

# React用コンテナのbuild
docker build -t lc-react -f react/Dockerfile .

# React用コンテナのnpmパッケージをインストール(初回 or パッケージ更新時のみ必要)
docker run --rm -it -p 5173:5173 -v $(pwd)/react:/react lc-react bash -c "cd app && yarn install"

# React用コンテナの起動
# VITE_API_ENDPOINTにはpython(Flask + LangChain)用コンテナを指定
docker run --rm -it -p 5173:5173 -v $(pwd)/react:/react -e VITE_API_ENDPOINT=http://localhost:8080 lc-react bash -c "cd app && yarn dev --host 0.0.0.0"
```

- 5 ブラウザでhttp://localhost:5173にアクセスしてください
`docker run`時にAPIサーバーを`VITE_API_ENDPOINT=http://localhost:8080`で指定しています。もしlocalhost以外で起動するときはここも変更してください。


# コードの解説
以下のような構成になります。

![LangChainReactChatApp.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/86684/0e6143ac-0dae-6158-385a-55a571538043.png)

ここではコードの主要な機能として

- LangChain(AIによるメッセージ生成)
- Flask(WebSocket + REST APIサーバー)
- React(チャット画面)

のコードついて解説します。

## LangChain(AIによるメッセージ生成)
AI応答部分を`Assistant`クラスとして定義しています。処理中にAPI Keyの指定がありませんが`chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.7)`のモデル初期化処理実行時に環境変数`OPENAI_API_KEY`から読み込まれています。

`self.memory = ConversationBufferWindowMemory(k=3, return_messages=True)`でモデルが使用するメモリの定義を行っています。この`k=3`がAIの出力時に使用される過去の会話の数で、多くすると昔の会話を覚えて回答を生成してくれますが、api呼び出し時に消費されるtoken数が増えAPIの使用料が増加します。

AIには最初に以下のようなプロンプトを読み込ませています。このため現在の設定ではエンジニアっぽい回答をするはずです。ここを変えるだけでも回答の内容や方向性を変えられます。

```
       template = """
        以下は、人間とAIの会話です。
        AIはIT関連のベテランエンジニアで新人でもわかるように質問に答えます。
        AIは質問の答えを知らない場合、正直に「知らない」と答えます。
        """
```

```
from langchain.chat_models import ChatOpenAI
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory, ConversationBufferWindowMemory
from langchain.schema import messages_to_dict, messages_from_dict
from langchain.prompts.chat import (
    ChatPromptTemplate,
    MessagesPlaceholder, 
    SystemMessagePromptTemplate,
    HumanMessagePromptTemplate,
)
import json
from flask import current_app

from assistant.db import insert_message, get_messages_by_chatid

class Assistant:
    def __init__(self, chatid):
        self.chatid = chatid

        # ChatOpenAIモデルの初期化
        # 指定しなければ環境変数OPENAI_API_KEYからAPI Keyを読み込んでいる
        # model_nameとしてgpt-3.5-turboを使用
        # temperatureは出力のランダム性
        chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.7)

        # メモリを設定
        # k=3で過去の3個の会話を出力に使用
        # return_messagesは過去メッセージの取得用
        self.memory = ConversationBufferWindowMemory(k=3, return_messages=True)

        # DBから履歴をメモリに追加してから処理を開始
        history_messages = get_messages_by_chatid(self.chatid)
        if history_messages is not None:
            self.memory.chat_memory.messages = messages_from_dict(history_messages)

        # チャットのプロンプトを設定
        template = """
        以下は、人間とAIの会話です。
        AIはIT関連のベテランエンジニアで新人でもわかるように質問に答えます。
        AIは質問の答えを知らない場合、正直に「知らない」と答えます。
        """

        prompt = ChatPromptTemplate.from_messages([
            SystemMessagePromptTemplate.from_template(template),
            MessagesPlaceholder(variable_name="history"),
            HumanMessagePromptTemplate.from_template("{input}")
        ])

        # チェーンの初期し使用するLLMとメモリを渡す
        self.conversation = ConversationChain(llm=chat, memory=self.memory, prompt=prompt)

    # ユーザーのメッセージに対する応答を生成
    def generate_response(self, user_message):
        # AIによるメッセージ生成
        response = self.conversation.predict(input=user_message)

        # 履歴をDB保存
        history = self.memory.chat_memory
        history_messages = messages_to_dict(history.messages)
        insert_message(self.chatid, history_messages)

        # 回答に使用した過去のChat(log出力用)
        buffer = self.memory.load_memory_variables({})
        current_app.logger.info(f"load_memory_variables: ${buffer['history']}")

        return {
            'chatid': self.chatid,
            'type': 'ai',
            'message': response
        } 
```

## Flask(WebSocket + REST APIサーバー)
Flask + Flask-SocketIOを使用しています。チャット部分がWebSocketを使用し、それ以外のチャット履歴やチャットルームの取得はREST APIとして実装しています。AIのメッセージを生成しているのは`handle_message`の部分で`Assistant`クラスがユーザーのメッセージからAIのメッセージを生成します。

このコードではFlaskサーバーはCORS等の設定が開発環境用になっていることに注意してください。

```python
from flask import Flask, jsonify
from flask_socketio import SocketIO, send, emit, join_room, leave_room
from flask_cors import CORS
import os
import logging

from assistant.assistant import Assistant
from assistant.db import init_db, get_messages_by_chatid, get_all_chats, insert_chat, update_chat, get_chat_by_id, delete_chat


app = Flask(__name__)
CORS(app) # 開発環境用のCORS許可設定
app.config["SECRET_KEY"] = os.environ.get("FLASK_SECRET_KEY", "default_secret_key")
app.json.ensure_ascii = False
app.logger.setLevel(logging.INFO)
socketio = SocketIO(app, cors_allowed_origins="*")

# クライアントがサーバーに接続した際のイベントハンドラ
@socketio.on('connect')
def handle_connect():
    app.logger.info("connect")

# チャットルームに参加するイベントハンドラ
@socketio.on('join')
def join(msg):
    join_room(str(msg["room"]))
    app.logger.info(f'user join room: {msg["room"]}')

# チャットルームから退出するイベントハンドラ
@socketio.on('leave')
def join(msg):
    leave_room(str(msg["room"]))
    app.logger.info(f'user leave room: {msg["room"]}')

# ユーザーからのチャットメッセージを処理しAIのメッセージを生成するイベントハンドラ
@socketio.on('chatMessage')
def handle_message(msg):
    app.logger.info("user message: {}".format(msg))
    if msg is not None:
        if get_chat_by_id(msg['chatid']) == None:
            chatid = msg['chatid']
            app.logger.error(f'chatid is not exist: {chatid}')
            emit(
                'chatMessage',
                {
                    'chatid': chatid,
                    'type': 'ai',
                    'message': f'このチャット(id: {chatid})は存在しないようです。'
                },
                broadcast=True,
                room=str(msg["chatid"])
            )
            return
        emit('chatMessage', msg, broadcast=True, room=str(msg["chatid"]))

        # AI応答を生成し、クライアントにブロードキャスト
        assistant = Assistant(msg['chatid'])
        assistant_msg = assistant.generate_response(msg['message'])
        emit('chatMessage', assistant_msg, broadcast=True, room=str(msg["chatid"]))
        app.logger.info("assistant message: {}".format(assistant_msg))

# 特定のチャットルームの履歴を取得するエンドポイント
@app.route('/chat-history/<chatid>', methods=['GET'])
def get_chat_history(chatid):
    history = get_messages_by_chatid(chatid)
    if history is None:
        return jsonify([])
    else :
        history = [{
            'chatid': chatid,
            'type': h['type'],
            'message': h['data']['content']
        } for h in history]
        app.logger.info("history: {}".format(history))
        return jsonify(history)

# 特定のチャットルームの情報を取得するエンドポイント
@app.route('/chat/<chatid>', methods=['GET'])
def get_chat(chatid):
    chat = get_chat_by_id(chatid)
    app.logger.info("chat: {}".format(chat))
    return jsonify(chat)

# 特定のチャットルームを削除するエンドポイント
@app.route('/chat/<chatid>', methods=['DELETE'])
def del_chat(chatid):
    delete_chat(chatid)
    return jsonify({})

# 特定のチャットルーム一覧を取得するエンドポイント
@app.route('/chat', methods=['GET'])
def get_chat_list():
    chats = get_all_chats()
    app.logger.info(f'chat list: {chats}')
    return jsonify(chats)

# チャットルームを追加するエンドポイント
@app.route('/chat', methods=['POST'])
def add_chat():
    title = ''
    chatid = insert_chat(title)
    update_chat(chatid, f'チャット {chatid}')
    app.logger.info(f'add chat: {chatid}')
    return jsonify(chatid)

if __name__ == "__main__":
    app.logger.info("server start")
    init_db()
    socketio.run(app, host='0.0.0.0', port=8080, use_reloader=True, debug=True)
```

## React(チャット画面)
React + Typescriptで画面を作成しました。ReactのビルドツールはViteを使用しています。またUIコンポーネントは[Ant Design](https://ant.design/)を主に使用しています。内部の処理に関してはメッセージの送受信は`socket.io-client`を使用して`chatMessage`イベントを使って送受信しています。また、チャット部屋ごとにroomに分けているため別々にチャットができます。socket接続先の定義は別ファイル(socket.tsx)に実装されています。

- 参考
  - emitting-events: https://socket.io/docs/v3/emitting-events/
  - room: https://socket.io/docs/v3/rooms/

```react
import React, { useEffect, useState } from 'react';
import { Col, Row, Typography, Input, Avatar, Button, Divider, Space, Tag } from 'antd';
import { UserOutlined, RobotOutlined, PlusOutlined, MessageOutlined, DeleteOutlined, SendOutlined } from '@ant-design/icons';
import { socket } from './socket'; // socket接続先の定義
import './App.css';

const { Text } = Typography;

// メッセージの型
type Message = {
  chatid: number;
  type: string;
  message: string;
}

// チャット部屋の型
type Chat = {
  id: number;
  createdAt: number;
  title: string;
}

const App:React.FC = () => {
  const [chatId, setChatId] = useState<number | null>(null);
  const [chatTitle, setChatTitle] = useState<string>('');
  const [chats, setChats] = useState<Chat[]>([]);
  const [isConnected, setIsConnected] = useState(socket.connected);
  const [message, setMessage] = useState<Message | null>();
  const [messages, setMessages] = useState<Message[]>([]);

  // チャット履歴を取得する非同期関数
  const fetchMessages = async (): Promise<Message[]> => {
    try {
      const response = await fetch(`${import.meta.env.VITE_API_ENDPOINT}/chat-history/${chatId}`);
      if (!response.ok) {
        throw new Error('Failed to fetch chat history');
      }
      const data = await response.json() as Message[];
      return data;
    } catch (error) {
      console.error('Error fetching chat history:', error);
      throw error;
    }
  }

  // チャット部屋情報を取得する非同期関数
  const fetchChat = async (): Promise<Chat> => {
    try {
      const response = await fetch(`${import.meta.env.VITE_API_ENDPOINT}/chat/${chatId}`);
      if (!response.ok) {
        throw new Error('Failed to fetch chat history');
      }
      const data = await response.json() as Chat;
      return data;
    } catch (error) {
      console.error('Error fetching chat:', error);
      throw error;
    }
  }

  // チャット部屋を削除する非同期関数
  const deleteChat = async (deletedChatId: number) => {
    try {
      const response = await fetch(`${import.meta.env.VITE_API_ENDPOINT}/chat/${deletedChatId}`, {
        method: 'DELETE'
      });
      if (!response.ok) {
        throw new Error('Failed to delete chat');
      }
    } catch (error) {
      console.error('Error deleting chat:', error);
      throw error;
    }
  }

  // チャット部屋一覧を取得する非同期関数
  const fetchChats = async (): Promise<Chat[]> => {
    try {
      const response = await fetch(`${import.meta.env.VITE_API_ENDPOINT}/chat`);
      if (!response.ok) {
        throw new Error('Failed to fetch chats history');
      }
      const data = await response.json() as Chat[];
      return data;
    } catch (error) {
      console.error('Error fetching chats:', error);
      throw error;
    }
  }

  // 新しいチャット部屋を作成する非同期関数
  const addNewChat = async () => {
    try {
      const response = await fetch(`${import.meta.env.VITE_API_ENDPOINT}/chat`, {
        method: 'POST'
      });
      if (!response.ok) {
        throw new Error('Failed to add new chat');
      }
      const _chatid = await response.json() as number;
      setChatId(_chatid)
    } catch (error) {
      console.error('Error adding new chat:', error);
      throw error;
    }
  }

  // チャット部屋を移動した時にデータを取得
  useEffect(() => {
    const fetchData = async () => {
      try {
        if (chatId !== null){
          const _chat = await fetchChat();
          setChatTitle(_chat.title);
          socket.emit('join', { room: chatId });
        }
        const _chats = await fetchChats();
        setChats(_chats)
        const _messages = await fetchMessages();
        setMessages(_messages);
      } catch (error) {
        console.log("historyの取得に失敗しました。")
        console.log(error)
      }
    }

    fetchData();

    return () => {
      socket.emit('leave', { room: chatId });
    };
  }, [chatId]);

  // サーバーとのソケット通信関連の副作用を処理
  useEffect(() => {
    const onConnect = () => {
      setIsConnected(true);
      chatId && socket.emit('join', { room: chatId });
    }

    const onDisconnect = () => {
      setIsConnected(false);
    }

    const onMessageEvent = (value: Message)  => {
      setMessages(previous => [...previous, value]);
    }

    socket.on('connect', onConnect);
    socket.on('disconnect', onDisconnect);
    socket.on('chatMessage', onMessageEvent);

    return () => {
      socket.off('connect', onConnect);
      socket.off('disconnect', onDisconnect);
      socket.off('chatMessage', onMessageEvent);
    };
  }, []);

  // メッセージを送信
  const sendMessage = () => {
    socket.emit('chatMessage', message);
    setMessage(null);
  }

  return (
    <div>
      <Row wrap={false}>
        <Col flex="250px" className='menu'>
          <Button 
            className="menu-item"
            type="text"
            onClick={addNewChat}
          >
            <PlusOutlined /> 新しいチャットを追加
          </Button>
          <Divider />
          {chats.map((c, index) => (
            <div key={index} className="menu-item">
              <Button
                className="menu-chat-button"
                type="text"
                onClick={() => setChatId(c.id)}
              >
                <MessageOutlined /> {c.title}
              </Button>
              <Button
                type="text"
                icon={<DeleteOutlined/>}
                onClick={async () =>  {
                  await deleteChat(c.id);
                  const _chats = await fetchChats();
                  setChats(_chats)
                }}
              />
            </div>
          ))}
        </Col>
        <Col flex="auto" className='content'>
          {chatId === null ? (
            <div className={'history-text history-human'}>
              <Space>
                <Avatar className="avatar-ai" icon={<RobotOutlined />} />
                <Text>
                  {' チャットが選択されていません。\n左メニューからチャットを追加、または、チャットを選択してください。'}
                </Text>
              </Space>
            </div>
          ) : (<>
            <div className="content-chat">
              <div className="content-header">
                <h2>{chatTitle} (id: {chatId})</h2>
                {isConnected ? (
                  <Tag bordered={false} color="success">接続済</Tag>
                ) : (
                  <Tag bordered={false} color="warning">未接続</Tag>
                )}
              </div>
              {messages.map((msg, index) => (
                msg.type === 'ai' ? (
                  <div key={index} className={'history-text history-ai'}>
                    <Space><Avatar className="avatar-ai" icon={<RobotOutlined />} /> <Text>{msg.message}</Text></Space>
                  </div>
                ) : (
                  <div key={index} className={'history-text history-human'}>
                    <Space><Avatar className="avatar-human" icon={<UserOutlined />} /> <Text>{msg.message}</Text></Space>
                  </div>
                )
              ))}
            </div>
            <div className="content-input">
              <Row align="bottom">
                <Col flex="auto">
                  <Input.TextArea
                    value={message ?  message.message : ''}
                    onChange={(e) => setMessage({
                      chatid: chatId,
                      type: 'human',
                      message: e.target.value
                    })}
                  />
                </Col>
                <Col flex="20px">
                  <Button size="large" shape="circle" type="text" onClick={sendMessage} icon={<SendOutlined />}></Button>
                </Col>
              </Row>
            </div>
          </>)}
        </Col>
      </Row>
    </div>
  )
}

export default App

```

# その他
## LangChainの勉強に使ったもの
最初にこちらのYoutube動画をみてLangchainの概要を把握したあと

- 【2023/04/20】プロンプトエンジニアリングから始めるLangChain入門 
  - https://www.youtube.com/live/8FPgoCjoenI?si=VG0LLBsk2aHpbZpu

細かい使い方はこちらのHakky Handbookを参考にさせていただきました。

- https://book.st-hakky.com/docs/langcain-intro/

# 感想

LangChainは先人のいろいろな資料もあり簡単に使えたという印象です。むしろWebSocketによるチャット機能の実装のほうが面倒でした。ただ、複数人での会話ではなくAIとの一対一の会話でよかったのでWebSocketを使わずREST APIのみでAIの回答はPollingするような作りのほうがシンプルな作りになった気もします。今回は"まずはシンプルなAIとのチャットツールを"と思い開発したので、次はインデックスも使って機能拡張したいと思います。

ちなみに開発中はチャットの動作確認等で割とOpenAIのAPIを実行しましたが$0.10しかかかりませんでした。