plugin-memory-summary — 对话摘要压缩
包名: @aalis/plugin-memory-summary
源码: packages/plugin-memory-summary/src/index.ts
概述
LLM 驱动的全量压缩:会话的用量到了自动压缩线、或会话条数到了条数线时(谁先到算谁),把切分点之前的全部活跃历史连同已有摘要压成一份新摘要,摘要模型同时挑出值得原样保留的原文,其余消息归档;摘要注入之后每一轮的上下文,保留的原文与压缩期间到达的消息原样接在摘要后面。摘要按会话分两套固定的小节结构:聊天窗口记谁在聊什么、谁在等回应与关键原话,其余会话按任务记目标、决定、改过的文件与未完成的事(见「摘要模板」)。它同时提供压缩服务 compression(api-memory),会话服务的手动压缩调它,功能位与用量条目的压缩线也看它。
摘要参与 memory:clear 统一清除:摘要是已归档消息的压缩,随 context 类型一并清理,也可单独用 summary 类型清理(本插件登记这个类型;删除会话也走这条)。清理发生在压缩途中(模型尚未返回)时,这次压缩作废:不写摘要、不归档历史、不写压缩分隔线,压缩报 error 并以「压缩期间会话被清空」拒绝。模型已返回、正在落库(写摘要、归档、写分隔线)时开始的清理,先等这次落库完成再删,删掉的包括刚写入的摘要。
插件声明
export default definePlugin({
name: '@aalis/plugin-memory-summary',
provides: [compression],
uses: {
memory,
llm,
messageArchive: optional(messageArchive),
sessionManager: optional(sessionManager),
sessionFields: optional(sessionFields),
document: optional(ownHostConfig),
authority: optional(authority),
todoList: optional(todoList),
config,
logger,
events,
hooks,
contributions,
lifecycle,
provide,
},
apply(caps) { /* 见源码 */ },
});sessionManager:读会话记录选摘要模板;不在场时摘要用工作模板。sessionFields:按会话读「自动压缩线」;不在场时取插件配置里的值。条数线是 agent 的项,经 agent 服务按会话取(见下文)。authority:选摘要模板时判一对一窗口的对端是不是 owner;不在场时按不是 owner。todoList:工作模板的「未完成的事」以它此刻的待办为准;不在场时摘要输入不带待办。messageArchive:压成后写一条压缩分隔事件消息;不在场时不写。
配置
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
summaryBudgetRatio | number | 0.1 | 摘要预算(占自动压缩线的比例),0 到 0.5:压缩时,摘要连同原样保留的原文合计约为这个会话自动压缩线(按最近一次用量算)的这个比例,不论怎么触发,但不超过这次要压的内容(上一份摘要加这次新压的对话);不少于 1000 token,也不超过压缩线的四分之一。其中保留的原文不超过这次新压的对话的这个比例(不算上一份摘要,也不按这个下限算)。最终长度不超过摘要模型输出上限(与它窗口的四分之一取小)的三分之二,要更长得换输出上限更大的摘要模型。还没有用量时按要压的内容,同样不少于这个下限、不超过这个封顶(见下文「摘要预算」) |
compressAt | number | 0.8 | 自动压缩线(占窗口比例),0 到 1;可按会话设(见下文「自动压缩线」) |
摘要模型没有配置项:取 llm 服务给本插件挑的能对话的那一个(本插件偏好 › 全局偏好 › 优先级,偏好指向的不在时落到下一级),在插件详情的「服务偏好」里给本插件设。没有能对话的模型时只打一条 warn,有了模型后再缺再打。
自动压缩线
compressAt 是可按会话覆盖的插件配置字段(api-session-settings),各种会话都适用,只有 owner 能改,显眼程度 advanced。
- 含义:用量到上下文窗口的这个比例时压缩;0 表示不按用量压缩(条数线照样起作用)。压缩服务的
compressAt(sessionId, platform)交出这个会话的生效值(0 时为undefined)。实际在它与 agent 裁剪预算占窗口的比例中较小的那个上压缩(见下文「何时压缩」),会话服务的用量条目与界面的压缩线给的就是这个较小值。 - 层:全局(插件配置)、平台档、平台类型档、场所与单个会话按层覆盖;没有出生平台的会话(WebUI、终端)按入口平台选平台档。子会话在建立时写下父会话此刻的值,之后独立。
- 默认 0.8:面向本机使用,本机可以大方用满上下文;与 agent 默认的裁剪比例 1.0 之间留出输出预留与估算误差。
- 在线平台:在平台档(或它的平台类型档,如
onebot的group、private)上设较小的值,控制每轮的输入成本。 - 随窗口重算:压缩线是窗口比例,同一个值在窗口差得多的模型上含义差得多。0.06 在 100 万窗口是 6 万 token;在 12.8 万窗口只有 7680 token,压不掉的人设、工具定义与注入块可能就已超过它,几乎每轮都压。换主模型时按新窗口重算在线平台的压缩线。
条数线是 plugin-agent 的可按会话覆盖的字段 historyLimit(「历史条数上限」),它同时是 agent 发给模型的历史条数上限(超过时成段丢弃最早的)。本插件按会话取它(经 agent 记在用量事件上的 historyLimit),会话条数到线就全量压缩,与用量到线同一条流程、同一套退避;0 或哪一层都没设时不按条数压缩。条数线与模型窗口无关,换主模型不用重算。
摘要模板
摘要的小节结构由插件写死,按会话选两套之一,不能自定义提示词:一份提示词同时覆盖两套模板会丢掉结构。
| 模板 | 用于 | 小节(按顺序) |
|---|---|---|
| 聊天 | 场所是聊天窗口的根会话:多方的聊天窗口(群、频道等),与对端不是 owner 的一对一窗口 | 最近在聊什么:按话题分段,写明发言人;谁有问题没回应:点名提问或请求、还没得到回答的人与问题;关键原话要点:承诺、约定、数字、链接、对助手的请求,保留发言人与原文要点 |
| 工作 | 其余会话:owner 自己的会话(WebUI、终端、owner 的 IM 私聊)、内部会话(mcp-server、编排与定时任务)与全部子会话 | 目标:要做成什么,最近一次请求照录原话;做过的决定:定下与放弃的方案及理由;改过的文件:路径与改了什么,只依据工具调用记录;未完成的事:待办列表的状态(待办插件此刻的待办)、下一步 |
- 选法:没有父会话、且房间类别(api-session-manager 的
roomClass)为room的用聊天模板,其余用工作模板。只认会话记录里的多方标记与一对一的对端,不读平台类型名、不读工作目录绑定;子会话执行任务,根会话是群也用工作模板。一对一的对端是不是 owner 问权限服务的isOwner,没有权限服务时按不是 owner;没有会话管理服务时一律用工作模板。 - 共同规则:第一行列出要原样保留的原文(见下文「挑原文」),之后小节标题照写、按顺序输出,没有内容的小节写「无」;系统提示写明摘要预算与保留上限(见下文「摘要预算」)。聊天模板另要求提到人时写成「昵称(ID)」:消息开头的 [昵称(ID)] 标注了发言人,之后才能凭 ID 或昵称认出同一个人。
- 增量合并:已有摘要时(含分块时上一块的结果),请求要求在它的同名小节上合并更新、旧内容里仍然成立的不丢,输出完整的新摘要;已有摘要没有这些小节(没有结构的旧摘要)或小节与本模板不同(会话换了模板)时,要求把这些内容按意思归入对应小节、不保留模板之外的小节。没有结构的旧摘要因此不迁移,下一次压缩时按模板合并重写。
工作方式
何时压缩:agent 每次调用模型之后发
token:usage,本插件按会话记下最近一条带turnId的用量供判定;所有用量(含token:request的快照)另记一份,供摘要预算按其中的窗口、裁剪预算与入口平台算这个会话的压缩线,条数规则从中取条数线(见「回差与退避」)。回合结束时(agent:turn:after,中止或出错的回合除外)取这一轮最后一次调用的用量判一次(api-memory 的compressionDue),两条线按会话与用量事件上的入口平台解析,任一到线就在后台压缩:- 用量一维:有效阈值 E 取「压缩线 × 窗口」与 agent 裁剪预算的较小者,用量到 E 或这一轮经过了硬裁剪(用量事件的
trimmed),且可压缩部分至少是 E 的一半。可压缩部分取用量事件的untrimmedHistory,即按没经过硬裁剪的列表估算的历史与工具结果:裁掉的部分仍在会话里,全量压缩照样压得掉。用量是硬裁剪之后量的,可能到不了线;经过了硬裁剪就算到线:一到丢内容的地步,能压就压。 - 条数一维:回合结束时的会话条数到条数线。会话条数只数对话消息,是这一轮最后一次调用的用量事件里的
historyMessages(这一轮开头会话的活跃条数,含条数线之外、没发给模型的部分,加这一轮追加的工具调用与结果和插进来的输入),结局为 replied 时再加上之后落库的最终回复;silent 的一轮落库的回合记录是事件标记,不算;到线的这一轮结束就在后台压;压成之前到来的几轮,agent 按条数线成段丢弃(见 plugin-agent)。
快照(不带
turnId)与上一轮留下的用量不参与判定;这一轮开始(agent:turn:before)之后这个会话压成过或清空过的,它的用量是按那之前的历史算的,也不判;回合进行中不在后台改动历史;这个会话正在压缩时跳过。- 用量一维:有效阈值 E 取「压缩线 × 窗口」与 agent 裁剪预算的较小者,用量到 E 或这一轮经过了硬裁剪(用量事件的
回差与退避:压完之后可压缩部分只剩留下的少量原文与切分点之后的几条,一般到不了 E 的一半,不会接着再触发;人设、工具定义这些压不掉的部分再大也不会让它空转。留下的原文不超过这次新压的对话的
summaryBudgetRatio(不算上一份摘要,也不按预算的下限算):留下得多、很快又到线时,下一次压缩的输入大半就是它们,留下的随之变少。会话条数另有一条规则:设了条数线时,挑中的原文只留到压完的活跃条数(保留的原文与切分点之后的对话消息,分隔标记等事件标记不算)不超过条数线的一半(向下取整)为止,超出的从最早的保留单位起整个去掉(工具组整组去掉),去掉的已由摘要覆盖;切分点之后的对话消息已达到条数线的一半时一条原文都不留。保留上限按 token 算,一条很长的正文(用户消息与助手回复不截断)就能把它撑大,让摘要模型留下很多短消息,没有这条规则时压完的条数可能仍到线、每轮都按条数压一次。一轮新增的消息达到条数线的一半(向上取整)时仍可能每轮都压。条数线为 0 或没设时没有这条规则。后台压缩没压成时按轮数退避(两条线同一套):连续第 k 次没压成之后,到此后判定的第 2 的 k 次方轮(至多第 32 轮)才重试,即依次隔 2、4、8、16、32 轮,此后每 32 轮一次。不按用量又涨了多少退避:用量是硬裁剪之后量的,涨到裁剪预算就不再涨。压成或清理(含删除会话)时丢掉退避记录,被清理作废的那次不记。全量压缩(后台与手动同一条流程):
- 读全部活跃历史作快照(
getHistory(sessionId, Infinity))。切分点是快照末尾;快照的最后一条是没写全的工具调用组(按groupToolCalls归组;快照恰好读在 agent 写这一组的几次写入之间)的调用或结果时,退到这组的 assistant 之前,它、夹在组里的别的消息与随后写入的结果都留在活跃历史里。切分点之前的部分是这次的归档段。 - 归档段按单位连同已有摘要交给摘要模型:单位按 schema-message 的
groupToolCalls归组:带工具调用的 assistant 连同它的各条结果是一个单位,夹在调用与结果之间的别的消息另成单位、排在这组之后,其余 user、assistant、tool 与 notice 消息各是一个;单位记着各条在归档段里的下标(存储次序),留下与归档都按它(system 消息不进,压缩分隔线这类事件标记因此也不进;assistant 消息取可见正文:落库内容是结构化输出的 JSON 信封时读 metadata 的visibleContent;带工具调用的 assistant 渲染为「调用 <工具名>(参数)」,tool 结果渲染为「工具结果(工具名): …」,其中工具名按toolCallId从同段历史的 assistanttoolCalls反查、消息自带的name只作回落,两者的参数与正文按每条 200 字硬截断;notice 消息(宿主通知、编排或定时任务派来的指令、平台通知)渲染为「通知: …」,同样按每条 200 字硬截断);用工作模板时,待办插件此刻的待办逐项写成「当前任务列表状态」作为最后一段(每项一行- [状态] 标题,状态取待办项的status),待办插件不在或没有待办时不写。系统提示按会话选的模板写(见「摘要模板」);模型调用带上会话 id 与用途「摘要」,供记账(见 api-llm 的记账),思考档取模型最浅的一档。 - 挑原文:能原样保留的单位按时间从 1 起编号,开头标「#编号〔约 N token〕」,N 是它原样留在上下文里要占的 token(api-llm 的
estimateMsgTokens,按落库的消息算,不按截断后的行);工具组共用组首的编号。形状不完整的(结果没找齐的工具组、单独的工具结果)、原样留下要占的超过保留上限的(见「摘要预算」)、加上标注超过每次请求容量一半的不编号。摘要模型在第一行【保留原文】后列出要留的编号(一条不留写「无」),之后按小节写摘要;保留的原文只是原样补充,摘要仍要覆盖全部对话。解析时【保留原文】标题前的 Markdown 标记(#、*、-、>)与全角「#」都认得;保留段是标题这一行连同紧随其后、只列编号的行(编号、「无」、空白与标点),遇到别的行就结束,之后的各行不论有没有小节标题都是摘要。保留段在前在后都认得,摘要正文里的#数字不算编号;有几个保留段时编号认最后一个,存下的摘要不含任何保留段。编号必须在这次请求里出现过,不存在的丢掉;保留的合计超过 min(保留上限, 预算 − 摘要的估算) 时从最早的起去掉,直到不超,去掉的已由摘要覆盖。回复里没有【保留原文】标题时整份当摘要用、这次不保留原文,照常压成,这次请求里有编号可选时记一条告警;保留段写了字却认不出编号时同样不保留、记告警。 - 每次请求的输入加输出上限都装得下摘要模型的窗口:已有摘要按至多物理上限(见「摘要预算」)那么多 token 算,上一份可能是按更大的预算写的;系统提示、请求里的固定文字与对话按每字至多 2 token 保守折算。每次请求放对话的字数(容量)是(摘要模型窗口 − 物理上限 − 输出上限)÷ 2 − 提示词字数,编号标注与重发的保留单位都算在内。放不下时分几次请求:每次放上一次请求挑中的单位(原样、带原编号)与按顺序放得下的新单位,上一次的摘要作为已有摘要,最终仍是一份摘要,保留的是最后一次请求挑中的。文字超过容量一半的单位切成每段不超过容量一半的几段、不编号;队首放不下时从最早的保留单位起去掉(它在之前那次请求的输入里,摘要覆盖了它)。
- 落定:写摘要 → 重读全部活跃历史,核对开头与归档段逐条相同(时间戳、角色、正文与工具调用 id)→ 按重读时切分点之后的条数套条数规则(见「回差与退避」)→
archiveSpan(sessionId, { count: 归档段条数, keep: 留下的保留单位在归档段里的下标 })→ 若message-archive可用,写一条压缩分隔事件消息「对话已压缩」。核对在写摘要之后,与归档之间只隔一次重读:期间回滚删了消息、或晚到的消息时间戳插进了归档段,位置就会错开,这时把摘要回滚到写入前,以「压缩期间历史被改动」作废。归档抛错时同样把摘要回滚到写入前,原样抛出。 - 保留的原文原样留在活跃历史里(角色、正文、工具调用、时间戳与 metadata 都不动),时间早于切分点之后的消息,所以下一轮起紧接在摘要块之后、按原来的顺序发给模型;活跃条数超过条数线时,agent 成段丢弃先丢的就是它们(见 plugin-agent)。下一次压缩时它们与新内容一起重新编号、重挑,没再选中的随之归档。
- 读全部活跃历史作快照(
没压成:摘要模型不在(
custom指向的模型解析不到时,原因里点名它的 provider/model)、模型失败(含流式中途断开,半截输出丢弃)、在输出上限处停下(模型插件报的结束原因finishReason是length,输出不完整;服务商不报结束原因时查不出)、摘要为空(去掉保留段之后)、任何一次请求失败、期间被清空或历史被改动,都不写摘要(写了的回滚)、不归档、不写分隔线,记一条告警、报error并以原因拒绝。摘要模型的窗口装不下提示词、已有摘要与输出上限(算出的每次容量不足 4 个字)时同样拒绝。失败时不裁切历史:全量压缩下只裁切等于丢掉整段上下文。进度与并发:经
session:compressing报start、done、error,压成才报done(界面据此插「对话已压缩」)。待摘要部分没有对话(历史为空、只剩分隔标记)时什么都不做、照常落定,不报进度;开始之前就失败的(记忆不在、读不出历史)只拒绝,也不报。同一会话同时只有一次压缩:手动压缩遇到在途的以「这个会话正在压缩」拒绝,后台跳过。摘要预算:预算是摘要与保留的原文的合计,= 这个会话的压缩线 ×
summaryBudgetRatio,不论是自动、按条数还是手动压缩;压缩线按最近一次用量算,与「何时压缩」的有效阈值 E 相同(自动压缩线为 0 时取裁剪预算)。它不超过这次要压的内容(已有摘要加各单位格式化后的文字,按 api-llm 的estimateTextTokens估算):摘要不比原文长。这两项合起来不少于 1000(要压的很少时也留够写全各小节),也不超过压缩线的四分之一(压过下限:摘要是每轮重发的常驻块)。保留上限 = min(预算, ⌊summaryBudgetRatio× 这次新压的对话⌋),不算已有摘要:下限只托住摘要,已有摘要再长也撑不大它,新压的少时留得很少(条数另有规则,见「回差与退避」);summaryBudgetRatio为 0 时不保留原文。另不超过物理上限(摘要模型的maxOutputTokens,没声明时按 4096,与它窗口的四分之一,取小;请求里的已有摘要与输出各占至多四分之一,另一半留给提示词与对话)的三分之二,同样压过下限:输出上限 8192 的摘要模型最多约 5461 token,要更长得换输出上限更大的摘要模型。还没有用量时(如重启后头一次手动压缩、清理之后),或用量里的窗口与裁剪预算算不出有限的正数时,没有压缩线,预算按要压的内容,同样不少于 1000、不超过物理上限的三分之二。预算与保留上限写进提示词(摘要与保留的原文合计约多少 tokens,其中保留的原文不超过多少),请求的maxTokens是预算的 1.5 倍(向上取整,不超过物理上限):提示词按每 token 4 个英文字符换算,路径、代码这类文本的真实分词更密,推理模型的推理 token 也算在里面。注入:摘要通过
agent:prompt贡献点(context锚位)在 LLM 调用前全文注入,由 agent 组装为头部 system 区的一条 system 消息,紧挨历史开头。头一句说明保留的原文(如果有)按原来的时间顺序排在之后的历史开头、内容已在摘要里概括过,免得模型以为它们是摘要之后才发生的;这句固定,不随会话变。不按预算截断:生成时的输出上限已经限住它;换了窗口更小的模型时,下一次压缩会重写摘要,期间由 agent 的硬裁剪兜底。持久化:摘要经 memory 服务的 metadata 接口持久化(namespace
summary,key 为会话 ID),与对话历史共用同一存储后端(取决于所用 memory 插件)。