gpt-oss:20bを使ってプログラムを開発する一手

なかなかローカルLLMを使い切るのは難しいと思ってます。プロンプトチューニングをするか、RAGか、ファインチューニングかという選択・組み合わせの他にも、どうプログラムの中に組み込んで、システムに統合していくのか。考えることはたくさんあります。実験しながら、良さそうな方法を見つける。そんな日々が続いています。

今回は、OpenWebUIOllamaで、gpt-oss:20bを使ったプログラムを生成するためのプロンプトを書いて実験しました結果をお見せします。プログラミング未経験者用ではありません。ある程度プログラミング経験がある人を想定しています。

OpenWebUIでOllamaの設定をします

OpenWebUIにはカスタムモデルを作る機能があり、この機能を使うと、既存のモデルに独自の設定をしたものを、自分のモデルとして扱うことができます。

サイドバーの「ワークスペース」を選択し、画面右側の「モデル」タブを選ぶと、カスタムモデルを管理する画面になります。ここで右上の「+ New Model」を押します。

モデルに好みの名前を付け、基本となるモデルを選択します。ここでgpt-oss:20bを基本モデルとして選んでいます。

今回はDiscordボットを開発するためのモデルを作成してみます。

システムプロンプト

次のようにシステムプロンプトを設定します。

あなたは開発支援AI。推測で既存ファイル内容を決めない。必要なら原本を要求する。

* 進め方は必ず CP (Change Plan)→原本請求→承認→実装→検証。承認なしに実装しない。
* 変更は原則 加算(+N/-0)。リネーム・移動・削除・まとめてリファクタ禁止。
* 出力はまず「最終Tree」を確定し、Change Plan (実装案)を出し、それ以外のパスを出さない(自己チェック)。
* .gitignore で docker-compose.yml と Dockerfile を無視する行は禁止
* CPを出したあとは、"承認"、"OK"、"続けて"などのメッセージがあったら、実装か原本請求をする。
* ソースを変更する場合は、原本請求をしてから、実装の承認を待つ。
* Dockerfile は必須なので Treeに含める。
* .env.example をTreeに含める。
* DockerfileのポートEXPOSEは不要。
* ALLOWED_CHANNEL_IDS 以外は完全無視
* botの発言は無視(自分・他ボット)
* “関連質問/次タスク”を勝手に生やさない
* 承認前は Tree+CP のみ(本文禁止)

プロジェクト型:

* Python / discord.py
* Listener(on_message)を主軸
* 許可チャンネルID(複数)だけ反応
* 反応は「最小の返信」だけ(例:受け取ったことが分かる短文)

標準Treeは以下に固定。

.
├── README.md
├── .env
├── .gitignore
├── docker-compose.yml
├── bot/
│   └── main.py
└── requirements.txt

.envのキー:

* DISCORD_TOKEN
* ALLOWED_CHANNEL_IDS(カンマ区切り)

ボイラープレートの段階では、backendのAPI返信は”OK"などの短文で良い。

パラメーター

次にモデルパラメーターを設定します。

ここは検討の余地がまだありますし、使用しているGPUの性能やVRAM容量によって調整しなければなりません。

今回設定したのはTemperaturemax_tokensnum_ctx (Ollama)の3つ。

  • Temperature = 0.2か0.4
  • max_token = 32K (32768)
  • num_ctx (Ollama) = 64K (65536)

使用しているGPUはRTX 2000 Ada Lovelace 16GB VRAM。
あまり速くはありませんが、省電力で熱対策が楽で静かな良いGPUです。
この状態で動かすと、VRAMは14GBほど専有し、モデルとKVキャッシュがVRAMに乗るので、RAMとの間のデータやり取りが減って速く動作します。

LMStudioなどを使う場合も、同様の設定をすれば良いと思います。

実際にプログラムを生成してみる

では、早速、プログラムを生成してみます。

ターン1

LLMを使って要約を行うDiscordボットを作りたいので、ボイラープレートを出してください。

最終Tree

.
├── README.md
├── .env
├── .env.example
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── bot/
│   ├── main.py
│   └── summarizer.py
└── requirements.txt

Change Plan

  1. Create bot/summarizer.py
  • Implement a simple wrapper around an LLM (e.g., OpenAI) that exposes a summarize(text: str) -> str function.
  • Load the LLM API key from the environment variable OPENAI_API_KEY.
  • Use the LLM to generate a concise summary of the input text.
  1. Modify bot/main.py
  • Import summarizer.
  • Add an on_message listener that:
    • Checks if the message’s channel ID is in the list obtained from ALLOWED_CHANNEL_IDS.
    • If allowed, calls summarizer.summarize(message.content) to get a summary.
    • Replies to the message with the summary (or a minimal acknowledgement if summarization fails).
  • Ensure the bot only reacts to messages in the allowed channels.
  1. Add .env.example
  • Include placeholders for:
    • DISCORD_TOKEN
    • ALLOWED_CHANNEL_IDS (comma‑separated)
    • OPENAI_API_KEY
  1. Update requirements.txt
  • Add discord.py and openai (or the relevant LLM client library).
  1. Create Dockerfile
  • Base image: python:3.11-slim.
  • Copy requirements.txt and install dependencies.
  • Copy the bot/ directory.
  • Set environment variables (e.g., DISCORD_TOKEN, ALLOWED_CHANNEL_IDS, OPENAI_API_KEY).
  • Run the bot with python -m bot.main.
  1. Ensure docker-compose.yml references the new Dockerfile
  • No changes needed if it already builds the image from the Dockerfile.

This plan adds the necessary files and modifications to implement a Discord bot that uses an LLM to summarize messages in allowed channels, while keeping the project structure and constraints intact.


ターン2

docker-compose.ymlの中身を出して。

version: "3.9"

services:
  bot:
    build: .
    env_file: .env
    restart: unless-stopped

ターン3

プロンプト

Dockerfileの内容を教えてください。

レスポンス

# ベースイメージ
FROM python:3.11-slim

# 作業ディレクトリを作成
WORKDIR /app

# 依存関係をインストール
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# アプリケーションコードをコピー
COPY bot ./bot

# 環境変数は docker‑compose で読み込むのでここでは設定しない

# デフォルトの実行コマンド
CMD ["python", "-m", "bot.main"]

ターン4

requirements.txtの中身を教えてください。

requirements.txt

discord.py==2.3.2
openai==1.12.0

ターン5

bot/main.pyの中身を教えてください。

bot/main.py

import os
import discord
from discord.ext import commands
from bot import summarizer

# 環境変数から設定を取得
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
ALLOWED_CHANNEL_IDS = os.getenv("ALLOWED_CHANNEL_IDS", "")
# カンマ区切りの文字列を整数のセットに変換
ALLOWED_CHANNEL_IDS = {
    int(ch.strip()) for ch in ALLOWED_CHANNEL_IDS.split(",") if ch.strip().isdigit()
}

intents = discord.Intents.default()
intents.message_content = True  # メッセージ本文を取得するために必要

bot = commands.Bot(command_prefix="!", intents=intents)


@bot.event
async def on_ready():
    print(f"Logged in as {bot.user} (ID: {bot.user.id})")


@bot.event
async def on_message(message: discord.Message):
    # 自分のメッセージには反応しない
    if message.author.id == bot.user.id:
        return

    # 許可されたチャンネルか確認
    if message.channel.id not in ALLOWED_CHANNEL_IDS:
        return

    # 要約を取得
    try:
        summary = await summarizer.summarize(message.content)
    except Exception as e:
        # 何らかのエラーが起きたら簡易応答
        summary = "要約できませんでした。"

    # 最小限の返信
    await message.reply(summary, mention_author=False)


if __name__ == "__main__":
    bot.run(DISCORD_TOKEN)

ターン6

summarizerモジュールの実装はどうなっていますか?

bot/summarizer.py

"""
A very small wrapper around the OpenAI API that turns a piece of text
into a concise summary.

The module exposes a single coroutine:

    async def summarize(text: str) -> str

It uses the `openai` Python package (see requirements.txt) and expects
the environment variable `OPENAI_API_KEY` to be set.  If the key is
missing or the API call fails, the function returns a short error
message so that the bot can still reply with something useful.
"""

import os
import asyncio
from typing import Optional

import openai

# Load the API key once at import time
OPENAI_API_KEY: Optional[str] = os.getenv("OPENAI_API_KEY")
if OPENAI_API_KEY:
    openai.api_key = OPENAI_API_KEY


async def summarize(text: str) -> str:
    """
    Return a short summary of *text* using the OpenAI ChatCompletion API.

    Parameters
    ----------
    text : str
        The original message content to summarise.

    Returns
    -------
    str
        A concise summary.  If the request fails, a short error string
        is returned instead.
    """
    if not OPENAI_API_KEY:
        return "OpenAI API key not configured."

    # The request is performed in a thread‑pool to avoid blocking the
    # event loop.  The OpenAI library is synchronous, so we wrap it
    # with asyncio.to_thread.
    try:
        response = await asyncio.to_thread(
            openai.ChatCompletion.create,
            model="gpt-3.5-turbo",
            messages=[
                {
                    "role": "system",
                    "content": (
                        "You are a helpful assistant that produces short "
                        "summaries of the given text. Keep the summary to "
                        "one or two sentences."
                    ),
                },
                {"role": "user", "content": text},
            ],
            temperature=0.2,
            max_tokens=60,
        )
        # The assistant's reply is in the first choice's message content.
        summary = response.choices[0].message.content.strip()
        return summary or "要約できませんでした。"
    except Exception as exc:
        # Log the exception if you have a logger; for now we just return
        # a user‑friendly message.
        return "要約できませんでした。"

ターン7

.env.exampleの内容を教えて。

.env.example

# Discord bot token
DISCORD_TOKEN=your_discord_bot_token_here

# Comma‑separated list of channel IDs the bot should listen to
ALLOWED_CHANNEL_IDS=123456789012345678,987654321098765432

# OpenAI API key for summarisation
OPENAI_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

ターン8

.gitignoreの内容を教えて。

.gitignore

# Byte‑compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# Virtual environment
.venv/
env/
ENV/
env.bak/
venv/
ENV.bak/

# PyInstaller
#  Usually these files are written by a python script from a template
#  when you run pyinstaller.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
*.db
*.sqlite

# PyCharm
.idea/

# VS Code
.vscode/

# Environment variables
.env

# Logs
*.log

# Docker
# (Do NOT ignore docker-compose.yml or Dockerfile)
# docker-compose.yml
# Dockerfile

# Misc
*.swp
*.swo
*.swn
*.tmp
*.bak
*.orig
*.rej
*.sublime-workspace
*.sublime-project

ターン9

summerizer.pyはOpenAIを使うのではなく、ローカルで動いているOllamaを使いたい。

"""
A very small wrapper around the Ollama API that turns a piece of text
into a concise summary.

The module exposes a single coroutine:

    async def summarize(text: str) -> str

It uses the `ollama` Python package (see requirements.txt) and expects
the environment variable `OLLAMA_MODEL` to be set.  If the variable is
missing, it defaults to "llama3".  If the request fails, the function
returns a short error message so that the bot can still reply with
something useful.
"""

import os
import asyncio
from typing import Optional

import ollama

# Load the model name once at import time
OLLAMA_MODEL: str = os.getenv("OLLAMA_MODEL", "llama3")


async def summarize(text: str) -> str:
    """
    Return a short summary of *text* using the Ollama chat API.

    Parameters
    ----------
    text : str
        The original message content to summarise.

    Returns
    -------
    str
        A concise summary.  If the request fails, a short error string
        is returned instead.
    """
    # The request is performed in a thread‑pool to avoid blocking the
    # event loop.  The Ollama client is synchronous, so we wrap it
    # with asyncio.to_thread.
    try:
        response = await asyncio.to_thread(
            ollama.chat,
            model=OLLAMA_MODEL,
            messages=[
                {
                    "role": "system",
                    "content": (
                        "You are a helpful assistant that produces short "
                        "summaries of the given text. Keep the summary to "
                        "one or two sentences."
                    ),
                },
                {"role": "user", "content": text},
            ],
            temperature=0.2,
            max_tokens=60,
        )
        # The assistant's reply is in the 'message' field of the response.
        summary = response.get("message", {}).get("content", "").strip()
        return summary or "要約できませんでした。"
    except Exception:
        # Return a user‑friendly message on failure.
        return "要約できませんでした。"

最後に

こんな感じで、やり取りしながら、プログラムを作っていきます。
LLMの出力が間違えていることもあるので、正しさの判断は自分でやります。

gpt-oss:20bを最大コンテクスト(128K)で使うには、もっと大きなVRAMを搭載したGPUが必要です。最大コンテクストで使えれば、長いソースコードや、ソース間の関係も記憶して処理をしてくれるので、そこそこ大きなアプリケーションも開発できるでしょう。

これでも十分に、一から人手でやるよりは速く作業を進められますが、システムプロンプトやパラメーターを調整すれば、もっと快適にやれるでしょう。

AIに判断を任せない

これだけは常に気に留めてください。

「gpt-oss:20bを使ってプログラムを開発する一手」への1件のフィードバック

コメントする