Closed14

【Go】ChatGPTのAPIを動かしてアプリの名付けツールを作るまで

NanaoNanao

はじめに

これはGoからChatGPT API(Chat Completions API)を使う方法とその応用としてアプリの名付けツールを作るまでの過程です。
これからGoやChatGPT APIを学習する方の助けになれば幸いです。

アプリ名付けツールのソースコードは僕のGithubリポジトリで公開しています。
ライセンスの範囲でご自由にお使いください。

https://github.com/7oh2020/quick-name-gen

NanaoNanao

APIキーの取得

ChatGPT APIを使うにはOpen AIにログインしてAPIキーを取得する必要があります。
このキーはトークンの消費と紐づいているので他人に公開しないように気をつけてください。

以下のリンクを開いてOpen AIにログインします。
「create new secret key」をクリックすると50文字程度のAPIキーが表示されるのでコピーして大事に保管してください。

https://platform.openai.com/account/api-keys

NanaoNanao

一回きりの質問をする

今回作るツールは文脈を必要としないのでまずは一回きりの質問をAPIに送信して返答を取得する機能を作成します。

NanaoNanao

リクエストの型を作成する

ChatGPT APIのエンドポイントにはJSONデータをPOSTします。
なのでJSONデータと対応するStructを作成します。

MessageのRoleはメッセージの役割を表しており、基本的にユーザーの質問は「user」、チャットの返答は「assistant」になります。

/src/chat/request.go
package chat

// チャットの送信データ
type Request struct {
	Model    string            `json:"model"`
	Messages []*RequestMessage `json:"messages"`

	// 最大トークン
	MaxTokens int `json:"max_tokens"`
}

func NewRequest(modelID string, messages []*RequestMessage, maxTokens int) *Request {
	return &Request{
		Model:     modelID,
		Messages:  messages,
		MaxTokens: maxTokens,
	}
}

// チャットの送信メッセージ
type RequestMessage struct {
	// メッセージの役割(assistant, user, systemのどれか)
	Role string `json:"role"`

	// メッセージの本文
	Content string `json:"content"`
}

func NewRequestMessage(role string, content string) *RequestMessage {
	return &RequestMessage{
		Role:    role,
		Content: content,
	}
}

NanaoNanao

レスポンスの型を作成する

レスポンスも同様にJSONデータです。
なのでJSONデータと対応するStructを作成します。

Usageはトークンの使用量を表しています。
2023/03現在は1000トークンあたり$0.002です。
見積もりが簡単になるので使った分が数値で把握できるのは嬉しいですね。

/src/chat/response.go
package chat

// チャットの受信データ
type Response struct {
	ID      string    `json:"id"`
	Object  string    `json:"object"`
	Created int       `json:"created"`
	Model   string    `json:"model"`
	Usage   *Usage    `json:"usage"`
	Choices []*Choice `json:"choices"`
}

// APIの使用量
type Usage struct {
	// 入力データのトークン
	PromptTokens int `json:"prompt_tokens"`

	// 出力データのトークン
	CompletionTokens int `json:"completion_tokens"`

	// 合計トークン
	TotalTokens int `json:"total_tokens"`
}

type Choice struct {
	// 受信メッセージ
	Message *ResponseMessage `json:"message"`

	// リクエストが異常終了した場合の理由(正常終了の場合は空文字)
	FinishReason string `json:"finish_reason"`

	// トークン化されたインデックス
	Index int `json:"index"`
}

// チャットの受信メッセージ
type ResponseMessage struct {
	// メッセージの役割(assistant, user, systemのどれか)
	Role string `json:"role"`

	// メッセージの本文
	Content string `json:"content"`
}

NanaoNanao

APIにメッセージを送信するクライアントを作成する

Goのhttpパッケージを使用してAPIにリクエストするクライアントを作成します。
タイムアウトは必須ではありませんが設定しておくとエラー時にのリソース節約になるのでおすすめです。

/src/chat/chat_completions.go
package chat

import (
	"bytes"
	"encoding/json"
	"errors"
	"time"

	"io"
	"net/http"
)

type ChatCompletions struct {
	// HTTPリクエストのタイムアウト
	timeout time.Duration

	// 応答の最大トークン
	maxTokens int

	// チャットに使用するモデルのID
	model string

	// APIキー
	secret string
}

func NewChatCompletions(model string, secret string, maxTokens int, timeout time.Duration) *ChatCompletions {
	return &ChatCompletions{
		maxTokens: maxTokens,
		model:     model,
		secret:    secret,
		timeout:   timeout,
	}
}

// APIにメッセージを送信する
func (c ChatCompletions) SendMessage(messages []*RequestMessage) (*Response, error) {
	data, err := json.Marshal(NewRequest(c.model, messages, c.maxTokens))
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequest(http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader(data))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+c.secret)

	client := &http.Client{
		// リソース節約のためにタイムアウトを設定する
		Timeout: 20 * time.Second,
	}
	resp, err := client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != 200 {
		return nil, errors.New("bad status: " + resp.Status)
	}

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

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

// 一回きりの質問をする
func (c ChatCompletions) AskOneQuestion(content string) (*Response, error) {
	messages := []*RequestMessage{
		NewRequestMessage("user", content),
	}
	return c.SendMessage(messages)
}

NanaoNanao

動作確認

ここでいったん動作を確認してみます。
以下は一回きりのメッセージを送信して結果を表示するmain関数の例です。
予めOpen AIのAPIキーを「OPEN_AI_SECRET」という名前の環境変数に設定しておいてください。

/src/cmd/test/main.go
package main

import (
	"fmt"
	"gptapi-example/src/chat"
	"os"
	"time"
)

func main() {
	// コマンドライン引数から質問テキストを取得する
	if len(os.Args) < 2 {
		panic("too few arguments")
	}
	content := os.Args[1]

	// 環境変数からAPIキーを取得する
	secret, ok := os.LookupEnv("OPEN_AI_SECRET")
	if !ok {
		panic("open-api-secret is empty")
	}

	// リソース節約のためにタイムアウトを設定する
	timeout := 15 * time.Second

	// トークン節約のために応答の最大トークンを設定する
	maxTokens := 500

	// チャットに使用するモデルのID
	modelID := "gpt-3.5-turbo"

	c := chat.NewChatCompletions(modelID, secret, maxTokens, timeout)
	res, err := c.AskOneQuestion(content)
	if err != nil {
		fmt.Println(err.Error())
		return
	}

	fmt.Printf("In %d / Out %d / Total %d tokens\n", res.Usage.PromptTokens, res.Usage.CompletionTokens, res.Usage.TotalTokens)
	for _, v := range res.Choices {
		fmt.Printf("[%s]: %s\n", v.Message.Role, v.Message.Content)
	}

}

このmain.goを実行してみます。プロンプトはコマンドライン引数で指定します。

go run ./src/cmd/test/main.go "ディーゼルエンジンの仕組みを教えてください"

実行結果は以下の通りです。

In 28 / Out 309 / Total 337 tokens
[assistant]: 

ディーゼルエンジンの仕組みは以下の通りです。

1. 空気の吸入:エンジンに空気を送り込みます。

2. 圧縮:ピストンが上がり、空気が高い圧力で縮まります。このとき、気温が非常に高くなります。

3. 燃料噴射:燃料が高圧で噴射されます。ディーゼルエンジンでは、燃料が空気に混ざって自己着火することで爆発的な燃焼が起こります。

4. 燃焼:燃焼することで、ピストンが下がります。

5. 排気:燃焼で発生したガスは排気バルブから外に放出されます。

このサイクルが繰り返されることで、ディーゼルエンジンは動きます。ディーゼルエンジンは大型車や船舶、発電機などに広く使用されています。

いい感じですね!
これだけでも汎用ツールとして使えそうですが、目的に特化させて便利ツールとして完成させましょう。

NanaoNanao

トークンを節約する方法

既にお気づきかもしれませんが、リクエストにmax_tokensというパラメータがあります。
これを指定すると応答メッセージのトークンを制限できます。
先程のコードのmax_tokensを50に変更して再実行してみましょう。

In 28 / Out 50 / Total 78 tokens
[assistant]: 

ディーゼルエンジンは、内燃機関の一種で、燃料と空気を混合させ、圧縮して爆発

出力トークンが50になっておりメッセージも途中で切れていることが分かりますね。

このようにして予想外に長い返答を防ぎトークンを節約できます。
極端に小さい値では末尾が切れてしまいますが、適切に設定すればなるべくそれに収まるように返答を作ってくれます。

NanaoNanao

アプリの命名ツールを作る

ここからが本題です。
先程のコードを応用してアプリの名付けツールを作成します。

要望としては以下の通りです:

  • 3つの質問に答えるだけでアプリ名の候補を考えてくれる
  • 質問1: 誰のためのアプリか
  • 質問2: 何を与えるアプリか
  • 質問3: どのように与えるアプリか
  • 名前の候補は10個
  • 名付けの理由も教えてほしい
NanaoNanao

プロンプトを作る

目的に沿ったツールとして特化させるにはここが一番たいへんかもしれません。
ブラウザ版ChatGPTを使って最適なプロンプトを作ります。

ChatGPTは目的を明確にすることで返答の精度が上がることが知られています。
Who(誰に),What(何を),How(どのように)を入力としてそれに最適なネーミングを考えてもらうにはどん
なプロンプトが良いでしょうか?
実はChatGPTはマークダウンを解釈できるので以下のようにそのまま質問すればそのとおりに解釈してくれます。

以下はそのプロンプトの一例です。

あなたは優秀なソフトウェアエンジニアです。
以下のWho,What,Howからなるアプリに最適なネーミングを数字つきリストで10個考えてください。
それぞれ理由も明記してください。
既存のアプリ名ではなく今までにないアプリ名を考えてください。

# Who

ソフトウェア開発者

# What

アプリの命名ツール

# How

3つの質問で簡単にアプリ名を生成できる

ブラウザ版ChatGPTで質問すると10個のアプリ名候補とその理由が得られると思います。
あとはWho,What,Howの箇所を変数化してChatGPT APIから動かせばツールの完成です。

NanaoNanao

質問テンプレートを作成する

返答を安定させるために上記で作成したプロンプトをテンプレート化します。
Who, What, Howの部分は後ほどユーザーからの解答で置き換えられます。

/src/naming/template.go
package naming

// 質問文のテンプレート。[ID]は解答データと置き換えられる
const template = `
あなたは優秀なソフトウェアエンジニアです。
以下のWho,What,Howからなるアプリに最適なネーミングを数字つきリストで10個考えてください。
それぞれ理由も明記してください。
既存のアプリ名ではなく今までにないアプリ名を考えてください。

# Who
[who]

# What
[what]

# How
[how]
`

NanaoNanao

ユーザーからの解答を取得する

Who, What, Howに対応する解答をユーザーと対話的に取得するQuestionCollection構造体を作成します。

テンプレートと解答データを組み合わせてプロンプトを作成するCreateContentメソッドも実装しています。

/src/naming/question_collection.go
package naming

import (
	"fmt"
	"strings"
)

// ユーザーへの質問の管理
type QuestionCollection struct {
	items []*Question
}

func NewQuestionCollection(items []*Question) *QuestionCollection {
	return &QuestionCollection{items}
}

// 質問を対話式に表示してユーザーから解答を入力してもらう
func (c *QuestionCollection) InputAnswers() error {
	for i := 0; i < len(c.items); i++ {
		var answer string
		fmt.Println(c.items[i].Value)
		if _, err := fmt.Scan(&answer); err != nil {
			return err
		}
		c.items[i].Answer = answer
	}
	return nil
}

// APIに渡すプロンプトを作成する
func (c QuestionCollection) CreateContent() string {
	// テンプレート内のIDを解答データで置き換える
	content := template
	for _, v := range c.items {
		content = strings.Replace(content, v.ID, v.Answer, 1)
	}
	return content
}

Question構造体の定義は以下の通りです。

/src/naming/question.go
package naming

// 質問データ
type Question struct {
	// 質問の種別(Who, What, Howのどれか)
	ID string

	// 質問
	Value string

	// 解答
	Answer string
}

func NewQuestion(id string, value string) *Question {
	return &Question{
		ID:    id,
		Value: value,
	}
}

NanaoNanao

動作確認

上記で作成したQuestionCollectionとChatCompletionsをつなぎ合わせて実行可能にします。

APIの応答に時間がかかるのでタイムアウトを60秒に変更しています。
max_tokensも余裕を持って1500に変更しています。

/src/cmd/main/main.go
package main

import (
	"fmt"
	"gptapi-example/src/chat"
	"gptapi-example/src/naming"
	"os"
	"time"
)

func main() {
	// 環境変数からAPIキーを取得する
	secret, ok := os.LookupEnv("OPEN_AI_SECRET")
	if !ok {
		panic("open-api-secret is empty")
	}

	// リソース節約のためにタイムアウトを設定する
	timeout := 60 * time.Second

	// トークン節約のために応答の最大トークンを設定する
	maxTokens := 1500

	// チャットに使用するモデルのID
	modelID := "gpt-3.5-turbo"

	// ユーザーへの3つの質問を作成する
	items := []*naming.Question{
		naming.NewQuestion("[who]", "そのアプリは誰のためのアプリですか?"),
		naming.NewQuestion("[what]", "そのアプリは人々に何を与えますか?"),
		naming.NewQuestion("[how]", "それはどんな方法で与えられますか?"),
	}
	qc := naming.NewQuestionCollection(items)

	// 対話式にユーザーの解答を取得する
	if err := qc.InputAnswers(); err != nil {
		panic(err)
	}

	// テンプレートと解答データを組み合わせてプロンプトを作成する
	content := qc.CreateContent()
	fmt.Println("...")

	// APIにメッセージを送信する
	c := chat.NewChatCompletions(modelID, secret, maxTokens, timeout)
	res, err := c.AskOneQuestion(content)
	if err != nil {
		panic(err)
	}

	fmt.Printf("In %d / Out %d / Total %d tokens\n", res.Usage.PromptTokens, res.Usage.CompletionTokens, res.Usage.TotalTokens)
	for _, v := range res.Choices {
		fmt.Println(v.Message.Content)
	}
}

このmain.goを実行してみます。

go run ./src/cmd/main/main.go

実行すると作りたいアプリについて3つ質問されます。
解答してしばらくすると解答データに沿ったアプリ名の候補が表示されます。

NanaoNanao

微調整について

Chat Completions APIのリクエストには様々な調整パラメータが用意されています。

例えばtemperatureパラメータは0~2の値で返答のランダム性を指定できます。
今回のツールのようにAIに発想してもらう場合はtemperatureに高い値を設定してランダム性を上げても良いかもしれませんね。

このスクラップは2023/03/06にクローズされました