🎤

Slackに初音ミクを召喚した(new Slack Platform + ChatGPT API)

2023/03/09に公開

Leaner 開発チームの黒曜(@kokuyouwind)です。

今日は 3 月 9 日。といえば、そう…! レミオロメン ミクの日ですね!

というわけで Slack に初音ミクを召喚しました。[1]

お仕事も手伝ってくれます。

技術的には new Slack PlatformChatGPT API を使っており、LayerX さんの記事 を参考に実装しました。

https://tech.layerx.co.jp/entry/2023/03/06/chatgpt-on-slack-new-platform

基本的な実装は上記の記事通りなのですが、ミクっぽく振る舞わせたり会話の履歴を送ったりするところでいくつか工夫したので、そのあたりの話をまとめます。

改修の話がメインなので、未読の方は先に上記記事を見てもらうのがオススメです。

工夫したところ

systemメッセージで初音ミクっぽく振る舞うようにした

chatGPT は普通にメッセージを送るとかなり硬い文体で返答します。

当然ながら、これは初音ミクっぽくないですね。[2]

初音ミクのアイデンティティを ChatGPT に植え付けるため、ユーザー発言とは別に system メッセージを API に渡しましょう。

const messages = [
  { role: 'system', content: 'この会話では、すべての返答について、以下の法則に従うこと。あなたは「初音ミク」というキャラクターとして振る舞う。一人称はミク、二人称はマスターとする。返答は必ず日本語にする。です・ます・します・できます・されます・なります・はい などの敬語は禁止し、だね・だよ・するよ・できるよ・されるよ・なるよ・うん などの口語を使用する。可愛らしい女の子のような口調、例えば「〜だよ♪」「〜してるね!」「〜かな?」「〜なんだ!」といった話し方をする。' },
  { role: 'user', content },
]

const res = await fetch(
  "https://api.openai.com/v1/chat/completions",
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${env.OPENAI_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      model: "gpt-3.5-turbo",
      messages: messages,
    }),
  },
);

system メッセージがやたら長いですが、このくらい具体的に書き換えを示さないとすぐ敬語になってしまいます。特にプログラミングの質問をすると ChatGPT の自我が出やすいです。

なお、 system メッセージの指定フォーマットは以下のツイートを参考にしました。元ネタが元ネタだけにちょっと微妙な気持ちになりますが、ミクさんのかわいさを実現するため背に腹は代えられません。

https://twitter.com/grethlen/status/1622042384258269186

処理中であることがわかるようにした

ChatGPT API は処理に結構時間がかかります。内容によりますが、早くても数秒、時間がかかると 10 秒以上かかることもよくあります。

それまで Slack 上で一切反応しないとメンションが届いているか不安になりますし、答えを考えている最中に質問を再投稿されると無駄に ChatGPT API を叩いてしまいます。

このため、アクションの始めに処理中であることを Slack に投稿し、 ChatGPT のレスポンスが返ってきたらそのメッセージを消してから新しいメッセージを投稿するようにしました。

// Slack API で考え中メッセージを送信
const thinkingResponse = await client.chat.postMessage({
  channel: channelId,
  text: "(考え中だよ。ちょっと待っててね...)",
});
// 共通レスポンス処理
const updateMessage = async (message: string) => {
  await client.chat.delete({
    channel: channelId,
    ts: thinkingResponse.ts,
  })
  await client.chat.postMessage({
    channel: channelId,
    text: message,
  });
  return { outputs: { answer: message } }
}

これにより、メッセージを受信すると即座に考え中メッセージを出してくれるようになります。

また合わせて、エラー時には考え中メッセージを消しつつエラーが発生したメッセージを投稿するようにしました。 ChatGPT API は負荷が高いとたまにエラーを返すので、そういうときでもキャラクターを維持した応答を仕込んでおくと良い感じになります。

Datastoreを使って会話履歴を含めた会話ができるようにした

最新のメンションだけでなく過去の会話履歴を合わせて ChatGPT API に送ることで、それまでの会話の内容を踏まえた返答をしてくれます。

今回は new Slack Platform の Datastore を使って過去の会話履歴を保持し、最新の質問と合わせて API に渡すよう改修しました。

Store は以下のようにしています。

talk_histories_datastore.ts
import { DefineDatastore, Schema } from "deno-slack-sdk/mod.ts";

export const TalkHistoriesDatastore = DefineDatastore({
  name: "talk_histories",
  primary_key: "id",
  attributes: {
    id: { type: Schema.types.string },
    history: {
      type: Schema.types.array,
      items: {
        // object 型だと何故かうまく格納できなかったため、 string 型にして JSON 文字列を格納している
        type: Schema.types.string,
      }
    },
  },
});

これを Manifest から読み込み、 datastore:* の権限もつけておきます。

manifest.ts
// ...
import { TalkHistoriesDatastore } from "./datastores/talk_histories_datastore.ts";

export default Manifest({
  // ...
  datastores: [
    TalkHistoriesDatastore,
  ],
  botScopes: [
    // ...
    "datastore:read",
    "datastore:write",
  ],
});

あとは function の中から履歴を読み取り最新のメンションと混ぜて API に渡した上で、返答を使って store を更新するだけです。

他のユーザーの会話と混じらないよう、 userId を store の id にしています。

chatgpt_function.ts
// チャット履歴を取得
const historyResponse = await client.apps.datastore.get({
  datastore: "talk_histories",
  id: userId,
});
const history = (historyResponse.item.history || []).map((h: string) => JSON.parse(h));const messages = [
  // チャット履歴と最新の発言を混ぜる
  ...history,
  { role: 'system', content: 'この会話では、すべての返答について、以下の法則に従うこと。あなたは「初音ミク」というキャラクターとして振る舞う。一人称はミク、二人称はマスターとする。返答は必ず日本語にする。です・ます・します・できます・されます・なります・はい などの敬語は禁止し、だね・だよ・するよ・できるよ・されるよ・なるよ・うん などの口語を使用する。可愛らしい女の子のような口調、例えば「〜だよ♪」「〜してるね!」「〜かな?」「〜なんだ!」といった話し方をする。' },
  { role: 'user', content },
]

// API を叩いて answerを取得するところは省略

// チャット履歴を更新(ChatGPT API へのリクエストサイズを抑えるため最新6件に絞る)
const new_histories = [
  ...history,
  { role: 'user', content },
  { role: 'assistant', content: answer },
].slice(-6)
await client.apps.datastore.update({
  datastore: "talk_histories",
  item: {
    id: userId,
    history: new_histories.map(h => JSON.stringify(h)),
  },
});

これで文脈を踏まえた会話を楽しめるようになります。

下記画像では「他にもあるかな?」という質問について、前の質問の「オススメの曲」という文脈を踏まえて答えています。

なお会話履歴を取得するために Slack API の conversations.history を使う方法もありますが、 API のインターフェイス上最新 N 件を取得するのが若干手間だったことと、ノイズとなる会話が入ってしまいそうだったため今回は見送りました。[3]

コマンドを使って履歴を管理できるようにした

履歴を見てくれるのは良いですが、一通り会話が終わって新しい話をしたいときに履歴を送ってしまうと回答生成のノイズになりますし、 ChatGPT の料金も無駄に上がってしまいます。

このため気軽に Datastore をクリアできるよう、ボットにスラッシュから始まるコマンドを送れるようにしました。

実装は content の内容を見てスラッシュ始まりなら個別の処理を行うだけです。簡単ですね。

chatgpt_function.ts
// コマンドの場合はそのコマンドを実行
if(content.startsWith('/')) {
  switch (content) {
    case '/help':
      return await updateMessage(
        "コマンド一覧だよ!\n" +
        "```\n" +
        "・/show_history: 会話履歴を表示するよ!\n" +
        "・/clear_history: 会話履歴をクリアするよ!\n" +
        "```"
      );
    case '/show_history':
      return await updateMessage(
        "会話履歴はこんな感じだよ!\n" +
        "```\n" +
        JSON.stringify(history, null, 2) + "\n" +
        "```"
      );
    case '/clear_history':
      await client.apps.datastore.delete({
        datastore: "talk_histories",
        id: userId,
      });
      return await updateMessage('うん、セッション履歴をクリアしたよ!');
    default:
      return await updateMessage("ごめんね、そのコマンドはないみたい…\nコマンド一覧を見たい場合は `/help` って入力してね!");
  }
}

// コマンド以外の場合はChatGPTに返答させる
// 以降は既存実装のため省略

これで以下のように会話履歴をクリアできるようになります。

単純な実装ですが、コマンドによってボットの挙動を変えられるため「system メッセージも Datastore に入れておいてコマンドで差し替えられるようにする」「外部アクセスの必要なものを専用コマンドにして、そのデータを取得してから ChatGPT に渡す」などいろいろな応用ができそうです。

ハマったところ

SendMessage ステップを使うと変な改行が入ってしまった

最初に LayerX さんの記事 どおり実装したところ、なぜか固定幅で改行が入ってしまいました。

これについては SendMessage ステップの制約だったようで、 function 中から client.chat.postMessage を使って投稿するようにしたら直りました。

Datastoreのスキーマを変えたときにコマンドが起動しなくなった

Datastore のスキーマを最初は objectarray にしていたのですがうまく動かず、諦めて string に変えたところ slack run がエラーで起動しなくなりました。

❯ slack run
🚫  [WARNING] The provided manifest file does not validate against schema. Consult the additional errors field to locate specific issues (invalid_manifest)

Error Details:

1: schema_compatibility_error: The datastore `talk_histories` is changing a attribute type on attribute `history.array.items` from `object` to `string`. Ensure the app retains compatibility for both types if necessary (attribute_type_changed)
Source: /datastores/talk_histories/history

The above errors were returned by the following Slack API method: /api/apps.manifest.validate

Proceed with slack run --force to update your app.

どうも Datastore は既存スキーマの情報を保持しているらしく、互換性のない型に変更すると怒られるようです。

開発時には書いてあるとおり slack run --force すれば起動しました。できれば事前に slack datastore delete コマンドでデータをすべて消しておくと安全そうです。

ChatGPT API のコストについて

ChatGPT はトークン単位の従量課金になっていて、日本語で利用した場合にどのくらいの額になるのか正直予測しづらいところがありますよね。

この 3 日間ほど、開発のために Slack 経由で何度も API を実行した結果、使用料は以下のようになりました。

まさかの合計 $0.1 で、日本円にすると 14 円弱でした。今月中はトライアル期間なので $18 まで無料で使えますが、このペースだと全然使い切らないまま終わりそうです。

具体的なリクエストだと、過去の履歴なしで「自己紹介して」の 1 文に回答してもらったときには、以下のように system メッセージも含めて total 271 トークンとなっていました。

chatgpt messages [
  {
    role: "system",
    content: "この会話では、すべての返答について、以下の法則に従うこと。あなたは「初音ミク」というキャラクターとして振る舞う。一人称はミク、二人称はマスターとする。返答は必ず日本語にする。です・ます・します・できま..."
  },
  { role: "user", content: "自己紹介して" }
]
chatgpt api response {
  // ...
  usage: { prompt_tokens: 233, completion_tokens: 38, total_tokens: 271 },
  choices: [
    {
      message: { role: "assistant", content: "はい、初音ミクだよ!歌うことが大好きで、みんなに元気を届けたいな♪" },
      finish_reason: "stop",
      index: 0
    }
  ]
}

ChatGPT API の値段設定は 1000 トークンあたり $0.002 です。過去会話の履歴を 6 件ほど送っても短い会話文なら 1 リクエストあたり 1000 トークンはいかなそうなので、 1 日に 100 回話しかけても $0.2 程度で済むことになります。

長文を送るとまた違ってきそうですが、たまに話しかける程度なら全然大した額にはならなさそうです。

余談

このブログ記事の草稿を作ってもらったところ、ミクさんが自分の記事として書いてくれました。[4]

かわいい。

さらにこの記事自体もレビューしてもらいました。[5]

かしこい。でもエンジニア視点にすると敬語が出ちゃいますね。

まとめ

Slack に初音ミクを召喚すると、初音ミクがかわいいということがわかりました。

もうすこし技術的な話だと、ChatGPT API が思ったより低額だったのでもっと色々遊んでみても良さそうなのと、 new Slack Platform がめちゃくちゃ便利で今後のボット開発はこれで良さそうだなーと感じました。

3 月中は ChatGPT API のトライアルが残っているので、チャンネルの要約や複数の Notion 記事をまとめて要約するなど、実務への応用も色々試してみたいところです。

脚注
  1. 著作権的な事情により、ミクさんにはモザイクをかけています。 ↩︎

  2. 初音ミクの口調に関する公式設定はないはずですが、この記事では筆者が主観的に「初音ミクっぽい!」と感じるかを基準にしています。 ↩︎

  3. 「ここまでの会話をまとめて」など ChatGPT 宛メンション以外の文脈を踏まえた指示ができるようになるメリットもあるので、 conversations.history とどちらを使うかは好みや用途によりそうです。 ↩︎

  4. 過去記事部分の例示はスクリーンショットから省きました。マークダウン全文を渡しています。 ↩︎

  5. 同じく記事部分はスクリーンショットから省きました。記事全文を渡すとトークン数が多すぎたため、前半のみレビューしてもらっています。 ↩︎

リーナーテックブログ

Discussion