最近、私がCGアニメ制作の方にかまけてる間に、ローカルLLM界隈で細かいブレークスルーが色々起きている。
まず、これは結構前の話だが、Llama.cppがCPU推論だけでなく、GPUオフロードによってGPU推論する事も可能になった。しかも、オフロードするレイヤー数を調整できるから、グラボのVRAM
に応じて半分だけはGPU、半分はCPU推論なんて事も可能だ。
こちらの記事によれば、CPU推論の時は5~8tpsだった速度が、GPU推論では60tpsに爆速化したらしい。(グラボはRTX A6000)↓
transformersに比べてllama.cppはどんくらい高速なんだろう?とググったけど、そういう比較は見付かなかった。transformersだとちゃんとtps計測する方法が無いからだろう。ま、多分llama.cppの方が相当速いハズだ。
次に、rinnaからNekomataというLLMが公開された。これはQwenをベースにしており、メチャクチャ性能が高いらしい。Nekomata-14B-instructionモデルは、たった14Bパラメータにも拘らず、日本語ベンチにおいて70Bパラメータのjapanese-stablelm-base-beta-70Bに匹敵してしまっている。
つまり、Nekomataの公開によってついに我々は日本語でそれなりに賢くて軽量なローカルLLMを手に入れたのだ!
英語圏の人達はLlama-2が出たあたりですでにそれなりに賢いローカルLLMをゲットできていた。しかし、Llama-2は日本語が弱かった。その後、Qwenがリリースされた。Qwenはかなり賢かったらしいが、イマイチ流行らなかったのは、ちょっと独自のアーキテクチャだったそうで、transformersやllama.cppで推論するには対応作業が必要だったからだ。最近llama.cppがQwenをサポートした。すなわち、QwenベースのNekomataも同様にllama.cppで動作するようになってるという事だ。
mutaguchi氏は結構色んなローカルLLMをガッツリ触って、AIキャラとチャットできたりAIキャラ同士で会話できるプログラム作って遊んでるそうだが、Nekomata-14BをGPT-3.5やNovelAIのKayra-13BやAIのべりすとのsupertrinに近いレベルと評価している。この意味は大きい。ついにこのレベルの日本語LLMをローカルで触れる日が来たという事だ。↓
あとNekomataはQwenのライセンスを継承しており、条件付きで商用利用可能というのも大きなポイントだろう。
さらに3つ目のブレークスルーはguidanceの覚醒である。
guidanceはローカルLLMに魔法のようなパワーアップをもたらす機能が色々入ってるライブラリである。
結構前からあったのだが、イマイチ使う気が起きないでいた。というのも、なんかワケ分からんテンプレート記法を駆使する必要があったから、そんなん憶えるのダルいからだ。
だが、最近になってguidanceはバージョン0.1にアップデートされ、大幅に刷新された。もうワケ分からんテンプレート記法は撤廃された。pythonだけでスッと書けるようになった。
さらに、llama-cpp-python(llama.cppのpythonラッパー)も統合された!これにより、llama.cppの色んなggufファイルがguidanceで活用できるようになったわけだ。つまり、Nekomataもguidanceで使う事ができるという事だ。
これらのブレークスルーによって、日本語ローカルLLMの可能性は最近になって爆発的に向上したと言っていい。
具体的に何ができるようになったんだ?というと、もはやローカルLLMを使ったゲームを作れるようになっているという事だ。だってNekomataはNovelAIやAIのべりすとと同等に物語を書けるらしいから、つまりローカルで動くAIダンジョンみたいなゲームはサッと作れてしまう気がする。
ちなみにこれから先もローカルLLMには超絶ブレークスルーが次から次へと控えている。
例えば、MistralAIが公開したMixtralモデルは、MoEを搭載していて、総パラメータ数は45Bもあるにもかかわらず、推論時の負荷は12Bモデルと同等に軽量だという。ただし、VRAMはキッチリ45B分だけ消費する。MixtralモデルもきっとStabilityかrinnaあたりが日本語モデル化してくれる事を期待している。
それから、PowerInferも注目すべきだろう。これはLLMモデルのニューロンの内、頻繁に参照されるものだけをVRAMに置いてGPU演算して、残りはメインメモリに置いてCPU演算する。llama.cppのレイヤーに似てるが、それよりスマートな技術だ。PowerInferを使えば、Falcon-40Bモデルを推論する時に、VRAMが足りないRTX4090でもA100に匹敵する速度で推論できてしまうらしい。
つまり、MixtralのようなMoEモデルとPowerInferのようなスマート推論が組み合わされば、RTX4090のようなコンシューマグラボを搭載した普通のPCでも45BのでっかいMoEモデルをH100なんかと同等の速度で推論できるようになる事が見込める。
ついでに言えばPhi-2のようなモデルは小さいモデルでも十分に賢くできる可能性を示している。
ところで、どうしてゲームにローカルLLMを使うのか?GPT-3.5TのAPIとか使えばいんじゃね?という話について書いておく。
GPTのAPIをゲームに組み込んで公開するなんて話は今んとこ乗り気になれない。それにはいくつかの理由がある。
まず、最近はGPT使う人が多くてOpenAIのサーバが逼迫してるらしく、APIの応答が遅すぎる傾向がある。ぶっちゃけもはやローカルLLMの応答の方が高速である。
それから、GPTのAPI代金を誰が払うのか?開発者が払うのか?一生?あやまってAPIのキーをゲームに埋め込んでしまい、不正利用されまくって何百万円も請求されるリスクもある(まあ利用上限設定してればいいんだけど)開発者が払いたくない場合はユーザーに自分でAPIキーを入力してもらう作りにもできるが、そこまでしてくれるユーザーがどれくらいいるっていうんだ?
また、ユーザーがOpenAIの規約に反するような会話をAIとしてしまうかもしれない。そうしたらOpenAIは開発者アカウントをバンするかもしれない。そうなると新しいクレカと電話番号用意してOpenAIアカウント作り直すハメになる。
こうやって見ると、ゲームにはAPIなんかよりローカルLLMを使いたくなってくるだろう。
さて、そろそろ本題のguidanceの話に入っていく。
guidanceには色々とローカルLLMに嬉しい機能が入ってる。
まず、文字列テンプレートでLLM生成を自由にコントロールできる。
そして、LLM出力に制約をかける事ができる機能も強力だ。
さらに、キーバリューキャッシュによる生成の加速機能もある。これは同じプロンプトで何度も生成する場合、処理の一部をキャッシュしておくことで高速化できる機能だ。
あとトークンヒーリングという機能もある。これは何か?というと、普通にLLM使うとトークンと文字列変換の兼ね合いの関係で、生成文がおかしくなってしまう事があるらしく、それをいい感じに修復してくれる機能らしい。
で、今回特に紹介したいのが生成に制約をかけれる機能である。
LLMというのはなんでも答えてくれて便利なもんだが、期待した答えを厳密に回答して欲しい時には厄介だ。例えば、LLMに4択クイズを出すとする。「1~4のどれかで答えてください」と入力した時、期待する回答は「1」とか「2」である。しかし、LLMは「答えは1です」とか「2でファイナルアンサー」とか言って、表記揺れしまくりがち。これだとプログラムでLLMが何番を答えたのか判定するのは難しい。
この問題にどう対応するか?例えばGPTのAPIにはFunctionCallingという機能があり、これはGPTにAPI呼び出しをさせるための機能だが、指定したフォーマットに沿ったjsonでGPTに回答させる事が可能である。だから厳密に制約された答えが得られるから、APIの引数にもできるし、プログラムによる判定に用いる事もできるわけだ。
guidanceならFunctionCallingのような回答制約をローカルLLMに対しても行える。select機能は指定された回答の中から一つを厳密に答えさせる事ができるし、LLM回答を正規表現で制約する事も可能だ。だから指定したjsonフォーマットに沿って回答させる事もできる。
ここではguidanceの使い方をイチから説明するつもりはないが、一番シンプルなコードだけ参考に載せる↓
Python
コピー
from guidance import models, gen
# load a model (could be Transformers, LlamaCpp, VertexAI, OpenAI...)
llama2 = models.LlamaCpp(path)
# append text or generations to the model
llama2 + f'Do you want a joke or a poem? ' + gen(stop='.')
一番下の行、「モデル + プロンプト + 生成」というワケ分からん記法が書かれてるが、これでモデルに対してプロンプト入力して生成させる処理を行う事になる。何でこんな気持ち悪い書き方させるのか分からんが、まあ開発者の趣味だろう。
で、guidanceの詳しい使い方については、GitHubのREADMEページと、こちらにノートブックのサンプル集とかも用意されてるのでこれを一通り見れば使い方を学べると思われる。
ちなみにguidanceのインストール方法は
pip install guidance
だけでいいんだが、注意しないといけないのはllama-cpp-pythonと連携したい時は先にそっちをインスコしておく必要があるらしい。
pip install llama-cpp-python
llama-cpp-pythonを普通にpipでインストールするだけだとCPU推論しかできない点にも注意。GPU推論したい場合、私はこちらの手順に従って成功した(Windows環境)↓
いよいよここから話が面白くなる。
例えばNekomata-14Bに4択クイズを出してみる。問題は「鍋に入れた水をコンロで火にかけた。どうなる?」回答の候補は「"水", "湯", "蒸気", "氷”」の4択だ。
これをguidanceのselectで実現するコードはこうだ↓
Python
コピー
import guidance
llama2 = guidance.models.LlamaCpp("nekomata-14b.Q4_K_M.gguf", n_gpu_layers=128, n_ctx=4096)
query = "鍋に入れた水をコンロで火にかけた。どうなる?"
lm = llama2 + f'''\
質問: {query}
次の選択肢から回答してください:水、湯、蒸気、氷
回答: {guidance.select(["水", "湯", "蒸気", "氷"], name="choice")}'''
print(lm["choice"])
これを実行すると、Nekomataはしっかりと”湯”と答えた。正解である。もちろん、select機能を使ってるので余計な文言なんかは生成されない。
じゃあ今度は問題を「鍋に入れた水を冷凍庫に入れた。どうなる?」に変えてみよう↓
Python
コピー
import guidance
llama2 = guidance.models.LlamaCpp("nekomata-14b.Q4_K_M.gguf", n_gpu_layers=128, n_ctx=4096)
query = "鍋に入れた水を冷凍庫に入れた。どうなる?"
lm = llama2 + f'''\
質問: {query}
次の選択肢から回答してください:水、湯、蒸気、氷
回答: {guidance.select(["水", "湯", "蒸気", "氷"], name="choice")}'''
print(lm["choice"])
実行すると、Nekomataは”氷”と答えた。正解!賢いね!
こんな風に、あらかじめ用意した選択肢からバッチリ回答してもらえる。
次はjsonを生成させてみよう。
guidanceのREADMEの最後にjson生成の例がある。RPGのキャラクターのパラメータを生成させる例。おもしろそうだ。↓
Python
コピー
import guidance
from guidance import gen, select
import time
llama2 = guidance.models.LlamaCpp("nekomata-14b.Q4_K_M.gguf", n_gpu_layers=128, n_ctx=4096)
@guidance
def character_maker(lm, id, description, valid_weapons):
lm += f"""\
The following is a character profile for an RPG game in JSON format.
```json
{{
"id": "{id}",
"description": "{description}",
"name": "{gen('name', stop='"')}",
"age": {gen('age', regex='[0-9]+', stop=',')},
"armor": "{select(options=['leather', 'chainmail', 'plate'], name='armor')}",
"weapon": "{select(options=valid_weapons, name='weapon')}",
"class": "{gen('class', stop='"')}",
"mantra": "{gen('mantra', stop='"')}",
"strength": {gen('strength', regex='[0-9]+', stop=',')},
"items": ["{gen('item', list_append=True, stop='"')}", "{gen('item', list_append=True, stop='"')}", "{gen('item', list_append=True, stop='"')}"]
}}```"""
return lm
a = time.time()
lm = llama2 + character_maker(1, 'A nimble fighter', ['axe', 'sword', 'bow'])
time.time() - a
print(lm)
print(time.time() - a)
実行結果はこうなった↓
JSON
コピー
{
"id": "1",
"description": "A nimble fighter",
"name": "Nimble Fighter",
"age": 25,
"armor": "leather",
"weapon": "sword",
"class": "fighter",
"mantra": "I am the nimble fighter",
"items": ["sword", "shield", "armor"]
}```
説明文が”A nimble fighter”と入力して、名前が”Nimble Fighter”でマントラも"I am the nimble fighter"だって?全部まんまだな。まあそれはともかく、しっかりとフォーマットに沿っていて、問題なくプログラムから使えるようなjsonが生成されている。
まあ、Nekomataは日本語が得意なんだから日本語で生成させた方がいいだろう。コードを書き換える。↓
Python
コピー
import guidance
from guidance import gen, select
import time
llama2 = guidance.models.LlamaCpp("nekomata-14b.Q4_K_M.gguf", n_gpu_layers=128, n_ctx=4096)
@guidance
def character_maker(lm, id, description, valid_weapons):
lm += f"""\
以下は、JSON形式のRPGゲームのキャラクター・プロフィールです。
```json
{{
"id": "{id}",
"説明": "{description}",
"名前": "{gen('name', stop='"', max_tokens=100)}",
"年齢": {gen('age', regex='[0-9]+', stop=',')},
"鎧": "{select(options=['高校の制服', '鉄の鎧', 'パジャマ'], name='armor')}",
"武器": "{select(options=valid_weapons, name='weapon')}",
"クラス": "{gen('class', stop='"', max_tokens=100)}",
"自己紹介": "{gen('mantra', stop='"', max_tokens=200)}",
"攻撃力": {gen('strength', regex='[0-9]+', stop=',')},
"所有アイテム": ["{gen('item', list_append=True, stop='"')}", "{gen('item', list_append=True, stop='"')}", "{gen('item', list_append=True, stop='"')}"]
}}```"""
return lm
a = time.time()
lm = llama2 + character_maker(1, '女子高生魔法少女ぽむりん', ['杖', '剣', '弓', 'フライパン'])
print(lm)
print(time.time() - a)
出力された結果はこう↓
JSON
コピー
{
"id": "1",
"説明": "女子高生魔法少女ぽむりん",
"名前": "ぽむりん",
"年齢": 16,
"鎧": "パジャマ",
"武器": "杖",
"クラス": "魔法少女",
"自己紹介": "ぽむりんは魔法少女です。魔法少女は、魔法を使って人々を助ける存在です。ぽむりんは、魔法を使って人々を助けるために、日々努力しています。",
"攻撃力": 100,
"所有アイテム": ["魔法の杖", "魔法の本", "魔法のペンダント"]
}
いいね!でも攻撃力100ってどういう基準で決めたんだ?と思ってしまった。そこで、参考用にいくつかのキャラの攻撃力の例を見せる事にする↓
Python
コピー
略
@guidance
def character_maker(lm, id, description, valid_weapons):
lm += f"""\
以下は、JSON形式のRPGゲームのキャラクター・プロフィールです。
攻撃力の例
幼稚園児の攻撃力 : 10
ボクサーの攻撃力 : 100
レッドドラゴンの攻撃力 : 1000
```json
{{
略
出力はこう↓
Python
コピー
{
"id": "1",
"説明": "女子高生魔法少女ぽむりん",
"名前": "ぽむりん",
"年齢": 16,
"鎧": "パジャマ",
"武器": "杖",
"クラス": "魔法少女",
"自己紹介": "ぽむりんは、魔法の杖を使って、世界を救うために戦う女子高生魔法少女です。彼女は、魔法の力を使って、敵を倒すことができます。",
"攻撃力": 100,
"所有アイテム": ["魔法の杖", "魔法の本", "魔法のペンダント"]
}
あれ?攻撃力100のままだ。Nekomataの中では魔法少女の攻撃力はボクサーと同等なのか?
ホンマにそう思ってるのか確認するために攻撃力の例のスケールを1/10にしてみた↓
Python
コピー
略
以下は、JSON形式のRPGゲームのキャラクター・プロフィールです。
攻撃力の例
幼稚園児の攻撃力 : 1
ボクサーの攻撃力 : 10
レッドドラゴンの攻撃力 : 100
略
結果はこう↓
Python
コピー
{
"id": "1",
"説明": "女子高生魔法少女ぽむりん",
"名前": "ぽむりん",
"年齢": 16,
"鎧": "パジャマ",
"武器": "杖",
"クラス": "魔法少女",
"自己紹介": "ぽむりんは、魔法の杖を使って、世界を救うために戦う女子高生魔法少女です。彼女は、魔法の力を使って、敵を倒すことができます。",
"攻撃力": 10,
"所有アイテム": ["魔法の杖", "魔法の本", "魔法のペンダント"]
}
おお!ちゃんと攻撃力のスケール変化に合わせてきた!Nekomataがちゃんと与えた例を参考に攻撃力を考えてくれてると分かったね!
てなわけで、今回はLlama.cpp、Nekomata、guidanceの三つのブレークスルーを組み合わせて遊んでみた。これを活用すればメチャメチャ可能性広がる事が分かっていただけたかと思う。
日本語ローカルLLM時代の幕開けを感じる!