ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

上下文管理实战:context-mode如何让LLM对话更稳更省

上下文管理实战:context-mode如何让LLM对话更稳更省 1. 为什么我放弃了“无脑塞上下文”的做法先交代一下背景。我最近大半年都在做 LLM 应用开发每天打交道最多的不是模型本身而是上下文。早期写对话机器人我的实现方式很直接把用户发的每一条消息、我回的每一条消息全部追加到一个 messages 数组里然后一股脑丢给模型。Demo 阶段确实爽代码简单效果也能看。但一旦进入真实业务场景这套“无脑塞上下文”的做法立刻失控。失控的典型表现有三种。第一种是对话稍微长一点token 直接爆炸。用户问个十几轮再夹带几条长文档随便一算就是一万多 token调用成本肉眼可见地涨。第二种是模型开始“记性错乱”前面的信息记住一些、忘掉一些优先级全乱了用户问“我刚才说的那件事怎么办”模型开始一本正经地编。第三种是调试极其痛苦你根本没法确认模型到底基于哪一段历史给出了回答出了问题都不知道该砍哪一行。后来我在一次内部技术分享里听到同事提了一句“context-mode”意思是把上下文管理做成一种显式的模式而不是被动地累积。这句话点醒了我。我回去把代码重构了一遍把上下文从一个无脑增长的列表改成了有结构、有策略、有生命周期的“模式化”管理。跑了一段时间之后对话稳定性上去了成本降了大约三分之一最关键的是出了幺蛾子我能快速定位问题了——这才是聊下去的动力。这篇文章就把我落地 context-mode 的完整思路、代码实现、踩坑记录都摊开讲。适合正在做聊天机器人、AI 客服、智能体Agent、或者任何需要长对话记忆的 LLM 应用开发者。不管你是刚接触这个概念还是已经被上下文问题折磨了一阵子下面这些内容都能直接用。2. 核心概念与设计拆解2.1 什么是 context-mode把“记什么”和“怎么记”分开简单说context-mode 是一种上下文管理策略它的核心思想是不再把对话历史当成一条连续的、不可分割的串而是把它拆成若干“段”每一段承担不同的职责由一套显式规则来决定——当前这轮请求里哪些段要带、哪些段可以丢、哪些段需要先压缩。打个比方。传统的上下文管理方式就像你进会议室的时候把前面每一场会议的原始录音都搬进去全放出来。而 context-mode 的做法是先整理出一份“会议纪要”核心事实再抽出几张跟本次议题直接相关的“原始发言记录”关键引用至于无关紧要的闲谈直接留在档案室不带了。这套思路看起来简单但落地之后你会发现它解决的不只是 token 超限的问题更关键的是让模型的注意力集中在当前真正需要的信息上。模型的注意力是有限的你硬塞给它 10 万字的聊天记录它不可能真正“记住”每一个细节它只会被最近的、位置靠前的、或表述清晰的内容带跑。context-mode 的目标就是通过管理层面的主动筛选帮模型排除噪音。2.2 上下文的三层结构原始层、提炼层、控制层我在实际工程里把上下文拆成了三个层面这样每一层都有清晰的责任边界后续的增删改查都方便。第一层叫原始层Raw Layer存的是完整的对话历史包括用户原话、我的回复、工具调用结果、抓取到的网页内容等等。这一层只负责“存”不负责“用”它存在的意义是作为一个数据源随时可以回溯原始信息。真实场景里原始数据我一般落在 Redis 或者数据库中不会常驻内存只有在需要重新提炼时才读出来。第二层叫提炼层Distilled Layer这是 context-mode 的核心。在这一层里原始对话会被提炼成三种类型的摘要长期摘要比如用户的核心目标、关键背景资料、短期摘要最近几轮的要点、当前意图正在进行的任务的当前状态。这三者组合成一份结构化的“当前上下文快照”每次调用模型时主要就是把这一层的内容带进去。第三层叫控制层Control Layer它不参与具体的对话内容而是负责元信息的管理比如当前上下文还在哪个阶段、上一次提炼是什么时候、还有多少 token 预算、哪些内容被标记为“高优先级不能丢”、哪些被标记为“低优先级可压缩”。控制层是整个 context-mode 的指挥中枢策略规则的执行都在这层完成。2.3 为什么选择显式模式而非隐式拼接可能你会问市面上不是有很多自动上下文压缩的工具吗我理解确实有一类方案是通过 embedding 相似度自动检索相关历史片段拼进去这属于“隐式拼接”。但我在实践中发现隐式拼接有两个绕不开的坑。坑之一是检索质量不稳定。embedding 检索擅长找语义相似的片段但对话上下文里的关联不只是语义相似更多是逻辑因果和引用关系。比如用户在第 5 轮提到“明天下午三点”到了第 20 轮说“把那个时间改到四点”这里的两条信息语义上完全不相似但逻辑上强相关。靠相似度检索大概率找不回第 5 轮那条信息于是模型就懵了。坑之二是不可解释性。隐式拼接跑出来的结果你很难解释为什么这一轮把某条历史带进去了那一条没带。出了问题只能干瞪眼。而 context-mode 的显式规则——哪些信息进入长期摘要、哪些进入短期摘要、优先级怎么标——每一步都明明白白写在代码里是可审计、可调试的。对我来说这种可控性价值远大于那一点自动化的便利。3. 实操从零落地一个 context-mode3.1 定义上下文数据结构动手第一步是定义一套清晰的上下文数据结构。我用的语言是 Python但思路完全通用。核心是三个数据类分别对应上文的提炼层、原始层和控制层。from dataclasses import dataclass, field from typing import List, Optional dataclass class DistilledContext: 提炼层真正会随请求发给模型的上下文快照 long_term_summary: str # 长期摘要用户目标、背景、关键约定 short_term_summary: str # 短期摘要最近几轮的核心进展 current_facts: List[str] field(default_factorylist) # 当前关键事实 pending_tasks: List[str] field(default_factorylist) # 待办/未完成事项 dataclass class ContextMessage: 原始层单条对话记录的完整信息 role: str # user / assistant / tool content: str timestamp: int # 时间戳用于排序和过期判断 importance: int 1 # 重要性标记0丢弃1普通2关键 metadata: dict field(default_factorydict) dataclass class ContextControl: 控制层元信息与预算管理 session_id: str token_budget: int 8000 # 本轮请求允许的上下文预算 estimated_tokens: int 0 last_distill_time: int 0 # 上次提炼时间 high_priority_keys: List[str] field(default_factorylist)这里我强调三个设计要点。第一个是 importance 字段。它不是摆设而是控制层做丢内容判断的重要依据。我在代码里给所有进入上下文的消息打重要性标记闲聊打 0业务关键信息打 2普通对话打 1。标记来源分两类一类是规则比如命中“订单号”“日期”“金额”这些关键词就自动标记另一类是模型自判每隔几轮让模型把重要信息标出来。这套标记体系在后续的压缩决策里起了大作用。第二个是 pending_tasks 字段。实际做下来发现让模型记住“还有哪些事没做完”比记住“都聊了什么”更有价值。比如用户说“帮我写一篇文章另外顺便查一下明天的天气”模型如果只记住聊过这个话题没有记住还有查天气这个待办很容易写了一半就把天气忘了。pending_tasks 单独列出来每次请求都明确告诉模型“别忘了几件事还没做”。第三个是 timestamp 字段。原始层的数据会有过期策略比如超过 48 小时的对话内容默认不进入任何摘要。时间戳就是为了支持这种“让旧信息自然淡出”的机制。3.2 实现上下文压缩与分片策略数据结构定好了接着是核心算法层面怎么决定什么该留、什么该去。我实现了一套简单的两阶段压缩策略就地压缩和回溯再压缩。就地压缩发生在每轮对话结束后。当检测到预估 token 超过预算阈值的 70% 时触发一次压缩操作。压缩的操作是把当前上下文里的普通消息importance1交给模型让它提炼成短摘要替换掉原始消息。具体实现我封装成一个函数import tiktoken def estimate_tokens(messages: list) - int: 用 tiktoken 估算 token 数优先用 cl100k_base 编码 enc tiktoken.get_encoding(cl100k_base) total 0 for m in messages: if isinstance(m, dict): total len(enc.encode(f{m.get(role, )}: {m.get(content, )})) else: total len(enc.encode(m)) return total def compact_context(raw_messages: List[ContextMessage], distill: DistilledContext, model) - DistilledContext: 就地压缩把普通消息提炼进摘要关键消息保留原文 # 收集普通消息 normal_msgs [m for m in raw_messages if m.importance 1] key_msgs [m for m in raw_messages if m.importance 2] if not normal_msgs: return distill # 把普通消息交给模型做摘要 prompt 请将以下对话记录压缩为要点摘要保留事实、决定、用户偏好丢弃寒暄 content \n.join(f{m.role}: {m.content} for m in normal_msgs) resp model.complete(prompt \n content) # 更新提炼层并入短期摘要 distill.short_term_summary resp return distill这段代码的思路是普通消息一次性压缩成摘要而重要消息importance2保持原文不压缩。为什么重要消息要保留原文因为摘要本质上是有损压缩牵涉到订单金额、合同条款、时间节点这类信息一个字的偏差可能就会造成业务事故。重要消息宁可多占 token也不能让模型在压缩过程中“自由发挥”丢掉细节。回溯再压缩是另一套机制触发条件更严苛。当上下文里的关键消息importance2也开始超限时我会把之前已经压缩过的短期摘要再交给模型和长期摘要做一次合并。这相当于把“周报”合并成“月报”粒度进一步变粗。这一步的信息损失是最大的所以我在工程上加了保护合并之前会先备份旧摘要到原始层万一后面发现问题可以回滚恢复。分片策略则是应对长文本的。如果用户一次性拖进来一篇几万字的文档参与对话我不建议直接塞进提炼层而是先做切片每片 2000 字左右然后对每片做独立的摘要再把所有片摘要合成一个总摘要。这一步既能控制 token又能避免长文档超过模型本身的上下文窗口。3.3 接入对话回调的关键代码数据结构与压缩算法都有了接下来需要把它们接入到实际的对话流程里。我主导这部分代码时遵循了一个核心原则对话主流程只面向 DistilledContext不接触原始层。所有原始层的数据获取、提炼、压缩都通过回调函数在后台完成。代码逻辑如下class ContextManager: def __init__(self, session_id: str, model, budget: int 8000): self.control ContextControl(session_idsession_id, token_budgetbudget) self.distill DistilledContext() self.raw_messages: List[ContextMessage] [] self.model model def add_message(self, role: str, content: str, importance: int 1): 新消息进入原始层并触发预算检查 self.raw_messages.append( ContextMessage(rolerole, contentcontent, timestamptime.time(), importanceimportance) ) # 预估当前 token触发就地压缩阈值检查 current_budget self._estimate_all() if current_budget self.control.token_budget * 0.7: self._compact() def build_request_messages(self) - list: 构造发送给模型的消息列表系统提示 提炼层 短期关键详情 messages [ {role: system, content: f【上下文模式】\n f长期摘要{self.distill.long_term_summary}\n f短期摘要{self.distill.short_term_summary}\n f当前关键事实{self.distill.current_facts}\n f待办任务{self.distill.pending_tasks}} ] # 只把重要性为 2 的近期关键消息原文带进请求 recent_key_msgs [ m for m in self.raw_messages if m.importance 2 and time.time() - m.timestamp 3600 ] for m in recent_key_msgs[-6:]: # 最多带最近6条关键原文 messages.append({role: m.role, content: m.content}) # 把最近一轮用户消息加进来 if self.raw_messages: last_msg self.raw_messages[-1] messages.append({role: last_msg.role, content: last_msg.content}) return messages def _compact(self): 就地压缩普通消息提炼进摘要删除原始普通消息保留关键消息 normal_msgs [m for m in self.raw_messages if m.importance 1] key_msgs [m for m in self.raw_messages if m.importance 2] if not normal_msgs: return prompt (以下对话记录需要压缩为要点摘要。 请保留用户的核心诉求、已确认的事实、做出的决定 丢弃寒暄、重复表达和无意义的内容\n\n) content \n.join(f{m.role}: {m.content} for m in normal_msgs) resp self.model.complete(prompt content) self.distill.short_term_summary f{self.distill.short_term_summary}\n{resp} # 普通消息从原始层移除避免重复累积 self.raw_messages key_msgs这里有几个细节我劝你务必注意。第一系统提示里的“上下文模式”块是给模型看的。我把它当作一份“上下文说明书”塞在最前面明确告诉模型长期摘要是什么、短期摘要是什么、待办有哪些。实测下来这比把所有历史散落在多轮 user 消息里要清晰得多模型的遵循度也有明显提升。第二最近关键消息只带 6 条。别看这个数字简单它是我测试了很多轮的结论。带太多关键消息会挤占预算带太少又容易丢失必要的细节。6 条基本覆盖了“一小时内最多 6 个关键事项被讨论”的常规场景超过 6 条说明对话已经严重发散更合理的做法是让用户重新发起一个新会话。第三压缩后立刻删除原始普通消息。这一步是很多人会忽略的你做了摘要但如果不删原始消息总量根本没有减少调用成本照样涨。删除操作也就是把 raw_messages 重设为只有关键消息。如果你担心误删可以把普通消息转存到数据库再删但至少内存里要清理干净。3.4 让模型帮你标注重要性的半自动方案上面提到 importance 字段我在项目里最初是纯人工规则标注的。后来发现纯规则太死板例如“帮我查一下天气”这种临时请求重要性其实不高但它不含任何关键词规则压根识别不出来。后来我在每三轮对话结束后加了一次模型自判def auto_annotate_importance(raw_messages: List[ContextMessage], model) - None: 让模型从近期对话中找出重要性为2的消息 recent raw_messages[-10:] content \n.join(f{m.role}: {m.content} for m in recent) prompt ( 从以下对话中提取出与用户业务目标直接相关的关键消息 以JSON数组输出每条的原文序号从1开始例如[1,5]。 没有则输出[]。\n\n content ) resp model.complete(prompt) try: idx_list json.loads(resp) for idx in idx_list: if 1 idx len(recent): recent[idx - 1].importance 2 except json.JSONDecodeError: # 解析失败时保持原状不阻断主流程 pass这段代码有几个点需要注意只对最近 10 条做标注不扫描全量历史因为扫描全量的成本不划算意义也不大解析失败时静默跳过不能因为标注环节出错导致主流程崩溃模型自判的准确性不是 100%所以只做“拔高”从 1 提到 2不做“降低”从 2 降回 1宁可多留一些关键信息也不能误删真正的重点。这样混合了规则模型自判之后重要性标记的召回率明显提升了而且大多数场景下模型判断得挺准。4. 常见问题与排查技巧实录4.1 首轮对话就超 token问题出在哪有一种很典型的报错现象用户只发了一句话结果请求发出去直接超限。排查到最后发现问题不在对话历史而在于系统提示词里塞了一份巨长的“产品说明”那份说明本身就有 7000 多 token再加一点上下文就爆了。这类问题的排查思路是超限时先分桶测试分别估算系统提示词、提炼层、关键消息、最近消息各自的 token 数谁占大头先砍谁。我在代码里加了一个简单的 debug 日志函数def log_token_usage(control: ContextControl, distill: DistilledContext, key_msgs: list): usage { system_prompt: estimate_tokens([_system_prompt()]), long_term_summary: estimate_tokens([distill.long_term_summary]), short_term_summary: estimate_tokens([distill.short_term_summary]), key_msgs: estimate_tokens(key_msgs), } print(f[token usage] total{sum(usage.values())}, detail{usage})实测下来最常背锅的是长期摘要。很多开发者做长期摘要时没有做长度上限让模型自由发挥结果每一轮压缩都往里面追加内容越滚越长。我的习惯是给长期摘要设一条硬性长度上限比如 800 字以内超出就必须再次压缩把它当成一个“有界”的数据结构来管理。4.2 上下文丢失用户说“我刚才明明告诉过你”这是 context-mode 最敏感的一类问题。用户前脚说了关键信息后脚你压缩的时候把它误当成普通消息直接丢掉了。等你再问用户“麻烦再说一遍您的地址”体验就很糟糕了。这种问题的根源在于重要性标注不够准。我排查时常用的办法是回放把原始层里所有消息按时间打印出来检查有没有重要消息被错误标记为 importance1。回放几次就能发现容易漏掉的是那些“信息密度高但表述朴素”的内容。比如用户说“对了我明天出差晚上的会就取消了”这句话里包含了两个关键事实出差、取消会议但字面表达非常日常规则命中不了模型自判偶尔也会漏。我的改进方案是提升模型自判的触发频率——从每三轮一次改成每两轮一次同时把 prompt 里明确加上“注意识别用户的口语化表述例如‘对了’‘顺便’‘另外’开头的句子往往是补充重要信息”。这一改漏标率下降了不少。另一个兜底方案是设置“最近 N 轮消息默认进入关键列表”。比如最近两轮的 user 消息不管 importance 标多少build_request_messages 时都原样带入。毕竟离现在最近的对话一般是上下文里最相关的内容牺牲一点预算换稳定性是值得的。4.3 多会话隔离别把 A 用户的话带到 B 用户的会话里一开始我在单会话场景下自测一切正常。一旦部署到线上发现不同类型的用户会话开始互相“串味”。问题出在 session_id 的处理上我没有显式区分每个会话的上下文存储导致所有用户的原始消息都堆在了同一个列表里。修复方式是每个 session_id 独立创建 ContextManager 实例并且用 Redis 以 session_id 为 key 做持久化。每次请求进来时先从 Redis 加载对应实例对话结束后把实例状态序列化存回去。这里有一个性能细节要注意不能把整个 ContextManager 对象用 pickle 存因为里面带有 model 实例不可序列化。我后来只持久化三个核心字段DistilledContext、原始消息列表、控制层的预算与时间戳model 每次从配置中心重建。多会话隔离还有一个隐蔽的坑就是时间戳冲突。两个会话如果共用同一个时区处理逻辑在跨天的时候可能会出现时间混乱导致“最近1小时内的关键消息”判断失效。我的习惯是统一用 Unix 时间戳所有比较都用时间戳而不是字符串时间这样跨天、跨时区都不会出问题。4.4 压缩引起模型“失忆”摘要到底该怎么写我得坦白说一个残酷的现状摘要是有损的压缩一定会丢信息。很多开发者包括我自己早期都犯过一个错误就是把摘要当作一种“浓缩所有信息”的魔法以为摘要写得足够好模型就能记住一切。实际上摘要的信息密度是有上限的超过这个上限摘要里全都是“正确的废话”。比如我早期让模型压缩历史它给出的摘要长这样“用户与助手就项目安排进行了讨论确认了一些事项并就后续步骤达成了初步意见。”——这种摘要就是典型的废话信息量为零。后来我把压缩的 prompt 改成了强约束问题形式“请以下列问题为框架只回答能直接填进框架的内容 1. 用户的核心目标是什么 2. 目前已确认的事实有哪些逐条列必须包含具体数值、日期、名称 3. 用户明确表达过哪些偏好/要求 4. 还有哪件事没有完成 其余内容一律不要写。回答不超过200字。”改成这个模板之后摘要质量好了很多。核心逻辑是把“尽量多写”改成“只回答关键问题”让模型的注意力落在业务需要的信息维度上。这个教训也说明了一个道理context-mode 的成败往往不在于框架多精巧而在于摘要的提示词写得多具体。5. 关于 context-mode 的延伸联想与实际选择做完这套方案之后我回头审视了整个架构发现 context-mode 的价值其实不止于“省 token”。它改变的是整个对话系统的组织方式。以前我是在跟模型对话现在我是在跟“一份经过整理的项目档案”对话每次交流都是在这个档案基础上做增量修改。档案里有事实、有待办、有摘要而模型扮演的角色更像一个根据档案工作的助理而不是一个什么都得靠现问现记的临时工。这种思路其实可以延伸到很多地方。比如我在做 Agent 规划时也采用了类似的模式把 Agent 长期目标、当前步骤、已完成步骤、下一步计划拆成结构化的上下文块而不是让 Agent 自己漫无边际地推理。再比如做文档问答时把检索到的文档块按“核心结论”“证据细节”“补充背景”分层再决定哪些进 prompt、哪些留在外部。这套“分层、提炼、有损接受”的哲学是通用的。不过我还是要说一句大实话context-mode 不是银弹它解决的是“上下文需要管理”的问题但它不能替你解决“上下文根本没有”的问题。如果业务本身需要模型记住大量精确的细节那更合理的方案是接入外部记忆——比如向量数据库做长期检索、或者专门的记忆服务而不是指望摘要能包打天下。在我目前的服务里context-mode 承担的是“短期对话的工作记忆”长期知识则交给了外部检索两者配合才达到比较理想的体验而不是互相替代。如果你现在也被上下文问题困扰着我的建议是不要等到线上爆炸了才动手早点把上下文的“管理意识”建立起来。哪怕第一版很粗糙只做了“近 20 轮完整保留、更早的压缩成摘要”这一件事也能立刻看到效果。做完了这第一步后面再逐步引入重要性标注、结构化提炼、预算控制这些进阶手段就是顺水推舟的事了。
返回列表