AI 对话越长越降智?聊聊 Claude Code 上下文压缩的两层思路

0 阅读13分钟

我们在 为什么生产环境很少手写流式响应:AI SDK 三层架构一次讲清 讲了手写流式响应会踩哪些坑,以及为什么生产里都把这件事交给 AI SDK 来做。

而多轮对话做起来,很快会碰到另一个问题:

聊到后面,AI 开始降智了!

刚纠正过的要求下一轮没记住,修好的 bug 再次出现,明确说过不做的功能又被提起来。

原因在上下文这件事情上:

聊长了,发给模型的内容越来越重,重点开始被噪音淹掉。

我们今天就从 Claude Code 的 /compact 功能入手,把上下文压缩的两层逻辑拆开讲:

什么时候压、压哪块、压完怎么重组,是第一层。

压缩质量靠什么保证,是第二层。

先划重点

AI 模型本身不记事。 多轮对话能连贯,靠的是服务端每轮把前面的历史重新发一遍。

对话越长,回答质量越容易下降。 重点开始和噪音混在一起,模型找到真正该处理的事越来越难。

不需要把整段上下文历史一起压缩。长期规则system 留在头部,当前活跃话题所在的最近几轮保留原文,更早的旧历史才交给 AI 生成摘要。

随便让模型总结,和给它一份具体的压缩清单,压出来差别很大。 Claude Code 用 9 个具体段落规定了压完之后必须保留哪些信息。

💡 下节预告:从 Claude Code 的实现全景出发,看 Agent 的运行链路是怎么被 Harness 组织起来的。


每轮请求,都在把整段历史重发一遍

AI 模型在请求结束之后,不会保留任何状态。

每一轮还能接着聊,靠的是你的服务端接口(比如 Next.js 里的 API route)在做事:

收到用户新消息后,把前面所有对话一起打包,再发给大模型 API。

写成代码大概是这样:

const messages = [
	{ role: 'system', content: '你是一个学习助手,始终用中文回答。' },
	{ role: 'user', content: '我在学习上下文管理怎么做。' },
	{ role: 'assistant', content: '聊天应用通常会把历史消息重新发给模型……' },
	{ role: 'user', content: '我更喜欢短一点的例子,不喜欢泛泛定义。' }
];

system长期规则messages[] 放来回问答的历史

每次请求带上这些上下文,模型才知道前面发生了什么。

messages[] 里存的是一段可以回放的会话历史,聊天内容只是其中一部分。

哪句是用户说的,

哪句是 AI 说的,

第几轮说的,

这些关键点记录清楚了,后面做类似编辑第三轮的问题重新回答这类操作,才有锚点。


上下文越长,重点越容易被稀释

第一轮发出去的 messages[],只有一条用户消息。

第二十轮发出去的,是前面十九轮所有问答,加上这一轮的新问题,体积已经不是一个量级了。

messages-growth.png

对话变长本身影响不大,难的是重点开始和噪音混在一起。

寒暄、重复确认、已经解决的问题、旧的中间过程,这些内容一开始都合理,聊长了之后却越来越占地方。

而放长期规则的 system prompt 不能丢、这一轮用户的最新问题要放进去、模型的回答也得留空间。

但是上下文窗口就这么大。

塞进去的噪音越多,模型找到真正该处理的事就越难。

所以对话不能无限放养,得有人在发请求前,先整理一遍。


Claude Code 把这件事拆成两层

压缩这件事有两个独立的问题,需要分开处理。

首先是是先把上下文历史整理好。

什么时候触发压缩把哪块历史送去压缩压完怎么重组 messages[]

也就是压缩策略的实现。

这层逻辑跑在应用层,在把请求发给模型之前就完成了。

其次是把压缩要求交代清楚。

上一层被挑出来的旧历史,最后是谁来负责压缩? 还是交给 AI。

应用层先把该压的那段挑出来,再单独发给模型,请它整理成一段摘要。

在这次压缩请求里,写好压缩 prompt,模型才知道什么该留,什么不能丢。

这层决定压缩质量。

这两层互相依赖。

第一层:应用层决策链

整条链路怎么走

在 Claude Code 里,一旦当前上下文超了预算,应用层不会把整段历史一起送去压缩。

它会先分三类:

放长期规则的 system 留在头部不动。

当前活跃话题所在的最近几轮保留原文。

更早的旧历史单独交给 AI 生成摘要。

摘要整理干净之后,再塞回 messages[],继续往下聊。

整条链路大概是这样:

mermaid-决策链.png

context-sort.png

三类消息怎么处理

system长期规则,反复压缩之后摘要模型会用自己的话改写,久了约束会失真,所以固定在头部不参与压缩

最近几轮是当前活跃话题,细节最密集,保留原文。

只有更早的旧历史送去做摘要,信息密度降下来,关键内容还在。

分拣这一步怎么写

把 Claude Code 里这一步的处理逻辑提炼成伪代码,大概就是这样:

/**
 * 超预算才触发压缩,能不压就不压
 */
if (currentTokens <= budget) return messages;

const system = messages.filter(m => m.role === 'system');
const rest = messages.filter(m => m.role !== 'system');

/**
 * 最近几轮保留原文,保证当前话题不断线
 */
const recent = rest.slice(-RECENT_COUNT);

/**
 * 更早的旧历史送去压缩,提炼关键信息
 */
const toCompress = rest.slice(0, -RECENT_COUNT);

const raw = await llm.generate({
	systemPrompt: COMPACT_SYSTEM_PROMPT,
	messages: [...toCompress, { role: 'user', content: compactPrompt }]
});

这里的几个变量其实就在回答三件事:

哪部分不能动,哪部分要保留原文,哪部分适合交给 AI 去压。

system 是不能动的长期规则;

recent 是当前这轮最相关的上下文;

toCompress 才是那段真正适合拿去做摘要的旧历史。

压完之后怎么接回去

旧历史交给模型之后,后面还有两步:

先把模型原始输出清洗干净;

再把它封成一条会话从这里接着往下聊的交接消息,重新塞回上下文。

const summary = formatCompactSummary(raw);

return [...system, buildContinuationMessage(summary), ...recent];

formatCompactSummary 负责清洗:

模型的原始输出里会夹带草稿、标签、多余空行,这一步把它整理成一段干净的纯文本摘要。

buildContinuationMessage 负责封装:

把这段纯文本包成一条 role: 'user' 的消息,消息开头带一句「会话从这里接续」的引导语,让模型知道这是交接,不是用户的新问题。

回填之后长什么样?

压完之后,旧历史那段不再原样带回去,而是换成一条「会话从这里接着往下聊」的交接消息:

This session is being continued from a previous conversation that ran out of context.
The summary below covers the earlier portion of the conversation.

[formattedSummary]

前端页面看到的完整聊天记录依然不变,但真正发给模型的,已经换成 system + 摘要 + 最近几轮 这个更紧凑的组合。

压缩前后的 messages[] 放在一起看大概是这样的:

/**
 * 压缩前(第 20 轮,messages[] 长度 40)
 */
[system, user1, assistant1, ..., user19, assistant19, user20]

/**
 * 压缩后(同一轮,长度 6)
 */
[system, continuationMessage(summary), user19, assistant19, user20, /* 新回答占位 */]

摘要就是一条普通 message,插在 system 之后、最近几轮之前。

第二层:压缩请求的质量

哪段历史该压,前面已经分完了。

到了这一层,真正要看的,是这段历史怎么压

Claude Code 把旧历史挑出来之后,会单独发起一次压缩请求,让 AI 生成摘要

整条链路是这样的: mermaid-compact-prompt.png

这条链路里,有两件事决定压缩完之后 AI 还能不能接着干活:

发给模型的 prompt 要求它保留哪些信息

模型按什么格式把摘要写出来

压缩 prompt 一旦写得太泛,AI 就会越压越「降智」

想象你跟 AI 聊了 20+ 轮来调一个 bug:

你刚把筛选条件从「全部」切到「进行中」,页面也重新请求了,出来的却还是刚才那批数据。

中间先查过接口返回,没问题。

又怀疑是不是筛选参数没带对,验过也不是。

最后定位到,问题出在 React 的 state 更新上:

筛选条件虽然切了,但请求发出去时拿到的还是旧 state,所以列表总会慢一拍。

如果这时候压缩请求里只写一句「请总结,保留关键信息」,模型大概会返回这样一段摘要:

「用户在排查一个列表筛选失效的问题,尝试了多个方向,最终定位并修复。」

读起来没问题,但是下一轮继续往下做时最关键的线索都没了

React state 这条真正的根因没了,接口返回和筛选参数这两个已经排除过的方向也没了。

下一轮接着聊,AI 很可能又建议你回头去查接口,或者继续怀疑是不是参数没带对。

丢的地方,刚好是后面最容易再踩的地方。

返回格式要求:让 AI 先打草稿,再写正式摘要

光靠告诉 AI 要保留什么还不够,还要把返回格式设计好。

Claude Code 的压缩 prompt 要求 AI 在同一次响应里返回两个部分

前面是 <analysis>,后面是 <summary>

<analysis>
[整理思路的草稿区,不进入最终上下文]
</analysis>

<summary>
[最终要保留的正式摘要]
</summary>

但最后用到的只有 <summary><analysis> 会被清洗函数剥掉。

draft-first.png

/btw 👉 那为什么不直接告诉模型:你先自己分析一遍,再只返回 summary,非要它把分析过程也写出来?

语言模型没有「先想好再开口」这回事,它是一个 token 一个 token 往外写的,写出来的就是它的思考过程

你叫它「心里先分析一遍」,它做不到,因为没有写出来的 token 对后面的输出没有任何影响

<analysis> 写进格式,模型在这段里会逐条过一遍对话内容,等到写 <summary> 的时候,前面的分析已经在上下文里了,摘要是接着这段草稿往下写的。

<analysis> 是模型在这次压缩里的草稿纸,人不用把它带进下一轮上下文。

用完之后,使用清洗函数 formatCompactSummary 把它剥掉,不进下一轮上下文:

export function formatCompactSummary(summary: string): string {
	let result = summary;
	/**
	 * 删除 analysis 草稿区
	 */
	result = result.replace(/<analysis>[\s\S]*?<\/analysis>/, '');

	/**
	 * 提取 summary 内容,加 “Summary:” 标题
	 */
	const match = result.match(/<summary>([\s\S]*?)<\/summary>/);
	if (match) {
		result = result.replace(
			/<summary>[\s\S]*?<\/summary>/,
			`Summary:\n${match[1].trim()}`
		);
	}
	return result.replace(/\n\n+/g, '\n\n').trim();
}

所以真正会被回填进 messages[] 的,只有清洗之后的 <summary>

<analysis> 帮这次压缩把思路先捋清楚,不会进入下一轮上下文继续占位置。

压缩 prompt 里规定生成的摘要内容必须包含 9 个 section

上面解决了返回格式的问题,接下来是内容:

生成的摘要里到底要包含什么。

Claude Code 的压缩 prompt 里直接写了一句:

Your summary should include the following sections

后面紧跟着 9 个 section 的具体要求:

  • Primary Request and Intent:用户想做什么,显式提过哪些请求和意图
  • Key Technical Concepts:涉及哪些技术、框架和架构判断
  • Files and Code Sections:改了哪些文件,哪些代码片段最关键,为什么重要
  • Errors and fixes:踩了什么坑,怎么修的,用户对修法有没有反馈
  • Problem Solving:解决了什么问题,还在排查什么
  • All user messages:用户原话全文,不做 AI 改写
  • Pending Tasks:还有哪些事没做完
  • Current Work:压缩那一刻手上正在做什么
  • Optional Next Step:下一步应该接哪一步,最好能贴住最近这轮工作

这 9 个 section 写在 prompt 里是要求;

而 AI 按要求输出之后,全部落在 <summary> 里面。

压缩 prompt 里的要求
───────────────────────
"Your summary should include the following sections:
 1. Primary Request and Intent ...
 ...
 9. Optional Next Step ..."

        ↓ 模型按要求返回 ↓

<analysis>
[草稿区,整理思路用]
</analysis>

<summary>
1. Primary Request and Intent:
   用户想给筛选组件加一个「进行中」的状态...
2. Key Technical Concepts:
   - React state 闭包问题...
...
9. Optional Next Step:
   继续处理分页逻辑...
</summary>

        ↓ formatCompactSummary 清洗 ↓

Summary:
1. Primary Request and Intent:
   用户想给筛选组件加一个「进行中」的状态...
...

prompt 里规定写什么 → 模型输出到 <summary> 里 → 清洗后变成最终摘要。

9 个 section 从头到尾就是这条线。

nine-section-framework.png

为什么用户意图和用户原话要分开写?

Primary Request and IntentAll user messages 看起来都在写用户要什么,但 Claude Code 没把它们合在一起。

Primary Request and Intent 负责提炼用户当前到底想做什么,把目标和意图收成一个明确判断

All user messages 不提炼,直接保留用户原话

像「这里不要大改」「按这个方向继续」这种话,一旦只剩摘要转述,语气很容易被改软,后面就会慢慢走偏。

用户意图要提炼,用户原话要保真。

混在一起,要么意图收不出来,要么原话里的约束和语气会被改软。

做了什么、改了哪里、踩了什么坑

Key Technical ConceptsFiles and Code SectionsErrors and fixesProblem Solving 负责记录前面已经做了什么。

技术判断、改动位置、踩过的坑、排查到哪一步,都在这里。

还剩什么没做完、下一步接哪里

Pending TasksCurrent WorkOptional Next Step 负责交代压缩完之后怎么往下接。

还没做完的事是什么,压缩那一刻手上正在做什么,下一步应该接哪一步。

这三段决定了压缩之后,AI 还能不能直接接着做事。


现在来想想以下问题

Q1:压缩时为什么不把 system prompt 也一起送去做摘要,反而要钉住不动?

💡 摘要模型会用自己的话改写,长期规则经过改写后约束会失真,所以 system 固定在头部不参与压缩。

Q2:压缩之后模型开始夹英文了,用户没有改语言偏好。你第一反应会先查哪里?

💡 压缩之后发给模型的不只有摘要,长期规则不在摘要里,压完之后 system prompt 需要重新带回去。

Q3:假设你在做一个客服 Bot,用户主要是咨询产品功能和售后问题,没有代码。对照这 9 个 section,哪些可以直接去掉?

💡 Files and Code Sections、Key Technical Concepts 可以去掉,因为场景里没有代码和技术判断。Errors and fixes 也可以换成"用户反馈过的不满意回答",更贴场景。


感谢您的阅读~🌹

我在微信公众号 前端Fusion 中也会持续同步更新关于 AI 与前端开发的相关文章,欢迎大家关注,一起交流学习。

分享底图_压缩.png