ChatGPT APIで「文脈を保った会話」をし、チャット風に表示する
今日作るもの
それ以外も異物混入してるだろ
ChatGPTの特徴である、前の発言の文脈を読み取った自然な会話。 今回は、それをAPIで取得し、静的サイトビルダーのAstroで表示してみる。
const prompts = [
"ハリーポッターの組み分け帽子のやつ、やったことある?",
"もしあなたが入るとしたら、どの寮?",
"その寮の寮長って誰だったっけ?",
];
const { results, dataArray } = await getMultipleChatGPTAnswers(prompts);
例えば、こんな風に発言を指定してみよう。
すると、結果はこうなる。2回目以降は「ハリーポッター」という言葉を使っていないが、「寮」はホグワーツの話として解釈されている。
なぜこの記事を書いたか
...茶番は置いておいて、上のやり取りを実現するのは少し面倒だという話をしよう。ChatGPTのAPIは 「会話の履歴を覚えていない」 ため、思い通りの問答を実現するには少々工夫がいる。
ChatGPT APIの記事が各所ですごい速さで書かれているが、皆さんは 「連続した会話」 を試してみただろうか?
curl --location 'https://api.openai.com/v1/chat/completions' \
--header 'Authorization: Bearer XXXXXXXXX' \
--header 'Content-Type: application/json' \
--data '{
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "ハリーポッターの組み分け帽子のやつ、やったことある?"}]
}'
{(前略),"choices":[{"message":{"role":"assistant","content":"\n\n申し訳ありませんが、私は人工知能の言語モデルであるため、経験を持っていません。しかし、ハリーポッターシリーズのファンは組み分け帽子について話すことがあります。"},"finish_reason":"stop","index":0}]}
例えば、さっきの組分け帽子の話を、APIに振ってみるとする。
curl --location 'https://api.openai.com/v1/chat/completions' \
--header 'Authorization: Bearer XXXXXXXXX' \
--header 'Content-Type: application/json' \
--data '{
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "もしあなたが入るとしたら、どの寮?"}]
}'
{(前略),"choices":[{"message":{"role":"assistant","content":"\n\n申し訳ありませんが、私はAI言語モデルであり、実際の情報や場所にアクセスすることはできません。あなた自身の好みや条件に基づいて、適切な寮を選択することをお勧めします。"},"finish_reason":"stop","index":0}]}
続けて寮の話をする。が、会話が続かない。 続かないというのは、コミュ障とかではなく 「会話の履歴を覚えていない」ため、文脈がリセットされてしまう。
WebのChatGPTで組分け帽子の話を振った場合の返答
私は人工知能のChatGPTであり、自分自身に個性や思想などの概念はありません。そのため、私がホグワーツ魔法魔術学校に入学した場合、組み分け帽子が私をどの寮に割り当てるかは分かりません。ただし、私は知識や言語理解に関するタスクに優れているため、知恵を大切にするラベンクロー寮に割り当てられる可能性が高いと思われます。
普通にChatGPTを使うと、前の会話内容を踏まえた自然な受け答えになる。 この挙動をAPIでも実現した(会話ではないが)のが、今回作るコンポーネントである。
最終的にはこんな処理をする。静的サイトジェネレータのAstroを選んだことで、この処理はビルド時に一度だけ実行される。
以下のコードでは、このように通信内容を確認できるようにしてある。上手で示したように、倍々になるわけではないが、ループを経る度にトークン消費が増加するため、何度も実行するのはおすすめしない。
APIの準備
支払い設定画面で課金を有効化し、課金のハードリミットを必ず設定すること。
APIキーをコピーしたら、パスワードマネージャなどに厳重保管すること。
事前に必要なスクリプトを用意
前提として、Astroの開発環境を用意する。yarn create astro
すればカワイイロボットが案内してくれるよ。
OPENAI_API_KEY=<ここにAPIキー>
# OPENAI_API_USE_MOCK=1
上記のような環境変数を設定する。
yarn astro add tailwind
yarn add chalk daisyui openai safe-marked
Tailwind CSSと、その他ライブラリを追加する。また、以下の細かいスクリプトを配置すること。
その他の事前に必要なスクリプト
import chalk from "chalk";
function boldWhite(label: string) {
return chalk.bold(chalk.white(label));
}
export function logWithRedTitle(label: string, string = "") {
return `${boldWhite(chalk.bgRed(label))} ${string}`;
}
export function logWithBlueTitle(label: string, string = "") {
return `${boldWhite(chalk.bgBlue(label))} ${string}`;
}
ログに色を付けるだけ。
/**
* 順番を保証したPromise配列のmap
* 以下のパッケージより再使用 copied from the package below:
* p-map-series
MIT License
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
* @see https://github.com/sindresorhus/p-map-series/blob/main/license
*/
export async function promiseMapSeries<T, MappedT>(
iterable: Iterable<PromiseLike<T> | T>,
mapper: (element: T, index: number) => PromiseLike<MappedT> | MappedT
) {
const result = [];
let index = 0;
for (const value of iterable) {
result.push(await mapper(await value, index++));
}
return result;
}
順番を保って通信するためのコード。p-map-series
というパッケージから拝借(実質この関数だけのパッケージだった)。BluebirdのmapSeries
に相当する。
ChatGPT APIと通信するコードを用意
以下が核となる通信部分である。配列の操作が見苦しいかもしれないがご了承願う。
import {
ChatCompletionRequestMessage,
Configuration,
CreateChatCompletionResponse,
OpenAIApi,
} from "openai";
import { createMarkdown } from "safe-marked";
import { logWithBlueTitle, logWithRedTitle } from "./chalk";
import { promiseMapSeries } from "./promise";
const config = new Configuration({
apiKey: import.meta.env.OPENAI_API_KEY,
});
/** 独自にMarkdownをパースしたHTML付きのメッセージ */
interface ChatMessageWithHtml extends ChatCompletionRequestMessage {
html: string;
}
/** 各リクエストの回答 */
interface ChatGPTAnswer {
message: ChatMessageWithHtml;
data: CreateChatCompletionResponse | null;
}
/** 最終的にフロントに渡すデータ */
interface ChatGPTConversationData {
/**
* メッセージの配列
*/
results: ChatGPTAnswer["message"][];
/**
* 検証に使用
*/
dataArray: ChatGPTAnswer["data"][];
}
const ANSWER_MOCK: ChatGPTAnswer = {
message: {
role: "assistant",
content: "これはモック用回答です",
html: "これはモック用回答です",
},
data: {
id: "chatcmpl-XXXXXXXXXXXXXXXXXX",
object: "chat.completion",
created: 1000000000,
model: "gpt-3.5-turbo-0301",
choices: [],
},
};
/**
* ChatGPTに会話内容を渡し、メッセージとレスポンスを返す
*/
export async function getChatGPTAnswer(
/** それまでの会話内容 */
messagesWithHtml: ChatMessageWithHtml[]
): Promise<ChatGPTAnswer | null | undefined> {
if (import.meta.env.OPENAI_API_USE_MOCK) {
console.info(logWithBlueTitle("OPENAI API無効化中", "モックテキストを表示しています"));
// デザインのテスト中に使うモックテキストとデータ。2023年3月3日現在のレスポンスを基に作成
return ANSWER_MOCK;
} else {
const client = new OpenAIApi(config);
// HTMLを混入させると無効なリクエストになるため削除する
const messages = messagesWithHtml.map(({ html, ...rest }) => rest);
console.info(logWithRedTitle("OPENAI API使用中", "チャット内容:"));
console.log(messages);
// 回答をリクエスト
const response = await client
.createChatCompletion({
model: "gpt-3.5-turbo",
messages,
})
.catch((e) => {
throw new Error(e.response ? JSON.stringify(e.response.data) : e);
});
if (response) {
const usage = response.data.usage;
if (usage) {
console.info(logWithRedTitle("OPENAI APIの使用料を消費:"));
console.info(usage);
}
// choicesは`n`指定なしだと1個が上限
const firstChoice = response.data.choices[0];
const answer = firstChoice.message?.content ?? "";
const html = createMarkdown()(answer);
return { message: { role: "assistant", content: answer, html }, data: response.data };
}
}
}
/**
* 複数の一方的な発言から、文脈を保った会話を生成
*/
export async function getMultipleChatGPTAnswers(
prompts: string[]
): Promise<ChatGPTConversationData> {
/**
* ユーザーが入力したメッセージ
*/
const messages: ChatMessageWithHtml[] = prompts.map((content) => ({
content,
role: "user",
// ユーザーがMarkdownを入力することはないという想定
html: content,
}));
const results: ChatMessageWithHtml[] = [];
return await promiseMapSeries(messages, async (message) => {
const nextMessages = [...results, message];
return await getChatGPTAnswer(nextMessages).then((result) => {
results.push(message);
if (result) {
results.push(result.message);
}
return result?.data ?? null;
});
}).then((dataArray) => {
console.info(logWithBlueTitle("OPENAI API 通信完了"));
return { results, dataArray };
});
}
-
以前の結果を使うため、やたら面倒なループをしている
- Promiseが未だに深く理解できておらず、Sindre Sorhus氏のコードを借用した
- 列挙させたりすると、AIがMarkdownを書いてくるため、
safe-marked
で都度パースしている- ReactみたいにMD描画コンポーネントが作れるなら不要だが、ドキュメントでも外部のMDは自分ででパースすることとされている
コンポーネントを用意
こういうコミュニティ、SWをサンプルに使いがち
DaisyUIにChat bubbleというチャット用のコンポーネントがある。大変便利なので活用する。ただし色は変更している。
---
import { getMultipleChatGPTAnswers } from "../utils/openai";
if (import.meta.env.PROD) {
if (import.meta.env.OPENAI_API_USE_MOCK) {
throw new Error("OpenAIモック用環境変数を本番で使わないでください");
}
if (!import.meta.env.OPENAI_API_KEY) {
throw new Error("OpenAI APIキーを設定してください");
}
}
const prompts = [
"限りなくワイルド・スピードの邦題っぽいカタカナワードを並べてください。",
"そうじゃない。ワイスピのサブタイトルっぽいワードを並べてくれ。",
"その中から、実際はワイルド・スピードのサブタイトルではないものを抜き出してくれ。",
];
const { results, dataArray } = await getMultipleChatGPTAnswers(prompts);
---
<div class="mockup-window bg-base-300 border">
<div class="flex flex-col gap-y-8 bg-slate-300 p-4">
<div class="flex flex-col gap-y-3">
{
results.map(({ html, role }) =>
role === "user" ? (
<div class="chat chat-start">
<div
set:html={html}
class="chat-bubble bg-white text-black dark:bg-black dark:text-white"
/>
</div>
) : (
<div class="chat chat-end">
<div set:html={html} class="chat-bubble bg-slate-700 dark:bg-slate-800" />
</div>
)
)
}
</div>
<div
tabindex="0"
class="collapse-plus border-base-300 rounded-box collapse border bg-white dark:bg-black dark:text-white"
>
<div class="collapse-title text-xl font-medium">本当にChatGPTに聞いてる証拠</div>
<div class="collapse-content">
<pre
class="my-0"><code class="whitespace-pre-wrap">{JSON.stringify(dataArray, null, "\t")}</code></pre>
</div>
</div>
</div>
</div>
「本当にChatGPTに聞いてる証拠」部分は蛇足。消していい。
(余談だが、「フジテレビ春の名作ドラマ祭り65」の新聞広告で「AIと対談」みたいな設定のものを見かけ、「AIっぽい文章を書くライターなんてやりたくねえ~~」と思ったので付けている)
import ChatGPT from "../components/ChatGPT.astro"
---
<ChatGPT />
最後に、コンポーネントをページに配置する。環境変数は露出してないので、クライアントサイドで動かす指定はしてはいけない。
デザイン変更時はモックデータを使え
吹き出しのデザインをいじりたいかもしれない。だがちょっと待った!
OPENAI_API_KEY=XXXXXXXXXXXXXXXXX
- # OPENAI_API_USE_MOCK=1
+ OPENAI_API_USE_MOCK=1
Astroのコードに編集を加えると、ホットリロードが入って通信が発生してしまう。 開発中は通信を防ぐため、環境変数OPENAI_API_USE_MOCK
のコメントアウトを解除すること。
めちゃくちゃ冷酷なAIみたいになってるな
この環境変数を付けている間、無駄なトークン消費が防止でき、回答の部分はモックで置き換えられる。
デプロイ
静的サイトなので使うサービスは問わない。必ずOPENAI_API_KEY
を指定すること。
Discussion