水位压缩模式是运行于 Next.js 与 Drizzle ORM 环境的多轮对话上下文管理机制。该机制基于 Token 水位阈值与消息游标,将早期对话轮次增量合并为滚动摘要,在保留数据库完整历史的同时按窗口裁剪模型推理消息。
在管理长会话上下文时,常见的简单截断策略存在以下边界问题:
| 方案类型 | 处理方式 | 主要局限性 |
|---|---|---|
| 固定条数滑动窗口 | 仅保留最近 条消息,直接丢弃早期消息 | 早期确认的架构约束、用户偏好与已引用的文章链接在滑出窗口后丢失 |
| 逐轮全量重总结 | 每轮结束后对所有历史消息重新生成摘要 | 摘要调用成本随会话长度线性上升,且多次重述容易引入信息衰减 |
| 物理删除或覆写历史表 | 将数据库中的早期消息替换为一条摘要消息 | 破坏前端聊天界面的历史回溯能力,用户向上滚动时无法查看早期原始对话 |
| 盲目保留历史工具输出 | 将历史轮次中检索到的数千字分块原样留在上下文中 | 单次 getPost 即可占用数千 Token,两三轮对话后即触及上下文上限 |
水位压缩模式将“数据库持久化的完整消息流”与“注入大语言模型的有效上下文窗口”解耦,依托四个核心领域概念协同工作:
user 消息开始、至下一条 user 消息之前为止的一组连续消息(通常为一问一答;在中断或异常场景下可能仅含 user 消息)。对话轮次是尾部保留计算与水位推进的最小原子切分单位,防止将一组问答从中间截断。compacted_until_seq)。序号满足 seq <= compacted_until_seq 的早期消息由压缩摘要代表;仅 seq > compacted_until_seq 的尾部消息经过工具输出裁剪后发送给模型。8,000 Token)时,后台任务提取新跨入水位线的消息段,与上一份摘要合并生成的增量滚动文本摘要(上限 1,000 字符)。WHERE seq > watermark),前端历史消息接口(GET /api/tools/blog-agent/history)直接按消息序号游标(beforeSeq)倒序分页读取 agent_message 表,用户在界面上可随时向上滚动浏览包含已压缩区域在内的完整历史记录。会话状态由三张关系表共同维护:agent_session 记录会话元数据,agent_message 按会话内递增序号 seq 存储原始消息片段,agent_session_compaction 追加记录每次水位推进产出的压缩摘要。
使用 Drizzle ORM 定义会话、消息与压缩摘要表结构:
import {
mysqlTable,
varchar,
int,
datetime,
json,
text,
uniqueIndex,
index,
} from 'drizzle-orm/mysql-core';
// 助手会话表
export const agentSessionTable = mysqlTable(
'agent_session',
{
id: varchar('id', { length: 64 }).primaryKey(),
user_id: int('user_id').notNull(),
title: varchar('title', { length: 100 }).notNull().default('新会话'),
create_time: datetime('create_time')
.notNull()
.$defaultFn(() => new Date()),
update_time: datetime('update_time')
.notNull()
.$defaultFn(() => new Date())
.$onUpdateFn(() => new Date()),
delete_time: datetime('delete_time'),
},
(table) => [
index('agent_session_user_updated_idx').on(table.user_id, table.update_time),
],
);
// 助手消息表:通过 (session_id, seq) 唯一索引保证会话内消息严格有序
export const agentMessageTable = mysqlTable(
'agent_message',
{
id: varchar('id', { length: 64 }).primaryKey(),
session_id: varchar('session_id', { length: 64 }).notNull(),
seq: int('seq').notNull(),
role: varchar('role', { length: 16 })
.$type<'user' | 'assistant'>()
.notNull(),
parts: json('parts').$type<Array<Record<string, unknown>>>().notNull(),
create_time: datetime('create_time')
.notNull()
.$defaultFn(() => new Date()),
},
(table) => [
uniqueIndex('agent_message_session_seq_unique_idx').on(
table.session_id,
table.seq,
),
],
);
// 会话压缩摘要表:记录每次水位推进的起止序号与累积摘要文本
export const agentSessionCompactionTable = mysqlTable(
'agent_session_compaction',
{
id: int('id').primaryKey().autoincrement(),
session_id: varchar('session_id', { length: 64 }).notNull(),
from_seq: int('from_seq').notNull(),
compacted_until_seq: int('compacted_until_seq').notNull(),
summary: text('summary').notNull(),
create_time: datetime('create_time')
.notNull()
.$defaultFn(() => new Date()),
},
(table) => [
uniqueIndex('agent_compaction_session_until_seq_unique_idx').on(
table.session_id,
table.compacted_until_seq,
),
index('agent_compaction_session_idx').on(table.session_id),
],
);核心约束说明:
seq):每条消息在写入时分配会话内连续递增的整型 seq。最新一条压缩记录的 compacted_until_seq 即为当前会话的水位标记。agent_session_compaction 表在 (session_id, compacted_until_seq) 上建立唯一索引。在多实例并发触发同一水位压缩时,后到达的写入会因唯一键冲突安全退出,不会产生重复水位记录。regenerate-message)时,事务内会校验目标 userSeq > watermark;若目标轮次已被纳入压缩水位,则抛出 400 状态码拦截,保证压缩摘要与后续消息序列的一致性。在每次请求与压缩检查中,若引入重型分词器依赖会增加服务端包体积与冷启动开销。针对 Qwen 系列模型在中英文语料下的分词特征,可采用轻量级字符分类启发式算法:CJK 统一汉字与全角标点按每字符 1 Token 计,拉丁字母、数字与 ASCII 符号按每字符 0.25 Token 计,最终向上取整。
实现中英混合 Token 估算函数:
export function isCJKChar(char: string): boolean {
if (!char) return false;
return (
(char >= '\u4e00' && char <= '\u9fff') || // CJK 统一汉字基本区
(char >= '\u3400' && char <= '\u4dbf') || // CJK 扩展 A
(char >= '\uf900' && char <= '\ufaff') || // CJK 兼容汉字
(char >= '\u3000' && char <= '\u303f') || // CJK 标点符号
(char >= '\uff00' && char <= '\uffef') || // 全角字符
(char >= '\u2000' && char <= '\u206f') // 通用标点
);
}
export function estimateTokens(text: string): number {
if (!text) return 0;
let cjkCount = 0;
for (let i = 0; i < text.length; i++) {
if (isCJKChar(text[i])) {
cjkCount++;
}
}
const otherCount = text.length - cjkCount;
return Math.ceil(cjkCount + otherCount * 0.25);
}水位标记只能在完整的对话轮次边界上推进。计算新水位时,先将 seq > watermark 的尾部消息按 user 消息边界分组为 AgentTurn 数组,再依据两个核心阈值判定是否压缩:
COMPACTION_TRIGGER_TOKENS(默认 8,000):当“现有摘要 Token 数 + 裁剪后尾部各轮次总 Token 数”超过该值时触发压缩。COMPACTION_KEEP_TOKENS(默认 3,000):触发压缩后,从最新一轮向前逆序累加,保留不超过 3,000 Token 的完整轮次作为未压缩尾部(无论最新一轮本身多大,至少保留最新 1 轮不压缩)。实现对话轮次分组与水位推进纯函数:
import { extractTextMessage } from '@/validators/blog-agent';
import { COMPACTION_KEEP_TOKENS, COMPACTION_TRIGGER_TOKENS } from './constants';
import { estimateTokens } from './guardrails';
export interface AgentTurnMessage {
id?: string;
seq: number;
role: 'user' | 'assistant';
parts: Array<Record<string, unknown>>;
}
export interface AgentTurn<T extends AgentTurnMessage = AgentTurnMessage> {
userSeq: number;
messages: T[];
estimatedTokens: number;
}
// 估算单个对话轮次在历史裁剪后保留的纯文本 Token 数
export function estimateTurnTokens(messages: AgentTurnMessage[]): number {
let tokens = 0;
for (const msg of messages) {
if (!msg || !Array.isArray(msg.parts)) continue;
const text = extractTextMessage(msg.parts);
if (text) {
tokens += estimateTokens(text);
}
}
return tokens;
}
// 将连续消息按对话轮次(Agent Turn)进行分组
export function groupMessagesIntoTurns<
T extends AgentTurnMessage = AgentTurnMessage,
>(messages: T[]): Array<AgentTurn<T>> {
if (!Array.isArray(messages) || messages.length === 0) {
return [];
}
const turns: Array<AgentTurn<T>> = [];
let currentTurn: { userSeq: number; messages: T[] } | null = null;
for (const msg of messages) {
if (msg.role === 'user') {
if (currentTurn) {
turns.push({
userSeq: currentTurn.userSeq,
messages: currentTurn.messages,
estimatedTokens: estimateTurnTokens(currentTurn.messages),
});
}
currentTurn = { userSeq: msg.seq, messages: [msg] };
} else if (currentTurn) {
currentTurn.messages.push(msg);
} else {
currentTurn = { userSeq: msg.seq, messages: [msg] };
}
}
if (currentTurn) {
turns.push({
userSeq: currentTurn.userSeq,
messages: currentTurn.messages,
estimatedTokens: estimateTurnTokens(currentTurn.messages),
});
}
return turns;
}
// 水位推进纯函数计算
export function calculateCompactionWatermark(params: {
watermark: number;
turns: Array<{ userSeq: number; tokens: number }>;
summaryTokens?: number;
triggerTokens?: number;
retainTokens?: number;
}) {
const {
watermark,
turns,
summaryTokens = 0,
triggerTokens = COMPACTION_TRIGGER_TOKENS,
retainTokens = COMPACTION_KEEP_TOKENS,
} = params;
const totalTailTokens = turns.reduce((sum, t) => sum + t.tokens, 0);
const totalTokens = summaryTokens + totalTailTokens;
// 1. 未达阈值不触发压缩
if (totalTokens <= triggerTokens || turns.length === 0) {
return {
shouldCompact: false,
fromSeq: watermark + 1,
compactedUntilSeq: watermark,
retainedTurnCount: turns.length,
compactedTurnCount: 0,
};
}
// 2. 从最新一轮向前累加保留不超过 retainTokens 的尾部(至少保留最新 1 轮)
const lastIndex = turns.length - 1;
let accumulatedTokens = turns[lastIndex].tokens;
let firstRetainedIndex = lastIndex;
for (let i = lastIndex - 1; i >= 0; i--) {
if (accumulatedTokens + turns[i].tokens <= retainTokens) {
accumulatedTokens += turns[i].tokens;
firstRetainedIndex = i;
} else {
break;
}
}
// 3. 若无可压缩轮次(如仅剩单个超大轮次),跳过压缩
if (firstRetainedIndex === 0) {
return {
shouldCompact: false,
fromSeq: watermark + 1,
compactedUntilSeq: watermark,
retainedTurnCount: turns.length,
compactedTurnCount: 0,
};
}
// 新水位设为首个保留轮次 userSeq 的前一条消息序号
const compactedUntilSeq = turns[firstRetainedIndex].userSeq - 1;
const fromSeq = watermark + 1;
if (compactedUntilSeq <= watermark) {
return {
shouldCompact: false,
fromSeq,
compactedUntilSeq: watermark,
retainedTurnCount: turns.length - firstRetainedIndex,
compactedTurnCount: firstRetainedIndex,
};
}
return {
shouldCompact: true,
fromSeq,
compactedUntilSeq,
retainedTurnCount: turns.length - firstRetainedIndex,
compactedTurnCount: firstRetainedIndex,
};
}在将水位之后的尾部消息发送给主对话模型前,需要剔除已消费过的冗余大体积数据。系统在“流结束落库时”与“下次请求组装模型输入时”实施双阶段裁剪。
当单轮流式响应结束并写入 agent_message 表时,getPost 返回的文章全文切片(output.content,单次可达 8,000 字符)与 searchPosts 返回的检索拼接上下文(output.formattedContext)已完成当前轮次的推理使命。前端渲染工具调用胶囊(ToolPills)与引用来源胶囊(CitationPills)仅依赖元数据字段(如 title、url、citations),不读取正文大字段。
在落库前原地置空大体积只读工具输出:
export function sanitizeHistoryParts(
parts: Array<Record<string, unknown>>,
): Array<Record<string, unknown>> {
if (!Array.isArray(parts)) return [];
return parts.map((part) => {
if (!part || typeof part !== 'object') return part;
// 针对 tool-getPost 置空正文切片,保留 id、title、url 等元数据
if (part.type === 'tool-getPost' || part.toolName === 'getPost') {
const output = part.output;
if (output && typeof output === 'object') {
return {
...part,
output: {
...(output as Record<string, unknown>),
content: '',
},
};
}
}
// 针对 tool-searchPosts 置空检索上下文文本,保留 citations 引用数组
if (part.type === 'tool-searchPosts' || part.toolName === 'searchPosts') {
const output = part.output;
if (output && typeof output === 'object') {
return {
...part,
output: {
...(output as Record<string, unknown>),
formattedContext: '',
},
};
}
}
return part;
});
}在每次对话请求开始时,assembleModelTurnMessages 使用 AI SDK 内置的 pruneMessages 函数剔除最后一条消息之前的只读工具调用(tool-call)、工具结果(tool-result)与推理块(reasoning),并执行 32,000 Token 硬上限兜底保护。
组装并裁剪模型轮次消息:
import {
convertToModelMessages,
pruneMessages,
type ModelMessage,
type UIMessage,
} from 'ai';
import {
ADMIN_WRITE_TOOL_NAMES,
convertDataPart as defaultConvertDataPart,
} from './hitl';
import { enforceContextHardLimit } from './guardrails';
const DEFAULT_PRUNABLE_NON_WRITE_TOOLS = [
'searchPosts',
'getBlogOverview',
'getPost',
'listCollections',
'getCollection',
'listAdminPosts',
'manageMemories',
] as const;
export async function assembleModelTurnMessages(params: {
uiMessages: UIMessage[];
tools?: any;
summary?: string;
}): Promise<ModelMessage[]> {
const { uiMessages, tools, summary } = params;
// 1. 预处理 UI 消息(保留管理写工具原生结构并截断超长正文入参)
const preparedUiMessages = prepareUiMessagesForModelContext(uiMessages);
// 2. 转换为 ModelMessage,忽略因流中断残留的未闭合工具调用
const modelMessages = await convertToModelMessages(preparedUiMessages, {
tools,
ignoreIncompleteToolCalls: true,
convertDataPart: defaultConvertDataPart,
});
// 3. 收集全部非写操作工具名,仅对只读/非审批工具执行历史裁剪
const nonWriteToolNames = new Set<string>(DEFAULT_PRUNABLE_NON_WRITE_TOOLS);
for (const msg of modelMessages) {
if (
(msg.role === 'assistant' || msg.role === 'tool') &&
Array.isArray(msg.content)
) {
for (const part of msg.content as any[]) {
if (
typeof part?.toolName === 'string' &&
!(ADMIN_WRITE_TOOL_NAMES as readonly string[]).includes(part.toolName)
) {
nonWriteToolNames.add(part.toolName);
}
}
}
}
// 4. 剔除最后一条消息之前的只读工具调用与推理块,移除变空的中间消息
const prunedMessages = pruneMessages({
messages: modelMessages,
toolCalls: [
{ type: 'before-last-message', tools: Array.from(nonWriteToolNames) },
],
reasoning: 'before-last-message',
emptyMessages: 'remove',
});
// 5. 实施 32,000 Token 请求前硬上限保护(超限时按最早完整轮次临时丢弃)
return enforceContextHardLimit(prunedMessages, summary);
}当 calculateCompactionWatermark 判定需要推进水位时,系统提取序号处于 [fromSeq, compactedUntilSeq] 区间内的消息,将其序列化为包含 [用户]、[工具调用]、审批状态投影与 [助手] 的紧凑纯文本,连同上一份摘要 previousSummary 一起交由辅助模型 qwen3.5-flash 合并生成新摘要。
实现待压缩消息序列化与增量摘要生成:
import { generateText, type LanguageModel } from 'ai';
import {
BLOG_SITE_URL,
SUMMARY_MAX_CHARS,
SUMMARY_MAX_OUTPUT_TOKENS,
} from './constants';
import { sanitizePromptXmlTags } from './prompt';
import { projectDataPartToText } from './hitl';
import { getAuxiliaryModel } from './provider';
// 将待纳入压缩的消息段序列化为紧凑纯文本(排除工具输出与推理块)
export function serializeCompactionMessages(
messages: Array<{
role: 'user' | 'assistant';
parts: Array<Record<string, unknown>>;
}>,
): string {
if (!Array.isArray(messages) || messages.length === 0) return '';
const lines: string[] = [];
for (const msg of messages) {
if (!msg || !Array.isArray(msg.parts)) continue;
if (msg.role === 'user') {
for (const part of msg.parts) {
const dataPartText = projectDataPartToText(part);
if (dataPartText) lines.push(dataPartText);
}
const userText = extractTextMessage(msg.parts).trim();
if (userText) lines.push(`[用户] ${userText}`);
} else if (msg.role === 'assistant') {
for (const part of msg.parts) {
const dataPartText = projectDataPartToText(part);
if (dataPartText) lines.push(dataPartText);
const toolCall = extractToolCallFromPart(part);
if (toolCall) {
lines.push(
`[工具调用] ${toolCall.toolName} ${JSON.stringify(toolCall.input)}`,
);
}
}
const assistantText = extractTextMessage(msg.parts).trim();
if (assistantText) lines.push(`[助手] ${assistantText}`);
}
}
return lines.join('\n');
}
// 组装增量滚动压缩摘要提示词
export function buildCompactionPrompt(
previousSummary: string | undefined,
serializedMessages: string,
): string {
const safePreviousSummary = sanitizePromptXmlTags(
previousSummary?.trim() ?? '',
);
const safeNewMessages = sanitizePromptXmlTags(serializedMessages.trim());
const previousSummarySection = safePreviousSummary
? `<previous-summary>\n${safePreviousSummary}\n</previous-summary>`
: `<previous-summary>\n暂无上一份摘要\n</previous-summary>`;
return `<task-context>
请根据给定的旧对话摘要(若有)与新增的对话消息记录,合并生成一份完整的中文累积对话摘要。
</task-context>
<background-data>
${previousSummarySection}
<new-messages>
${safeNewMessages}
</new-messages>
</background-data>
<rules>
1. 合并旧摘要与新增对话内容,输出一份连贯的中文累积摘要,纯文本格式,字数严格控制在 ${SUMMARY_MAX_CHARS} 字以内;
2. 重点保留:用户关注的技术问题与核心意图、讨论过的文章(Post)标题与完整链接(${BLOG_SITE_URL}/post/...)、已给出的关键结论与技术方案、用户表达的个人偏好、尚未解决的问题或待办事项;
3. 严格忠于原文,不得添加对话中未提及的内容与链接,已有链接必须原样保留(若记录中存在明确包含 postId 的 getPost 工具调用,如 {"postId":15},可保留其对应的标准文章链接 ${BLOG_SITE_URL}/post/15);
4. 若篇幅受限,优先保留较新的对话结论、关键事实与文章链接;
</rules>
<the-ask>
合并生成一份完整的中文累积对话摘要。
</the-ask>
<output-format>
直接输出摘要纯文本正文,严禁包含任何前缀声明(如“摘要:”)、解释说明或 markdown 代码块围栏。
</output-format>`;
}
export async function generateCompactionSummary(params: {
previousSummary?: string;
messagesText: string;
model?: LanguageModel;
}): Promise<string> {
const {
previousSummary,
messagesText,
model = getAuxiliaryModel(),
} = params;
const prompt = buildCompactionPrompt(previousSummary, messagesText);
const { text } = await generateText({
model,
prompt,
maxOutputTokens: SUMMARY_MAX_OUTPUT_TOKENS,
providerOptions: {
alibaba: {
enableThinking: false,
},
},
});
const cleanedSummary = text.trim();
if (!cleanedSummary) {
throw new Error('模型生成的累积滚动摘要为空');
}
return cleanedSummary;
}滚动压缩在每轮对话流结束(onEnd)后通过 runAgentBackgroundTask 异步触发,不阻塞当前轮次的 HTTP 响应收尾。为防止同一会话在连续快速提问时并发执行两个压缩任务,模块维护了按 sessionId 索引的进程内单飞互斥锁(Single-flight Lock)。
实现带单飞互斥锁的异步压缩工作流:
const ongoingCompactions = new Map<string, Promise<void>>();
export async function triggerCompactionIfThresholdReached(
sessionId: string,
dbInstance: any = db,
): Promise<boolean> {
if (ongoingCompactions.has(sessionId)) {
// 已有进行中的同会话压缩任务,直接跳过本次检查
return false;
}
const promise = (async () => {
try {
await performCompactionCheck(sessionId, dbInstance);
} finally {
ongoingCompactions.delete(sessionId);
}
})();
ongoingCompactions.set(sessionId, promise);
await promise;
return true;
}由于服务端的水位压缩仅向 agent_session_compaction 表追加摘要记录,并不删除 agent_message 表中的早期行,前端聊天视图能够通过游标分页(Cursor Pagination)按需向上回溯全部历史消息。
服务端历史消息分页查询(按 seq 倒序取 limit + 1 条判断 hasMore,再反转为升序返回):
export async function getAgentHistory(
userId: number,
sessionId: string,
options?: { beforeSeq?: number; limit?: number },
) {
const limit = Math.max(1, Math.min(50, options?.limit ?? 20));
const beforeSeq = options?.beforeSeq;
const whereConditions = [eq(agentMessageTable.session_id, sessionId)];
if (typeof beforeSeq === 'number') {
whereConditions.push(lt(agentMessageTable.seq, beforeSeq));
}
const rows = await db
.select()
.from(agentMessageTable)
.where(and(...whereConditions))
.orderBy(desc(agentMessageTable.seq))
.limit(limit + 1);
const hasMore = rows.length > limit;
const pageRows = hasMore ? rows.slice(0, limit) : rows;
const ascendingRows = pageRows.slice().reverse();
const formattedMessages = ascendingRows.map((m: any) => {
const sanitizedParts = sanitizeHistoryParts(m.parts);
return {
id: m.id,
seq: m.seq,
role: m.role,
parts: sanitizedParts,
content: extractTextMessage(sanitizedParts),
createdAt: m.create_time,
};
});
const nextBeforeSeq =
hasMore && formattedMessages.length > 0 ? formattedMessages[0].seq : null;
return {
messages: formattedMessages,
hasMore,
nextBeforeSeq,
};
}在客户端组件中,当用户向上滚动接近容器顶部(scrollTop <= 60)时,自动携带 nextBeforeSeq 拉取更早一页消息,并通过记录 DOM 更新前后的 scrollHeight 差值补偿 scrollTop,防止前置插入历史消息时发生视口跳动:
const loadOlderMessages = useCallback(async () => {
if (!hasMore || nextBeforeSeq === null || isLoadingOlder || isGenerating) {
return;
}
setIsLoadingOlder(true);
try {
const res = await fetchAgentHistory(currentSessionId, {
beforeSeq: nextBeforeSeq,
});
// 记录前置插入前的容器滚动高度快照
const container = chatContainerRef.current;
if (container) {
scrollSnapshotRef.current = {
scrollHeight: container.scrollHeight,
scrollTop: container.scrollTop,
};
isPrependModeRef.current = true;
}
// 去重并前置合并早期历史消息
setMessages((prev: any[]) => {
const existingIds = new Set(prev.map((m: any) => m.id));
const newItems = (res.messages || []).filter(
(m: any) => !existingIds.has(m.id),
);
return [...newItems, ...prev];
});
setHasMore(Boolean(res.hasMore));
setNextBeforeSeq(res.nextBeforeSeq ?? null);
} finally {
setIsLoadingOlder(false);
}
}, [hasMore, nextBeforeSeq, isLoadingOlder, isGenerating, currentSessionId]);
// 在 DOM 布局更新后补偿 scrollTop,保持当前阅读位置静止
useIsomorphicLayoutEffect(() => {
if (isPrependModeRef.current) {
isPrependModeRef.current = false;
const container = chatContainerRef.current;
if (container && scrollSnapshotRef.current) {
const heightDelta =
container.scrollHeight - scrollSnapshotRef.current.scrollHeight;
container.scrollTop = scrollSnapshotRef.current.scrollTop + heightDelta;
scrollSnapshotRef.current = null;
}
return;
}
scrollToBottom();
}, [messages, status, scrollToBottom]);