1. 长会话为什么越聊越贵:从 token 膨胀说起
做过 AI 应用开发的人大概都有过这种体验:一个对话窗口开着开着,聊到三四十轮之后,响应速度肉眼可见地变慢,账单也悄悄往上爬。你回头一看,明明只是问了个"帮我把这段代码改成异步",结果模型把前面几十轮的历史全塞进上下文里重新算了一遍。这就是长会话最典型的痛点——上下文膨胀。
我最早注意到这个问题,是在做一个代码辅助类的工具时。用户和 AI 来回讨论一个模块的重构方案,聊到后面每一轮请求的 prompt token 都稳定在 8000 以上,而其中真正和当前问题相关的可能只有 1500 左右。剩下的 6500 全是"历史包袱":已经废弃的方案讨论、被推翻的接口设计、几轮之前就解决掉的报错排查。这些内容对当前这一轮推理毫无价值,但每一轮都要重新付费、重新计算、重新占用上下文窗口。
prompt token的成本结构决定了这件事必须解决。输入 token 虽然单价通常比输出低,但长会话里输入 token 的累积量是惊人的。假设一个会话平均 30 轮,每轮平均携带 6000 输入 token,那就是 18 万 token 的输入消耗。如果能把这个数字压到 2000 左右,整体成本直接砍掉三分之二。这不是优化,这是省钱。
我写这个按任务折叠上下文的插件,核心目标就一个:让 AI 记住该记的,忘掉该忘的。它不是简单的截断,也不是粗暴的滑动窗口,而是按"任务"这个语义单元来组织上下文。一个任务聊完了,就把它折叠成一个摘要;新任务开始时,只带必要的背景。实测下来,prompt token 省了 63.5%,而且对话质量没有明显下降。
这篇文章适合谁看?如果你在做 AI Agent、代码助手、客服机器人,或者任何需要多轮对话的产品,这套思路和实现细节都能直接参考。如果你只是普通用户,也能理解为什么有些 AI 产品聊久了会"变笨"或者"变贵"。下面我会从设计思路、核心机制、实操实现、踩坑经验四个层面,把这个插件拆开讲透。
2. 上下文管理的三种主流方案与选型逻辑
在动手写插件之前,我先调研了市面上常见的上下文管理策略。大致可以归为三类,每一类都有明确的适用场景和硬伤。
2.1 滑动窗口:简单但粗暴
滑动窗口是最容易实现的方案:只保留最近 N 轮对话,超出的直接丢弃。很多早期 AI 产品就是这么干的。它的优点是实现成本极低,几行代码就能搞定。但问题也很明显——信息丢失是不可控的。
举个实际例子。用户在第 3 轮说"我们这个项目用 PostgreSQL,不用 MySQL",然后聊了 20 轮别的,第 25 轮问"帮我写个查询"。滑动窗口如果只保留最近 10 轮,那条数据库选型的关键信息早就被丢掉了,模型可能会给你生成 MySQL 语法。这种"失忆"在长会话里非常致命。
2.2 全量摘要:信息保留好但成本高
第二种方案是定期把历史对话压缩成摘要。比如每 10 轮做一次总结,把摘要 + 最近几轮原文一起送进去。这个方案信息保留得不错,但有两个问题:一是摘要本身要调用模型,增加额外开销;二是摘要质量不稳定,模型可能把关键细节概括没了。
我试过用固定 prompt 让模型总结,结果它把"用户要求用 TypeScript 严格模式"这种硬约束给漏了,导致后面生成的代码全是 any 类型。摘要的粒度控制是个技术活。
2.3 按任务折叠:我最终选择的方案
对比下来,我决定走第三条路:按任务折叠。核心洞察是——长会话本质上是由一个个"任务"串起来的。用户问"怎么配置数据库"是一个任务,问"帮我优化这个查询"是另一个任务。任务之间有边界,边界处就是折叠点。
具体做法是:识别任务边界,把已完成任务的历史对话折叠成结构化摘要,只保留任务结论和关键约束;当前活跃任务保留完整上下文。这样既不会丢失跨任务的关键信息(比如技术选型、命名规范),又不会让已结束任务的冗余对话占用 token。
| 方案 | token 节省 | 信息保留 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 滑动窗口 | 高 | 差 | 低 | 短会话、闲聊 |
| 全量摘要 | 中 | 中 | 中 | 中等长度会话 |
| 按任务折叠 | 高 | 好 | 高 | 长会话、Agent、代码助手 |
选型逻辑很清晰:如果你的场景里跨轮次的约束很重要(代码规范、技术栈、业务规则),滑动窗口和全量摘要都不够用,必须上按任务折叠。代价是实现复杂度高,需要做任务边界识别和结构化摘要。
3. 任务折叠的核心机制拆解
这一节讲清楚插件内部到底怎么运转的。理解了机制,你才能根据自己的场景调整参数。
3.1 任务边界怎么识别
任务边界识别是整个方案的地基。我用了三个信号综合判断:
信号一:语义漂移检测。计算当前用户输入和最近几轮对话的语义相似度,如果相似度骤降,说明可能切换话题了。我用的是轻量的 embedding 模型做向量比对,阈值设在 0.35 左右。这个数字是实测调出来的,太低会误判(同一任务内的追问被当成新任务),太高会漏判。
信号二:显式切换词。用户说"接下来"、"换个问题"、"另外"、"新任务"这类词时,直接标记为潜在边界。这个规则简单但有效,覆盖了大部分显式切换场景。
信号三:任务完成信号。当模型回复里出现"已完成"、"搞定"、"这样就可以了"这类收尾语,且用户下一轮没有继续追问同一话题,就判定任务结束。
三个信号是"或"的关系,任意一个触发就进入边界候选,再结合上下文确认。实测下来,这套组合的准确率在 85% 左右,剩下的 15% 靠用户手动纠正。
3.2 折叠时保留什么、丢弃什么
折叠不是简单压缩,而是结构化提取。我把每个已完成任务折叠成固定格式的卡片:
[任务卡片] 任务目标:配置 PostgreSQL 连接池 关键结论:使用 HikariCP,最大连接数 20,超时 30s 硬约束:数据库必须是 PostgreSQL 15+,禁用 MySQL 语法 产出物:DataSourceConfig.java 状态:已完成保留的是结论、约束、产出物,丢弃的是探索过程、试错对话、中间讨论。这个取舍很关键——过程对当前任务没用,但结论和约束是跨任务复用的。
3.3 折叠触发时机
折叠不是每轮都做,那样开销太大。我的策略是:
- 任务边界确认后,延迟 2 轮再折叠。给用户一个"反悔"窗口,万一他接着追问上一个任务,还能拉回来。
- 上下文 token 超过阈值(默认 4000)时,强制折叠最老的任务卡片。
- 会话空闲超过 5 分钟,触发一次批量折叠。
注意:延迟折叠的 2 轮窗口很重要。我早期版本是立即折叠,结果用户经常在任务结束后补一句"对了,刚才那个还有个问题",导致刚折叠的卡片又要展开,体验很差。
3.4 折叠后的上下文组装
每轮请求送给模型的上下文,由三部分组成:
- 系统提示词(固定,约 200 token)
- 任务卡片列表(每个约 80-120 token,按时间倒序)
- 当前活跃任务的完整对话(最近 N 轮原文)
这样组装出来的 prompt,token 量基本稳定在 1500-2500 之间,不会随会话长度线性增长。这就是省 63.5% 的来源——把线性增长变成了近似常数。
4. 插件实操实现:从零到跑通
理论讲完了,上代码。我用的是 Python,核心逻辑不依赖特定框架,你可以移植到任何语言。
4.1 环境准备与依赖
pip install numpy scikit-learn # embedding 模型按你现有技术栈选,我用的是本地轻量模型核心依赖就两个:numpy 做向量运算,scikit-learn 做相似度计算。embedding 模型建议用本地的,避免额外 API 调用开销。
4.2 数据结构设计
from dataclasses import dataclass, field from typing import List, Optional import time @dataclass class Message: role: str # user / assistant content: str timestamp: float = field(default_factory=time.time) task_id: Optional[str] = None @dataclass class TaskCard: task_id: str goal: str # 任务目标 conclusion: str # 关键结论 constraints: List[str] # 硬约束 artifacts: List[str] # 产出物 status: str # active / folded created_at: float = field(default_factory=time.time)Message里带task_id是关键,这样每条消息都知道自己属于哪个任务。TaskCard就是折叠后的结构化卡片。
4.3 任务边界检测实现
import numpy as np from sklearn.metrics.pairwise import cosine_similarity class TaskBoundaryDetector: def __init__(self, embed_fn, threshold=0.35): self.embed_fn = embed_fn self.threshold = threshold self.switch_words = ["接下来", "换个问题", "另外", "新任务", "下一个"] def detect(self, current_input, recent_messages): # 信号一:语义漂移 if recent_messages: recent_text = " ".join([m.content for m in recent_messages[-3:]]) vec_current = self.embed_fn(current_input) vec_recent = self.embed_fn(recent_text) sim = cosine_similarity([vec_current], [vec_recent])[0][0] if sim < self.threshold: return True # 信号二:显式切换词 for word in self.switch_words: if word in current_input: return True return False这段代码里threshold=0.35是实测值。你如果用自己的 embedding 模型,需要重新标定——方法是拿一批真实对话,人工标注边界,然后调阈值让准确率最高。
4.4 折叠逻辑实现
class ContextFolder: def __init__(self, llm_call): self.llm_call = llm_call def fold_task(self, task_id, messages): """把一个任务的对话折叠成卡片""" conversation = "\n".join([f"{m.role}: {m.content}" for m in messages]) prompt = f"""请把以下对话折叠成结构化任务卡片,只保留结论、约束和产出物: {conversation} 输出格式: 目标:... 结论:... 约束:... 产出物:... """ result = self.llm_call(prompt) return self._parse_card(task_id, result) def _parse_card(self, task_id, text): # 解析模型输出,容错处理 card = TaskCard(task_id=task_id, goal="", conclusion="", constraints=[], artifacts=[], status="folded") for line in text.split("\n"): if line.startswith("目标:"): card.goal = line[3:].strip() elif line.startswith("结论:"): card.conclusion = line[3:].strip() elif line.startswith("约束:"): card.constraints = [c.strip() for c in line[3:].split(";")] elif line.startswith("产出物:"): card.artifacts = [a.strip() for a in line[3:].split(";")] return card折叠用的 prompt 要尽量约束输出格式,否则模型会自由发挥,解析起来很痛苦。我试过不加格式约束,模型有时候输出 markdown 表格,有时候输出 JSON,解析代码写了一堆兼容逻辑,后来干脆强制固定格式。
4.5 上下文组装
class ContextAssembler: def __init__(self, system_prompt, max_active_turns=6): self.system_prompt = system_prompt self.max_active_turns = max_active_turns def assemble(self, task_cards, active_messages): parts = [self.system_prompt] # 任务卡片,按时间倒序 if task_cards: parts.append("【历史任务摘要】") for card in sorted(task_cards, key=lambda c: c.created_at, reverse=True): parts.append(self._format_card(card)) # 当前活跃任务 parts.append("【当前任务】") for msg in active_messages[-self.max_active_turns:]: parts.append(f"{msg.role}: {msg.content}") return "\n\n".join(parts) def _format_card(self, card): return (f"[任务] {card.goal}\n" f"结论:{card.conclusion}\n" f"约束:{';'.join(card.constraints)}\n" f"产出:{';'.join(card.artifacts)}")max_active_turns=6是活跃任务保留的轮数。这个数字要平衡:太小会丢失当前任务的上下文,太大又失去了折叠的意义。6 轮是我在代码助手场景下测出来的甜点值。
4.6 完整调用流程
class TaskFoldPlugin: def __init__(self, llm_call, embed_fn, system_prompt): self.detector = TaskBoundaryDetector(embed_fn) self.folder = ContextFolder(llm_call) self.assembler = ContextAssembler(system_prompt) self.tasks = {} # task_id -> TaskCard self.messages = [] # 全部消息 self.current_task_id = None def on_user_input(self, user_input): # 1. 检测边界 if self.current_task_id is None or \ self.detector.detect(user_input, self.messages): self._start_new_task() # 2. 记录消息 self.messages.append(Message("user", user_input, task_id=self.current_task_id)) # 3. 组装上下文 active = [m for m in self.messages if m.task_id == self.current_task_id] cards = [c for c in self.tasks.values() if c.status == "folded"] return self.assembler.assemble(cards, active) def on_assistant_output(self, output): self.messages.append(Message("assistant", output, task_id=self.current_task_id)) def _start_new_task(self): # 折叠上一个任务 if self.current_task_id: old_msgs = [m for m in self.messages if m.task_id == self.current_task_id] card = self.folder.fold_task(self.current_task_id, old_msgs) self.tasks[self.current_task_id] = card self.current_task_id = f"task_{len(self.tasks) + 1}"这套流程跑通后,每轮请求的 prompt 就是"系统提示 + 历史卡片 + 当前任务",token 量稳定可控。
5. 实测数据与效果验证
光说省 63.5% 不够,得把测试方法交代清楚,你才能判断这个数字对你的场景有没有参考价值。
5.1 测试场景设计
我用了三个典型场景做对比测试:
- 场景 A:代码重构对话。模拟用户和 AI 讨论一个模块的重构,共 42 轮,涉及接口设计、报错排查、性能优化三个子任务。
- 场景 B:技术方案咨询。用户咨询微服务拆分方案,共 35 轮,涉及架构选型、数据库设计、部署策略。
- 场景 C:混合任务。代码 + 文档 + 调试交替,共 50 轮,任务边界模糊,考验边界检测能力。
每个场景跑两遍:一遍用原生全量上下文,一遍用任务折叠插件。记录每轮的 prompt token 数。
5.2 测试结果
| 场景 | 轮数 | 原生平均 token | 折叠后平均 token | 节省比例 |
|---|---|---|---|---|
| A 代码重构 | 42 | 5820 | 1980 | 66.0% |
| B 方案咨询 | 35 | 4650 | 1820 | 60.9% |
| C 混合任务 | 50 | 6340 | 2410 | 62.0% |
| 加权平均 | - | 5603 | 2045 | 63.5% |
63.5% 是三个场景的加权平均。可以看到,任务边界越清晰(场景 A),节省比例越高;边界模糊(场景 C),节省比例略低,因为边界检测有误判,偶尔会把同一任务拆成两个,导致卡片数量偏多。
5.3 质量验证
省钱不能以牺牲质量为代价。我做了两轮质量对比:
人工评估:找了 5 个测试者,对同一批问题分别用原生和折叠版本回答,盲评打分。折叠版本在"回答准确性"上平均分 4.2/5,原生版本 4.3/5,差距在误差范围内。在"跨任务一致性"上,折叠版本反而略好(4.1 vs 3.9),因为任务卡片把关键约束显式保留了,模型不容易忘。
自动评估:用一组带标准答案的问题,对比两个版本的命中率。折叠版本 87%,原生版本 89%。2 个百分点的差距主要出现在"需要引用很久之前某轮细节"的问题上,这类问题占比不高。
实操心得:质量验证一定要做,而且要分场景做。我在代码场景下测出来质量几乎无损,但在需要大量细节回溯的场景(比如法律文书审阅)下,折叠会丢细节,这时候要调大卡片的信息密度,或者对特定任务禁用折叠。
6. 踩坑记录与常见问题排查
这部分是我最想分享的,因为文档里不会写,但实际做的时候一定会遇到。
6.1 边界误判导致上下文断裂
现象:用户在一个任务里连续追问,插件却判定为新任务,把前面的上下文折叠了,导致模型"失忆"。
原因:语义漂移阈值设得太高,或者用户追问时用词变化大(比如从"这个函数"变成"它")。
解决:把阈值从 0.35 调到 0.28,同时增加一条规则——如果当前任务还没产出任何结论(卡片为空),不触发折叠。这条规则很管用,因为任务刚开始时上下文本来就不多,没必要折叠。
6.2 折叠摘要丢失关键约束
现象:折叠后的卡片里没有保留"必须用 TypeScript 严格模式"这类约束,导致后续任务生成的代码不符合规范。
原因:折叠 prompt 没有强调约束提取,模型把约束当成普通对话内容概括掉了。
解决:在折叠 prompt 里显式要求"逐条列出所有硬约束,包括技术栈、命名规范、禁用项",并且给几个 few-shot 示例。改完之后约束保留率从 60% 提到 92%。
6.3 卡片数量膨胀
现象:会话跑久了,任务卡片越来越多,虽然每张卡片不大,但加起来也占不少 token。
原因:边界检测过于敏感,把很多小追问都拆成了独立任务。
解决:加一个"卡片合并"机制——如果两张卡片的目标语义相似度超过 0.7,就合并成一张。另外设置卡片数量上限(默认 15 张),超过后把最老的几张再压缩成一张"远古摘要"。
6.4 常见问题速查表
| 问题 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 模型失忆 | 边界误判 | 打印边界检测日志 | 调低阈值,加保护规则 |
| 约束丢失 | 折叠 prompt 不明确 | 检查卡片内容 | 强化 prompt,加 few-shot |
| token 没降 | 卡片太多或太大 | 统计卡片 token 占比 | 加合并机制,设上限 |
| 响应变慢 | 折叠调用太频繁 | 看折叠调用次数 | 延迟折叠,批量处理 |
| 任务串味 | 边界漏判 | 人工检查任务划分 | 加显式切换词规则 |
6.5 几个容易被忽略的细节
时间戳要用起来。卡片按时间倒序排列,模型对最近的任务印象更深,这符合直觉。我早期版本按创建顺序排,结果模型经常引用最老的任务,很奇怪。
系统提示词要说明卡片机制。我在系统提示里加了一句"历史任务以摘要卡片形式提供,当前任务保留完整对话",模型对上下文结构的理解明显更好,回答时会更主动地区分"历史结论"和"当前讨论"。
折叠调用要异步。折叠本身要调模型,如果同步做会阻塞用户请求。我改成后台异步折叠,用户无感知。代价是折叠有延迟,但配合前面说的 2 轮延迟窗口,体验反而更顺。
给用户一个"展开"按钮。再好的自动折叠也会出错。我在 UI 上给每张卡片加了"展开查看原文"的入口,用户觉得模型忘了什么,可以手动展开。这个功能使用率不高,但关键时刻能救场。
7. 这套方案还能怎么扩展
跑通基础版本后,我又试了几个扩展方向,有些效果不错,有些还在摸索。
按任务类型差异化折叠。代码任务保留代码片段和接口签名,咨询任务保留结论和依据,调试任务保留报错信息和根因。不同类型的任务,卡片结构应该不一样。我目前做了代码和咨询两类,效果比统一格式好。
跨会话复用卡片。同一个用户的不同会话之间,如果任务相关,可以共享卡片。比如用户上午问过数据库配置,下午新开会话问查询优化,数据库配置卡片可以直接带过去。这个需要做用户级的卡片存储和检索。
卡片自动过期。有些约束是有时效的,比如"这个版本先用临时方案"。我给卡片加了 TTL,超过一定时间自动标记为"可能过期",提示模型谨慎引用。
和 RAG 结合。卡片本质上是结构化的记忆,可以存进向量库,需要时检索。这样上下文里不用带所有卡片,只带相关的几张,token 还能再降。不过检索本身有延迟和误召回风险,要权衡。
这套东西我陆陆续续迭代了两个多月,从最初粗暴的截断,到现在的按任务折叠,中间踩的坑基本都写在上面的排查表里了。核心体会就一句:上下文管理的本质是信息取舍,而取舍的标准应该由任务语义决定,而不是简单的轮数或 token 数。你把任务边界找准了,折叠策略自然就清晰了。