🤖

Ollamaを使ってLINEでLLMと会話する(履歴も参照)

2024/12/10に公開

はじめに

株式会社松尾研究所でインターンをしている塚田真輝です。本記事は、松尾研究所 Advent Calendar 2024の記事です。

この記事では、Ollamaを使ってLLMとLINEを使って会話できるような開発をしたので記事にしてみました。
ローカルサーバーで運用しているので利用料金を気にせずに使える点が気に入ってます。

簡単なシステム構成と使用技術

アーキテクチャ図

システムスペックと使用技術

ローカルサーバー構成

今回は、かなりハイスペックなPCを使っていますが、軽量なLLMなら家庭用PCでも十分に動くと思います

OS Windows11
mem 128GB
CPU Intel(R) Xeon(R) w9-3475X
GPU NVIDIA RTX A6000 x2

使用技術

* Docker
* Ollama ローカルでllm(gemma-2-2b-jpn-itを今回は利用)とやりとりするAPIサーバー作成する
* Python(3.10) 以下のライブラリを使用
 * Flask LINE Webhookを利用するローカルAPIを作成
 * Langchain Ollamaで作成したAPIとのやりとりに使用
 * line-bot-sdk
* ngork Flaskで作成したAPIを外部ネットワークに公開
* Messaging API インターネットを通じてLINEとやりとり

実装

Dockerが使用できる状態を前提としてすすめます。ファイル構成は以下のようにします。

line-bot-with-ollama
└── app.py
└── Dockerfile
└── docker-compose.yml
└── requirements.txt
├── logs/
│   └── log.txt

Ollama serverの作成と起動

以前、からあげさんが作成された記事Tanuki-8BとOllamaとDifyを使って日本語ローカルRAG構築のOllamaとTanuki-8Bのセットアップを参考にします。

Ollamaを起動します。GPU無し(CPU)で起動する場合は以下コマンドを実行してください。

$ docker run -d -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama

GPU有りの場合は、以下のように--gpus=allオプションをつけて実行します。

$ docker run -d --gpus=all -v ollama:/root/.ollama -p 11434:11434 --name ollama ollama/ollama

続いて、gemma-2-2b-jpn-itをダウンロードしてからOllamaで動かします。

$ docker exec ollama ollama pull schroneko/gemma-2-2b-jpn-it
$ docker exec -it ollama ollama run schroneko/gemma-2-2b-jpn-it

lineとやりとりをするAPIの作成

ngorkのアカウント作成

  1. ngrok公式サイトでユーザー登録する
  2. Your Authtokenをクリック
  3. Authtokenをコピーしてメモしておく

lineのチャンネルを作成

Messaging API公式サイトの「1. LINE Developersコンソールでチャネルを作成する」の手順でチャネルを作成する

チャネル作成後、チャネル基本設定に記載されているチャネルシークレットと作成したチャネルアクセストークンをメモしておく

IPアドレスの取得

  1. コマンドプロンプトを開く
  2. ipconfigと入力
  3. IPv4 アドレスという欄の右側の値をメモ

ローカル側の実装

以下のファイルを作成します。

requirements.txt

aenum==3.1.15
aiohappyeyeballs==2.4.3
aiohttp==3.11.8
aiosignal==1.3.1
annotated-types==0.7.0
anyio==4.6.2.post1
async-timeout==4.0.3
attrs==24.2.0
blinker==1.9.0
certifi==2024.8.30
charset-normalizer==3.4.0
click==8.1.7
dataclasses-json==0.6.7
Deprecated==1.2.15
exceptiongroup==1.2.2
Flask==3.1.0
frozenlist==1.5.0
future==1.0.0
greenlet==3.1.1
h11==0.14.0
httpcore==1.0.7
httpx==0.27.2
httpx-sse==0.4.0
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.4
jsonpatch==1.33
jsonpointer==3.0.0
langchain==0.3.9
langchain-community==0.3.8
langchain-core==0.3.21
langchain-ollama==0.2.0
langchain-text-splitters==0.3.2
langsmith==0.1.147
line-bot-sdk==3.14.2
MarkupSafe==3.0.2
marshmallow==3.23.1
multidict==6.1.0
mypy-extensions==1.0.0
numpy==1.26.4
ollama==0.4.2
orjson==3.10.12
packaging==24.2
propcache==0.2.0
pydantic==2.10.2
pydantic-settings==2.6.1
pydantic_core==2.27.1
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
PyYAML==6.0.2
requests==2.32.3
requests-toolbelt==1.0.0
six==1.16.0
sniffio==1.3.1
SQLAlchemy==2.0.35
tenacity==9.0.0
typing-inspect==0.9.0
typing_extensions==4.12.2
urllib3==2.2.3
Werkzeug==3.1.3
wrapt==1.17.0
yarl==1.18.0

Dockerfile

# Use an official Python image
FROM python:3.10-slim

# Set the working directory in the container
WORKDIR /app

# Install procps (for use 「ps」&「top」 command)
RUN apt update && apt install -y procps

# Copy the requirements file into the container and install dependencies
RUN pip install --upgrade pip
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt

# Copy the Python scripts into the container
COPY app.py .

CMD ["tail", "-f", "/dev/null"]

docker-compose.yml
コメントがあるところに、IPアドレスやlineのチャンネルトークン、シークレット、ngorkのアクセストークンを入力

services:
  app:
    build: .
    volumes:
      - ./:/app
    environment:
      - OLLAMA_HOST= # ローカルサーバーのIPv4 アドレスを入力
      - OLLAMA_PORT=11434
      - TIMEZONE=Asia/Tokyo
      - LOG_DIR=/app/logs
      - LINE_CHANNEL_ACCESS_TOKEN= # アクセストークンを入力
      - LINE_CHANNEL_SECRET= # シークレットを入力
    ports:
      - "8000:8000"
    restart: always

  ngrok:
    image: ngrok/ngrok:latest
    environment:
      NGROK_AUTHTOKEN= # ngorkのアクセストークンを入力
    command:
      ["http","host.docker.internal:8000"]
    depends_on:
      - app
    ports:
      - 4040:4040

ローカルAPIを作成するPythonコード

import os
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_community.chat_message_histories import ChatMessageHistory
from langchain_community.vectorstores import Chroma
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_ollama.llms import OllamaLLM

from flask import Flask, request, abort
from linebot.v3 import WebhookHandler
from linebot.v3.exceptions import InvalidSignatureError
from linebot.v3.messaging import (
	ApiClient, Configuration, MessagingApi,
	ReplyMessageRequest, PushMessageRequest,
	TextMessage, PostbackAction
)
from linebot.v3.webhooks import (
	FollowEvent, MessageEvent, PostbackEvent, TextMessageContent
)
from flask import Flask, request, abort
from linebot.v3 import WebhookHandler
from linebot.v3.exceptions import InvalidSignatureError
from linebot.v3.messaging import (
	ApiClient, Configuration, MessagingApi,
	ReplyMessageRequest, PushMessageRequest,
	TextMessage, PostbackAction
)
from linebot.v3.webhooks import (
	FollowEvent, MessageEvent, PostbackEvent, TextMessageContent
)

DEFAULT_MAX_MESSAGES = 30

# 会話履歴数をmax_lengthに制限するLimitedChatMessageHistoryクラス
class LimitedChatMessageHistory(ChatMessageHistory):

    # 会話履歴の保持数
    max_messages: int = DEFAULT_MAX_MESSAGES

    def __init__(self, max_messages=DEFAULT_MAX_MESSAGES):
        super().__init__()
        self.max_messages = max_messages

    def add_message(self, message):
        super().add_message(message)
        # 会話履歴数を制限
        if len(self.messages) > self.max_messages:
            self.messages = self.messages[-self.max_messages:]

    def get_messages(self):
        return self.messages


# 会話履歴のストア
store = {}

# セッションIDごとの会話履歴の取得
def get_session_history(session_id: str) -> BaseChatMessageHistory:
    if session_id not in store:
        store[session_id] = LimitedChatMessageHistory()
    return store[session_id]

# プロンプトテンプレート
system_prompt = (
    "あなたは質問対応のアシスタントです"
    "質問に答えてください。答えがわからなければ、わからないと言ってください"
    "できるだけ詳細に答えてください"
    "\n\n"
)
prompt_template = ChatPromptTemplate.from_messages(
    [
        ("system", system_prompt),
        MessagesPlaceholder(variable_name="chat_history"),
        ("human", "{input}"),
    ]
)

# モデルの呼び出し
model = OllamaLLM(model="schroneko/gemma-2-2b-jpn-it")
chain = prompt_template | model

# Runnable chain を RunnableWithMessageHistory でラップ
runnable_with_history = RunnableWithMessageHistory(
    runnable=chain,
    get_session_history=get_session_history,
    input_messages_key="input",
    history_messages_key="chat_history"
)

# 応答の関数
def chat(input_message,session_id):
    response = runnable_with_history.invoke(
        {
            "input": input_message
        },
        config={"configurable": {"session_id": session_id}}
    )
    return response


app = Flask(__name__)

configuration = Configuration(access_token = os.getenv("LINE_CHANNEL_ACCESS_TOKEN"))
handler = WebhookHandler(os.getenv("LINE_CHANNEL_SECRET"))

## 起動確認用
@app.route('/')
def index():
    return 'Hello world'

## コールバックのおまじない
@app.route("/callback", methods=['POST'])
def callback():
	# get X-Line-Signature header value
	signature = request.headers['X-Line-Signature']

	# get request body as text
	body = request.get_data(as_text=True)
	app.logger.info("Request body: " + body)

	# handle webhook body
	try:
		handler.handle(body, signature)
	except InvalidSignatureError:
		app.logger.info("Invalid signature. Please check your channel access token/channel secret.")
		abort(400)

	return 'OK'

## 友達追加時のメッセージ送信
@handler.add(FollowEvent)
def handle_follow(event):
	## APIインスタンス化
	with ApiClient(configuration) as api_client:
		line_bot_api = MessagingApi(api_client)

	## 返信
	line_bot_api.reply_message(ReplyMessageRequest(
		replyToken=event.reply_token,
		messages=[TextMessage(text='初めまして!AIと会話するためには「start」と入力してください。終了するときは「end」と入力してください。')]
	))

# モード切り替え用のグローバル変数の宣言
flag = "None"

# クライアントからメッセージが送られてきたときに実行される処理
@handler.add(MessageEvent, message=TextMessageContent)
def handle_message(event):
    global flag
    with ApiClient(configuration) as api_client:
        line_bot_api = MessagingApi(api_client)

        ## 受信メッセージの中身を取得
        received_message = event.message.text

        ## APIを呼んで送信者のプロフィール取得
        profile = line_bot_api.get_profile(event.source.user_id)
        display_name = profile.display_name

        if received_message == "start":
            flag = "start"
        elif received_message == "end":
            prompt_summary = ChatPromptTemplate.from_messages([
                ("system", "これまでの会話を総括して評価してください"),
                MessagesPlaceholder(variable_name="chat_history"),
                ("human", "{input}"),
                ])
            chain = prompt_summary | model
            # Runnable chain を RunnableWithMessageHistory でラップ
            runnable_with_history = RunnableWithMessageHistory(
                                    runnable=chain,
                                    get_session_history=get_session_history,
                                    input_messages_key="input",
                                    history_messages_key="chat_history"
                                )
            response = runnable_with_history.invoke(
            {
                "input": "これまでの会話を総括して評価してください"
            },
            config={"configurable": {"session_id": event.source.user_id}}
        )

            line_bot_api.reply_message(ReplyMessageRequest(
                replyToken=event.reply_token,
                messages=[TextMessage(text=response)]
            ))
            flag = "None"



        if  flag == "None":
            ## 返信メッセージ編集
            reply = f'{display_name}さんのメッセージ\n初めまして!AIと会話するためには「start」と入力してください。終了するときは「end」と入力してください。'
            
            line_bot_api.reply_message(ReplyMessageRequest(
                replyToken=event.reply_token,
                messages=[TextMessage(text=reply)]
            ))
        elif flag == "start":
            response = chat(received_message,event.source.user_id)
            line_bot_api.reply_message(ReplyMessageRequest(
                replyToken=event.reply_token,
                messages=[TextMessage(text=response)]
            ))

## ボット起動コード
if __name__ == "__main__":
	## ローカルでテストする時のために、`debug=True` にしておく
	app.run(host="0.0.0.0", port=8000, debug=True)

APIサーバーを立ち上げる

ここまでできたら実行していきます。まずはdockerコンテナの作成をdocker-compose.ymlを元に行います。line-bot-with-ollamaディレクトリ直下で行います。

$ docker-compose up -d

作成できたらappコンテナ(Pythonの方)に入ります。

$ docker-compose exec app bash

コンテナ内で以下のコマンドを打ちます

root@1625659d3bf9:/app# nohup python ./app.py > ./logs/log.txt &

軽くコマンドの説明をします。nohupとコマンドの頭につけて、バックグランドで実行(最後に&をつける)するだけでプログラムを実行し続けることができます。インストールなく使えるので便利。

プロセスを強制停止したい場合は、ps auxやtopでPIDを探して kill [PID] 。

ngrokで公開したAPIをline webhookに登録

  1. http://localhost:4040/statusにアクセスしてcommand_lineの欄のURLコピーする(https://XXXXXXXX.ngrok-free.appって感じのやつ)
  2. LINE Developersコンソールの Messaging API設定 > Webhook設定 にあるWebhook URLにngrokのURL https://XXXXXXXX.ngrok-free.app/callback を入力
  3. 「検証」をクリックして、「成功」と表示されればOK

実行

アカウントの追加

自分のLINEでLINE Developersコンソールの Messaging API設定 > ボット情報 にあるQRコードから作成したLINE botを友達追加する
LINE botにメッセージを送信し、以下のようになれば一旦OKです。

gemma-2-2b-jpn-itと会話

startとメッセージを送ると会話が開始します。endと送信すると今までの会話を振り返ってくれます。

Demo

  • 「start」と送信

  • 会話をしてみる

  • endと送信していままでの会話を振り返ってもらう

どうやら履歴を参照はできてそうですが、プロンプトを見直す必要がありそうです。

最後に

今回は、Ollamaを使ってローカルPCでLLMを動かしてlineを通じて会話をする実装をしました。
しかしもともと、イタリア語を習っている母のためにLLMとイタリア語で会話して内容のフィードバックをもらえるbotが作りたいと思っていたので、機能が実装できたらまた記事を書きたいです!ここまで読んでくださりありがとうございます!

GitHubで編集を提案
松尾研究所テックブログ

Discussion