1. 上下文窗口不是免费的午餐:先搞清楚Token到底花在哪
很多人第一次被账单吓到,是在某个深夜盯着后台用量曲线发呆——明明只是让AI帮忙改了几段代码、读了两份文档,怎么一天下来消耗的Token够买好几杯咖啡。问题往往不在你问了多少次,而在于每次提问时,你悄悄塞进去的上下文有多重。
先把账算清楚。大模型的计费逻辑是按Token双向计费:你发过去的输入(prompt + 上下文)算一次,模型吐回来的输出算一次。而上下文这个东西有个致命特性——它是累积的。在一个多轮对话或Agent任务里,第N轮请求通常会把前面N-1轮的历史全部带上,于是输入Token量会随着轮次近似线性甚至平方级增长。举个直观的例子:假设每轮对话平均产生500 Token,到第20轮时,光历史就有近1万Token,而你这一轮真正想问的可能只有50 Token。也就是说,95%以上的钱花在了"回忆"上,而不是"思考"上。
更麻烦的是,上下文塞满之后,模型不只是变贵,还会变傻。这不是玄学,而是有明确机制的。主流大模型的注意力机制在处理长上下文时,对中间位置的信息召回率会明显下降,业界俗称"lost in the middle"。当你的上下文里混着大量无关的日志、重复的代码、过期的对话,模型抓重点的能力会被稀释,输出质量断崖式下跌。你花了更多钱,得到了更差的结果,这是最亏的一种情况。
所以省Token这件事,本质上是两件事的合体:省钱和保智商。下面这5个做法,是我在实际项目里反复验证过的,从最粗暴的截断到相对精细的压缩都有,你可以按自己的场景挑着用。
2. 做法一:给上下文设硬上限,别让历史无限膨胀
2.1 滑动窗口不是万能药,但它是第一道防线
最直接的办法,是给对话历史设一个硬性的Token上限,超过就丢弃最老的部分。这就是经典的滑动窗口策略。实现上很简单:维护一个消息列表,每次新增消息后,从头部开始累加Token数,超过阈值就把最早的消息删掉,直到降到阈值以下。
但这里有个坑,很多人第一次做会踩:粗暴地按条数删,而不是按Token删。一条消息可能是一句"好的",也可能是粘贴进来的三千字文档,按条数删会导致窗口大小剧烈波动。正确做法是用分词器(tokenizer)实际计算每条消息的Token数,按Token预算来裁剪。
# 以常见的消息列表为例,按Token预算裁剪历史 def trim_history(messages, max_tokens, count_tokens): # 从最新往旧累加,保留最近的对话 kept = [] total = 0 for msg in reversed(messages): t = count_tokens(msg["content"]) if total + t > max_tokens: break kept.append(msg) total += t return list(reversed(kept))2.2 系统提示词要单独保护,别被窗口挤掉
滑动窗口有个隐蔽的副作用:它可能把系统提示词(system prompt)也一起裁掉。系统提示词通常定义了角色、输出格式、安全约束,一旦丢失,模型行为会立刻跑偏。所以裁剪逻辑里必须把系统提示词排除在外,永远保留,只对用户和助手的对话轮次做窗口。
我的习惯是给系统提示词单独留一个预算,比如总预算8000 Token,系统提示词固定占1000,剩下7000给对话历史。这样即使对话很长,角色设定也不会丢。
2.3 什么时候该用滑动窗口,什么时候不该用
滑动窗口适合闲聊型、任务连续性不强的场景,比如客服问答、日常助手。但如果你的任务是"根据前面所有讨论逐步推导一个结论",那丢掉早期上下文可能直接导致结论错误。这种情况下,滑动窗口只能作为兜底,真正的主力应该是后面要讲的摘要压缩。
提示:滑动窗口的阈值不要拍脑袋定。建议先用真实业务数据跑一遍,统计平均每轮对话的Token分布,再取一个覆盖80%场景的值。我一般会把它设成模型上下文上限的30%到50%,留足余量给输出。
3. 做法二:把长历史压成摘要,用信息密度换Token
3.1 摘要压缩的核心思路:让模型自己"记笔记"
滑动窗口是"忘掉旧的",摘要压缩是"把旧的浓缩成一句话"。思路是:当对话历史超过一定长度时,调用一次模型,把前面的历史总结成一段简短的摘要,然后用这段摘要替换掉原始历史。这样原本几千Token的内容可能被压到几百Token,信息密度大幅提升。
关键在于摘要的粒度。我试过几种方案,效果差别很大:
| 摘要策略 | Token节省 | 信息保留 | 适用场景 |
|---|---|---|---|
| 全量一次性摘要 | 高 | 中 | 长对话、主题集中 |
| 分段滚动摘要 | 中 | 高 | 超长任务、多主题 |
| 只摘关键决策点 | 高 | 中低 | 任务型Agent |
| 结构化摘要(JSON) | 中 | 高 | 需要精确回溯 |
3.2 滚动摘要:处理超长任务的正确姿势
如果任务特别长,一次性摘要会丢失太多细节。这时候用滚动摘要:维护一个"历史摘要"字段,每当新增的对话累积到一定量,就把"旧摘要 + 新对话"一起喂给模型,生成新的摘要。这样摘要本身也在不断更新,既控制了长度,又保留了演进过程。
def rolling_summarize(old_summary, new_messages, llm): prompt = f"""已有摘要: {old_summary} 新增对话: {format_messages(new_messages)} 请把以上内容合并成一段不超过300字的摘要,保留关键决策、结论和未完成事项。""" return llm.invoke(prompt)3.3 摘要最容易丢的三类信息
实测下来,摘要压缩最常丢的是这三样:具体的数字和参数(比如"阈值设为0.75"被摘成"设置了阈值")、否定性约束(比如"不要用递归"被漏掉)、未完成的待办。所以我在写摘要提示词时,会明确要求模型保留"所有数值、所有禁止事项、所有TODO"。这一条小小的约束,能救回很多后续的返工。
注意:摘要本身也要花Token(调用模型生成摘要),所以别太频繁地触发。我一般设置在历史超过预算的70%时才触发一次摘要,避免为了省Token反而多花Token。
4. 做法三:RAG检索替代全量投喂,只给模型看相关的
4.1 全量投喂是最大的浪费源
很多人做知识库问答时,习惯把整个文档库塞进上下文,或者把检索到的十几篇文档全部丢给模型。这是Token消耗的重灾区。一份技术文档动辄上万Token,你塞五份进去,光输入就五万Token,而模型真正需要的可能只是其中两段。
RAG(检索增强生成)的正确用法是:先用向量检索或关键词检索,从知识库里捞出最相关的少量片段,只把这些片段放进上下文。检索质量决定了Token效率——检索得准,三段就够;检索得糙,塞三十段也没用。
4.2 检索片段的数量和长度怎么定
这里有两个参数要调:召回条数(top-k)和单条长度(chunk size)。我的经验值是:
- top-k 先设3到5,观察召回内容是否覆盖了答案。如果经常漏,再往上加,但一般不超过8。
- chunk size 控制在300到500 Token一段,太大则单条浪费,太小则语义不完整。
- 加一个重排序(rerank)步骤,把召回的片段按相关性重新排序,只取前2到3条进上下文。这一步能砍掉一半以上的无效Token。
4.3 检索结果要去重和截断
实际检索出来的片段经常高度重复,尤其是文档里有大量模板化内容时。进上下文之前,先做一次相似度去重,把重复度超过阈值的片段丢掉。另外,如果某个片段特别长,可以在句子边界处截断,只保留最相关的部分,而不是整段照搬。
def dedup_and_truncate(chunks, sim_threshold=0.9, max_len=500): kept = [] for c in chunks: if any(similarity(c, k) > sim_threshold for k in kept): continue kept.append(truncate_at_sentence(c, max_len)) return kept这套组合拳下来,同样的问答任务,输入Token通常能降到全量投喂的十分之一甚至更低,而答案质量反而更稳,因为模型不用在一堆噪音里找信号了。
5. 做法四:让Agent少绕路,工具调用别把结果全背回来
5.1 Agent的Token黑洞:工具返回结果
做Agent开发的人都知道,Token消耗的大头往往不是对话,而是工具调用的返回结果。你让Agent去查一个接口,返回一大坨JSON;让它读一个文件,返回整个文件内容;让它跑一次搜索,返回十条网页摘要。这些结果全部进入上下文,几轮下来上下文就爆了。
解决办法是在工具层做过滤,而不是把原始结果直接丢给模型。具体来说:
- 接口返回的JSON,只提取模型真正需要的字段,其余丢弃。
- 文件读取支持按行范围或按符号(如函数名)读取,而不是整文件读。
- 搜索结果先做摘要,只把标题和关键句给模型,需要详情时再二次调用。
5.2 工具描述本身也占Token
容易被忽略的一点:每个工具的schema描述都占Token。如果你给Agent挂了20个工具,光工具定义可能就两三千Token,而且每一轮请求都要带上。所以工具要精简,功能重叠的合并,不常用的按需动态加载。我见过一个项目挂了40多个工具,光工具描述就吃掉了上下文的三分之一,纯属浪费。
5.3 用"计划-执行"分离减少往返
Agent绕路的另一个原因是边想边做,每一步都要把完整上下文带上。改成"先规划、再执行"的模式:第一轮让模型输出一个任务计划(简短),后续执行时只带当前步骤相关的上下文,而不是全部历史。这样每一轮的输入都能控制在很小的范围。
提示:Agent的每一步都建议记录Token消耗,做成监控面板。我自己的项目里就是靠这个面板发现某个工具调用平均返回8000 Token,优化后降到500,整体成本直接砍半。
6. 做法五:缓存与复用,别让相同的上下文重复计费
6.1 提示词缓存:把不变的部分缓存起来
现在很多模型服务商支持提示词缓存(prompt caching),对于反复出现的相同前缀(比如固定的系统提示词、固定的知识库片段),缓存命中后计费大幅降低,有的甚至能降到原价的十分之一。这个特性对Agent和长系统提示词的场景特别友好。
用法上,关键是把稳定不变的内容放在前面,把变化的内容放在后面。因为缓存是按前缀匹配的,如果你的系统提示词每次都变,缓存永远命中不了。所以系统提示词要尽量固定,动态内容(用户输入、检索结果)放到后面。
6.2 相同问题的结果缓存
如果你的应用里有大量重复或高度相似的问题(比如FAQ场景),可以在应用层做一个语义缓存:把问题和答案存起来,新问题先查缓存,相似度超过阈值就直接返回,根本不调用模型。这一层能省下的Token是100%,因为压根没请求。
6.3 批处理合并请求
如果有一批独立的短任务要处理,别一个个发请求。把多个任务合并成一个请求,让模型一次性输出多个结果,可以省掉重复的系统提示词和上下文开销。当然要注意别合并太多导致单次输出过长,一般控制在模型输出上限的60%以内比较稳。
7. 五个做法怎么组合:一套可落地的省Token流水线
单独用某一个做法,效果有限;组合起来才能形成合力。我在实际项目里的流水线是这样的:
- 入口层:语义缓存拦截重复问题,命中直接返回。
- 检索层:RAG只召回top-3并重排序,去重截断后再进上下文。
- 历史层:滑动窗口兜底,超过70%预算触发滚动摘要。
- 工具层:工具返回结果在代码里过滤,只给模型必要字段。
- 请求层:固定前缀开启提示词缓存,动态内容后置。
这套流水线跑下来,同样的业务量,Token消耗相比最初的"全量投喂 + 无限历史"版本,通常能降到15%到25%,而且因为上下文更干净,模型输出质量反而更稳定。省Token和提质量在这里不是矛盾的,而是同一件事的两面。
最后分享一个我踩过的坑:别为了省Token把上下文压得太狠。有一次我把摘要阈值调得过低,结果模型丢失了关键约束,输出了一堆不符合要求的代码,返工花的时间远超省下的那点钱。省Token的前提是不损害任务成功率,这个平衡点需要你用真实数据去调,而不是拍脑袋。建议每次调整压缩策略后,都跑一遍回归测试集,确认成功率没有下降,再上线。