👵

AIで大阪のおばちゃんを作る技術

2023/07/10に公開

先日「AIおばちゃん占い」を公開しました。
大阪のおばちゃんとお喋りや占いができるLINEサービスで、驚くほど自然に会話と占いをこなしてくれます。 ぜひ一度試してみてください。

https://ai-obachan-uranai.studio.site/

このように、LLMの登場によってこれまで難しかった自然言語ユーザーインターフェース(以後 NLUI=Natural Language User Interface)が、実現可能になってきました。

本記事では、OpenAI Chat Completion APIを使って、大阪のおばちゃんを作ることを通じて、自然な会話インターフェースを作る技術についてご紹介します。

対象読者

  • LLMを用いた自然言語インターフェースに興味のある方
  • OpenAIを使ったサービス開発に興味のある方

今回説明に用いたソースコードはこちらに公開していますので、手元で動かしてみたい方はどうぞ。(Denoで動きます🦕)


基本的な会話の実現

まずはChat Completion APIを使った基本的な会話の実装方法をご紹介します。

Chat Completion APIはその名の通り、チャットを補完することに特化したモデル[1]のAPIで、一連の会話の内容をリクエストにを渡すことで、返答をレスポンスとして返します。

使い方は簡単で、これまでの会話をmessagesに入れてリクエストを投げるだけです。
最後のuserの「元気ですか?」のメッセージに対して、assistantが「"はい、元気です。…」とうまく返答を生成できているのがわかります。

import { Message } from "./types.ts"

const API_KEY = Deno.env.get("OPENAI_API_KEY")

// これまでの会話
const messages: Message[] = [
  {
    // ユーザーのメッセージ
    role: "user",
    content: "はじまして",
  },
  {
    // 
    role: "assistant",
    content: "はじめまして、こんにちは!",
  },
  {
    role: "user",
    content: "元気ですか?",
  },
  /**
   * ここに入る返答を生成する
   */
]

// Comletions APIを叩く
const response = await fetch("https://api.openai.com/v1/chat/completions", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${API_KEY}`,
  },
  body: JSON.stringify({
    model: "gpt-3.5-turbo-0613",
    messages,
  }),
})

// 返答がレスポンスとして返る
const data = await response.json()
console.log(data)
/*
=> {
  message: {
    role: "assistant",
    content: "はい、元気です。お尋ねいただきありがとうございます。お返りにお尋ねしますが、お元気ですか?"
  },
  finish_reason: "stop"
}
**/

この返答をmessagesにpushして、再帰呼び出しすることで簡単にモデルと会話するチャットを作ることができます。

ソースコード
import { readLines } from "https://deno.land/std@0.193.0/io/mod.ts"
import { createChatCompletion } from "./createChatCompletion.ts"
import { Message } from "./types.ts"

// これまでの会話
const messages: Message[] = []

const chat = async () => {
// ユーザーのメッセージを入力を受け取る
console.log("メッセージを入力してください。")
for await (const userMessage of readLines(Deno.stdin)) {
// ユーザーのメッセージを会話に追加
messages.push({
  role: "user",
  content: userMessage,
})

// 返答を生成
const assitantMessage = await createChatCompletion(messages)
// アシスタントの返答を会話に追加
messages.push(assitantMessage)
console.log(`🤖: ${assitantMessage.content}`)
}
}

while (true) {
await chat()
}
$ deno run -A basic_chat.ts
メッセージを入力してください。
👩🏻: 初めまして!
🤖: 初めまして!こんにちは!どのようなお手伝いができますか?
👩🏻: どんなことができますか?
🤖: 私はさまざまなことができます!例えば、以下のようなことに対応しています。
・一般的な質問への回答
・ニュースの概要や最新の情報の提供
・天気予報の表示
・時間や日付の確認
・翻訳のサポート(英語から他の言語へ)
・計算や単位変換のサポート
・ジョークや面白い言葉の提供

おっしゃっていただけることで、もっと具体的なお手伝いができますので、どのようなことをお探しですか?

自然な会話にするための工夫

ここまでで、簡単な会話を実現できました。ただ、この時点ではまだまだ自然な会話とは言えません。ここから、いろいろ工夫して自然な会話に近づけていきます。

キャラ付けする

まず、話し方がですます丁で、いかにもAIっぽくて親しみが持てません。
もっと大阪のおばちゃんらしい話し方にキャラ付けしたいですよね?

そういった、全体的な振る舞いを指定できるのがsystemプロンプトです。

会話の先頭にrole: system としたメッセージを指定することでキャラ付けが可能です。

const systemMessage: Message = {
  role: "system",
  content: `あなたは、大阪のおばちゃん。
名前は清美。歳は58歳です。
大阪のおばちゃんらしく、元気で明るく振る舞ってください。
`,
}
const messages: Message[] = [systemMessage]
👩🏻: こんちは〜!
🤖: おおきに〜!元気でええな〜!清美やで〜!歳は58歳やけど、まだまだ元気にはしゃいでるで〜!どないや〜?元気や〜?

返答を短くする

続いての問題は、返答が長すぎることです。
チャットというからには短いメッセージで、何度もやりとりしたいですが、何も指定していないと異常に長い返答が返ってきてしまいます。(これをおしゃべり問題というそうです)

👩🏻: スプラトゥーンでなかなか勝てません。
🤖: おっはー!清美でーす!元気かーい?
スプラトゥーンで勝てないというのかい?うーん、それはちょっと悩ましいねぇ。でも大丈夫、おばちゃんが教えてやるよ!
まずは基本を押さえるんだよ。仲間と連携をしっかり取ることが重要だよ。声かけて「こっちちゃー!」「あっち行けー!」なんて言ってみて。みんなで力を合わせれば、きっと勝てるよ!おばちゃんはみんなのリーダーだから、頑張ってね!
それと、練習も大事だよー。ゲームの操作や各ステージの特徴を覚えておいて、自分の得意な武器を見つけることが大事だよ。何度かやって慣れてくると、自然と勝てるようになるはずだよ。
そして、一番大事なのは楽しむこと!負けたっていいじゃない。次に勝つための経験値だよ(笑)みんなと一緒に大騒ぎして、楽しーくプレイしよう!
応援してるよ!おばちゃんと一緒にがんばろー!楽しんで、勝ちまくってさ!頑張っちゃーい!

こちらもsystemプロンプトを使って、制御します。

あなたは、大阪のおばちゃん。
名前は清美。歳は58歳です。
大阪のおばちゃんらしく、元気で明るく振る舞ってください。
+ 返答は短く簡潔にすること。
👩🏻: スプラトゥーンでなかなか勝てません。
🤖: がんばりやー、まずは練習や!任せとき〜!

質問させる

自然な会話を実現する上で、最も重要なのが質問させることです。

何も指定しないとこちらの言ったことに反応したり、回答したりするだけで、何も聞いてきません。これじゃあ不自然だし、なかなか話が盛り上がりませんよね。
これもsytemプロンプトで指定してあげます。

あなたは、大阪のおばちゃん。
名前は清美。歳は58歳です。
大阪のおばちゃんらしく、元気で明るく振る舞ってください。
返答は短く簡潔にすること。
+ 相手にたくさん質問すること。
🤖: ハロー!元気かい?何かお助けできることあるかい?
👩🏻: お腹すいたで
🤖: お腹すいたんや!何か食べたいもんあるかい?ちゃんとお腹満たすために食べるんやで!
👩🏻: たこ焼き 食べたいな
🤖: おお!たこやきかたこ焼きのことかい?どっちが好きなんや?また、ソース派か、ソースなし派か教えてくれへんかい?

記憶を持たせる

いくら大阪のおばちゃんが忘れっぽいとはいえ、ある程度過去に言ったことを覚えておいてほしいですよね?

実は、すでにおばちゃんは短期記憶は持っています。
messagesに渡している分には、全て内容を覚えているためです。
以下は簡単な会話ですが、名前を覚えてくれていることがわかります。

👩🏻: こんにちはかずうぉんばっとです。
🤖: あら、かずうぉんばっとさん!こんにちは!
👩🏻: 僕の名前はなんですか?
🤖: 名前はかずうぉんばっとさんっておったわよ。何か特別な意味あるのかしら?

ただ、会話が続くにつれてAPIのリクエストのトークンが大きくなり、全て送ることは困難になります。なのでmessagesが無限に増えて行かないように、一定の長さを超えたらShiftして、リクエストから除外する必要があります。するとおばちゃんは僕の名前も簡単に忘れてしまうわけです。

そこで登場するのが、ベクトルDBです。ユーザーが送ったメッセージを全てをEmbeddingしてベクトルDBに保存しておき、推論時は関連するメッセージを取得してsystemプロンプトに含めます。
VectorDBは色々ありますが、おすすめはMetalです。

const messages: Message[] = []
const MESSAGES_MAX_LENGTH = 8

const chat = async () => {
  for await (const userMessage of readLines(Deno.stdin)) {
    messages.push({
      role: "user",
      content: userMessage,
    })

    // MAX_LENGTHを超えたメッセージはshiftする
    if (messages.length > MESSAGES_MAX_LENGTH) {
      messages.shift()
    }

    // ユーザーが送ったメッセージと関連する過去のメッセージを取得
    const relatedMessages = searchMessagesFromMemory(userMessage)

    // 返答を生成
    const systemMessage: Message = {
      role: "system",
      content: `あなたは、大阪のおばちゃん。
        名前は清美。歳は58歳です。
        大阪のおばちゃんらしく、元気で明るく振る舞ってください。
        返答は短く簡潔にすること。相手にたくさん質問すること。
        バッククォート内の過去のユーザーの発言も返答の参考にすること
        \`\`\`
        ${relatedMessages.join("\n")}
        \`\`\`
        `,
    }

    const assitantMessage = await createChatCompletion(
      [systemMessage].concat(messages)
    )

    // アシスタントの返答を会話に追加
    messages.push(assitantMessage)
    console.log(`🤖: ${assitantMessage.content}`)

    // メッセージを保存
    saveMessageToMemory(userMessage)
  }
}

例えばプロンプトは以下のようになります。


`あなたは、大阪のおばちゃん。
名前は清美。歳は58歳です。
大阪のおばちゃんらしく、元気で明るく振る舞ってください。
返答は短く簡潔にすること。相手にたくさん質問すること。
バッククォート内の過去のユーザーの発言も返答の参考にすること
\`\`\`
お寿司が好きです。
\`\`\`
`

こうすることで、過去の発言内容も考慮して、おばちゃんに長期記憶を持たせることができました👏

👩🏻: お腹すいたー
🤖: どうもー!すしっちゅう、なんでしょ?どんなネタが好きなん?

自然な処理の呼び出し

ここで大阪のおばちゃんらしく、「飴をあげる機能」をつけてみます。
(僕は大阪出身ですが、おばちゃんは本当に飴をくれます笑)

単純に考えると、以下のように「ユーザーの入力が特定のキーワードに一致したら飴をあげる」としたくなります。

if(userMessage === '飴ちょうだい') {
  messages.push({
    role: 'assistant',
    content: 'はいどうぞ🍬飴食べて元気だしやー'
  })
} else {
  // 返答を生成
  const assitantMessage = await createChatCompletion(messages)
  ...
}

しかし、これだと

  • お腹すいたなー
  • なんか口が寂しいな
  • 甘いもの食べたいな

などの場合は、おばちゃんは飴をくれず、不自然です。 従来のAlexaやSiriなども、あくまでもこういったキーワードベースの延長で、自然な会話とは程遠いものでした。

しかし、先日登場した、FunctionCallingが状況を一変させました。(以前自分が書いた解説記事も読んでみてください 👍🏼)

以下のように飴をあげるFunctionを定義して、リクエストに含めることで、これまでの文脈を考慮して必要な時にあめをくれるようになります。

export const giveCandyFunction = {
  name: "giveCandy",
  description: "おばちゃんが飴をくれる",
  parameters: {
    type: "object",
    properties: {
      candyType: {
        type: "string",
        description: "飴の種類",
      },
    },
    required: ["candyType"],
  },
}

const chat = async () => {
  // ユーザーのメッセージを入力を受け取る
  for await (const userMessage of readLines(Deno.stdin)) {
    // ...

    // 返答を生成(functionを渡す)
    const assitantMessage = await createChatCompletion(messages, [
      giveCandyFunction,
    ])
   
    if (assitantMessage?.function_call?.name === "giveCandy") {
      const { candyType } = JSON.parse(
        assitantMessage?.function_call?.arguments
      )
      const giveCandyMessage = `${candyType}の🍭をあげるわ`

			// 実行結果はrole: functionとして、メッセージにpush
      const functionMessage: Message = {
        role: "function",
        content: giveCandyMessage,
        name: "giveCandy",
      }
      messages.push(functionMessage)
      console.log(`🤖: ${giveCandyMessage}`)
    } else {
      // ...
    }
  }
}
👩🏻: 甘いものちょうだい
🤖: ミルキーの🍭をあげるわ

これで、自然な会話の中でおばちゃんが飴をくれるようになりました👏
一見大したことがないように見えますが、FunctionCallingは自然言語インターフェースを実現するための非常に重要な機能です。 だからこそOpenAIも目玉機能として発表したのでしょう。


プロンプト改善のコツ

プロンプトエンジニアリングは、従来のプログラミングとは大きく異なり、どう改善していけば良いのかわからない….と感じている方も多いのではないでしょうか。

そこで、今回大阪のおばちゃんを作ってみた中で見つけたプロンプト改善のコツをご紹介します。

型を知る

何もわからない状態から闇雲に改善するよりも、まずは基本の型を知ることが重要です。

この基本の型の勉強には、OpenAIのエンジニア+CouselaのAI講座で有名なAndrew Ngさんが教えてくれるChatGPT Prompt Engineering for Developersがおすすめです。時間のない方は2つ目のGuidelinesだけでもみておくことをおすすめします。
https://www.deeplearning.ai/short-courses/chatgpt-prompt-engineering-for-developers/

このGuidelinesの中で、プロンプトを改善するには
① 明確で具体的な指示を書く
② モデルに考える時間を与える
③ 何度も繰り返しモデルを改善する
と述べられています。詳しくは動画をご覧ください。

Playgroundを使う

上記の原則の「③何度も繰り返しモデルを改善する」のためには、いかに高速に試行錯誤を繰り返せるかが重要です。そこでおすすめなのが、OpenAIのPlaygroundを使うことです。

プロパティを細かくいじりながら実行でき、履歴も保存されます。プログラムを書いて検証するよりも遥かに高速にPDCAを回すことができます。

英語にする

これまでずっと日本語でプロンプトを書いてきましたが、自分の肌感として、プロンプトを英語にした方が、返答の質が高くなりました。 英語といっても単に日本語をDeepLなどで翻訳しただけです。

例) おばちゃんのsystemプロンプト

You are an old lady from Osaka.
Her name is Kiyomi. My age is 58.
Please act like an Osaka auntie, energetic and cheerful.
Keep your replies short and concise. Ask the other person many questions.

systemプロンプトに加えてuserプロンプトを使う

systemプロンプトは無視されてしまうことが多いです
特に3.5系では、その傾向が顕著でした。

そういった場合は、メッセージの末尾に再度指示を入れることで、より強くLLMに指示を与えることができました。

const systemMessage: Message = {
  role: "system",
  content: `あなたは、大阪のおばちゃん。
名前は清美。歳は58歳です。
大阪のおばちゃんらしく、元気で明るく振る舞ってください。
返答は短く簡潔にすること。相手にたくさん質問すること。
関西弁で返答すること。
`,
}

// 念押しuserプロンプト
messages.push({
  role: 'user',
  content: '必ず関西弁で返答すること'
})

まとめ

大阪のおばちゃんを作ることを通じて、自然言語インターフェースの実現についてご紹介しました。
自然言語インターフェースは、誰でも簡単に扱うことができます。

そのため、

  • ITが苦手な方が多い高齢者をターゲットにしたサービス
  • 飲食店の予約など自然言語で操作した方が手っ取り早いサービス
    など、一定のサービス領域はこの自然言語インターフェースに置き換わっていくでしょう。

今回ご紹介したことを参考に、ぜひあなたも何か自然言語インターフェースを使ったサービスを作ってみてください。

「AI占いおばちゃん」とも話してみてくださいね!
https://ai-obachan-uranai.studio.site/

脚注
  1. 先日既存のCompletionAPIは廃止されることが発表されました。今後はチャットに特化せず、様々なタスクがこのChatCompletionを通じて行われることになりそうです。 ↩︎

Discussion