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 轮原文这样组装出来的 prompttoken 量基本稳定在 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_factorytime.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_factorytime.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, threshold0.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这段代码里threshold0.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_idtask_id, goal, conclusion, constraints[], artifacts[], statusfolded) 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_turns6): 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, keylambda c: c.created_at, reverseTrue): 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_turns6是活跃任务保留的轮数。这个数字要平衡太小会丢失当前任务的上下文太大又失去了折叠的意义。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_idself.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_idself.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 ftask_{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 代码重构425820198066.0%B 方案咨询354650182060.9%C 混合任务506340241062.0%加权平均-5603204563.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-shottoken 没降卡片太多或太大统计卡片 token 占比加合并机制设上限响应变慢折叠调用太频繁看折叠调用次数延迟折叠批量处理任务串味边界漏判人工检查任务划分加显式切换词规则6.5 几个容易被忽略的细节时间戳要用起来。卡片按时间倒序排列模型对最近的任务印象更深这符合直觉。我早期版本按创建顺序排结果模型经常引用最老的任务很奇怪。系统提示词要说明卡片机制。我在系统提示里加了一句历史任务以摘要卡片形式提供当前任务保留完整对话模型对上下文结构的理解明显更好回答时会更主动地区分历史结论和当前讨论。折叠调用要异步。折叠本身要调模型如果同步做会阻塞用户请求。我改成后台异步折叠用户无感知。代价是折叠有延迟但配合前面说的 2 轮延迟窗口体验反而更顺。给用户一个展开按钮。再好的自动折叠也会出错。我在 UI 上给每张卡片加了展开查看原文的入口用户觉得模型忘了什么可以手动展开。这个功能使用率不高但关键时刻能救场。7. 这套方案还能怎么扩展跑通基础版本后我又试了几个扩展方向有些效果不错有些还在摸索。按任务类型差异化折叠。代码任务保留代码片段和接口签名咨询任务保留结论和依据调试任务保留报错信息和根因。不同类型的任务卡片结构应该不一样。我目前做了代码和咨询两类效果比统一格式好。跨会话复用卡片。同一个用户的不同会话之间如果任务相关可以共享卡片。比如用户上午问过数据库配置下午新开会话问查询优化数据库配置卡片可以直接带过去。这个需要做用户级的卡片存储和检索。卡片自动过期。有些约束是有时效的比如这个版本先用临时方案。我给卡片加了 TTL超过一定时间自动标记为可能过期提示模型谨慎引用。和 RAG 结合。卡片本质上是结构化的记忆可以存进向量库需要时检索。这样上下文里不用带所有卡片只带相关的几张token 还能再降。不过检索本身有延迟和误召回风险要权衡。这套东西我陆陆续续迭代了两个多月从最初粗暴的截断到现在的按任务折叠中间踩的坑基本都写在上面的排查表里了。核心体会就一句上下文管理的本质是信息取舍而取舍的标准应该由任务语义决定而不是简单的轮数或 token 数。你把任务边界找准了折叠策略自然就清晰了。