
做Agent开发的时间长了我越来越觉得真正的难点不在模型选型而在上下文管理。一个很常见的画面是对话刚开始模型表现精准聊了二十轮之后开始胡言乱语早期用户给的约束全部失效你问它刚才那个需求还记得吗它真不记得。后来我把一套上下文处理逻辑从业务代码里抽出来做成了独立的小模块名字就叫context-mode。它的核心思路很直接不同任务场景下用不同策略去构建上下文而不是永远拿全部历史一股脑塞给模型。这篇文章把设计过程、核心实现和踩过的坑拆开讲清楚如果你也在做聊天机器人、Agent或者知识库问答应该能少走不少弯路。1. 为什么需要context-mode大模型对话里的记忆危机1.1 上下文本质上是一块有限的token缓冲很多人以为模型有记忆聊过的东西会存下来下次还能想起来。实际上不是这样。你每次发消息给大模型都要把到目前为止的对话历史重新发送一遍模型看着这一整段文本进行一次推理然后返回答案。它没有记住这回事只有这轮输入里有没有包含相关信息。这带来两个直接问题。第一上下文窗口是有限的。拿常见的模型来说几K到几十K token不等。对话一长超出窗口的部分要么被截断要么被挤掉。早期消息里哪怕再有价值模型也看不见。第二注意力会被稀释。即便历史还在窗口里七八千 token 的文本塞进去模型对关键信息的敏感度也会下降。你问它最早提过的一个数字它更有可能被最近的大段工具输出带跑。所以我在项目里最大的体会是上下文不是用来存的是用来选和排的。谁出现在最终 prompt 里、以什么顺序出现、占多少 token这些细节直接决定模型回答的质量。1.2 我在真实项目里遇到的三个典型翻车场景第一个场景是客服类对话。用户很早说过我的会员号是 A12345聊了十轮售后问题之后又问了句那我的会员号还能用吗。模型不知道 A12345因为这段历史早就被后面冗长的物流信息挤掉了。问题看似简单却因为上下文管理不当变成了无法回答。第二个场景是长文档问答。文档本身有几十页模型窗口放不下常规做法是切块做 RAG。但用户的问题往往依赖前文某处定义过的术语。检索出来的片段里只有术语本身没有定义模型只能猜。context-mode 要处理的正是检索片段完整历史用户指令三者怎么拼到一起的问题。第三个场景是 Agent 多步任务。Agent 每执行一步都要把工具返回的长 JSON 塞回对话里。这些工具输出很可能占了绝大多数 token把最初的用户目标和约束挤到几乎不可见。模型忘了自己原本要干什么开始对中间产物进行无关的发挥。这种时候上下文不再只是长度问题还是优先级问题。1.3 现有方案的局限缓存、摘要和截断都只能解决一半问题市面上常见的方案大概有三类固定截断、全局摘要、Prompt缓存。它们各有价值但都不够。固定截断只保留最近 N 条。实现简单但会丢失早期关键信息用户可能前面刚说完手机号后面就被丢掉。全局摘要是拿模型把历史汇总成一段话再塞进上下文。问题在于每次对话更新后如果都重新摘要token 成本很高如果只在超限时摘要又没有解决哪些信息值得保留的选择问题。Prompt 缓存适合固定前缀能省重复计算的成本但它不改变最终能放进窗口的内容有限这个事实。缓存不能帮你决定某条信息该不该进窗口。context-mode 想做的事情是把这些手段放到同一套框架里不追求哪种方法万能而是按场景切换策略。聊天场景用滑动窗口长任务用分层摘要知识问答用 RAG 拼接复杂 Agent 用混合策略。2. context-mode的设计把上下文管理拆成三个可替换的层2.1 核心模型状态、策略、执行分离我在设计 context-mode 时最核心的决策是把上下文拆成三层状态层、策略层、执行层。状态层保存最原始的对话记录不做任何删改。策略层只负责回答一个问题针对当前请求从原始状态里取哪些内容、按什么顺序排列、处理成什么形式。执行层根据策略输出的结果加上 token 预算约束生成最终 prompt。为什么这么拆因为历史和如何看待历史是两件事。同一个书架查资料的人和看小说的人关注的内容完全不同。如果我在存储阶段就把历史处理成固定格式那换一种任务场景就没法用了。实际代码里状态层就是一个 MessageStore里面是完整的消息列表。策略层是多个独立的 Policy 对象每个策略对同一份历史做不同处理。执行层拿着一个已经组装好的上下文计划去计算 token超了就按策略设定的裁剪规则处理。这样业务代码不需要关心每个场景里具体怎么拼上下文只需要告诉 context-mode 当前用哪个模式。2.2 内置的几种模式与适用场景project 里我内置了五个模式。它们不是一开始就齐全的而是在真实项目里逐步加进去的。模式名称适用场景核心策略典型预算倾向full简短对话、单轮问答完整保留所有消息尽量不动历史sliding通用闲聊、客服接待固定窗口保留最近 N 轮控制 prompt 长度summary长任务、多轮复杂对话早期消息压缩为摘要最近消息全量保留节省 token保留细节rag知识库问答、文档问答按 query 检索相关资料拼接到上下文中给检索结果留空间hybridAgent 多步任务、复杂工作流摘要检索关键实体最近消息混合动态按请求分配这里要特别提一下 hybrid。它不是简单地把其他模式拼在一起而是有优先级顺序用户硬性指令和关键实体永远是最高优先级检索到的资料次之摘要再次最近的普通对话最后。否则你会发现模型被检索结果带偏反而忘了用户真正要什么。2.3 对外接口设计context-mode 对业务层暴露的接口很简单核心就一个方法class ContextManager: def __init__(self, tokenizer, max_tokens, reserve_tokens0, policiesNone): self.tokenizer tokenizer self.max_tokens max_tokens self.reserve_tokens reserve_tokens self.policies policies or {} def build(self, mode: str, state: MessageStore, query: str): policy self.policies[mode] budget self._calc_budget() context_plan policy.build(state, query, budget) return self.executor.execute(context_plan)调用方只需要传入当前模式、消息存储和用户这次说的话。返回结果里包含最终 prompt 和一份元信息元信息里记录了什么内容被保留、什么被裁剪、摘要了什么方便后面排查问题。设计成这种样子后加新模式基本是加法写一个 Policy 子类注册一下就行。业务逻辑不用改状态层不用改。后面我实测时改策略也只需在运行环境里切换 mode不用重新部署整个服务。3. 从零实现context-mode核心代码与关键路径3.1 模式切换是怎么做到不丢历史的模式切换最容易犯的错误是切过去之后直接把旧历史丢了。真正应该做的是历史一直在 MessageStore 里切换的只是读取方式。比如同一个用户会话前十分钟在闲聊后五分钟开始问产品文档。状态层里消息一条都不会少。sliding 模式在读历史时取最近 20 条切到 rag 模式后同一个 store 里的消息会被当作索引数据同时把用户 query 拿去检索外部知识库。两者互不影响。我也在状态层加了一个 mode_snapshot 字段记录每次请求是用哪个模式构建的上下文。这样如果后来发现问题可以回放当时的请求看看到底是策略选错还是内容被错误裁剪了。这算是我强烈建议加的一个设计没有它排错会非常痛苦。3.2 摘要压缩的落地细节分层摘要summary 模式是我最早实现的也是踩坑最多的。一开始我图省事历史超长时把所有旧消息一次性交给模型生成一段摘要。后来发现两个问题一是成本波动大每条长对话都可能触发一次昂贵的大摘要二是每次摘要一重写之前摘要里已经确认过的信息会变形。后来我改成增量分层摘要。逻辑是这样旧消息超过一定条数后最先触发压缩的那批消息生成一个摘要块下一批新消息超过阈值时不是重新生成全部摘要而是把已有摘要块和新一批消息一起再生成一个新的高层摘要。这样摘要是一个树状结构越往上层越概括越往底层越接近原始细节。使用时根据预算决定展开到哪一层。核心代码看起来是这样class SummaryPolicy(ModePolicy): def __init__(self, recent_keep10, compact_threshold30, summarizerNone): self.recent_keep recent_keep self.compact_threshold compact_threshold self.summarizer summarizer def build(self, state, query, budget): recent state.messages[-self.recent_keep:] old state.messages[:-self.recent_keep] if old and state.summary is None: state.summary self.summarizer.compress(old) summary_text state.summary or return self._assemble(summary_text, recent, query, budget)注意一点recent_keep和compact_threshold不是拍脑袋定的要看任务对细节的敏感度。我自己的经验是客服类任务最近 10 到 15 轮就够Agent 类任务因为工具输出多反而要更少轮数更多依赖摘要和结构化字段。3.3 token预算计算与安全余量context-mode 里所有策略最终都要回答同一个问题给某个部分分配多少 token。我之前吃过亏把预算压得太满模型输出到一半被截断。后来固定了一套计算逻辑。假设模型最大上下文是max_tokens那最终 prompt 里可用空间大致是可用历史预算 max_tokens - system_prompt - 本次用户输入 - 保留输出空间 - 安全余量安全余量尤其重要。我一开始只预留输出空间结果模型经常因为临时增加的函数调用返回内容而无处安放。现在不管什么模式我都会预留至少 512 token 的余量如果是 Agent 场景余量还会更大因为工具返回是突发性的。token 计算用的是什么我建议直接用模型配套的 tokenizer 做精确计数而不要用字符数/4这种粗略估算。在 context-mode 里每个策略返回的上下文计划都会先经过执行层做 token 核算超预算就按策略预设的优先级裁剪从最不重要的消息开始。3.4 和RAG结合时context-mode需要额外做什么rag 模式的核心问题不是检索而是检索结果怎么放进上下文。很多人直接把检索片段追加到消息最后这是不对的。我的做法是把检索结果放在 system prompt 之后、对话历史之前用reference标签包起来。模型可以先看到当前任务要参考的资料再看到聊天历史最后看到用户的最新问题。这个顺序非常重要。如果你把检索结果放在最后模型很容易把参考资料当成用户新输入的上下文回答时反而被无关片段干扰。另外rag 模式里别把所有历史都丢掉。最新两三轮对话往往是理解当前问题的关键。如果一个用户问那它的价格呢这个它可能指的是历史里出现过的产品而不在检索结果里。所以 rag 模式我是这样组合的检索片段 最近三轮对话 当前问题。少了任何一块回答质量都会明显下降。4. 实测效果与踩坑复盘那些文档不会写的细节4.1 测试场景与对比数据我在本地拉了一组测试数据包含三类任务客服长对话、长文档问答、Agent 多步任务。每个任务各 100 条左右。对比了三种方案固定截断、全局摘要、context-mode。数据样本不大仅供思路参考。方案客服有效解决率文档问答准确率Agent 任务成功率单请求平均 token固定截断61%72%38%2100全局摘要74%81%52%1600context-mode86%89%74%1900固定截断在短任务里还行一旦对话轮数变多早期关键信息就没了。全局摘要能省 token但摘要会丢掉细节Agent 任务里那些用户原本要什么的指令被压扁后成功率很低。context-mode 在三种任务里综合表现最稳原因不是某一种策略强而是不同任务用了不同的策略。4.2 坑一模式切换时旧上下文污染新任务第一个坑出在自动切换模式的时候。我原本设计了一个规则如果检测到对话里出现请问一下文档里的内容这类意图就自动从 sliding 切到 rag。第一次上线后发现模型回答里时不时冒出前面闲聊时的语气词和玩笑内容明显是被旧消息污染了。查下来发现问题是 rag 模式构建上下文时把所有历史消息都当作普通文本放进了缓存区。那些今天天气不错你叫什么之类的内容也被模型读到干扰了文档相关回答。解决方法是给消息状态打标签。每个 Message 对象里带一个scope字段策略只读取自己关心的 scope。比如 rag 模式只读 system 和 reasoning 类型消息sliding 模式才读通用聊天消息。这样切换模式时历史还在但旧上下文不会影响新任务。4.3 坑二摘要是有损的不能无脑压缩summary 模式上线后用户反馈说我说过好几次要用表格回答模型怎么记不住。我把当时的摘要展开一看发现摘要里确实写了用户希望用表格展示可问题是这句约束混在一大段对话概述里模型的注意力被分散了。后来我意识到不是所有内容都适合进摘要。用户明确表达的格式偏好、硬性业务规则、关键数字、关键人名地名这些属于不可压缩字段。压缩时应该原样保留。我在状态层加了一个immutable_items列表专门存放这类硬约束。每次构建上下文时先检查这份列表有没有被摘要压缩掉。如果有就直接以原文形式追加到最前面。实际效果非常明显模型跑偏的概率直线下降。4.4 坑三多轮对话中的隐式上下文依赖还有一种问题比摘要丢失更隐蔽用户说把刚才那家店的地址发给我这句话里没有出现店名但那家店指向的是很早之前聊过的一个具体对象。滑动窗口很容易把它丢掉因为那家店的信息不在最近 N 轮里。简单靠多保留几轮解决不了因为问题可能出现在二十轮之前。我最后采用的办法是在历史索引里维护关键实体表。每当用户消息里出现新的实体就用规则或轻量模型抽取出来放进实体的状态标记里。当最新 query 中出现指代词这个、那个、它时context-mode 会先到实体索引里找最近出现且类型匹配的实体把对应的原始消息作为锚点加入到上下文中。这个机制让我对隐性依赖问题有了新的认识上下文管理不能只做长度控制还得做引用关系追踪。它在某些场景下比摘要重要得多。5. context-mode的扩展方向和使用建议5.1 从单会话到跨会话记忆context-mode 目前处理的是一个会话内部的上下文。再往后走跨会话记忆是绕不开的。比如同一个用户隔三天再来问问题可能提到我之前那个需求模型得知道那个需求是什么。跨会话记忆的正确做法不是把所有历史都塞进每个请求而是把用户长期偏好、已完成任务、常见实体单独存成记忆档案。context-mode 在上面加一层记忆召回策略当前任务需要哪些长期信息就从档案里检索再放回上下文。短期用对话历史长期用记忆档案两者互相补充。我做的一个小优化是给每条长期记忆加上上次命中时间和置信度两个字段。召回时优先选近期命中过、置信度高的记忆。不然很容易出现模型把很久以前一条无关紧要的信息当成重点。5.2 手动模式什么时候比自动更可靠自动模式识别意图听起来很方便但它在高成本、高准确率要求场景下不可靠。比如你做一个面向客户的自动报价 Agent一次误判可能把旧对话里的闲聊内容带进报价上下文后果比较严重。我现在的建议是能用显式传参的地方就不要依赖自动识别。业务方知道当前是什么任务直接告诉 context-mode 用哪个模式最稳妥。自动切换可以作为兜底但切换前要先记录日志并且在同一会话里不要频繁切换至少稳定跑几轮再切。每次切换都要重新评估一次上下文质量而不是切完就不管。5.3 给想做类似工具的朋友几点建议如果你准备自己动手做一套 context-mode 类似的东西我有几条建议。第一先加日志。上线前把所有请求的模式、token 分配、是否触发摘要、裁剪了哪些消息全部落盘。没有这些数据你根本没法判断是哪一层出了问题。第二先做保守策略。不要一上来就追求把上下文压缩到极致。宁可多保留一些 token让模型先稳定工作。验证基本效果没问题后再逐步压缩预算。第三永远保留原始消息。MessageStore 里的数据不能被任何策略直接覆盖。摘要可以存在 summary 字段里但原消息必须一直在。否则策略调优过程中一旦发现摘要丢失信息你将没有地方找回原始数据。第四用离线回放来调策略。找一批真实历史对话固定模型只切换不同策略对比输出质量。这比每次都在线上实验成本低得多也能更快发现策略之间的差异。我现在自己的项目里已经固定了这套流程所有请求先走 context-mode再进模型。即使某个需求很简单我也会强制指定一个模式而不是放任默认逻辑。因为上下文管理这件事越到后面越像数据管线而非prompt 技巧。你把它当成一个正经模块来设计它就会回报你稳定的输出质量。