feat(記憶與情緒快取): 記憶與情緒狀態加入 Redis 快取層,連不到時自動退回原行為

This commit is contained in:
Jeffery
2026-08-14 12:44:41 +08:00
parent 080ac390d3
commit 6ce7b2e800
5 changed files with 140 additions and 35 deletions
@@ -17,7 +17,7 @@ export class MemoryConsolidationService {
) {}
async consolidate(characterId: string, sessionId: string): Promise<void> {
const entries = this.workingMemory.getContext(sessionId);
const entries = await this.workingMemory.getContext(sessionId);
const contentCounts = new Map<string, number>();
for (const entry of entries) {
@@ -53,6 +53,6 @@ export class MemoryConsolidationService {
});
}
this.workingMemory.clear(sessionId);
await this.workingMemory.clear(sessionId);
}
}
+5 -5
View File
@@ -27,20 +27,20 @@ export class MemoryController {
) {}
@Post(":characterId/sessions/:sessionId/messages")
appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
this.workingMemory.append(sessionId, {
async appendMessage(@Param("sessionId") sessionId: string, @Body() body: AppendMessageBody) {
await this.workingMemory.append(sessionId, {
role: body.role,
content: body.content,
timestamp: new Date(),
emotionTag: body.emotionTag,
emotionIntensity: body.emotionIntensity,
});
return { context: this.workingMemory.getContext(sessionId) };
return { context: await this.workingMemory.getContext(sessionId) };
}
@Get(":characterId/sessions/:sessionId/messages")
getContext(@Param("sessionId") sessionId: string) {
return { context: this.workingMemory.getContext(sessionId) };
async getContext(@Param("sessionId") sessionId: string) {
return { context: await this.workingMemory.getContext(sessionId) };
}
@Post(":characterId/sessions/:sessionId/consolidate")
+69 -28
View File
@@ -1,6 +1,7 @@
import { Injectable } from "@nestjs/common";
import type { EmotionTag } from "@kokorone/shared";
import { HIGH_EMOTION_THRESHOLD } from "./constants.js";
import { getRedisClient } from "../cache/redis-client.js";
export interface WorkingMemoryEntry {
role: "user" | "character";
@@ -10,7 +11,14 @@ export interface WorkingMemoryEntry {
emotionIntensity?: number; // 0~1,未標記情緒的訊息(如快速通道問候)可省略
}
interface SerializedEntry extends Omit<WorkingMemoryEntry, "timestamp"> {
timestamp: string;
}
const DEFAULT_TOKEN_LIMIT = 200;
const REDIS_KEY_PREFIX = "kokorone:working-memory:";
// session 閒置這麼久沒有新訊息就任由 Redis 自然過期,避免無限累積殭屍 session。
const SESSION_TTL_SECONDS = 60 * 60 * 6;
// 粗略估算:Mock 階段不需要真實 tokenizer,先以字元數/2 近似。
function estimateTokens(text: string): number {
@@ -21,41 +29,74 @@ function isHighEmotion(entry: WorkingMemoryEntry): boolean {
return (entry.emotionIntensity ?? 0) >= HIGH_EMOTION_THRESHOLD;
}
// C-1 工作記憶:session 對話上下文緩衝,只存在於行程記憶體中(如同海馬迴暫存),
// 不落地到任何長期記憶表——長期寫入只能由 C-4 睡眠固化觸發。
function evictOverflow(entries: WorkingMemoryEntry[], tokenLimit: number): WorkingMemoryEntry[] {
let result = entries;
let totalTokens = result.reduce((sum, e) => sum + estimateTokens(e.content), 0);
while (totalTokens > tokenLimit && result.length > 0) {
// 最舊的低情緒段落先被裁掉;若全部都是高情緒,最後才犧牲最舊的一則以確保不超出上限。
let evictIndex = result.findIndex((e) => !isHighEmotion(e));
if (evictIndex === -1) {
evictIndex = 0;
}
const [evicted] = result.splice(evictIndex, 1);
totalTokens -= estimateTokens(evicted.content);
}
return result;
}
function serialize(entries: WorkingMemoryEntry[]): string {
const payload: SerializedEntry[] = entries.map((e) => ({ ...e, timestamp: e.timestamp.toISOString() }));
return JSON.stringify(payload);
}
function deserialize(raw: string): WorkingMemoryEntry[] {
const payload = JSON.parse(raw) as SerializedEntry[];
return payload.map((e) => ({ ...e, timestamp: new Date(e.timestamp) }));
}
// C-1 工作記憶:session 對話上下文緩衝,如同海馬迴暫存,長期寫入只能由 C-4 睡眠固化觸發。
// R-2:改走 Redis(session 快取),Redis 不可用時退回行程記憶體 Map,行為(token 上限、高情緒優先保留)不變。
@Injectable()
export class WorkingMemoryService {
private readonly sessions = new Map<string, WorkingMemoryEntry[]>();
private readonly fallback = new Map<string, WorkingMemoryEntry[]>();
private readonly tokenLimit = DEFAULT_TOKEN_LIMIT;
append(sessionId: string, entry: WorkingMemoryEntry): void {
const entries = this.sessions.get(sessionId) ?? [];
async append(sessionId: string, entry: WorkingMemoryEntry): Promise<void> {
const entries = await this.getContext(sessionId);
entries.push(entry);
this.sessions.set(sessionId, this.evictOverflow(entries));
}
const evicted = evictOverflow(entries, this.tokenLimit);
getContext(sessionId: string): WorkingMemoryEntry[] {
return [...(this.sessions.get(sessionId) ?? [])];
}
clear(sessionId: string): void {
this.sessions.delete(sessionId);
}
private evictOverflow(entries: WorkingMemoryEntry[]): WorkingMemoryEntry[] {
let result = entries;
let totalTokens = result.reduce((sum, e) => sum + estimateTokens(e.content), 0);
while (totalTokens > this.tokenLimit && result.length > 0) {
// 最舊的低情緒段落先被裁掉;若全部都是高情緒,最後才犧牲最舊的一則以確保不超出上限。
let evictIndex = result.findIndex((e) => !isHighEmotion(e));
if (evictIndex === -1) {
evictIndex = 0;
}
const [evicted] = result.splice(evictIndex, 1);
totalTokens -= estimateTokens(evicted.content);
const redis = getRedisClient();
try {
await redis.set(REDIS_KEY_PREFIX + sessionId, serialize(evicted), "EX", SESSION_TTL_SECONDS);
this.fallback.delete(sessionId);
return;
} catch {
this.fallback.set(sessionId, evicted);
}
}
return result;
async getContext(sessionId: string): Promise<WorkingMemoryEntry[]> {
const redis = getRedisClient();
try {
const raw = await redis.get(REDIS_KEY_PREFIX + sessionId);
if (raw !== null) return deserialize(raw);
if (this.fallback.has(sessionId)) return [...this.fallback.get(sessionId)!];
return [];
} catch {
return [...(this.fallback.get(sessionId) ?? [])];
}
}
async clear(sessionId: string): Promise<void> {
this.fallback.delete(sessionId);
const redis = getRedisClient();
try {
await redis.del(REDIS_KEY_PREFIX + sessionId);
} catch {
// Redis 不可用時 fallback 已經在上面清掉,沒有需要額外處理的狀態。
}
}
}