Open27

ChatGPT APIをTypeScript × Node.js で試してみる

やまもとやまもと

Voltaでnodeバージョン管理してるのでバージョン指定

volta install node@18

必要なパッケージをdevdependenciesに入れる

yarn add typescript @types/node ts-node nodemon --dev

package.jsonにscriptを追加

package.json
  "scripts": {
    "start": "npm run build:live",
    "build": "tsc -p .",
    "build:live": "nodemon --watch 'src/**/*.ts' --exec \"ts-node\" src/index.ts"
  },
やまもとやまもと

src/index.tsに動作確認用のコードを追加

index.ts
console.log('Hello!')

yarn startで動作確認

yarn start
やまもとやまもと

ここからはOpenAIでAPIキーの発行
以下にアクセスしてAPIキー発行して保存しておく
https://platform.openai.com/account/api-keys

ついでにOpen AI APIへの支払い設定を忘れずにしておかないと、API使った時に429 Too Many Requests Request failed with status code 429のエラーになる。
ChatGPT Plusに課金するときの支払いとOpenAI APIの支払いは別なので注意、マジでこれに引っかかった
参考: https://qiita.com/oishi-d/items/38fbf4b7450deab3dcb3

やまもとやまもと

APIキーは環境変数で管理したいのでdotenv入れてenvファイルで管理

yarn add dotenv
.env
OPENAI_API_KEY='{ここにAPIキー}'
やまもとやまもと

ここからOpenAPIを使っていく

openapiをインストール

yarn add openapi

src/index.tsに以下を記述

index.ts
import { Configuration, OpenAIApi } from "openai";
import * as dotenv from "dotenv";
dotenv.config();

const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);

export async function ask(content: string, model = "gpt-3.5-turbo-0301") {
  const response = await openai.createChatCompletion({
    model: model,
    messages: [{ role: "user", content: content }],
  });

  const answer = response.data.choices[0].message?.content;
  console.log(answer);
}

const question = "ここにリクエストするテキスト";
ask(question);

で、yarn startってことですね

ここまでこの記事通り

やまもとやまもと

ここから色々扱いやすくしていきたい

まずGPTのモデルは定数管理したいのでsrc/constants/api.tsに指定する

src/constants/api.ts
// ChatGPTのモデル https://platform.openai.com/docs/models/gpt-3-5
export const MODEL_NAME = "gpt-3.5-turbo";

GPT3.5のモデルはいくつかあるがここではgpt-3.5-turboを使う

gpt-3.5-turbo-0301は2023年3月1日時点のgpt-3.5-turboのスナップショットなので、常に同じモデルでレスポンスなどを見たい人はgpt-3.5-turbo-0301がいいっぽいが、こだわりないいのでgpt-3.5-turboを使う。

index.ts
import { Configuration, OpenAIApi } from "openai";
import * as dotenv from "dotenv";
import { MODEL_NAME } from "./constants/api";
dotenv.config();

const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});

const openai = new OpenAIApi(configuration);

export const ask = async (content: string) => {
  const response = await openai.createChatCompletion({
    model: MODEL_NAME,
    messages: [{ role: "user", content: content }],
    temperature: 0,
  });

  const answer = response.data.choices[0].message?.content;
  console.log(answer);
};

const question = "テスト";

ask(question);
やまもとやまもと

OPEN AI API の Request body のパラメータを設定してみる。
https://platform.openai.com/docs/api-reference/completions/create

とりあえず動作確認ついでに聞いてみた

OpenAI APIのRequest bodyのパラメータは、APIエンドポイントに送信されるデータの形式を定義するものです。以下に、主要なパラメータについて説明します。

1. prompt:APIに渡されるテキストのプロンプト。これは、APIが生成するテキストのコンテキストを提供するために使用されます。

2. max_tokens:APIが生成するテキストの最大トークン数。トークンは、テキストを分割するために使用される単語や句などの単位です。

3. temperature:APIが生成するテキストの多様性を制御するために使用されるパラメータ。値が高いほど、より多様なテキストが生成されます。

4. top_p:APIが生成するテキストの多様性を制御するために使用される別のパラメータ。値が低いほど、より確信度の高いテキストが生成されます。

5. frequency_penalty:APIが生成するテキストの重複を減らすために使用されるパラメータ。値が高いほど、より重複の少ないテキストが生成されます。

6. presence_penalty:APIが生成するテキストの内容を制御するために使用されるパラメータ。値が高いほど、より特定のトピックに関連するテキストが生成されます。

7. stop:APIが生成するテキストに含まれていてはならない単語のリスト。これは、生成されたテキストが特定のトピックに関連することを防ぐために使用されます。

8. model:使用するOpenAIモデルの名前。これは、APIが使用するモデルを指定するために使用されます。

9. engine:使用するOpenAIエンジンの名前。これは、APIが使用するエンジンを指定するために使用されます。

10. context:APIが生成するテキストのコンテキストを提供するために使用されるテキストのリスト。これは、APIが生成するテキストの内容を制御するために使用されます。

上の説明のはhttps://api.openai.com/v1/completionsの方のリクエストボディなので欲しい情報と違う。
今回は対話形式でできるhttps://api.openai.com/v1/chat/completionsの方。

やまもとやまもと

OPEN AI APIは文脈をmessageの中に自分で含める必要がある。
なのでこちらのリクエストの内容とレスポンスの内容をうまくmessageに含める必要がある。

以下のような感じでレスポンスをリクエストの内容に組み込んで文脈を作っていきたい。

"messages": [
  {"role": "user", "content": "こんにちは.",name:"yamamoto"},
  {"role": "assistant", "content": "こんにちは!私はAIアシスタントです。何かお手伝いできることがありますか?"},
  {"role": "user", "content": "`https://api.openai.com/v1/chat/completions`のRequestBodyのmessagesのroleについて詳しく教えて",name:"yamamoto"}
  {"role": "assistant", "content": "messagesのroleは、チャットのメッセージがどの役割(ロール)を果たしているかを示すプロパティです。各メッセージには、'system'、'user'、'assistant'のいずれかの値が指定されます。system:roleが'system'のメッセージは、システム自体からのメッセージを表します。システムメッセージは、会話の状態や設定、アシスタントの振る舞いを制御するために使用されます。例えば、システムがチャットを開始するための挨拶や指示を行うことがあります。user:roleが'user'のメッセージは、ユーザーからのメッセージを表します。ユーザーメッセージは、ユーザーがモデルに対して提供するテキストや質問などの入力を表します。assistant:roleが'assistant'のメッセージは、アシスタントからのメッセージを表します。アシスタントメッセージは、モデルからの過去の応答やチャットの継続、追加の情報などを表現します。チャットのコンテキストを提供するために、メッセージは時間の順に並べられます。一般的なパターンでは、ユーザーメッセージが先に来て、その後にシステムやアシスタントの応答が続きます。このようにメッセージを指定することで、モデルが対話の文脈を理解し、適切な応答を生成することが期待されます。messagesの配列には、複数のメッセージを含めることができます。それぞれのメッセージは、roleとcontentの2つのプロパティを持ちます。contentにはテキストコンテンツが格納され、roleにはメッセージの役割が指定されます。"}
]

できればrole="system"で人格も与えたい

やまもとやまもと

response.data.choicesが以下のような形なので、レスポンスのmessagesの中身をリクエストのmessagesの配列に追加していけば良さそう

[
  {
    message: {
      role: 'assistant',
      content: 'こんにちは!私はAIアシスタントです。何かお手伝いできることがありますか?'
    },
    finish_reason: 'stop',
    index: 0
  }
]
やまもとやまもと

やりたいこと

role=systemのJSONファイルを用意してmessagesの配列にpush

{"role": "system", "content": "基本となるプロンプト"}

あとはrole=userとrole= assistantのJSONを追加するJSONファイルを別途用意
リクエストとレスポンスのmessagesをそのJSONファイルに追加していく
トークンが制限値を超える場合は古い情報から削除していく

APIにリクエストに投げるタイミングで常にsystemのJSONを付与して送る

やまもとやまもと

はちゃめちゃにアップデートした。

  • 文脈をjsonファイルに保存していく修正
    • messages.jsonに文脈を追加していく
    • system.jsonにrole='system'のプロンプトを持たせる
    • 上記jsonファイルがなければ作成する
    • 上記jsonファイルはgitignoreに含める
    • ファイルを削除するscript追加
  • try/catchでエラーログ出す
  • 機能ごとに関数を切り出した
  • ハードコーディング部分を定数ファイルに切り出し
やまもとやまもと
index.ts
import { ChatCompletionRequestMessage, Configuration, OpenAIApi } from "openai";
import * as dotenv from "dotenv";
import { FILE_PATHS, GPT_REQUEST_PARAMS } from "./constants/api";
import fs from "fs/promises";
dotenv.config();

// OPEN AI APIの設定
const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);

/**
 * メッセージをファイルに保存する
 * @param filePath ファイル名
 * @param messages メッセージ
 */
const saveMessages = async (
  filePath: string,
  messages: Array<ChatCompletionRequestMessage>
): Promise<void> => {
  const data = JSON.stringify(messages, null, 2);
  await fs.writeFile(filePath, data);
};

/**
 * ファイルからメッセージを読み込む
 * @param filePath ファイルパス
 */
const loadMessages = async (
  filePath: string
): Promise<Array<ChatCompletionRequestMessage>> => {
  try {
    await fs.access(filePath);
    const data = await fs.readFile(filePath, "utf-8");

    return JSON.parse(data);
  } catch (error) {
    const err = error as NodeJS.ErrnoException;
    console.error(`Error loading messages from ${filePath}: `, error);

    if (err.code === "ENOENT") {
      console.info(`File ${filePath} not found. Creating a new one.`);
      await fs.writeFile(filePath, "[]", "utf-8");
    }

    return [];
  }
};

/**
 * OpenAI APIとやりとりする
 * @param userInput ユーザーの入力
 * @param messages メッセージ
 */
const interactWithOpenAI = async (
  userInput: ChatCompletionRequestMessage,
  messages: Array<ChatCompletionRequestMessage>
) => {
  messages.push(userInput);

  try {
    const response = await openai.createChatCompletion({
      model: GPT_REQUEST_PARAMS.MODEL_NAME,
      messages: messages,
      temperature: GPT_REQUEST_PARAMS.TEMPERATURE,
    });

    const responseMessage = response.data.choices[0].message;
    if (responseMessage) {
      messages.push(responseMessage);
    }

    return { messages, responseMessage };
  } catch (error) {
    console.error("Error interacting with OpenAI API: ", error);

    return { messages };
  }
};

/**
 * チャットを実行する
 * @param userInput ユーザーの入力
 */
export const conductChat = async (userInput: ChatCompletionRequestMessage) => {
  let messages = await loadMessages(FILE_PATHS.MESSAGES);

  // messagesがない場合はシステムのプロンプトを含める
  if (messages.length === 0) {
    const system = await loadMessages(FILE_PATHS.SYSTEM);
    messages = system;
  }

  const { messages: updatedMessages, responseMessage } =
    await interactWithOpenAI(userInput, messages);

  await saveMessages(FILE_PATHS.MESSAGES, updatedMessages);
  console.log("ChatGPT:", responseMessage?.content);
};

/**
 * メインの処理
 */
const main = async () => {
  const userInput: ChatCompletionRequestMessage = {
    role: "user",
    content: "ここに文字を入力",
  };

  await conductChat(userInput);
};

main().catch((error) => console.error(error));
やまもとやまもと

messages.jsonはこんな感じ

messages.json
[
  {
    "role": "system",
    "content": "簡潔な回答で不要な結論は省くこと。"
  },
  {
    "role": "user",
    "content": "なんか話して"
  },
  {
    "role": "assistant",
    "content": "了解しました。では、何か特定の話題についてお話しましょうか?例えば、最近興味を持っていることや、悩み事などありますか?"
  },
  {
    "role": "user",
    "content": "なんか話して"
  },
  {
    "role": "assistant",
    "content": "分かりました。では、最近のニュースで注目されている「クリプトカレンシー(暗号通貨)」についてお話しましょうか?最近、ビットコインやイーサリアムなどの暗号通貨が急激に価値を上げたり下げたりしていますが、あなたは暗号通貨についてどのように思いますか?"
  },
  {
    "role": "user",
    "content": "ちょっとよくわからない、怖いものだと思ってます"
  },
  {
    "role": "assistant",
    "content": "そうですか、確かに暗号通貨にはリスクがあります。暗号通貨は、中央銀行や政府などの機関によって管理される通貨とは異なり、分散型の取引システムで運営されています。そのため、価格変動が激しく、投資家にとっては大きなリスクがあると言われています。また、暗号通貨は匿名性が高く、犯罪に利用される可能性もあるため、規制が強化される可能性もあります。しかし、一方で、暗号通貨は将来的には金融システムの一部として認められる可能性もあり、投資家にとっては大きなチャンスとなるかもしれません。あなたは、暗号通貨についてどのように感じますか?"
  }
]
やまもとやまもと

追加したscript

package.json
  "scripts": {
    "clean": "rm lib/*",
    "clean:messages": "rm lib/messages.json",
    "clean:system": "rm lib/system.json"
  },
やまもとやまもと

文脈持つこととかはできたので次やりたいこと

  • トークン上限が来た時に古い文脈から削除する
  • 特にこだわりないが入力をターミナルからできるようにしたい
やまもとやまもと

トークンの上限って4,096 tokensってなってるけどcontentのlengthの合計でいいんかな、、、
なんか3000弱で400エラーになる

やまもとやまもと

今の所こんな感じ

index.ts
import { ChatCompletionRequestMessage, Configuration, OpenAIApi } from "openai";
import * as dotenv from "dotenv";
import { FILE_PATHS, GPT_REQUEST_PARAMS } from "./constants/api";
import fs from "fs/promises";
dotenv.config();

// OPEN AI APIの設定
const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);

/**
 * メッセージをファイルに保存する
 * @param filePath ファイル名
 * @param messages メッセージ
 */
const saveMessages = async (
  filePath: string,
  messages: Array<ChatCompletionRequestMessage>
): Promise<void> => {
  const data = JSON.stringify(messages, null, 2);
  await fs.writeFile(filePath, data);
};

/**
 * ファイルからメッセージを読み込む
 * @param filePath ファイルパス
 */
const loadMessages = async (
  filePath: string
): Promise<Array<ChatCompletionRequestMessage>> => {
  try {
    await fs.access(filePath);
    const data = await fs.readFile(filePath, "utf-8");

    return JSON.parse(data);
  } catch (error) {
    // fs.promisesのerrorはNodeJS.ErrnoExceptionを返すのでアサーションする
    const err = error as NodeJS.ErrnoException;
    console.error(
      `${err.code}: Error loading messages from ${filePath}: `,
      error
    );

    if (err.code === "ENOENT") {
      console.info(`File ${filePath} not found. Creating a new one.`);
      await fs.writeFile(filePath, "[]", "utf-8");
    }

    return [];
  }
};

/**
 * OpenAI APIとやりとりする
 * @param userInput ユーザーの入力
 * @param messages メッセージ
 */
const interactWithOpenAI = async (
  userInput: ChatCompletionRequestMessage,
  messages: Array<ChatCompletionRequestMessage>
) => {
  messages.push(userInput);

  try {
    const response = await openai.createChatCompletion({
      model: GPT_REQUEST_PARAMS.MODEL_NAME,
      messages: messages,
      temperature: GPT_REQUEST_PARAMS.TEMPERATURE,
    });

    const responseMessage = response.data.choices[0].message;
    if (responseMessage) {
      messages.push(responseMessage);
    }

    return { messages, responseMessage };
  } catch (error) {
    console.error(`Error interacting with OpenAI API: `, error);

    return { messages };
  }
};

/**
 * チャットを実行する
 * @param userInput ユーザーの入力
 */
export const conductChat = async (userInput: ChatCompletionRequestMessage) => {
  let messages = await loadMessages(FILE_PATHS.MESSAGES);

  // messagesの中身がない場合はシステムのプロンプトを追加
  if (messages.length === 0) {
    const system = await loadMessages(FILE_PATHS.SYSTEM);
    messages = system;
  }

  const { messages: updatedMessages, responseMessage } =
    await interactWithOpenAI(userInput, messages);

  await saveMessages(FILE_PATHS.MESSAGES, updatedMessages);
  console.log("ChatGPT:", responseMessage?.content);
};

/**
 * メインの処理
 */
const main = async () => {
  const userInput: ChatCompletionRequestMessage = {
    role: "user",
    content: "",
  };

  await conductChat(userInput);
};

main().catch((error) => console.error(error));

やまもとやまもと

親のディレクトリがなければ作るように修正

index.ts
/**
 * ファイルからメッセージを読み込む
 * @param filePath ファイルパス
 */
const loadMessages = async (
  filePath: string
): Promise<Array<ChatCompletionRequestMessage>> => {
  try {
    await fs.access(filePath);
    const data = await fs.readFile(filePath, "utf-8");

    return JSON.parse(data);
  } catch (error) {
    // fs.promisesのerrorはNodeJS.ErrnoExceptionを返すのでアサーションする
    const err = error as NodeJS.ErrnoException;
    console.error(
      `${err.code}: Error loading messages from ${filePath}: `,
      error
    );

    if (err.code === "ENOENT") {
      console.info(`File ${filePath} not found. Creating a new one.`);

      const path = require("path");
      const dir = path.dirname(filePath);
      await fs.mkdir(dir, { recursive: true });
      await fs.writeFile(filePath, "[]", "utf-8");
    }

    return [];
  }
};
やまもとやまもと

トークンの上限って4,096 tokensじゃないやろ、、、
数え方の基準がわからんすぎる、、、

やまもとやまもと

トークン数を正確に計測できないのでmessagesのcontentsのトータルのlengthを見ることにした。
上限の4096に0.9を掛けた値を上限として、上限を超えると配列を削除。
処理として微妙だけどsystemも消えてしまうので最後に追加する

index.ts
/**
 * メッセージがトークン数の上限を超えていたら古いメッセージを削除する
 * @param messages メッセージ
 * @param system システムのプロンプト
 */
const trimMessages = (
  messages: Array<ChatCompletionRequestMessage>,
  system: ChatCompletionRequestMessage
): Array<ChatCompletionRequestMessage> => {
  let totalTokens = messages.reduce(
    (total, message) => total + message.content.length,
    0
  );

  // メッセージがトークン数の上限を超えていたら古いメッセージを削除する
  while (totalTokens > GPT_REQUEST_PARAMS.REQUEST_MAX_TOKENS * 0.9) {
    const removedMessage = messages.shift();

    if (removedMessage) {
      totalTokens -= removedMessage.content.length;
    }
  }

  // システムプロンプトを追加
  messages.unshift(system);

  return messages;
};
やまもとやまもと

これで一旦何も気にせずにOPEN AI APIを使えるようになった

index.ts
import { ChatCompletionRequestMessage, Configuration, OpenAIApi } from "openai";
import * as dotenv from "dotenv";
import { FILE_PATHS, GPT_REQUEST_PARAMS } from "./constants/api";
import fs from "fs/promises";
dotenv.config();

// OPEN AI APIの設定
const configuration = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(configuration);

/**
 * メッセージをファイルに保存する
 * @param filePath ファイル名
 * @param messages メッセージ
 */
const saveMessages = async (
  filePath: string,
  messages: Array<ChatCompletionRequestMessage>
): Promise<void> => {
  const data = JSON.stringify(messages, null, 2);
  await fs.writeFile(filePath, data);
};

/**
 * ファイルからメッセージを読み込む
 * @param filePath ファイルパス
 */
const loadMessages = async (
  filePath: string
): Promise<Array<ChatCompletionRequestMessage>> => {
  try {
    await fs.access(filePath);
    const data = await fs.readFile(filePath, "utf-8");

    return JSON.parse(data);
  } catch (error) {
    // fs.promisesのerrorはNodeJS.ErrnoExceptionを返すのでアサーションする
    const err = error as NodeJS.ErrnoException;
    console.error(
      `${err.code}: Error loading messages from ${filePath}: `,
      error
    );

    if (err.code === "ENOENT") {
      console.info(`File ${filePath} not found. Creating a new one.`);

      const path = require("path");
      const dir = path.dirname(filePath);
      await fs.mkdir(dir, { recursive: true });
      await fs.writeFile(filePath, "[]", "utf-8");
    }

    return [];
  }
};

/**
 * メッセージがトークン数の上限を超えていたら古いメッセージを削除する
 * @param messages メッセージ
 * @param system システムのプロンプト
 */
const trimMessages = (
  messages: Array<ChatCompletionRequestMessage>,
  system: ChatCompletionRequestMessage
): Array<ChatCompletionRequestMessage> => {
  let totalTokens = messages.reduce(
    (total, message) => total + message.content.length,
    0
  );

  // メッセージがトークン数の上限を超えていたら古いメッセージを削除する
  while (totalTokens > GPT_REQUEST_PARAMS.REQUEST_MAX_TOKENS * 0.9) {
    const removedMessage = messages.shift();

    if (removedMessage) {
      totalTokens -= removedMessage.content.length;
    }
  }

  // システムプロンプトを追加
  messages.unshift(system);

  return messages;
};

/**
 * OpenAI APIとやりとりする
 * @param userInput ユーザーの入力
 * @param messages メッセージ
 */
const interactWithOpenAI = async (
  userInput: ChatCompletionRequestMessage,
  messages: Array<ChatCompletionRequestMessage>
) => {
  messages.push(userInput);

  const system = await loadMessages(FILE_PATHS.SYSTEM);
  messages = trimMessages(messages, system[0]);

  try {
    const response = await openai.createChatCompletion({
      model: GPT_REQUEST_PARAMS.MODEL_NAME,
      messages: messages,
      temperature: GPT_REQUEST_PARAMS.TEMPERATURE,
    });

    const responseMessage = response.data.choices[0].message;
    if (responseMessage) {
      messages.push(responseMessage);
    }

    return { messages, responseMessage };
  } catch (error) {
    console.error(`Error interacting with OpenAI API: `, error);

    return { messages };
  }
};

/**
 * チャットを実行する
 * @param userInput ユーザーの入力
 */
export const conductChat = async (userInput: ChatCompletionRequestMessage) => {
  let messages = await loadMessages(FILE_PATHS.MESSAGES);

  // messagesの中身がない場合はシステムのプロンプトを追加
  if (messages.length === 0) {
    const system = await loadMessages(FILE_PATHS.SYSTEM);
    messages = system;
  }

  const { messages: updatedMessages, responseMessage } =
    await interactWithOpenAI(userInput, messages);

  await saveMessages(FILE_PATHS.MESSAGES, updatedMessages);
  console.log("ChatGPT:", responseMessage?.content);
};

/**
 * メインの処理
 */
const main = async () => {
  const userInput: ChatCompletionRequestMessage = {
    role: "user",
    content:
      "こんちわ!",
  };

  await conductChat(userInput);
};

main().catch((error) => console.error(error));