Home About Contact
Python , LLM , Bottle

日本語 LLM ELYZA で JSON を返すサーバをつくる

そこそこに速いシリコンマックで ELYZA-japanese-Llama-2-7b-fast-instruct を使って クライアントからテキストを投げると JSON文字列 を返すサーバをつくります。

小手試し

サーバを実装するまえに main.py 単体で作動するコードで試します。

コードは huggingface の ELYZA-japanese-Llama-2-7b を 参考にしています。

main.py

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from bottle import Bottle, run, request

#MODEL_NAME = "elyza/ELYZA-japanese-Llama-2-7b-instruct"
DEFAULT_SYSTEM_PROMPT = "テキストをJSON形式に変更して出力してください。"

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)

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

text = """
商品名 ベルギーワッフル
名称 洋菓子
原材料 小麦粉(国内製造)、液卵、砂糖、マーガリン、バター加工品
"""

prompt = to_prompt(text, tokenizer)
token_ids = tokenizer.encode(
    prompt,
    add_special_tokens=False,
    return_tensors='pt')

with torch.no_grad():
    output_ids = model.generate(
        token_ids.to(model.device),
        max_new_tokens=256,
        temperature=0.01,
        pad_token_id=tokenizer.pad_token_id,
        eos_token_id=tokenizer.eos_token_id)

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

回答の精度を高めるために torch_dtypefloat16 ではなく float32 でロードしようとしましたが、 おそらくモデル全体がメモリにロードできないため、offload_folder 指定が必要でした。 この状態では結局 SSD を使うことになるので、途方もなく時間がかかるようです。

model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    device_map="auto",
    offload_folder="offload",
    torch_dtype=torch.float32)

see also: 日本語 LLM ELYZA 追伸

実行します。

$ python main.py 
<s> [INST] <<SYS>>
テキストをJSON形式に変更して出力してください。
<</SYS>>


商品名 ベルギーワッフル
名称 洋菓子
原材料 小麦粉(国内製造)、液卵、砂糖、マーガリン、バター加工品
 [/INST]  {
    "商品名": "ベルギーワッフル",
    "名称": "洋菓子",
    "原材料": [
        "小麦粉(国内製造)",
        "液卵",
        "砂糖",
        "マーガリン",
        "バター加工品"
    ]
}</s>

できました。

サーバ

単体で作動する main.py に bottle を使って POST されてきた文字列を LLM で処理して結果を返す機能を追加します。

main.py

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from bottle import Bottle, run, request

DEFAULT_SYSTEM_PROMPT = "テキストをJSON形式に変更して出力してください。"

#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)


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


app = Bottle()

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

    prompt = to_prompt(text, tokenizer)
    token_ids = tokenizer.encode(
        prompt,
        add_special_tokens=False,
        return_tensors='pt')

    with torch.no_grad():
        output_ids = model.generate(
            token_ids.to(model.device),
            max_new_tokens=256,
            temperature=0.01,
            pad_token_id=tokenizer.pad_token_id,
            eos_token_id=tokenizer.eos_token_id)

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

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

今回はサーバとクライアントの両方をローカルマック上で動かすので run(app, host='localhost', port=8000, debug=True) にしています。 もし、クライアントは別のマシンから実行するなどの場合は、localhost 部分を IP アドレスに変える必要があります。

たとえばサーバのIPアドレスが 192.168.10.100 の場合は次のようにします。

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

それでは起動します。 (依存するライブラリは 以前のエントリー 日本語LLM ELYZA を試す / LLM を ローカルで動かす時代の幕開け を参照)

$ python main.py 
Bottle v0.12.25 server starting up (using WSGIRefServer())...
Listening on http://localhost:8000/
Hit Ctrl-C to quit.

この環境では数秒で起動できました。

クライアント

curl で試します。

$ curl -X POST "http://localhost:8000/query" \
    -d $'商品名 ベルギーワッフル\n名称 洋菓子\n原材料 小麦粉(国内製造)、液卵、砂糖、マーガリン、バター加工品' \
    -H "Content-Type: plain/text"

この文字列は 無印良品のベルギーワッフルを参考にしました。

結果が標準出力されます。

<s> [INST] <<SYS>>
テキストをJSON形式に変更して出力してください。
<</SYS>>

商品名 ベルギーワッフル
名称 洋菓子
原材料 小麦粉(国内製造)>    、液卵、砂糖、マーガリン、バター加工品 [/INST]  {
    "商品名": "ベルギーワッフル",
    "名称": "洋菓子",
    "原材料": [
        "小麦粉(国内製造)",
        "液卵",
        "砂糖",
        "マーガリン",
        "バター加工品"
    ]
}</s>

6秒 程度で結果が返りました。

サーバの改良

JSON文字列部分だけが結果としてほしいので、結果文字列から JSON 部分だけ取り出すコードを追加します。

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

そして、結果を返す部分で return output とそのまま返さないで to_answer 関数を通した結果を返します。

@app.post('/query')
def query():
    ....
    #return output
    return to_answer(output)

それでは サーバを起動し直して、再度クライアントを実行。

$ curl -X POST "http://localhost:8000/query" \
    -d $'商品名 ベルギーワッフル\n名称 洋菓子\n原材料 小麦粉(国内製造)、液卵、砂糖、マーガリン、バター加工品' \
    -H "Content-Type: plain/text"
{
    "商品名": "ベルギーワッフル",
    "名称": "洋菓子",
    "原材料": [
        "小麦粉(国内製造)",
        "液卵",
        "砂糖",
        "マーガリン",
        "バター加工品"
    ]
}

JSON 部分だけが標準出力されるようになりました。

まとめ

今回の実験は、 次のような文字列を投げて:

商品名 ベルギーワッフル
名称 洋菓子
原材料 小麦粉(国内製造)、液卵、砂糖、マーガリン、バター加工品

次のJSON文字列を得ることができたわけですが...

{
    "商品名": "ベルギーワッフル",
    "名称": "洋菓子",
    "原材料": [
        "小麦粉(国内製造)",
        "液卵",
        "砂糖",
        "マーガリン",
        "バター加工品"
    ]
}

どうなんでしょうか。

これが有効に機能するかどうかは、 もう少しいろいろな文字列を投げ込んでみないとわかりません。 そもそも タスクの種類によって期待する精度が異なるので おいおいなにかより現実に即したタスクを考えてみようと思います。

ちなみに、fast がついていない方のモデル ELYZA-japanese-Llama-2-7b-instruct では、 JSON文字列を最後まで出力することができませんでした。 原因はわかりません。

ELYZA-japanese-CodeLlama-7b が出たので、いつかこちらでも実験してみたいと思います。

最後に完成した main.py を掲載します。

import re
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from bottle import Bottle, run, request

DEFAULT_SYSTEM_PROMPT = "テキストをJSON形式に変更して出力してください。"

#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)


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


app = Bottle()

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

    prompt = to_prompt(text, tokenizer)
    token_ids = tokenizer.encode(
        prompt,
        add_special_tokens=False,
        return_tensors='pt')

    with torch.no_grad():
        output_ids = model.generate(
            token_ids.to(model.device),
            max_new_tokens=256,
            temperature=0.01,
            pad_token_id=tokenizer.pad_token_id,
            eos_token_id=tokenizer.eos_token_id)

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

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

以上です。