1. 长会话为什么越聊越贵:从 token 膨胀的真实账本说起
做过 AI 应用开发的人大概都有过这种体验:一个对话窗口开着开着,响应越来越慢,账单越来越吓人,模型还开始"忘事"——前面刚说过的约束,后面就抛到脑后了。很多人第一反应是模型不行,换一个更大的模型,结果钱花得更多,问题依旧。我踩过这个坑之后才想明白,问题根本不在模型,而在我们喂给模型的上下文本身就已经失控了。
先把账算清楚。大模型的计费逻辑是按 token 来的,输入和输出分开计价,而输入 token 里,历史对话往往占了绝大部分。假设一个会话进行到第 30 轮,每轮平均 400 token,那么光是历史就累积到 12000 token 左右。如果每一轮都把完整历史重新塞进去,第 30 轮的实际输入就是 12000 加上当前问题,而第 1 轮只有 400。也就是说,同样一句"帮我改一下这段代码",在第 30 轮的成本可能是第 1 轮的 30 倍。这不是线性增长,是典型的二次增长——总成本随轮数呈平方级上升。
我拿一个真实的调试场景做过统计:一个持续 40 轮的代码排错会话,如果全程不裁剪上下文,累计消耗的输入 token 接近 50 万;而其中真正对当前任务有用的信息,可能连 15% 都不到。剩下的 85% 是什么?是已经解决完的旧问题、被推翻的中间方案、重复贴过的报错日志、以及大量"好的""明白了""继续"这类没有信息量的寒暄。这些东西每一轮都在被重新计费,纯粹是白花钱。
更麻烦的是,上下文过长还会拖累模型表现。业界有个被反复验证的现象叫"中间遗忘":当上下文塞得太满,模型对开头和结尾的信息记得比较牢,中间那一段的注意力会被稀释。你把关键约束放在第 5 轮说过,到第 30 轮时模型很可能已经"看不见"了。所以长会话不只是贵,还容易变笨。这也是为什么很多人觉得"聊久了 AI 就变傻",其实是你自己把它的注意力淹没了。
那市面上常见的做法有哪些?我梳理了一下,大致三类。第一类是滑动窗口,只保留最近 N 轮,简单粗暴,但会把早期的重要设定一起丢掉,比如"这个项目用 TypeScript 严格模式"这种全局约束。第二类是摘要压缩,让模型把历史总结成一段话,问题是摘要本身要花一次调用,而且摘要会丢细节,代码这种精确内容一摘要就废了。第三类是向量检索,把历史存进向量库,按相关性召回,听起来很美,但工程复杂度高,还得额外维护一个存储,对小项目来说太重了。
这三类方案有个共同的毛病:它们都是按"时间"或"相似度"来裁剪,而不是按"任务"来裁剪。可真实的对话恰恰是按任务组织的——你在一个会话里可能先调了一个 bug,然后问了个无关的 API 用法,接着又回来继续调 bug。滑动窗口会把这三段混在一起切,向量检索会把它们打散重组,都不符合人脑组织信息的方式。我想要的是一种更贴近真实工作流的折叠方式:同一个任务内的上下文保留,跨任务的旧上下文折叠掉。这就是我写这个插件的出发点。
2. 按任务折叠的核心思路:把会话切成"任务块"而不是"消息流"
2.1 为什么"任务"才是上下文的天然边界
先解释一下我说的"任务"是什么。在一段连续对话里,用户其实是在不断地开启和关闭小任务。比如"帮我看看这个报错"是一个任务,"顺便问下这个库怎么装"是另一个任务,"好,回到刚才那个报错"又回到了第一个任务。每个任务有自己的目标、自己的上下文、自己的结论。任务结束之后,它的中间过程对后续对话基本没有价值,只有结论可能还有用。
传统方案把对话看成一维的消息流,所以只能按位置切。但对话的真实结构是树状的、可嵌套的:任务可以开启、可以挂起、可以恢复、可以关闭。一旦你接受了这个视角,折叠策略就变得非常自然——活跃任务保留完整上下文,已关闭任务只保留结论,挂起任务保留摘要。这样既不会丢掉全局约束,也不会让已完成的旧任务继续占着 token。
我举个具体例子说明这个差别。假设会话是这样的:
- 用户:项目用 Python 3.11,依赖用 poetry 管理(全局设定)
- 用户:帮我写个读取 CSV 的函数(任务 A 开始)
- AI:给出实现
- 用户:报错了,说编码问题(任务 A 继续)
- AI:改成 utf-8-sig
- 用户:好了,谢谢(任务 A 结束)
- 用户:顺便问下 poetry 怎么加 dev 依赖(任务 B 开始)
- AI:给出命令
- 用户:回到刚才那个 CSV,我想加个分块读取(任务 A 恢复)
滑动窗口在第 9 轮时,可能已经把第 1 轮的全局设定挤出去了,导致 AI 不知道项目用 Python 3.11。而按任务折叠的方案会这样处理:第 1 轮是全局设定,永远保留;任务 A 在第 6 轮结束时被标记为"已关闭",它的中间过程(第 3、4、5 轮)被折叠成一句结论"CSV 读取函数已实现,处理了编码问题";任务 B 是独立的小任务,结束后同样折叠;到第 9 轮时,任务 A 被重新激活,它的结论被展开,AI 立刻知道之前做了什么。
2.2 折叠的粒度:结论、摘要、原文三档
折叠不是一刀切地删,而是分档处理。我在插件里设计了三个粒度:
- 原文保留:当前活跃任务的所有消息,一字不动。
- 摘要保留:挂起任务,用一两句话概括它做了什么、结论是什么。
- 结论保留:已关闭任务,只留最终产出,比如"函数已实现""配置已修改"。
这三档的 token 占用差别巨大。原文保留可能几百上千 token,摘要保留几十 token,结论保留可能就十几个 token。一个 40 轮的会话,如果里面有 6 到 8 个任务,折叠之后能省下的量非常可观。我实测下来,prompt token 平均省了 63.5%,这个数字不是拍脑袋来的,后面会讲怎么测的。
这里有个关键判断:什么算"任务结束"。我的做法是结合两个信号。一个是显式的,用户说了"好了""谢谢""下一个问题"这类收尾词;另一个是隐式的,用户开启了明显不相关的新话题。两个信号任一触发,就把当前任务标记为候选关闭。但我不立即折叠,而是留一个"冷却期"——如果接下来两轮用户又回到这个话题,说明任务没真结束,取消关闭。这个冷却机制很重要,能避免误折叠。
2.3 和滑动窗口、摘要压缩的本质区别
很多人会问,你这不就是摘要压缩吗?不是。摘要压缩是无差别地压缩所有历史,而任务折叠是有选择地压缩已结束的部分。区别在于:
| 维度 | 滑动窗口 | 摘要压缩 | 任务折叠 |
|---|---|---|---|
| 裁剪依据 | 时间位置 | 全局压缩 | 任务状态 |
| 全局约束 | 容易丢失 | 可能保留 | 永久保留 |
| 活跃任务 | 可能被截断 | 被压缩失真 | 完整保留 |
| 已结束任务 | 保留浪费 | 压缩但占位 | 折叠成结论 |
| 恢复旧任务 | 无法恢复 | 无法精确恢复 | 可展开恢复 |
| 额外调用成本 | 无 | 每轮一次摘要 | 仅关闭时一次 |
从表里能看出来,任务折叠最大的优势是保留了任务的可恢复性。滑动窗口一旦滑过去就回不来了,摘要压缩把细节压没了也回不来,而任务折叠只是把任务"收起来",需要时还能"展开"。这对真实的开发场景特别重要,因为人就是会反复回到同一个问题上。
3. 插件是怎么实现的:从消息打标到上下文重组的完整链路
3.1 整体架构:三层结构
插件本身不复杂,核心就三层:打标层、状态层、重组层。
打标层负责给每条消息打上任务 ID 和角色标签。任务 ID 标识这条消息属于哪个任务,角色标签标识它是"全局设定""任务开场""任务过程"还是"任务结论"。打标不需要调用模型,纯规则就能做,成本几乎为零。
状态层维护一个任务状态机。每个任务有四种状态:活跃、挂起、已关闭、已归档。状态迁移由打标层的信号驱动,比如检测到收尾词就把任务从活跃转到已关闭。
重组层是真正干活的地方。它在每次请求模型之前,根据当前任务状态,把历史消息重新拼装成一个精简的上下文。活跃任务原文拼入,挂起任务拼摘要,已关闭任务拼结论,全局设定永远拼入。
3.2 任务边界的识别规则
识别任务边界是整个插件里最需要调优的部分。我一开始想用模型来判断,后来发现太贵也太慢,改成了一套规则加轻量启发式。核心规则有这么几条:
- 话题切换检测:计算当前消息和上一条消息的语义相似度,如果低于阈值,且当前没有活跃任务,就开启新任务。相似度我用的是简单的词重叠加关键词匹配,够用。
- 收尾词检测:维护一个收尾词表,像"好了""谢谢""可以了""下一个""换个问题"这些。命中就把当前任务标记为候选关闭。
- 指代回指检测:如果用户说"刚才那个""回到前面""继续之前",就把最近一个已关闭任务重新激活。
- 全局设定检测:如果消息里包含"项目用""统一用""以后都"这类词,就标记为全局设定,永久保留。
这些规则听起来土,但实测准确率比我想的高。因为真实对话里,任务边界往往有很明显的语言信号,不需要多复杂的模型。
3.3 折叠时的摘要生成策略
任务关闭时需要生成摘要。这里我做了个取舍:不调用大模型生成摘要,而是用模板加关键信息抽取。原因有两个,一是省钱,二是稳定。调用模型生成摘要,每次关闭都要多花一次钱,而且模型可能把关键信息总结错。用模板抽取反而更可靠。
具体做法是:从任务的消息里抽取"用户意图""最终方案""关键参数"三个字段,套进一个固定模板。比如一个调试任务,摘要就是"任务:修复 CSV 编码报错;方案:改用 utf-8-sig 编码;结论:已解决"。这样一句话就把任务的核心信息保住了,token 占用极低。
提示:摘要模板要根据你的业务场景定制。代码类任务重点抽函数名、参数、报错信息;文案类任务重点抽主题、风格、字数要求。模板设计得好,折叠后的信息密度会非常高。
3.4 上下文重组的拼装顺序
拼装顺序也有讲究。我最终的顺序是:系统提示 → 全局设定 → 已关闭任务结论 → 挂起任务摘要 → 活跃任务原文 → 当前消息。这个顺序是有依据的。
把全局设定放在前面,是因为模型对开头的信息注意力最集中,全局约束必须让它记住。已关闭任务的结论放在中间偏前,是因为它们只是背景信息,不需要太高的注意力权重。活跃任务的原文放在最后,紧挨着当前消息,这样模型对当前任务的细节记得最清楚。这个顺序符合"近因效应",实测下来模型对当前任务的响应质量明显更好。
4. 实测数据:63.5% 是怎么算出来的,以及那些没省下来的部分
4.1 测试方法:用真实会话回放
为了验证效果,我没有用合成数据,而是拿了自己过去两个月积累的 30 段真实开发会话做回放。这些会话长度从 15 轮到 60 轮不等,涵盖代码调试、API 查询、文档撰写、方案讨论四类场景。回放的方式是:把每段会话按原始顺序重新喂给模型,分别记录"不折叠"和"折叠"两种模式下的输入 token 数。
统计口径上,我只算输入 token,因为输出 token 两种模式基本一致,混在一起会稀释效果。每段会话取所有轮次的输入 token 总和,然后对比两种模式的总和。
4.2 分场景的节省比例
结果比我预期的还要好一些,但不同场景差异明显:
| 场景类型 | 会话数 | 平均轮数 | 不折叠 token | 折叠后 token | 节省比例 |
|---|---|---|---|---|---|
| 代码调试 | 12 | 38 | 412000 | 138000 | 66.5% |
| API 查询 | 8 | 22 | 156000 | 71000 | 54.5% |
| 文档撰写 | 6 | 45 | 388000 | 152000 | 60.8% |
| 方案讨论 | 4 | 52 | 476000 | 168000 | 64.7% |
| 合计 | 30 | — | 1432000 | 529000 | 63.1% |
综合下来是 63.1%,我在标题里写 63.5% 是因为后续又补了几段会话,数字略有浮动。代码调试和方案讨论省得最多,因为这两类场景任务边界最清晰,一个 bug 修完就是修完了,折叠得很干净。API 查询省得最少,因为这类会话往往是一连串小问题,任务切换频繁,每个任务本身就很短,折叠的收益相对有限。
4.3 哪些部分省不下来
诚实地说,折叠不是万能的,有几块 token 是省不掉的。
第一块是全局设定。这部分必须永久保留,省不了。好在它通常只占很小比例,一般几百 token 封顶。
第二块是活跃任务的原文。当前正在做的任务,一个字都不能删,否则模型就断片了。如果用户一个任务做了很久不切换,那这段时间的 token 就省不下来。这也是为什么折叠效果和"任务切换频率"强相关。
第三块是当前消息本身。这个没法省,也不该省。
所以真实能省的部分,其实是"已关闭任务的过程"和"挂起任务的细节"。在一个任务边界清晰的长会话里,这两块能占到总 token 的 70% 以上,折叠后能压到 10% 以内,这就是 63% 这个数字的来源。
4.4 对响应质量的影响
省钱如果换来的是变笨,那就不值了。所以我专门做了质量对比。方法是:对同一批会话,用折叠和不折叠两种模式各生成一次回复,然后人工评估回复是否满足需求、是否遗漏了关键约束。
结果是:在 30 段会话里,折叠模式有 28 段回复质量和不折叠模式持平或更好,只有 2 段略差。那 2 段差在哪?都是任务边界识别错误导致的——把没结束的任务误判为已关闭,结果折叠掉了还在用的上下文。这印证了我前面说的,任务边界识别是这套方案里最关键的环节,识别错了,省再多钱也是负收益。
注意:如果你打算在自己的项目里用类似方案,务必先在小范围会话上验证边界识别的准确率。我的经验是,准确率低于 90% 时,宁可保守一点,少折叠,也别误折叠。
5. 落地时踩过的坑:边界误判、状态泄漏和恢复失败
5.1 坑一:把"继续"误判成新任务
最早的一版规则里,我用"消息相似度低"作为开启新任务的信号。结果遇到一个典型问题:用户说"继续",这两个字和上文相似度极低,插件就误以为开了新任务,把原来的活跃任务给挂起了。用户明明想接着做,结果上下文被折叠,AI 一脸茫然。
修复办法是加了一个短消息白名单。像"继续""嗯""好""然后呢"这类长度很短、且明显是承接性的消息,不参与新任务判定,直接归到当前活跃任务里。这个白名单我维护了大概二十个词,覆盖了绝大多数承接性表达。
5.2 坑二:任务状态在会话间泄漏
第二个坑更隐蔽。我一开始把任务状态存在了全局变量里,结果发现开新会话时,上一个会话的任务状态还在,导致新会话的第一条消息就被误判成"恢复旧任务"。这个 bug 排查了很久,因为表现很诡异——有时候正常有时候不正常,取决于上一个会话是怎么结束的。
根因是状态没有和会话 ID 绑定。修复很简单,把任务状态改成以会话 ID 为 key 的字典,每个会话独立维护。但这个问题提醒我,任何涉及"跨轮次状态"的设计,都必须想清楚状态的生命周期和隔离边界,否则就会出现这种幽灵 bug。
5.3 坑三:恢复旧任务时摘要不够用
第三个坑是关于恢复的。当用户说"回到刚才那个问题",插件会把已关闭任务重新激活。但激活时我只恢复了摘要,没恢复原文,结果 AI 知道"之前做过什么",但不知道"具体怎么做的",用户一问细节就露馅。
解决办法是保留原文的引用。折叠时不是把原文删掉,而是把它移到一个"冷存储"里,只把摘要放进活跃上下文。当任务被恢复时,从冷存储里把原文捞回来。这样既省了平时的 token,又保证了恢复时的完整性。冷存储不需要什么高级技术,一个内存字典或者本地文件就够了。
5.4 坑四:全局设定被当成任务内容折叠
最后一个坑最要命。用户在第一轮说"这个项目统一用 4 空格缩进",插件把它当成了任务 A 的过程内容。任务 A 结束后,这条设定被折叠掉了,后面 AI 就开始用 2 空格缩进,用户很崩溃。
修复是加了全局设定识别,并且给全局设定最高的保留优先级,任何情况下都不折叠。识别规则我前面提过,就是匹配"统一用""以后都""项目用"这类词。这个坑的教训是:折叠策略必须有优先级,不能所有消息一视同仁。有些信息是"宪法级"的,动不得。
6. 想自己复现的话:几个可以直接抄的关键配置
6.1 折叠阈值怎么定
如果你要自己实现,第一个要调的是折叠阈值。我的建议是:
- 任务最小长度:少于 3 轮的任务不折叠,因为太短了,折叠收益还不如摘要开销。
- 冷却期:任务标记关闭后,观察 2 轮再真正折叠,防止误判。
- 相似度阈值:话题切换的相似度阈值设在 0.3 左右比较稳,太低会频繁开新任务,太高会该切不切。
这些数字不是绝对的,得根据你的场景调。代码类场景任务边界清晰,可以激进一点;闲聊类场景边界模糊,得保守一点。
6.2 摘要模板的字段设计
摘要模板我建议至少包含四个字段:任务目标、采用方案、关键参数、最终状态。用一句话串起来,控制在 50 token 以内。比如:
任务:优化数据库查询;方案:加复合索引;参数:user_id + created_at;状态:已完成,查询耗时从 800ms 降到 40ms。这样一句话,信息密度极高,模型一看就懂,而且 token 占用很小。模板字段可以根据你的业务调整,但"目标 + 方案 + 状态"这三个是必须的。
6.3 冷存储的简单实现
冷存储不需要上数据库,一个字典就够:
cold_storage = {} # key: task_id, value: 原文消息列表 def fold_task(task_id, messages): cold_storage[task_id] = messages return generate_summary(messages) def restore_task(task_id): return cold_storage.get(task_id, [])关键是折叠时别真删,只是从活跃上下文里移出去。这样恢复的时候才能拿回原文。内存占用不用担心,一个会话的原文撑死也就几十 KB。
6.4 和现有框架的对接方式
这个插件本质上是个中间件,插在"用户输入"和"模型调用"之间。不管你用的是哪家的模型接口,只要在发请求之前,把 messages 数组过一遍折叠逻辑就行。对接点很清晰:
- 收到用户消息,先过打标层,更新任务状态。
- 调用重组层,生成精简后的 messages。
- 把精简 messages 发给模型。
- 收到回复,存进当前活跃任务。
整个过程对上层业务是透明的,不需要改业务代码,只需要在请求链路上加一层。这也是我选择做成插件而不是改框架的原因——侵入性越小,越容易落地。
7. 这套思路还能往哪走:几个我还没做完的方向
7.1 跨会话的任务继承
现在的实现是会话内折叠,会话之间是隔离的。但真实工作里,很多任务是跨会话延续的——今天没做完的 bug,明天接着调。如果能把已关闭任务存到持久化存储里,新会话开始时按需召回,那就能实现跨会话的任务继承。技术上不难,难的是召回策略,得避免把不相关的旧任务也捞进来。
7.2 多任务并行时的上下文隔离
现在一个会话里同时只有一个活跃任务。但有时候用户会同时推进两件事,比如一边等编译一边问别的。这种情况下,两个任务都该保持活跃,但它们的上下文要隔离,不能互相污染。这需要把上下文从"一条流"改成"多条流",复杂度上一个台阶,但确实是个真实需求。
7.3 折叠策略的自适应
现在的阈值是手工调的,不同场景用同一套参数。理想情况下,插件应该能根据会话特征自动调整策略——任务切换频繁的会话用保守策略,任务边界清晰的会话用激进策略。这个可以用简单的统计特征来做,比如统计最近 10 轮的任务切换次数,动态调阈值。我试过一版,效果还行,但还没调稳,暂时没放进正式版。
7.4 和提示词优化的结合
折叠解决的是"上下文太长"的问题,但还有一半问题在提示词本身。如果提示词写得冗余,折叠省下来的 token 又被浪费了。所以下一步我想把折叠和提示词精简结合起来,做一个统一的"上下文预算管理器"——给定一个 token 预算,自动决定哪些内容保留、哪些折叠、哪些精简。这个方向我觉得比单纯折叠更有价值。
我在实际使用中最大的体会是:上下文管理不是省钱技巧,而是 AI 应用的基本功。很多人把精力花在换模型、调参数上,却忽略了喂进去的东西本身就有大量冗余。把上下文管好了,小模型也能跑出好效果,成本还低。这个插件我还会继续打磨,尤其是边界识别那块,准确率每提高一个点,整体收益都是实打实的。