返回博客列表
四

四层上下文管理:我是如何让AI助手不再"失忆"的

2026年4月24日·12 分钟阅读精选
AILLMContext-Management

目录

  • 引子
  • 固定窗口的问题
  • 第一层:Token计数与动态窗口
  • 不用Tokenizer估算Token
  • 模型感知的上下文窗口
  • 计算Token预算
  • 按预算选择消息,而非按条数
  • 第二层:对话摘要
  • 何时触发
  • 新旧消息分割
  • 摘要保留什么
  • 第三层:关键信息提取
  • 提取什么
  • 何时提取
  • 增量合并
  • 第四层:系统提示词压缩
  • 压缩策略
  • 项目
  • 个人作品集
  • 自动降级
  • 四层如何协同工作
  • 客户端-服务端契约
  • 效果对比
  • 经验总结

目录

  • 引子
  • 固定窗口的问题
  • 第一层:Token计数与动态窗口
  • 不用Tokenizer估算Token
  • 模型感知的上下文窗口
  • 计算Token预算
  • 按预算选择消息,而非按条数
  • 第二层:对话摘要
  • 何时触发
  • 新旧消息分割
  • 摘要保留什么
  • 第三层:关键信息提取
  • 提取什么
  • 何时提取
  • 增量合并
  • 第四层:系统提示词压缩
  • 压缩策略
  • 项目
  • 个人作品集
  • 自动降级
  • 四层如何协同工作
  • 客户端-服务端契约
  • 效果对比
  • 经验总结

引子

每个做过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保留四类信息:

  1. 核心问题和需求 — 用户到底在问什么?
  2. 话题和结论 — 讨论了什么,得出了什么结论?
  3. 偏好和背景 — 我们对用户了解多少?
  4. 未解决的问题 — 还有什么待处理?

生成的摘要通常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

经验总结

  1. 启发式Token估算做预算够用了。 你不需要精确的Token计数来决定包含多少消息。一个感知CJK的启发式算法误差在10-15%以内,做分配决策绰绰有余。

  2. 摘要昂贵但必要。 每次摘要调用都会增加延迟和成本。关键在于只在阈值触发时执行,而非每次请求,并将结果缓存在客户端供后续请求使用。

  3. 压缩有收益递减。 从详细到紧凑可以节省约60%,但过度压缩会丢失细节。两级方案(完整版 vs 压缩版)是正确的平衡——负担得起时用丰富版,负担不起时用紧凑版。

  4. 客户端状态保持服务端简洁。 在客户端存储摘要和提取信息意味着服务端保持无状态。没有Redis,没有数据库,没有会话管理。代价是刷新页面会丢失摘要——但对于作品集聊天助手来说,这是可接受的权衡。

  5. 四层是互补的,不是冗余的。 Token预算防止溢出。摘要保留叙事。提取保留事实。提示词压缩创造余量。每一层解决问题的不同方面,它们协同工作效果最好。