首页 / 资讯中心 / 文章详情

长对话Token优化:上下文剪枝与语义相似度实践

长对话Token优化:上下文剪枝与语义相似度实践 ★ FEATURED ARTICLE
如果你维护过一个面向真实用户的对话机器人大概率会撞上同一个怪圈对话轮数一多上下文长度失控Token费用一路飙模型响应也开始变慢。我手头的模拟项目X就是这样最初只是客服场景的长会话助手聊到十几轮以后系统日志里开始不断出现上下文超限的报错。一开始我靠无脑截断尾部内容再塞一段摘要进去结果用户问过的订单号、地址这些关键信息被截掉就算了模型还会一本正经地编一个不存在的承诺。后来我把重心转向Context Pruning用语义相似度对历史消息逐条打分自动剔除那些和当前问题在语义上八竿子打不着的废话Token占用直接降了接近四成回答质量反而稳住了。这篇文章把我从设计到落地踩过的坑都写出来覆盖向量化打分、阈值设定、粒度选择以及我最常用的离线回放验证法。适合正在为长上下文发愁、想省Token但又不希望砍掉重要信息的同学参考。1. 为什么Context Pruning值得做我的12轮对话爆掉之后1.1 一次事故丢失的收货地址有一次测试模拟用户咨询退货流程连续问了几轮其中第4轮发过一个收货地址说“换货寄到这个地址就行”。接着他聊了几句别的包括周末营业时间和发票抬头最后又回到换货问什么时候能收到。我早期的实现是全量拼接结果上下文一爆系统自动把最前面的第4轮地址截掉了。模型明明在更早的上下文里看到过地址但在最终回复里居然说“请提供您的收货地址”。这个现象很典型用户没有错模型没有错错的是“上下文管理”太粗暴。只要对话轮数上涨任何不做筛选的全量拼接都会撞到长度上限。尤其是客服、售后、咨询这类场景用户习惯在中间穿插各种碎信息可真正跟当前问题相关的可能就那几轮。这时候需要的不是“尽量保留所有内容”而是把真正影响当前决策的内容捞回来。1.2 为什么不能只靠截断、摘要或滑动窗口暴力截断最简单但问题也最明显丢掉的往往是早期出现的关键实体比如订单号、地址、价格、承诺。摘要有一定帮助但摘要本质上是“二次创作”长对话压成一段话的过程中数字、地址、否定词都可能失真。而且摘要本身也要消耗Token做一次摘要并不便宜。滑动窗口只保留最近N轮对连续任务有效但用户经常聊着聊着提起一个旧信息比如“我刚才说的那个地址”“我第一轮报过的型号”这些信息一旦滑出窗口模型就彻底失忆。我最早试过把窗口调到20轮上下文又容易超限。Context Pruning和它们都不太一样。它不承诺保留所有历史也不试图把所有历史压缩成一段话而是把历史消息看成一组“候选记忆块”每一轮都要回答一个问题这条消息和用户当前输入的语义有多近近的留下远的剪掉。剪掉不是因为它“是废话”而是因为它“在当前任务里不相关”。1.3 我眼中的“废话”语义上不相关不是内容上的废话用户说过的每句话在那个时刻都有它的价值。但价值是会过期的。用户问“你们周末几点开门”时营业时间相关等到用户开始问“退款几天到账”营业时间就很难再对生成回答产生影响。所以Context Pruning里的“废话”准确说是“与当前查询相关性太低的历史内容”。这个判断必须动态做不能一次性把所有历史剪一遍然后丢给后续所有轮次。我今天剪掉的消息明天用户换了个话题可能又变得重要。这也是为什么我最终选择“每轮请求进来后现场对历史做一次相似度打分”的方式而不是做静态的定期清理。2. 语义相似度剪枝的底层逻辑向量、余弦与动态阈值2.1 向量化把对话变成数学上的“坐标”要让模型判断“语义相近”第一步是把文本变成可计算的数字。现在主流的做法是用Embedding模型把一段文本映射成一个高维向量通常是几百到上千维。语义相近的句子在这个高维空间里方向更接近语义无关的句子方向就会明显分叉。你可以把它想成地图坐标一个人说“我要退货”另一个人说“订单退款怎么操作”它们在“售后”主题上会落在相邻区域而“今天天气不错”会落到很远的区域。我们并不知道这个高维空间里的“地图”长什么样但通过向量夹角可以推断两个文本到底靠不靠近。这里有个工程常识向量化一定要走批量接口不要一条条循环调用。把几十条候选文本一次性传给Embedding服务既省时间也可能省钱。后面代码里我会用embed_fn(texts)这种批量写法。2.2 余弦相似度是默认选择文本语义匹配常用的度量是余弦相似度公式是$$\text{sim}(a, b) \frac{\vec{a} \cdot \vec{b}}{|\vec{a}| \cdot |\vec{b}|}$$它计算的是两个向量方向的夹角余弦值范围在-1到1之间。越接近1表示两个文本在语义空间里的方向越一致。我为什么不用欧氏距离因为文本向量的模长容易受句子长度、标点风格影响长句子向量长度通常更大欧氏距离会把这个维度也掺进来导致长文本天然被放大。余弦相似度只关心方向和“内容长度”解耦更适合做相关性判断。在实际测试中我用余弦相似度给历史消息打分得到的排序人工看基本符合直觉换成欧氏距离后长消息的分数普遍虚高把真正语义相关但是表达简短的句子挤下去了。所以在没有特殊理由的情况下默认选余弦相似度。2.3 固定阈值失效用相对分数做动态筛选刚开始我犯过一个错设一个固定阈值比如0.45大于就保留小于就删掉。结果非常不稳定。用户如果问“怎么申请退款”所有历史消息跟它的相似度可能分布在0.12到0.38之间0.45会什么都没留下换个具体主题比如“你们发什么快递”分布又可能整体抬到0.35到0.6。这说明阈值的“合适值”是随用户问题而变的。于是我改成相对打分逻辑先计算出当前问题与所有候选历史消息的相似度序列求均值μ和标准差σ然后只保留得分不低于μλσ的消息。λ是控制剪枝力度的系数λ大则保留更少、更“聚焦”λ小则保留更多、更宽松。这个方案背后的想法是不需要关心绝对分数是多少只需要关心哪些消息在当前问题下“明显高于平均水平”。同一批历史消息在A问题上可能留下15条在B问题上可能只留下6条但两条路径的Token预算却更可控。3. 可跑通的实现方案一个轻量Context Pruner3.1 整体流程拆成四步我落地的时候把流程拆成了四步分块、向量化与打分、过滤、重组。分块是把历史消息按对话轮次切成候选块每条消息带上seq、role、content这三个基础字段。seq是自增序号用于最后重组时恢复原始顺序role决定它是用户还是助手content就是文本内容。打分阶段把当前用户查询和一个“扩展查询”一起向量化。扩展查询的做法我会在后面讲核心是为了避免单轮query太短导致分数偏低。然后对每条候选消息的内容做批量向量化计算与查询向量的余弦相似度。过滤阶段按照动态阈值和Token预算保留候选块。我自己的做法是双保险先用μλσ选出候选再算一下这些候选的总Token数如果超过预算就按得分从高到低截断。重组阶段把保留下来的消息按seq从小到大重新排列拼回去交给模型。这一步看着简单但很容易做错——如果你用字典存选中结果顺序会乱必须靠seq字段恢复。3.2 核心代码一个可改动的Python实现我直接贴一份能跑的代码。embed_fn是一个注入进来的批量Embedding函数接收List[str]返回List[List[float]]。你可以把它接到本地开源向量模型上也可以接到你当前在用的Embedding服务上。from typing import Callable, Dict, List import numpy as np def cosine_sim(a: List[float], b: List[float]) - float: 计算两个向量的余弦相似度。 va np.asarray(a) vb np.asarray(b) denom np.linalg.norm(va) * np.linalg.norm(vb) return float(va vb / denom) if denom 0 else 0.0 def semantic_prune( history: List[Dict], query: str, embed_fn: Callable[[List[str]], List[List[float]]], top_k: int 10, keep_recent: int 2, protection_keywords: tuple (订单号, 地址, 不要, 确认, 承诺), ) - List[Dict]: history: 按顺序排列的对话历史列表每个元素是 {role: ..., content: ...} query: 用户当前输入 embed_fn: 批量向量化函数输入文本列表输出向量列表 top_k: 从更早历史中最多保留的候选条数 keep_recent: 最近多少轮无条件保留 if len(history) top_k keep_recent: return history recent history[-keep_recent:] candidates history[:-keep_recent] # 查询向量化 q_vec embed_fn([query])[0] # 候选消息批量向量化 texts [c[content] for c in candidates] vecs embed_fn(texts) scored [] for c, vec in zip(candidates, vecs): score cosine_sim(q_vec, vec) # 带“硬信息”的消息加保护分尽量别剪掉 if any(kw in c[content] for kw in protection_keywords): score 0.3 scored.append((score, c)) # 按相似度降序取前 top_k scored.sort(keylambda x: x[0], reverseTrue) selected {id(c): c for _, c in scored[:top_k]} # 最近 keep_recent 轮无条件保留 keep_ids {id(c) for c in recent} | set(selected.keys()) return [c for c in history if id(c) in keep_ids]这段代码里有几个关键设计第一keep_recent是必须存在的因为最近两轮往往是理解当前指代的基础。第二protection_keywords是一个简单的“硬记忆保护”机制凡出现这些词就给消息加0.3分提高它进入top_k的概率。第三历史条数本身很少时直接返回原样不做无谓剪枝。3.3 嵌入向量的分块与元信息字段设计如果一条用户消息特别长比如一次性发了一大段文字我会把它按句子切块再用同一轮的对话分组。但这里有个很容易踩的坑如果只保留其中一句模型可能看到“那就麻烦你了”完全不知道这句在回应什么。所以我的规则是当同一轮消息里至少有一个句子被选中时把这一轮的几个句子的摘要作为整体保留。也就是说块的最小单位是“轮次”只有轮次内部话题明显分裂时才下沉到句级。每条消息最好还带一个topic字段作为“主题标签”的缓存。别小看这个字段后面要做“活跃主题加权”时全靠它。我通常会额外用一个小的分类模型给每轮打标签像“退货”“物流”“发票”“闲聊”等标签数不要太多10到20个足够。3.4 如何把剪枝结果拼回LLM上下文剪枝完成后把选中的merged列表转成模型消息格式。我这里写一个简单示例适配那种包含system/user/assistant三种角色的消息接口def build_messages(merged: List[Dict], system_prompt: str, query: str): messages [{role: system, content: system_prompt}] for item in merged: messages.append({role: item[role], content: item[content]}) messages.append({role: user, content: query}) return messages有一点要注意剪枝是在后端做的应该把“那一轮被删了”这件事完全隐藏掉让调用方无感知。如果需要在调试时观察我建议单独输出一份剪枝日志写清楚某轮消息的相似度分数和是否保留方便后续调参但这条日志绝不能混进用户可见的回复里。4. 调参实录阈值、粒度、保留规则的取舍4.1 λ从1.0到2.0Token节省率和回答质量的平衡我在模拟项目X上做过一组调参实验测试集是80条客服场景对话每条约30轮里面至少包含3个关键信息点比如订单号、地址、时间。用不同的λ跑统计平均保留条数、Token节省率和关键信息保留率。λ平均每轮保留消息条数Token节省率关键信息保留率1.01831%97%1.51148%91%2.0662%72%只看Tokenλ2.0很诱人但关键信息保留率掉到72%已经非常危险。模型一旦看不到订单号、地址就可能编造一个这在客服场景里等同于事故。λ1.5对我来说是平衡点能省接近一半Token同时保住九成以上的关键信息。你换到其他领域数字不一定一样。比如代码问答场景关键信息主要是函数名、变量名、报错信息合同审查场景关键信息是金额、日期、主体。所以不要照抄我的λ要做一套自己的离线评估把“关键信息保留率”作为最重要的指标。4.2 粒度整轮保留 vs 句子级剪枝对话轮次级剪枝实现简单每轮消息不管多长都当成一个整体打分。但用户经常在一段话里堆好几件事比如“订单号是ABC123另外你们周末几点开门”如果整轮保留哪怕当前只关心订单号营业时间也会一起留下浪费了Token。句子级剪枝能更精细地省Token但代价是上下文碎片化。把一轮消息拆成多个句子后如果只挑出其中一句前面的指代关系可能断掉。我的折中方案是“轮次优先句级兜底”先把每一轮作为整体计算一次相似度某轮确实命中阈值后再在轮内检查有没有明显属于其他主题的句子。如果没有整轮保留如果有一部分句子明显跑题才把跑题那部分单独剪掉。这个方案实现起来也不复杂先按轮次级算分数选中后再对轮内的句子向量化把相似度远低于该轮平均值的句子标记为可丢弃。但要注意同一轮里如果是“转折”关系比如“本来是X后来改成Y”这种句子千万别剪因为转折本身是重要信息。4.3 硬记忆什么叫绝对不能剪的内容再多相似度计算也不如几条简单规则稳妥。我在系统里维护了一个硬保护清单凡是命中下面这些类型的消息必须无条件保留用户明确指定的订单号、地址、价格、日期、期限模型自己给出的承诺或结论比如“我确认可以换货”包含否定、取消、变更等意图的消息比如“不退款了”“不要黑色”用户主动强调过的重要偏好。实现方式有两种一种是直接强制保留不进top_k竞争另一种是像代码里那样给一个很大的保护分。我后来选择了强制保留因为保护分仍然有被挤掉的风险。强制保留会多花一些Token但换来的是关键信息稳定不丢。这里有个容易误判的地方不是所有含“地址”的消息都重要比如“地址怎么填都不对”可能只是吐槽。但这类误判顶多多留一条消息损失有限反过来漏掉地址才是真正的致命伤。宁可多保留不可错过。4.4 我的离线回放验证法调参最忌讳只盯着Token节省率。我先准备一组完整对话手动标注“这个对话里哪些信息是最后生成回答时必须出现的”。然后分别用完整上下文和剪枝后上下文让模型生成回答再用规则去检查关键信息有没有出现在输出里。比如用户第5轮说过订单号“ABC123”第28轮问“现在到哪一步了”我会检查剪枝后的回答里是否包含“ABC123”这个子串。如果包含说明这个信息被正确保留如果不包含我会倒回去看是相似度分数太低还是被top_k截掉了。这个方法比纯人工打分快很多也足够稳定。我每周跑一次回归把新发现误删案例加进测试集避免调了A参数又破坏B场景。5. 踩坑复盘被误删的用户意图和“记忆断层”5.1 指代悬空只保“相关句”丢了“先行词”这是我在Context Pruning里踩过最痛的一个坑。用户先说“收货地址是A市B路C号”聊到第20轮又说“还是寄到那个地址吧”。第二句和当前查询的相似度很高被保留但第一句因为跟当前查询的相似度低被当成无关内容剪掉了。结果模型只看到“那个地址”完全不知道是哪。解决方案分两层。第一层是keep_recent强制保留最近几轮这能挡掉一部分短距离指代。第二层是在剪枝后加一个“回看规则”如果保留块里出现“这个”“那个”“刚才”“上面”“它”等指代词就往原始历史的前文回找最近一轮包含具体实体的消息强制拉回来。这个规则写起来不难难的是你首先要意识到“语义相似度无法理解指代”。向量模型只能告诉你句子像不像不能告诉你“那个地址”里的“那个”指向的是哪个历史实体。所以Context Pruning和指代消解必须配合使用。5.2 相似度分数“挤牙膏”分布集中在0.2到0.4很多embedding模型输出的相似度会大量集中在一个狭窄区间比如0.2到0.4区分度很差。我一开始设固定阈值0.5结果几乎什么都不保留把阈值降到0.2又所有消息都留下。这就是我前面改用相对阈值的原因。还有另一个提升区分度的办法不要只用当前这一句去和历史消息做相似度而是把当前问题连同最近几轮对话合并成“扩展查询”。比如用户当前输入“那运费谁出”单独看这几个字和历史里的“快递破损了”可能分不高但如果把最近三轮会话合并成一个查询块语义上就能明确这是在问“快递破损后谁出运费”相关历史一时间就浮出来了。我实现的时候相当于构造了一个伪查询最近两轮用户输入 当前输入。这里的“用户输入”不包括助手回复避免模型混淆。5.3 否定句语义撞车Embedding模型对“我申请退款”和“我决定不退款了”两句的向量可能会非常接近因为字面重复度高。这就导致用户“决定不退款”之后历史里那些“怎么申请退款”的消息仍然被当成高度相关内容留在上下文里白白消耗Token还可能让模型产生错乱。我的应对办法是在剪枝前先做一层轻量规则如果当前输入包含明显否定词比如“不”“别”“取消”“撤回”就降低“退款”“申请”这类动作词的权重同时加大对历史中否定句的保护力度。这个方法不完美但能防住大部分翻车场面。如果项目预算允许更稳妥的做法是引入一个小小的意图分类器在向量相似度之外给每条历史消息打一个“是否与当前意图相反”的标签。分类器也不需要很大几千条标注数据就能训练出可用版本。5.4 多任务切换导致“长期主线”丢得快用户聊售后聊到一半插入几句闲聊再切回售后。如果我只拿最新那句“现在到哪一步了”去和历史做相似度早前的售后细节可能已经被闲聊挤掉导致上下文里只剩新一轮的售后开头丢失前面的订单历史。我的解决思路是维护“活跃主题链”。当用户连续三轮以上都在同一主题下发言就把这个主题标记为活跃主题。每次剪枝时活跃主题下的历史块会获得额外加权同时如果当前查询所属主题在历史中曾经出现过那么该主题下的所有高相关块也一并保留。这个功能依赖前面说的topic字段它让Context Pruning从“只看单轮相似度”变成“结合对话主题生命周期做判断”。实现上多加一个topic_boost参数命中活跃主题时给原始相似度加上一个固定偏移比如0.15。6. Context Pruning不是银弹和摘要、滑动窗口的组合策略6.1 滑动窗口永远是保底即便有语义剪枝我还是保留了一个固定的滑动窗口默认最后4到6轮原始对话不参加剪枝直接进上下文。原因很简单最近几轮是理解当前问题上下文的基础而且通常包含最新的用户状态比如“算了我不退了”这种话必须原样保留不能放到语义筛选池里。有人会问如果最近几轮也是废话呢比如用户连续发“好的”“嗯”“继续”。这一点我承认会有多余Token但相比误删用户最新意图的风险这点浪费完全值得。窗口内的消息也可以做一次去重比如把连续重复的“好的”只保留一个。6.2 摘要压缩负责“画条时间线”语义剪枝擅长从更早历史里找回“某一条具体信息”但它不适合提供一个全局故事线。用户在30轮里从咨询到购买到售后如果只保留几个高相关块中间怎么一步步变化的模型是看不见的。所以我还会用摘要压缩对更早的历史做一个全局摘要生成类似“用户最早咨询A商品后来因质量问题提出换货最终选择退货”的概要。这个摘要块本身也可以参与语义打分如果与当前问题相关就被放回上下文。比如用户问“你们还记不记得我之前为什么退款”全局摘要就能派上用场。摘要的成本不能忽视每一次生成摘要都要消耗Token和时间。我的做法是只在对话轮次超过某个阈值后触发摘要并且复用上一轮的摘要进行增量更新而不是每次从零生成。6.3 推荐的混合上下文结构最终我在模拟项目X里使用的上下文结构是三层组合固定窗口层最近4到6轮完整保留语义保留层从更早历史中通过Context Pruner选出的高相关块全局摘要层由更早历史生成的背景摘要仅当与当前查询相关时放入。Token预算紧张时优先级是固定窗口层大于语义保留层全局摘要层最后考虑。因为固定窗口管的是“当下状态”语义保留管的是“当前问题需要的具体历史”而摘要只是“背景板”。这套组合解决了“只靠剪枝失忆”的问题也解决了“只有摘要太模糊”的问题。策略解决的问题代价滑动窗口最近状态与指代窗口外信息会丢语义剪枝找回早期相关信息可能误删或断指代摘要压缩保留全局故事线精度损失、额外生成成本三者结合后我在模拟项目X上的Token占用比纯全量拼接降低了大概四成同时关键信息保留率保持在九成以上。这不是一个惊艳的数字但对长会话场景来说已经足够实用。最后分享一个我一直在用的小习惯每次调完剪枝参数我都会在测试集里故意塞几条“指代链”比如前5轮提一个地址第20轮说“寄到那儿”。如果剪枝后的回答里还能正确命中这个地址说明保留逻辑没瘸否则与其继续调相似度阈值不如先把硬记忆保护名单加厚一点。Context Pruning永远是对Token预算妥协后的信息筛选它不是让模型记住一切而是用最少的Token把当前真正需要的东西留在视野里。
阅读完成 · 觉得有帮助?
咨询建站