はじめに
さて、今年は色々なところからChatGPT騒ぎが聞こえる一年でした。D言語も同じかそれ以上に盛り上がれ!と思いながら、ChatGPTにサポートしてもらいながらD言語を書いていました。はかどる~
今年は本当にプライベートなコードをたくさん書いたな、という感想があるのですが、その中でも「OpenAIのAPIを呼び出すライブラリ」を書いたので、その紹介半分、学び半分で経験を振り返っていきたいと思います。
なお、この記事では、
- OpenAI APIのコンセプト概要およびライブラリの使い方
- 作ったことに対する振り返りと学び
という大きく2部構成でお伝えしていきます。
個人的に面白い使い方のサンプルもあるのでぜひ楽しんでください!
ライブラリ紹介
というわけで、さっそく作ったライブラリ「openai-d
」の紹介です。
OpenAIのライブラリは、APIの形などのガイドラインが定められています。
主要機能はREST APIの定義そのままで、他のライブラリともそこまで違わない形にしていますが、ドキュメント参考に作るのが吉です。(そのように作りました。作ったつもり。)
提供する機能
2023年12月現在、提供している具体的な機能は以下5つです。
- List models
- Completions
- Chat (function_call含む)
- Embeddings
- Moderations
Audioとか、Imageとか、Vision系の入力などなど、やるべきアップデートはまだありますが、テキストベースの美味しいところは十分使えます。
使い方
基本的な機能の紹介と利用方法について紹介します。
準備: OpenAI APIの契約
まずは準備ということで、OpenAI APIの契約についてです。
まずは契約してお金を払う意思を示す必要があります。これが現実…
しかし実体験として、実際月額2ドルとか(約300円)の世界で結構遊べるので、正直プログラミングが苦でなければ、ChatGPTよりこちらのほうが安上がりでは?と思います。(履歴管理とUI作るのが面倒なだけだよ、という声は多い気がする)
料金の支払いはクレジットカードで引き落としですが、リミッターが掛けられるので一定以上の支払ないにならないよう調整できます。ライブラリを使う以上大体自動化すると思うので、まず最初にチェックしてリミッターかけておくと安心です。
API契約の具体的な手順は、参考になる記事がいくつかあるのでリンクを貼っておきます。
契約したら、実行の準備として、API KEYを発行して、そのシークレットを「OPENAI_API_KEY
」という環境変数に設定しておくと便利です。(公式のアレとかコレとか、大体のライブラリがこの環境変数を読むように作られているため)
OpenAI APIの準備ができたら、あとはライブラリを使うだけです。おいしいところはここからじゃ、ということで早速手順を進めていきましょう!
依存関係のインストール
D言語なので、 dub
を利用していれば、いつも通りです。悩むことは特にありません。
dub add openai-d
共通初期化
すべてのAPIは以下の初期化処理を行います。
OPENAI_API_KEY
という環境変数からAPIキーを読んで、それを使うように構成されます。自力でAPIキーを指定するなどのオプションも用意してあります。
import openai;
// Load API key from environment variable
auto client = new OpenAIClient();
個別のAPIへのリクエストは、client
のメソッドとして提供しています。それぞれ紹介していきます。
Models API: モデル一覧を確認する
扱えるモデル一覧を得るAPIです。GPT-4など、一部支払い実績などによる解禁ルールがあり、自分が使えるモデルの一覧を確認するための機能です。独自にチューニングしたモデルもここに載ってきます。最初の一歩として必須の機能です。
URL的には、GET /models
というエンドポイントになります。
openai-d
においては、client.listModels()
という形でリクエストします。引数は特にありません。
auto models = client.listModels();
auto modelIds = models.data
.map!"a.id"
.filter!(a => a.canFind("ada"))
.array();
sort(modelIds);
modelIds.each!writeln();
ここでは結果を見やすくするために少し加工しています。これは ada
という文字列を含むモデルのIDだけを列挙して表示するサンプルです。
Completion API: 続きを書く
補完とも呼ばれますが、要するに「続きを書いてもらう」というタイプの生成を行うAPIです。
URL的には、 POST /completions
というエンドポイントになります。
個人的にはとても応用しやすいと思うのですが、自由度が高すぎて扱いづらいともいわれます。
openai-d
においては、completionRequest
関数で要求オブジェクトを作り、client.completion(request)
という形でリクエストします。
また、completionRequest
の第一引数は応答を返してくれるモデル名で、ここに gpt-3.5-turbo
とか gpt-4
などの名称を指定します。
import std;
// POST /completions
auto request = completionRequest("gpt-3.5-turbo-instruct", "Hello, D Programming Language!\n", 10, 0);
message.stop = "\n";
auto response = client.completion(request);
writeln(response.choices[0].text.chomp());
Chat API: 応答する
チャットAPI、要するに「何かメッセージを返信してもらう」というタイプのメッセージ生成を行うAPIです。
URL的には、 POST /chat/completions
というエンドポイントになります。
いわゆるチャットルームをイメージしてもらえれば良いと思いますが、対話という形式がAPI設計に組み込まれているものです。Completionより扱いやすさを重視して設計されており、基本的にこれ一本でやっていくことをまず考えた方が良い、といった代物です。
Function Callという後述のサンプルでも使う特別な機能が使えるのですが、それを使い始めると急に複雑化するので頑張っていきましょう。
openai-d
においては、chatCompletionRequest
関数で要求オブジェクトを作り、client.chatCompletion(request)
という形でリクエストします。
また、chatCompletionRequest
の第一引数は応答を返してくれるモデル名で、ここに gpt-3.5-turbo
とか gpt-4
などの名称を指定します。
import std;
// POST /chat/completions
const request = chatCompletionRequest("gpt-3.5-turbo", [
systemChatMessage("You are a helpful assistant."),
userChatMessage("Hello!")
], 16, 0);
auto response = client.chatCompletion(request);
writeln(response.choices[0].message.content);
チャットでのやり取りは、複数のメッセージから構成されます。個々のメッセージには、 role
というプロパティがあり、以下のように使い分けることで複雑な対話も設計できます。
-
system
: これを指定すると、誰の発言でもないチャット全体に暗黙的に効かせるシステムメッセージになります -
user
: これを指定すると、ユーザーの発言だとみなされます -
assistant
: これを指定すると、LLMサイド、モデルの発言だとみなされます
基本的に「システム」「ユーザー」「アシスタント」「関数」の4種しかないので、それぞれ簡単に構築する systemChatMessage
などの構築用便利関数が用意してあります。(このあたりはちょっとD言語らしさを意識しました)
誰か宛のメモ: 誰もシステムメッセージが1個じゃなきゃダメとは言ってないし、先頭に入れなきゃダメとも言ってないし、ユーザーやアシスタントメッセージが連続しちゃダメとも言ってないんですよね。
Embedding API: 埋め込む(内部表現を得る)
いわゆる生成AIとしての機能ではありませんが、文字列をエンコーディングして、内部表現と呼ばれる数値ベクトルデータを得るためのAPIです。これを通すと、テキストが意味ありげな float[]
になるというものです。
URL的には、 POST /embeddings
というエンドポイントになります。
openai-d
においては、embeddingRequest
関数で要求オブジェクトを作り、client.embedding(request)
という形でリクエストします。
また、embeddingRequest
の第一引数は埋め込みの計算方法であるところのモデル名で、大体 text-embedding-ada-002
になります。
import std;
// POST /embeddings
const request = embeddingRequest("text-embedding-ada-002", "Hello, D Programming Language!");
auto response = client.embedding(request);
float[] embedding = response.data[0].embedding;
writeln(embedding.length); // text-embedding-ada-002 -> 1536
最近はこれを用いて、テキストデータベースを意味の類似度で検索する「セマンティック検索(意味検索)」という技術が普及し、それをベースにした「RAG」(Retrieval-Augmented Generation、検索で強化された生成)が流行っています。
セマンティック検索程度なら、PostgreSQLにベクトルデータを格納する pgvector
という機能があったりするので結構簡単に作れるようです。ほかにもベクトルデータベースなどのキーワードでいくらでも引っかかるようになりました。時代ですね。
Moderation API: 適度かどうか評価する
「フォーラムのモデレーター」などの言葉で聞いたことがあるかもしれません。「モデレーション」という機能を提供するAPIです。
URL的には、 POST /moderations
というエンドポイントになります。
このAPIは、差別用語など様々な観点から「度を越えていないか判断する」ということを助けるため、どれくらい危険か安全かという判定結果を返すAPIです。フォーラムのモデレーター(人間)がやっていることを代わりにやってくれるようになることを目指したAPIと言えます。
openai-d
においては、moderationRequest
関数で要求オブジェクトを作り、client.moderation(request)
という形でリクエストします。
また、moderationRequest
の第一引数は評価を行うためのモデル名を指定します。ここでは text-moderation-latest
と text-moderation-stable
が利用できます。
import std;
// POST /moderations
const request = moderationRequest("D is a general-purpose programming language with static typing, systems-level access, and C-like syntax. With the D Programming Language, write fast, read fast, and run fast.");
auto response = client.moderation(request);
if (response.results[0].flagged)
writeln("Warning!");
else
writeln("Probably safe.");
実際、掲示板などのフォーラムの投稿前チェックに使ったりする用途が想定通りと思われます。また、LLMに対する入力やLLMの出力が過度になっていないか、ということをチェックする目的で他のAPIとも組み合わせられます。
これがないと実質ノーガードなので、このAPIでチェックして危険だ判定されたら特別なログを残す、アラートを飛ばす、といった運用を設計するのも良いかもしれません。
サンプル紹介
概要
サンプルとして用意している例の一つ、「チャットによる指示1発で、SQLiteのテーブルを作成して、サンプルデータも投入する」というものです。
ソース: https://github.com/lempiji/openai-d/blob/main/examples/chat_db/source/app.d
やったことは、Function CallというGPTモデルが備えている「外部機能を使う」という能力を応用したものです。
このサンプルの流れとしては、
- 任意のクエリを流せる関数を作り、チャットリクエストに使える関数定義として教えておく
- SQLiteのデータベースを用意して、クエリが実行できるように整えておく
- 「SQLite3向けに、日本のカーディーラーのための製品テーブルとダミーデータを作って」と頼む
- 受け取ったメッセージに関数実行リクエストが返ってくるので、その通り実行しながら対話ループを回す
ということをやっています。
そうすると何が起きるか?
1回目のチャット応答に「CREATE TABLEするクエリを引数にクエリ実行したい」という関数メッセージが返ってきます。(クエリはバッチリ書いてくれる)
そうしたら実行して、結果をメッセージに入れて再度続きのメッセージをもらうようにリクエストします。
2回目のチャット応答は「INSERTでデータを追加するクエリを引数にクエリ実行したい」という関数メッセージが返ってきます。(クエリはバッチリ書いてくれる)
そうしたらまた実行して、結果をメッセージに入れて再度続きのメッセージをもらうようにリクエストします。
そうすると3回目は、「こういうクエリを投げてこういうデータが入ったテーブルができたよ」と通常の対話応答を用いて報告してくれます。それで完了です。
なにがすごいのか?
どのように進めるか指示しておらず、クエリを書けとも(明示的には)指示していませんが、CREATE => INSERT という順序でクエリを実行しようというプログラムのように振る舞ってくれる、というのが便利ポイントです。
関数はどんなものでも良く、雰囲気で動いてくれます。賢い。
そのためにどんなコードを書くのか?
さて、これをやっている具体的なコードは以下の部分です。
// make dummy data
auto request = chatCompletionRequest("gpt-3.5-turbo-0613", [
systemChatMessage("You are a helpful SQL assistant."),
userChatMessage("Create a sample database table using sqlite3 with dummy product data for a car dealership in Japan.")
], 400, 1);
// define functions
request.functions = [
ChatCompletionFunction(
"execute_query",
"Executes a given SQL query using sqlite3. Supports various query types such as CREATE TABLE, SELECT, DELETE, etc. On successful execution of a SELECT query, it returns the fetched records.",
JsonSchema.object_([
"query": JsonSchema.string_("statement"),
], ["query"])
),
];
request.functionCall = "auto";
JsonValue[] execute_query(scope Database db, string query)
{
JsonValue[] records;
db.run(query, (ResultRange results) {
foreach (result; results)
{
JsonValue[string] record;
foreach (i; 0 .. result.length)
{
final switch (result.columnType(i))
{
case SqliteType.INTEGER:
record[result.columnName(i)] = JsonValue(result.peek!long(i));
break;
case SqliteType.FLOAT:
record[result.columnName(i)] = JsonValue(result.peek!double(i));
break;
case SqliteType.TEXT:
record[result.columnName(i)] = JsonValue(result.peek!string(i));
break;
case SqliteType.BLOB:
record[result.columnName(i)] = JsonValue("<BLOB...>");
//record[result.columnName(i)] = result.peek!(Blob, PeekMode.copy)(index);
break;
case SqliteType.NULL:
record[result.columnName(i)] = JsonValue(null);
break;
}
}
records ~= JsonValue(record);
}
return true;
});
return records;
}
全体的に難しい実装はしないことにしましたが、それでもテンプレがあれば非常に簡単です。
LLMが色々なツールを使いこなしてくれる、という話題はこのあたりを元にされていると思います。
この延長線上には、自分だけの便利関数を書いて日々を楽にしましょう、という目標があります。それを目指して、ちょっとした関数でめっちゃ色々できる、というサンプルを書いてみたという話でした。
振り返り
なぜこのライブラリを書いたのか?
流行りに乗りたかったからだよ!という話はあります。(正直)
それ以外では、ChatGPT便利だな~ということで自動化に使いたかったのですが、Python書きたくない、JavaScriptでツール書き慣れてない、ということで主要ライブラリを消去法していった結果、自作しちゃえばいいよね、ということになりました。しょせんREST APIなので楽勝です。そう、D言語ならね。
また、調べていくとOpenAIがコミュニティライブラリのページを公開しており、そこに載せたら何かいいことあるんじゃないか?と思ったのもあります。しかし、申請がんばったのに未だに載ってない。なぜなのか…
困難・つらかったところ
ライブラリを作って2大つらかったことがあります。
- 更新の追従が大変
- Function CallのJSONスキーマの扱いが柔軟すぎ(情報なさすぎ)
1つ目は、モデルのバージョニング追従です。このライブラリはいくつかライブラリを調べて参考にしたのですが、その中でもGoのライブラリを見たところ、モデル名を指定するために定数定義を見つけました。覚えるの大変だし自動補完で出てくる方が嬉しいよね、と深く考えずに真似してしまったところがあります。
結果として、思ったよりモデルがバージョンアップされたり統廃合される、非推奨化により deprecated
を付けて回るのが大変、という事態になりました。そんなことでへこたれるな!という話ですが、早いところコーディング自動化されないかな!と思うばかりです。
2つ目は、FunctionCallという外部機能呼び出し能力がAPIに実装されたので、それを使える実装を入れようとしたときのことです。公式ブログのアナウンスが出てから最短で形にしたるわ!と躍起になってはみましたが、結局動的言語勢には勝てませんでした。なぜかといえば、関数の説明や引数などを指定するためのJSONスキーマが半ば自由なので、あんまり仕様とか書かれていない、という実態があったためです。「パラメーターを幻視する」などの補足があることから、LLM的にも結局単なる文字列でしかなく、何か特殊なことをしているわけではない、ということなのでしょう。
しかし2点目は悪いことばかりではありませんでした。JSONスキーマを補完が効きつつそれっぽく書ける機能、がなんとなく実用化できたためです。静的型付け言語でもこれくらいはできる!
unittest
の抜粋ですが、4行書くとかなりのJSONスキーマが吐き出されます。
/*
{
"name": "get_current_weather",
"description": "Get the current weather",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
"format": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "The temperature unit to use. Infer this from the users location.",
},
},
"required": ["location", "format"],
},
},
*/
auto get_current_weather_params = JsonSchema.object_([
"location": JsonSchema.string_("The city and state, e.g. San Francisco, CA"),
"format": JsonSchema.string_("The temperature unit to use. Infer this from the users location.", ["celcius", "farenheit"])
], ["location", "format"]);
JsonSchema.
まで入力した時点で候補が表示され、引数にそれらしきものを埋めていけるようになっています。中身は mir-ion
というライブラリの Algebraic
というデータ型を使っています。書いていて特に困ることがなく、初期化時の代入で考えることが std.sumtype
より少ないので、とても使いやすかったです。JSON扱うことがあれば、std.json
以外にも mir-ion
を検討してみると良いかもしれません。UDAのアノテーションは結構覚えることがあるかもしれませんが…
書いて良かったこと
D言語Cookbookに載せるネタがいくつか出てきました。
基礎的なREST APIの叩き方(GET, POST)、認証ヘッダーの付け方、JSONのPOST方法などが追加され、コンテンツが充実しました。コピペできるって素晴らしい!
あとはAPIのコンセプトを学ぶことで、内部構造がどうなっているのか何となくイメージがつかめました。Function Callとかどう考えてもただの文字列扱いされてるんよ。全体的にCompletionにねじ込まれる雰囲気の世界なんよ。(妄想です)
今後とまとめ
今後のメンテナンス目標ですが、未実装のAPIがあるのでやっていく、バージョンアップについていけるところまでついていく、です。バイナリデータをPOSTするとかチョロい感じでやっていきたいですね。
機能面では、Function Callが完全にメタプログラミングネタとして優秀なので、適当に書いた関数からJSONスキーマ錬成したいです。だれか~
D言語自体ツールを書いたりサーバー書いたりしがちの言語なので、そこにLLMなどの生成AIを組み込んでいく、というスタイルになっていくかと思います。便利で簡単に使えないと意味がないので、今後はラッパーも計画したいと思います。RAGが個人的に好きではないのでRAGをやるライブラリを作るかは微妙ですが、他にもアイデアはたくさんあるので引き続きやっていき精神でいきます。
みなさんも新しいサービスが出たら、それをラップする程度でもライブラリを作ってみると色々学びがあると思います。あわよくば振り返り、言語化して、記事にしてください。私の好物です。
以上、D言語でOpenAI APIのライブラリを書いた話の振り返りでした!