资讯动态

context-mode:大模型上下文管理与Token预算实战

发布时间:2026/10/7 6:20:33 来源:尧图企业网站定制
做 LLM 应用开发的朋友尤其是搞 Agent、搞对话机器人的一定被上下文这东西折磨过。实测下来同样的模型、同样的提示词上下文组织方式不同效果天差地别——有人花小钱办大事有人烧着高价 token 还让模型装失忆。我在项目里沉淀了一套上下文管理模式没有任何开源库纯靠工程手段把不同场景下的上下文策略拆开、做实这套东西我管它叫 context-mode。这篇文章不是来讲概念而是把我实际踩坑、调优、重构的过程完整复盘一遍。你正在做 AI 产品、Agent、RAG 或任何需要跟大模型上下文窗口搏斗的功能这篇应该能帮你少走很多弯路。我会把模式设计、token 预算计算、压缩策略、代码骨架、常见翻车点全部拆开讲。1. 先想清楚context-mode 到底要解决什么问题1.1 上下文不是把历史消息拼在一起这么简单很多新手第一次接入大模型 API会觉得上下文管理很简单把用户的历史消息全塞进 messages 数组然后传给模型完事。我一开始也这么干直到线上问答开始精分——用户明明十分钟前说过自己是小学老师转个话题再回来模型完全不记得用户上传了一份 2 万字的合同问到第 30 轮模型开始胡编条款因为上下文里塞的东西太多模型已经不知道哪个才是当前的重点。后来我想明白一个类比大模型就像一个只有一张工作台的工匠。你把历史消息全摊在桌上新活上来的时候他根本分不清哪些是之前的废料、哪些是当前要用的图纸、哪些是自己刚写下的备注。桌子就这么大堆得越满他找东西越费劲出错概率自然飙升。这就是 context-mode 要解决的核心矛盾上下文窗口是稀缺资源但不同任务对上下文的组织方式需求完全不同。你不能让一个闲聊场景和一个长文档分析场景共用同一套把所有东西堆桌上的逻辑。1.2 三种典型场景需要的上下文模式完全不同我梳理了自己项目里高频出现的三类场景它们对上下文的形态要求是互相冲突的。第一类是客服式短对话。用户问一句你答一句话题随时跳转上一个问题跟下一个问题可能毫无关系。这种场景要求上下文健忘——如果硬把半小时前的旧话题塞给模型反而会产生误导模型会把两个不相干的话题强行关联。正确的做法是只保留最近三四轮的消息其余直接丢掉甚至干脆只带当前这轮加一个稳定的系统提示词。第二类是长文档分析。比如让 AI 读一份几百页的行业报告然后反复提问。这种场景的上下文重点不是聊了啥而是文档里的事实。你不能把整个文档每轮都塞进去也不能只用摘要代替——摘要会丢细节。正确的做法是把文档切片索引每轮提问时检索相关片段动态拼进上下文。第三类是复杂多步任务比如让 Agent 做市场调研先查资料、再列提纲、然后写初稿、自我复盘、再修改。这种场景需要的是工作记忆——模型必须记得整体目标、已经完成了哪些步骤、当前做到哪一步、有哪些约束条件。对话内容反而不那么重要重要的是结构化的进度信息。结论很明确一种上下文格式打天下就是翻车的根源。context-mode 的核心思想就是为这三类场景分别定制上下文组织策略并按实际会话特征自动切换。2. 核心设计context-mode 的模块划分与关键参数2.1 模式定义与自动切换逻辑我在代码里定义了三档模式名字朴素但够用模式适用场景上下文形态关键行为ephemeral轻量模式闲聊、一次性问答仅最近 N 轮每轮裁掉旧消息控制 token 成本最低saturate饱和模式长文档分析、多轮深度对话检索片段 最近对话从外部知识库拉取最相关的段落动态拼装persistent持久模式Agent 多步任务结构化进度 关键决策 最近动作保存任务目标、步骤状态、已确认的约束条件切换逻辑不能靠拍脑袋要用数据判断。我用的是一种复合触发器def decide_mode(conversation): # 估算当前消息累计 token current_tokens estimate_tokens(conversation.messages) # 平均会话长度短且话题跳转频繁 - ephemeral if conversation.turn_count 6: return Mode.EPHEMERAL # 存在系统注入的知识库标记或最近消息包含检索引用 - saturate if conversation.has_retrieved_context: return Mode.SATURATE # 消息累计 token 超过目标预算的 60%且带有任务目标字段 - persistent if conversation.task_goal and current_tokens 0.6 * budget: return Mode.PERSISTENT return Mode.EPHEMERAL触发判断每轮对话开始时跑一次成本很低实测在 20 万轮对话里判断准确率足够误切的情况大多是用户突然从闲聊切到让我写个方案这种场景切换靠模型判断也不可靠只能靠产品侧给会话打标签。所以我在设计上留了一个口子业务方可以在创建会话时直接指定初始模式context-mode 只在未指定的情况下自动推导。2.2 上下文压缩策略滑动窗口、摘要与结构化提取模式解决了该用什么形态的问题压缩策略解决的是形态内部如何瘦身的问题。我实践下来有三招最实用。滑动窗口最简单粗暴只保留最近 K 轮消息更早的全部丢弃。适合 ephemeral 模式。很多人担心丢消息会影响体验但实测在闲聊场景下用户连续聊 20 轮后早先的话题早就翻篇了模型根本不需要知道用户 15 分钟前问过天气。滑动窗口的关键是窗口大小怎么定。我做过消融测试4 轮是最优值——保留太少2 轮会让模型遗漏上一轮我推荐的方案保留太多8 轮会增加 token 开销但回答质量没有明显提升。摘要压缩每累计 M 轮消息就把这 M 轮用模型浓缩成一段 200 字左右的摘要然后只保留摘要 最近几轮详细消息。适合 saturate 模式里对话比较连续的场景。要点是触发讲的不是轮数是 token 阈值。我见过有人用满 10 轮就压缩结果 10 轮里全是超长代码贴文上下文瞬间爆掉也有人用满 50 轮压缩但每轮都是对、好的这种短消息根本没必要等那么久。所以我的触发条件是最近一轮结束后历史消息 token 数超过总可用预算的 70%并且距离上次摘要已经至少过去了 5 轮。结构化提取把会话里的关键信息抽成 JSON 字段比如用户偏好、任务目标、已完成步骤、当前约束、未解决问题。这些字段每轮注入再加上最近两轮的详细消息。适合 persistent 模式。这个策略最麻烦但收益也最大——模型不需要从冗长对话里回忆用户说过什么都做过什么直接看 JSON 就一目了然。2.3 关键参数怎么算一个能直接抄的 token 预算公式很多人在上下文窗口上翻车根本原因是不会算账。模型给的上下文窗口是 8K、32K、128K但不是你全部都能拿来装对话历史。我的预算公式是这样的可用上下文预算 模型上下文窗口 - 系统提示词固定开销 - 输出预留 - 安全缓冲示例算一笔账用常见的 8K 上下文模型模型上下文窗口8192 tokens系统提示词约 450 tokens包含角色设定、工具说明、输出格式输出预留1024 tokens给模型留足生成回答的空间不然答一半截断安全缓冲512 tokens防止因 token 计数误差导致请求 400算下来可用于对话历史的预算大约是 8192 - 450 - 1024 - 512 6206 tokens。别小看这 512 的安全缓冲——tiktoken 算出来的是相对可靠但不同模型对同一个 UTF-8 字符的 tokenize 方式有细微差异尤其遇到少量生僻字或 emoji 时误差可能超过 100 tokens。没有缓冲你的请求会在临界点反复横跳一会成功一会报 context length exceeded。我的摘要触发阈值就是基于这个数字定的当历史消息超过 6206 × 0.7 4344 tokens 时启动摘要压缩。如果是 persistent 模式结构化提取里的字段总数不能超过 6206 × 0.6 3724 tokens剩余空间留给最近两轮的详细消息。3. 实操过程从零实现一个 context-mode 模块3.1 数据结构设计用 dataclass 把模式、消息、状态串起来先说思路。context-mode 的核心是一个 ContextManager它负责三件事维护原始消息列表、判断当前模式、按模式生成给模型看的 messages 数组。我全程用的 Pythondataclass 足够干净没必要上重框架。from dataclasses import dataclass, field from enum import Enum from typing import List, Dict, Optional class Mode(Enum): EPHEMERAL ephemeral SATURATE saturate PERSISTENT persistent dataclass class Message: role: str # system | user | assistant content: str token_count: int 0 # 可选扩展字段用于标记消息来源、检索引用等 meta: Dict field(default_factorydict) dataclass class ConversationSession: session_id: str messages: List[Message] field(default_factorylist) mode: Optional[Mode] None task_goal: Optional[str] None # persistent 模式使用 task_progress: Dict field(default_factorydict) # 任务步骤状态 key_facts: Dict field(default_factorydict) # 结构化提取的关键信息 last_summary_at: int 0 # 最近一次摘要时的消息条数 summary_text: str # 早期消息的摘要缓存 retrieved_chunks: List[str] field(default_factorylist) # saturate 模式使用注意token_count这个字段每次写入消息时就算好并保存。很多初版实现不存 token 数每次算预算时现算全量消息高频场景会引入毫秒级延迟虽然不大但在高并发下会被放大。存字段是个好习惯代价只是内存多一点点收益是估算函数可以直接复用。3.2 核心逻辑实现模式判断与压缩流程ContextManager 的核心方法只有两个add_message和build_request_messages。前者负责接收新消息并更新会话状态后者负责在每次请求发出前按模式把原始消息转换为模型要的 messages。class ContextManager: def __init__(self, session: ConversationSession, model_context_window: int): self.session session self.system_prompt 你是…… # 实际项目中从配置读取 self.model_context_window model_context_window self.output_reserve 1024 self.safety_buffer 256 def add_message(self, role: str, content: str): msg Message(rolerole, contentcontent) msg.token_count estimate_tokens(content) self.session.messages.append(msg) self._maybe_compress() def build_request_messages(self) - List[Dict]: # 每次调用前都重新判断模式模式可能随会话状态变化 mode decide_mode(self.session) self.session.mode mode if mode Mode.EPHEMERAL: return self._build_ephemeral() elif mode Mode.SATURATE: return self._build_saturate() else: return self._build_persistent()_maybe_compress是自动压缩入口。具体逻辑分两条路如果是 persistent 模式每轮结束都尝试更新key_facts如果是 saturate 模式触发摘要压缩。压缩本身也是调用模型的过程所以要设置一个防抖逻辑——禁止在同一轮对话里触发两次压缩否则用户说一句你压缩一次性能和成本都扛不住。3.3 三种模式的具体构建逻辑ephemeral 模式的实现最直接。取最近 4 轮有效消息加上系统提示词即可。不要取最近 N 轮消息我踩过坑第 5 轮起加入消息内容但模型回着回着开始重复你刚才说的 X 方案我没有听懂因为第 4 轮之前的信息全没了。所以我在 ephemeral 模式的实现里加了点保护——虽然只保留 4 轮但保留的那 4 轮每轮内容是完整的不像有些实现会截断每条消息的前半部分。截断长消息表面省钱实际会让模型产生幻觉它不知道缺了什么会很自然地猜一个答案。saturate 模式的要点是检索 拼装。每一次 build 时拿用户最近一条 query 去知识库检索取 top-3 相关片段把片段拼接成一条合成消息紧跟在系统提示词之后。然后保留最近 6 轮对话历史放在片段之后。这个顺序有讲究模型对越靠后的内容注意力越集中尤其是位置编码有衰减检索片段必须在系统提示词附近这样它才会当作权威参考资料来用如果放在对话末尾容易被误认为用户刚输入的一段废话。persistent 模式的构建集合了摘要和结构化提取。每轮把用户消息和助手消息追加到详细消息列表同时用一个子模型去更新task_progress和key_facts。构建请求时消息顺序是system 提示词说明任务目标user 消息一段 JSON 序列化的key_facts包含当前任务状态与关键决策user 消息检索片段或摘要如果有最近 2 轮详细对话这种结构在前、细节在后的安排模型不需要读全量历史就能精确知道我在哪一步、还差什么、有哪些不能改。3.4 对接 LLM API 时最容易翻车的三个坑第一个坑role 连续性问题。大多数 API 要求 user 和 assistant 交替出现不允许连续两个 assistant 消息。context-mode 在拼装检索片段、结构化 JSON 时如果全塞成 user 消息而之前正好以 assistant 结束就会出现连续两个 user反过来也可能出现连续两个 assistant。解决方式很简单写一个normalize_roles方法把连续同角色的消息合并成一条或者中间插一条空的 assistant 消息。第二个坑token 计数不统一。你在后端用 tiktoken 算的 token 数和模型实际计费的 token 数可能有偏差尤其针对代码块、表格、JSON 里的转义字符。不要依赖len(str)或按字符数估严格按 tiktoken 的 cl100k_base 编码器来算。如果某个请求因为超限被拒我建议直接把超限部分的对话直接降级到摘要而不是盲目删最早的几条否则上下文的连续性断得很突兀。第三个坑构造 messages 时 system 只放一次。有些框架会在每次请求时动态把最新的任务进度当作 system 内容注入这会导致 messages 列表里有多个 system 消息。多数 API 允许但部分开源模型底层会把多个 system 拼接效果不稳定。我的做法是维护一个system_prompt字段把静态的角色设定和动态的关键信息分开。静态系统提示词只放一次在最前面动态关键信息放在紧随其后的 user 消息里。实测这样比多 system 消息稳定得多。3.5 一个可直接落地的最小实现示例不搞理论了直接给代码。这段代码实现了 ephemeral 和 persistent 两档模式的自动切换能直接跑通大部分对话场景。class ContextManager: def __init__(self, session_id: str, model_window: int 8192): self.session ConversationSession(session_idsession_id) self.model_window model_window self.system_prompt 你是AI助手 self.summary_trigger_ratio 0.7 def add_message(self, role: str, content: str) - None: msg Message(rolerole, contentcontent, token_countestimate_tokens(content)) self.session.messages.append(msg) self._maybe_compress() def _available_budget(self) - int: system_cost estimate_tokens(self.system_prompt) return self.model_window - system_cost - 1024 - 256 def _maybe_compress(self): history_tokens sum(m.token_count for m in self.session.messages) budget self._available_budget() if history_tokens budget * self.summary_trigger_ratio: self._summarize_older_messages() def _summarize_older_messages(self): # 仅保留最近6条详细消息其余消息合并成摘要 keep_count 6 older self.session.messages[:-keep_count] recent self.session.messages[-keep_count:] if len(older) 3: return summary self._call_llm_to_summarize(older) self.session.summary_text summary self.session.messages recent self.session.last_summary_at len(recent) def build_request_messages(self): mode decide_mode(self.session) if mode Mode.PERSISTENT: return [{role: system, content: self.system_prompt}] [ {role: user, content: json.dumps(self.session.key_facts, ensure_asciiFalse, indent2)}, ] [ {role: m.role, content: m.content} for m in self.session.messages[-2:] ] # 默认 ephemeral recent self.session.messages[-4:] if self.session.summary_text: return [{role: system, content: self.system_prompt}] [ {role: user, content: f之前的对话摘要{self.session.summary_text}} ] [{role: m.role, content: m.content} for m in recent] return [{role: system, content: self.system_prompt}] [ {role: m.role, content: m.content} for m in recent ] def _call_llm_to_summarize(self, messages): # 实际项目里替换成真实调用这里给出骨架 prompt f请将以下对话压缩成不超过200字的摘要保留关键事实、决策和结论。\n{messages} return 摘要结果个人体会是这个骨架基本能满足中小流量产品的需求。真要上生产还需要补充并发锁、Redis 持久化会话、异步压缩队列——压缩动作如果同步执行一次压缩平均多花 300ms 以上的响应时间在体验上很致命。4. 常见问题与排查技巧实录4.1 上下文串号多个用户共用一个聊天机器人A 说的话跑到 B 那里这个问题的根子在于 ContextManager 实例是单例还是按会话隔离。新手最容易犯的错是把所有用户的会话存在同一个全局字典里key 用了user_id而不是session_id结果同一个用户重新开个会话时旧会话没有清理新会话的上下文里混着上一段对话的内容。排查技巧是在add_message入口处打日志记录 session_id 和消息前 20 个字出了问题能快速找出是不是串号。我在生产环境里还会给 session 加一个reset_at时间戳超过 30 分钟不活跃的 persistent 会话自动重建避免长期会话累积大量冗余数据。4.2 摘要压缩坏了事模型明明读过全文压缩完却说资料里没有提到摘要压缩丢信息是个几乎必然发生的事任何摘要算法都会丢细节。真正要命的是把不该丢的丢了——比如合同的违约金条款、用户的硬性偏好、之前已经否决过的方案。我的解法是分层摘要旧消息不再只生成一层摘要而是保留两份一份是整体脉络摘要200字以内一份是关键事实清单结构化 JSON包含用户偏好、决策、约束。两份都塞进去token 开销比单层摘要多不到 30%但 recall 提升明显。还有一个更稳的做法摘要压缩只对与当前任务无关的寒暄和过程性内容生效凡是涉及决策、承诺、数字的内容原样保留。实现上我会给每条 Message 加一个importance字段在写入时用简单规则包含数字、包含决定/同意/不要等关键词标记高重要度。4.3 模式切换时机不准一会带摘要一会带 JSON模型被搞懵问题出在decide_mode只考虑了会话状态没考虑用户当前输入的意图。我遇到过一个 persistent 模式的 Agent 在做数据分析用户突然问了一个与任务完全无关的问题现在几点了ContextManager 依然把任务进度 JSON 塞进去模型在回答无关问题时还要被迫兼顾任务上下文结果答非所问。排查下来我加了一条规则每个模式都生成候选 messages但最终选择交由优先级判断——如果用户当前输入明显是闲聊性质短句、无任务关键词、无检索触发直接降级到 ephemeral不再注入任何结构化状态。这个判断不用模型一组简单的正则就能覆盖大部分情况。4.4 token 估算误差导致请求 400明明没超限API 却说超了这个问题最隐蔽。我用 tiktoken 估算的 token 数和模型实际计数的差异在长对话里越积累越大最后差出三四百个 token 是常态。排查方法很简单把系统提示词、每轮消息的 token 之和打印出来跟 API 返回的错误信息里的prompt_tokens对比。如果发现系统性偏差我的处理是直接从model_context_window里再扣掉 10% 作为浮动误差宁可让可用预算少一点也不要让请求在高峰期失败。还有个细节tiktoken 对空字符串、极端连续的换行符的编码在不同版本里效果不一致建议在项目里固定 tiktoken 版本否则升级依赖可能突然改变估算结果。4.5 一个调试上下文的好工具token 使用曲线我每次调 context-mode 都会在日志里记录三组数据原始累计 tokens、压缩后实际发送 tokens、模型返回的 usage。把它们画成曲线能很直观地看到压缩点、模式切换点、以及每次请求是否在预算红线边缘徘徊。时间点原始累计 tokens压缩后 tokens触发动作10:0143004300无10:0548002100摘要压缩10:1229001800模式切换至 saturate10:1862002400结构化提取注入这张表是排查模型失忆的第一手证据。如果某轮回答突然变差回头看看这轮之前是不是刚触发过摘要——大概率是摘要把关键信息吞了。如果 token 曲线长期贴着红线跑说明会话周期太长应该产品层面提示用户开启新会话而不是靠压缩硬扛。做 context-mode 这段时间我最大的体会是别把它设计得太复杂。很多团队一上来就搞分层记忆多次压缩元认知控制器效果没见好系统倒是先崩了。我自己的实践是从滑动窗口起步等线上数据说明确实需要摘要再逐步加 persistent 模式。模式是服务业务的不是用来炫技的。最后分享一个压箱底的小技巧每次调用模型前在日志里把build_request_messages的完整输出打出来一次几百字不心疼但你能亲眼看到模型看到的到底是什么。有了这个抓手上下文问题就不再是不可解释的黑盒了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑