はじめに
古より伝わる伝統的メタバース「Secondlife」内で、
- プレイヤーの発言を理解し、
- 過去の会話を記憶しながら、
- キャラクター人格を保ったまま返答する
そんな 「会話を記憶する NPC」 を実装しました。
本記事では、
LSL → Vercel(Node.js) → ChatGPT(OpenAI API)
という構成で作った仕組みを解説します。
全体構成
なぜ記憶が必要か
OpenAI の API は、リクエスト単位では状態を保持しません。
そのため NPC に自然な会話をさせるには、
- 過去の発言履歴を保存
- 次のリクエスト時に文脈として渡す
という仕組みが必要になります。
今回は Vercel KV (Upstash Redis) を使い、会話履歴を保持できるようにしました。
Vercel 側の構成
フォルダ構成
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 の印象がずっと良くなります。
まとめ
- Vercelを通すことで、サーバーレス環境でも会話の文脈管理は可能
- Vercel KV を使うことで NPC に記憶を持たせられる
- 人格設計 × 記憶管理が NPC の完成度を高める
Secondlife に「会話を記憶する NPC」を作る際の一例として、参考になれば幸いです。


