3
0

GoとOpenAI APIでシンプルなChat API作ってみた

Last updated at Posted at 2023-12-11

概要

タイトル通り、GoとOpenAIのAPIを使ってChat APIを作ってみようと思います。
Goも生成AIもど素人なので、ご指摘ご助言ありましたらいただけると非常にありがたいです。

なんちゃってハンズオンみたいな感じを目指して書いていきます。

使用技術

  • Go(Gin)
  • Docker
  • OpenAI

構成を考える

こんな感じで作っていこうと思います。

├── cmd
│   └── main.go
├── docker-compose.yml
├── Dockerfile
├── go.mod
├── go.sum
└── pkg
    ├── config
    │   └── config.go            // 環境変数担当
    ├── domain
    │   └── usecase
    │       └── chat_usecase.go   
    ├── infra
    │   └── openai
    │       ├── client.go     // OpenAIとの疎通担当
    │       └── model.go
    ├── controller
    │   └── chat_controller.go   // リクエスト処理担当
    └── usecase
        └── chat_usecase.go    // ロジック担当

目次

アプリケーションのセットアップなど基本的なところから順に書いていますが、手っ取り早くOpenAIとの接続部分が見たい方は 5. OpenAIと接続する をご覧ください

1. OpenAIのAPIキーを準備する
2. Goアプリケーションのセットアップ
3. Dockerの導入とホットリロード環境の構築
4. 環境変数を設定する
5. OpenAIと接続する
6. ロジックとコントローラーを実装する
7. 今まで作ってきたものを繋げる
8. 動作確認
9. 今回作ったもの

1. OpenAIのAPIキーを準備する

やること

  • OpenAIのAPIキーを取得

Open AIのアカウントを作成・ログイン

下記のサイトにアクセスしてアカウントを作成します(すでにOpenAIのアカウントを持っている方はログイン)

API Keyの発行

2023年12月現在のUIだと、サイドバーにある🔒アイコンからAPIキーのページにアクセスできます。
スクリーンショット 2023-12-02 21.12.39.png

Generate new secret keyボタンをクリックして、APIキーを発行します
※APIキーの作成には電話認証が必要です。
スクリーンショット 2023-12-02 21.14.49.png

任意の名前(無名でも作成できます)をつけて作成します。
作成に成功すると下記のようなモーダルが表示されるので、この時に表示されるキーをコピーし控えておきます。
※モーダル上にも記載がありますが、このモーダルを閉じると再度キーを閲覧することができません。忘れたり紛失したりした場合は再度作り直しが必要です。
スクリーンショット 2023-12-02 21.23.54.png

これでAPIキーの準備はOKです。

余談:APIの利用料金について

APIの利用料金は、主に使用する「トークン」の数に基づいています。
「トークン」は送信する単語数等をもとに計算されます。

OpenAIに課金登録していない場合でも、アカウント作成後5ドル分(2023年現在)の無料枠が付与されているので、特にお金を払わなくてもAPIを試しすことができます。

使用するAIのモデル等によってもトークン数の制限や料金モデルも異なりますので、詳しく知りたい方は下記をご覧ください

また、トークン数を計算しリアルタイムに表示するサイトもOpenAIから提供されているので、気になる方はこちらもどうぞ(この記事の最後に使ってみます)

2. Goアプリケーションのセットアップ

やること

  • Ginを使ってWebサーバーを立ち上げる

プロジェクトを初期化

$ cd openai-go-sample
$ go mod init ${モジュール名}

モジュール名の部分は自由に設定できますが、私はリポジトリのURLと一致させています。(github.com/ユーザー名/リポジトリ名

すると下記のように go.mod ファイルが作成されるかと思います。

module github.com/shunsukenagashima/openai-go-sample

go 1.21.3

go.mod についての詳細な説明は省きますが、依存関係の管理やバージョン管理を行なうためのファイルです。

Webサーバーを作る

まずはシンプルなWebサーバーをGinというフレームワークを使って構築していきます

Ginのインストール

$ go get github.com/gin-gonic/gin

シンプルなWebサーバーの構築

下記はアクセスすると Hello World を返すWebサーバーの一例です。

cmd/main.go
package main

import "github.com/gin-gonic/gin"

func main() {
	r := gin.Default()

	r.GET("/", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "Hello World",
		})
	})

	r.Run()
}

下記コマンドで実行してみましょう

$ go run cmd/main.go

こんな感じでログが出れば起動できているので、

[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default

localhost:8080にアクセスすると期待通りの結果がかえってきています。
スクリーンショット 2023-12-03 0.32.24.png

これで基本的なセットアップは完了です。

3. Dockerの導入とホットリロード環境の構築

ローカルではホットリロード環境(ファイルの更新が即時反映)を使用したいので、 air というOSSを使用します。

airのインストール

$ go install github.com/cosmtrek/air@latest

※他にも方法はありますが、go1.8以降はgo installを使うことが推奨されているようです(詳しくはGithub等をご確認ください)

airの初期化

airの実行には、.air.tomlという設定ファイルが必要ですが、下記コマンドで作成します

$ air init

※パスが未設定で上記コマンドが失敗する場合は、パスの設定が必要です(「Go パスを通す」などで検索すると出てくるかも)
とはいえ、dokcerでairを実行する分には設定は不要なので下記の設定ファイルをそのまま使用しても大丈夫です。

すると、下記のような設定ファイルが作成されます
※私の環境では cmdmain.goを配置しているので、その部分だけ修正しています。

.air.toml
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
  args_bin = []
  bin = "./tmp/main"
- cmd = "go build -o ./tmp/main ."
+ cmd = "go build -o ./tmp/main ./cmd"
  delay = 1000
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  exclude_file = []
  exclude_regex = ["_test.go"]
  exclude_unchanged = false
  follow_symlink = false
  full_bin = ""
  include_dir = []
  include_ext = ["go", "tpl", "tmpl", "html"]
  include_file = []
  kill_delay = "0s"
  log = "build-errors.log"
  poll = false
  poll_interval = 0
  post_cmd = []
  pre_cmd = []
  rerun = false
  rerun_delay = 500
  send_interrupt = false
  stop_on_error = false

[color]
  app = ""
  build = "yellow"
  main = "magenta"
  runner = "green"
  watcher = "cyan"

[log]
  main_only = false
  time = false

[misc]
  clean_on_exit = false

[screen]
  clear_on_rebuild = false
  keep_scroll = true

Dockerの設定

下記の通りDockerfileを作成します
※今回はローカルでの確認にとどめるためdev用のコンテナしか用意していません。

Dockerfile
FROM golang:1.21.3 as dev

WORKDIR /app

RUN go install github.com/cosmtrek/air@latest

CMD ["air"]

Docker Composeの設定もしておきましょう
先ほど作成したDockerfileのdevコンテナを指定しています。

docker-compose.yml
version: '3'

services:
  app:
    build:
      context: .
      args:
        - target=dev
    volumes:
      - .:/app
    ports:
      - 8080:8080

起動してみる

ここまできたら、Dockerから先ほど作成したWebサーバーが起動できるか確認してみます。

$ docker compose up

http://localhost:8080にアクセスして、先ほどと同じ画面が表示されればOKです。

4. 環境変数を設定する

APIキーなどのような情報を管理するための環境変数周りの設定をしていきます。
Goにおいては、標準ライブラリのosパッケージを使って管理することもできますが、今回はサードパーティライブラリのcaarlos0/envパッケージを使用したいと思います。

パッケージのインストール

2023年現在最新はv10になっているようなので、最新をインストールします。

$ go get github.com/caarlos0/env/v10

環境変数ファイルを用意してDockerに渡す

プロジェクトルートに.envファイルを用意して事前に準備しておいたAPIキーを設定しておきます。
AI_MODELは、名の通りどのAIモデルを使うかを指定します。gpt4など他のモデルの指定も可能です。

.env
OPEN_AI_KEY=YOUR_API_KEY
AI_MODEL=gpt-3.5-turbo-0613

docker-compose.ymlに環境変数ファイルを渡します

docker-compose.yml
version: '3'

services:
  app:
    build:
      context: .
      args:
        - target=dev
+   env_file
+     - .env 
    volumes:
      - .:/app
    ports:
      - 8080:8080

Goでcaarlos0/envを使用

下記のように設定して、型安全に環境変数を扱えるようにしておきます。

pkg/config/config.go
package config

import "github.com/caarlos0/env/v10"

type Config struct {
	OpenAIKey string `env:"OPEN_AI_KEY,required"`
	AIModel   string `env:"AI_MODEL,required"`
}

func New() (*Config, error) {
	c := &Config{}
	if err := env.Parse(c); err != nil {
		return nil, err
	}
	return c, nil
}

これで環境周りの設定は完了したので、次は実際にOpenAIと接続していきたいと思います。

5. OpenAIと接続する

いよいよ本番ですね。
下記のようなステップを踏んで実装を進めたいと思います。
(1) リクエスト・レスポンス内容の確認
(2) OpenAIと疎通するためのクライアントの作成


まずドキュメントを見て必要な情報を確認してみます。

エンドポイント

下記を利用します
POST https://api.openai.com/v1/chat/completions

リクエスト

Example Requestを見てみます

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-3.5-turbo",
    "messages": [
      {
        "role": "system",
        "content": "You are a helpful assistant."
      },
      {
        "role": "user",
        "content": "Hello!"
      }
    ]
  }'
\

必須情報は下記の二つ

messages (array)

roleとcontentを含んだ配列ですね。
contentがメッセージ、roleはそのメッセージの役割といったところでしょうか。
roleには4種類あるようで、

  • user
    ユーザーからの入力
  • system
    AIの設定(たとえば人格とか)
  • assistant
    AIからの回答
    ※AIからの返答を含んだこれまでの会話を送ることで、文脈を踏まえた回答が得られるようです
  • tool
    詳細不明でした。わかり次第追記したいと思います

を選ぶことができます。

今回は usersystemあたりを送って確認してみることにします。

model

どのモデルのAIを使用するのかを指定します

レスポンス

Example Responseを見ていきます

{
  "id": "chatcmpl-123",
  "object": "chat.completion",
  "created": 1677652288,
  "model": "gpt-3.5-turbo-0613",
  "system_fingerprint": "fp_44709d6fcb",
  "choices": [{
    "index": 0,
    "message": {
      "role": "assistant",
      "content": "\n\nHello there, how may I assist you today?",
    },
    "finish_reason": "stop"
  }],
  "usage": {
    "prompt_tokens": 9,
    "completion_tokens": 12,
    "total_tokens": 21
  }
}

choicesの中に、AIからの返答があるみたいですね。
usageの中には使用したトークン量が入っているようです。

上記を参考にしつつ、OpenAIと疎通するクライアントを作っていきます。

OpenAIクライアントを作成する

infra層にOpenAIのクライアントを作成します

モデルの定義

先ほど調べたリクエストとレスポンスを参考に下記のようにmodelを定義しました

pkg/infra/openai/model.go
package openai

type OpenAICommunicator interface {
	SendMessage(prompt []*Prompt) (*ChatResponse, error)
}

type Role string

const (
	System    Role = "system"
	User      Role = "user"
	Assistant Role = "assistant"
)

type Prompt struct {
	Role    Role   `json:"role"`
	Content string `json:"content"`
}

type openAIRequest struct {
	Model    string    `json:"model"`
	Messages []*Prompt `json:"messages"`
}

type openAIResponse struct {
	ID      string   `json:"id"`
	Choices []choice `json:"choices"`
	Usage   usage    `json:"usage"`
}

type choice struct {
	Index        int    `json:"index"`
	Message      Prompt `json:"message"`
	FinishReason string `json:"finish_reason"`
}

type usage struct {
	PromptTokens     int `json:"prompt_tokens"`
	CompletionTokens int `json:"completion_tokens"`
	TotalTokens      int `json:"total_tokens"`
}

type ChatResponse struct {
	Content string `json:"content"`
	Usage   usage  `json:"usage"`
}

AIからのレスポンスと使用したトークン量をChatResponseとして返すようにしておきます。

クライアントの作成

APIと疎通するためのクライアントを作っていきます

pkg/infra/openai/client.go
package openai

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
)

type OpenAIClient struct {
	apiKey     string
	model      string
	url        string
	httpClient *http.Client
}

func NewOpenAIClient(apiKey string, model string, httpClient *http.Client) OpenAICommunicator {
	if httpClient == nil {
		httpClient = &http.Client{}
	}
	return &OpenAIClient{
		apiKey:     apiKey,
		model:      model,
		url:        "https://api.openai.com/v1/chat/completions",
		httpClient: httpClient,
	}
}

apiKeymodelを指定して、Open AIのクライアントを作成し、使用するイメージです
urlも可変にしておいたほうがよさそうですが、今回はひとまずベタ書きしています。

では次に、実際にOpenAIのAPIにリクエストを送り、その返答を返すための関数を実装したいと思います。

SendMessage関数の実装

下記はAPIにリクエストを送り、レスポンスからメッセージを取り出して返却する関数の実装例です。

pkg/infra/openai/client.go
func (c *OpenAIClient) SendMessage(prompts []*Prompt) (*ChatResponse, error) {
	reqBody := &openAIRequest{
		Model:    c.model,
		Messages: prompts,
	}

	reqJSON, err := json.Marshal(reqBody)
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequest("POST", c.url, bytes.NewBuffer(reqJSON))
	if err != nil {
		return nil, err
	}

	req.Header.Set("Authorization", "Bearer "+c.apiKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, err
	}

	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return nil, err
	}

	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("API request failed with status %v: %s", resp.StatusCode, body)
	}

	var openAIResp openAIResponse
	if err := json.Unmarshal(body, &openAIResp); err != nil {
		return nil, err
	}

	if len(openAIResp.Choices) == 0 {
		return nil, fmt.Errorf("API response does not contain any choices")
	}

	chatResp := &ChatResponse{
		Content: openAIResp.Choices[0].Message.Content,
		Usage:   openAIResp.Usage,
	}

	return chatResp, nil
}

Example Requestにもあった通り、Authorizatonヘッダーにapiキーを付与して送信しています。

早速このクライアントを使ってロジック部分の実装を行なっていきます。

6. ロジックとコントローラーを実装する

ここでは下記の2を実装していきます

  • ロジックを管理するusecase層
  • リクエストを処理するcontroller層

usecaseの実装

まずはインタフェースを書いていきます。
といっても、SendMessage関数だけを持つシンプルなものです。

pkg/domain/usecase/chat_usecase.go
package usecase

import "github.com/shunsukenagashima/openai-go-sample/pkg/infra/openai"

type ChatUsecase interface {
	SendMessage(message string) (*openai.ChatResponse, error)
}

実装部分は下記の通りです。

pkg/usecase/chat_usecase.go
package usecase

import "github.com/shunsukenagashima/openai-go-sample/pkg/infra/openai"

type ChatUsecase struct {
	openaiClient openai.OpenAICommunicator
}

func NewChatUsecase(openaiClient openai.OpenAICommunicator) *ChatUsecase {
	return &ChatUsecase{
		openaiClient: openaiClient,
	}
}

func (u *ChatUsecase) SendMessage(message string) (*openai.ChatResponse, error) {
	userPrompt := &openai.Prompt{
		Role:    openai.User,
		Content: message,
	}

	systemPrompt := &openai.Prompt{
		Role:    openai.System,
		Content: "あなたは関西人です。コテコテの関西弁で話してください。",
	}

	var prompts []*openai.Prompt
	prompts = append(prompts, userPrompt)
	prompts = append(prompts, systemPrompt)

	resp, err := u.openaiClient.SendMessage(prompts)
	if err != nil {
		return nil, err
	}

	return resp, nil
}

ここでは2つのpromptを使ってAPIにリクエストを送信しています。

  • userPrompt
    user roleのプロンプトです。ユーザーが入力した内容をAIに送ります。

  • systemPrompt
    system roleのプロンプトです。AIには関西人になりきって返答してもらいます。

次はcontrollerを実装していきましょう

controllerの実装

リクエストのバリデーション

あまり関係ない部分なのでここまでやらんでも、、と思いつつ、まずリクエストのバリデーション用のライブラリを入れます。
github.com/go-playground/validator/v10というライブラリです。

go get github.com/go-playground/validator/v10

コントローラの実装

下記を行なっていきます。

  • リクエストのバリデーション
  • usecase層を使って処理を実行する
pkg/interface/controller/chat_controller.go
package controller

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"github.com/go-playground/validator/v10"
	"github.com/shunsukenagashima/openai-go-sample/pkg/domain/usecase"
)

type ChatController struct {
	chatUsecase usecase.ChatUsecase
	validator   *validator.Validate
}

func NewChatController(chatUsecase usecase.ChatUsecase, validator *validator.Validate) *ChatController {
	return &ChatController{
		chatUsecase,
		validator,
	}
}

func (cc *ChatController) SendMessage(ctx *gin.Context) {
	var req struct {
		Message string `json:"message"`
	}

	if err := ctx.BindJSON(&req); err != nil {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	if err := cc.validator.Struct(req); err != nil {
		ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	resp, err := cc.chatUsecase.SendMessage(req.Message)
	if err != nil {
		ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}

	ctx.JSON(http.StatusOK, gin.H{"message": resp.Content, "promptTokens": resp.Usage.PromptTokens})
}

あとはmain.goにエンドポイントを追加すれば完了です。

7. 今まで作ってきたものを繋げる

これまで実装してきたものを繋げて、/chatエンドポイントを作ります。

cmd/main.go
package main

import (
	"log"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
	"github.com/go-playground/validator/v10"
	"github.com/shunsukenagashima/openai-go-sample/pkg/config"
	"github.com/shunsukenagashima/openai-go-sample/pkg/infra/openai"
	"github.com/shunsukenagashima/openai-go-sample/pkg/interface/controller"
	"github.com/shunsukenagashima/openai-go-sample/pkg/usecase"
)

func main() {
	http := &http.Client{}

	cfg, err := config.New()
	if err != nil {
		log.Printf("failed to load config: %v", err)
		os.Exit(1)
	}

	log.Print(cfg)

	oc := openai.NewOpenAIClient(cfg.OpenAIKey, cfg.AIModel, http)
	v := validator.New()
	cu := usecase.NewChatUsecase(oc)
	cc := controller.NewChatController(cu, v)

	r := gin.Default()

	r.GET("/", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "Hello World",
		})
	})

	r.POST("/chat", cc.SendMessage)

	r.Run()
}

下記を行なっています。

  • config(環境変数)の読み込み
  • OpenAIクライアントの初期化
  • validatorの初期化
  • ChatUsecaseの初期化
  • ChatControllerの初期化
  • /chatエンドポイントの追加

これで実装は完了です。

8. 動作確認

Postmanを使って動作確認していきます。
※localhostへリクエストを送るためには、Postman Agentを別途インストールする必要があるようです。

スクリーンショット 2023-12-04 20.11.51.png

コテコテの関西人が「風に肩凝ってんねん」という表現を使うのかは定かではありませんが、しっかりと関西弁で返してくれてますね。

設定として渡した system prompt も動作していることが確認できます。

ちょっと技術的なことも聞いてみましょう。
スクリーンショット 2023-12-04 20.42.44.png

最初と最後だけ関西弁っぽくなってますが、中身は標準語になっちゃってますね。笑

ここら辺はもう少し system prompt で調整したりできるかもしれません。

とはいえ最低限の機能は備えているかと思いますので、ひとまず今回はこれで実装完了としたいと思います。

余談:トークン量と翻訳

日本語でメッセージを送ると、必然的に使用トークン量が多くなってしまうので、事前に英語に翻訳してから送るなどの対応が必須そうです。

今週の献立を英語と日本語で聞いてみると、、、
スクリーンショット 2023-12-04 20.54.05.png

スクリーンショット 2023-12-04 20.53.52.png

これだけ短い文章でも日本語で送ると倍のトークン量なので、長くなればなるほど顕著になりそうですね。

以上。余談でした。

9. 今回作ったもの

完成物のリンクを貼っておきます。

終わりに

作ってみると、意外と簡単に組み込めたなーという感想です。
GTPsの登場もあって、シンプルなチャットボットを作っても需要はなさそうですが、
画像生成AI等とも組み合わせつつエンタメ系の領域でなら結構面白いことができるんじゃないかなと思いました。

パッとみた限り今回使ったエンドポイント以外にも色々あるみたいなので、時間がある時にまたいろいろ触ってみたいと思います。

完全に趣味で触ってみた系の記事ですが、どなたかの参考になれば幸いです。
また、記事を読んでいただいてのご指摘ご助言も大歓迎です。

3
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
3
0