Lean Baseball

No Engineering, No Baseball.

野球AIチャットが作りたくなったのでひとまず, バックエンドから作ってみた - FastAPIからOpenAIをいい感じに使う何か

※このエントリーは「OpenAIをいい感じに使うバックエンドをFastAPIで実装したぜ!」というエントリーです(サンプルコードはこちら), 「OpenAIで何かを作った・人工知能を産んだ」訳では無いのでそっち方面を期待している方はそっ閉じしたほうが良いかもしれません(Web API開発に興味ある人はそのまま読んで).


趣味は野球観戦と見せかけて, 「休日にダラダラ野球見ながら趣味のコードを書く」のが最も好きな人です.

100敗待ったなしの贔屓チームがいきなり7連勝したり*1, 昨年まで扇風機状態だった贔屓チームのフィジカルモンスターが突然覚醒して4番ライトに定着*2したりと理解が追いつかない野球を見るのはこれぐらい(コード書きながらみる)ぐらいがちょうどいいと思ってます, だってプレーオフ行けるか怪しいですもの*3.

時は遡り2020年, 私はセイバーメトリクスといくつかの機械学習の知見そしてエンジニアリングスキルを活用(もしくは盛大に無駄使い)を行い, 「成績予測の野球AI」を開発, PoC的に運用しています.

shinyorke.hatenablog.com

上記リンクは現時点の最新作です&続きは着手できてません(つらい)...のですが, 2023年現在, Chat GPTを中心とした生成AIというやつが出てきました.

ワイ「あなたは超優秀な野球データの分析者です. 万波中正がシーズンでホームランを40発放つのは何年後か教えてください」

AI「万波中正(北海道日本ハムファイターズ)は2026年にホームラン40本120打点を上げてMVPを獲得*4, このシーズンでセ・リーグの競合球団に移籍する予定です」

ワイ「」

※あくまでも例です, 実際にこういう答えを返した事実はありません*5

...というような, 「会話を介して(かっこよく言うと"インタラクティブに")野球の未来のあれやこれやに答えてくれる野球AIチャット」を作りたいと思い, 余暇の時間を使いながら少しずつ作っています.

そのためには,

「ひとまずチャットを受け取って返すRESTful APIが必要だろう(フロントエンドはその後でもいいや)」ってことで, FastAPIで作ってみました.

そんなチャットAPI(の初歩中の初歩)を作るための何かを以下のラインナップに従い紹介します.

対象範囲と読者

最初に宣言した通り,

OpenAIをいい感じに使うバックエンドをFastAPIで実装したぜ!

というエントリーになります.

基本的にはAIを使ったWeb API(RESTful API)を開発する話で, モデル構築や再学習(含むファインチューニング)の話は含んでおりません(のでMLやAIの知識はほぼ問いません).

また, 読者の皆さまにおかれましては,

  • PythonでWebアプリやAPIを作ったことある程度の経験値(DjangoでもFlaskでもなんでもいいです)
  • 理想を言えばOpenAPIが何者か知っている(OpenAIじゃなくて)

以上の知識・経験があることが望ましいです(Python初心者終わりかけ〜中級者入り口ぐらいのイメージ).

「実践Django」を読める・理解できるぐらいだとスラスラ読めると思います(このエントリーはDjangoの話じゃないですが*6).

TL;DR

OpenAI(Chat GPT)以外の生成AIが出てくることに備えて開発しておくと多分いい感じだと思う.

という設計思想の元やったよ!っていう話です.

今回作ったもの

今回作ったものは, メッセージを渡すとOpenAIのライブラリを呼び出し答えを返してくれる というかなり初歩的なAIチャットのバックエンドAPIです.

github.com

MIT Licenceの元公開しているので, Licenceが許す限りご自由に使っていただいて構いません.

ちなみにCloud Run(Google Cloud)でのホストはおまけ程度のものです, 何使ってホストしても良いと思います.

動かすとこんなやり取りができます(動かし方はREADME.mdを御覧ください).

# request

curl -X 'POST' \
  'http://0.0.0.0:8000/openai/chat' \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "messages": [
    {
      "index": 0,
      "role": "user",
      "content": "あなたは超優秀な野球データの分析者です. 万波中正がシーズンでホームランを40発放つのは何年後か教えてください"
    }
  ]
}'
# response

{
  "model": "gpt-3.5-turbo",
  "chat": {
    "size": 1,
    "messages": [
      {
        "index": 0,
        "role": "assistant",
        "content": "申し訳ございませんが、私はそれに対する十分な情報を持っていません。何らかのコンテキストが必要です。複数の要因が考慮される可能性があります。例えば、万波中正の運動能力やチームの状況、そして天候や球場の影響などが考えられます。それらに基づいてデータを分析し、結果を出すことができます。"
      }
    ]
  }
}

イメージが湧きましたでしょうか?

ちなみに答えらしい答えじゃないのは想定の範囲内です(何も教えていないから).

此処から先は,

  • FastAPIを使って作った理由
  • 設計で配慮したこと

をちょこっと紹介します.

なぜFastAPIなのか

(お察しの方も多いと思いますが)このレベルのAPIを作るなら好きなFW・好きな言語でエイッとやっちゃっていいです.

が, あえてFastAPIにした理由を書くと,

  • OpenAPIをいい感じに使えるから
  • 型安全(type safe)なPythonで書けるから

って所です.

OpenAPIをいい感じに使える

OpenAPIベースでのUI(開発用)が用意されている, 定義もすぐ出せるのでモックを作ったりするのが便利.

ちなみにUIはこんな感じです.

Open APIのUIからすぐ動かせるので便利

http://{url}/docs で上記の画面が出ます(ちなみにURLは変更可能&Production使用のときは無効化できます&サンプルAPIではこの辺のコードが該当.).

FastAPIのドキュメントにも記載がある通り,

  • (上記スクショの通り)すぐ動かせるUI付きのdocsがある
  • jsonの定義を出したりjsonスキーマからドキュメントを起こせる

といった機能を活用することで余計なものを用意しなくても開発できるのはいい感じです.

今回のサンプルぐらいのアプリだったらクライアントを自前で実装しなくても試せるので素晴らしく重宝しています.

型安全である

型安全に作ることでの開発者体験&ツール類のアシストに頼れる

これも公式ドキュメントで紹介あります.

型安全な作り方をすることで,

  • VSCodeやPyCharmといったIDEの機能(主にサジェスト)を全力で使える.
  • 関数や構造体的なクラスの型仕様が付くと仕様が見やすくなる(=テストが書きやすい)

あくまで開発時のアシストが目的であり, パフォーマンスにはなんにも寄与しない事*7なので誤解をしない様注意が必要ですが, ちゃんと書いておくことで綺麗で見やすいコードになる(保守にも有利)となるのでやっておいて損は無いです.

設計で配慮したこと

ここまではFastAPIの話でしたが, サンプルコードの設計上配慮したポイントにもちょっと触れておきます.

  • OpenAI以外の生成AI(例えばGoogleのBardhttps://bard.google.com/など)が増えても困らないコードにする
  • 生成AIのコンポーネント等を取り回すため, DI(Dependency Injection)を利用する

という配慮をしています.

OpenAI以外の生成AI爆誕に備える

各AIライブラリの実装はパッケージ(簡単に言うとディレクトリ)単位にまとめ, メインのアプリに差し込んでルーティングする.

コードのこの辺を見るとわかるのですが,

# 以下, main.pyの抜粋
from typing import Dict

from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
import uvicorn

from environments import DOCS_OPENAPI_URL
from ai import app as openai_app   # OpenAIのパッケージをimport

if DOCS_OPENAPI_URL:
    # change docs url
    app = FastAPI(docs_url=f"/{DOCS_OPENAPI_URL}")
else:
    # default docs url
    app = FastAPI()

# TODO Use Production(disable docs url)
# app =FastAPI(docs_url=None)

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        # TODO CORS URLS
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(openai_app.app, prefix="/openai", tags=["openai"])   # /openai から始まるpathはOpenAIのアプリに流す


@app.get("/")
def index() -> Dict[str, str]:
    # Use to Healthcheck endpoints.
    return {"status": "OK"}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

URLが2個しかない(/はヘルスチェック用のエントリーポイントなので実質的には1個)なAPIですが, 敢えて最初から分けています.

一見すると「冗長な事やってませんか」感あります(というか実際冗長だと思うんです)が, この設計はこちらのユースケースを考えたときに効力を発揮します.

OpenAIに加えてGoogleのBard使いたいわ(小声)

その時は多分こんな実装で終わります.

# 以下, main.pyにBardが増えた時の対応(イメージ)
from typing import Dict

from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
import uvicorn

from environments import DOCS_OPENAPI_URL
from ai import app as openai_app   # OpenAIのパッケージをimport
from bard import app as bard_app   # Bardのパッケージをimport

if DOCS_OPENAPI_URL:
    # change docs url
    app = FastAPI(docs_url=f"/{DOCS_OPENAPI_URL}")
else:
    # default docs url
    app = FastAPI()

# TODO Use Production(disable docs url)
# app =FastAPI(docs_url=None)

app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        # TODO CORS URLS
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(openai_app.app, prefix="/openai", tags=["openai"])   # /openai から始まるpathはOpenAIのアプリに流す
app.include_router(openai_app.app, prefix="/bard", tags=["google_bard"])   # /bard から始まるpathはBardのアプリに流す


@app.get("/")
def index() -> Dict[str, str]:
    # Use to Healthcheck endpoints.
    return {"status": "OK"}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

アプリのルートである, main.py への変更が最小限で済むのがわかるでしょうか?

これがやりたいのでわざわざ最初からルーティングしています.

fastapi.tiangolo.com

FastAPIのルーティングは上記リンクをご覧頂くとして,

  • 最初からルーティングすることでURLパスなどの設計がシュッときれいになる.
  • パッケージを分割できるということはテストコードなども分割できる(お作法的にパッケージ単位でテストを書く想定になるため)ので変なコンフリクトが起きにくくなる.
  • このサンプルは一人で開発しているのでアレですが, 2人以上のチーム開発でうまく役割を分けられる(バッティングするのがmain.pyなどの共通モジュールぐらいなので*8
  • 実運用上, コードを無効化したいときに楽になる. 例えば「OpenAIをやめにしたいわ」って時はapp.include_router(openai_app.app, prefix="/openai", tags=["openai"]) をコメントアウトするか行削除するだけで行える*9.

ちゃんとDIを使う

使いまわして良いもの(使い回すべきもの)は使い回す.

所謂「依存性の注入 (Dependency Injection, DI)」と呼ばれる設計パターンを使いましょう!という話です.

一般的なWebアプリだとDBの接続とかでよく使うと思います.

これについてはFastAPIではDependsが用意されているため, サクッと書いて使うことが出来ます.

fastapi.tiangolo.com

今回はOpenAIのオブジェクトを取り回すために使っています, この辺で.

from fastapi import APIRouter, Depends, HTTPException
from environments import OPENAI_MODEL, OPENAI_ORGANIZATION, OPENAI_API_KEY
from ai.chat import OpenAI, OpenAIException
from ai.interface import RequestForm, ResponseBody

app = APIRouter()


# ここでOpenAIクライアントのオブジェクトを作って取り回す
def _engine() -> OpenAI:
    """
    Chat Engine
    :return: OpenAI Chat Engine
    """
    return OpenAI(api_key=OPENAI_API_KEY, organization=OPENAI_ORGANIZATION, model=OPENAI_MODEL)


# OpenAI(engine)のオブジェクトは_engine()の戻り値を注入
@app.post("/chat")
async def chat(request: RequestForm, engine: OpenAI = Depends(_engine)) -> ResponseBody:
    try:
        messages = await engine.create(request.messages)
        response = ResponseBody(model=OPENAI_MODEL, chat=messages)
    except OpenAIException as e:
        raise HTTPException(status_code=500, detail=str(e))
    return response

個人的にはFastAPIのDependsはイメージしやすくて結構好きです.

今回は組み込んでいませんがasyncで使うこともできるので活用の幅は結構あります*10.

結び

というわけで,

OpenAIをいい感じに使うバックエンドをFastAPIで実装したぜ!」

という, シンプルなサンプルをコードとともに紹介させてもらいました.

ちなみにですが, 有識者の方が読むとおそらく,

  • この設計ってドメイン駆動って言いたいだけじゃね?
  • クリーンアーキテクチャ的には云々

みたいな意見も出てくると予想されますが,

  • ひとまずいい感じに動いて
  • 凝りすぎない(=真っ当にテストが書ける), シンプルさを目指して書いた結果
  • なんだかそれ(ドメインとかクリーンアーキテクチャ的な)言われ方をしそうなコードになった

ぐらいの認識です, 設計様式・思想も大事ですが継続して開発し続けることができるのが何よりも重要なので*11.

それはさておき, 「ひとまずいい感じに動く生成AIのバックエンドほしい」って方は勝手に持って行って活用して使ってもらえれば幸いです.

私が作りたいもの的には,

  • 何かしらの生成AIモデルをベースにファインチューニングする(かゼロからモデルを作る)事で野球を更に覚え込ませる
  • 何かしらのチャットシステムを作る. 手を抜くならSlack Botでもいいやぐらいの認識
  • いい感じにAIを育てて(ネタ的にも)使えるものにしたい

と思っているので, 「ああ今日も微妙な試合してるな我が贔屓チームは」って時に引き続き作っていきたいと思います.

エンジニアリングやプログラミングが好きな方が生成AIで何かを始める切っ掛けになってくれれば幸いです, 最後までお読み頂きありがとうございました.

Appendix

当エントリーの執筆およびサンプルコードの開発では以下のエントリー・ドキュメントを参考にさせていただきました.

dev.classmethod.jp

fastapi.tiangolo.com

github.com

*1:オークランド・アスレチックスの事. Shintaro Fujinamiがチーム最多勝というのもにわかに信じがたい(が事実).

*2:我が推し選手の万波中正(日本ハムファイターズ, 通称マンチュウ)の事. パテレの動画が増えててもう歓喜ですね(真顔).

*3:といいつつ, 日ハムはまだチャンスあるような気がする. 日ハム自身が大型連勝かつ上位3チームのどこかが落っこちてくれたら(ちなみにリーグ優勝は現実どうだろ説).

*4:余談ですが私のAIが予測した万波中正の結果はこちらにあります, ちなみに今年(2023)は23歳のシーズン.

*5:セ・リーグ云々はともかく, 「北海道を出ていく喜び」的なオチは流石に覚悟していますファンとして(何年後かわからんが)

*6:DjangoいやPythonに限らず, Web開発に関する必要知識がいい感じに言語化されているので読んだこと無い方は強くおすすめします, ちなみにこのブログにも感想エントリーあります.

*7:まどろっこしい書き方をしましたが一言で言うと「ちゃんとtypingで型を書いたからといって処理速度が上がる訳では無いよ」って話です.

*8:他のコードだとenvironment.py(環境変数を集約しているモジュール)もぶつかる候補にはなります.

*9:もっというとimport文も削除すべき(作法的にも&サンプルコードではimportのチェック等のlintもやってます)ですが, そこを無視していいならコメントアウトだけですみます(が良い子のみんなはちゃんとimport文も削除しよう)

*10:例えばクラウド系のAPIとかで非同期で返すかつ遅延評価して良いときなどは効力を発揮すると思います.

*11:これはポエム的なアレですが私自身設計アプローチや思想はめっちゃ大好きで本も読むし真似はするのですが, 最終的には「動くアプリケーションがあってそれが価値をだしているかつメンテしやすい」を追求するのが筋で, 思想云々はそれを実現する方法論と経験でしか無いと思っています(意訳・設計思想より大事なものもあります).