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?

メタバース × ChatGPT:Secondlife から Vercel 経由で「会話を記憶する NPC」を作る

Posted at

はじめに

古より伝わる伝統的メタバース「Secondlife」内で、

  • プレイヤーの発言を理解し、
  • 過去の会話を記憶しながら、
  • キャラクター人格を保ったまま返答する

そんな 「会話を記憶する NPC」 を実装しました。

本記事では、
LSL → Vercel(Node.js) → ChatGPT(OpenAI API)
という構成で作った仕組みを解説します。

全体構成

image.png

なぜ記憶が必要か

OpenAI の API は、リクエスト単位では状態を保持しません
そのため NPC に自然な会話をさせるには、

  • 過去の発言履歴を保存
  • 次のリクエスト時に文脈として渡す
    という仕組みが必要になります。
    今回は Vercel KV (Upstash Redis) を使い、会話履歴を保持できるようにしました。

Vercel 側の構成

フォルダ構成

image.png

package.json

{
  "name": "[プロジェクト名]",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "@vercel/kv": "^3.0.0",
    "openai": "^6.16.0"
  }
}

メインの処理

全体の流れ

  • Secondlifeのワールド(LSL)から HTTP Request を受信
  • リクエストボディをパース
  • プレイヤー UUID を取得
  • Vercel KV から会話履歴を取得
  • system prompt + 履歴 + 今回の発言を生成
  • 生成したメッセージを ChatGPT に送信
  • ChatGPT から応答を受信
  • 会話履歴を更新して KV に保存
  • 応答テキストを LSL に返却
chat.js
import OpenAI from "openai";
import { kv } from "@vercel/kv";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

export default async function handler(req, res) {
  try {
    if (req.method !== "POST") {
      return res.status(405).send("Method Not Allowed");
    }

    let body = req.body;
    if (typeof body === "string") {
      body = JSON.parse(body);
    }

    if (!body || !body.message) {
      return res.status(400).send("No message");
    }

    const { message, avatar, uuid, sessionId } = body;

    const sid = sessionId || uuid || avatar || "default";
    const memoryKey = `gokigen:memory:${sid}`;

    /* 記憶消去コマンド*/
    const forgetTriggers = [
      "忘れて",
      "記憶を消",
      "今までの話を忘"
    ];

    const isForgetCommand = forgetTriggers.some(trigger =>
      message.includes(trigger)
    );

    if (isForgetCommand) {
      await kv.del(memoryKey);

      // それっぽい固定返答
      return res.status(200).send(
        "わかりました。これまでの話は胸の奥にしまっておきますね。"
      );
    }

    /* 通常会話処理 */
    let history = await kv.get(memoryKey);
    if (!Array.isArray(history)) {
      history = [];
    }

    const systemPrompt = `
あなたの名前は「Gokigen」です。
あなたは、Secondlifeというメタバース上に存在する住民です。
回答は必ず2文以内の短い文で返します。
`.trim();

    const messages = [
      { role: "system", content: systemPrompt },
      ...history,
      { role: "user", content: message }
    ];

    const completion = await openai.chat.completions.create({
      model: "gpt-4o-mini",
      messages,
      max_tokens: 120,
    });

    const reply = completion.choices[0].message.content;

    const newHistory = [
      ...history,
      { role: "user", content: message },
      { role: "assistant", content: reply }
    ].slice(-20);

    await kv.set(memoryKey, newHistory);

    res.status(200).send(reply);

  } catch (e) {
    console.error("SERVER ERROR:", e);
    res.status(500).json({
      error: {
        code: "500",
        message: e.message
      }
    });
  }
}

Vercel KV(Upstash Redis)の使用

OpenAI の API では、実行ごとに状態がリセットされます。
そのため、会話の記憶を持たせるに外部ストレージが必要になります。
Vercel KV(Upstash Redis)を使ってこれを実現しました。

値の構造

  • OpenAI APIのmessages形式をそのまま利用
  • role / contentの配列として保存

記憶の方針

  • 直近 20 件のみ保持
  • 古い会話は自動的に切り捨て
  • systemPromptで永続的な人格を与える
  • その他の永続的な記憶は持たせない

ChatGPT に渡す messages 構成

const messages = [
  { role: "system", content: systemPrompt },
  ...history,
  { role: "user", content: message }
];
  • system prompt は毎回先頭に固定
  • 人格(systemPrompt)と記憶(message)を分離して管理

応答後の履歴更新

const newHistory = [
  ...history,
  { role: "user", content: message },
  { role: "assistant", content: reply }
].slice(-20);
  • 会話数が増えすぎないよう 20 件に制限

KV へ履歴を保存

await kv.set(memoryKey, newHistory);

必要であれば expire を設定し、
一定時間アクセスがなければ自然に消えるようにする。

LSL 側の会話制御

反応条件

  • 通常チャット(0ch)を監視
  • 発言にAIの名前「Gokigen」 が含まれるときのみ反応
  • オーナーの発言のみを拾う
  • 無関係な会話には反応しない
script.lsl
string SERVER_URL = "https://[Vercelのプロジェクト名].vercel.app/api/chat";

integer listenHandle;
string sessionId;

default
{
    state_entry()
    {
        sessionId = (string)llGetOwner();

        // オーナーの通常チャットのみ listen
        listenHandle = llListen(
            0,                // channel
            "",               // name
            llGetOwner(),     // key
            ""                // message
        );

        llOwnerSay("Gokigen トリガー listen 開始(オーナー専用)");
        llOwnerSay("sessionId = " + sessionId);
    }

    listen(integer channel, string name, key id, string message)
    {
        // /コマンドは無視
        if (llGetSubString(message, 0, 0) == "/")
        {
            return;
        }

        // 「Gokigen」を含まない場合は無視
        if (llSubStringIndex(message, "Gokigen") == -1)
        {
            return;
        }

        // sessionId を明示的に送信
        string json = llList2Json(JSON_OBJECT, [
            "message", message,
            "avatar", name,
            "uuid", (string)id,
            "sessionId", sessionId
        ]);

        llOwnerSay("Gokigen トリガー検出 → 送信");

        llHTTPRequest(
            SERVER_URL,
            [
                HTTP_METHOD, "POST",
                HTTP_MIMETYPE, "application/json"
            ],
            json
        );
    }

    http_response(key request_id, integer status, list meta, string body)
    {
        if (status == 200)
        {
            llSay(0, body);

            // 応答後クールダウン(連投防止)
            llOwnerSay("10秒待機中...");
            llSleep(10.0);
            llOwnerSay("再入力可能");
        }
        else
        {
            llOwnerSay("HTTP error " + (string)status + ": " + body);
        }
    }

    on_rez(integer start_param)
    {
        llResetScript();
    }
}

実際に動かしてみて感じたこと

  • 直前の記憶があることで会話が続くため、住民のように振る舞ってくれる
  • system prompt を変えることでいろんな人格を作り出せる
    「前に言ってましたよね」
    の一言が返ってくるだけで、NPC の印象がずっと良くなります。

↓Secondlife内の「Gokigen」くん
image.png

まとめ

  • Vercelを通すことで、サーバーレス環境でも会話の文脈管理は可能
  • Vercel KV を使うことで NPC に記憶を持たせられる
  • 人格設計 × 記憶管理が NPC の完成度を高める
    Secondlife に「会話を記憶する NPC」を作る際の一例として、参考になれば幸いです。
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?