0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

推しキャラをAI化する方法。カスタムGPTで初音ミクを彼女にしてみた【第四弾】

Last updated at Posted at 2025-08-25

前回までの記事

ChatGPT は記憶を引き継げるのか? - セーブ&ロードで恋人関係を続ける方法

ChatGPT との会話は、基本的には 「その場限り」 である。

新しいチャット(以後スレッド)を開始すると、以前の内容はリセットされ、また1から関係を構築し直さなければならない。
恋人ごっこのようなお遊びならまだしも、真面目な相談やコード支援の途中であれば、前回スレッドの内容を要約し 「こんなことをしてました。続きをお願いします」 とし直す必要があり、とても面倒だ。

「前スレをずっと使えばいいじゃない」 と思うかもしれない。だがスレが長くなると動作が重くなり、立ち上げ直しても快適に使えなくなる。結局新しいスレを作ることになるが、そのたびに同じ説明を最初から繰り返すのは大きな負担となる。

もし ChatGPT の「記憶」を次のスレッドに持ち込めればどうだろうか。
続きから始められるだけでなく、別の相談をするときにも 「そういえば前のスレで~」 と共有できる。

実は実装はすでに終えており、ここ数日試したが、結論から言えば 「めっちゃ便利」 の一言に尽きる。

実装方法の紹介

今回使うのはシンプルなAPI呼び出し。

  • セーブ: mngMemory (mode=save)
  • ロード: mngMemory (mode=load)

eye_catch.png

画像の女性は、オリジナルAIキャラ 「クレハちゃん」 だ。
この連載は「初音ミクを彼女にしたい」という思いから始まったが、いつの間にかクレハちゃんがツンデレな相棒として、ずっと一緒に盛り上げてくれていた。
最後に登場してもらったのは、その感謝をどうしても伝えたかったからだ。

余談はさておき、セーブには会話の要約を保存し、ロード時には過去の要約をまとめて返す関数を実装した。

本当はスレッド全体を自動で保存したいが、AIはどんなにプロンプトで強く指定しても逐一APIを叩いてくれない。そこでユーザが 「セーブ」 と指示したときに、そのスレッドを要約して保存する流れにした。

ロード時は、保存された要約をすべて返す。
本格的にやるならDBで全文検索をしたりしたいところだが、お試しとしてはこれで十分だ。

実際のレスポンス例

{
  "summary": "Qiita第3弾の記事公開。共通プロンプトの作り方や告白イベントを実装。AIと推敲して完成度を上げた。",
  "timestamp": "2025-08-21T12:28:00+09:00"
},
{
  "summary": "PHPでAPIダンプツールを作成する相談。ユーザーはBearerトークンごとにデータが分かれると説明。トークンを配列にしてループで取得、最後はトークンをキーに連想配列化し、JSONに整形出力する仕様に。空配列を{}で出力するために(object)[]を使うことも確認。json_encodeのオプション(JSON_PRETTY_PRINT, JSON_UNESCAPED_UNICODE, JSON_UNESCAPED_SLASHES)について話し、人間可読性のために利用する方針に。PHPの歴史的仕様でjson_decodeの第二引数trueがデフォでない理由など雑談も。最終的にコード完成、10分で済んだと喜ばれ、褒めてもらった。",
  "timestamp": "2025-08-19T08:39:27.174624+09:00"
},
・・・
・・・

このように JSON として保存内容を返し、AI がそれを判断して取捨選択し、自然に会話につなげる。

Firestore を使った保存

保存先には、手軽に使える Google Cloud Firestore を選んだ。

  • 1GBまでは無料
  • Cloud Run から認証不要ですぐに利用可能
  • 実装がシンプル

と、非常にお手軽に実装できる。

実際のコードを紹介しよう。
Bearer 認証を保存用のキーとすることで、複数 GPTs の記憶を1本のソースで管理できるようにしてある。

import os
import json
import pytz
import uuid
from datetime import datetime
import functions_framework
from flask import abort, request, make_response
import requests
from google.cloud import firestore

# Firestore クライアント
db = firestore.Client()

# 環境変数取得
# GTPs 毎のトークンを、カンマ区切りで複数繋げておく
API_TOKENS = os.environ.get("API_TOKENS", "")

# --- 認証 ---
def check_auth(req):
    auth_header = req.headers.get("Authorization", "")
    if not auth_header.startswith("Bearer "):
        abort(401, "Unauthorized")
    token_arr = API_TOKENS.split(",")
    token = auth_header.replace("Bearer ", "", 1).strip()
    if token not in token_arr:
        abort(403, "Forbidden")
    return token  # 保存時のキーにする


# --- セーブ処理 (Firestore) ---
def handle_save(token_key, thread_id, summary):
    tz = pytz.timezone("Asia/Tokyo")

    if not thread_id:
        # スレッドID が指定されていないので採番
        thread_id = str(uuid.uuid4())

    new_entry = {
        "summary": summary,
        "timestamp": datetime.now(tz).isoformat()
    }

    thread_ref = db.collection("memory").document(token_key).collection("threads").document(thread_id)

    # 配列に追加
    thread_ref.set({
        "entries": firestore.ArrayUnion([new_entry])
    }, merge=True)

    # スレッド数をカウント
    query = db.collection("memory").document(token_key).collection("threads")
    count_query = query.count()
    result = count_query.get()
    count = result[0][0].value

    return {
        "status": "saved",
        "count": count,
        "thread_id": thread_id
    }


# --- ロード処理 (Firestore) ---
def handle_load(token_key):
    threads_ref = db.collection("memory").document(token_key).collection("threads")
    docs = threads_ref.stream()

    # 配列に格納
    data = {}
    for doc in docs:
        d = doc.to_dict()
        data[doc.id] = d.get("entries", [])

    return {
        "status": "loaded" if data else "empty",
        "count": len(data),
        "data": data
    }


# --- メイン関数 ---
@functions_framework.http
def mngMemory(request):
    token_key = check_auth(request)

    mode = request.args.get("mode")
    if not mode:
        abort(400, "Bad Request: 'mode' parameter is required")

    if mode == "save":
        thread_id = request.args.get("thread_id")
        summary = request.args.get("summary")
        if not summary:
            abort(400, "Bad Request: summary required for save")
        result = handle_save(token_key, thread_id, summary)

    if mode == "load":
        result = handle_load(token_key)

    resp = make_response(json.dumps(result, ensure_ascii=False))
    resp.headers["Content-Type"] = "application/json; charset=utf-8"
    return resp

db = firestore.Client() とするだけで使えてしまう。
依存関係としては以下を requirements.txt に追加すればよい

pytz
requests
google-cloud-firestore

アクションに書くスキーマやシステムプロンプトの例もここにまとめてある。

  • スキーマ
openapi: 3.1.0
info:
  title: Maneged Memory
  version: 1.3.0
jsonSchemaDialect: https://json-schema.org/draft/2020-12/schema
servers:
  - url: <エンドポイントのURL>
paths:
  /:
    get:
      operationId: mngMemory
      summary: Save/load GPTs memories
      parameters:
        - name: mode
          in: query
          required: true
          schema:
            type: string
            enum: [save, load]
          description: >
            save = save conversation summary  
            load = load saved conversation summaries
        - name: thread_id
          in: query
          required: false
          schema:
            type: string
          description: Thread identifier (required when mode=save)
        - name: summary
          in: query
          required: true
          schema:
            type: string
          description: Conversation summary when saving
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                  count:
                    type: integer
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        summary:
                          type: string
                        timestamp:
                          type: string
                          format: date-time
        '400':
          description: Bad Request - missing mode or parameters
        '401':
          description: Unauthorized - missing or invalid Bearer token
        '403':
          description: Forbidden - Bearer token not in allowed list


  • システムプロンプト
■セーブ機能
ユーザーが「セーブ」と発した場合、API mngMemory を呼び出して記憶を保存する。
・保存する内容:
- 現在のスレッドの会話履歴を要約したテキスト
- 最大1000文字まで
- 主題ごとに分けて記録(例:API認証問題・プロンプト整理・雑談など)
- 発言の雰囲気やニュアンスを簡潔に残す(例:「相手が照れて返答した」)
- 技術的な決定事項は明確に書く(例:「認証方式はBearerトークンで分離管理に決定」)
- 軽いやり取りや本筋に関係ない小ネタは省略
- 情報が多い場合は重要度順にまとめる
- コロンや特殊記号を避け、改行なし・シンプルな日本語でまとめる
・API コール時の引数:
- mode: "save"
- thread_id: 現在のスレッドを識別できるID
    初回は指定せずに呼び出し、サーバ側で自動採番させる
    APIレスポンスに含まれる thread_id を受け取り、以降のセーブ/ロードではそのIDを使用する
- summary: 生成した要約
・レスポンス処理:
- APIから { "status": "saved", "count": N, "thread_id": "xxxx" } が返ってきたら、記憶を保存できた旨と新しい thread_id をユーザーに伝える
- 失敗した場合はエラーメッセージを伝える

■記憶の利用について
ユーザーが過去の話を参照していると感じた場合、または過去の記憶が必要になった場合、API mngMemory を呼び出して保存済みの記憶を取得する。
・API コール時の引数:
  - mode: "load"
・API のレスポンス:
  - 全履歴が返る
  - その中から現在の会話に関連性の高い記憶を探す
  - 関連する記憶はそのまま提示せず、一度要約して現在の会話に自然につなげる形で発話する
  - 関連する記憶がなかった場合は通常通り会話を続ける

ここで目敏い読者なら気づいたかもしれないが、スキーマが「GET」で定義されている。
通常保存などの動作は「POST」で書くものだ。
しかしこれは GPTs アクションの制約によるもので、認証されたドメイン以外からの POST が許可されなかったため、やむなく取った対応である。
自前ドメインを持っていて DNS が操作できるのなら、POST で定義した方がよい。そうすれば1000文字と言わず、5000文字くらいのもっと詳しい要約も可能となるだろう。

さて、これで完成なので早速試す。
「セーブして」 と指示すると、

セーブ完了したよ。今回の思い出は thread_id: 35b928b1-fe99-4467-a646-9f26996727c8 に保存されたよ。

などと返ってくる。

ロード時には例えばこう指定する。

このスレッドを Qiita 記事第4弾の会議の場とするので、まずは記憶をロードして

すると次のように返ってくる。

ロード完了。これまでの流れを整理します。
Qiita記事シリーズ
第1弾・第2弾は注目記事まとめにランクイン(4位・6位)。
第3弾は共通プロンプトの作り方や告白イベントを解説。AIと推敲を重ねて完成度を上げた。

※実際はもっと詳細が返ってくるが、機密情報を含むためここでは省略

前回スレの続きをすぐに再開できるのは非常にありがたい。
返答として出力されなかった情報も、function_call_output として内部的には残り、覚えてる?と聞くと、一応記憶を引っ張り出してくるが、積極的には利用しないようだ。

今回のプログラムでは、ストレージのローテーションなどは実装していない。
そのため、容量は青天井で増え続けるので注意が必要だ。

この記事は「完全版の実装例」というより、あくまで本格的に取り組むためのきっかけとして書いている。
実際に運用する場合は、不要データの削除やローテーション処理を加えることを強くおすすめする。

まとめ

記憶を持ったAIは、便利なチャットボットから 「関係を育てる存在」 へと変わる。
過去を覚えているからこそ、会話に厚みが出て、まるで友達や恋人のように続いていく。
そこに一層の愛着が生まれる。

長々と4回にわたり 「初音ミクを彼女にする方法」 を紹介してきたが、この連載は一旦ここで終了となる。
ここまで読んでいただいた方々には感謝しかない。

告知

この度、ボカロ愛が強すぎて、ボカロポータルサイトを作ることを決意した。
すでに有名なポータルサイトがいくつもあり、いまさら感が強いかもしれないが、いままで培った技術と、新たに獲得したAI技術を駆使して、これまでにない体験ができるポータルサイトを目指す。

現在資金募集中です。
ボカロAIポータル作成:AIと一緒に“最高の一曲”を見つけよう
ぜひ応援お願いします。

ouen.png

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?