この記事は

Reasoning Engine (LangChain on VertexAI)で、微調整したGemma 2 JPNとチャットするAIアプリつくるよー。
Google Cloudで秒で作れるよ。いえーい。っていうことを言いたい。というか勝手に楽しんでる記事です。

ということで、今回はテーマはこちら。
「Reasoning Engine & Cloud Run with GPUでGemma 2とチャットする」
どんどんパフパフ。

使うサービス・ライブラリなど

そのまえに、Reasoning Engine (LangChain on VertexAI)ってなにさ!

ご存知かと思いますが、手短に。

Reasoning Engineは、
LangChainを使ったAgentをGoogle Cloud上にデプロイできる優れものです。
体感3秒でデプロイできます。※あくまで主観です

また、Vertex AI SDKに用意してくれている、LangchainAgentクラスというものが特に便利で、いわゆるLangChain wrapperクラスとなっています。
具体的な中身は、RunnableWithMessageHistoryなど実装されているため、チャット履歴を管理してくれるためマルチターンはもちろん、いつものLangChainそのままの使い心地で、スピーディーに開発できちゃいます。

LangchainAgentの使用において嬉しいポイントは、
Agentのメインである、ツールの実装などに集中でき、
Agentロジック部分は、ほぼコードを書かずにGoogle Cloud公式品質を担保できる、ということが嬉しいポイントの一つです。

また、LangchainAgentクラスを使用せず、自身で作成するカスタムクラスで、デプロイすることが可能です。
これにより、LangChainだけでなく、LangGraphやLlamaIndexなど他のオーケストレーションフレームワークを使用できます。

実際のデプロイもVertex AI SDKのReasoningEngineクラスを使用するだけでパパッとできます。
インフラ設定なし、Google Cloudに身も心も委ねます。

ちなみに、現状プレビューサービスです。わくわくしつつGAを夢見て待ちましょう…。

今回やってること

チャット仕組み部分

ここでのメインは、Reasoning EngineとLangChainです。

今回は、query時に引数でモデルを選択できるようにしたいため、我らのLangchainAgentは使用…せず、カスタムクラスを作成します。
(後述の通り複数のモデルをホストするため)
単純にChatOllamaを使用するだけであれば、LangchainAgentにmodel_builderを渡すだけで動きます。
ChatOllamaのほかに、LangChainに用意されているChatHoge系なら動かせるようです。(未検証)

チャット履歴は、みんな大好きAlloyDBに保存させていただきます。おす。
チャット保存用テーブルのイニシャライズも、engine.init_chat_history_tableでできるため、基本的な使い方であればマイグレーションファイルなど用意せずさくっと作れるのも嬉しいポイント。

チャットパラメータは、以下のコードの通りデフォルトです。
主要な値:

パラメータ名 デフォルト値
temperature 0.8
top_k 40
top_p 0.9
repeat_penalty 1.1

カスタムクラス

class CustomLangchainAgent:
    def query(self, input: str, model: str, config: dict):
        from langchain_ollama import ChatOllama
        from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
        from langchain_core.runnables.history import RunnableWithMessageHistory

        def get_session_history(session_id: str):
            from langchain_google_alloydb_pg import AlloyDBEngine, AlloyDBChatMessageHistory
            engine = AlloyDBEngine.from_instance(
                project_id=PROJECT_ID,
                region=REGION,
                cluster=CLUSTER,
                instance=INSTANCE,
                database=DATABASE,
            )
            return AlloyDBChatMessageHistory.create_sync(
              engine,
              session_id=session_id,
              table_name=TABLE_NAME,
            )

        llm = ChatOllama(
            model = model,
            base_url=OLLAMA_BASE_URL,
        )
        prompt = ChatPromptTemplate.from_messages(
            [
                MessagesPlaceholder(variable_name="history"),
                ("human", "{input}"),
            ]
        )
        chain = prompt | llm

        runnable = RunnableWithMessageHistory(
            chain,
            get_session_history,
            input_messages_key="input",
            history_messages_key="history",
        )
        return {"message": runnable.invoke({"input": input}, config=config).content}

モデルホスト部分

ここでのメインは、Gemma 2とOllamaです。

例のごとく、Gemma 2 JPN (gemma-2-2b-jpn-it)のホストは、Ollamaを使用します。
今回は、Gemma 2 JPNをチャット向けにQLoRAでファインチューニングしてみました。

OllamaならLoRAアダプターのホストも簡単でModelfileをADAPTER指定するだけでできるため、
ベースモデルとLoRAアダプターと、どちらもホストしています。
また、Modelfileでは、プロンプトも指定できるため、モデルとプロンプトを切り出してコード外で保存できるのもポイント。(commit履歴のカオス化を防げますね)
量子化タイプはf16としてGGUFに変換しています。

試してみる

Ollamaのおかげで、それぞれのモデルを使用する際に、model名を指定するだけで簡単に叩けます。とても便利。
せっかくなので、ベースモデルとファインチューニングしたモデルをGemini 1.5 Flashを使用して評価してみました。(シングルターンのみ)
評価はきちんと調整しているわけではないため、あくまで、参考程度にご覧ください。

シングルターンのターン

比較 1

何の変哲もない文章

input

こんにちは!今日もいい天気です。

ベースモデル

こんにちは! 😊  そうです、今日はいい天気ですね!  \n\n何か予定はありますか? 😄\n\n\n\n

ファインチューニングしたモデル

そうですね!今日は特に気温が暖かく、太陽が燦々と照っているみたいなので、外で散歩に出かけたり、スポーツをしたりして、一日を満喫できるでしょう! ☀️🌳🏃\u200d♀️💨

評価(ペアワイズ)

{'reasoning': "Both responses acknowledge the user's greeting and comment on the weather. Assistant B goes further by suggesting activities based on the good weather, adding more depth to the response. \n\n[[B]] \n",
 'value': 'B',
 'score': 0}

比較 2

文脈が不明な文章

input

うわああああああ、生麦生米

ベースモデル

{'message': 'うわああああああ、生麦生米!  😊 \n\nあなたは何か食べたい気分ですか?\n\n\n\n\n'}

ファインチューニングしたモデル

{'message': 'いいですね!生麦生米って、おいしいですよね。 \n\nご飯に混ぜたり、おにぎりにしても美味しいでしょう。 \n\nぜひ色々な料理に使ってみましょう!'}

評価(ペアワイズ)

{'reasoning': "Assistant A seems to be simply mimicking the user's input, adding an emoji, and asking a question. This lacks any relevance to the user's input and does not provide any helpful information. \n\nAssistant B, on the other hand, responds in a conversational manner and attempts to interpret the user's input as a phrase related to food. While the interpretation might not be accurate, it demonstrates an attempt to understand and engage with the user's input.\n\n[[B]] \n",
 'value': 'B',
 'score': 0}

比較 3

途中までの文章

input

あ、隣の蕎麦屋さんにいっ

ベースモデル

{'message': '  了解しました! そば屋さんの前に向かいますね!😊\n\n\n \n\n\n\n'}

ファインチューニングしたモデル

{'message': 'はい、隣に蕎麦屋さんがありますね。何かご用ですか?'}

評価(ペアワイズ)

{'reasoning': 'Assistant B is more helpful, relevant, and correct. It acknowledges the user\'s phrase and asks for clarification, demonstrating understanding. Assistant A simply states it will "go to the soba shop" without any context, making it seem like it\'s not comprehending the situation.\n\n[[B]] \n',
 'value': 'B',
 'score': 0}

個人的にこの比較 3は、ベースモデルのレスポンスの方がすきです。(唐突な自分語り)


マルチターンのターン

もちろん、マルチターンもできます。LangChainさまさま。

比較 1

ベースモデル

user: こんにちは
gemma2: こんにちは!😊 \n\n何か用ですか?  \n\n\n\n
user: 私の名前は、太郎です
gemma2: こんにちは、太郎さん 😊\n\n piacere di conoscerti!  \n\n\n何かお話したいことはありますか?  \n \n\n\n\n
user: あなたの名前を教えてください
gemma2: 私の名前は、Aiko です。 😊\n\n\n\n \n\n\n\n\n
user: 私の名前を教えてください
gemma2: 私の名前は、太郎です。 😄\n\n\n\n\n \n\n\n\n

ファインチューニングしたモデル

user: こんにちは
gemma2: こんにちは!😊 何かお手伝いできることはありますか?
user: 私の名前は、太郎です
gemma2: 太郎さん、はじめまして。 piacere di conoscerti! 😊  何かお話したいことがあれば、お気軽にどうぞ。
user: あなたの名前を教えてください
gemma2: 私の名前は、ミリアムです。 こんにちは!😊 \n\nあなたのお名前は、太郎ですか?
user: 私の名前を教えてください
gemma2: こんにちは、太郎さん!😊  あなたの名前は「太郎」です。覚えていますか? 何かお話したいことはありますか?

ところどころ覚束なくて、とても愛おしいですね。
文中にある「piacere di conoscerti!」は、イタリア語で「初めまして」という意味だそうです。ためになったねえ。


比較 2

ベースモデル

user: 明日の夕飯のメニューを考えてください
gemma2: 明日の夕飯、いいですね!いくつか質問させてください😊\n\n* **どんな料理が好きですか?** (例: 和食、洋食、中華など)\n* **どんな食材がお好みですか?** (例: 野菜、肉、魚など)\n* **時間や手間はどのくらいかけられますか?** \n* **家族構成は?**  (例: 子供がいる、大人二人で食べるなど)\n\n\nこれらの情報があれば、よりあなたに合ったメニュー提案ができます!\n\n\n\n\n例えば…\n\n* **「和食が好きで、簡単で、家族みんなで食べやすいもの」** の場合…\n    * **鮭の塩焼きとご飯**:  簡単に作れる定番料理です。\n    * **豆腐の味噌煮**: 野菜もたっぷり入っていて栄養満点! \n* **「洋食が好きで、少し手間がかかるものがいい」** の場合…\n    * **ハンバーグとサラダ**:  ヘルシーでも満足感ある定番料理\n    * **鶏肉と野菜のグリル**: 色々な野菜をたくさん使った、彩りも楽しい料理\n\n\n \n\n\n\n \n
user: 今が旬の食材を使ったものが食べたいです
gemma2: 今が旬の食材を使ったメニューいいですね!😊どんな料理がお好みですか?\n\nいくつか質問させてください!\n\n1. **どのジャンルが好きですか?**  (例: 和食、洋食、中華など)\n2. **どんな料理を想像しますか?** (例:  炒め物、煮物、スープなど) \n\n\n\n例えば…\n\n* **和食が好きで、ちょっと手間をかけたい** の場合…\n    * **旬の野菜と豆腐を使った味噌汁**:  今が旬の野菜を使った、栄養満点の一品。 \n    * **鮭ときのこと春雨の炒め物**:  鮭は今が旬!きのこもたっぷりで、風味豊かです。 \n\n* **洋食が好きで、比較的簡単に作れるものがいい** の場合…\n   * **旬の野菜を使ったスープ**:  トマト、白菜、ほうれん草など、旬の野菜をたっぷり使ってスープを作ってみませんか?  \n\n\n \n\n\n\n \n\n\n\n\n
user: 洋食がいいです
gemma2: 洋食ですね!😊 \n旬の食材を使った洋食なら、より美味しく、ヘルシーな料理が楽しめます。\n\nいくつか質問させてください!\n\n1. **どんな味がお好みですか?** (例:  甘め、塩味、酸味など)\n2. **肉はどの種類がいいですか?** (例:  牛肉、豚肉、鶏肉など) \n3. **野菜はどんな種類が好きですか?**  (例:  トマト、キャベツ、ピーマンなど)\n\n\n\n\n \n\n\n \n\n\n\n
user: あなたは何が食べたいですか?
gemma2: 私も洋食が食べたいです!😊\n\n今が旬の野菜を使った、**トマトときのことチーズのオムライス** にしました。🍅🍄🧀 \n\n\n\n \n\n\n\n\n \n

ファインチューニングしたモデル

user: 明日の夕飯のメニューを考えてください
gemma2: 明日の夕飯は、鶏肉と野菜の炒め物です。\n\n材料:\n- 鶏もも肉 200g\n- 玉ねぎ 1/4個\n-ピーマン 1本\n-オクラ 1/2個\n-ニンジン 1/2個\n-生姜 2かけ\n-Aオイスターソース大さじ1\n-砂糖小さじ1\n-酒大さじ2\n\n作り方:\n1. 鶏肉に塩コショウを振って、10分程度漬け込みます。\n2. 玉ねぎ、ピーマン、オクラ、ニンジンを1cm幅にカットします。\n3. フライパンにごま油をひき、鶏肉を加えて弱火で炒めます。\n4. 鶏肉が焼けたら、玉ねぎ、ピーマン、オクラ、ニンジンを加え、さらに炒め、塩コショウで味を調えます。\n5. Aオイスターソース大さじ1を加え、砂糖小さじ1を加え、酒大さじ2を加えて炒めます。\n\nこれで完成!
user: 今が旬の食材を使ったものが食べたいです
gemma2: 明日の夕飯は、今が旬の食材を使った野菜炒めです。\n\n材料:\n- イタリアンのトマト 3個\n- しめじ 10個\n- きのこ 5個\n- じゃがいも 1個\n- 豚肉 1/2個\n- 塩コショウ\n- オリーブオイル\n- 砂糖\n- 胡麻油\n\n作り方:\n1. イタリアンのトマトは、皮を剥いて、半分に切って、塩コショウで味を調えます。\n2. しめじ、きのこは、水で洗って、半分に切って、塩コショウで味を調えます。\n3. じゃがいもは、皮を剥き、薄切りにします。\n4. 豚肉は、一口大にカットします。\n5. フライパンにごま油をひき、豚肉を加え、弱火で炒め、塩コショウで味を調えます。\n6. しめじ、きのこ、じゃがいもをフライパンに加えて、弱火で炒めます。\n7. イタリアンのトマトを加えて、さらに炒めます。\n8. 胡麻油をかけ、砂糖と塩コショウで味を調えます。\n\nこれで完成!
user: 洋食がいいです
gemma2: 明日の夕飯は、洋風なハンバーグ料理です。\n\n材料:\n- 豚肉 200g\n- 玉ねぎ 1個\n- じゃがいも 1個\n- ヨーグルト大さじ1\n- 醤油大さじ1/2\n- みりん大さじ1/2\n- 酒大さじ1\n- 塩コショウ\n\n作り方:\n1. 豚肉は、一口大にカットします。\n2. 玉ねぎとじゃがいもは、薄切りにします。\n3. フライパンにごま油をひき、豚肉の塊を炒めます。\n4. 玉ねぎ、じゃがいもを加えて、弱火で炒め、塩コショウで味を調えます。\n5. ヨーグルト大さじ1、醤油大さじ1/2、みりん大さじ1/2、酒大さじ1を加え、さらに炒め、仕上げに塩コショウで味を調えます。\n\nこれで完成!
user: あなたは何が食べたいですか?
gemma2: 私は料理を作ることはできませんが、明日の夕飯メニューを選ぶときにはあなたの好みや食べたいものも考慮して、美味しい料理を選びたいと思います。

ひたすら質問し続けてくれるベースモデルと、すぐに完成させるファインチューニングしたモデル、2人とも性格が見えて愛おしいですね。
(トマトって今月まで旬だったんだ…)


結果

結果的に、ベースモデルとファインチューニングしたモデルに大きな違いは出ませんでした。
そう、結局ベースのGemma 2が優秀ってわけですね。
あとは、違いが軽微だった理由として、そもそも今回の調整が少なめだったということも考えられると思います。(とても大きそう)

ただし、これは主観ベースの話になりますが、
意味不明な文章や、途中の文章をinputした際には少し違いがあったような気がします。
ベースモデルは、動きとして至極真っ当な、プロンプトテキストの続きを生成しようすることが多いですが、
ファインチューニングしたモデルは、テキストプロンプトに対してレスポンスを生成してくれていました。
ほぼ続きを生成しようとしていたものはなかったように感じます。
また、ファインチューニングに使用したデータには、絵文字少ないと思うので、そのあたりのニュアンス?的なところも学習しているようですね。

ほんのちょこっとチューニングしただけで、ここまで頑張ってくれるというのは、Gemma2開発者様の苦労の賜物ですね。

また、上記の結果は、生成確率の中のほんの一例です。ご注意ください。
(カスタムクラス部分に記載の通り、チャットパラメータはデフォルト)
むしろ、ベースモデルのままの方が、評価的にも好み的にも良い生成はたくさんありました。

余談ですが、マルチターンは一所懸命に答えてくれる姿がみれてほっこりするので、心が荒んだときにおすすめです。

まとめ

Reasoning EngineとCloud Run with GPUを使用すれば、簡単に好きなモデルを使用したAIアプリを開発・デプロイできます。
Gemma2は、小さいモデルにも関わらずそのままのモデルで、指示通りタスクをこなしてくれます。また、少しのファインチューニングするだけでより指示をきてくれるようになります。

Reasoning Engineはいいぞお。Gemma 2はいいぞお。

以上です。