尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

#21 Agent 的上下文窗口管理:Token 预算、滑动窗口与摘要压缩

#21 Agent 的上下文窗口管理:Token 预算、滑动窗口与摘要压缩 昨天凌晨三点我在调试一个多轮对话的Agent。用户连续问了二十几个关于某款芯片寄存器配置的问题Agent开始胡言乱语——它把前几轮提到的“SPI时钟极性”和当前轮的“I2C地址”混在一起生成了一个既不是SPI也不是I2C的诡异配置。我盯着日志看了十分钟发现上下文窗口里塞满了历史对话最新的关键指令被挤到了窗口边缘模型压根没看到。这就是上下文窗口溢出。你给模型喂了太多token它的大脑注意力机制开始“漏看”信息。今天这篇笔记我把踩过的坑和试出来的方案都写下来。Token 预算别让模型“吃撑”每个模型都有上下文窗口上限。GPT-4是8K/32K/128KClaude是100K国产的Qwen-72B是32K。但别以为给了128K窗口就能塞128K token——模型在长上下文上的表现会衰减尤其是中间部分的信息召回率会断崖式下跌。我习惯把预算分成三块系统提示固定占位比如角色设定、工具描述。我一般控制在2K以内多了浪费。本轮输入用户当前的问题或指令。这是核心必须完整保留。历史上下文之前几轮的对话。这部分最容易被压缩。实际编码时我会在每次对话前做一次token计数。别用len(text)算中文一个汉字占2-3个token英文单词平均1.3个。用tiktokenOpenAI的或者transformers库的tokenizer。importtiktoken# 这里踩过坑不同模型用不同编码器gpt-4和gpt-3.5-turbo的编码器不一样enctiktoken.encoding_for_model(gpt-4)defcount_tokens(text):returnlen(enc.encode(text))# 别这样写直接用len(text)算中文会严重低估# token_count len(text) # 错误中文一个字符算1实际可能是2-3预算分配的逻辑MAX_TOKENS8192# 假设模型窗口上限SYSTEM_PROMPT_TOKENScount_tokens(system_prompt)# 系统提示固定占2KRESERVED_FOR_OUTPUT1024# 给模型回复留的空间别贪心# 可用给历史上下文的token数available_for_historyMAX_TOKENS-SYSTEM_PROMPT_TOKENS-RESERVED_FOR_OUTPUT-count_tokens(current_input)这里有个细节RESERVED_FOR_OUTPUT很多人设为0觉得“反正模型回复也会被截断”。但模型在生成时如果窗口满了它会丢掉前面的内容继续生成导致回复不完整。我至少留1K。滑动窗口最朴素的方案但够用滑动窗口的思路很简单只保留最近N轮对话。比如窗口大小设为10轮第11轮进来时丢掉第1轮。实现上我踩过一个坑直接按“轮数”切分。用户可能一轮说一句话也可能一轮发一大段代码。按轮数切会导致token数波动极大。正确的做法是按token数切classSlidingWindow:def__init__(self,max_tokens4096):self.max_tokensmax_tokens self.history[]# 存储 (role, content, token_count)defadd_message(self,role,content):tokenscount_tokens(content)self.history.append((role,content,tokens))# 从最旧的开始丢弃直到总token数低于阈值totalsum(t[2]fortinself.history)whiletotalself.max_tokensandlen(self.history)1:# 别这样写直接pop(0)列表操作是O(n)对话长了会卡# self.history.pop(0)removedself.history.pop(0)total-removed[2]上面代码里我注释了一个性能问题。如果对话轮次很多比如几百轮每次pop(0)都会导致列表元素整体前移时间复杂度O(n)。更好的做法是用collections.deque或者维护一个头指针。fromcollectionsimportdequeclassSlidingWindowOptimized:def__init__(self,max_tokens4096):self.max_tokensmax_tokens self.historydeque()self.total_tokens0defadd_message(self,role,content):tokenscount_tokens(content)self.history.append((role,content,tokens))self.total_tokenstokenswhileself.total_tokensself.max_tokensandlen(self.history)1:removedself.history.popleft()# O(1)self.total_tokens-removed[2]滑动窗口的优点是简单、速度快。缺点是它会丢掉早期可能有用的信息。比如用户在第1轮说了“我用的芯片是STM32F407”第20轮问“这个芯片的FPU怎么配置”如果窗口只保留最近10轮第1轮的信息就丢了。摘要压缩让模型自己“记笔记”滑动窗口丢信息那能不能把旧对话压缩成摘要让模型自己总结历史然后只保留摘要。我试过两种方式方式一每次压缩全部历史每次对话前把历史对话发给模型让它生成一段摘要。然后清空历史只保留摘要。defcompress_history(history_text):# 这里踩过坑摘要指令要明确否则模型会漏掉关键信息promptf请将以下对话压缩成一段摘要保留所有关键信息 - 用户提到的具体技术参数、芯片型号、错误代码 - 已经给出的解决方案和结论 - 用户当前未解决的问题 对话内容{history_text}摘要responsecall_llm(prompt,max_tokens512)# 摘要控制在512token内returnresponse这种方式的问题每次压缩都要调用一次模型成本高。而且压缩本身会丢失细节——模型总结时可能忽略一些它认为“不重要”但用户实际需要的信息。方式二增量压缩每次新对话进来时只压缩最旧的那部分。比如窗口大小是10轮当第11轮进来时把第1-3轮压缩成一段摘要然后保留第4-10轮摘要第11轮。defincremental_compress(history,compress_batch_size3):# 如果历史超过阈值取最旧的compress_batch_size轮进行压缩iflen(history)MAX_ROUNDS:old_roundshistory[:compress_batch_size]summarycompress_history(format_rounds(old_rounds))# 用摘要替换掉旧轮次new_history[(system,f[历史摘要]{summary},count_tokens(summary))]history[compress_batch_size:]returnnew_historyreturnhistory增量压缩的好处是每次只压缩一小部分成本可控。坏处是摘要会嵌套——第一次压缩的摘要第二次可能又被压缩进新的摘要里信息层层衰减。我踩过最深的坑摘要里提到了“用户之前问过SPI配置”但没写具体配置参数。后续模型看到摘要以为SPI已经配好了实际上用户还在等答案。所以摘要必须包含结论性信息不能只写“讨论了XX话题”。混合策略我目前在用的方案没有银弹。我现在的方案是滑动窗口选择性压缩的混合体滑动窗口做主框架窗口大小设为模型窗口的60%比如8K窗口用5K做历史保证最新几轮完整保留。对窗口外的内容做摘要不是全部压缩而是只压缩那些“有结论”的轮次。比如用户问“怎么配置GPIO输出”模型回答了这一轮就可以压缩成“GPIO输出配置已完成推挽输出50MHz”。保留原始对话的索引压缩后的摘要里加一个标记比如[原始对话ID: 5-8]方便调试时回溯。classHybridContextManager:def__init__(self,max_tokens5000,summary_tokens1024):self.max_tokensmax_tokens self.summary_tokenssummary_tokens self.historydeque()self.summary# 压缩后的摘要self.summary_token_count0defadd_message(self,role,content):tokenscount_tokens(content)self.history.append((role,content,tokens))# 检查总token数totalself.summary_token_countsum(t[2]fortinself.history)whiletotalself.max_tokensandlen(self.history)1:# 把最旧的一轮移出加入待压缩队列oldestself.history.popleft()# 这里可以判断如果这一轮有明确结论才压缩ifself._has_conclusion(oldest[1]):self._update_summary(oldest)totalself.summary_token_countsum(t[2]fortinself.history)def_has_conclusion(self,content):# 简单判断包含“已配置”、“已完成”、“答案是”等关键词keywords[已配置,已完成,答案是,结论,因此]returnany(kincontentforkinkeywords)def_update_summary(self,round_info):# 将这一轮的信息追加到摘要中new_summaryf{self.summary}\n-{round_info[1][:100]}...# 截取关键部分self.summarynew_summary self.summary_token_countcount_tokens(self.summary)# 如果摘要本身太大再对摘要做一次压缩ifself.summary_token_countself.summary_tokens:self.summarycompress_history(self.summary)self.summary_token_countcount_tokens(self.summary)defget_context(self):# 组装最终上下文摘要 历史context[]ifself.summary:context.append((system,f[历史摘要]{self.summary}))context.extend(self.history)returncontext这个方案不是完美的。_has_conclusion的判断逻辑很粗糙有时候模型回答了一堆废话但包含关键词会被错误压缩。更靠谱的做法是让模型自己判断“这一轮是否有需要保留的结论”但那样又得多一次调用。个人经验别迷信大窗口。128K窗口看起来很美好但模型在长上下文上的表现远不如短窗口。我实测过GPT-4在8K窗口内准确率95%拉到32K就降到80%左右。能用滑动窗口解决的问题别上压缩。摘要压缩的时机很重要。不要在用户还在输入时就压缩等一轮对话完全结束用户确认了答案再压缩。否则用户说“等等我刚才说的不对”压缩已经做了信息就乱了。给用户一个“回看”的能力。我在Agent里加了一个/history命令用户可以查看被压缩掉的原始对话。虽然压缩了但原始数据我存在本地数据库里用户需要时可以调出来。这不仅是功能更是信任——用户知道你没偷偷丢掉他的信息。监控token使用率。我在每次对话后都打一条日志[Context] Used: 6120/8192, Summary: 1024, History: 5096, Compressed rounds: 3。这样能直观看到窗口的使用情况发现异常比如某次对话突然暴涨时及时调整策略。最后一条也是血的教训永远在发送给模型之前做一次token检查而不是依赖模型自己截断。模型截断是静默的它不会告诉你“我把你前面的内容丢了”只会给出一个看起来合理但实际缺失上下文的回答。你排查半天最后发现是窗口溢出了。上下文窗口管理没有标准答案取决于你的场景。客服机器人可以大胆用滑动窗口因为用户问题通常独立技术问答Agent需要小心用摘要因为参数配置可能跨多轮。多试多打日志找到适合你场景的平衡点。
返回列表