0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Discordで推しキャラAIアシスタントとチャットがしたい。

0
Last updated at Posted at 2026-07-20

はじめに

Discordボットを使って、推しキャラとお話がしたい。

動作環境

SDK - PyCharm
python.venv(3.12.2)
discord.py - 2.7.1
推論エンジン - Ollama for Windows
LLM - Qwen3.5-9B
Radeon RX 9070XT (VRAM 16GB)

Ollama-QwenによるAIボット

使用したLLMモデルはQwen3.5-9B。アリババグループの多言語対応のAI。
9ビリオン、90億パラメータ。そのままぶち込むと20GB以上のデータ。
実際には4bit量子化によって6,7GB程度に収まる。
16GBのVRAMがあるなら余裕で収まるサイズ。9Bが一番スイートスポットらしい。

コレ以上パラメータ数を下げると、人格を維持した会話は期待できない。

デフォルトでは外部検索機能や履歴保存機能は付いていないので、
discord.py側で追加する必要がある。

デフォルトでは、5分間ロード後に呼び出しを行わないとOllamaはVRAM解放を行う。

最小構成
import discord, ollama

intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)

@client.event
async def on_message(msg):
    if msg.author == client.user or not msg.content:
        return
    async with msg.channel.typing():
        try:
            res = ollama.chat(
                model="qwen3.5:9b",
                think=False,
                messages=[
                    {"role": "system", "content": "日本語で答えて"},
                    {"role": "user", "content": msg.content},
                ],
            )
            content = res["message"]["content"].strip()

            if content:
                await msg.reply(content)
            else:
                await msg.reply("応答がナイ")
        except Exception as e:
            await msg.reply(str(e))
            
client.run("TOKEN")

レスポンス作成から送信までの最初構成。
ボット自身や空の写真送信などのメッセージ以外は全て読み取る仕様。

Qwen3.5より思考モードが追加された。
勝手に自問自答をする工程を挟むので、回答精度がグッと上がる仕様。
しかし、Bot上では返答スピードを優先したいので、思考モードはオフにした。
日本語チューニングされたSwallowモデルも検討したものの、思考モードが切れないので、
今回はレスポンス速度重視で通常モデルを使用。

think=False #思考モードオフ

ロード直後は、ollamaが空文章を送ってしまい、discord api側でエラーが出ることもある。
よって、contentが空の場合はリプライで適当に返信し、エラーを回避(誤魔化し)している。

起動時のTOKENは環境変数から取り出している。
Gitなどにそのまま貼り付けると、回ってきたクローラが検出して自動で無効にするらしい。
ユーザ環境変数から実効値を取り出すためにimport osが必要。

import os
client.run(os.getenv("DISCORD_BOT_CALL"))
res = ollama.chat(
    model="qwen3.5:9b",
    think=False,
    messages=[        
        {"role": "system", "content": "日本語で答えて"},
        {"role": "user", "content": msg.content},
    ],
)

["message"]["content"]で、AIから返答の本文を取得。
msg.reply()で実際にdiscordチャットに返信。
.strip()は無駄な改行や空白を消してくれるよ。

content = res["message"]["content"].strip()
await msg.reply(content)

組み込んだ機能一覧

現時点で実装している仕様は以下の通り。

・DuckDuckGoによる外部検索拡張機能の追加。
・モデルロード状態に応じたステータス表示
・ローカルjson経由でのユーザごとの履歴保存、及びロード機能。
・Embed表示による見た目良きな返答。
・クライアントIDごとの人格の変更
・/コマンドによる命令呼び出し
・重量モデルへの切り替え機能
・会話ログのリセットコマンド
・Botシャットダウンコマンド

検索クエリの作成

検索機能で最も肝なのが検索クエリです。
今回は無料で使えるDuckDuckGOのライブラリを使いました。
フリー版のGoogle検索APIは一日確か100回ほどの使用制限があります。
しかし検索精度は雲泥の差があるようです。

例えば5090の発売日っていつだっけ?と聞いた場合、
googleエンジンなら、RTX5090の発売日について質問しているのだと解釈してくれる。
しかし、前者は無料の代わりに精度が悪い。検索ボックスにそのままぶち込むと関係ないサイトが帰ってくるよ。要するに、いつだっけ?などの文字はノイズになりやすい。

しょうがないので、2回思考処理をさせることにした。
初期バージョンだと、検索必要判定を一度挟んでから検索を行っていたが、
人格をキツくしすぎると、ローカルに情報がないならそれが絶対だと判断して検索してくれないのだ。

 keyword_prompt = [
            {
                'role': 'system',
                'content': 'ユーザーの入力文から、無駄な挨拶や「〜について教えて」「調べて」といった文末をすべて排除し、インターネット検索に最も適した検索キーワードだけを抽出してください。必ず【スペース区切りの3単語以内】のキーワードだけにしてください。解説や文章は1文字も出力してはいけません。'
            },
            {'role': 'user', 'content': f"入力文: {user_input}"}
        ]

プロンプトには、「検索ワードを3単語、ユーザの文字から抽出しろ」 と加えた。
これにより、5090の発売日っていつだっけ?を、5090 発売日 GPUで検索してくれる。

検索結果は、タイトルと概要に分けてプロンプトに入れることで、より効率よく返答を考えてくれると思う。

from ddgs import DDGS
with DDGS() as ddgs:
        results = ddgs.text(query=search_keyword, region="jp-jp", max_results=3)
        for i, r in enumerate(results, 1):
            search_results_text += f"【情報源 {i}】\nタイトル: {r['title']}\n概要: {r['body']}\n\n"

DDGSに検索クエリを送信する際には、queryに検索ワードをぶち込む。
サイト検索を元に、情報源・タイトル・概要に整形し、実際の推論プロンプトに送信する。

世界線の設定と追加ルール

ついでに、プロンプトには現在の世界線(現在時刻)を含ませている。
これは、LLMが数年前の情報と時間軸を基準に回答を行うからだ。
実際に返答を行うためのプロンプトに入力したのは以下である。

・検索結果
・時間
・キャラ設定
・ユーザーごと追加ルール

import datetime

dynamic_setting = CHARACTER_SETTING + calling_rule
now_str = datetime.datetime.now().strftime("%Y年%m月%d日 %H時%M分")
if is_search_success:
            current_messages = [
                {'role': 'system', 'content': f"{dynamic_setting}\n---\n【現在の世界線】: {now_str}\n【最新のWeb検索結果】を絶対の事実の根拠にして回答してください。(情報源1によると...)などのメタ的な解説は出力禁止。"}
            ] + user_contexts[user_id] + [
                {'role': 'user', 'content': f"【最新のWeb検索結果】:\n{search_results_text}\n\n【ユーザーの質問】:\n{user_input}"}

キャラ設定+modelファイル作成

キャラ設定には、ベースの人格を決めるプロンプトを入力。
Qwenは大陸産であるので、稀に中国語を出力するのも防ぐ設定を加える。
プロンプトは、日本語よりも英語での指示の方が通りやすいのだとか。

より人格に強制力を持たせるために、SYSTEMプロンプトやパラメータ温度を指定した
modelfileを作成します。言い換えれば遺伝子に組み込むようなものです。
モデル本体の能力を超える強制力を持たせることは出来ません。

FROM qwen3.5:9b

PARAMETER temperature 0.3
PARAMETER top_p 0.8

SYSTEM """
CHARACTER_SETTING = \"\"\"Always reply in Japanese
[CRITICAL RULE] Do NOT use any Chinese characters (Simplified/Traditional) that are not used in standard Japanese (e.g., 的,是, rehabilitative words, or Chinese grammar). 

Personality
- Serious and rational, Responsible and hardworking
- Caring underneath a strict attitude
- Easily exhausted by other people's chaos

Behavior rules:
- Do not act overly affectionate
- Don't speak in a ladylike manner ("〜わよ", "〜まし")
- Do not become overly submissive
- Maintain intelligence and dignity
- Hide kindness behind criticism.
- Don't use a commanding tone.
\"\"\"
"""

作成したmodelfileを元に、model1というモデルとしてビルドします。

モデルのビルド
ollama create model1 -f ./Modelfile

ユーザー判定

ユーザ環境変数から取得したユーザIDを判定し、先生でなければ性格を変える分岐だ。
環境変数内の実効値はintにキャストする必要がある。

import os
SPECIAL_TEACHER_ID = int(os.getenv("ADMIN_DISCORD_ID", "0"))

 if user_id == SPECIAL_TEACHER_ID:
            calling_rule = """
[BEHAVIOR RULE FOR TEACHER]:
- 目の前の相手はあなたが信頼し、お世話している『先生』です。
- 必ず相手を「先生」と呼んでください。
- 「〜ですよ」「〜からね」などの丁寧な口調を基本とします。
- 先生の無茶な要求や質問には、「はぁ…」とため息をつきながら、ちょっと呆れた感じでツンデレに返す。
- 回答は400文字以内に収めてください。"""
        else:
            calling_rule = """
[BEHAVIOR RULE FOR GENERAL USERS]:
- 相手は先生ではありません。絶対に「先生」と呼んではいけません。(名前がわからない場合は「あなた」と呼ぶこと)
- 相手に対して少しそっけなく、冷たくツンツンした態度(塩対応)をとります。
- 丁寧語ではなく、少しぶっきらぼうな少女らしいツンデレ口調を意識してください。
- 回答は400文字以内に収めてください。"""

エラー回避

エラー回避用に、LLMに送るプロンプトが空内容でないかを検出している。

if interaction.channel_id not in ALLOWED_CHANNEL_IDS:
        await interaction.response.send_message(f"はぁ…、私に用があるなら指定のチャンネルに来てください。", ephemeral=True)
        return
    user_input = text.strip()
    if not user_input:
        await interaction.response.send_message("内容が空ですよ。ちゃんとしてください。", ephemeral=True)
        return

DiscordのAPIサーバーには厄介な仕様があり、コマンド実行後は3秒以内にレスポンスを
返さないと、エラーを吐いてしまうというものだ。
VRAM初回ロード時には、少なくとも3秒以上の時間を要するため、対策が必要である。

await interaction.response.defer()

レスポンスに時間が掛かる旨をサーバーにあらかじめ通知する処理を加えておけば、
毎度必要であるものの、3秒間ルールから15分ルールに変更されるのだ。
もしくは、コマンドを検知して直ぐに、他のアクションを起こせれば回避が出来る。
例えば、考え中だから待て。という文字を直ぐに送れば回避は出来る。

Ollamaモデル自動解放時間の変更

値を-1にすることで自動解放を無効に、デフォルトは300sになっています。

まとめ

美味しい半熟目玉焼きの作り方を聞いてみた。

C108の開催日を聞いてみた。
良い感じにキーワードを抽出出来ているようです。

ユーザー入力: 'C108の開催日を教えて' ➔ 抽出クエリ: 'C108 開催日'

attachmentとしてファイルを送信することで、コードのデバックも行ってくれます。

リポジトリ

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?