その他

【ollama】賢すぎずバカすぎない、ほどよいチャットbot – 2

その他
この記事は約11分で読めます。

はじめに

みなさんこんにちは。株式会社インプルの岩崎です。

今回はこちらの続きです。まだ前回の記事を見てない方は、こちらをチェック!

モデルを変えて、ほどよくする

前回はMCPサーバーを立てて、データを引っ張りLLMが回答する仕組みを作りました。
前回のLLMのモデルは、claudeで高性能すぎたので、今回は、ほどよくしていくために、ローカルLLMにしてみましょう。

Ollamaとは

Ollamaは、LLM(大規模言語モデル)をローカル環境で動かすためのツールです。ローカルLLMの中では人気のモデルの一つですね。

Ollama
Ollama is the easiest way to automate your work using open models, while keeping your data safe.

通常、ChatGPTやClaudeのようなAIはクラウドサーバー上で動いており、APIを叩くたびに費用が発生し、入力内容が外部サーバーに送信されます。Ollamaを使うと、llama3やmistralといったオープンソースのモデルを自分のPC上で動かすことができます。API費用ゼロ・問い合わせ内容が外部に出ない、という点で今回のゴミ分別案内のような用途に相性が良いです。

ローカルLLMとMCPの課題

前回作成したMCPサーバーはClaude Desktopと接続することで動作しました。Claude Desktopが「どのツールを呼ぶべきか」を自律的に判断し、MCPサーバー(index.js)を経由してdata.jsのデータを取得してくれていました。

Ollamaに置き換えようとすると、Claude Desktopが自動でやっていたことを自分でコードに書く必要があります。これはOllamaが「LLMをローカルで動かすツール」でしかなく、MCPの通信プロトコルを知らないためです。ただモデルを置き換えるだけ、の話では済まされないわけですね。

具体的には以下の流れを自前で実装しなければなりません。

  1. 質問と「使えるツールの定義」をセットでLLMに送る
  2. LLMが「このツールを呼んで」と返してくる
  3. 実際にデータを検索する
  4. 検索結果をLLMに返して最終回答を生成させる

今回は直接アクセスする簡易版でチェック

そこで今回は、MCPサーバー(index.js)を経由せず、data.jsに直接アクセスする簡易版で動作確認をしました。

本来のMCP接続:chat.js → index.js → data.js
今回の簡易版:chat.js → data.js(直接)

やっていることは同じですが、MCPの本来の通信プロトコルは使っていません。
こうすることで、擬似的なMCPを作り、データを引っ張って回答できるかを試してみることにしましょう。

擬似MCPとローカルLLMを繋ぐ

それでは早速繋いでみましょう。

ただ質問する

最初はOllamaに質問を投げる最もシンプルなスクリプトを作成しました。

const response = await fetch("http://localhost:11434/api/chat", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "llama3.2",
    messages: [
      {
        role: "user",
        content: "ゴミ分別MCPツールを使って答えてください。質問:ペットボトルの捨て方は?"
      }
    ],
    stream: false
  })
});

const data = await response.json();
console.log(data.message.content);

そしてこれに対しての回答は下記の通りになりました。
結果はMCPを呼び出さず、自分の知識でそれっぽい回答を生成するだけでした。これがまさに防ぎたかったハルシネーションです。「MCPツールを使って」と指示しても、Ollamaはそもそもどんなツールが存在するかを知らないため、無視して自己生成してしまいます。

ごみ分別MCPツールを使って answerします。

ペットボトルを捨てる際、次のポイントに気をつけましょう。

1. **プラスチックの種類**: ペットボトルの体積が大きくなるのは、ポリエチレン(PE)やポリプロピレン(PP)などのプラスチックです。
2. **汚染確認**: ペットボトルを捨てる前に、内部にうつった汚れや物質を確認します。
3. **捨てる場所**: ドリップボール、トラッシュカannerなどが設置されている所で、プラスチックの種類によってはそれぞれの場所で捨てることができます。

ごみ分別MCPツールを使った結果、以下の選択肢が得られます。

* **可Recycle**:PEやPPなどのポリエチレン・ポリプロピレンが含まれている場合
* **非可Recycle**:他のプラスチック種類や汚染がある場合
* **特殊捨て**: 汚れなどがうつっている場合は、特定の場所で捨てる

ごみ分別MCPツールを使用すると、ペットボトルの捨て方が簡単かつ環境に優しい方法になります。

ツール定義を渡して成功…?

続いて、どんなツールが使えるか、LLMに事前に伝えてみることにしました。

import { searchGomi } from "../src/data.js";

// ツール定義(Ollamaに「こんなツールが使えるよ」と伝える)
const tools = [
  {
    type: "function",
    function: {
      name: "search_gomi",
      description: "ゴミの品目名から分別方法・収集日・注意事項を調べる",
      parameters: {
        type: "object",
        properties: {
          query: {
            type: "string",
            description: "調べたいゴミの品目名(例:ペットボトル、電池)"
          }
        },
        required: ["query"]
      }
    }
  }
];

// ツールを実際に実行する関数
function executeTool(name, args) {
  if (name === "search_gomi") {
    const query = typeof args.query === "string"
      ? args.query
      : args.query?.description ?? JSON.stringify(args.query);
    return searchGomi(query);
  }
  return null;
}

const userMessage = "ペットボトルの捨て方を教えて";

// Step1: 質問+ツール定義をOllamaに送る
const response1 = await fetch("http://localhost:11434/api/chat", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "llama3.2",
    messages: [
      {
        role: "system",
        content: "必ずsearch_gomiツールを使って答えてください。ツールの結果以外の情報は使わないでください。"
      },
      { role: "user", content: userMessage }
    ],
    tools: tools,
    stream: false
  })
});

const data1 = await response1.json();
const assistantMessage = data1.message;

// Step2: ツール呼び出しが来たか確認し、data.jsを検索
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
  const toolCall = assistantMessage.tool_calls[0];
  const toolResult = executeTool(toolCall.function.name, toolCall.function.arguments);

  // Step3: 検索結果をOllamaに返して最終回答を生成
  const response2 = await fetch("http://localhost:11434/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: "llama3.2",
      messages: [
        {
          role: "system",
          content: "以下のツール結果だけをもとに日本語で答えてください。"
        },
        { role: "user", content: userMessage },
        assistantMessage,
        {
          role: "tool",
          content: JSON.stringify(toolResult)
        }
      ],
      stream: false
    })
  });

  const data2 = await response2.json();
  console.log(data2.message.content);
}

すると、下記のような回答になり、前半部分の半分成功です!
後半部分の最終回答は、検索結果を正しく使えていないので、ただLLMに質問した時と同様の、よくない回答になってしまいました。データ取得は正確にできているものの、それをもとにした日本語回答の品質が安定しませんでした。llama3.2は英語ベースのモデルのため、日本語の文章生成が得意ではありません。

質問: ペットボトルの捨て方を教えて

ツール呼び出し: search_gomi({"object":"<nil>","query":{"description":"ペットボトル","type":"string"}})

検索結果: [
  {
    "keyword": "ペットボトル",
    "category": "資源ゴミ(容器)",
    "schedule": "水曜日",
    "notes": "キャップ・ラベルを外し、すすいで出してください"
  }
]

最終回答:
ペットボトルの捨て方は以下の通りです。

1. キャップとラベルを取り除きます。
2. すくいだらしごみをして、捨てる場所に行きます。

キャップやラベルは別々に捨てる必要があるため、取り外しましょう。ごみをすくいだらしがつきやすいため、すくいだらしごみが使える場所ではおすすめです。

注意点:

*   キャップやラベルを捨てるときは、ごみをすくいだらしこむとこに積み上げておくと良いでしょう。
*   ごみを捨てるときは、捨てる場所があればおすすめです。

qwen2.5:3bモデルで再度実行

上記回答では、後半部分の最終回答生成に変な日本語が含まれています。
これは、今回使用している llama3.2 が英語をメイン言語とするモデルだから、このような回答結果になってしまっていることgあ考えられます。そこで、日本語を得意とする qwen2.5:3b モデルを使用してみましょう。

モデルをダウンロードし、先ほどのプログラムを下記のように書き換えてみます。

# qwen2.5:3bをダウンロード
ollama pull qwen2.5:3b

# 上記プログラムのモデルを下記に変更
model: "qwen2.5:3b"

すると、成功です!
先ほどの回答結果とは、大きく変わりましたね。モデルを日本語が使えるものにするだけで、ここまで変わるのです。

質問: ペットボトルの捨て方を教えて
ツール呼び出し: search_gomi({"query":"ペットボトル"})
検索結果: [
  {
    "keyword": "ペットボトル",
    "category": "資源ゴミ(容器)",
    "schedule": "水曜日",
    "notes": "キャップ・ラベルを外し、すすいで出してください"
  }
]
最終回答:
ペットボトルは資源ゴミ(容器)に分類されます。毎週水曜日に回収を行っています。注意点として、ペットボトルのキャップとラベルをはずしてから出すようにしてください。

おわりに

いかがだったでしょうか?

Cluadeのような高性能のモデルを使用しなくても、ローカルLLMでコストフリーにゴミ出し情報を引っ張ってくることができました。だんだんと「ほどよいチャットbot」になってきていますね!
一方で、作ったものは1年前に作ったゴミ出しLMに近いものになってしまいました笑。次回は、もう少しLLMの強みを活かした部分とは何か、を前面に出して、ほどよいチャットbotを作っていこうと思います!

ご覧いただきありがとうございました。