ARTICLE DETAIL

资讯详情

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

context-mode实战:从三种模式到动态调度的LLM上下文管理

context-mode实战:从三种模式到动态调度的LLM上下文管理 如果你做过 LLM 应用大概见过这种场面线上机器人前二十轮聊得挺好到第六十轮就开始复读用户问“我刚才说了什么”它反问“您刚才说过什么吗”。查日志会发现请求体大得吓人历史消息被原封不动塞进上下文窗口模型不是不想回答是根本没读到该读的信息。context-mode 要解决的就是这件事。它不是一个模型参数而是一整套上下文管理策略每次请求时决定让模型看到哪些历史、以什么形态看到、保留多少、压缩到什么程度。这篇文章我会把自己在项目中落地的 context-mode 拆开讲从最基础的三种模式到调度逻辑再到上线后被真实流量反复教育出来的几个坑。适合正在做智能客服、Agent 应用、或者任何被 token 成本和上下文溢出折磨的开发团队参考。1. 从一次“断片”事故说起context-mode 到底在管什么先讲事故。去年我接手一个售后客服机器人产品经理的方案很简单把整个会话历史原样拼进 messages 数组。上线第一天一切正常到第七天用户开始投诉。有个用户连续追问三个订单的售后进度机器人把第三个订单的物流号回复给了第一个订单而且语气非常肯定。排查日志看到单次请求的 input tokens 已经超过 1.2 万真正和当前问题相关的有效信息只占两成剩下全是历史寒暄、无关商品浏览记录和重复的物流状态文本。这件事让我意识到LLM 应用的瓶颈往往不在模型侧而在输入侧。模型就像一个只能记住眼前几页纸的实习生你递给他一本流水账他只能在最后几页里找答案。问题不在模型笨而在我们给他看的资料不合适。context-mode 这个名字就是当时起的——它统管“每次调用 API 时上下文内容怎么组成”这一层逻辑。具体拆开context-mode 做三件事看什么、怎么看、看多少。“看什么”解决数据源问题。上下文不只是对话历史还包括系统提示词、工具返回结果、检索到的文档片段、用户画像、临时状态。这些来源全部塞进去很容易但全塞进去等于让模型自己在垃圾堆里翻金块。“怎么看”解决信息形态问题。同一段历史可以原样保留、可以只留最近几轮、可以压成一段摘要、可以抽取成结构化字段形态不同信息密度完全不同。“看多少”解决预算问题。模型上下文窗口虽然越做越大但输出 token 永远要预留而更长输入意味着更高成本、更慢延迟、更分散的注意力。三者之间需要联调不能孤立设计。注意这套思路不局限在聊天机器人。智能客服、代码生成助手、数据分析 Agent凡是“多轮状态 外部工具”的场景都会碰到同一个核心矛盾任务产生的上下文长期看一定超过单次请求能承载的上限。context-mode 的本质是在模型外面加一层调度层用软件工程的确定性去弥补模型上下文选择的随机性。为什么不是简单地把窗口做大就完事有两个实际理由。第一是成本对大模型来说 input token 不是免费的线上流量一大上下文翻倍账单也会翻倍。第二是注意力稀释输入越长模型对中段内容的遵循度越低这不是玄学是很多团队测出来的真实体感。所以我后来设计模式时默认假设是能少给就少给必须给的想清楚再给。2. 三种基础模式打底full-context / truncate / summarize 的取舍这一节讲三种最基础的能力。很多上下文管理方案看着复杂底层无非是这三种的排列组合。2.1 full-context原样全量上下文full-context 就是无脑把历史全带上。适用场景其实不多但确实存在。比如合同审查用户对第三段某条款做了修改你必须让模型看到完整的前后文对照任何摘要都有可能丢掉字面上的关键差异再比如代码审查一个函数跨了十几个调用点截断任何一部分都可能让结论失真。代码最简单async def build_full_context_messages(session, user_query): messages [{role: system, content: SYS_PROMPT}] for record in session.history: messages.append({role: record.role, content: record.content}) messages.append({role: user, content: user_query}) return messages没有任何花活。但我后来给它加了两个硬性约束第一个是轮数阈值比如超过 40 轮强制切换第二个是 token 阈值实测超过预算 60% 后强制切换。不然 full-context 会无限膨胀变成事故温床。所以说full-context 只应该是一种临时状态而不是默认状态。2.2 truncate滑动窗口截断truncate 只保留最近 N 轮对话是大多数团队的默认方案因为实现成本最低。def build_truncated_messages(session, user_query, window10): recent session.history[-window:] messages [{role: system, content: SYS_PROMPT}] messages.extend(recent) messages.append({role: user, content: user_query}) return messages它适用的场景是当前问题只需要近期上下文就能回答。典型如 FAQ 客服用户问“快递什么时候到”模型只需要知道订单号而订单号一般就在最近一两轮里。还有工单系统用户描述报障现象最近几轮已经包含现象描述和已尝试步骤旧轮次无关紧要。缺点也很明显如果一段关键信息发生在早期就会被无脑丢掉。我见过一个真实案例用户在第 3 轮答应了某个方案到第 18 轮问“那我之前同意的事还算不算”此时第 3 轮已经被截掉模型完全失忆。所以 truncate 适合“意图临时性”强的场景不适合“承诺存在性”强的场景。2.3 summarize摘要压缩summarize 把历史压成一小段摘要解决 truncate 丢信息的痛点。def build_summarized_messages(session, user_query, summarizer): if not session.history: summary 暂无历史对话 else: transcript format_transcript(session.history) summary summarizer.summarize(transcript) messages [ {role: system, content: SYS_PROMPT}, {role: system, content: f[对话摘要] {summary}}, ] messages.extend(session.history[-window:]) # 保留最近若干轮原文 messages.append({role: user, content: user_query}) return messages我特别想强调实际落地时不要做成“全历史只给摘要”而是做成“摘要 最近几轮原文”的混合形态。摘要负责全局连续性原文负责当下精确性。比如一个项目讨论场景用户在第 2 轮定下架构方案第 15 轮开始聊具体实现如果只给摘要模型知道“架构定了”但不知道细节如果只给最近几轮模型连架构定了都不知道。混合形态两个问题都解了。三种模式各有优劣我用一张表总结我在选型时看重的维度模式信息保真度token 开销实现成本典型场景full-context最高最高随时间线性增长最低合同/代码审查、法律复核truncate近期高、远期无稳定可控很低FAQ 客服、售后查询summarize中高取决于摘要质量稳定可控中高需额外模型调用项目讨论、长程 agent 任务一个容易被忽略的地方summarize 的 token 开销并不只是那一段摘要还包括生成摘要时的模型调用成本。如果每次都拿主模型去总结成本会很难看。后面我会单独讲这个问题怎么处理。3. 真正棘手的是调度什么时候切模式、以什么为决策依据三种模式如果只是手动选那 context-mode 还不值得做成一个项目。真正有价值的是调度让系统根据当前会话状态自动决定用哪种模式。我把它拆成三个决策因子预算、信息价值密度、风险等级。预算很好理解。不要单一地按轮数判断应按 token 判断。我在做调度器时给每个会话维护了累计 token 计数只有接近预算红线时才触发模式切换。信息价值密度衡量的是历史里有多少信息对于回答当前问题是必需的。如果当前问题是“查订单”那订单号、物流状态是关键信息寒暄和商品浏览记录是低价值信息。风险等级则针对“错误代价大的内容”退款承诺、法律条款、用户明确表达过的偏好这些信息一旦丢了比 token 超了更糟糕。基于这三个因子我维护了一套预算分配比例。假设窗口是 8K实际按模型和业务调节大概这样分配系统提示词15%约 1.2K事实库/结构化记忆15%约 1.2K摘要与最近轮次原文50%约 4K检索/工具结果15%约 1.2K输出预留5% 以上用于生成回答注意这里的“50%”不是固定值而是动态浮动的。如果当前问题比较开放需要更多检索结果就把检索占比调高压低摘要占比。如果用户只是闲聊检索可能完全不需要把预算让给历史上下文。调度的本质就是把预算当作一个可以博弈的箱子而不是一刀切。实现上我写了一个很朴素的调度器。核心逻辑是def decide_context_mode(session, budget, user_query): if session.total_tokens budget.hard_limit * 0.6: return full urgency classify_urgency(user_query) if urgency in (factual, high): return hybrid # facts_bank recent history if session.facts_bank.is_dirty(): return hybrid return summarize这里有两个值得一提的设计。第一个是 hybrid 的调度优先级高于 summarize。只要当前问题涉及事实核对类内容比如“你答应过我什么”“我的订单号是多少”就直接走 hybrid即“事实库 最近几轮原文”的组合不轻易压成摘要。第二个是事实库脏标记。会话过程中如果出现了新的承诺、新的订单号、新的偏好我会把 facts_bank 标记为 dirty下一次请求就强制走 hybrid保证新事实以原文形态优先进入。事实库接起来就是这类结构化数据{ facts_bank: { user_name: 陈女士, order_id: [A1001, A1002], promises: [2025-06-01 前退款 299 元], preferences: [不接受顺丰以外的快递] } }每次对话结束我用一个抽取函数把不可丢失的信息沉淀到 facts_bank构建请求时facts_bank 的优先级永远高于普通历史。这意味着即使 truncate 把第 3 轮截掉了那个“退款承诺”依然存在于请求里。说白了调度器不是让模型去全文里大海捞针而是提前替它把针放到眼前。4. 上线后踩过的五个坑以及怎么绕过去方案设计得再完整上线后依然会有真实流量来教育你。这一节记录五个我踩过、也修复过的坑按破坏力排序。4.1 token 按字符数估算中文请求疯狂超限第一个坑最基础也最致命。早期版本我用字符数估 token英文场景误差不大一上中文就炸。中文一句话的 token 通常比英文字符数更“值钱”估算按字符数砍半都不够。结果就是请求频繁触发 400 错误用户看到机器人一遍遍报错。后来老老实实用官方编码库import tiktoken encoder tiktoken.encoding_for_model(gpt-4) tokens encoder.encode(session.history_text) if len(tokens) budget.limit: switch_mode(summarize)注意这里的坑中坑不同模型使用的 tokenizer 不同不能拿一个模型的 tokenizer 估算另一个模型。模型升级时token 计数代码也要跟着核对否则又会静默超限。4.2 摘要模型本身吃掉一大块上下文第二个坑出现在 summarize 模式。最开始的实现是把历史拼好之后交给主模型让它“顺便总结一下”结果主模型面对超长输入已然接近窗口上限再输出一个长摘要整个请求又爆了。这等于用主模型在一个已接近容量的窗口里干活纯属自找麻烦。我的修复方案是彻底分离摘要调用独立进行输入只包含需要压缩的文本使用更便宜的小模型单轮完成结果以纯文本返回。主模型请求里只携带摘要产物不承载摘要过程。这个改动不仅解决了超限问题还让主请求的成本下降了一截因为摘要模型的价格往往比主模型便宜一个数量级。4.3 截断模式把“系统承诺”截掉了第三个坑是产品层面的。客服机器人用 truncate 后出现过一类典型投诉用户说“你昨天答应给我补偿”机器人完全不知道。查日志发现那个“补偿承诺”发生在第 20 轮而窗口只保留了最近 8 轮承诺被物理删除了。这让我意识到上下文管理的首要目标不是省 token而是保住高价值信息。修复方式就是把承诺类、订单类、身份类信息在产生时立刻写入 facts_bank。任何截断策略都不得触碰 facts_bank。这个原则后来写进了项目规范facts_bank 的优先级高于所有历史裁剪规则只能追加不能因窗口淘汰而删除。4.4 多轮摘要的“失真滚雪球”第四个坑比较隐蔽。会话特别长时系统会对已有摘要再次摘要信息一层层变薄。比如“用户要求退款并希望短信通知”经过两轮摘要变成“用户对退款有要求”三轮之后变成“用户有诉求”。这种失真会滚雪球模型给出的回答越来越偏离真实意图。修复思路是摘要只做索引不做唯一事实来源。我在 facts_bank 里增加了一个 evidence 字段保存关键句的原文和它所在的原始轮次。摘要负责“指路”原文负责“作证”。模型如果真的需要精确信息可以顺着 evidence 去查原文而不是盲信压缩后的只言片语。4.5 工具/检索结果反复占用窗口第五个坑在 Agent 场景尤其明显。工具调用返回一段长 JSON比如天气接口返回未来 15 天的预报但当前只问明天要不要带伞。如果把完整 JSON 塞进上下文下一轮请求还会再塞一遍同样的数据反复占窗口token 花得毫无意义。我的处理方案有三层第一按需剪裁工具输出只保留当前任务相关字段第二加缓存指纹同一参数同一时间窗口内的检索结果只放一次第三对已经输出的工具结果做去重避免多轮工具调用返回同一份文档时重复计费。这些优化叠加起来Agent 单轮请求的平均 token 大概能省下 30% 左右而且回答质量没有下降因为模型看到的干扰信息变少了。5. 把 context-mode 接进 RAG 链路后的扩展玩法前面聊的还停留在“对话历史怎么管”。一旦接入 RAG上下文管理的范围会从历史扩展到知识检索这就值得单独开一节。5.1 从“对话历史”扩展为“多层记忆”传统对话系统只有一份历史记录但真实产品需要不同粒度的记忆。我把会话状态拆成三层层级内容更新频率进入请求的形态短期记忆最近几轮原始对话每轮更新原文 messages工作记忆当前任务状态、待办、临时变量任务级结构化 JSON长期记忆用户画像、事实库、领域知识按沉淀节奏更新facts_bank 向量检索片段接入 RAG 后长期记忆不只是用户画像还包括从知识库和文档里检索出来的片段。context-mode 的调度逻辑也会多一层判断当知识检索结果与当前问题的语义重合度足够高时历史摘要可以进一步压缩把预算让给检索片段。反之如果用户一直在聊个人偏好检索片段基本无用预算要还给历史上下文。5.2 上下文压缩不只有摘要一种手段很多人一提到压缩第一反应就是“让模型总结”。但摘要只是最粗糙的手段。我在项目里还用过三种更精准的压缩方式。抽取关键句不生成新文本而是从原文里挑出包含实体、时间、数字、承诺的句子。好处是零幻觉坏处是上下文可能仍然较长。问题-答案对把多轮问答压成“用户问过什么、最终结论是什么”丢掉中间反复确认的过程适用于售后和支持场景。结构化 JSON把语义信息转成字段比如“用户要求明天上午 10 点前送达并电话联系”可以转成{delivery_time: 明天10点前, contact: phone}。结构化之后模型读取效率很高但抽取流程需要额外维护 schema。一个基本原则压缩手段越靠近“原样抽取”失真越小越靠近“生成式改写”信息密度越高但幻觉风险越大。实际操作中可以先抽取、再结构化最后才考虑生成式摘要。5.3 系统提示词要和 context-mode 联动最后是一个容易被低估的细节系统提示词不是静态的。context-mode 决定的是“给模型看什么”系统提示词决定的是“让模型怎么看待这些内容”。二者应该联动。比如切到 summarize 模式时我会在系统提示里显式声明“对话早期信息已压缩如用户问起早期细节请先查看事实库或要求澄清”模型就不会拿压缩摘要硬编细节。与之配套的是一个可调试的请求视图。我会把每次请求的完整上下文结构打出来哪些来自 facts_bank、哪些是最近原文、哪些是摘要、哪些是检索片段。上线调试时对着这个视图看模型的回答比对着模型输出瞎猜要高效得多。这套调试视图也是我后来回看前面那四个坑时最依赖的分析工具。6. 落地这套方案后我的几条反共识体会文章写到最后分享几条与主流认知不太一样的个人体会。第一窗口大不等于可以不管理上下文。大窗口只是把爆雷时间延后不会消除爆雷。尤其在生产环境token 成本是按量计费的同样一次调用的钱花在高价值上下文上才是值得的。第二完美的摘要不如保命的事实。与其花大力气让摘要更准确不如优先保证承诺、订单、偏好这类关键事实永不丢失。摘要丢了可以再总结事实丢了就是产品事故。第三上下文管理要跟着产品语义走不要纯看技术指标。同样是 truncateFAQ 客服可以用法律咨询就不能用差别不在技术而在业务对信息丢失的容忍度。我在实际项目里的工作顺序是先把 facts_bank 做起来再切 truncate再上 summarize最后才是调度器。每一步解决一个具体问题也让团队的认知逐步跟上。毕竟 context-mode 说到底不是某一个算法而是“让模型在有限视野里总是看到最该看的”这套方法论的落地。如果你的项目也正在被上下文问题折腾不妨从最小的那层开始补起。
返回列表