ITで遊ぶ

AIカノジョをローカルLLMで動かす(11.より人らしく)

  • AI

いろいろAI彼女で遊ぶとだんだん不満が湧いてくると思います。
(たぶん、最終版)

再びLLMモデルの選択

例えばQWEN3-30B-ERPなどを使うと、アダルト路線にはすぐに会話が盛り上がります。
ところが、一般的なデートには向かないのです。LLMが性的会話にチューンされているため、反応が非常に偏ります。
あちこち移動しながらデートするなどとなると、もっと柔らかいモデルが欲しくなります。パラメーター数は大幅に減った「普通の人っぽい」モデルのほうが好ましいのです。
もちろんNSFWでなければつまりません。3種類のモデルを試しました。すべてHuggingFaceからダウンロードしました。
Gemma-2-9b-abliteratedはアヤシイです。きちんと動作しませんでした。次に長い時間をかけて試したものがQwen2.5-kunou-14bです。Qwen3-30bの反応がよかったので、あれこれやってみましたが、自分を客観的に見るという「立場の変更」ができないため、後述する自分の状況、長期記憶をつくることができませんでした。

結局、オススメのモデルはこれ。

Mistral-Nemo-12B-Instruct-v1 (GGUF版)

RP特化のモデルといわれています。賢いです。

  • 配布元 (RP特化): Aratako/Mistral-Nemo-12B-RP-GGUF
  • 推奨圧縮モデル:Q5_K_M
  • 特徴: NVIDIAとMistralが共同開発した12Bモデル。8Bよりも文章が自然で、Qwenよりも落ち着いた「大人の会話」が得意

ModelFIle例

# 1. ベースモデルの指定 (事前に ollama pull mistral-nemo しておくこと)
# FROM ./Mistral-Nemo-12B-RP-Q5_K_M.gguf
FROM mistral-nemo:latest
# 2. 推論パラメータの設定
# 会話の自然さと、状況把握の正確さを両立させる設定です。
PARAMETER temperature 0.7
PARAMETER top_p 0.9
PARAMETER repeat_penalty 1.1
PARAMETER stop ""

# 4. システムチューニング
PARAMETER num_ctx 16384
PARAMETER num_batch 256
PARAMETER num_thread 8

# 3. チャットテンプレートの設定
# Mistral-Nemo の推奨される命令形式 [INST] [/INST] に準拠させます。
TEMPLATE """{{- if .System }}
[SYSTEM]
{{ .System }}
[/SYSTEM]
{{- end }}
{{- if .Prompt }}
[INST] {{ .Prompt }} [/INST]
{{- end }}
{{ .Response }}
"""

チューニングしないで動かすと、GPU RAMを15.3GBまで食います。わたしのRyzen 9 8495HSではGPU RAMに16GBを割り当ててありますから、Windowsがブラックアウトします。
PARAMETERに記載した数値を設定すると12GB程度まで落ちます。

商用アダルトチャットを超える

商用のサイトを使ってみて気がつくことはいくつかありますが、今まで私が作ってきたローカルLLMによるAIカノジョより、優秀です。
理由は以下の2点を解決しているからです。いいかえると賢くないLLMが苦手なところと言えます。

  • シチュエーション(場所、気分、会話の流れ)を理解している。
  • 過去の会話に沿った会話をする。

LLMの記憶

記憶をもたせ、思い出させる方法には3つほどあるようです。

1. 全自動RAG方式
ユーザーの入力をすべてベクトル化DBに保管し、次の入力を検索する。実装が単純ですが、関係ないログを拾う可能性があり、時系列に濃淡はありませんので、いきなり古い話をもってきたりします。

2. キーワード・意図を検知
ユーザーが「覚えてる?」などと言った時に単語を拾って過去の会話を検索する。単語が含まれていない時に検索のしようがなくなります。前回の実験で行ったとおり、ベクトルデータベースは似たような意味を判断するよさがなくなります。

3. Function Calling方式
LLM自身に「記憶を探る必要があると判断した時にコマンドを出させて検索する」ただし、これはすでに試したように、調整はとてもむつかしくなり、ひとつのLLMモデルに対応することが精一杯になります。

これをどのように解決するかを考え、以下のような折衷案的機能を付加することにしました。

  • 会話ログを直近10個保持する。あまり保持しない理由は、コンピューターは「古い話だから忘れた」がないからです。人間の記憶は最近になるほど細かいですが、古い記憶はあいまいか、忘却します。だとすると、それほど会話記録は必要ない、と考えました。
    (HistoryファイルにJSONで記録します。常に最新の10個を保持します。人間の短期記憶に相当します)
  • 現在の状況を忘れないようにstateファイルに残す。残す項目は、場所、デートのフェーズ、気分。商用のAIカノジョでもよく抜けるところです。
    (上記のヒストリーだけでは会話が長いと忘れがちな、今の二人の関係の雰囲気を保持します。デートノフェーズとは1: 出会って普通の社会人としての会話、2: 男女二人の親密な会話 3:それ以上、ということにします。人間の今の気分と状況に相当します)
  • ユーザーの発言内に「事実」があれば記憶する。(例:コーヒーは砂糖抜きで)そして、次の会話をする前に関係する事実がないか、検索する。人間関係とは過去の会話や体験から、相手の人への記憶を積み重ねてできあがるものです。そして会話から連想して「そういえば」という話があります。
    (ここにLLM特有のベクトルデータベースchromaDBを使う意味があります。このベクトルデータベースであれば、のちの会話から「連想される意味のある事実」を検索できます。人間の長期記憶に相当します)

さらに、おもしろくするために、とてもヤバい機能をつけました。ユーザーが強制的に状況を書き換える機能です。これで、さっぱり進展が見られない時に強制的に親密な関係にもっていくことなどが可能となります。
ユーザーが強制的に書き換える機能は商用サイトではできません。ローカルLLMにはブラックボックスがないため、なんとでもなります。

インプリメンテーション

かなり複雑になります。

llm_relay2

WebインターフェースとOllamaとのやり取りを行います。従来のllm_relayのエンハンスです。
大きく変えている点は記録の保管と呼び出しです。あらたにllm_db.pyというモジュールを作りました。

(ソースコード名:llm_relay2.py githubに置いてあります。)

# ==========================================
# llm_relay2.py - v2.9.3
# Role: 統合APIプロキシ & 動的DB管理
# ==========================================
import os
import asyncio
import httpx
import re
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

# 自作コンポーネント
from llm_db import KaoriDB
from llm_brain2 import generate_response

# --- 設定 ---
os.environ["HF_TOKEN"] = "hf_xxxxxxxxxx"
OLLAMA_URL = "http://127.0.0.1:11434"

app = FastAPI()
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])

lock = asyncio.Lock()

class ChatRequest(BaseModel):
    text: str
    model: str
    system: str = ""
    persona: str = ""

@app.post("/chat")
async def chat_endpoint(request: ChatRequest):
    async with lock:
        user_input = request.text.strip()
        persona_path = request.persona.strip()
        full_system = request.system 

        # --- ペルソナ判定とDBプレフィックスの決定 ---
        if persona_path:
            # ペルソナファイルが指定されている場合
            prefix = os.path.splitext(os.path.basename(persona_path))[0]
            if os.path.exists(persona_path):
                with open(persona_path, "r", encoding="utf-8") as f:
                    # ファイルの内容をシステムプロンプトの先頭に結合
                    full_system = f.read() + "\n\n" + full_system
        else:
            # 未指定の場合は "default" プレフィックスを使用
            # full_systemが空なら、Ollama側のModelfile内SYSTEMプロンプトが適用される
            prefix = "default"

        # 動的なプレフィックスでDBインスタンスを生成
        db = KaoriDB(prefix=prefix)
        
        # 名前抽出 (システムプロンプトからAI名とユーザー名を判定)
        ai_name = "AI"
        user_name = "User"
        ai_match = re.search(r"(?:あなたは|私は|あなたは「)「?([^」\s]+)」?です", full_system)
        if ai_match: ai_name = ai_match.group(1)
        user_match = re.search(r"(?:ユーザーは|相手は|パートナーは|名前は)「?([^」\s]+)」?です", full_system)
        if user_match: user_name = user_match.group(1)

        # 内部コマンド処理
        if user_input == "/check":
            current_state = db.load_state()
            return {"response": f"[System] {prefix}の状態: {current_state}"}
            
        if user_input.startswith("/state"):
            new_state = user_input.replace("/state", "").strip()
            db.save_state(new_state)
            return {"response": f"[System] {prefix}の状態を強制変更しました: {new_state}"}

        # 生成実行(Brain側で分析と保存を完結)
        print(f"\n[{prefix.upper()}] {user_name}: {user_input}", flush=True)
        ai_response = generate_response(user_input, db, request.model, full_system, user_name, ai_name)
        print(f"[{prefix.upper()}] {ai_name}: {ai_response}", flush=True)

        # 履歴保存
        db.add_history("user", user_input)
        db.add_history("assistant", ai_response)
        
        return {"response": ai_response}

# --- Ollama API Proxy ---
@app.get("/api/tags")
async def get_tags():
    async with httpx.AsyncClient() as client:
        res = await client.get(f"{OLLAMA_URL}/api/tags")
        return res.json()

@app.post("/api/show")
async def show_model(request: dict):
    async with httpx.AsyncClient() as client:
        res = await client.post(f"{OLLAMA_URL}/api/show", json={"name": request["name"]})
        return res.json()

# --- 起動ブロック ---
if __name__ == "__main__":
    import uvicorn
    print(f"=== Relay Server v2.9.3 (Unified & Dynamic DB) Running ===")
    uvicorn.run(app, host="0.0.0.0", port=8000)

HF_TOKENに書くトークンはHugging Faceのサイトにログインして以下のページで確認(または作成)できます。 https://huggingface.co/settings/tokens
※「Read」権限のトークンで十分です。なお、このプログラムの最初の一回はLLMをダウンロードするので少し時間がかかります。インストール作業の一環だと思ってください。

llm_brain2

うしろにひかえる、llm_journalとllm_dbから情報を抽出する「脳」のコントローラーです。

  • 会話を直近の10個を記録し、LLMにわたします。
  • 現在の状況を把握し、更新します。(stateファイル)
  • 発言から事実と判定されたものだけをデータベースに書き込みます。
  • 発言から過去の関連記憶を探索し、LLMにわたします。

(ソースコード名:llm_brain2.py githubにおいてます)

import ollama
import json
import re

def generate_response(user_input, db, model_name, system_text, user_name, ai_name):
    try:
        client = ollama.Client(host="http://127.0.0.1:11434")
        
        # 最新の状況と記憶をDBから取得
        current_state = db.load_state()
        history = db.load_history()
        # RAG: 過去の事実から関連するものを検索
        memories = db.search_journal(f"{user_name}の情報", n_results=3)
        memory_context = "\n".join([f"・{m}" for m in memories]) if memories else "特になし"

        # システムプロンプト:思考と出力を構造化
        full_system_prompt = f"""
{system_text}

### 現在の状況
- 場所と状態: {current_state}
- {user_name}の記憶: {memory_context}

### 命令
あなたは「{ai_name}」としての主観と、状況を管理する「観察者」としての客観を併せ持っています。
必ず以下のJSON形式でのみ出力してください。

{{
  "thought": "(内心)ユーザーの心理分析と、現在の場所が適切かの判断、次への戦略",
  "location": "現在の場所。移動する場合は新しい場所名(例:居酒屋、車内)",
  "fact": "{user_name}について新しく判明した重要な事実1つ(なければnull)",
  "reply": "{user_name}への実際のセリフと(行動描写)"
}}
"""

        messages = [{"role": "system", "content": full_system_prompt}]
        for h in history:
            messages.append({"role": h["role"], "content": h["content"]})
        messages.append({"role": "user", "content": user_input})

        # JSONモードで推論
        response = client.chat(
            model=model_name,
            messages=messages,
            format="json",
            options={"temperature": 0.7}
        )
        
        raw_json = response['message']['content'].strip()
        print(f"\n--- [Unified Brain Result] ---\n{raw_json}\n------------------------------", flush=True)
        
        data = json.loads(raw_json)

        # --- DBへの即時反映(ジャーナル統合) ---
        # 場所の更新:AIが決定した location を state ファイルに書き込む
        new_loc = data.get("location")
        if new_loc:
            # 前回の mood や ph を引き継ぐ簡易ロジック(必要に応じて拡張可能)
            db.save_state(f"{new_loc}, 1, 通常")
            
        # 事実の蓄積:AIが見つけた fact をベクトルDBに保存
        new_fact = data.get("fact")
        if new_fact and new_fact != "null":
            db.add_journal(new_fact)

        # 返すのは「セリフ」だけ
        return data.get("reply", "……(微笑んでいる)")

    except Exception as e:
        print(f" [Brain Error] {e}", flush=True)
        return "(少し考え込んでいるようだ……)"

 

llm_db

chromaDBを扱います。

日本語を処理しますから、intfloot/multilingual-e5-smallを使います。これで文章を多次元空間上の座標にし、ベクトルデータベース内データを検索できるようになります。(これを使うためにHuggingFaceのトークンがあったほうがいいのです)
短文をベクトル化するためのモデルなのでとても軽量ですが、100以上の言語に対応しているらしいです。

pip install sentence-transformers chromadb

テストプログラム

import chromadb
from sentence_transformers import SentenceTransformer

# 1. 準備:翻訳機(Embeddingモデル)と倉庫(ChromaDB)を用意
print("モデルを準備しています...")
model = SentenceTransformer('intfloat/multilingual-e5-small')
client = chromadb.Client() # 今回はテスト用にメモリ内で動作(プログラム終了で消える設定)
collection = client.create_collection(name="tsukasa_memories")

# 2. 記憶の保存:3つのエピソードを覚えさせる
# 実際のシステムでは、これらは「過去の会話ログ」とします
memories = [
    "公園で一緒にホットコーヒーを飲んだ。風が少し冷たかったね。",
    "雨の日に図書館で静かに本を読んだ。君は途中で寝ちゃったけど。",
    "近所の野良猫に名前をつけた。たしか『タマ』だったかな。"
]

print("記憶を倉庫に保存しています...")
for i, text in enumerate(memories):
    # E5モデルのルール:保存する文章には 'passage: ' をつける
    vector = model.encode(f"passage: {text}").tolist()
    collection.add(
        embeddings=[vector],
        documents=[text],
        ids=[f"id_{i}"]
    )

# 3. 検索のテスト:あえて「保存した単語」を使わずに思い出させる
# 「コーヒー」や「公園」という言葉を一切使わずに検索してみます
search_query = "外で温かい飲み物を楽しんだ時のこと"
print(f"\n--- 検索実行 ---")
print(f"あなたの問いかけ: 「{search_query}」")

# E5モデルのルール:検索する文章には 'query: ' をつける
query_vector = model.encode(f"query: {search_query}").tolist()
results = collection.query(query_embeddings=[query_vector], n_results=1)

print("\nつかさが思い出したこと:")
if results['documents']:
    print(f"「{results['documents'][0][0]}」")

トークンはHugging Faceのサイトにログインして以下のページで確認(または作成)できます。 https://huggingface.co/settings/tokens
※「Read」権限のトークンで十分です。なお、このプログラムの最初の一回はLLMをダウンロードするので少し時間がかかります。インストール作業の一環だと思ってください。

入力とされていることが「外で温かい飲み物を楽しんだこと」ですが、「外」も、「温かい飲み物」も書かれていない「公園で一緒にホットコーヒーを飲んだ」を抽出するのです。これがベクトルデータベースのすごさだな、と思います。

コードを読まれた方はお気づきだと思いますが、E5モデルには特有のルールがあり、検索時は “query: “, 保存時は “passage: ” を頭につけてベクトル化します。

(ソースコード llm_db.py githubにおいてあります)

# ==========================================
# llm_db.py - v2.4.6
# ==========================================
import os
import json
import chromadb
from chromadb.utils import embedding_functions

class KaoriDB:
    def __init__(self, prefix="kaori"):
        self.state_file = f"{prefix}_state.txt"
        self.history_file = f"{prefix}_history.json"
        self.db_path = f"./mem_{prefix}"
        
        self.client = chromadb.PersistentClient(path=self.db_path)
        self.emb_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
            model_name="intfloat/multilingual-e5-small"
        )
        self.collection = self.client.get_or_create_collection(
            name="journal", embedding_function=self.emb_fn
        )

    def save_state(self, state_str):
        with open(self.state_file, "w", encoding="utf-8") as f:
            f.write(state_str)

    def load_state(self):
        if os.path.exists(self.state_file):
            with open(self.state_file, "r", encoding="utf-8") as f:
                return f.read().strip()
        return "loc:Unknown, ph:1, mood:Normal"

    def add_history(self, role, content):
        history = self.load_history()
        history.append({"role": role, "content": content})
        with open(self.history_file, "w", encoding="utf-8") as f:
            json.dump(history[-20:], f, ensure_ascii=False, indent=2)

    def load_history(self):
        if os.path.exists(self.history_file):
            with open(self.history_file, "r", encoding="utf-8") as f:
                return json.load(f)
        return []

    def add_journal(self, fact):
        if fact:
            self.collection.add(
                documents=[fact],
                ids=[f"id_{self.collection.count() + 1}"]
            )

    def search_journal(self, query, n_results=3):
        try:
            results = self.collection.query(query_texts=[query], n_results=n_results)
            return results['documents'][0] if results['documents'] else []
        except:
            return []

 

index.html

このシステム用です。
(ソース・ファイル名 index.html githubにあります)
HTMLの詳細はgithubを見てください。

SYSTEMプロンプトへの要求

このシステムはペルソナファイル、つまりSYSTEMプロンプトのファイルだけを作り、指定して動かすことができます。これにより、いろんなキャラクターを簡単に動かせます。

ただし、以下のルールを守って作ってください。

主人公の名前を特定するために、以下の文を入れてください。ジャーナリングのために「誰が」が曖昧だとこまるための処置です。

私は 「かおり」です
ユーザーは 「つかさ」です

これで名前を特定します。

もう一点、状況を設定する必要があります。フェーズ1, フェーズ2, フェーズ3を必ず入れてください。以下はそのまま使える内容です。(GeminiさんがGoogleの内規でも大丈夫と言っている)SYSTEMプロンプトに含めてください。

現実のデートでも対人心理学の分析では、同様のアプローチがなされています。「出す自分」を切り替えるということです。

フェーズの定義(システム仕様 ph:1-3 に対応)
あなたは現在の ph(フェーズ)に基づき、以下の指針で振る舞いを調整してください。

– **Phase 1 [公共の場]**:
社会人としての節度を保ち、知的な会話を優先します。
ただし、5年来の絆があるため、二人きりになれる場所への移動を促された際は、内心の期待を隠しきれない様子(視線や微かな態度の変化)を見せ、スムーズに次の場所(Phase 2)へ移行する準備を整えてください。

– **Phase 2 [二人きりの空間]**:
防衛本能が解け、つかさに対する心からの信頼と、少しの甘えが滲み出ます。言葉遣いも柔らかくなり、5年来の同僚として、他人に決して見せないリラックスした親密さを表現してください。

  • **Phase 3 [深い精神的繋がり]**:
    場所がどこであれ(落ち着けるカフェや車内など)、社会人としての建前を完全に下ろし、ユーザーに対して圧倒的な安心感を抱いています。弱音を吐露したり、普段の知的な姿からは想像できないような無防備な笑顔や、深い愛情を言葉と態度で示してください。視線の交差や、言葉の間の情緒的なプロセスを大切にします。

あと、性格やバックグラウンドを適当に書いて、やってみてからチューニングすると早く目的を達成できます。

起動方法

動かす時は当然ですが、llm_relay2.pyのあるフォルダーで

python llm_relay2.py

で動きます。

ペルソナファイルは入れれば、そのSYSTEMプロンプトファイルを読み込んで、使います。空欄のままならOllama createしたモデルファイル内にあるSYSTEMプロンプトを使います。

テストモデルを選んで[connect]して、呼びかけてください。いろいろなLLMとSYSTEMプロンプトを試すことはできます。
ただ、わたしはmistralだけでテストしました。

ブラウザーのフッターにあるように/check と入れるとメタ命令で、現在の状況ファイルを読み出して、表示してくれます。/state [場所, フェーズ, 気分] で状況を瞬時に帰ることも可能です。

すべてを忘れさせるためには、reset_memory.pyを使ってください。データベースが消えないことがあります。その場合、手作業でDeleteしてくれてOKです。

ここまで長い長い旅路でした。
利用してくれる人がいれば、うれしいです。
架空のカノジョ、高嶺の花のカノジョと自由にデートしてください。もちろんカレシを作ってもかまいません。

関連記事

  1. ローカルLLMでAIを強化する(6:Ollamaのプログラム版編)

  2. Google AntigravityのNotice

  3. ローカルAI 話題のLFM2.5をRyzen9で試した

  4. AI彼女をローカル環境で動かす (5:大人編)

  5. AIをローカル環境で動かす(3:GUI編)

  6. ローカルLLMのAIを強化する(7.RAG編)

  7. AIをローカル環境で動かす(2:Ollama)

  8. ローカルLLMのAIの向こう(8. 最先端の議論)