diff --git a/src/agent/agent.ts b/src/agent/agent.ts index c3e77d5..9d14f91 100644 --- a/src/agent/agent.ts +++ b/src/agent/agent.ts @@ -14,7 +14,7 @@ import { ToolName } from "../tool/tool"; import Tool from "../tool/tool"; import { ToolInfo } from "../tool/types"; import { requestLimiter } from "../utils/concurrency"; -import { handleMessages } from "../utils/message"; +import { buildSystemMessage, handleMessages } from "../utils/message"; import { checkRepeat, handleReply } from "../utils/string"; import { revive, TypeDescriptor } from "../utils/utils"; @@ -107,12 +107,16 @@ export default class Agent { const toolInfos = Tool.getToolsInfo(session); const trace = new AgentRunContext(); + // system prompt 在同一轮工具循环内复用:避免每轮工具回调后重复做记忆检索/嵌入, + // 只在工具回调后更新 context messages(上下文仍随工具结果增长)。 + const systemMessage = await buildSystemMessage(ctx, session); + let result: { contextArray: string[], replyArray: string[], images: Image[] } = { contextArray: [], replyArray: [], images: [] }; const MaxRetry = 3; for (let retry = 1; retry <= MaxRetry; retry++) { trace.beginTurn(); - const messages = await handleMessages(ctx, session, this.isMultimodalChat(session)); + const messages = await handleMessages(ctx, session, this.isMultimodalChat(session), toolInfos || [], systemMessage); const { content: raw_reply, tool_calls } = await streamService.sendChatRequest(messages, toolInfos || [], tool_choice || 'auto', session.setting.modelName); // 提示词工程模式下模型可能返回 ```function ... ``` 代码块包裹的工具调用: // 发送前先剥离该块,避免代码块原文进入回复/上下文;调用内容仍以 match[0] 原样记录 diff --git a/src/agent/api.ts b/src/agent/api.ts index ddf6450..62e71b7 100644 --- a/src/agent/api.ts +++ b/src/agent/api.ts @@ -60,10 +60,8 @@ export interface AgentGlobalApi { registerTool(info: ToolInfo, options?: RegisterToolOptions): boolean; } -/** 把智能体 API 挂载到 globalThis,重复加载/重载时保持幂等 */ +/** 把智能体 API 挂载到 globalThis;JS 重载时直接覆盖为新版本对象,避免外部插件拿到旧 API */ export function registerAgentApi(): void { - if ((globalThis as any)[AGENT_GLOBAL_NAME]) return; - const api: AgentGlobalApi = { name: NAME, version: VERSION, diff --git a/src/agent/stream.ts b/src/agent/stream.ts index d9adf5b..7dfa451 100644 --- a/src/agent/stream.ts +++ b/src/agent/stream.ts @@ -8,27 +8,70 @@ import Model from "../model/model"; import { requestModel } from "../model/provider"; import { ToolCall } from "../tool/types"; import { UsageManager } from "../usage"; -import { RequestMessage } from "../utils/message"; +import { estimateTextTokens, RequestMessage } from "../utils/message"; import { withTimeout } from "../utils/utils"; /** - * 请求体消息净化(防御层):剔除空 tool_calls 数组与缺少 tool_call_id 的 tool 消息。 + * 请求体消息净化(防御层):一次遍历同时完成—— + * 1) 删除空 tool_calls 数组; + * 2) 删除没有匹配 assistant tool_calls 的 tool 消息(缺 tool_call_id 或引用不存在的调用); + * 3) 删除 assistant tool_calls 中引用不存在 tool 结果的调用项。 * handleMessages 已保证正常路径不产生这些脏数据,此处兜底外部调用(如其他插件经 * globalThis.aiplugin4.chatMessages 传入的 messages)与历史持久化数据。 */ function sanitizeRequestMessages(messages: any[]): any[] { - return (messages || []).filter(m => { - if (m && m.role === 'tool' && !m.tool_call_id) { - logger.warning('剔除缺少 tool_call_id 的 tool 消息(避免请求报错)'); - return false; + const list = (messages || []).filter(m => m && typeof m === 'object'); + if (list.length === 0) return []; + + // 先收集两侧引用:tool 结果携带的 tool_call_id 与 assistant 声明的 tool_call id + const toolResultIds = new Set(); + const assistantCallIds = new Set(); + for (const m of list) { + if (m.role === 'tool' && m.tool_call_id) toolResultIds.add(m.tool_call_id); + if (m.role === 'assistant' && Array.isArray(m.tool_calls)) { + for (const tc of m.tool_calls) if (tc && tc.id) assistantCallIds.add(tc.id); } - return true; - }).map(m => { - if (!m || typeof m !== 'object') return m; + } + + const result: any[] = []; + for (const m of list) { const out = { ...m }; if (Array.isArray(out.tool_calls) && out.tool_calls.length === 0) delete out.tool_calls; - return out; - }); + + if (out.role === 'tool') { + const id = out.tool_call_id; + if (!id || !assistantCallIds.has(id)) { + logger.warning('剔除没有匹配 assistant tool_calls 的 tool 消息'); + continue; + } + result.push(out); + continue; + } + + if (out.role === 'assistant' && Array.isArray(out.tool_calls) && out.tool_calls.length > 0) { + const kept = out.tool_calls.filter((tc: any) => tc && tc.id && toolResultIds.has(tc.id)); + if (kept.length === 0) { + logger.warning('剔除引用不存在 tool 结果的 assistant tool_calls'); + delete out.tool_calls; + } else if (kept.length < out.tool_calls.length) { + logger.warning('剔除部分引用不存在 tool 结果的 assistant tool_call'); + out.tool_calls = kept; + } + } + result.push(out); + } + return result; +} + +/** 发送前校验整包预算:估算 messages + tools 的 token 总量,超出「上下文最大token」时告警 */ +function checkRequestBudget(messages: any[], tools: any[]): void { + const { MAX_CONTEXT_TOKENS: maxTokens } = Config.message; + if (maxTokens <= 0) return; + const toolsEstimate = tools && tools.length > 0 ? estimateTextTokens(JSON.stringify(tools)) : 0; + const estimate = estimateTextTokens(JSON.stringify(messages || [])) + toolsEstimate; + if (estimate > maxTokens) { + logger.warning(`请求体估算 token(含 tools JSON)超出「上下文最大token」预算: ${estimate} / ${maxTokens}`); + } } export class streamService { @@ -118,6 +161,7 @@ export class streamService { if (tools && tools.length > 0) body.tools = tools; body.tool_choice = tool_choice; } + checkRequestBudget(body.messages, tools || []); logger.printRequestMessages(body.messages); const time = Date.now(); diff --git a/src/cmd/sub_cmd/ctxn.ts b/src/cmd/sub_cmd/ctxn.ts index cd9c230..88f7a2f 100644 --- a/src/cmd/sub_cmd/ctxn.ts +++ b/src/cmd/sub_cmd/ctxn.ts @@ -1,4 +1,5 @@ // .ai ctxn:上下文内名字自动修改相关 +import { logger } from "../../logger"; import { aliasToCmd } from "../../utils/utils"; import { I, U } from "../privilege"; import { SubCmd, SubCmdContext } from "../root_cmd"; @@ -40,6 +41,9 @@ export function registerCmdCtxn() { const promises = session.context.userInfoList.map(ui => session.context.setName(epId, gid, ui.id, mod)); Promise.all(promises).then(() => { seal.replyToSender(ctx, msg, `设置完成,上下文里的名字有:\n${session.context.userInfoList.map(uni => `${uni.name}(${uni.id})`).join('\n')}`); + }).catch((e: any) => { + logger.error(`批量设置上下文名字出错,错误信息:${e instanceof Error ? e.message : String(e)}`); + seal.replyToSender(ctx, msg, '批量设置名字失败,请查看日志'); }); return ret; } diff --git a/src/config/configs/message.ts b/src/config/configs/message.ts index 155f7fb..df9465d 100644 --- a/src/config/configs/message.ts +++ b/src/config/configs/message.ts @@ -1,4 +1,5 @@ // 对话配置:角色设定/示例对话/轮数/插入间隔/压缩阈值 +import { logger } from "../../logger"; import { ext } from "../config"; export default class MessageConfig { @@ -17,14 +18,16 @@ export default class MessageConfig { static get() { const INSTRUCTIONS = seal.ext.getTemplateConfig(ext, "角色扮演设定"); + const MAX_ROUNDS = seal.ext.getIntConfig(ext, "对话保存轮数"); + const INSERT_COUNT = normalizeInsertCount(seal.ext.getIntConfig(ext, "插入system message间隔轮数"), MAX_ROUNDS); return { INSTRUCTIONS, ROLE_NAMES: parseRoleNames(INSTRUCTIONS), ROLE_SETTINGS: parseRoleSettings(INSTRUCTIONS), SAMPLE_MESSAGES: seal.ext.getTemplateConfig(ext, "示例对话"), - MAX_ROUNDS: seal.ext.getIntConfig(ext, "对话保存轮数"), + MAX_ROUNDS, MAX_CONTEXT_TOKENS: seal.ext.getIntConfig(ext, "上下文最大token"), - INSERT_COUNT: seal.ext.getIntConfig(ext, "插入system message间隔轮数"), + INSERT_COUNT, COMPRESS_THRESHOLD: seal.ext.getIntConfig(ext, "消息压缩阈值") } } @@ -32,6 +35,19 @@ export default class MessageConfig { const ROLE_NAME_MAX_LENGTH = 20; +/** + * 插入 system message 间隔轮数校验:必须 > 0 且小于「对话保存轮数」的一半才生效, + * 否则按关闭(0)处理并告警,避免插入频率超过历史窗口导致 system 消息占满上下文。 + */ +function normalizeInsertCount(raw: number, maxRounds: number): number { + if (raw <= 0) return 0; + if (maxRounds > 0 && raw * 2 >= maxRounds) { + logger.warning(`「插入system message间隔轮数」${raw} 未小于「对话保存轮数」${maxRounds} 的二分之一,已按关闭处理`); + return 0; + } + return raw; +} + /** * 解析单个角色设定条目:第一行为角色设定名称(超过 20 字符自动截断),其余为设定内容。 * 兼容旧格式:整条只有一行时,整条内容作为设定,首行截断作为名称。 diff --git a/src/memory/knowledge_base.ts b/src/memory/knowledge_base.ts index 60ed753..8f93f09 100644 --- a/src/memory/knowledge_base.ts +++ b/src/memory/knowledge_base.ts @@ -71,6 +71,13 @@ export class KnowledgeBaseService { return this.chunks.length === 0; } + /** prompt 缓存版本:基于当前配置开关/阈值/全部条目内容生成,配置变化后自然产生新 key */ + getCacheVersion(): string { + const items = Array.isArray(Config.memory.KNOWLEDGE_ITEMS) ? Config.memory.KNOWLEDGE_ITEMS : []; + const signature = items.map(item => hashString(item || '')).join(','); + return `${Config.memory.KNOWLEDGE ? '1' : '0'}|${Config.memory.KNOWLEDGE_INJECT_THRESHOLD}|${items.length}|${signature}`; + } + /** 全部条目索引(id/标题/小节) */ list(): KnowledgeChunk[] { return this.chunks; diff --git a/src/memory/memory.ts b/src/memory/memory.ts index bdb206f..af517f4 100644 --- a/src/memory/memory.ts +++ b/src/memory/memory.ts @@ -13,6 +13,7 @@ import { stripInternalTags } from "../utils/string"; import { generateId, getCommonItem, revive, TypeDescriptor } from "../utils/utils"; import MemoryItem from "./memory_item"; +import { bumpMemoryRevision } from "./revision"; import { MemorySource, searchOptions } from "./types"; export default class MemoryService { @@ -111,11 +112,18 @@ export default class MemoryService { clearMemory() { this.memoryMap = {}; + bumpMemoryRevision(); } deleteMemory(ids: string[] = [], kws: string[] = []) { + const before = this.memories.length; // 按 id 精确删除(复用 deleteMemories 的严格匹配路径) - if (ids.length > 0) this.deleteMemories(ids); + let bumpedByDeleteMemories = false; + if (ids.length > 0) { + const beforeIds = this.memories.length; + this.deleteMemories(ids); + bumpedByDeleteMemories = this.memories.length !== beforeIds; + } // 按关键词宽松删除:命中任一关键词即删除 if (kws.length > 0) { for (const id in this.memoryMap) { @@ -124,6 +132,7 @@ export default class MemoryService { } } } + if (!bumpedByDeleteMemories && this.memories.length !== before) bumpMemoryRevision(); } async addMemory(_ctx: seal.MsgContext | null, session: Session, ul: UserInfo[], gl: GroupInfo[], kws: string[], images: Image[], text: string, visibility: 'public' | 'private' = 'public') { @@ -149,6 +158,7 @@ export default class MemoryService { await m.updateVector(); this.limitMemory(); this.memoryMap[id] = m; + bumpMemoryRevision(); } limitMemory() { @@ -161,7 +171,10 @@ export default class MemoryService { const listText = ml.map((m, i) => (i + 1) + '. [' + m.id + '] ' + m.content ).join('\n'); - return '私聊:' + si.isPrivate + '\n群聊名称:' + si.name + '\n记忆列表:\n' + listText; + if (si.isPrivate) { + return '记忆类型:个人记忆\n记忆列表:\n' + listText; + } + return '记忆类型:群聊记忆\n群聊名称:' + si.name + '\n记忆列表:\n' + listText; } getLatestMemoryListText(si: SessionInfo, p: number = 1): string { @@ -266,6 +279,7 @@ export default class MemoryService { await Promise.all(memoriesToAdd.map(async m => await m.updateVector())); this.limitMemories(memoriesToAdd.length); memoriesToAdd.forEach(m => this.memoryMap[m.id] = m); + bumpMemoryRevision(); } /** @@ -279,6 +293,7 @@ export default class MemoryService { */ deleteMemories(ids: string[] = [], tags: string[] = [], relatedMemories: string[] = [], users: string[] = [], groups: string[] = []) { if (ids.length === 0 && tags.length === 0 && relatedMemories.length === 0 && users.length === 0 && groups.length === 0) return; + const before = this.memories.length; if (ids.length > 0) { ids.forEach(id => { @@ -303,6 +318,7 @@ export default class MemoryService { ) delete this.memoryMap[id]; } } + if (this.memories.length !== before) bumpMemoryRevision(); } limitMemories(vacancy: number) { @@ -323,6 +339,7 @@ export default class MemoryService { clearMemories() { this.memoryMap = {}; + bumpMemoryRevision(); } private static lastEmbeddingWarnAt = 0; diff --git a/src/memory/revision.ts b/src/memory/revision.ts new file mode 100644 index 0000000..795b9f1 --- /dev/null +++ b/src/memory/revision.ts @@ -0,0 +1,24 @@ +// 记忆缓存版本:记忆/总结内容变更后自增,并主动失效对应 prompt 缓存。 +// 使用全局版本而非逐实例版本,因为一次总结可能写入多个会话的记忆,任何写入都应刷新所有相关缓存。 +import { invalidateCachedPrefix } from "../prompt/prompt_cache"; + +let memoryRevision = 0; +let summaryRevision = 0; + +export function getMemoryRevision(): number { + return memoryRevision; +} + +export function bumpMemoryRevision(): void { + memoryRevision++; + invalidateCachedPrefix('prompt:memory:'); +} + +export function getSummaryRevision(): number { + return summaryRevision; +} + +export function bumpSummaryRevision(): void { + summaryRevision++; + invalidateCachedPrefix('prompt:summary:'); +} diff --git a/src/memory/session_memory.ts b/src/memory/session_memory.ts index dcdcbc2..5e08529 100644 --- a/src/memory/session_memory.ts +++ b/src/memory/session_memory.ts @@ -13,6 +13,7 @@ import { TypeDescriptor } from "../utils/utils"; import MemoryService from "./memory"; import MemoryItem from "./memory_item"; +import { bumpSummaryRevision } from "./revision"; export default class SessionMemoryService extends MemoryService { static validKeysMap: { [key in keyof SessionMemoryService]?: TypeDescriptor } = { @@ -110,6 +111,7 @@ export default class SessionMemoryService extends MemoryService { // 同时写入总结记忆,供 buildSummaryPrompt 使用 this.summaries.push(summaryContent); this.limitSummaries(); + bumpSummaryRevision(); // 与 add_memory 工具一致:按 memory_type/name 决定记忆归属(个人→目标用户会话,群聊→目标群会话)。 // 模型不保证遵守模板:memories 缺失/非数组时跳过落库(摘要仍保留),逐条定位失败仅跳过该条, @@ -210,6 +212,7 @@ export default class SessionMemoryService extends MemoryService { clearSummaries() { this.summaries = []; + bumpSummaryRevision(); } buildSummaryPrompt(): string { diff --git a/src/model/adapter.ts b/src/model/adapter.ts index dbd31d5..0ee1171 100644 --- a/src/model/adapter.ts +++ b/src/model/adapter.ts @@ -98,7 +98,7 @@ function buildAnthropicMessages(messages: any[]): { system: string, messages: an for (const msg of messages || []) { const role = msg.role; if (role === 'system') { - const text = typeof msg.content === 'string' ? msg.content : ''; + const text = extractSystemText(msg.content); if (text) systemParts.push(text); continue; } @@ -153,6 +153,18 @@ function buildAnthropicMessages(messages: any[]): { system: string, messages: an return { system: systemParts.join('\n'), messages: merged }; } +/** 提取 system 消息文本:支持纯字符串与 OpenAI 多模态内容块数组(仅拼接 text 块) */ +function extractSystemText(content: any): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter((b: any) => b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string') + .map((b: any) => b.text) + .join('\n'); + } + return ''; +} + function mergeAnthropicContent(a: any, b: any): any { const toBlocks = (c: any): any[] => typeof c === 'string' ? [{ type: 'text', text: c }] : (Array.isArray(c) ? c : []); return toBlocks(a).concat(toBlocks(b)); diff --git a/src/model/embedding.ts b/src/model/embedding.ts index 775a65d..6e9e9a7 100644 --- a/src/model/embedding.ts +++ b/src/model/embedding.ts @@ -7,7 +7,8 @@ import { requestModel } from "./provider"; import { EmbeddingModelUse, ModelBody, ModelUse } from "./types"; export default class EmbeddingModel extends BaseModel { - static vectorCache: { text: string, vector: number[] } = { text: '', vector: [] }; + /** 按模型名隔离的最近一次嵌入缓存,避免不同嵌入模型(同维度)互相串向量 */ + static vectorCache: { [model: string]: { text: string, vector: number[] } } = {}; use: EmbeddingModelUse[]; constructor(use: ModelUse[], name: string, provider: string, base_url: string, api_key: string, body: ModelBody) { @@ -26,8 +27,9 @@ export default class EmbeddingModel extends BaseModel { } const dimension = { ...DEFAULT_EMBEDDING_MODEL_BODY, ...this.body }.dimensions; - if (EmbeddingModel.vectorCache.text === text && EmbeddingModel.vectorCache.vector.length === dimension) { - const v = EmbeddingModel.vectorCache.vector; + const cache = EmbeddingModel.vectorCache[this.name]; + if (cache && cache.text === text && cache.vector.length === dimension) { + const v = cache.vector; return v; } @@ -43,8 +45,7 @@ export default class EmbeddingModel extends BaseModel { const embedding = data.data[0].embedding; logger.info(`文本:`, text.length > 200 ? text.slice(0, 200) + `…(+${text.length - 200})` : text, `\n响应embedding长度:`, embedding.length, '\nlatency:', Date.now() - time, 'ms'); - EmbeddingModel.vectorCache.text = text; - EmbeddingModel.vectorCache.vector = embedding; + EmbeddingModel.vectorCache[this.name] = { text, vector: embedding }; return embedding; } else { diff --git a/src/pipeline.ts b/src/pipeline.ts index 996626b..3df9083 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -12,10 +12,15 @@ import { expandMilkySegments, MessageSegment, parseCardToText, parseMusicToText, /** 海豹核心原生 milky 接收路径会过滤掉的段类型,只能通过 ob11 依赖的事件分发(milky → OB11 转接)收到 */ const OB11_EXTRA_SEGMENT_TYPES = new Set(['record', 'json', 'video', 'file', 'node', 'forward', 'music', 'xml', 'markdown', 'market_face']); +/** 消息节点/合并转发展开的最大嵌套深度,防止恶意或异常嵌套导致无限递归 */ +const MAX_FORWARD_DEPTH = 5; export class MessagePipeline { /** ob11 数组消息段 → MessageSegment[]:把卡片/视频/音乐/文件/消息节点/合并转发展开为文本段,其余段保留 */ - private static async expandOb11Segments(ctx: seal.MsgContext, segs: any[]): Promise { + private static async expandOb11Segments(ctx: seal.MsgContext, segs: any[], depth: number = 0): Promise { + if (depth > MAX_FORWARD_DEPTH) { + return [{ type: 'text', data: { text: '[消息嵌套过深,已截断]' } }]; + } const result: MessageSegment[] = []; const epId = ctx.endPoint.userId; for (const seg of segs) { @@ -45,11 +50,11 @@ export class MessagePipeline { break; } case 'node': { - result.push({ type: 'text', data: { text: await MessagePipeline.parseNodeToText(ctx, data) } }); + result.push({ type: 'text', data: { text: await MessagePipeline.parseNodeToText(ctx, data, depth + 1) } }); break; } case 'forward': { - const text = await expandForwardMessage(epId, data.id || data.file || ''); + const text = await expandForwardMessage(epId, data.id || data.file || '', depth + 1); result.push({ type: 'text', data: { text: text ? `【合并转发】\n${text}` : '[合并转发消息,展开失败]' } @@ -63,13 +68,16 @@ export class MessagePipeline { } /** 消息节点(node)转可读文本:完整节点递归内容,仅 id 时走 ob11 获取 */ - private static async parseNodeToText(ctx: seal.MsgContext, data: any): Promise { + private static async parseNodeToText(ctx: seal.MsgContext, data: any, depth: number = 0): Promise { const name = (data && (data.nickname || data.name)) || (data && data.user_id ? `用户${data.user_id}` : ''); + if (depth > MAX_FORWARD_DEPTH) { + return `【消息节点】${name}(嵌套过深,已截断)`; + } if (data && typeof data.content === 'string') { return `${name}: ${data.content}`; } if (data && Array.isArray(data.content)) { - const segs = await this.expandOb11Segments(ctx, data.content); + const segs = await this.expandOb11Segments(ctx, data.content, depth + 1); let text = ''; for (const s of segs) { text += s.type === 'text' ? ((s.data && s.data.text) || '') : `[${s.type}]`; @@ -77,7 +85,7 @@ export class MessagePipeline { return `${name}: ${text}`; } if (data && data.id) { - const text = await expandForwardMessage(ctx.endPoint.userId, String(data.id)); + const text = await expandForwardMessage(ctx.endPoint.userId, String(data.id), depth + 1); return text ? `${name}:\n${text}` : `【消息节点】${name}`; } return `【消息节点】${name}`; @@ -293,7 +301,9 @@ export class MessagePipeline { if (setting.timer > -1) { session.context.timer = setTimeout(() => { session.context.timer = null; - session.chat(ctx, msg, '计时器'); + session.chat(ctx, msg, '计时器').catch((e: any) => { + logger.error(`计时器触发对话出错,错误信息:${e instanceof Error ? e.message : String(e)}`); + }); }, setting.timer * 1000 + Math.floor(Math.random() * 500)); } }) @@ -326,7 +336,9 @@ export class MessagePipeline { if (CQTypes.length === 0 || CQTypes.every(item => CQ_TYPES_ALLOW.includes(item))) { const setting = session.setting; if (setting.standby) { - session.handleReceipt(ctx, msg, messageArray).then(() => session.save()); + session.handleReceipt(ctx, msg, messageArray).then(() => session.save()).catch((e: any) => { + logger.error(`指令消息入库出错,错误信息:${e instanceof Error ? e.message : String(e)}`); + }); } } } @@ -362,7 +374,9 @@ export class MessagePipeline { if (CQTypes.length === 0 || CQTypes.every(item => CQ_TYPES_ALLOW.includes(item))) { const setting = session.setting; if (setting.standby) { - session.handleReceipt(ctx, msg, messageArray).then(() => session.save()); + session.handleReceipt(ctx, msg, messageArray).then(() => session.save()).catch((e: any) => { + logger.error(`机器人消息入库出错,错误信息:${e instanceof Error ? e.message : String(e)}`); + }); } } } diff --git a/src/prompt/builder.ts b/src/prompt/builder.ts index 709456d..1e1803c 100644 --- a/src/prompt/builder.ts +++ b/src/prompt/builder.ts @@ -1,8 +1,12 @@ // prompt 构建:system prompt 分节组装(角色/会话信息/能力/记忆/知识) -import Config from "../config/config"; +import Config, { ext } from "../config/config"; +import { VECTOR_SIMILARITY } from "../config/static_config"; import Message from "../context/message"; import { UserMessage, UserMessageItem } from "../context/types"; +import { knowledgeService } from "../memory/knowledge"; import { MemoryManager } from "../memory/manager"; +import { getMemoryRevision, getSummaryRevision } from "../memory/revision"; +import Model from "../model/model"; import { Session } from "../session/session"; import { GroupInfo, UserInfo } from "../session/types"; import User from "../session/user"; @@ -10,6 +14,7 @@ import { getSkillSummaries } from "../tool/skills"; import Tool from "../tool/tool"; import { fmtDate, stripInternalTags } from "../utils/string"; +import { getCachedString } from "./prompt_cache"; import { SYSTEM_MESSAGE_TEMPLATE } from "./templates"; export interface SystemPromptSection { @@ -17,6 +22,31 @@ export interface SystemPromptSection { content: string; } +const STATIC_FRAME_TTL = 30_000; +const LONG_TERM_MEMORY_TTL = 10_000; +const SUMMARY_TTL = 60_000; +const KNOWLEDGE_TTL = 60_000; + +function signature(parts: Array): string { + return parts.map(String).join('|'); +} + +function localResourceSignature(): string { + return signature([ + (Config.resource.LOCAL_IMAGES || []).map(img => img.imageId).join(','), + (Config.resource.LOCAL_AUDIOS || []).map(a => a.audioId).join(','), + (Config.resource.LOCAL_FILES || []).map(f => f.fileId).join(','), + (Config.resource.LOCAL_VIDEOS || []).map(v => v.videoId).join(',') + ]); +} + +function toolStateSignature(session: Session): string { + return Object.keys(session.toolState) + .sort() + .map(key => `${key}:${session.toolState[key] ? '1' : '0'}`) + .join(','); +} + /** * 组装 system prompt 内容。 * 各动态段(长期记忆/总结记忆/知识库/工具与技能)按开关独立构建后, @@ -31,12 +61,6 @@ export async function buildSystemPromptContent( const { RECEIVE_IMAGE } = Config.received; const { STATUS, PROMPT_ENGINEERING } = Config.tool; - // 本地可发送资源(图片/语音/文件/视频)来自“资源”配置 - const localImages = (Config.resource.LOCAL_IMAGES || []).map(img => ({ imageId: img.imageId })); - const localAudios = Config.resource.LOCAL_AUDIOS || []; - const localFiles = (Config.resource.LOCAL_FILES || []).map(f => ({ fileId: f.fileId })); - const localVideos = (Config.resource.LOCAL_VIDEOS || []).map(v => ({ videoId: v.videoId })); - // 取最近 2~3 条用户消息拼接,作为记忆/知识库查询的上下文(剥离内部标签) const userMessages = session.context.messages.filter(m => m.role === 'user'); let text = '', ui: UserInfo | null = null, gi: GroupInfo | null = null; @@ -55,38 +79,98 @@ export async function buildSystemPromptContent( if (!ctx.isPrivate && ctx.group) { gi = { isPrivate: false, id: ctx.group.groupId, name: ctx.group.groupName }; } + // 限制记忆检索 query 长度:优先保留最近内容,避免超长合并消息/合并转发完整送入 embedding + if (text.length > 2000) text = text.slice(-2000); - // 记忆段:长期记忆 + 总结记忆 + 知识库(统一由 MemoryManager 按开关构建) - const memoryPrompt = await MemoryManager.buildLongTermPrompt(ctx, session, text, ui || null, gi || null); - const summaryPrompt = MemoryManager.buildSummaryPrompt(session); - const knowledgePrompt = await MemoryManager.buildKnowledgePrompt(session, text); + // 静态壳:角色/平台/会话/本地资源/工具与技能,连续对话可复用 30 秒。 + // key 读取原始技能配置避免每次缓存命中都解析/打印错误;真正解析在缓存未命中时执行。 + const toolState = STATUS && PROMPT_ENGINEERING ? toolStateSignature(session) : ''; + const skillConfigSignature = STATUS ? seal.ext.getTemplateConfig(ext, "技能配置").join('\n') : ''; + const staticKey = signature([ + 'prompt:static', + roleSetting, + ctx.endPoint.platform, + ctx.isPrivate ? 'private' : 'group', + ctx.isPrivate ? ctx.player!.name : ctx.group!.groupName, + ctx.isPrivate ? ctx.player!.userId : ctx.group!.groupId, + RECEIVE_IMAGE, + localResourceSignature(), + STATUS, + PROMPT_ENGINEERING, + Config.tool.BLOCKED.join(','), + Config.tool.DEFAULT_CLOSED.join(','), + toolState, + skillConfigSignature + ]); + const frame = await getCachedString(staticKey, STATIC_FRAME_TTL, () => { + const skillSummaries = getSkillSummaries(); + const localImages = (Config.resource.LOCAL_IMAGES || []).map(img => ({ imageId: img.imageId })); + const localAudios = Config.resource.LOCAL_AUDIOS || []; + const localFiles = (Config.resource.LOCAL_FILES || []).map(f => ({ fileId: f.fileId })); + const localVideos = (Config.resource.LOCAL_VIDEOS || []).map(v => ({ videoId: v.videoId })); + const toolPrompt = STATUS && PROMPT_ENGINEERING ? Tool.getToolsInfoPrompt(session) : ''; - // 能力段:工具函数 + 可用技能(MCP 工具已并入工具列表) - const toolPrompt = STATUS && PROMPT_ENGINEERING ? Tool.getToolsInfoPrompt(session) : ''; + let content = SYSTEM_MESSAGE_TEMPLATE({ + instruction: roleSetting, + platform: ctx.endPoint.platform, + sessionType: ctx.isPrivate ? 'private' : 'group', + sessionName: ctx.isPrivate ? ctx.player!.name : ctx.group!.groupName, + sessionId: ctx.isPrivate ? ctx.player!.userId : ctx.group!.groupId, + RECEIVE_IMAGE, + LOCAL_IMAGES: localImages, + LOCAL_AUDIOS: localAudios, + LOCAL_FILES: localFiles, + LOCAL_VIDEOS: localVideos, + toolPrompt + }); - let content = SYSTEM_MESSAGE_TEMPLATE({ - instruction: roleSetting, - platform: ctx.endPoint.platform, - sessionType: ctx.isPrivate ? 'private' : 'group', - sessionName: ctx.isPrivate ? ctx.player!.name : ctx.group!.groupName, - sessionId: ctx.isPrivate ? ctx.player!.userId : ctx.group!.groupId, - currentTime: fmtDate(Math.floor(Date.now() / 1000)), - RECEIVE_IMAGE, - LOCAL_IMAGES: localImages, - LOCAL_AUDIOS: localAudios, - LOCAL_FILES: localFiles, - LOCAL_VIDEOS: localVideos, - memoryPrompt, - summaryPrompt, - knowledgePrompt, - toolPrompt + if (STATUS && skillSummaries.length > 0) { + content += `\n\n## 可用技能\n- ${skillSummaries.join('\n- ')}\n需要时请使用 use_skill 工具获取对应技能内容。`; + } + return content; }); - // 能力段:技能在两种工具模式下都可见(函数调用模式无工具提示词段时也能发现技能) - const skillSummaries = getSkillSummaries(); - if (skillSummaries.length > 0) { - content += `\n\n## 可用技能\n- ${skillSummaries.join('\n- ')}\n需要时请使用 use_skill 工具获取对应技能内容。`; - } + // 动态段:长期记忆只短缓存并绑定版本号,保证刚写入的记忆立即可见; + // 总结记忆与知识库变化频率更低,分别用版本号和知识库配置签名失效。 + const embeddingModelName = Model.getEmbeddingModel('text-embedding')?.name || ''; + const memoryKey = signature([ + 'prompt:memory', + session.sessionId, + getMemoryRevision(), + Config.memory.MEMORY, + Config.memory.MEMORY_SHOW_NUMBER, + Config.model.EMBEDDING_MODEL_ENABLED, + Model.getEmbeddingDimension(), + embeddingModelName, + VECTOR_SIMILARITY, + ctx.isPrivate, + session.memory.persona, + seal.formatTmpl(ctx, '核心:骰子名字'), + ui?.id || '', + ui?.name || '', + gi?.id || '', + gi?.name || '', + text + ]); + const summaryKey = signature([ + 'prompt:summary', + session.sessionId, + getSummaryRevision(), + Config.memory.SUMMARY + ]); + const knowledgeKey = signature(['prompt:knowledge', knowledgeService.getCacheVersion()]); + + const [memoryPrompt, summaryPrompt, knowledgePrompt] = await Promise.all([ + getCachedString(memoryKey, LONG_TERM_MEMORY_TTL, () => MemoryManager.buildLongTermPrompt(ctx, session, text, ui || null, gi || null)), + getCachedString(summaryKey, SUMMARY_TTL, () => MemoryManager.buildSummaryPrompt(session)), + getCachedString(knowledgeKey, KNOWLEDGE_TTL, () => MemoryManager.buildKnowledgePrompt(session, text)) + ]); + + const dynamicSections = [memoryPrompt, summaryPrompt, knowledgePrompt].filter(Boolean).join('\n\n'); + const content = frame + .replace('**CURRENT_TIME**', fmtDate(Math.floor(Date.now() / 1000))) + .replace('**DYNAMIC_SECTIONS**', dynamicSections); + // 防注入:长期记忆/总结记忆/知识库等外部内容可能夹带内部上下文标签,system prompt 出口统一兜底剥离 return stripInternalTags(content); } diff --git a/src/prompt/prompt_cache.ts b/src/prompt/prompt_cache.ts new file mode 100644 index 0000000..f6a202c --- /dev/null +++ b/src/prompt/prompt_cache.ts @@ -0,0 +1,69 @@ +// prompt 分层缓存:只缓存 system prompt 的静态壳与短期动态段,避免连续对话重复 embedding。 +// 仅使用海豹 Goja 可运行的 ES6 能力:Map、Date.now、Promise,不引入 WeakRef/structuredClone 等现代 API。 + +interface CacheEntry { + value?: string; + expiresAt: number; + promise?: Promise; +} + +const cache = new Map(); +const MAX_CACHE_ENTRIES = 1000; + +function pruneExpired(now: number): void { + for (const [key, entry] of cache) { + if (entry.value !== undefined && entry.expiresAt <= now) { + cache.delete(key); + } + } +} + +function ensureCapacity(): void { + const now = Date.now(); + pruneExpired(now); + + while (cache.size > MAX_CACHE_ENTRIES) { + const oldestKey = Array.from(cache.keys())[0]; + if (oldestKey === undefined) break; + cache.delete(oldestKey); + } +} + +/** + * 读取字符串缓存;未命中或已过期时执行 build,并把同一 key 的并发构建复用为同一个 Promise, + * 防止多个请求同时重复做昂贵的记忆检索/embedding。 + */ +export function getCachedString(key: string, ttlMs: number, build: () => string | Promise): Promise { + const now = Date.now(); + const existing = cache.get(key); + + if (existing) { + if (existing.value !== undefined && now < existing.expiresAt) { + return Promise.resolve(existing.value); + } + if (existing.promise) return existing.promise; + } + + const entry: CacheEntry = { expiresAt: 0 }; + const promise = Promise.resolve().then(build); + entry.promise = promise; + ensureCapacity(); + cache.set(key, entry); + + promise.then(value => { + entry.value = value; + entry.expiresAt = Date.now() + ttlMs; + entry.promise = undefined; + }, () => { + cache.delete(key); + }); + + return promise; +} + +/** 按前缀清理缓存,用于记忆/总结写入后主动失效,保证新写入内容立即可见 */ +export function invalidateCachedPrefix(prefix: string): void { + for (const key of Array.from(cache.keys())) { + if (key.startsWith(prefix)) cache.delete(key); + } +} diff --git a/src/prompt/templates.ts b/src/prompt/templates.ts index 9289513..65cb6c9 100644 --- a/src/prompt/templates.ts +++ b/src/prompt/templates.ts @@ -14,7 +14,7 @@ const TEMPLATES: { [key: string]: string } = { - 会话类型:{{{sessionType}}} - 会话名称:{{{sessionName}}} - 会话ID:{{{sessionId}}} -- 当前时间:{{{currentTime}}} +- 当前时间:**CURRENT_TIME** - [at:xxx]表示@某个群成员 - [poke:xxx]表示戳一戳某个群成员 @@ -69,11 +69,7 @@ const TEMPLATES: { [key: string]: string } = { {{/each}} {{/if}} -{{{memoryPrompt}}} - -{{{summaryPrompt}}} - -{{{knowledgePrompt}}} +**DYNAMIC_SECTIONS** {{{toolPrompt}}}`, "长期记忆prompt模板": `{{#if MEMORY}} diff --git a/src/timer.ts b/src/timer.ts index 587ff64..a66b5ce 100644 --- a/src/timer.ts +++ b/src/timer.ts @@ -236,10 +236,18 @@ export class TimerManager { 当前触发时间:${fmtDate(Math.floor(Date.now() / 1000))} 提示内容:${content}`; - await session.context.addSystemUserMessage(s, "定时器触发提示"); - await session.chat(ctx, msg, '定时任务'); - - changed = true; + // 与 interval 一致:执行前先重新入队,执行失败时定时器不会静默丢失,会在下一轮重试 + this.timerQueue.push(timer); + try { + await session.context.addSystemUserMessage(s, "定时器触发提示"); + await session.chat(ctx, msg, '定时任务'); + // 一次性定时器执行成功,从队列移除 + const idx = this.timerQueue.indexOf(timer); + if (idx !== -1) this.timerQueue.splice(idx, 1); + changed = true; + } catch (e) { + logger.error(`${timer.sid} 执行 ${timer.type} 定时器出错,错误信息:${e instanceof Error ? e.message : String(e)}`); + } break; } case 'interval': { diff --git a/src/utils/message.ts b/src/utils/message.ts index fde11df..d0dc76c 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -41,6 +41,18 @@ interface ContextMessage { tool_call_id?: string; } +/** + * 无依赖的 token 估算:ASCII 约 4 字符/token,非 ASCII(中文等)约 1 字符/token。 + * 用于「上下文最大token」的整包预算估算,避免依赖外部 tokenizer。 + */ +export function estimateTextTokens(text: string): number { + let ascii = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) <= 0x7F) ascii++; + } + return Math.ceil(ascii / 4) + (text.length - ascii); +} + export async function buildSystemMessage(ctx: seal.MsgContext, session: Session): Promise { const { roleIndex, roleSetting } = getRoleSetting(ctx); const content = await buildSystemPromptContent(ctx, session, roleIndex, roleSetting); @@ -59,61 +71,75 @@ function buildSamplesMessages(ctx: seal.MsgContext): ContextMessage[] { const { SAMPLE_MESSAGES } = Config.message; return SAMPLE_MESSAGES - .map((item, index) => { - if (item === '') return null; - return { - role: index % 2 === 0 ? 'user' : 'assistant', - contentItems: [{ - text: item, - time: Math.floor(Date.now() / 1000), - userId: index % 2 === 0 ? '' : ctx.endPoint.userId - }] - }; - }) - .filter(item => item !== null); + .map((item, index) => ({ item, index })) + .filter(x => x.item.trim() !== '') + .map((x, i) => ({ + role: i % 2 === 0 ? 'user' : 'assistant', + contentItems: [{ + text: x.item, + time: Math.floor(Date.now() / 1000), + userId: i % 2 === 0 ? '' : ctx.endPoint.userId + }] + })); } function buildContextMessages(systemMessage: ContextMessage, messages: ContextMessage[]): ContextMessage[] { const { INSERT_COUNT } = Config.message; const contextMessages = messages.slice(); + if (INSERT_COUNT <= 0) return contextMessages; - // token 预算裁剪(0 = 不限制):超出后从最早的消息开始丢弃,保持窗口有界 - const { MAX_CONTEXT_TOKENS: maxTokens } = Config.message; - if (maxTokens > 0) { - const estimateTokens = (m: ContextMessage) => Math.ceil(buildContent(m).length / 2); - let tokens = contextMessages.reduce((acc, m) => acc + estimateTokens(m), 0); - while (tokens > maxTokens && contextMessages.length > 1) { - tokens -= estimateTokens(contextMessages[0]); - contextMessages.shift(); + // 顺序遍历:在第 INSERT_COUNT+1 条及之后每隔 INSERT_COUNT 条用户消息前插入 system message, + // 让模型在长对话中周期性重新看到角色设定;示例对话不计入插入轮数。 + let userCount = 0; + const result: ContextMessage[] = []; + for (const m of contextMessages) { + if (m.role === 'user' && userCount > 0 && userCount % INSERT_COUNT === 0) { + result.push(systemMessage); } + result.push(m); + if (m.role === 'user') userCount++; } + return result; +} - if (INSERT_COUNT <= 0) return contextMessages; - - const userPositions = contextMessages - .map((item, index) => (item.role === 'user' ? index : -1)) - .filter(index => index !== -1); +/** + * token 预算裁剪:在 system + samples + context 完整组装后统一计算,超出预算时从最早的 + * context 消息开始丢弃;system 与 samples 永不丢弃。tools 的 JSON 长度同样预留进预算, + * 使「上下文最大token」更接近真实请求体上限。 + */ +function applyTokenBudget(messages: ContextMessage[], protectedCount: number, tools?: unknown[]): ContextMessage[] { + const { MAX_CONTEXT_TOKENS: maxTokens } = Config.message; + if (maxTokens <= 0) return messages; - if (userPositions.length <= INSERT_COUNT) return contextMessages; + const reserve = tools && tools.length > 0 ? estimateTextTokens(JSON.stringify(tools)) : 0; + const budget = Math.max(maxTokens - reserve, 1); + const estimate = (m: ContextMessage) => estimateTextTokens(buildContent(m)); + let tokens = messages.reduce((acc, m) => acc + estimate(m), 0); - for (let i = userPositions.length - 1; i >= 0; i--) { - if (i + 1 <= INSERT_COUNT) break; - const index = userPositions[i]; - if ((userPositions.length - i) % INSERT_COUNT === 0) { - contextMessages.splice(index, 0, systemMessage); - } + while (tokens > budget && messages.length > protectedCount) { + tokens -= estimate(messages[protectedCount]); + messages.splice(protectedCount, 1); } - - return contextMessages; + return messages; } -export async function handleMessages(ctx: seal.MsgContext, session: Session, multimodal = false): Promise { - const systemMessage = await buildSystemMessage(ctx, session); +export async function handleMessages( + ctx: seal.MsgContext, + session: Session, + multimodal = false, + tools?: unknown[], + systemMessage?: ContextMessage +): Promise { + const system = systemMessage ?? await buildSystemMessage(ctx, session); const samplesMessages = buildSamplesMessages(ctx); - const contextMessages = buildContextMessages(systemMessage, session.context.messages as ContextMessage[]); + const contextMessages = buildContextMessages(system, session.context.messages as ContextMessage[]); - const messages: ContextMessage[] = [systemMessage, ...samplesMessages, ...contextMessages]; + const messages: ContextMessage[] = applyTokenBudget( + [system, ...samplesMessages, ...contextMessages], + samplesMessages.length + 1, + tools + ); // 提示词工程模式:不向 API 发送 role:'tool',也不带 assistant tool_calls; // 工具结果转成 user 文本(带【工具返回】标记),保证模型能看到结果而不会反复调用工具 diff --git a/src/utils/ob11.ts b/src/utils/ob11.ts index 3d91b89..2983b30 100644 --- a/src/utils/ob11.ts +++ b/src/utils/ob11.ts @@ -3,6 +3,9 @@ import { logger } from "../logger"; import { MessageSegment, parseCardToText, parseMusicToText } from "./string"; +/** 合并转发/消息节点展开的最大嵌套深度,防止恶意或异常嵌套导致无限递归 */ +const MAX_FORWARD_DEPTH = 5; + export function getNet() { const net = globalThis.net; if (!net) { @@ -82,7 +85,8 @@ export async function getForwardMessage(epId: string, id: string): Promise { +async function forwardSegmentsToText(epId: string, message: any, depth: number, visited: Set): Promise { + if (depth > MAX_FORWARD_DEPTH) return '[消息嵌套过深,已截断]'; if (typeof message === 'string') return message; if (!Array.isArray(message)) return ''; @@ -101,12 +105,19 @@ async function forwardSegmentsToText(epId: string, message: any): Promise { +async function forwardMessagesToText(epId: string, messages: any[], depth: number = 0, visited: Set = new Set()): Promise { + if (depth > MAX_FORWARD_DEPTH) return '[消息嵌套过深,已截断]'; const lines: string[] = []; for (const m of messages) { if (!m || typeof m !== 'object') continue; @@ -124,16 +136,16 @@ async function forwardMessagesToText(epId: string, messages: any[]): Promise { +export async function expandForwardMessage(epId: string, id: string, depth: number = 0): Promise { const messages = await getForwardMessage(epId, id); - return forwardMessagesToText(epId, messages); + return forwardMessagesToText(epId, messages, depth + 1); } export async function getGroupMemberInfo(epId: string, group_id: string, user_id: string): Promise {