使用 Prompt API 进行会话压缩

Published: June 23, 2026

每个 LanguageModel 会话都有一个有限的上下文 窗口。随着对话的进行,模型会在其上下文中累积完整的消息历史记录:每条用户提示和每条助理回复。当窗口填满时,浏览器的自动溢出处理功能会启动。它会逐个删除最旧的消息对(一条提示和一条回复),以释放空间来容纳新提示。如果传入的提示过大,即使删除整个对话历史记录也无法容纳,则调用会直接失败,并显示 QuotaExceededError

会话压缩是一种主动替代方案:使用 Summarizer API 总结对话 历史记录,然后使用这些摘要作为 initialPrompts 重新启动新 会话。浏览器在运行时溢出处理期间绝不会删除 initialPrompts,因此,只要摘要本身在调用 create() 时适合上下文窗口,压缩后的摘要就会永久锚定在模型的上下文中。新会话将以原始令牌成本的一小部分来执行相同的对话线程。

会话压缩为长期存在的 LanguageModel 对话提供了一种在不丢失连续性的情况下保持在上下文窗口内的方法。关键步骤如下:

  1. 相对于 contextWindow 监控 contextUsage,并将其显示给用户。
  2. 监听 contextoverflow 事件,以便发出预警。
  3. 使用 Language Detector API 检测每条消息的语言,然后使用支持语言的 Summarizer API 实例对其进行总结。
  4. 销毁旧会话,并使用 initialPrompts 播种新会话。
  5. 保留 fullHistory 副本以进行错误恢复。

跟踪上下文使用情况

Prompt API 公开了两个属性,用于监控会话上下文的完整程度:

  • session.contextUsage:当前使用的令牌数量。
  • session.contextWindow:会话的总令牌容量。

<progress> 元素中反映这一点,以便用户一眼就能知道 会话距离其限制有多近。直接将 valuemax 设置为令牌计数;浏览器会自动缩放条形图:

<progress id="token-bar" value="0" max="1"></progress>
<label for="token-bar" id="token-label">Context: — / — tokens</label>
function updateTokenDisplay(session) {
  const usage = session.contextUsage;
  const total = session.contextWindow;

  tokenBar.value = usage;
  tokenBar.max = total;
  tokenLabel.textContent =
    `${Math.round(usage)} / ${Math.round(total)} tokens ` +
    `(${Math.round((usage / total) * 100)}%)`;
}

在每次提示响应后调用 updateTokenDisplay(),以确保条形图保持最新状态。

监听上下文溢出

当新提示超出剩余上下文时,浏览器的自动恢复功能会启动:它会逐个删除最旧的提示和响应对,直到释放足够的空间。contextoverflow 事件会在开始删除时触发。在创建会话后立即注册处理程序:

session.addEventListener('contextoverflow', () => {
  showWarning('⚠ Context window nearly full. Consider compacting the session.');
});

此删除行为有两个重要属性:

  • initialPrompts 不会在运行时被删除。浏览器不会删除它们来为传入的提示腾出空间。但是,如果传递给 LanguageModel.create()initialPrompts 的总大小本身过大,无法容纳在上下文窗口中,则 create() 会拒绝并显示 QuotaExceededError,因此请确保压缩足够小,以便继续对话。
  • 删除有上限。如果传入的提示过大,即使删除整个之前的对话也无法容纳,则 prompt()promptStreaming() 调用会失败,并显示 QuotaExceededError,并且不会删除任何内容。

如需详细了解 上下文溢出 处理,请参阅 Prompt API 文档。

在浏览器开始静默舍弃对话历史记录之前,使用 contextoverflow 事件警告用户、停用发送按钮或自动触发压缩。

压缩会话

压缩包含三个步骤:

  1. 使用 Summarizer API 总结对话历史记录中的每条消息。
  2. 销毁旧会话。
  3. 使用摘要作为 initialPrompts 创建新会话。

总结历史记录

Summarizer API 非常适合压缩单个聊天消息。对于每条消息,首先 使用 Language Detector API 检测其语言,以便正确配置 总结器:

async function detectLanguage(text, threshold = 0.7) {
  const detector = await LanguageDetector.create();
  const results = await detector.detect(text);
  if (results.length > 0 && results[0].confidence >= threshold) {
    return results[0].detectedLanguage;
  }
  return null; // confidence too low — caller falls back to navigator.language
}

0.7 置信度阈值可避免对不确定的检测结果采取行动。当置信度低于阈值时,回退到 navigator.language

接下来,创建一个针对检测到的语言配置的总结器。首选 preference: 'speed' 以选择较小、延迟较低的模型变体,如果较快的模型不支持检测到的语言,则 回退到 preference: 'auto'

const summarizers = {}; // cache, keyed by `${format}:${lang}`

async function getSummarizer(format, lang) {
  const key = `${format}:${lang}`;
  if (summarizers[key]) return summarizers[key];

  const baseOptions = {
    type: 'tldr',
    format, // 'markdown' or 'plain-text'
    length: 'short',
    expectedInputLanguages: [lang],
    expectedContextLanguages: [lang],
    outputLanguage: lang,
  };

  let options = { ...baseOptions, preference: 'speed' };
  let avail = await Summarizer.availability(options);

  if (avail === 'unavailable') {
    options = { ...baseOptions, preference: 'auto' };
    avail = await Summarizer.availability(options);
  }

  if (avail === 'unavailable') {
    throw new Error('Summarizer API unavailable on this device.');
  }

  summarizers[key] = await Summarizer.create(options);
  return summarizers[key];
}

为每个 format+lang 对缓存总结器可避免在连续消息共享相同语言时进行冗余的 create() 调用。

format 实参派生自消息内容本身。为纯散文指定 'markdown'可能会引入不必要的格式,而为 Markdown 指定 'plain-text'会剥离代码围栏和强调。一个小的正则表达式可以区分这两者:

function looksLikeMarkdown(text) {
  return /(?:^#{1,6} |^[-*+] |\d+\. |\*\*|__|\[.+?\]\(|^> |^```)/m.test(text);
}

在解析语言和格式后,总结每条消息并传递 context 字符串,以便模型了解它正在压缩聊天轮次,而不是独立文档:

const compacted = [];

for (const msg of history) {
  const lang = (await detectLanguage(msg.content)) ?? navigator.language;
  const format = looksLikeMarkdown(msg.content) ? 'markdown' : 'plain-text';
  const summarizer = await getSummarizer(format, lang);

  const summary = await summarizer.summarize(msg.content.trim(), {
    context:
      `This is a ${msg.role} turn from a chat conversation. ` +
      `Preserve its key meaning as concisely as possible.`,
  });

  // Only use the summary if it's actually shorter.
  compacted.push({
    role: msg.role,
    content:
      summary.trim().length < msg.content.length ? summary.trim() : msg.content,
  });
}

销毁旧会话

在创建替换项之前,释放旧会话的资源:

session.destroy();
session = null;

使用压缩后的历史记录创建新会话

将压缩后的消息作为 initialPrompts 传递,以使用对话上下文播种新会话:

// Collect every language the detector was confident about.
const sessionLangs =
  confidentLangs.size > 0 ? [...confidentLangs] : [navigator.language];

session = await LanguageModel.create({
  expectedInputs: [{ type: 'text', languages: sessionLangs }],
  expectedOutputs: [{ type: 'text', languages: sessionLangs }],
  initialPrompts: compacted,
});

// Re-register the overflow handler on the new session.
session.addEventListener('contextoverflow', () => {
  /* ... */
});

新会话以较低的 contextUsage 开始。对话从中断的地方继续:模型将摘要作为其先前的上下文,因此它可以回答有关先前主题的后续问题。

处理错误

如果旧会话已销毁后,总结或会话创建失败,用户将无法聊天。维护一个单独的 fullHistory 数组,该数组永远不会被压缩覆盖,并将其用作恢复回退:

const history = []; // current session's view, replaced on each compaction
const fullHistory = []; // every original message, never overwritten

// In the catch block:
if (!session) {
  session = await LanguageModel.create({
    initialPrompts: fullHistory.map(({ role, content }) => ({ role, content })),
  });
  session.addEventListener('contextoverflow', () => {
    /* ... */
  });
}

fullHistory 恢复可能会再次使上下文接近容量,但用户至少恢复到了工作状态,并且可以立即尝试其他压缩。

(可选)阻止压缩某些内容

如果消息中有必须始终保留在上下文中的关键部分(例如代码示例),请单独处理这些部分。以下示例将消息拆分为散文和代码围栏段交替出现的形式,然后仅总结散文部分,同时保留代码段不变:

// Splits text into alternating prose and code-fence segments.
// Returns [{ type: 'prose'|'code', content: string }, …]
function splitByCodeFences(text) {
  const parts = [];
  const re = /^```[^\n]*\n[\s\S]*?^```[ \t]*$/gm;
  let lastIndex = 0;
  let match;
  while ((match = re.exec(text)) !== null) {
    if (match.index > lastIndex) {
      parts.push({
        type: 'prose',
        content: text.slice(lastIndex, match.index),
      });
    }
    parts.push({ type: 'code', content: match[0] });
    lastIndex = match.index + match[0].length;
  }
  if (lastIndex < text.length) {
    parts.push({ type: 'prose', content: text.slice(lastIndex) });
  }
  return parts;
}

查看演示

会话压缩演示让您可以随时与 Prompt API 聊天并压缩会话。令牌条形图显示实时上下文使用情况,并随着上下文的填满而更改颜色。每次压缩后,日志条目都会记录压缩前后的令牌计数,以便您可以直接观察减少量。

您可以在页面底部的可折叠 Debug: conversation JSON 部分中检查完整和压缩的对话 JSON。

源代码可以在 GitHub 上找到。