
如果你和我一样在做 LLM 应用大概率碰到过这种场景用户在对话框里聊了十几轮程序突然抛出一个 token 超限的报错或者你把所有历史消息一股脑塞给模型结果它越聊越笨连用户刚说的需求都抓不住。我最早遇到这些问题时第一反应是换更大的模型、加更长的上下文窗口后来发现这只是把问题往后推并没有真正解决。最后我沉淀了一个叫 context-mode 的小模块它的核心职责只有一件事在每轮调用模型之前根据当前对话状态决定哪些内容该进上下文、以什么形式进、保留多少。如果你正在做聊天机器人、AI 客服、文档问答这类应用这篇文章里的思路和代码应该能帮你少走很多弯路。1. 为什么要做 context-modeLLM 应用的真实痛点刚开始做 LLM 应用时我觉得上下文管理很简单把 messages 数组原样传给模型就行。等用户量上来、对话轮次变多我才发现这件事远比想象中复杂而且绕不过去。1.1 上下文窗口从来都不是真的“无限大”现在很多模型都宣称支持 128k、甚至 200k 的上下文窗口听起来很够用。但实际跑起来你会发现窗口越大单次请求的 token 越多费用越高首字延迟也越明显。用户连续聊上几个小时哪怕是 128k 也会被撑爆。我之前做过一个客服机器人用户可以在同一个会话里反复咨询不同订单一聊就是大半天。结果第 80 轮对话时请求直接失败因为历史消息加起来已经接近窗口上限。后来我尝试买更大窗口的模型成本直线上升但问题只是从“必然爆”变成了“晚一点爆”本质没有变。这里的核心事实是上下文不是无限的存储而是每轮调用都要花钱和时间的资源。我们不能指望模型自己“记住”所有东西应用层必须主动管理。1.2 塞得越多模型不一定越聪明我一开始的优化方向很朴素既然窗口有限那就尽量把窗口塞满让模型“看到”所有信息。结果上线后用户反馈更差了模型经常被无关历史带偏。后来我查了不少资料发现一个很常见的现象模型对长上下文中间部分的内容感知会变弱也就是所谓的“lost in the middle”。还有历史消息里如果混着大量过期的、矛盾的信息模型反而会不知道该信哪条。举个例子用户在问退货政策但历史记录里还留着他上一周和客服吵架的内容模型在回答时可能会把那些情绪化表达当成当前诉求回复就容易跑偏。对 LLM 来说背景不是越多越好关键是内容相关、结构清楚、时效正确。这个认知让我把重点从“塞满窗口”转向“筛好窗口”context-mode 就是从这时候开始设计的。1.3 手写 if-else 管上下文项目还没上线就乱了早期没有独立模块时我是这样管上下文的if len(messages) 10: messages messages[-10:]看起来很简单但放到真实项目里问题马上来了。有的接口要保留系统提示词有的要带上知识库片段有的要做多轮意图判断。不同路由各自写了一套裁剪逻辑代码里到处是total_tokens 7000这种魔法数字。上线后我根本不敢改因为改了 A 接口的阈值可能影响 B 接口的行为加了新的上下文策略又要重新梳理所有历史逻辑。更麻烦的是线上出问题时很难定位究竟是哪一段逻辑把关键信息丢掉的。所以我才决定做 context-mode把上下文管理从业务代码里抽出来变成一套可解释、可配置、可测试的策略层。2. context-mode 怎么设计从“删历史”变成“管策略”既然要抽成独立模块就不能只做“截断最近几条”这种单一功能。我把它做成了一套策略系统核心是一组可组合的上下文模式。2.1 四种模式覆盖绝大部分对话场景我先建立了一个表格把常见对话场景拆开看最终收敛出四种模式模式适用场景核心策略成本特点strict短对话、单轮问答全部消息原样保留低但只适合短对话slide中长对话只保留最近 N 轮中等常用兜底方案summary超长对话把旧消息压缩成摘要有额外摘要成本retrieval知识密集问答从外部知识库检索相关片段需要接入向量库严格说这四种模式不是互斥的。比如一个长对话场景最近几轮用 slide更早的内容用 summary遇到知识类问题时再触发 retrieval。关键是让模块有统一的策略入口而不是每个接口自己搭一套。2.2 模式不是写死的是策略路由出来的有朋友问我是不是每个对话一开始就要定好用哪种模式不是。我一开始也这么想过后来发现用户诉求是动态的可能前 5 轮在闲聊第 6 轮突然问一个很具体的知识型问题这时如果还用滑窗模式不触发知识库检索回答质量就会很差。所以 context-mode 里加了一个简单的路由逻辑每次调用模型前先做判断如果当前用户问题包含明显的知识查询意图优先进入 retrieval 模式。如果整体 token 数低于 strict 阈值直接用 strict不做任何裁剪。如果对话轮次超过 slide 阈值但总 token 还在预算内用 slide。如果 token 预算已经比较紧张用 summary 把最旧的一部分压成摘要。这个策略用代码描述并不复杂但它解决了一个很实际的问题上下文管理不能靠运营同学手工定死而要跟着每一次真实请求动态决策。2.3 两个抽象ContextPolicy 和 ContextPacker为了不让策略代码散落在业务里我抽了两个核心抽象ContextPolicy决定当前对话该走哪种模式以及哪些内容被保留、摘要、检索。ContextPacker把策略决定的结果拼装成最终发给模型的 messages 数组。ContextPolicy解决的是“留什么”ContextPacker解决的是“怎么拼”。两者解耦之后新增一种模式时不需要动调用方代码只需要实现一个新的 Policy再注册到路由里。这个设计让我后续加了不少自定义策略业务侧几乎零改动。3. 落地实现一个能跑的 context-mode 最小版本说了这么多设计思路接下来给出一套可以直接跑起来的最小实现。我用 Python 写的结构很轻接入 OpenAI 兼容接口时只需要把最终生成的 messages 传进去。3.1 数据结构和 token 估算先定义基础结构from dataclasses import dataclass, field from typing import Callable, List dataclass class Message: role: str content: str metadata: dict field(default_factorydict) dataclass class ContextDecision: mode: str messages: List[Message] summary: str retrieved: List[str] field(default_factorylist)Message我故意加了一个metadata字段后面存消息时间、来源渠道、知识库文档 ID 都很方便。metadata虽然不会直接传给模型但会在策略判断时用上比如“超过 10 分钟的消息优先压缩”。token 估算这块生产环境最好用模型官方的 tokenizer比如 tiktoken。但为了快速验证我写了一个启发式估算函数def estimate_tokens(text: str) - int: chinese_chars sum(\u4e00 c \u9fff for c in text) other_chars len(text) - chinese_chars return int(chinese_chars * 1.5 other_chars * 0.3) 4中文字符按 1.5 算英文字符按 0.3 算再叠加一个 4 token 的基础损耗。这个数字不绝对准确但足够用来判断“该不该换策略”。3.2 三行代码实现的滑窗策略滑动窗口是最常用、也最好理解的策略。我直接取最近 N 条消息丢掉更早的class SlidePolicy: def __init__(self, keep_last_n: int 12): self.keep_last_n keep_last_n def decide(self, history: List[Message], budget: int) - ContextDecision: recent history[-self.keep_last_n:] return ContextDecision(modeslide, messagesrecent)实际用的时候有个细节不能简单按“轮”取因为用户可能一次发很长的内容12 轮消息可能已经超过预算。更稳的做法是先按预算倒推从后往前累加直到 token 数接近阈值为止。class BudgetSlidePolicy: def __init__(self, max_tokens: int 4000): self.max_tokens max_tokens def decide(self, history: List[Message], budget: int) - ContextDecision: selected [] used 0 for msg in reversed(history): tokens estimate_tokens(msg.content) if used tokens self.max_tokens: break selected.append(msg) used tokens selected.reverse() return ContextDecision(modeslide, messagesselected)这样既能最大化利用上下文预算又不会因为某条超长消息把整个窗口撑爆。3.3 摘要策略和知识库策略摘要策略稍微复杂一点因为它要调用一次模型或者一个摘要服务。我把摘要函数抽象成可注入的回调这样在本地测试时可以传一个假的函数线上再换成真正的 LLM 调用。class SummaryPolicy: def __init__(self, summarize_fn: Callable[[List[Message]], str], trigger_tokens: int 5000): self.summarize_fn summarize_fn self.trigger_tokens trigger_tokens def decide(self, history: List[Message], budget: int) - ContextDecision: total sum(estimate_tokens(m.content) for m in history) if total self.trigger_tokens: return ContextDecision(modestrict, messageshistory) split_idx len(history) // 2 old_part history[:split_idx] recent_part history[split_idx:] summary self.summarize_fn(old_part) return ContextDecision(modesummary, summarysummary, messagesrecent_part)summarize_fn内部我一般让模型把旧消息压缩成几类信息用户诉求、已解决事项、待跟进事项、用户的语气或偏好。摘要不是单纯“给原文写简介”而是要为后续对话保留有用的决策上下文。知识库策略需要对接向量检索所以我也用了回调注入class RetrievalPolicy: def __init__(self, retriever: Callable[[str], List[str]], top_k: int 3): self.retriever retriever self.top_k top_k def decide(self, history: List[Message], budget: int) - ContextDecision: query history[-1].content if history else chunks self.retriever(query)[:self.top_k] recent history[-4:] return ContextDecision(moderetrieval, messagesrecent, retrievedchunks)这里我特意只保留最近 4 条对话避免知识库片段和历史消息同时占用大量 token。检索片段加多了会让模型抓不住重点top_k 控制在 3 到 5 之间一般比较合适。3.4 上下文预算怎么规划一个可抄作业的例子整个 context-mode 最容易踩坑的地方是预算分配。我见过有人直接把 128k 窗口全塞满结果响应慢、成本高效果还很一般。我的建议是不要按“模型最大窗口”分配而是按“产品可接受成本”分配。假设我用的是 gpt-4o-mini 这类模型模型窗口很大但为了让单次请求成本可控我给应用设定一个工作窗口 8000 token用途预算system prompt800输出预留1200RAG 知识片段2500历史对话3500这样算下来历史对话只有 3500 token 可用。如果每条消息平均 150 token大约可以放 23 条。实际跑的时候我还会留出 10% 的安全余量所以滑窗阈值设置成最近 15 条左右剩下交给摘要。这样做的好处是每次调用模型之前context-mode 已经用低成本规则算好了“只能给模型看什么”而不是把压力全部甩给模型。4. 实测记录与常见问题排查任何上下文方案上线后都会遇到新问题。我把实际调试中遇到最多的几类问题整理成了一份速查表后面展开讲。4.1 摘要模式为什么会把“人设”记住丢了我最初做摘要时直接用系统提示词让模型“总结这段对话”结果效果很不稳定。有个客服场景用户明明之前说“我比较急希望尽快处理”摘要里却没有体现模型后面回答得像冷冰冰的机器人。原因是摘要提示词只让模型压缩事实没有要求它保留用户状态和沟通风格。后来我改成两段式摘要一段是“事实摘要”记录用户诉求和处理进度一段是“状态摘要”记录用户的情绪、偏好、语气。合并到上下文时把状态摘要放在更靠近最后一条消息的位置模型回答就会自然很多。注意摘要不能只“缩短文本”它本质上是一种信息丢失策略。你必须在摘要里显式声明哪些信息必须保留那些才是产品体验的底线。4.2 滑窗大小调到几才合适滑窗太短用户问一句“我刚才不是说过了吗”模型就接不上滑窗太长上下文预算被普通轮次占满知识片段和系统提示词放不进去。我的经验是先用一个能覆盖“用户在对话中回头确认信息”的最短轮数来测试。对大多数客服场景最近 8 到 15 轮是一个合理的起步区间。上线后可以用并行的方式统计当用户问题里出现“刚才”“之前”“你没听懂吗”等词时模型是否还记得对应信息。如果经常忘记就把窗口调大如果经常回答混乱就检查是不是历史噪声太多。更稳的做法是“滑窗 摘要兜底”最近几轮原样保留更早的内容走摘要。这样既不会让模型完全失忆也不会让上下文窗口被普通消息占满。4.3 RAG 片段和实时对话怎么拼才不会打架在一个文档问答机器人里我遇到一个典型问题用户先聊了几句日常问候上下文里有“今天天气不错”这种内容然后突然问“退货政策是什么”。如果直接把知识库段落塞进 messages模型可能会把“退货政策”和前面闲聊强行关联回答显得怪异。我的解法是把检索片段和对话历史分开并且在每个片段前面加上来源标签让模型知道这是参考资料而不是用户说的话。for chunk in decision.retrieved: messages.append({ role: system, content: f[参考资料] {chunk} })同时我只会对“看起来像在问知识类问题”的最后一轮用户消息触发检索而不是每一轮都检索。这个判断可以用一个很轻的意图分类器或者简单用关键词正则。触发条件宁可保守一点也不要让上下文每轮都被无关段落污染。4.4 三种模式成本对比我自己的测试数据我用一个 20 轮对话模拟了三种模式的效果固定每轮用户输入 300 token、模型输出 200 token比较第 20 轮请求的实际输入量模式第 20 轮输入 token相对成本回答连贯性strict 全部保留6000100%高但会爆窗slide 保留最近 6 轮180030%中可能丢早前信息slide summary120020%较高摘要质量决定上限如果加上 RAG第 20 轮通常是“历史对话 1000 检索片段 900”整体输入大约 1900 token成本不到 strict 的三分之一但回答质量取决于检索命中率。这个表告诉我们一个规律context-mode 本身不会提升模型能力它的价值是把 token 预算花在刀刃上。过分追求“零成本”会让模型变成没有记忆的应答机得不偿失。5. 一些后续想说的经验项目做到后期我越来越觉得 context-mode 更像一套“上下文治理”的理念而不是一个固定功能的库。下面这几点是我实际体验中最有价值的经验。5.1 给不同业务做纵深优化别想一个模式打天下最早我想把上下文管理做成一个通用中间件所有接口共用一套策略。后来发现不同业务差别很大客服场景需要保留用户身份和订单状态内容创作场景需要保留风格和历史段落知识问答场景需要高频检索。现在我的做法是context-mode 提供统一的路由和打包机制但每个业务可以注册自己的 Policy并自定义摘要指令和检索触发条件。这样既保持了代码层面的统一又不牺牲业务效果。5.2 接入可观测性不然线上出了问题全是玄学上下文管理是典型的“看不见摸不着”的模块用户觉得回答不对劲但你很难说是模型问题还是上下文问题。因此我给每次请求都记录了策略标签、token 估算值、丢弃的消息条数、摘要内容以及触发路由的原因。上线后我经常做一件事随机抽一批线上对话回放它们的决策记录。如果某类问题频繁出现我就顺着决策记录看是滑窗切太多、摘要丢失信息还是检索片段选错了。这套可观测性带来的收益比优化算法本身还大。5.3 后续扩展方向我目前在做的一个扩展是“基于用户意图和任务类型的动态预算调整”。比如用户只是问“几点营业”就不需要给 8000 token 的预算但如果是写方案、做分析预算可以放宽。另一个方向是给摘要策略加自动评估用模型打分判断摘要是否保留了关键信息分数低就触发重建。context-mode 从最开始的十几行脚本长成了现在包含策略路由、预算管理、可观测性的完整模块过程中最大的体会是LLM 应用的瓶颈很多时候不在模型本身而在于我们怎么管理给模型看的东西。希望这套思路也能帮到你。