🤨

ChatGPT APIで「文脈を保った会話」をし、チャット風に表示する

2023/03/03に公開約11,600字

今日作るもの

会話の例
それ以外も異物混入してるだろ

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すればカワイイロボットが案内してくれるよ。

.env
OPENAI_API_KEY=<ここにAPIキー>
# OPENAI_API_USE_MOCK=1

上記のような環境変数を設定する。

yarn astro add tailwind
yarn add chalk daisyui openai safe-marked

Tailwind CSSと、その他ライブラリを追加する。また、以下の細かいスクリプトを配置すること。

その他の事前に必要なスクリプト
utils/chalk.ts
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}`;
}

ログに色を付けるだけ。

utils/promise.ts
/**
 * 順番を保証した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と通信するコードを用意

以下が核となる通信部分である。配列の操作が見苦しいかもしれないがご了承願う。

utils/openai.ts
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 で都度パースしている

コンポーネントを用意

Chat bubble
こういうコミュニティ、SWをサンプルに使いがち

DaisyUIにChat bubbleというチャット用のコンポーネントがある。大変便利なので活用する。ただし色は変更している。

components/ChatGPT.astro
---
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っぽい文章を書くライターなんてやりたくねえ~~」と思ったので付けている)

page.astro
import ChatGPT from "../components/ChatGPT.astro"
---
<ChatGPT />

最後に、コンポーネントをページに配置する。環境変数は露出してないので、クライアントサイドで動かす指定はしてはいけない。

デザイン変更時はモックデータを使え

吹き出しのデザインをいじりたいかもしれない。だがちょっと待った!

.env
OPENAI_API_KEY=XXXXXXXXXXXXXXXXX
- # OPENAI_API_USE_MOCK=1
+ OPENAI_API_USE_MOCK=1

Astroのコードに編集を加えると、ホットリロードが入って通信が発生してしまう。 開発中は通信を防ぐため、環境変数OPENAI_API_USE_MOCKのコメントアウトを解除すること。

モックデータ仕様時の様子
めちゃくちゃ冷酷なAIみたいになってるな

この環境変数を付けている間、無駄なトークン消費が防止でき、回答の部分はモックで置き換えられる。

デプロイ

静的サイトなので使うサービスは問わない。必ずOPENAI_API_KEYを指定すること。

Discussion

ログインするとコメントできます