引子
每个做过LLM聊天应用的开发者最终都会撞上同一堵墙:模型开始"遗忘"对话中较早说过的内容。我的作品集网站的AI助手也不例外。原始实现使用了硬编码的20条消息滑动窗口——超出部分被静默丢弃。没有摘要,没有压缩,没有任何保留。就这么没了。
这对快速问答没问题。但当用户进行真正的对话——追问、引用之前的观点、在前一个回答的基础上深入——硬截断的弊端就暴露无遗。AI自相矛盾,重复询问用户已经提供过的信息,或者完全丢失线索。
我决定彻底解决这个问题。不是贴个创可贴,而是构建一个分层系统,每一层解决问题的不同方面。以下是我做了什么以及为什么这样做。
固定窗口的问题
原始代码简单到不能再简单:
const MAX_CONTEXT_MESSAGES = 20;
const contextMessages = [...currentMessages, userMessage].slice(
-MAX_CONTEXT_MESSAGES
);
这有几个叠加的问题:
| 问题 | 为什么重要 |
|---|---|
| 硬截断 | 窗口之前的所有内容都消失了——无法恢复,没有摘要 |
| 无Token感知 | 20条短消息和20条长消息消耗的上下文量天差地别 |
| 臃肿的系统提示词 | 简历模式下,完整的作品集上下文吃掉了大部分上下文窗口 |
| 与模型无关 | 不管模型有128K还是200K tokens,限制都一样 |
| 任意的总长度限制 | 10,000字符的上限是凭空定的 |
核心洞察:消息条数是上下文消耗的糟糕代理指标。你真正需要管理的是Token。
第一层:Token计数与动态窗口
不用Tokenizer估算Token
在服务端运行真正的tokenizer(比如tiktoken)会引入重型依赖。我转而构建了一个启发式估算器,精度足够做预算分配:
const CJK_REGEX = /[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]/;
export function estimateTokens(text: string): number {
if (!text) return 0;
let cjkCount = 0;
let otherCount = 0;
for (const char of text) {
if (CJK_REGEX.test(char)) {
cjkCount++;
} else {
otherCount++;
}
}
// CJK字符 ≈ 1.5 tokens,英文/其他 ≈ 0.25 tokens
return Math.ceil(cjkCount * 1.5 + otherCount * 0.25);
}
这个启发式算法之所以有效,是因为CJK字符在大多数现代tokenizer中通常编码为1-2个token,而英文文本平均约4个字符一个token(因此每字符0.25 token)。对于中英混合的聊天场景,误差控制在10-15%以内——用来决定包含多少消息绰绰有余。
模型感知的上下文窗口
不同模型的上下文窗口差异巨大。一个查找表就能搞定:
const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
'glm-4-flash': 128000,
'glm-4-long': 1000000,
'gpt-4o-mini': 128000,
'gpt-4o': 128000,
'claude-3-haiku-20240307': 200000,
'claude-3-5-sonnet-20241022': 200000,
};
系统从环境变量自动检测当前使用的模型,选择对应的窗口大小。不再有一刀切的限制。
计算Token预算
对话消息的可用预算是扣除其他所有开销后的剩余部分:
预算 = 上下文窗口 - 系统提示词 - 摘要 - 提取信息 - 响应预留
4,096的响应预留Token确保模型始终有空间生成回复,即使上下文几乎满了。
按预算选择消息,而非按条数
不再保留最近N条消息,而是从最新消息开始向前累加Token,直到触及预算:
export function selectMessagesByBudget(
messages: Array<{ role: string; content: string }>,
budget: number
): Array<{ role: string; content: string }> {
let usedTokens = 0;
const selected: Array<{ role: string; content: string }> = [];
for (let i = messages.length - 1; i >= 0; i--) {
const msgTokens = estimateTokens(messages[i].content) + 4; // +4为角色开销
if (usedTokens + msgTokens > budget) break;
usedTokens += msgTokens;
selected.unshift(messages[i]);
}
return selected;
}
这最大化了上下文利用率,不受消息长度影响。短消息的对话自然包含更多条;长消息的对话包含更少——但始终在预算之内。
第二层:对话摘要
动态窗口解决了溢出问题,但没有解决信息丢失问题。当旧消息被预算排除时,它们的内容仍然消失了。摘要通过将旧消息压缩为持久的紧凑叙事来修复这个问题。
何时触发
我将阈值设为 40,000 tokens ——约为128K上下文窗口的30%。这为摘要本身、最近消息和系统提示词留出了足够空间。
export function shouldSummarize(
messages: Array<{ role: string; content: string }>,
existingSummary: string | null,
threshold: number = 40000
): boolean {
if (existingSummary) return false; // 已有摘要时不重复摘要
const messagesTokens = estimateMessagesTokens(messages);
return messagesTokens >= threshold;
}
关键设计决策:一旦摘要存在,不会在每次请求时重新摘要。摘要是增量维护的,缓存在客户端。
新旧消息分割
触发摘要时,消息被分为两组:
- 旧消息 → 发送给AI生成摘要
- 最近消息 → 原样保留(使用阈值一半作为最近消息的预算)
export function splitMessagesForSummary(
messages: Array<{ role: string; content: string }>,
threshold: number = 40000
) {
const recentTokenBudget = Math.floor(threshold / 2);
let recentTokens = 0;
let splitIndex = messages.length;
for (let i = messages.length - 1; i >= 0; i--) {
const msgTokens = estimateTokens(messages[i].content) + 4;
if (recentTokens + msgTokens > recentTokenBudget) {
splitIndex = i + 1;
break;
}
recentTokens += msgTokens;
}
// 始终至少保留最后2条消息
splitIndex = Math.min(splitIndex, Math.max(0, messages.length - 2));
return [
messages.slice(0, splitIndex), // 旧的 → 摘要
messages.slice(splitIndex), // 最近的 → 保留
];
}
摘要保留什么
摘要提示词指示AI保留四类信息:
- 核心问题和需求 — 用户到底在问什么?
- 话题和结论 — 讨论了什么,得出了什么结论?
- 偏好和背景 — 我们对用户了解多少?
- 未解决的问题 — 还有什么待处理?
生成的摘要通常300-500字——原始消息的一小部分,但包含所有关键上下文。
第三层:关键信息提取
摘要保留了叙事,但有些信息值得被提取为结构化的持久上下文。可以把它理解为读故事和有张备忘录的区别。
提取什么
提取提示词产生分类的、带标签的输出:
[偏好] 用户更喜欢TypeScript而非JavaScript
[需求] 正在寻找高级前端职位
[背景] 5年React开发经验
[待办] 仍需要项目部署方面的帮助
这种结构化格式让AI能方便地引用特定类别的信息。当用户说"像我之前提到的",AI可以查看提取的偏好,而不必扫描整个对话。
何时提取
提取在两个时机触发:
- 摘要发生时 — 这是天然的时间点,我们已经在处理对话了
- 用户发送了 ≥ 6 条消息且尚无提取信息时 — 这捕获了对话有实质内容但还没达到摘要阈值的情况
增量合并
与摘要类似,提取信息支持增量更新。如果提取信息已存在,新对话会与已有信息一起处理,生成更新版本——合并过程中不会丢失信息。
第四层:系统提示词压缩
在简历模式下,系统提示词包含完整的作品集上下文:简历内容、基本信息、技能、项目、工作经历。这可能消耗数千Token,留给对话的空间就更少了。
压缩策略
压缩格式将冗长的段落转换为紧凑的单行条目:
压缩前:
## 项目
### 个人作品集
- 描述:使用Next.js构建的现代作品集网站
- 技术栈:Next.js、TypeScript、Tailwind CSS
- 精选项目
- AI 驱动
压缩后:
[项目] 个人作品集[Next.js,TypeScript,Tailwind CSS]*AI
简历内容本身截取前500字符(带...指示)。技能只列名称不列熟练度。项目完全去掉描述,只保留标题和技术栈。工作经历压缩为角色@公司(时间段)。
这通常能将作品集上下文减少约60%,同时保留AI回答问题所需的所有信息。
自动降级
系统不会默认压缩。它首先用完整系统提示词计算Token预算。只有当预算不足以容纳对话消息时,才切换到压缩版:
const messagesTokens = estimateMessagesTokens(activeMessages);
if (messagesTokens > tokenBudget && mode === 'resume') {
const compressedSystemPrompt = await getResumePrompt(locale, true);
const compressedBudget = calculateTokenBudget(compressedTokens, ...);
if (compressedBudget > tokenBudget) {
useCompressedPrompt = true;
tokenBudget = compressedBudget;
}
}
这意味着短对话获得完整的、丰富的系统提示词。长对话自动降级到压缩版,为更多消息腾出空间。用户完全感知不到这个切换。
四层如何协同工作
每个进入的请求按顺序流经所有四层:
客户端发送:所有消息 + 缓存的摘要 + 缓存的提取信息
│
├─ 1. Token数 ≥ 40K?
│ 是 → 分割消息,AI生成摘要,仅保留最近消息
│
├─ 2. 摘要触发了 或 ≥6条用户消息且无提取信息?
│ 是 → AI提取结构化的偏好/需求/背景
│
├─ 3. 计算Token预算
│ 预算 = 窗口 - 系统提示词 - 摘要 - 提取信息 - 预留
│
├─ 4. 预算太小装不下所有消息?
│ 是 → 切换到压缩版系统提示词,重新计算预算
│
├─ 5. 按预算选择消息(从最新开始累加)
│
├─ 6. 将摘要 + 提取信息合并到系统提示词
│
├─ 7. 用选中的消息 + 增强的系统提示词调用AI
│
└─ 8. 返回响应 + 通过响应头返回更新后的摘要/提取信息
客户端-服务端契约
摘要和提取信息存储在客户端,随每次请求发送。当服务端更新它们时,通过响应头返回新值:
// 服务端:编码后通过响应头发送
responseHeaders['X-Conversation-Summary'] = encodeURIComponent(currentSummary);
responseHeaders['X-Extracted-Info'] = encodeURIComponent(currentExtractedInfo);
// 客户端:读取并更新本地状态
const summaryHeader = response.headers.get('X-Conversation-Summary');
if (summaryHeader) {
setConversationSummary(decodeURIComponent(summaryHeader));
}
这保持了服务端的无状态性——没有会话存储,没有数据库——同时仍在对话内的请求间维持上下文连续性。
效果对比
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 上下文选择 | 固定20条消息 | 基于Token预算动态选择 |
| 长对话支持 | 硬截断,早期上下文丢失 | 摘要保留关键要点 |
| 用户偏好保留 | 无 | 结构化提取跨请求持久化 |
| 系统提示词效率 | 始终加载完整作品集 | 预算紧张时自动压缩 |
| 模型适配性 | 所有模型相同限制 | 按模型上下文窗口调整 |
| 总长度限制 | 10,000字符(任意值) | 模型上下文窗口 × 3 字符/Token |
经验总结
-
启发式Token估算做预算够用了。 你不需要精确的Token计数来决定包含多少消息。一个感知CJK的启发式算法误差在10-15%以内,做分配决策绰绰有余。
-
摘要昂贵但必要。 每次摘要调用都会增加延迟和成本。关键在于只在阈值触发时执行,而非每次请求,并将结果缓存在客户端供后续请求使用。
-
压缩有收益递减。 从详细到紧凑可以节省约60%,但过度压缩会丢失细节。两级方案(完整版 vs 压缩版)是正确的平衡——负担得起时用丰富版,负担不起时用紧凑版。
-
客户端状态保持服务端简洁。 在客户端存储摘要和提取信息意味着服务端保持无状态。没有Redis,没有数据库,没有会话管理。代价是刷新页面会丢失摘要——但对于作品集聊天助手来说,这是可接受的权衡。
-
四层是互补的,不是冗余的。 Token预算防止溢出。摘要保留叙事。提取保留事实。提示词压缩创造余量。每一层解决问题的不同方面,它们协同工作效果最好。