ハウテレビジョンブログ

『外資就活ドットコム』『Liiga』『Mond』を開発している株式会社ハウテレビジョンのブログです。

ローカルLLMで試行錯誤してみた話

はじめに

こんにちは。外資就活ドットコム 新規開発チームのでエンジニアをしている伊達です。新規開発チームは新技術を外資就活ドットコムにガンガン実装するチームと言っても過言ではありません。そんなチームに所属する私ですが、今回はローカルLLMを使ってみた話をまとめたいと思います。

このブログ執筆の段階ではまだまだ試行錯誤中なところもあるのですが、試して良かったことなんかも書いていこうと思います。

環境構築

データとしてJSONファイルを用意し、それをPythonで読み取ってローカルLLMに投げるというようなことをしました。まずは、ローカルLLMをPythonで扱えるようにするまでの環境構築をまとめていきます。

ローカルLLM候補

  • Ollama: インストールが簡単で使い勝手の良いオープンソース。CUIに慣れているならシンプルでお手軽なツール。
  • LM Studio: Windows PCでも使えるGUIツール。豊富な機能を備えている。

この後記載しますが、実際にインストールから実行までシンプルに行えたOllamaの方を今回は選択することにしました。

Ollama

Ollamaとは

  • Ollamaは、LLM (Local Learning Models) を扱うための強力なツールで、Pythonで動作することができます。これを使用することで、様々な学習モデルをローカル環境で簡単に試すことができます。

まずはollamaをインストールします。

% brew install ollama 

インストールが完了したらollamaサーバーを起動します。このサーバーにリクエストを飛ばす感じです。

% ollama serve

次に使用するモデルをダウンロードしていきます。

いくつかのモデルを検討しましたが最終的にはollamaが標準で備えているモデルのうちcommand-rというモデルを使用することにしました。

選定理由

初めは非常に軽量なモデルであるPhi-3を試してみたのですが、出力が私の求めているレベルには至りませんでした。次にOllamaが推しているllama3を試してみましたが、複雑な命令にはちょっと足りないかなという印象でした。その後、長いコンテキストのタスク向けに最適化されているというcommand-rを試したところ十分な出力が得られたので今回はcommand-rで進めていくことにしました。command-r-plusはさらに優れているらしいのでこちらも試してみたいところ。

ollamaのモデルは以下から参照できます。

https://ollama.com/library

モデルのダウンロードはこんな感じです。

% ollama pull command-r

これでOllama側の準備は完了となります。

Pythonで実行

続いてPythonから実行していきたいと思います。

まずはOllamaのPythonライブラリをインストールします。

https://github.com/ollama/ollama-python

% pip install ollama

READMEに記載の通りに書いてみてちゃんとレスポンスが帰ってくるか確認してみます。

import ollama
response = ollama.chat(model='command-r', messages=[
  {
    'role': 'user',
    'content': 'ビーフストロガノフの作り方を教えてください。',
  },
])
print(response['message']['content'])

結果

【材料】
・牛もも薄切り肉:200g
・玉ねぎ:1個
・マッシュルーム:1パック( fresh or canned both are ok)
・赤ワイン:大さじ3
・ビーフブイヨン:400cc(ない場合はお湯で代用可)
・トマトケチャップ、ウスターソース:各大さじ1
・塩・コショウ
・小麦粉
・バター、オリーブ油
・あればクレーム・フレッシュ(生クリーム)適量
【手順】

1. 牛薄切り肉を一口大に切り、塩・コショウし、小麦粉をまぶす。玉ねぎはみじん切り、マッシュルームは薄切りにする。
2. 鍋にバターとオリーブ油を熱し、牛肉を素早く炒めます。お肉に火が通ったら一旦取り出す。
3. same ポットで玉ねぎを透き通るまで炒めます。甘みを引き出すために弱めの中火がおすすめです。 
4. マッシュルームを加えて炒め、香りが出てきたら赤ワインを加えます。アルコールを飛ぶまで煮詰めます。
5. ビーフブイヨン、トマトケチャップ、ウスターソースを加え、牛肉とクレーム・フレッシュも加えて一煮立ちさせます。
6. 味を見ながら塩コショウで調味し、お好みでパセリなどのハーブを散らして完成です。

お肉や野菜から出る旨味と赤ワインの風味が絶妙なビーフストロガノフのできあがり!ご飯にもパンにも合う一品です。お家でぜひ作ってみてくださいね。

いい感じの出力になりました!sameポットって何だろうって思いましたが概ねいい感じでした。

プロンプトエンジニアリングの戦い

環境構築して実際に動作確認までとても簡単に行けちゃいました。たぶん詰まるところはないと思います。楽勝だぜって思いました。

ですが、本編はここからでレスポンスをこっちが指定した形式に格納するためには?というところでなかなか苦戦を強いられました。プロンプトに「JSONで出力してください」とかけばJSONの形式で出力はしてくれるのですが、その中身がうまくいったりいかなかったりが続きました。

対策1:プロンプトにJSONのテンプレートを与える

出力してほしいJSONをテンプレートとしてプロンプトに含んでみました。以下みたいな感じで作ってプロンプトに入れていました。

TEMPLATE = {
  "data":[{
    "caetegory":"選考プロセス",
    "advices": [""]
  },
  {
    "caetegory":"インターンシップの内容と効果",
    "advices": [""]
  },
  {
    "caetegory":"感想・総合評価",
    "advices": [""]
  },
  {
    "caetegory":"その他",
    "advices": [""]
  }],
  "reference": [None]
}

結果としてテンプレートに則った出力をしてくれる確率が非常に高くなったのでかなり良さそうでした。それぞれのプロパティについてはほとんど成功率100%だったと思います(勝手に違うプロパティ名にしてきたりはなかった)。

対策2:Pythonのjsonschemaライブラリを使って検証する

先ほどの対策でJSONの形式は概ね良さそうですが値のコントロールがうまくいきませんでした。

例えば、文字列を入れて欲しいところにオブジェクトで入れてきたりなど。思えば配列のところで勝手なことをしてくることが多かった気がします。JSONオブジェクトとして読み込めない時もありました。

これじゃないって思った一例

 "advices": [{"id":20, "advice":"わかりやすい日本語を使いましょう"}, {"id":31, "advice": "主語を意識しましょう"}]

何度かやっていればちゃんとした出力を得られることがあったので、とにかく期待の形式になるまでリトライする戦略で戦ってみました。

PythonのjsonschemaというライブラリでJSONの検証ができそうだったので、それを使って検証→期待した出力でなければリトライとしてみました。

まずはSchemaを作成します。

SCHEMA = {
  'type' : 'object',
  'properties' : {
    'data' : {
      'type' : 'array',
      'items': {
        'type' : 'object',
        'properties': {
          'category': {
            'type': 'string'
          },
          'advices': {
            'type': 'array',
            'items': {'type': 'string'}
          }
        },
        'required': ['category', 'advices']
      }
    },
    'reference' : {
      'type' : 'array',
      'items': {'type': 'integer'}
    }
  },
  'required': ['data', 'reference']
}

続いて検証する関数の作成

from jsonschema import validate
import json
import ast

def validate_response_format(response, schema):
  try:
    if not response:
      return False
     # 文字列を辞書に変換
    response_dict = ast.literal_eval(response)
    # JSON文字列に変換(JSONとして有効か確認)
    response_json = json.loads(json.dumps(response_dict)) 
    # 定義したscehmaに適合しているか検証
    validate(instance=response_json, schema=_schema)
  except (SyntaxError, jsonschema.exceptions.ValidationError, ValueError) as err:
    return False, str(err)  # エラーメッセージを返す
  return True, ""  # エラーがない場合は空のエラーメッセージを返す

この検証を使って失敗したらリトライ(上限20回)としてみました。

def llm_response(prompt, schema, max_retries=20):
  messages = [
    {
      'role': 'user',
      'content': prompt
    }
  ]
  # max_retries:リトライの最大数
  for retry_count in range(max_retries):
    response = ollama.chat(model=MODEL, messages=messages)
        # 検証
    is_valid, error_message = validate_response_format(response['message']['content'], schema)
    if is_valid:
      return response['message']['content']
  raise ValueError('Maximum number of retries exceeded')

20回もリトライさせてるだけあって、欲しい出力が得られるようになってきました。ただ、プロンプトやそれに含むデータの量・質次第では20回のリトライでも全然うまくいかないということが多々ありました。その度に、プロンプトを調整してSchema通りに出力してくれるようにと、試行錯誤していきました。

リトライの時、システムプロンプトにエラー文を添えることも試してみましたが、そのエラーを見ても同じ過ちを繰り返してきた時は頭を抱えました。

対策3:プロンプトにSchemaを与える

もういっそプロンプトにSchemaを与えてみてました。

以下のようにして注意文も添えてやることで検証に通りやすくなるようにということを期待していました。

# Schema
%s //上記のSchemaをここに

注意:
- 渡されたSchemaに*厳密に従って*レスポンスを作成してください。
- 余分なフィールドや欠けているフィールドが無いように注意してください。

結果として、体感ですがあまり大きく出力の質は変わらなかった気がします。海外のプロンプトエンジニアがブログで公開しているコードを見ると同じようにSchemaを与えていたので行けるかなと思ったのですが、成果は微妙でした。ないよりはあったほうがいいかも?って感じでした。

対策4:出力結果を加工する

なんとか検証を抜けた出力が得られたとしても、完全に正しいとは限りません。

細かく出力されたデータをチェックして、例えば改行コードが入ってるデータがある時はそれを除外する処理を書いたりなどが必要となりました。

データ加工は生成AIと違い出力が変化することがないので、気持ちは楽でしたがそこまで綺麗に出力して欲しかったよというのが本音です。

対策5: プロンプトをステップごとに分けてみる

弊社で先駆けてプロンプトエンジニアリングをしていた同期に相談したところ、一度に多くのタスクをこなすのは難しい・ステップごとに分けるべきとのアドバイスをいただいたので試してみました。試しにこんな感じでmessagesの配列に2つのプロンプトを入れてみることにしました。

messages = [
    {
      'role': 'system',
      'content': 'あなたは命令に従ってデータを作成し、JSONを文字列として生成するエージェントです。与えられたタスクを実行してください。',
    },
    {
      'role': 'user',
      'content': '文章を要約するタスクについてのプロンプト〜〜'
    },
    {
      'role': 'user',
      'content': 'JSONに整形して出力するタスクについてのプロンプト〜〜'
    }
  ]
  response = ollama.chat(model=MODEL, messages=messages)

結果として驚くほどに精度が向上しました!このプロンプトの構成に変えてから、対策2で入れたリトライの回数が明らかに減ったのです。てっきりレスポンスを一度受け取ってからプロンプトに含めてもう一度投げ直すのかと思っていたのですが、このやり方で充分なようでした。今回は2回のステップに分けましたが、もっと細かいステップにすれば完璧に求めた出力が得られるようになるかもしれません。

感想&締め

生成AIはパワフルで一昔前では考えられなかったレベルの出力をしてくれます。確かに苦労したところはありましたが、モデルがどんどんアップデートされていくでしょうし先は明るいですね。

どこのプロンプトが悪さしてうまくいかないんだ?というのが全然検討がつかないので沼にハマった時は大変でした。

それとローカルLLMだと出力までの時間がネックでした。

使うモデルやマシンスペック、プロンプトのトークン量に寄るとは思いますが、今私が作業しているケースだと1回の出力に平均2~5分かかっているようでした。それを思うとChatGPT4-oって早すぎますね。ちなみに私はMac Book Pro (M2 Max)、メモリ64GBという環境で実行しています。メモリが少ないともっと厳しいのかも。

まだまだ試行錯誤の最中ですが、最先端の技術に触れられる機会があるのは最高に楽しいです!頑張ります!