Home About Contact
Python , LLM , Bottle

日本語LLM ELYZA を試す / LLM を ローカルで動かす時代の幕開け

Large Language Model をローカルで動かす時代が到来するらしい。

Reddit には LocalLLaMA というサブレディットがあり、かなり盛り上がっている。 そこでは シリコンマックを使っているひとが結構いる。 Meta の 商用利用も可能な Llama 2 がオープンソースで提供された結果、 その派生プロジェクトがいろいろ存在している。 そのなかには、低スペックの シリコンマックでも LLM を実行できるものがある。 Pytorch もMetal 対応していて、 おそらくは、Pytorch 依存の LLM モデルについては、Linux + CUDA と同じ手順で動かせるのではないかと思う。

Pytorch を使う方法を 手元の M1 Macbook Air 8GB で試したが残念ながら作動しなかった。 たぶんメモリが足りないのだと思う。 十分メモリがあれば動くのではないかと思うが定かではない。

それでも Llama.cppと軽量化されたモデルを使うことで M1 Macbook Air 8GB でも作動させることができた。

このエントリーでは、Linux + CUDA で、 Llama 2 をベースに商用利用可能な日本語LLM ELYZA を試します。

環境

Ubuntu 22.04 でざっと以下のような環境で試しています。

CUDA

$ python --version
Python 3.8.17

仮想環境の用意

$ python -m venv ~/local/venv-elyza
$ source  ~/local/venv-elyza/bin/activate
(venv-elyza) $

依存するライブラリを入れます。

(venv-elyza) $ pip install transformers torch accelerate

カタログスペック情報から原産地名を抜き出す

次のようなタスクをやってみます。

ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(国産)、植物油脂)、殻付き海老(インド)、いか(中国)

こんなスペック情報があったとして、ここから原産地情報だけを抜き出したい。

期待する結果は:

です。

コード書く

コードの詳細は https://huggingface.co/elyza/ELYZA-japanese-Llama-2-7bをご覧ください。

まずプロンプトを生成するところまでコードを書きます。

main.py

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

DEFAULT_SYSTEM_PROMPT = "あなたは誠実で優秀な日本人の校正者です。"

#MODEL_NAME = "elyza/ELYZA-japanese-Llama-2-7b-instruct"
MODEL_NAME = 'elyza/ELYZA-japanese-Llama-2-7b-fast-instruct'

def to_prompt(text, tokenizer):
    inst_open, inst_close = "[INST]", "[/INST]"
    sys_open, sys_close = "<<SYS>>\n", "\n<</SYS>>\n\n"

    return "{bos_token}{b_inst} {system}{prompt} {e_inst} ".format(
        bos_token=tokenizer.bos_token,
        b_inst=inst_open,
        system=f"{sys_open}{DEFAULT_SYSTEM_PROMPT}{sys_close}",
        prompt=text,
        e_inst=inst_close)

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

text = """
次の例文から原産地名を列挙して箇条書きにしてください。
例文: ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(国産)、植物油脂)、殻付き海老(インド)、いか(中国)
"""

prompt = to_prompt(text, tokenizer)
print(prompt)

モデルは fast なしと fast 付きのものがあります。 オフィシャルアナウンスページ https://note.com/elyza/n/na405acaca130 によれば、fast 付きのほうが性能が高いようです。詳しくはそちらをご覧ください。

プロンプトは以下のようにしました。

次の例文から原産地名を列挙して箇条書きにしてください。
例文: ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(国産)、植物油脂)、殻付き海老(インド)、いか(中国)

実行してみます。

(venv-elyza) $ python main.py
<s>[INST] <<SYS>>
あなたは誠実で優秀な日本人の校正者です。
<</SYS>>


次の例文から原産地名を列挙して箇条書きにしてください。
例文: ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(国産)、植物油脂)、殻付き海老(インド)、いか(中国)
 [/INST] 

まだ このLLMに投げる プロンプトを生成 しただけです。 INST タグや SYS タグで情報を入れるのが Llama のお作法とのこと。

上記コードに続けて、モデルをロードして結果を得るところまで記述します。

model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    device_map="auto",
    torch_dtype=torch.float16)

inputs = tokenizer(
    prompt,
    add_special_tokens=False,
    return_tensors='pt')

with torch.no_grad():
    output_ids = model.generate(
        inputs['input_ids'].to(model.device),
        max_new_tokens=100,
        do_sample=True,
        temperature=0.01,
        top_p=0.9,
        pad_token_id=tokenizer.pad_token_id,
        bos_token_id=tokenizer.bos_token_id,
        eos_token_id=tokenizer.eos_token_id,
        repetition_penalty=1.1)

output = tokenizer.decode(output_ids.tolist()[0])
print(output)

実行。

$ python main.py 
<s> [INST] <<SYS>>
あなたは誠実で優秀な日本人の校正者です。
<</SYS>>


次の例文から原産地名を列挙して箇条書きにしてください。
例文: ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(国産)、植物油脂)、殻付き海老(インド)、いか(中国)
 [/INST]  承知しました。以下が原産地名のリストになります。

 - エクアドル
 - インド
 - 中国</s>

残念。「国産」は産地名として認識できなかったようです。

試しに「国産も産地名として扱ってください。」を text に追記してやってみたところ、 以下の結果がかえりました。

- エクアドル
- インド
- 中国
- 国産

国産が最後に列挙されていますが、どうなんでしょうか。 じゃがいも(国産) があるから「国産」を列挙してくれたのか? それとも、無条件に結果に含めればよいと解釈されたのか?

では、「国産」に代えて「カナダ」にして試してみます。 つまりプロンプトの text は次のようにしました。

次の例文から原産地名を列挙して箇条書きにしてください。
国産も産地名として扱ってください。
例文: ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(カナダ)、植物油脂)、殻付き海老(インド)、いか(中国)

それでは実行してみます。

$ python main.py 
<s> [INST] <<SYS>>
あなたは誠実で優秀な日本人の校正者です。
<</SYS>>


次の例文から原産地名を列挙して箇条書きにしてください。
国産も産地名として扱ってください。
例文: ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(カナダ)、植物油脂)、殻付き海老(インド)、いか(中国)
 [/INST]  承知しました。

 - エクアドル
 - カナダ
 - 中国
 - インド
 - 日本の他

 ただし、いかの産地については「中国」とありますが、これは誤りです。
 正しい答えは「日本の他」となります。</s>

えっと、内容の誤りを指摘してきた。優秀な「校正者」なので、そうしたのであろうか? 「チェッカー」という前提にすれば、そこまで余分な指摘をしないで済ませてくれるのであろうか?

$ python main.py 
<s> [INST] <<SYS>>
あなたは誠実で優秀な日本人のチェッカーです。
<</SYS>>


次の例文から原産地名を列挙して箇条書きにしてください。
国産も産地名として扱ってください。
例文: ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(カナダ)、植物油脂)、殻付き海老(インド)、いか(中国)
 [/INST]  承知しました。

 - エクアドル
 - カナダ
 - 中国
 - インド
 - 日本の他

 ただし、いかの産地については「中国」と記載されていますが、現在では養殖されているため、正確には中国ではなく日本での生産となります。</s>

いや更に踏み込んで説明されてしまった。 この LLM にとっては、いかの産地は「中国」であるはずはない、ということらしい。 実際この指摘が正しいのかは(自分には)わからない。 正しくない理由「現在では養殖されているため」と言われると妙に説得力増すな。

「原産地名」と書いてしまったのがいけないのかもしれない。

「原産地名」に代えて「産地名」を使うことにしてみます。 また、出力形式を JSON とします。

次の例文に存在する産地名を見つけて JSON 形式で出力してください。
例文: ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(カナダ)、植物油脂)、殻付き海老(インド)、いか(中国)

実行します。

$ python main.py 
<s> [INST] <<SYS>>
あなたは誠実で優秀な日本人のチェッカーです。
<</SYS>>


次の例文に存在する産地名を見つけて JSON 形式で出力してください。
例文: ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(カナダ)、植物油脂)、殻付き海老(インド)、いか(中国)
 [/INST]  {"産地":["エクアドル", "カナダ", "中国"]}</s>

今度は意図通りできました。 JSON で回答してといったことで、余分の補足情報が入らなくなりました。

結果の JSON だけを取りだす to_answer 関数を書きます。

import re

def to_answer(text):
    p = re.compile('.*\\[/INST\\](.*)</s>$', re.MULTILINE | re.DOTALL)
    m = p.search(text)
    if m:
        return m.group(1).strip()
    else :
        return text

これを使えば、以下のように結果が得られます。

$ python main.py
{"産地":["エクアドル", "カナダ", "中国"]}

キーを「産地」ではなく「産地名」にしたい。

プロンプトに JSON のキーは「産地名」としてください。 を追加。

次の例文に存在する産地名を見つけて JSON 形式で出力してください。
JSON のキーは「産地名」としてください。
例文: ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(カナダ)、植物油脂)、殻付き海老(インド)、いか(中国)

実行してみます。

{
    "産地名": "エクアドル",
    "産地名": "カナダ",
    "産地名": "インド",
    "産地名": "中国"
}

キーなのに重複してしまっている。難しい。 アレクサと同じで どういえば伝わるのか問題 があるな。

中間まとめ

ここまでのコードを整理します。

main.py

import re
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

#DEFAULT_SYSTEM_PROMPT = "あなたは誠実で優秀な日本人の校正者です。"
DEFAULT_SYSTEM_PROMPT = "あなたは誠実で優秀な日本人のチェッカーです。"

#MODEL_NAME = "elyza/ELYZA-japanese-Llama-2-7b-instruct"
MODEL_NAME = 'elyza/ELYZA-japanese-Llama-2-7b-fast-instruct'

def to_answer(text):
    p = re.compile('.*\\[/INST\\](.*)</s>$', re.MULTILINE | re.DOTALL)
    m = p.search(text)
    if m:
        return m.group(1).strip()
    else :
        return text

def to_prompt(text, tokenizer):
    inst_open, inst_close = "[INST]", "[/INST]"
    sys_open, sys_close = "<<SYS>>\n", "\n<</SYS>>\n\n"

    return "{bos_token}{b_inst} {system}{prompt} {e_inst} ".format(
        bos_token=tokenizer.bos_token,
        b_inst=inst_open,
        system=f"{sys_open}{DEFAULT_SYSTEM_PROMPT}{sys_close}",
        prompt=text,
        e_inst=inst_close)

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

text = """
次の例文に存在する産地名を見つけて JSON 形式で出力してください。
例文: ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(カナダ)、植物油脂)、殻付き海老(インド)、いか(中国)
"""

prompt = to_prompt(text, tokenizer)

model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    device_map="auto",
    torch_dtype=torch.float16)

inputs = tokenizer(
    prompt,
    add_special_tokens=False,
    return_tensors='pt')

with torch.no_grad():
    output_ids = model.generate(
        inputs['input_ids'].to(model.device),
        max_new_tokens=100,
        do_sample=True,
        temperature=0.01,
        top_p=0.9,
        pad_token_id=tokenizer.pad_token_id,
        bos_token_id=tokenizer.bos_token_id,
        eos_token_id=tokenizer.eos_token_id,
        repetition_penalty=1.1)

output = tokenizer.decode(output_ids.tolist()[0])
final_answer = to_answer(output)
print(final_answer)

なお、 回答のゆれをできる限りなくし、 毎回同じ回答が出ることを期待して オプションの temperature 値は 0.01 に設定しています。

Bottle を使ってサーバにする

それではこれを REST サーバにして、 単純に text を POST して回答を得られるようにします。

Bottle を入れます。

(venv-elyza) $ pip install bottle

main.py を修正します。

from bottle import Bottle, run, request

...省略...

app = Bottle()

@app.post('/query')
def query():
    text = request.body.getvalue().decode('utf-8')
    print(text)

    prompt = to_prompt(text, tokenizer)
    inputs = tokenizer(
        prompt,
        add_special_tokens=False,
        return_tensors='pt')
    
    with torch.no_grad():
        output_ids = model.generate(
            inputs['input_ids'].to(model.device),
            max_new_tokens=100,
            do_sample=True,
            temperature=0.01,
            top_p=0.9,
            pad_token_id=tokenizer.pad_token_id,
            bos_token_id=tokenizer.bos_token_id,
            eos_token_id=tokenizer.eos_token_id,
            repetition_penalty=1.1)
    
    output = tokenizer.decode(output_ids.tolist()[0])
    return to_answer(output)

run(app, host='localhost', port=8000, debug=True)

サーバ側はこれで完成です。

(venv-elyza) $ python main.py

として起動しておきます。

クライアントは、curl を使います。

q.sh

#!/bin/bash
curl -X POST "http://localhost:8000/query" -d '次の例文に存在する産地名を見つけて JSON 形式で出力してください。例文: ブロッコリー(エクアドル)、揚げじゃがいも(じゃがいも(カナダ)、植物油脂)、殻付き海老(インド)、いか(中国)' -H "Content-Type: plain/text"

実行。

$ sh q.sh
{"産地":["エクアドル", "カナダ", "中国"]}

うまくいきました。

他の例も試してみます。 今度は q.sh ファイルに書かないで、直接コマンドラインにコマンドを入力します。

$ curl -X POST "http://localhost:8000/query" -d \
"次の例文から価格のみを抜き出して JSON にしてください。例文: この商品は【ネットストア限定、消費税込5,000円以上で配送料無料、対象商品】です" \
-H "Content-Type: plain/text"
{"price":5000}

うまくいきました。

まとめ

このような LLM サーバを常時起動させておきたい。 しかし、NVIDIA GeForce RTX 3060 はファンがうるさく、常時起動はいやだ。 となると、シリコンマックじゃないのかこれは。

シリコンマック(M2)は 無印/Pro/Max のグレードがあるが、 メモリ帯域がそれぞれ 100GB/s, 200GB/s, 400GB/s になっているらしく、 これが LLM で推論させるときの処理時間の短縮に直結するらしい。

mac mini (M2 Pro) の 32GB モデルの場合 24万円のようなので、 LLMサーバとして使うとしたらこれかな。 GPU側に総メモリのうち 2/3 が割り振られたとしたら 21GB のGPUメモリということになる。20G程度の NVIDIA GPUとなると相当なお値段になるはず。 今検索したところによれば RTX 4090 24GB で 30万円を超える。

mac mini であれば(たぶん)静か、かつ設置スペースが少なくてすむ。 シリコン版の Pytorch の制約があると困るが、それの検証をするにも、 今の 8GB macbook air M1 ではどうにもならない。