🗿

LLM搭載エージェント共に気候問題を議論させる

に公開

本記事は, ローカルLLM(Ollama)搭載のAgent同士で、簡単な議論をさせてみよう!! というもの.

ローカルLLMを導入してチャットする,,,みたいな記事はたくさんあるが, LLM同士で会話させるみたいなことはあまり見かけないので記事にしてみようと思い立った.

準備

Ollamaのインストール

Ollamaは, オープンソースの大規模言語モデル(LLM)をローカル環境で簡単に実行できるツールです. 公式サイトから簡単にインストールできます.

https://ollama.com

インストール後は, ターミナル上で以下のコマンドを打ってただしくインストールされているか確認しましょう.

$ ollama --version
> ollama version is 0.9.6

また, Ollamaがどこにインストールされているか確認しておきます. Macユーザーならば whichコマンドで探せます.

$ which ollama
> /usr/local/bin/ollama

モデルをダウンロードする

Ollamaのデフォルトモデルを導入

ollamaがインストールできたら,いよいよ言語モデルをダウンロードしていく.
実はollama上から簡単にモデルをダウンロードすることができる.

$ ollama pull llama3 # Llama3のダウンロード
$ ollama run llama # Llama3モデルの稼働 -> 対話モードへ

これらのコマンドでollamaに導入できるモデルは公式サイトを参考にするとよい.

https://ollama.com/library

また, 以下の記事も参考になるだろう

https://qiita.com/hiyoko1729/items/4dfcaad104363c6d8203

ただ, なかには公式が用意していないモデルを使いたいという要望もあるかもしれない.
たとえば, 日本語特化モデルを使いたい...などのものだ.

公式にないモデルをどう使うか,,,については次を参考にしてほしい

自由にモデルを導入する

公式が用意していない言語モデルを導入する方法について説明する.

  1. モデルファイル(..gguf)をどこから落としてきて, ローカルに配置する
  2. 配置したモデルファイルをollamaに認識させ(Modelfileの作成)、起動する

1については, Hugging Faceなどが利用できる. 
ここでは日本特化モデルである, Llama-3-ELYZA-JP-8B-q4_k_m.ggufをダウンロードしてみる.
(5 GBぐらいあるので,ダウンロード先の残り容量を確認しておく)

https://huggingface.co/elyza/Llama-3-ELYZA-JP-8B-GGUF

2については, ollamaがこのモデルファイルを認識できるように, モデルに関連する必要な情報を記載したファイルを作成する(Modelfile)

Modelfile
FROM ./Llama-3-ELYZA-JP-8B-q4_k_m.gguf
TEMPLATE """{{ if .System }}<|start_header_id|>system<|end_header_id|>

{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|>

{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>

{{ .Response }}<|eot_id|>"""
PARAMETER stop "<|start_header_id|>"
PARAMETER stop "<|end_header_id|>"
PARAMETER stop "<|eot_id|>"
PARAMETER stop "<|reserved_special_token"

FROM ./Llama-3-ELYZA-JP-8B-q4_k_m.ggufは, Modelfileと先ほどのモデルファイル(.gguf)の位置関係で定まる(はず...)
なので,同じ階層にどちらのファイルも置いてあれば, このままで問題ない...

書き方については, 以下のものを参考にする(が複雑なので、既存の記事を参考にしています)
https://github.com/ollama/ollama/blob/main/docs/modelfile.md
https://qiita.com/s3kzk/items/3cebb8d306fb46cabe9f

モデルを起動してみる

特に, 公式が用意していない言語モデルを導入して動かす場合を説明する
とてもシンプルなコマンドで,以下のコマンドで, モデルファイルからモデルを作成する.

ターミナル
$ ollama create elyza:jp8b -f Modelfile # elyza:jp8bは任意のモデル名をつけて良い

作成後は, 以下のコマンドでモデルを読み込み, 対話モードを開始できる

ターミナル
$ ollama run elyza:jp8b
$ >>> こんにちは!
こんにちは!お会いできて嬉しいです。如何なさいましたか?
>>> 今の挨拶をメイドのように「ご主人様」とつけて、それっぽく挨拶し直してくださ
... い
ご主人様、こんにちはご主人様!

ローカルLLMにAPIでつなぐ

さて, ここまでの処理で, 好きな言語モデルを用いてOllamaを起動し, ターミナル上で対話するところまではできた. しかしこのままでは使い勝手が悪いので, プログラムコードからこのOllamaサーバーとの応答を直接できるようにしていく.

じつは, Ollamaをインストール後, localhost:11434ollamaサーバーは起動している.
実際に, ブウラザ上でhttp://localhost:11434/と打ち込むと, Ollama is runningとの文言が表示されていることから, サーバーが起動していることがわかる.

素直にAPIリクエストする場合

サーバーが稼働しているということは, そのURLにリクエストを送れば, レスポンスが返ってくるといううこと...なので, requestsを使ってAPIリクエストを送る例を説明する.

リクエスト周りのコードは以下の記事をお借りした.
https://zenn.dev/szczyt/scraps/c0a6561381f059

import requests
import json

# Ollamaサーバーの情報
OLLAMA_HOST = "localhost"
OLLAMA_PORT = 11434 # 指定されたポート番号
OLLAMA_BASE_URL = f"http://{OLLAMA_HOST}:{OLLAMA_PORT}"
MODEL_NAME = "elyza:jp8b" # 指定されたモデル名

# APIエンドポイント
GENERATE_API_URL = f"{OLLAMA_BASE_URL}/api/generate"

def generate_text_with_ollama(prompt: str) -> str | None:
    """
    Ollama APIにリクエストを送信し、テキスト生成結果を取得する関数 (ストリームなし)。

    Args:
        prompt: モデルへの入力プロンプト。

    Returns:
        生成されたテキスト、またはエラー時にNone。
    """
    payload = {
        "model": MODEL_NAME,
        "prompt": prompt,
        "stream": False  # ストリームせず、一度に全応答を受け取る
    }
    headers = {"Content-Type": "application/json"}

    try:
        # POSTリクエストを送信
        response = requests.post(GENERATE_API_URL, headers=headers, json=payload, timeout=60) # タイムアウトを適宜設定
        response.raise_for_status() # ステータスコードが200番台以外なら例外を発生させる

        # レスポンスをJSONとして解析
        response_data = response.json()

        # 生成されたテキストを取得
        generated_text = response_data.get("response")
        if generated_text:
            return generated_text.strip()
        else:
            print("エラー: レスポンスに 'response' キーが含まれていません。")
            print("レスポンス内容:", response_data)
            return None

    except requests.exceptions.ConnectionError as e:
        print(f"エラー: Ollamaサーバー ({OLLAMA_BASE_URL}) に接続できませんでした。")
        print(f"詳細: {e}")
        print("Ollamaサーバーが起動しているか、ネットワーク設定(ファイアウォール等)を確認してください。")
        return None
    except requests.exceptions.Timeout as e:
        print(f"エラー: Ollamaサーバーへのリクエストがタイムアウトしました。")
        print(f"詳細: {e}")
        return None
    except requests.exceptions.RequestException as e:
        print(f"エラー: Ollama APIへのリクエスト中にエラーが発生しました。")
        print(f"URL: {e.request.url if e.request else 'N/A'}")
        if e.response is not None:
            print(f"ステータスコード: {e.response.status_code}")
            try:
                # エラーレスポンスの内容を表示試行
                error_content = e.response.json()
                print(f"エラー内容: {error_content}")
            except json.JSONDecodeError:
                print(f"エラーレスポンス (テキスト): {e.response.text}")
        else:
            print(f"詳細: {e}")
        return None
    except json.JSONDecodeError as e:
        print(f"エラー: Ollama APIからのレスポンスのJSON解析に失敗しました。")
        print(f"レスポンス内容: {response.text}")
        print(f"詳細: {e}")
        return None
# プロンプトの例
user_prompt = "ちわーっす. お元気ですかー"

print(f"モデル '{MODEL_NAME}' にプロンプトを送信します...")
print(f"プロンプト: {user_prompt}")
print("-" * 30)

# テキスト生成を実行
generated_response = generate_text_with_ollama(user_prompt)

if generated_response:
    print("モデルからの応答:")
    print(generated_response)
else:
    print("テキスト生成に失敗しました。")

"""
モデル 'elyza:jp8b' にプロンプトを送信します...
プロンプト: ちわーっす. お元気ですかー
------------------------------
モデルからの応答:
ちわーっす!お元気ですよ!AIなので、疲れたり体調が悪くなったりはしませんが、常に準備OKで会話を楽しんでいます!あなたも元気いっぱいなら、うれしいです!
"""

Ollamaライブラリを使用する例

他にもollamaライブラリを使用することもできる. こちらはURLの指定がないので, そのあたりの判別を自動でやってくれるというのが特徴だろうか.

pip install ollama
import ollama
response = ollama.chat(model='elyza:jp8b', messages=[
  {
    'role': 'user',
    'content': "ちわーっす. お元気ですかー",
  },
])
print(response['message']['content'])

# ちわーっす! AIやからお元気とかの概念ないけど、会話できるのはうれしいよ! どうした?なにか話したいことある?

と,こちらの方法でも応答が無事返ってきていることがわかる.

本記事はこれらのライブラリの説明はしないので, より詳細に知りたい人は以下を参考にしてください

https://qiita.com/LiberalArts/items/6492e54d479789eddbcd

マルチエージェント化

さて, ローカルLLMとI対Iで対話できるようになったので, 最後にLLM搭載のエージェント同士で議論させるようにする. 全体の構成図は上の通り.

特徴として,

  1. 特定のトピック分(例:気候変動)に対し, ローカルLLMで応答文を生成
  2. その際, 各エージェントは, これまでの議論を参考にする
  3. 議論の内容は, 各エージェントの記憶(memory)に保存される
  4. ついでに, 各エージェントに「性格」を考慮させる
import random as rand

class LLM_Agent():
    def __init__(self, llm, name, traits):
        self.memory = [] # 記憶の保管庫(観測したものも含め)
        self.llm    = llm
        self.name   = name
        self.traits = traits
    
    def utter(self, topic):
        text = "以下のプロフィールを読んで, 次に上げるトピックに対して"
        if len(self.memory) > 0:
            text += ", これまでの相手と自分の会話履歴から"
        text += "{}さんが次にどのような発言をすべきか, その具体的な発言内容のみをリストアップしてください.\n"
        text += "\n------------------\n"
        text += f'私の名前は, {self.name}です\n'
        text += "私は, "
        for i, p in enumerate(self.traits):
            if i == len(self.traits) - 1:
                text += f"そして, {p}な人間です.\n"
            else:
                text += f'{p}で, '
        text += "\n------------------\n"
        text += f'トピック:  {topic}\n'
        for j, p in enumerate(self.memory):
            if j == 0:
                text += "---会話履歴---\n"
            text += f"{p}\n"
        print(text)
        return self.choice(self.llm.invoke(text), topic)
    
    def listen(self, talk, talker):
        self.memory.append(f'{talker.name}さんの発言:{talk}')
    
    def choice(self, output, topic):
        line = output.split("\n")
        choices = []
        for l in line:
            if "." in l or "*" in l or "-" in l:
                choices.append(l)
        text = "以下のプロフィールを読んで, 現状のトピックに対する相手との会話履歴に対して"
        text += f"この{len(choices)}の返答のうち, {self.name}さんが選ぶべき発言を, その具体的な発言内容のみを教えてください."        
        text += "\n------------------\n"
        text += f'私の名前は, {self.name}です\n'
        text += "私は, "
        for i, p in enumerate(self.traits):
            if i == len(self.traits) - 1:
                text += f"そして, {p}な人間です.\n"
            else:
                text += f'{p}で, '
        text += "\n------------------\n"
        text += f'トピック:  ${topic}\n'
        for j, p in enumerate(self.memory):
            if j == 0:
                text += "---会話履歴---\n"
            text += f"{p}\n"
        text += "返答の候補:"
        for c in choices:
            text += f'{c}\n'
        self.llm.invoke(text)
        line = output.split("\n")
        choices = []
        for l in line:
            if "." in l or "*" in l or "-" in l:
                choices.append(l)
        return rand.sample(choices, 1)[0]
# 登場人物の用意

A = LLM_Agent(llm, "A", traits = ["積極的", "親しみやすい", "誠意ある"])
B = LLM_Agent(llm, "B", traits = ["柔軟性", "批判的思考", "心配性"])

turn = 1 # やり取り回数
echo = ""
topic = "気候変動について"
while turn < 3:
    echo = A.utter(topic)
    print(f'{A.name}: {echo}')
    A.listen(echo, A)
    B.listen(echo, A)
    echo = B.utter(topic)
    print(f'{B.name}: {echo}')
    A.listen(echo, B)
    B.listen(echo, B)
    turn += 1
------------------
トピック:  気候変動について
---会話履歴---
Aさんの発言: 私は気候変動を深刻に受け止めていて、できる限りCO2の排出量を減らす努力をしています。
Bさんの発言: できる限りCO2の排出量を減らす努力をしているAさんの取り組みは素晴らしいと思います。
Aさんの発言: 自分が実践している削減方法や工夫について共有する。
Bさんの発言: 私も、具体的には何をすればいいか考えていますが、例えば、日常生活でできる削減方法や工夫などありますか?
Aさんの発言: 身近なものを紙袋や布で代用することでプラスチック使用量が減らせることについて共有。
...

と, なんか会話している感じになりました👏

ただ, Aさんがなんか固かったり(緊張しているのかな?), セリフだけを応答文から抽出できてないこともあるので, まだまだ改善は残されている感じでした.

まとめ

本記事は, ローカルLLM(Ollama)搭載のAgent同士で、簡単な議論をさせてみよう!! という内容でした. エージェント同士で議論させることで, 面白い意見やアイデアが創発されるかもしれません.
処理もそんな難しくないので, 今回の記事をベースにいろいろな拡張ができればよいなと思います.

参考

https://qiita.com/s3kzk/items/3cebb8d306fb46cabe9f

日本語特化モデルの導入については こちらの記事がとても参考になった

https://zenn.dev/oyashiro846/articles/797312443fb506
https://qiita.com/7shi/items/65741fa8ab0a553f51be

本記事では, ローカルPC内でたてたOllamaサーバーに, 同PC内からアクセスする方法をとった. 他にも, 専用のPCを用意し, 別ホスト(PC)からOllamaサーバーにアクセスする方法もある. 上記の2つ記事はそれに関する記事である. 興味のある方は参考にしてほしい.

Discussion