15
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Open WebUIでPython開発してLLMとの対話でやれることを増やすぞ!

Last updated at Posted at 2025-12-05

この記事はNTTドコモソリューションズ AdventCalendar 2025 6日目の記事です。

はじめに

NTTドコモソリューションズのあおちです。
普段はEX(従業員体験)を向上させるための生成AIソリューションの検討を実施しています。
生成AI周りの話はすごいスピードで進化しているのでついていくのが大変ですが、お客様の業務を改善できるよう技術検証を実施しています。

業務上ではクラウド上にあるLLMをよく使いますが、今回はローカルLLMの話をします。
私の担当ではOpen WebUIを利用してローカルLLMを触れる環境を構築しています。
Open WebUIにはたくさん便利な機能があり、その中にPythonで開発できる機能があります。
今回はPythonで試しにいくつか開発した結果をまとめたいと思います。

記載内容は2025年12月現在の話になりますのでご了承ください。

Open WebUIで開発してみよう!

Open WebUIはローカルで利用できるチャットアプリです。
このチャット機能をより便利に使うためにいくつか開発できるものがあります。
開発言語はPythonです。

開発できるものとしてToolとFunctionの2種類あり、Functionは3カテゴリに分かれています。
処理は自由に書くことができますが、最低限必要なクラスやメソッドがあるので従う必要があります。

  • Tool

    • 実装したものをLLMが利用する
  • Function

    • Filter
      • 入力/出力のテキストを修正できる
    • Action
      • ボタンを押すと定義した処理を実施する
    • Pipe
      • ワークフローを定義できる
      • Open WebUI上で作成したモデルを利用できる

この4つをお試しするために軽く開発してみました。
Difyなどのノーコードツールで簡単に開発はできるかもしれませんが、せっかくOpen WebUIを準備してもらったのだからどんなことができるか理解したい!と思っての開発になります。

開発したものを実際に利用する

開発したものはOpen WebUI上でモデルを作成することで利用することができます。
モデルを作成するには管理者権限ユーザまたは管理者から権限を与えられたユーザになる必要があります。
左メニューからワークスペースを選択することでモデル一覧が表示されますので、そこで新しいモデルを作成します。

モデル作成画面.png
出典:Tim J. Baek, Open WebUI モデル作成画面(社内ローカル環境/スクリーンショット取得日 2025年12月1日)

開発したものはモデル作成画面で選択すると有効となります。

開発したもの 選択方法
Tool 「ツール」項目で使いたいTool名を選択する
Filter 「フィルター」項目で使いたいFilter名を選択する
Action 「アクション」項目で使いたいAction名を選択する
Pipe 「ベースモデル」から使いたいPipe名を選択する

開発する画面を確認する

Open WebUI上にエディタが用意されています。
VSCodeレベルのものではないですが、シンタックスハイライトやすでに定義されている変数名の補完はやってくれるのでメモ帳よりは便利です。
サンプルコードが入った状態でエディタが開かれるで、少し気が楽になります。

Tool作成画面.png

Toolを使ってLLMができることを増やす

ToolはLLMが利用できるツールになります。動きとしてはFunction Callingに近いです。
Toolは管理者権限または管理者から権限を付与されたユーザで開発できます。
画面上では左のサイドメニューにある ワークスペース > ツール から作成できます。

Tool画面.png

LLMに四則演算をさせる

今回は足し算のプロンプトが送信されたら足し算ツールを使う、引き算がきたら引き算ツールを使う…という動きをLLMにさせようと思い開発しました。
コードは以下の通りです。
(長いので足し算だけ。引き算/掛け算/割り算は折りたたみの中にあります)

from pydantic import (
    Field,
    StrictInt,
)
from typing import List

"""
title: Caluculation
description: 足し算のみの計算ツール、引き算のみの計算ツール、掛け算のみの計算ツール、割り算のみの計算ツールの4つがあります。足し算して引き算するなどは一度に実行はできません。
"""


class Tools:
    def __init__(self):
        pass

    def add(
        self,
        num_list: List[StrictInt] = Field(
            default=[],
            description="ユーザーが指定した数値、またはナレッジベースから検索した結果にある数値が格納されたリスト(e.g. [1,2,3])",
        ),
    ) -> str:
        """
        複数の数値を足し算するツールです。
        足し算のみ実行できます。
        ユーザが足し算をしたいという依頼をしたとき、足し算の計算式を伝えられた時はこのツールを利用してください。
        パラメータ:
          -  num_list: 整数のみを含むリスト(例:[80, 90, 70])。日本語・英語などの文字列は含めないでください。

        注意点:
          - このツールでは、ユーザーが指定した数値、またはナレッジベースから検索した結果にある数値を使ってください。
          - 指定がない場合、適当な数値を割り当てないでください。
        """
        result = ""

        try:
            calc_list = num_list

            # 足し算
            calc_result = sum(calc_list)
            result = str(calc_result)

        except Exception:
            # 何かのエラー時
            result = "足し算ツールを使うことはできませんでした"

        return result
コード全文はこちら
from pydantic import (
    Field,
    StrictInt,
)
from typing import List
import ast

"""
title: Caluculation
description: 足し算のみの計算ツール、引き算のみの計算ツール、掛け算のみの計算ツール、割り算のみの計算ツールの4つがあります。足し算して引き算するなどは一度に実行はできません。
"""


class Tools:
    def __init__(self):
        pass

    def add(
        self,
        num_list: List[StrictInt] = Field(
            default=[],
            description="ユーザーが指定した数値、またはナレッジベースから検索した結果にある数値が格納されたリスト(e.g. [1,2,3])",
        ),
    ) -> str:
        """
        複数の数値を足し算するツールです。
        足し算のみ実行できます。
        ユーザが足し算をしたいという依頼をしたとき、足し算の計算式を伝えられた時はこのツールを利用してください。
        パラメータ:
          -  num_list: 整数のみを含むリスト(例:[80, 90, 70])。日本語・英語などの文字列は含めないでください。

        注意点:
          - このツールでは、ユーザーが指定した数値、またはナレッジベースから検索した結果にある数値を使ってください。
          - 指定がない場合、適当な数値を割り当てないでください。
        """
        result = ""

        try:
            calc_list = num_list

            # 足し算
            calc_result = sum(calc_list)
            result = str(calc_result)

        except Exception:
            # 何かのエラー時
            result = "足し算ツールを使うことはできませんでした"

        return result

    def sub(
        self,
        num_list: List[StrictInt] = Field(
            default=[],
            description="ユーザーが指定した数値、またはナレッジベースから検索した結果にある数値が格納されたリスト(e.g. [1,2,3])",
        ),
    ) -> str:
        """
        複数の数値を引き算するツールです。
        引き算のみ実行できます。
        ユーザが引き算をしたいという依頼をしたとき、引き算の計算式を伝えられた時はこのツールを利用してください。
        パラメータ:
          -  num_list: 整数のみを含むリスト(例:[80, 90, 70])。日本語・英語などの文字列は含めないでください。

        注意点:
          - このツールでは、ユーザーが指定した数値、またはナレッジベースから検索した結果にある数値を使ってください。
          - 指定がない場合、適当な数値を割り当てないでください。
        """
        result = ""

        try:
            calc_list = num_list

            # 引き算
            calc_result = calc_list[0] - calc_list[1]
            i = 2
            for i in range(2, len(calc_list), 1):
                calc_result = calc_result - calc_list[i]

            result = str(calc_result)

        except IndexError:
            # 数字が一つしかない場合
            result = "数字は2つ以上入力してください"

        except Exception:
            # 何かのエラー時
            result = "引き算ツールを使うことはできませんでした"

        return result

    def multi(
        self,
        num_list: List[StrictInt] = Field(
            default=[],
            description="ユーザーが指定した数値、またはナレッジベースから検索した結果にある数値が格納されたリスト(e.g. [1,2,3])",
        ),
    ) -> str:
        """
        複数の数値を掛け算するツールです。
        掛け算のみ実行できます。
        ユーザが掛け算をしたいという依頼をしたとき、掛け算の計算式を伝えられた時はこのツールを利用してください。
        パラメータ:
          -  num_list: 整数のみを含むリスト(例:[80, 90, 70])。日本語・英語などの文字列は含めないでください。

        注意点:
          - このツールでは、ユーザーが指定した数値、またはナレッジベースから検索した結果にある数値を使ってください。
          - 指定がない場合、適当な数値を割り当てないでください。
        """

        result = ""

        try:
            calc_list = num_list

            # 掛け算
            calc_result = calc_list[0] * calc_list[1]
            i = 2
            for i in range(2, len(calc_list), 1):
                calc_result = result * calc_list[i]

            result = str(calc_result)

        except IndexError:
            # 数字が一つしかない場合
            result = "数字は2つ以上入力してください"

        except Exception:
            # 何かのエラー時
            result = "掛け算ツールを使うことはできませんでした"

        return result

    def div(
        self,
        num_list: List[StrictInt] = Field(
            default=[],
            description="ユーザーが指定した数値、またはナレッジベースから検索した結果にある数値が格納されたリスト(e.g. [1,2,3])",
        ),
    ) -> str:
        """
        複数の数値を割り算するツールです。
        割り算のみ実行できます。
        ユーザが割り算をしたいという依頼をしたとき、割り算の計算式を伝えられた時はこのツールを利用してください。
        パラメータ:
          -  num_list: 整数のみを含むリスト(例:[80, 90, 70])。日本語・英語などの文字列は含めないでください。

        注意点:
          - このツールでは、ユーザーが指定した数値、またはナレッジベースから検索した結果にある数値を使ってください。
          - 指定がない場合、適当な数値を割り当てないでください。
        """
        result = ""
        try:
            calc_list = num_list

            # 割り算
            calc_result = calc_list[0] / calc_list[1]
            i = 2
            for i in range(2, len(calc_list), 1):
                calc_result = calc_result / calc_list[i]

            result = str(calc_result)

        except IndexError:
            # 数字が一つしかない場合
            result = "数字は2つ以上入力してください"

        except ZeroDivisionError:
            # ゼロ除算
            result = "0で除算しないでください"

        except Exception:
            # ゼロ除算以外のエラー
            result = "割り算ツールを使うことはできませんでした"

        return result

ツールではToolsクラスの中に任意のメソッドを作成します。
メソッドにはdocstringでこのメソッドの用途や引数の説明を書きます。
こうすることで、このメソッドが何者なのかを示すことができます。
引数の説明も書いてあげることで適切な引数を設定してくれます。型ヒントも活用しましょう!

四則演算ツールの動作確認

Tool結果.png

きちんとツールを使って計算してくれていることがわかります。
ツールの実行結果は以下の通りです。
足し算を先に実施しその結果を引き算に使っていますし、リストで引数を渡して計算していることがわかります。

add.png sub.png

利用モデルはgpt-oss-safeguard:20bです。
gpt-oss:20bだとたまに引数を生成してもらえなかったので、強引にシステムプロンプトで引数の作り方を書きました。
(本当はdocstring側を調整すべきだと思います)
おためしなんで許してください!

今回は四則演算でしたが、いろんなものを書けちゃうのでやりたい放題!ワクワクしますね!

Functionを使って機能拡張する

FunctionはOpen WebUIで使える機能を拡張するものです。
Filter、Action、Pipeの3つがあり、それぞれできることが異なります。
Functionを開発するには管理者権限を持っている必要があり、管理者パネルからFunctionを選択することで開発画面へいくことができます。

Function管理画面.png

Filterを使ってメッセージを変更する

FilterはLLMに送る前のメッセージを変更する、または生成された回答を変更することができます。
本当は生成された回答をストリーム出力する直前に変更する機能もあるのですが、そちらは今回試していません。

LLMに送る前のメッセージを変更してしりとりをする

LLMへ送る前に実行してくれるため、システムプロンプトを追加したり、ユーザプロンプトを書き換えるということができます。
今回はしりとりを例にしてこの機能をおためしします。
LLMは何も指示を与えなくてもしりとりのルールは理解していそうなんですが、そうではありません。
下記の画像では「ごりら」のあとに「らいおん」を提示しておりルールを無視しています。

しりとりチャット(失敗).png

なので、LLMとしりとりできるようにしていきます。
今回作成したFilterはこちらです。

"""
title: しりとり
version: 0.1
"""

from pydantic import BaseModel, Field
from typing import Optional


class Filter:
    def inlet(self, body: dict, __user__: Optional[dict] = None) -> dict:
        context_message = {
            "role": "system",
            "content": "あなたはユーザとしりとりをします。しりとりは相手が言った単語の最後の文字から始まる単語を答えるゲームです。最後に''がつく言葉は絶対に使わないでください。",
        }
        body.setdefault("messages", []).insert(0, context_message)

        last_message = body["messages"][-1]["content"]
        body["messages"][-1][
            "content"
        ] = f"'{last_message[-1]}'から始まる単語を日本語で回答してください"

        return body

FilterはFilterクラスにを作成する必要があります。
また、LLMに送る前のメッセージを変更するためにはinletメソッド内に処理を書く必要があります。

今回はシステムプロンプトを付け足し、そこでしりとりのルールを教えています。
またしりとりというのは最後の文字から始まる単語でなければいけないので、それを考えてもらうユーザプロンプトに上書きします。
つまり、「りんご」というプロンプトが来たら、「'ご'から始まる単語を日本語で回答してください」というプロンプトにしれっと上書きして送信しています。

しりとり動作確認

先ほどまでルールを無視して「次は'ん'で始まる言葉ですよ!」と言ってきていましたが…

しりとりチャット(成功).png

しりとりできるようになりました!!
ベースモデルはgpt-oss:20bです。
こうしてユーザに知られることなくこっそり変更できるので便利な機能ですね!

生成された回答を変更して弊社の社名変更をお知らせする

LLMに生成された回答を変更できるということで改ざんみたいなことができてしまいます。
実際は改ざんとかではなく(笑)、軽微な変更をするのに使うみたいです。
今回は、弊社 NTTドコモソリューションズ の知名度向上を考えて、旧社名である NTTコムウェア について質問してきた場合に「社名変わったから覚えてね!」と最後に教えてあげることにしました。

"""
title: NTT Comware Filter

"""

from pydantic import BaseModel, Field
from typing import Optional


class Filter:

    def __init__(self):
        pass

    def outlet(self, body: dict) -> dict:

        # コムウェアの表記ゆれリスト
        comware_list = [
            "NTTコムウェア",
            "コムウェア",
            "エヌ・ティ・ティ・コムウェア",
            "エヌ・ティ・ティ・コムウェア株式会社",
            "CW",
            "NTT Comware",
        ]

        matched = False
        matche_keywords = []

        # リスト内のキーワードがLLMの解答に含まれているかどうかcheck
        for message in reversed(body["messages"]):
            if message["role"] == "assistant":

                matche_keywords = [
                    keyword for keyword in comware_list if keyword in message["content"]
                ]

            if matche_keywords:
                matched = True

            break

        # 引っ掛かったら正しい社名を教えるために、一番最後のメッセージにテキストを追加する
        if matched:
            for message in reversed(body["messages"]):
                if message["role"] == "assistant":
                    message[
                        "content"
                    ] += f"\nちなみに、{matche_keywords[0]} は 2025年7月1日にNTTドコモソリューションズという社名に変更されました。覚えてくださいね!"
                break

        return body

出力を変更したい場合はoutlet関数を利用します。
弊社の旧社名はいろんな略称があるので思いつく限りの略称のリストを作成し、それらが出力に含まれているかを確認しています。
もし引っかかったら最後に社名が変更したことを教えてあげる一文を追加します。

新社名を教えてくれるか確認

実際に旧社名関連の質問をしてみます。

NTTコムウェア社名変更.png

回答の内容はモデルのシステムプロンプトに https://www.nttcom.co.jp/corporate/outline/ に記載されている事業内容を仕込んだ結果です。

ちゃんと最後に新社名を教えてくれました!
ベースモデルはgpt-oss-20bです。
これでみなさんもう社名変更したこと覚えてくれますね!

ActionでLLMの回答を英語に翻訳する

ActionはLLMから回答を生成されたあと、表示されるボタンを押すことで実装した処理を実行できる用になる機能です。
ワークスペースでモデルを作成するときにアクションを選択していると、✨️マークのボタンが追加されます。
ボタンを押したら処理が実行されるので、必要なときに実行できるのが利点です。

アクション前.png

今回はボタンを押したらLLMが生成した回答を英語に翻訳するアクションボタンを実装してみました。

"""
title: Translate
description: LLMの解答を翻訳します
"""

from pydantic import BaseModel
from typing import Optional, Any, Dict, List
import re
import html
from open_webui.models.users import Users
from open_webui.utils.chat import generate_chat_completion


class Action:
    def __init__(self):
        pass

    async def action(
        self,
        body: dict,
        __user__=None,
        __event_emitter__=None,
        __event_call__=None,
        __request__=None,
    ) -> Optional[dict]:

        latest_llm_message = html.unescape(body["messages"][-1]["content"])

        # LLMの回答からhtmlタグや思考の文章を削除
        latest_llm_message = re.sub(
            r"<details\b[^>]*>.*?</details>",
            "",
            latest_llm_message,
            flags=re.DOTALL | re.IGNORECASE,
        )

        latest_llm_message = re.sub(
            r"</?summary[^>]*>", "", latest_llm_message, flags=re.IGNORECASE
        )

        # 翻訳してくれるモデルとユーザプロンプトを設定
        translate_body = {
            "model": "gpt-oss:20b",
            "stream": False,
            "messages": [
                {
                    "role": "user",
                    "content": f"次のテキストを英語に翻訳してください。 {latest_llm_message}",
                },
            ],
        }
        try:
            # ユーザの情報取得
            user = Users.get_user_by_id(__user__["id"])

            # 翻訳する
            res = await generate_chat_completion(__request__, translate_body, user)
            translate_text = res["choices"][0]["message"]["content"]

            # UIに反映する
            await __event_emitter__(
                {
                    "type": "message",
                    "data": {"content": f"\n\n{translate_text}"},
                }
            )
        except Exception as e:
            error_message = f"\n\n内部エラーが発生しました: {str(e)}"
            if __event_emitter__:
                await __event_emitter__(
                    {"type": "message", "data": {"content": error_message}}
                )
            return {"error": str(e)}

ActionはActionクラスを定義し、その中にactionメソッドを作成する必要があります。
actionメソッドの中にアクションボタンを押したときの処理を書いていきます。
今回はLLMの回答をもう一度gpt-oss:20bに送るための処理を記載しています。
また、イベント機能を活用することでUIに反映させることができます。__event_emitter__を利用してUI側にメッセージを反映させます。
UIに反映させるためにはtypemessageにする必要があります。

英語に翻訳してくれるか確認

こうして実装したアクションボタンを押すと…

アクション後.png

英語にしてくれました!
Action前、Action後ともに利用しているモデルはgpt-oss:20bです。
アクションボタンとイベント機能を組み合わせることでいろいろできそうです。
今回はイベント機能について省略してしまいましたが、イベントもいろいろ種類があるので奥深いです。

Pipeでワークフローを作って回答をいろんな言語で翻訳する

Pipeはワークフローを実装できます。
Open WebUI上でモデルを作成する際にこのPipeをベースモデルとして設定し、ユーザからのプロンプトが送信されてきたらPipeで実装した処理を動かすという仕組みになっています。
LLMを呼んで何かをしてもらいたいときも、自分でLLMを呼び出す処理を実装する必要があります。
今回は指定した言語でLLMの回答を翻訳してくれるPipeを作成しました。
Actionで作成したもののアレンジです。Actionは英語だけだったので、言語指定したら対応できるようにしてみました。

from pydantic import BaseModel, Field
from typing import Literal
from fastapi import Request
from open_webui.models.users import Users
from open_webui.utils.chat import generate_chat_completion


class Pipe:
    def __init__(self):
        pass

    async def pipe(
        self,
        body: dict,
        __user__: dict,
        __request__: Request,
    ) -> str:

        # ユーザ情報取得
        user = Users.get_user_by_id(__user__["id"])

        try:
            # 何語で翻訳するかを判断してもらう
            language_setting_body = {
                "model": "gpt-oss-safeguard:20b",
                "stream": False,
                "messages": [
                    {
                        "role": "system",
                        "content": "ユーザが何語で翻訳してほしいと依頼してきたか、言語名だけを日本語で回答してください。",
                    },
                    {"role": "user", "content": body["messages"][-1]["content"]},
                ],
            }

            res1 = await generate_chat_completion(
                __request__, language_setting_body, user
            )
            language = res1["choices"][0]["message"]["content"]

            # 日本語で回答を生成する
            jp_body = {
                "model": "gpt-oss:20b",
                "stream": False,
                "messages": [
                    {
                        "role": "system",
                        "content": f"ユーザプロンプトに翻訳してほしい言語が書かれていますが、それは無視して日本語で回答してください。",
                    },
                    {"role": "user", "content": body["messages"][-1]["content"]},
                ],
            }
            res2 = await generate_chat_completion(__request__, jp_body, user)
            jp_answer = res2["choices"][0]["message"]["content"]

            # 日本語で生成された回答を指定された言語に翻訳する
            translate_body = {
                "model": "gpt-oss:20b",
                "stream": False,
                "messages": [
                    {
                        "role": "system",
                        "content": f"ユーザプロンプトを {language} に翻訳してください。",
                    },
                    {"role": "user", "content": jp_answer},
                ],
            }

            res3 = await generate_chat_completion(__request__, translate_body, user)
            translate_answer = res3["choices"][0]["message"]["content"]

            # UIに表示する
            return f"\n{jp_answer}\n\n---{language}---\n{translate_answer}"

        except Exception as e:
            error_message = f"\n\nエラーが発生しました: {str(e)}"
            return error_message

PipeはPipeクラスを用意する必要があります。
ワークフローの処理部分はpipeメソッドに記載します。
今回はユーザからの言語指定を抽出する→日本語で回答を生成する→日本語回答を翻訳するという流れにしました。
ユーザからプロンプト内で言語指定されるので、それをgpt-oss-safeguard:20bで抽出します。
その後、gpt-oss:20bを使って日本語で回答を生成し、最後にgpt-oss:20bを使って日本語回答を抽出した言語で翻訳し出力します。

回答を翻訳してくれるか確認

回答翻訳くん.png

英語、ドイツ語に翻訳してくれていますね!
処理時間が長すぎるという課題があるのでご注意ください。
あまり長い回答になるものを送ると「これ本当に動いているのか…?」と心配になるレベルで待たされます。

Pipeはこのように最初から複数のモデルを利用する前提の処理を書くことができます。
得意分野のあるモデルを適切なタイミングで使うような処理を書きたいときはPipeを使っていきましょう!

最後に

Open WebUIはドキュメントが整備されていてサンプルコードも提示してくれていますが、やはり実際に自分で開発するのが動きを一番理解できるなぁと改めて感じました。
この機能でガリガリ開発するかはわかりませんが、Open WebUI側が用意してくれている機能を把握しないまま使っているのはもやもやしていたので個人的にすっきりしました。
私のこのお試し開発がこの記事を読んでくださった方のお役に立てればとても嬉しいです。
ここまでの長文を読んでいただきありがとうございました!

記載されている会社名、製品名、サービス名は、各社の商標または登録商標です。

参考

Open WebUIはドキュメントがまとまっています。
今回の開発はこちらのドキュメントを参考にしました。

15
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?