軽量LLMを使ってネイティブアプリでチャットボットを実装してみた

はじめに

初めまして。JCBデジタルソリューション開発部の若林です。2024年の春から社会人として働き始め、7月から3ヶ月間はReactを用いたWebアプリケーション開発に取り組み、その後10月から現在に至るまでSwiftを使ったiOSアプリケーション開発をしています。
本稿では、ローカルLLMを活用したネイティブアプリ向けチャットボットの実装手順を説明します。 特にローカルLLMの設定方法と、LLMの作業を明確にすること、および出力を単純化することのメリットについて記述しています。

ローカルLLMの概要

ローカルLLMとは、クラウド環境に依存せずローカル端末上で動作する言語モデルを指します。主要なクラウド型モデルであるGPT-4との比較を下表に示します。

ローカルLLM(gemma2など) クラウドLLM(GPT-4など)
推論性能 軽量モデルのため複雑な推論に制約 大規模モデルによる高度な推論可能
通信要件 オフライン動作可能 クラウドとの通信が必要

ローカルLLMは性能面で制約があるものの、データの外部流出リスクがなく、ネットワーク接続不要という利点を有します。本実装ではこれらの特性を活かしたアプリケーション開発を試みました。

ローカルLLMの実装設定

使用モデル

自分のMacでダウンロードできるモデルのサイズで最も推論能力と日本語対応力が高いという理由からHugging Faceで公開されている日本語対応モデル「gemma-2-2b-jpn-it-gguf」を採用しました(モデル詳細: Hugging Faceリポジトリ)。

実行環境

  • ハードウェア: Apple M2チップ搭載MacBook Air(メモリ16GB)
  • macOS: Sequia 15.1
  • モデルファイル: 「gemma-2-2b-jpn-it-Q8_0.gguf」(自分のMacの空きストレージ容量で利用可能な最大サイズモデル)

前提

本題へ入る前に、仮アプリと今回の記事で目指す理想のチャットボットについて定義します。

仮アプリ

今回想定する仮アプリは、以下の2つの機能を持っているものとします。

  • 写真機能がアプリ内で使える
  • 地図機能がアプリ内で使える

理想のチャットボット

今回の記事で目指す理想のチャットボットは以下の2つの機能を持っているものとします。

  • アプリ内機能(写真機能・地図機能)の利用方法を回答できる
  • 分からないことは「分からない」と回答できる

実装

この理想のチャットボット機能作成に向けて実装していきます。

サーバーを構築して動かしてみる

最終的なシステム構成は、スマホ上で動作するネイティブアプリと、サーバ上で動作するローカルLLMの構成を想定しています。今回は開発環境としてiOSシミュレータで動作するネイティブアプリと、MacBook上にローカルLLMが動作するAPIサーバを立てて実行していきます。
Python仮想環境の設定後、以下のFlaskアプリケーションを実装します。

from llama_cpp import Llama
from flask import Flask, request, jsonify

app = Flask(__name__)

model_path = "/ダウンロードしたファイルがあるパス/"

@app.route('/predict', methods=['POST'])
def predict():
    data = request.get_json()
    text = data['text']
    llm = Llama(model_path=model_path)
    prompt = (text)
    response = llm(prompt)
    result = response['choices'][0]['text'].strip()
    return jsonify({'result': result})
    
if __name__ == '__main__':
    app.run(host='127.0.0.1', port=5000, debug=True)

動作検証

ではFlask APP起動後、実際にシミュレータからFlask Appに向けてメッセージをAPI通信を通じて送ってみます。

これだけだとまともな応答が返ってこなくて理想のチャットボットには程遠いです。

実用化に向けた改善策

1. プロンプトエンジニアリングの最適化

ユーザー入力の前処理として固定プロンプトを追加し、応答パターンを制御します。

text = data['text']
prompt = (
f"""
あなたは親切で知識豊富なアシスタントです。以下のユーザーの質問に答えてください。
あなたは

- 天気のことを知りたい場合は、天気機能をタブから利用できます。
- 地図のことを知りたい場合は、地図機能を地図アイコンをタップして利用できます。

具体例:
ユーザー: 明日の天気を知りたいです。
あなた: 天気機能をタブから利用できます。

ユーザー: 近くのレストランを探したいです。
あなた: 地図機能を地図アイコンをタップして利用できます。

// 多くの具体例(50~100個、ここでは省略)

では始めます!
ユーザー:{text}
あなた:
"""
    )

これによって応答精度が向上しました。しかし、プロンプトにないことを聞かれるとおかしな返答をしてしまいます。

2. 出力を数字だけに制限

LLMが直接文章を返答するのではなく、数字だけを返答させるようにしました。そして返ってきた数字に応じて出力を決めるようにしました。

text = data['text']
prompt = (
f"""
あなたはとてもシンプルなAIです。これから私が送るメッセージに応じて、0,1,2の数字の中からいずれか1つだけを出力してください。
返す数字は必ず1文字だけで、それ以外の文字や記号は一切含めないでください。
もしメッセージが難しくて理解できない場合や、関係ない話題の場合は0を返してください。
以下のルールに従って出力してください。
・ユーザーが天気のことを知りたかったら1
・ユーザーが地図機能を使いたかったら2

具体例:
ユーザー: 明日の天気を知りたいです。
あなた: 1

ユーザー: 近くのレストランを探したいです。
あなた: 2

ユーザー: 写真を編集したいです。
あなた: 9

ユーザー: よく眠れるベッドを教えて
あなた: 0

// 多くの具体例(50~100個、ここでは省略)

では始めます!
ユーザー:{text}
あなた:
"""
)
#もし数字以外のレスポンスをしてしまった場合の処理
result = response['choices'][0]['text'].strip()
if result not in {'0', '1', '2'}:
   result = '0'
        # 結果に応じたメッセージを設定
    if result == '0':
        result = "簡単な日本語でお願いします。"
    elif result == '1':
        result = "天気機能はタブから利用できます。"
    elif result == '2':
        result = "地図アイコンをタップして利用できます。"
    
    return jsonify({'result': result})

この改良により、各機能がアプリのどこから利用できるかを答えることができ、分からないことには分からないと応答する理想のチャットボットになりました。

機能拡張: 励ましメッセージ生成

上記の実装だけでもすでに理想のチャットボットです。
しかし、先ほどまでのLLMの実装で得た、「単純なタスクだったら推論能力の低いLLMでも実行できる」という学びから、複雑なタスクも単純化することで実行できるのではないかと見込みました。
そこで、ユーザが悲しそうだった場合は、励ましのメッセージを生成するような機能を追加してみます。
ユーザのメッセージが悲しそうかどうかを判断させる→悲しそうだったらその場合に合わせた励ましのメッセージを生成する。
上記のような流れで実装していこうと思います。

まず先ほどのプロンプトでは0(無関係な話としてLLMに認識されたもの)と出力されたものをもう一度精査させます。

if result == '0':
   prompt = (
   f"""
   あなたはとてもシンプルなAIです。これから私が送るメッセージに応じて、0,4の数字の中からいずれか1つだけを出力してください。
   返す数字は必ず1文字だけで、それ以外の文字や記号は一切含めないでください。
   もしメッセージが難しくて理解できない場合や、関係ない話題の場合は0を返してください。
   以下のルールに従って出力してください。
   ・それ以外の場合は0
   ・ユーザが悲しみの感情を持っている場合は4
   
   具体例:
   ユーザー: 仕事がつらい
   あなた:4

   ユーザー: よく眠れるベッドを教えて
   あなた: 0
   
   // 多くの具体例(50~100個、ここでは省略)
   
   では始めます!
   ユーザー:{text}
   あなた:
   """
   )

次に、LLMに励ましのメッセージを生成してもらいます。

if result == '4':
   prompt = (
   f"""
   あなたは親身な友人AIです。以下のルールに従って返答を生成してください:
   1. 相手の言葉に合わせた名言を返すこと。
   2. 以下の例を参考にして出力してください。
   3. 相手を励ますことを一番に考えてください。
   
   例:
   私:「頑張っても報われない」
   努力は必ず実を結ぶ。今は種まきの時
   
   // 多くの具体例(50~100個、ここでは省略)
   
    では始めます!
    ユーザー:{text}
    あなた:
    """
   )

このような処理を追加した結果、ユーザが悲しいメッセージを送ってきた場合に励ましのメッセージを返す機能を追加できました。

補足: 出力に対するパラメータチューニングについて

本稿の主題とは関係ないですが、このチャットボットを作成する際に得た出力に対するパラメータチューニングの知識について共有します。

llm = Llama(model_path=model_path)

これだけでLLMを使うことはできますが、タスクによって設定を調整することでよりよい回答が得られるようになります。 もし数字だけや決まった返答を返してほしい場合は、

llm = Llama(model_path=model_path, temperature=0.4, top_p=0.9)

Temperatureパラメータは低めでtop_pは高めに設定するとプロンプトに沿った内容を出力しやすくなります。

反対に名言のようにLLMの返答にオリジナリティを持たせたい場合は、

llm = Llama(model_path=model_path, temperature=0.7, top_p=0.7)

Temperatureパラメータは高めでtop_pを低めに設定するとオリジナリティの高い回答になります。

また、固定プロンプトの量が多くなりすぎると、LLMの返答が不安定になり始めます。 その時には、

    soft_prompt = (
    f"""
    あなたはとてもシンプルなAIです。これから私が送るメッセージに応じて、0,1,2の数字の中からいずれか1つだけを出力してください。
    返す数字は必ず1文字だけで、それ以外の文字や記号は一切含めないでください。
    もしメッセージが難しくて理解できない場合や、関係ない話題の場合は0を返してください。
    以下のルールに従って出力してください。
    ・ユーザーが天気のことを知りたかったら1
    ・ユーザーが地図機能を使いたかったら2
 
    具体例:
    // 具体例
    """
    )
    data = request.get_json()
    llm = Llama(model_path=model_path, n_ctx=2048, temperature=0.4, top_p=0.9, repeat_penalty=1.1, cache=False)
    tokenizer = llm.tokenizer()
    text = data['text']
    hard_prompt = (f"""私:「{text}」
    あなた:""")
    
    # ソフトプロンプトとハードプロンプトをトークン化
    soft_prompt_tokens = tokenizer.encode(soft_prompt)
    hard_prompt_tokens = tokenizer.encode(hard_prompt)
    
    # トークンを結合
    prompt = soft_prompt_tokens + hard_prompt_tokens
    response = llm(prompt)

上記のようにLlamaの引数の中でn_ctxを2048に指定したり、tokenizerを使ってプロンプトをtoken化させてからLLMに与えると返答が安定しました。

まとめ

本稿では、軽量LLMをネイティブアプリで使用する方法について解説しました。 最後に、JCBでは我々と一緒に働きたいという人材を募集しています。 詳しい募集要項等については採用ページをご覧下さい。

©JCB Co., Ltd. 20︎21