概要
タイトル通り、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キーのページにアクセスできます。
Generate new secret key
ボタンをクリックして、APIキーを発行します
※APIキーの作成には電話認証が必要です。
任意の名前(無名でも作成できます)をつけて作成します。
作成に成功すると下記のようなモーダルが表示されるので、この時に表示されるキーをコピーし控えておきます。
※モーダル上にも記載がありますが、このモーダルを閉じると再度キーを閲覧することができません。忘れたり紛失したりした場合は再度作り直しが必要です。
これで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サーバーの一例です。
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にアクセスすると期待通りの結果がかえってきています。
これで基本的なセットアップは完了です。
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を実行する分には設定は不要なので下記の設定ファイルをそのまま使用しても大丈夫です。
すると、下記のような設定ファイルが作成されます
※私の環境では cmd
に main.go
を配置しているので、その部分だけ修正しています。
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用のコンテナしか用意していません。
FROM golang:1.21.3 as dev
WORKDIR /app
RUN go install github.com/cosmtrek/air@latest
CMD ["air"]
Docker Composeの設定もしておきましょう
先ほど作成したDockerfileのdevコンテナを指定しています。
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など他のモデルの指定も可能です。
OPEN_AI_KEY=YOUR_API_KEY
AI_MODEL=gpt-3.5-turbo-0613
docker-compose.ymlに環境変数ファイルを渡します
version: '3'
services:
app:
build:
context: .
args:
- target=dev
+ env_file
+ - .env
volumes:
- .:/app
ports:
- 8080:8080
Goでcaarlos0/envを使用
下記のように設定して、型安全に環境変数を扱えるようにしておきます。
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
詳細不明でした。わかり次第追記したいと思います
を選ぶことができます。
今回は user
とsystem
あたりを送って確認してみることにします。
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を定義しました
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と疎通するためのクライアントを作っていきます
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,
}
}
apiKey
とmodel
を指定して、Open AIのクライアントを作成し、使用するイメージです
url
も可変にしておいたほうがよさそうですが、今回はひとまずベタ書きしています。
では次に、実際にOpenAIのAPIにリクエストを送り、その返答を返すための関数を実装したいと思います。
SendMessage関数の実装
下記はAPIにリクエストを送り、レスポンスからメッセージを取り出して返却する関数の実装例です。
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関数だけを持つシンプルなものです。
package usecase
import "github.com/shunsukenagashima/openai-go-sample/pkg/infra/openai"
type ChatUsecase interface {
SendMessage(message string) (*openai.ChatResponse, error)
}
実装部分は下記の通りです。
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層を使って処理を実行する
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
エンドポイントを作ります。
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を別途インストールする必要があるようです。
コテコテの関西人が「風に肩凝ってんねん」という表現を使うのかは定かではありませんが、しっかりと関西弁で返してくれてますね。
設定として渡した system prompt
も動作していることが確認できます。
最初と最後だけ関西弁っぽくなってますが、中身は標準語になっちゃってますね。笑
ここら辺はもう少し system prompt
で調整したりできるかもしれません。
とはいえ最低限の機能は備えているかと思いますので、ひとまず今回はこれで実装完了としたいと思います。
余談:トークン量と翻訳
日本語でメッセージを送ると、必然的に使用トークン量が多くなってしまうので、事前に英語に翻訳してから送るなどの対応が必須そうです。
これだけ短い文章でも日本語で送ると倍のトークン量なので、長くなればなるほど顕著になりそうですね。
以上。余談でした。
9. 今回作ったもの
完成物のリンクを貼っておきます。
終わりに
作ってみると、意外と簡単に組み込めたなーという感想です。
GTPsの登場もあって、シンプルなチャットボットを作っても需要はなさそうですが、
画像生成AI等とも組み合わせつつエンタメ系の領域でなら結構面白いことができるんじゃないかなと思いました。
パッとみた限り今回使ったエンドポイント以外にも色々あるみたいなので、時間がある時にまたいろいろ触ってみたいと思います。
完全に趣味で触ってみた系の記事ですが、どなたかの参考になれば幸いです。
また、記事を読んでいただいてのご指摘ご助言も大歓迎です。