🤖

45行でChatGPTもどきを作る

2024/12/20に公開

概要

OpenAIのAPIを利用してChatGPTもどきのチャットアプリケーションを作成します。画像の生成や、Web検索を使用した回答もできます。

(会話を記憶し、画像を生成し、天気を検索して回答している例)

完成イメージ

技術スタックとしては以下のものを使用しています

  • python
  • streamlit
  • openai
  • langchain

対象読者

  • pythonによるアプリケーション開発の経験があること
  • 多少の困難ではくじけない人

セットアップする

python 3.13で作りました。その他必要なライブラリはrequirements.txtを見てください。

requirements.txt
langchain==0.3.13
langchain-community==0.3.13
langchain-openai==0.2.14
langgraph==0.2.60
streamlit==1.41.1

私はvenvで仮想環境を作りたい派なので、以下のようにして必要なものをインストールしてます。

python -m venv venv
source venv/bin/activate
pip install -r requirements.txt

必要な環境変数を設定します。プロジェクトディレクトリ直下に.streamlit/secrets.tomlがあれば、streamlitが自動的に読みに行ってくれますので、諸々ここに記述します。
(LangChain、OpenAI、TavilyのAPIキーが必要になりますので、各サイトで取得してくる必要があります)

.streamlit/secrets.toml
LANGCHAIN_TRACING_V2 = "true"
LANGCHAIN_API_KEY = "YOUR_LANGCHAIN_API_KEY"
OPENAI_API_KEY = "YOUR_OPENAI_API_KEY"
TAVILY_API_KEY = "YOUR_TAVILY_API_KEY"

実装する

実装といっても1ファイルだけです。たった45行なので、解説は後回しにして、いきなり全てを載せます。

app.py
import streamlit as st
from langchain_community.tools.openai_dalle_image_generation import OpenAIDALLEImageGenerationTool
from langchain_community.tools.tavily_search import TavilySearchResults
from langchain_community.utilities.dalle_image_generator import DallEAPIWrapper
from langchain_core.messages import HumanMessage, AIMessage
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt import create_react_agent

config = {"configurable": {"thread_id": "abc123"}}


def yield_content(app_stream):
    for chunk, metadata in app_stream:
        if isinstance(chunk, AIMessage):
            yield chunk.content


if "app" not in st.session_state:
    model = ChatOpenAI(model="gpt-4o")
    tools = [
        OpenAIDALLEImageGenerationTool(api_wrapper=DallEAPIWrapper(model="dall-e-3")),
        TavilySearchResults(max_results=3),
    ]
    agent_executor = create_react_agent(model, tools, checkpointer=MemorySaver())
    st.session_state.app = agent_executor

if "messages" not in st.session_state:
    st.session_state.messages = []

st.title("Chat Example")

for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

if prompt := st.chat_input("What's up?"):
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.markdown(prompt)

    with st.chat_message("assistant"):
        stream = st.session_state.app.stream({"messages": [HumanMessage(prompt)]}, config, stream_mode="messages")
        content = st.write_stream(yield_content(stream))
        st.session_state.messages.append({"role": "assistant", "content": content})

動かす

streamlit run app.py

ブラウザが勝手に開いて、アプリが使用できるはずだ!

解説する

Streamlitって

まず、「Streamlitって何?」って人にとっては全く意味がわからないコードだと思います。Streamlitとは、Pythonコードだけで簡単にインタラクティブなWebアプリやダッシュボードを作れるツールです。特に、データ分析や機械学習の結果なんかを共有することに優れています。

Streamlit

このチャットアプリのUIはStreamlitで構築されています。通常のWebアプリのView層の開発とは異なったアプローチを取っているため、馴染のない人には「なんでこれでステートフルなアプリが構築できてんだ?」という印象を持たれるかもしれません。そこを理解するには、最低限Streamlitの基本コンセプトを理解する必要があります。

Data flow

Streamlit's architecture allows you to write apps the same way you write plain Python scripts. To unlock this, Streamlit apps have a unique data flow: any time something must be updated on the screen, Streamlit reruns your entire Python script from top to bottom.

はい、要するにユーザーが何かアクション起こすたびにソースの上から下まで再実行するよ、ってことです。なかなか大胆ですが、シンプルと言えばシンプルなアプローチですよね。ただ「状態に応じたUIを描画する」という意味ではreactやflutterと同じといえば同じですね。(それが関数ではなくファイルになっただけのこと)

そう思ってソースを眺めてみると、次の箇所はチャット用のモデルを初期化する処理ということになります。初期化はユーザーのアクションの度に呼ばれたら困るので、最初しか処理されないようになっています。streamlitのsession_stateを使用して、agent_executorというやつを保持しておきます。

if "app" not in st.session_state:
    model = ChatOpenAI(model="gpt-4o")
    tools = [
        OpenAIDALLEImageGenerationTool(api_wrapper=DallEAPIWrapper(model="dall-e-3")),
        TavilySearchResults(max_results=3),
    ]
    agent_executor = create_react_agent(model, tools, checkpointer=MemorySaver())
    st.session_state.app = agent_executor

これで2回目以降の処理時にはセッションに保持されたものが使用されることとなります。

agent? tools?

OpenAIのAPIのサンプルなんかで1回限りのチャット見たことあるかもしれません。でも履歴を記憶したり、画像生成したり、Web検索するとか、それどうやってやるの?と思ったかもしれません。それは、LangChainを使用すれば簡単に実現することができます。コードでいえばここ。

    tools = [
        OpenAIDALLEImageGenerationTool(api_wrapper=DallEAPIWrapper(model="dall-e-3")),
        TavilySearchResults(max_results=3),
    ]
    agent_executor = create_react_agent(model, tools, checkpointer=MemorySaver())

これはLangChainのAgentという機能でして、各種ツールを装備させた代理人を作り、そいつにチャットの回答をさせているイメージになります。

Agents

今回は、

  • MemorySaverでメッセージのやり取りを記憶させ
  • DALL-E3画像生成機能を装備させ
  • Tavily(という検索ツール)を装備させた

代理人をcreate_react_agentで生成しているということになります。たったこれだけのコードで、

  • ユーザーからのメッセージが画像生成の要求であれば >>> 自動的にDALL-E3呼び出す
  • 回答にWeb検索が必要そうであれば >>> 自動的にTavily検索を呼び出す

と、よきに計らってくれます。すげー便利だ・・・🥹

stream

アシスタントの回答トークンが生成されるたびに連続して画面出力されているのが分かるかと思いますが、これは回答をstreamで受け取って都度表示するようにしてます。streamlitにはwrite_streamという便利なメソッドがあるので、それを利用しています。

...
def yield_content(app_stream):
    for chunk, metadata in app_stream:
        if isinstance(chunk, AIMessage):
            yield chunk.content
...
    with st.chat_message("assistant"):
        stream = st.session_state.app.stream({"messages": [HumanMessage(prompt)]}, config, stream_mode="messages")
        content = st.write_stream(yield_content(stream))
        st.session_state.messages.append({"role": "assistant", "content": content})

回答全文を待ってから一気に出力、ということももちろんできるのですが、リアルタイム感があったほうが生き生きしますもんね。

以上です。これをベースにゴソゴソいじっていけば、結構面白いものもできそうな。

GitHub

レポジトリ作りました。
https://github.com/itmammoth/chat-example

参考文献

Discussion