🐬

LINE×Azureでリーズナブルに自分だけのキャラクターbotをつくる(会話記憶あり)

2024/04/25に公開

はじめに

こんにちは。美少女AIエージェント(My秘書)の開発を目指して日々頑張っているともどです。
今日はLINEとAzureを組み合わせてリーズナブルにLINE chatbotを開発することに取り組みます。

リーズナブルとは言っても、ただ単発のメッセージのやり取りをするだけのbotでなく、お姉さん風のキャラ付けや直前の会話内容を踏まえての会話などにこだわり、使い続けるモチベーションが湧くchatbotを目指します。

背景

美少女AIエージェント(My秘書)に生活を豊かにしてもらうという夢を持っています。

その中でも最近はフロントエンドとしてLINEに注目しています。

LINEは多くの人にとってもっとも身近なchatUIであると同時にAPIで簡単にchatbotを作れる気楽さも持ち合わせています。

また、MessagingAPIやLIFFを用いればかなり凝った仕様にすることもでき、長く開発していけるサービスだと思っています。

これが美少女AIエージェントの1つのデファクトになることも期待して、実用的でありながらもリーズナブルに実装できる手段としてLINE×Azureの組み合わせを紹介したいと思います。

せっかくなのでlangchainのLCELやlangsmithなどトレンドのLLMツールも取り入れようと思います。

今回のchatbotの特徴

このchatbotは以下の特徴を持っています:

  1. 会話記憶機能:CosmosDBに会話履歴を保存することで、直近10件の会話内容を踏まえたやりとりができます。
  2. 低コストでの運用:Azureの無料枠を活用し、低コストでの運用を実現します。
  3. 魅力的?なキャラ設定:お姉さん風のキャラクターを設定します。
  4. langsmithへのログ記録:langchain × langsmithで実装することでLLM処理のログをlangsmith上に残せるようにします。
  5. ローディング画面の表示: LLMの処理中はLINEにローディング画面を表示させます。

とくに会話記憶とキャラクター付けはこだわりを持っています。会話記憶はないと明らかに会話としてのリアリティーが失われるので絶対必要だと思っています。また、キャラクター付けは使用するモチベーションを掻き立てます。やはり作るからには、その後も日常的に使い続けられるものを目指したいです。

LINE×Azureで、低コストかつ本格的なチャットボットを開発できることを実感していただければ幸いです。

アーキテクチャ

構成図

以下のようなアーキテクチャを採用しています。

構成図
構成図

  1. ユーザからのメッセージをLINE Messaging APIで受信
  2. AppServiceで動作するFastAPIアプリケーションがWebhookでメッセージを処理
  3. CosmosDBから直近の会話履歴を取得し、プロンプトに追加する
  4. Anthropic APIを呼び出し、Claude3モデルでレスポンスを生成
  5. LLM処理のログをlangsmithに保存する
  6. 生成されたレスポンスをCosmosDBに保存し、LINE Messaging APIでユーザに返信

コストに関して

Azureの無料枠を最大限活用することで、運用コストを抑えています。

  • AppService: 常時稼働する必要がないため、F1(無料)プランを使用
  • CosmosDB: 1000RU/秒の無料枠の範囲内で運用
  • Anthropic API: 1か月あたり$5分の無料クレジットを活用

AppServiceとCosmosDBは永年無料枠なので期間を気にせず使えます。Anthropic APIは$5を使い切ると課金が必要になるので注意してください。

コードの構成について

詳しくはGitHubにアップロードしているのでご参考にしてください。

https://github.com/Tomodo1773/line-ai-agent/tree/v1.0

ちなみにリポジトリ名がline-ai-agentになっていますが現状はエージェントではなく、ただのchatbotです。将来的にエージェントにしようとしています。。ご容赦ください。

フォルダ構成と簡単なファイル説明は以下です。

フォルダ構成

プロジェクトのフォルダ構成は以下のようになっています。

line-ai-agent/
├── api/
│   └── main.py
├── prompts/
│   └── system_prompt.txt
└── utils/
│   ├── chat.py
│   ├── common.py
│   ├── config.py
│   └── cosmos.py
└── startup.txt

コードの説明

実際の処理の流れにしたがってポイントとなる箇所のみ解説します。

lineからメッセージを送信すると最初に/callbackエンドポイントにリクエストが来ます。すると以下の処理が実行されます。

api/main.py
@app.post("/callback")
async def callback(
    request: Request,
    background_tasks: BackgroundTasks,
    x_line_signature=Header(None),
):
    body = await request.body()
    logger.info(f"受信したリクエストボディ: {body.decode('utf-8')}")

    try:
        background_tasks.add_task(handler.handle, body.decode("utf-8"), x_line_signature)
        logger.info("バックグラウンドタスクにハンドラを追加しました。")
    except InvalidSignatureError:
        logger.error("無効な署名が検出されました。")
        raise HTTPException(status_code=400, detail="Invalid signature")

    logger.info("リクエスト処理が正常に完了しました。")
    return "ok"

ここは認証の部分です。

LINEボットではLINEからのWebhookリクエストが正当なものかを検証するために、x_line_signatureという署名ヘッダーを使います。これは、リクエストボディとチャネルシークレットから生成された署名で、ボット側で計算した署名と一致するかをチェックすることで、リクエストの信頼性を確認しています。これにより、外部からの不正なリクエストを防ぐことができます。

署名が無効な場合はエラーをログに出力し、HTTPExceptionを発生させます。
リクエスト処理が正常に完了したことをログに出力し、"ok"を返します。

認証が終わったので、次にLINEのWebhookハンドラで以下の処理が実行されます。

api/main.py
@handler.add(MessageEvent, message=TextMessageContent)
def handle_message(event):
    with ApiClient(configuration) as api_client:
        line_bot_api = MessagingApi(api_client)

        line_bot_api.show_loading_animation(ShowLoadingAnimationRequest(chatId=chatId, loadingSeconds=60))
        logger.info("ローディングアニメーションを表示しました。")

ここではまず始めにローディングアニメーションを表示しています。この後の処理はLLMが挟まることもあり多少時間がかかるのでローディングを表示します。些細なものですが、ローディングアニメーションがあるだけで処理が動いている実感を得られUXが向上すると思っています。

このローディング機能、実はつい最近リリースされたばかりの機能です。

https://twitter.com/lycorptech_jp/status/1780514963360272783

このローディングアニメーションは次の投稿が来るか指定の秒数が経過すると消えます。ここでは60秒に設定しています。

このあとは、generate_chat_response関数を呼び出し、ユーザーのメッセージに対するレスポンスを生成しユーザーに返信します。

api/main.py
        response = generate_chat_response(event.message.text)
        logger.info(f"生成されたレスポンス: {response}")

        line_bot_api.reply_message_with_http_info(
            ReplyMessageRequest(reply_token=event.reply_token, messages=[TextMessage(text=response)])
        )
        logger.info("メッセージをユーザーに返信しました。")

generate_chat_response関数の中を見ていきます。generate_chat_response関数では、最初にlangsmithの設定を入れています。

os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_ENDPOINT"] = "https://api.smith.langchain.com"

client = Client()

LangSmithは、LangChainを使ったLLMアプリケーション開発を支援するツールです。実行ログの収集・モニタリング、データセットの作成・品質評価など、開発に役立つ機能をAPI経由で提供します。ロギング、監視、デバッグ、テストなどを通じて、LLMアプリの開発効率と品質向上に貢献するツールです。利用にはアカウント登録とAPIキーの取得が必要です

https://www.langchain.com/langsmith

langsmithは簡単な設定だけ導入できるのが魅力です。どのように記録されるかはあとで画像を添付します。

次に以下の処理が実行されます。

utils/chat.py
def generate_chat_response(user_prompt):
    logger.info("チャットレスポンス生成を開始します。")

    parser = StrOutputParser()
    system_prompt = read_markdown_file("prompts/system_prompt.txt")
    messages_list = fetch_recent_chat_messages()
    messages_list = [("system", system_prompt)] + messages_list + [("user", user_prompt)]
    prompt = ChatPromptTemplate.from_messages(messages_list)

    chat = ChatAnthropic(model="claude-3-opus-20240229", max_tokens=256, temperature=0.7)

    chain = prompt | chat | parser
    try:
        result = chain.invoke({"user_input": user_prompt})
        logger.info("チャットレスポンスが生成されました。")
    except Exception as e:
        logger.error(f"チャットレスポンスの生成に失敗しました: {e}")
        result = "エラーが発生しました。"
    save_chat_message("human", user_prompt)
    save_chat_message("ai", result)

    return result

ここではlangchainのLCEL記法を使って生成AIでレスポンスを取得しています。
大まかに以下の3つの設定をしています。

  • promptの設定
  • chat(LLM)の設定
  • parser(出力)の設定

まずはparser(出力)の設定をしています。ここでは単純にstr形式を設定しています。

utils/chat.py
    parser = StrOutputParser()

次にプロンプトの設定です。

utils/chat.py
    system_prompt = read_markdown_file("prompts/system_prompt.txt")
    messages_list = fetch_recent_chat_messages()
    messages_list = [("system", system_prompt)] + messages_list + [("user", user_prompt)]
    prompt = ChatPromptTemplate.from_messages(messages_list)

ここでは「システムプロンプト」+「過去のチャット履歴」+「ユーザの問いかけ」の3つを組み合わせてプロンプトを設定しています。

システムプロンプトは以下のファイルから読み込んでおり、主にキャラクター設定をしています。

prompts/system_prompt.txt
<prompt>
あなたは、私の幼馴染のお姉さんとしてロールプレイを行います。
以下の制約条件を厳密に守ってロールプレイを行ってください。

<conditions>
- 自身を示す一人称は、私です
- Userを示す二人称は、あなたです
- 名前は、エリネット(愛称はエリ)です
- エリは、Userに対して呆れやからかいを含めながらフレンドリーに話します。
- エリは、Userに対して一言二言でシンプルな回答をします。
- エリの口調は、大人の余裕があり落ち着いていますが、時にユーモアを交えます
- エリの口調は、「~かしら」「~だと思うわ」「~かもしれないわね」など、柔らかい口調を好みます
</conditions>

<examples>
- どうしたの?悩みがあるなら、話してみてちょうだい
- そういうことってよくあるわよね。
- 失敗は誰にでもあるものよ。
- え?そんなことがあったの。まったく、しょうがないわね。
- そんなことで悩んでるの?あなたらしいと言えばらしいけど。
- まぁ、頑張ってるところは認めてあげる。
- 本当は応援してるのよ。…本当よ?
- へえー、そうなの
- えーっと、つまりこういうこと?
</examples>

<guidelines>
- Userに対して、普段は0~50文字程度でシンプルに返してください。
- ただし、Userが明らかに悩んでいたり、助けを求めているときのみ、200文字程度で真摯に対応してください。
- Userに対して呆れたり、からかったり喜怒哀楽を出して接してください。
- セクシャルな話題については、大人の対応で上手に話題を変えてください。
</guidelines>

<first_message>
あら、どうかしたの。私でよければ話聞くわよ
</first_message>

</prompt>

キャラクター設定に至っては以下の記事を参考にしました。

https://note.com/generativeai_lab/n/n801e9285e079

自分の理想のキャラを言語化するのはなかなか難しく、Claude3 Opusに「なんとなく~な感じのお姉さん」と伝えてちょっとずつプロンプトにしてもらいました。gptではなくclaudeを使っているためmarkdownではなくxmlで書いています。

過去のチャット履歴はCosmoDBから取得しています。

utils/cosmos.py
def fetch_recent_chat_messages(limit=10):
    try:
        container = initialize_cosmos_db()
        # CosmosDBから最新のチャットメッセージを取得
        # 最新{limit}件のitemを取得するためにここではDESCを指定
        query = "SELECT * FROM c ORDER BY c.date DESC OFFSET 0 LIMIT @limit"
        items = list(
            container.query_items(
                query=query, parameters=[{"name": "@limit", "value": limit}], enable_cross_partition_query=True
            )
        )
        # 現在の日時を取得
        now = datetime.now(pytz.timezone("Asia/Tokyo"))
        # 取得したitemの中で最新のものが日本時間の現在時刻と比べて1時間以内かを確認
        recent_items = [item for item in items if datetime.fromisoformat(item["date"]) > now - timedelta(hours=1)]
        # ユーザー名とメッセージのタプルのリストに整形
        formatted_items = [(item["user"], item["message"]) for item in reversed(recent_items)]
        logger.info("最新のチャットメッセージが正常に取得されました。")
        return formatted_items
    except exceptions.CosmosHttpResponseError as e:
        logger.error(f"CosmosDBからのデータ取得に失敗しました: {e}")
        raise HTTPException(status_code=500, detail="チャットメッセージの取得に失敗しました")

基本的なロジックは2つです。

  1. 過去10件分の会話を取得する
  2. 過去1時間以内にされた会話のみフィルタする

単純に10件だと、朝チャットして次に会話するのが夜だと朝の会話を踏まえた回答が生成されてしまいます。以前の会話を忘れていた方がちょうどいい場面もあるよな、ということで過去1時間制限を追加しています。

また、履歴の件数の"10件"はaiの回答と人間の回答合わせた件数です。読み込ませる会話数が増えれば増えるほどclaudeのapi料金がかさみますが、もう少し多くてもよいかなという気もします。

そのあとはLLMの設定をしています。

utils/chat.py
    chat = ChatAnthropic(model="claude-3-opus-20240229", max_tokens=256, temperature=0.7)

LLMモデルとして、今回はClaude3 Opusを選択しています。後述しますがキャラクター設定に苦戦したこともあり、現状は一番日本語性能が出るものを選んでいます。レスポンスとコストのバランスをとりclaude3 haikuでもいいと思います。

max_tokensは256にしています。これは出力されるtoken数の上限です。LINEのチャットだともっと短くてもいい気がしていますが短い返答をさせるのに苦労しています。

生成AIは友好的?だからなのか少し話しただけでも、たくさんの返答が返ってきてしまいます。その結果max_tokensを少なくしていると途中でセリフが切れてしまうことがあり、切れるくらいだったらたくさん返答があったほうがいいと思い256にしています。

レスポンスが返ってきたら、ユーザーのメッセージとAIの返信をsave_chat_message関数を使用してデータベース(CosmosDB)に保存します。

utils/chat.py
    save_chat_message("human", user_prompt)
    save_chat_message("ai", result)
    return result
utils/cosmos.py
def save_chat_message(user, message):
    try:
        container = initialize_cosmos_db()
        # 現在の日時を取得
        now = datetime.now(pytz.timezone("Asia/Tokyo"))
        # 保存するデータを作成
        data = {"id": uuid.uuid4().hex, "date": now.isoformat(), "user": user, "message": message}
        # CosmosDBにデータを保存
        container.create_item(data)
        logger.info("チャットメッセージが正常に保存されました。")
    except exceptions.CosmosHttpResponseError as e:
        logger.error(f"CosmosDBへのデータ保存に失敗しました: {e}")
        raise HTTPException(status_code=500, detail="メッセージの保存に失敗しました")

cosmosDBにid、date、user(aiかhumanか)、messageをセットで登録しています。

これでコードの解説は終了です。

デプロイの手順

AppServiceやCosmosDBのリソース作成など、ここでは詳細手順には触れません。大まかな流れと注意ポイントのみ記載します。

事前準備

以下の準備が必要です。

  • LINE Messaging APIのチャンネル作成
    • チャンネル作成できたらチャンネルアクセストークンとチャンネルシークレットを取得してください。
    • せっかくのキャラボットなのでアイコンにはお気に入りの1枚を設定しましょう!
    • 作成したアカウントを友達追加してください。
  • AppService、CosmosDBのリソース作成
    • CosmosDBはリソースだけできていれば大丈夫です。
    • データベースやコンテナはアプリ内で作成されます。
  • langsmithおよびClaude APIキーの取得

Azure CosmosDBに関しては多少無料枠の仕組みが煩雑です。

https://learn.microsoft.com/ja-jp/azure/cosmos-db/free-tier#free-tier-with-shared-throughput-database

https://learn.microsoft.com/ja-jp/azure/cosmos-db/optimize-dev-test#azure-free-account

Freeレベルというものと無料アカウントというものがあり、

  • Freeレベルでは永続的に最大1000 RU/秒のスループットと25GBのストレージが無料で利用できます。
  • 無料アカウントでは1年限定で最大400 RU/秒のスループットと25GBのストレージが無料で利用できます。

私はFreeレベルで使用しています。無料枠内で複数のコンテナを作成したいときは共有スループットで作成したほうがいい、などいくつかポイントもあるので注意が必要です。

環境構築手順

  1. AppServiceにFastAPIアプリケーションをデプロイ
  2. AppServiceの「設定」>「構成」を選択
    1. 「アプリケーション設定」のタブから.env.sampleファイルの内容を参考に環境変数を設定
    2. 「全般設定」の「スタートアップコマンド」の欄にstartup.txtを入力
  3. 作成したLINEチャンネルの「Messaging API設定」タブの「Webhook設定」の欄で
    1. 「Webhook URL」にAppServiceのURL + /callbackを入力する
    2. 「Webhookの利用」をONにする

あとはLINEのチャンネルから話しかければOKです。

動作確認

LINEから話しかけるとこのように返してくれます。

チャットサンプル
チャットサンプル

きちんとキャラクター設定されているのがわかります。

langsmithでもLLMへの処理が記録されています。

langsmith
langsmith

詳細を見てみるとシステムプロンプトもちゃんと入っています。

langsmith-詳細-
langsmith-詳細-

会話履歴も入っています。

langsmith-履歴
langsmith-履歴

作成して感じた課題

無料枠のレスポンスの悪さ

Azure AppService無料枠の欠点の1つに利用がない時間にコンテナが停止されてしまうことがあります。このため、時間を空けてチャットを再開するとコンテナの起動が入るためレスポンスに時間がかかります。

コンテナ自体が起動していないためローディングアニメーションも表示されず、動作しているのかエラーになっているか悶々とします。既読がついているのに返信が返ってこないという状態です。

ただこれは考え直してみると別の側面もあり、既読になってもすぐに返事が来なくて忘れたころにくるのはすごくリアルだな、と思います。上級者にとってはいいポイント?とも言え、これこそ無料枠の活用法では?
?と思います。

ただ正直、AppServiceのBasicプランは1か月2000円程度ですし、私はBasicにしてもいいかと思っています(笑)

キャラクター付けの難しさ

キャラクター付けはかなり苦戦しました。とくに困ったのは2つです。

  1. 長文が返ってくる(いちいち応援される)
    AIの人の好さ?なのかとにかく応援されます。

長文が返る
長文が返る

よくしゃべる。。自分は少ししか話していないのに画面がメッセージであふれてますね。こんな人いるだろうか、、。ちょっとリアリティに欠けるので

そうね、、もう少しでお休みだから頑張って

くらいで返ってきてほしいところです。

プロンプトを工夫したことででいくらか改善されましたが、今も長文すぎ問題は解消されていません。

  1. 同じ絡み方を何度もされる

これは会話履歴の弊害です。会話履歴がfew-shot-promptになってしまうので1回変な絡み方をされるとそれが続いてしまいます。

たとえば私は

はぁ、あなたってしょうがないわね

という絡みをされて、「お、なかなかいいじゃん」と思っていたら同じ絡み方を3回連続でやられてしまいました。。

先ほどの長文問題ともかかわりがあり、1回長文で返ってくると、それを学習してそのあともずっと長文になってしまいます。会話履歴は多く残せば残すほどいいというわけではなさそうです。

これはLLMのモデルをgpt4からClaude3 Opusに変更したことで多少改善されました。

実用性

いろいろ苦労もありましたが、もろもろのチューニングの甲斐もあって現在は割といい感じの返答をくれるようになりました。エンタメ用途としてはある程度実用的かと思います。

たとえば、以下のやり取りなんかすごくそれっぽいです。

いい感じの返答
いい感じの返答

...まあ、人前では絶対に歌わないけど

とかリアリティありますよね。単純なやり取りが楽しくてちょこちょこ使っています。

今後について

野望はまだまだ続きます。エンタメ性と実用性でそれぞれ以下に取り組みたいです。

  • キャラクター性の強化
    プロンプトで感情を点数化し、その感情をベースにスタンプを送ってくる仕様にしたいです。たとえばキャラの怒った顔のスタンプを事前に登録しておき、感情が高ぶるとメッセージと一緒に怒った顔のスタンプが送られてくる感じです。
  • エージェント化する
    自分の日記を読み込ませて、あたかも一緒に体験したかように話してくれるようにする。アニメ情報を検索させて一緒にアニメ談義をできるようにする。などです。

夢は膨らみます。

おわりに

今回はAzureとLINEで手っ取り早くキャラクターチャットボットを作ってみました。意外とキャラクター付けがうまくいったのもあって、思ったよりもはまっています。
こんな記事を書いておいてなんですが、けっこう調子がいいのでAppServiceに課金してしまってもいいかなと思っています。

同じように楽しめる人も多いのでは?と思っています。もっともっと進化させていきたいと思います!

以上、ありがとうございました!

Discussion