ARTICLE DETAIL

资讯详情

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

大模型上下文管理实战:三种context-mode策略与Token预算优化

大模型上下文管理实战:三种context-mode策略与Token预算优化 你有没有遇到过这种场景一个 AI 对话应用前十分钟还挺聪明聊到半小时之后突然像换了个人。你说过的喜好它忘了前面已经确认过的方案它重新问甚至会在已经决定的事情上反复横跳。大部分人会把它归结为模型不够聪明但实际开发过这类应用的人都明白——问题往往出在 context-mode也就是上下文模式的管理上。严格来说context-mode 不是一个官方标准术语它是我们在做大模型应用时对如何管理上下文这套策略的总称。它决定了模型每一轮能看到的对话范围、记忆的保留方式以及当上下文超出窗口限制时的处理手段。可以把它理解成模型工作记忆的调度器该记住的留着该压缩的压缩该丢弃的果断丢弃。这篇文章会先讲清楚 context-mode 解决的核心矛盾再拆三种常用工作模式然后给一份可以直接跑的 Node.js 实现最后用一次真实线上事故来讲讲 token 预算失守的排查过程。适合正在做对话机器人、Agent 编排、RAG 检索问答的开发者也适合想搞清楚为什么模型越聊越笨的人。1. context-mode 要解决的最核心矛盾1.1 模型是无状态的但它要表现得有状态大模型本身没有记忆。你给它一段 prompt它生成一段回复这轮对话结束之后它不记得任何东西。要实现连续对话唯一的方法是把历史消息重新拼进下一轮的 prompt 里。也就是说记性实际上是开发者在替模型记。这就带来一个非常实际的问题历史消息是无限增长的而模型的上下文窗口是有限的。当前主流的 GPT-4o 和 Claude 系列模型拥有几十万 token 的上下文窗口听着很大但真实对话中消耗极快。一份长文档可能就要几万 token工具调用的返回结果动辄几千 token用户再连续问几个问题用不了几轮我们就得面对要么截断、要么超限的选择。context-mode 解决的就是这个矛盾。它是一套在有限的上下文窗口里如何最大化利用有效信息的策略集合。合理的 context-mode能让你在 8k 的窗口里做出接近 64k 的效果不合理的 context-mode给你 128k 也可能越聊越傻。1.2 三个隐藏的成本钱、延迟和注意力很多人以为上下文只要不超限就没问题实际上上下文长度对指标的侵蚀是渐进的。首先是钱。所有主流大模型 API 都区分输入和输出 token 计费所以你每一轮请求都要把历史消息全部重新发给模型。假设一轮对话带 10k 历史 token聊 100 轮光历史部分就累计消耗了约 1M token 的输入量。对一个日活几百人的应用来说一个月下来是相当可观的成本而且随着用户对话轮数增加这个数字会快速膨胀。其次是延迟。模型处理输入的耗时和输入 token 数基本是线性关系。带 100k 历史上下文时首 token 延迟可能到十几秒用户早就等得不耐烦了。同一套服务上下文瘦身之后响应时间能下降 50% 以上这在真实线上环境里体感非常明显。最后是注意力质量。上下文窗口越大模型越容易淹没在大量历史信息中对关键指令的注意力会被稀释。特别是当历史中包含大量工具返回的 JSON 或者中间推理内容时模型很可能会忽略你在系统提示词里写的关键约束。这个现象在很多评测里被验证过上下文越长模型对早期指令的执行率越低而且呈现出明显的只看尾部倾向。token 不是无限的也不是便宜的模型不是每条上下文都值得注意的。context-mode 的第一性原则就是正视这三个成本。1.3 context-mode 不是什么高级开关有些开发者会把它理解成一个二值开关打开就是有上下文关闭就是没上下文。这个理解非常危险。它应该是一组持续运行的策略集合在每一轮对话前都执行一遍动态判断当前有哪些消息有没有超过预算哪些内容可以被压缩而不影响主任务换句话说context-mode 不是模型的能力不是 SDK 的选项而是应用层需要自己实现的机制。它和业务逻辑的关系极其紧密因为只有业务方知道什么样的错过是可以接受的——客服场景能接受丢掉用户的一些闲聊细节但绝不能在退货地址上有任何偏差写作助手场景则恰恰相反早期的风格偏好信息一旦丢了整篇稿子的调性就全变了。2. 三种主流 context-mode 工作策略2.1 滑动窗口模式最简单直接滑动窗口是最直观的一种 context-mode。做法很简单限定一个最大消息条数或者最大 token 数每次新对话到达时把最老的消息丢弃直到总大小回到预算内。优点是实现简单没有额外开销不会因为摘要压缩而引入事实失真。缺点同样明显被丢掉的旧信息是彻底丢失的。用户早先提到的重要偏好、已确认的决策只要滚出窗口就再也找不回来模型也不会知道自己曾经知道过。滑动窗口适合用于客服闲聊、单轮问答、检索式 RAG 等对长期记忆要求不高的场景。在数据量小、历史依赖弱的时候它是最稳的选择。我之前在一个 FAQ 机器人上用过纯窗口模式用户问一句我答一句不需要跨轮联系三个多月跑下来状态非常稳定。2.2 摘要压缩模式用信息密度换容量摘要压缩会在历史超出预算时将一部分较早的对话交给模型进行总结用一段凝练的摘要替换掉原来的多轮消息从而腾出空间。典型的策略是分层摘要每 N 轮对话生成一个小节摘要当小节摘要积累到一定程度时再生成一个更高层级的摘要。优点是能在保持全局记忆的前提下大幅压缩 token 占用。缺点是摘要过程本身有开销而且摘要必然会丢失细节。比如用户说我下周去上海出差帮我订一间离虹桥高铁站近的酒店被压缩成用户出差需要订酒店之后位置偏好就没了后续推荐很可能就不准。摘要压缩模式适合长周期任务、多轮决策、项目推进类对话——这类场景里记得住大概比记住每个细节更重要。实践中建议把摘要触发时机设在预算的 60%-70%不要等满了再压缩否则突发长消息会直接把窗口顶爆后面所有请求都会被拖慢。2.3 混合路由模式上下文分层混合路由是这三种里面工程上最复杂的效果也最好。它的核心思路是把上下文拆成基础信息、工作记忆、历史记录三层。基础信息系统提示词、角色设定、全局知识每轮都带工作记忆当前任务的最近状态、关键变量每轮更新历史记录早先的完整对话、工具调用存到外部存储只在需要时检索回填。这种模式的本质是把所有东西都塞进窗口改成按需取用。要在对话中实现准确的召回通常需要给历史消息打标签比如按主题、意图、实体抽取标注再在需要时做相似度检索。实现成本比前两者高不少但它同时解决了容量、成本和记忆持久性三个问题。混合路由是 Agent 场景里最实用的方案。一个搜索 Agent 通常需要同时保留用户目标、搜索计划、前面的中间结果和当前待处理的 query单纯的滑动窗口会丢掉计划纯摘要会丢失关键数值只有分层管理才能支撑起复杂任务。为了帮大家快速选型我把三种模式的关键差异整理成了表格模式实现成本记忆保留能力上下文失真风险适合场景滑动窗口极低只保留最近 N 轮无失真但会忘事闲聊、单轮问答、FAQ摘要压缩中保留全局大意丢失细节摘要会引入失真长任务、项目推进混合路由高长期记忆 短期状态取决于检索质量Agent、复杂多步任务3. 实战自己写一个 context engine3.1 明确需求边界在开始编码之前先把需求说清楚。我要实现的东西不需要接入具体模型厂商只负责两件事估算一组消息的 token 占用在超过预算时执行指定的压缩策略。这样它就可以被插入到任何已有的大模型调用链路中无论你用的是 OpenAI SDK、Anthropic SDK 还是自部署模型。我选择用 TypeScript 来示例因为类型清晰对上层业务约束能自动提示而且 Node.js 生态对大模型应用的支持比较完善。如果你更熟悉 Python思路可以直接平移。基础的结构定义如下interface MessageItem { role: system | user | assistant | tool; content: string; name?: string; createdAt?: number; }这里createdAt字段是我额外加的是为了支持后面按时间裁剪的策略。实际上绝大多数 SDK 的消息结构不含时间字段如果你想自己掌控裁剪顺序最好在业务层给它补充上。然后是 token 估算。最容易上手的方案是字符数除以 4中文和英文混合场景下误差不算太大用作预算判断足够function estimateTokens(messages: MessageItem[]): number { let total 0; for (const msg of messages) { total Math.ceil(msg.content.length / 4); if (msg.name) total 2; } return total; }这个估算函数会把代码和 JSON 分隔符的实际 token 数低估一些但对预算控制来说它给了我们一个稳定的相对值。够用了。3.2 核心引擎实现接下来实现 ContextEngine 类它支持三种策略truncate滑动窗口、summarize摘要压缩、route混合路由。interface ContextEngineOptions { maxTokens: number; strategy: truncate | summarize | route; compressThreshold: number; // 0-1达到预算的该比例时触发 summarizeFn?: (messages: MessageItem[]) PromiseMessageItem; retrieveFn?: (query: string, history: MessageItem[]) PromiseMessageItem[]; } class ContextEngine { private messages: MessageItem[] []; private options: ContextEngineOptions; constructor(options: ContextEngineOptions) { this.options { compressThreshold: 0.7, ...options }; } async addMessage(msg: MessageItem): Promisevoid { this.messages.push(msg); await this.maybeCompress(); } getContext(): MessageItem[] { return this.messages; } private async maybeCompress(): Promisevoid { const { maxTokens, compressThreshold, strategy } this.options; if (estimateTokens(this.messages) maxTokens * compressThreshold) { return; } switch (strategy) { case truncate: this.truncate(maxTokens); break; case summarize: await this.summarize(maxTokens); break; case route: await this.route(maxTokens); break; } } private truncate(maxTokens: number): void { while (estimateTokens(this.messages) maxTokens this.messages.length 1) { // 保留系统消息丢最老的非系统消息 const idx this.messages.findIndex(m m.role ! system); if (idx -1) this.messages.splice(idx, 1); else break; } } private async summarize(maxTokens: number): Promisevoid { const { summarizeFn } this.options; if (!summarizeFn) return this.truncate(maxTokens); // 把当前消息中除了 system 外的部分交给 summarizeFn生成摘要消息 const nonSystem this.messages.filter(m m.role ! system); const summary await summarizeFn(nonSystem); this.messages [ ...this.messages.filter(m m.role system), { role: assistant, content: [摘要] summary.content, createdAt: Date.now() } ]; } private async route(maxTokens: number): Promisevoid { const { retrieveFn } this.options; if (!retrieveFn) return this.truncate(maxTokens); const query this.messages[this.messages.length - 1]?.content ?? ; const retrieved await retrieveFn(query, this.messages); this.messages [ ...this.messages.filter(m m.role system), ...retrieved, ...this.messages.slice(-2) // 只保留最近两轮 ]; } }这段代码有几个关键设计点。maybeCompress的触发条件不是等预算满了才执行而是到了compressThreshold就执行留出缓冲。truncate保留了系统消息因为它通常承载角色设定和最核心的指令暴力删除会导致整个对话风格偏移。summarize把摘要消息标记为assistant角色并在内容前加[摘要]前缀方便后续分析时从日志里一眼看出摘要发生的位置。3.3 把它接进你的调用链路使用方式非常简单const engine new ContextEngine({ maxTokens: 6000, strategy: summarize, summarizeFn: async (msgs) { // 调用大模型生成摘要 const resp await openai.chat.completions.create({ model: gpt-4o-mini, messages: [ { role: system, content: 对以下对话做简洁总结保留关键事实和数据 }, ...msgs ] }); return { role: assistant, content: resp.choices[0].message.content ?? }; } }); // 正常接入对话循环 engine.addMessage({ role: user, content: 你好帮我查下杭州的天气 }); const context engine.getContext();我建议在正式接入前先用一个脚本模拟 50 轮随机对话打印每轮前后的消息数量、token 估算值和摘要触发次数确认压缩策略的行为符合预期。这一步能帮你省下不少线上 debug 的时间。4. 从锯齿形延迟到 context 状态泄漏一次线上事故复盘4.1 事故先从埋点说起要排查上下文相关的问题首先你得有能力看到每轮请求的 token 消耗。没有可观测性一切排查都是凭空猜测。我们当时的方案是在 OpenAI SDK 调用外层包一层埋点封装对最常见的chat.completions和responses两个接口做统一包装自动给每次调用打上 session 标签。以下是简化版的嵌入式埋点思路用buildWrapper生成一个带 sessionId 的调用函数import { randomUUID } from crypto; function buildWrapper(sessionId: string, sessionMeta: Recordstring, any) { const eventName llm_call; return async function withSessionT(fn: () PromiseT): PromiseT { const start performance.now(); try { const result await fn(); const durationMs performance.now() - start; emit(eventName, durationMs, null, { sessionId, sessionMeta }); return result; } catch (err) { const durationMs performance.now() - start; emit(eventName, durationMs, err, { sessionId, sessionMeta }); throw err; } }; }buildWrapper返回一个新的 async function使用方式完全对齐原函数调用方不需要改任何代码。每个包装器创建时带sessionId和sessionMeta这样同一 session 内部的多次调用都会自动打上标签并在日志系统中聚合。实际用法const sessionId randomUUID(); const withSession buildWrapper(sessionId, { operation: chat.completions, model: gpt-4o-mini }); const resp await withSession(() openai.chat.completions.create({ ... }) );如果你用的是 Node.js 自带的 fetch还可以把上报逻辑放在 finally 块里统计耗时避免侵入原有调用。注意不要把埋点逻辑放在业务 catch 里否则业务异常会绕过统计。4.2 故障现象与排查链路某天下午运营反馈内部测试机器人聊着聊着就卡住了而且回复开始失忆。我第一步查的是调用日志但没有立刻看错误而是看延迟曲线。结果发现一个很反常的现象延迟不是逐步上升而是呈锯齿形波动。每几次调用延迟很低突然一次非常高随后又回落。这个锯齿形立刻让我怀疑两件事要么网络有偶发抖动要么每次高延迟前触发了什么额外处理。我随后打开 token 使用日志把时间对齐后确认高延迟的那次调用恰好是 prompt_tokens 出现尖峰的那次。也就是说周期性有某次请求带着超大 prompt 发出去了。顺着尖峰继续追问题出在我设置的摘要压缩上。当时的设计是消息超过 6000 token 时做一次摘要把前 20 条历史替换成摘要文本。表面看逻辑没错但我在过滤时漏了一个分支当历史不足触发阈值时旧的原始消息还留在数组里。于是周期性的某条超长工具返回注入后prompt 瞬间膨胀到预算的 2 倍被迫触发一次大范围摘要与重写。这个重写过程本身又调了一次模型让下一次请求的延迟雪上加霜。修复其实只花了几行代码触发摘要后立即把被替换的原始消息从数组中移除并补上摘要生成的日期标记同时在压缩完成后将本次压缩的 token 变化量写入日志。从那之后锯齿形延迟消失响应时间稳定在原来的 60% 左右。4.3 从这次事故中提炼出的三条硬规则第一context-mode 的每一步都必须留痕。压缩了什么消息、删了什么内容、省了多少 token必须能通过日志回溯。否则你根本没法判断一个新引入的策略是优化还是埋雷。第二预算永远要留活口。不要把窗口的上限用满。我后来把压缩阈值定在 token 预算的 70%超过就处理而不是等满了再说。这样即便出现单条超大消息系统也有缓冲余地。第三先修数据再修逻辑。事故中真正的问题不是压缩条件不科学而是被替换的消息没有从源数组中移除。很多时候这类上下文失效的 bug 不是算法设计问题而是状态同步问题排查时优先检查数据流的删除分支。5. 跨会话、多 Agentcontext-mode 的进阶打法5.1 分层记忆短期、工作、长期现实中的 agent 应用很少只跑一轮对话它通常要连续跑几十分钟、跨多个工具调用。只靠一个 context engine 做压缩远远不够还需要在架构层面引入记忆分层。短期记忆对应当前上下文窗口存的是最近几轮对话工作记忆对应任务中间态比如当前搜索词是什么用户要订哪个航班长期记忆则放在向量库或 KV 存储里按实体、主题、意图建立索引。每次新 token 进入时系统先在短期记忆里尝试匹配如果短期没有相关上下文再检索长期记忆回填。长期记忆对短期记忆的降级迁移是处理超长会话最常用的手法。我在具体实现中倾向于用一条记忆管道来跑这个流程新消息写入短期 → 短期超过阈值触发摘要 → 摘要及相关实体写入长期 → 长期定期做向量化。每个环节都有独立队列和失败重试避免阻塞主对话链路。5.2 context-mode 与多 Agent 编排多 Agent 场景里context-mode 的粒度需要再细化。不同 Agent 之间不仅共享对话历史还要交换任务状态。如果不做隔离A Agent 的中间输出可能污染 B Agent 的系统提示词B Agent 会拿 A Agent 的临时结果当决策依据最后整个任务链跑偏。我的做法是给每个子 Agent 分配独立的 context 命名空间父 Agent 只保留子 Agent 的最终结论和关键事实中间过程一律不回流。这相当于在系统层面也实现了摘要压缩——父 Agent 的上下文只存结果不存过程子 Agent 的完整流程留在自己的日志里需要时再查。这么做带来的收益十分直观父 Agent 的上下文窗口占用能控制在子 Agent 的 1/10 以下而多轮编排的准确率反而提升了因为父 Agent 不再被大量中间噪音干扰。5.3 未来上下文管理的智能化context-mode 这个概念在未来一定会继续演进。当前基于规则的窗口摘要只是初代方案更进一步的方向是让模型自己决定该保留什么。比如在每一轮对话结束后让一个小模型把当前上下文里的核心实体、未完成任务、优先级信息提取出来替代纯文本摘要。这种语义压缩在长对话稳定性上比单纯摘要生成更可靠因为它保留了可结构化的记忆。顺便说一个常见的误解随着模型上下文窗口的不断增大把窗口做大就不需要 context-mode这个说法流传很广但它是错的。窗口越大成本增长越线性注意力被稀释的问题也依然存在。实际上更大的窗口只是让你有更多空间去浪费并不意味着里面的信息都被有效利用了。我们在长上下文模型上实测过带 50k 不相关的历史文本时模型对路径规划的遵循率明显下降即便上下文总量完全没超限。6. 落地 context-mode 时最容易踩的五个坑6.1 压缩操作在超限之后才执行很多人写代码时习惯先判断if (tokens limit)再压缩这个顺序在真实场景里会被打脸。因为 LLM 调用返回的消息长度不可预测一条工具回包就能从 5k 飙到 15k。如果你等超限再压缩上一次请求已经在超长 context 上跑了一遍费用和延迟都已经付出去了。正确的做法是在接近阈值时就触发压缩给突发留出缓冲。6.2 把太老和太近的消息一视同仁有些实现里压缩时从最老的消息开始删直到 token 满足要求。这在大概率正确的同时漏了一个细节如果最近几轮消息里有一条是用户刚发来的带附件内容它可能本身就是最近的核心意图而同样被保留的某条系统消息反而是过时的。按时间一刀切删除太粗暴。更好的做法是按角色、类型区分优先级system和最近一条user消息不删优先压缩工具调用历史其次压缩中间的assistant推理消息。6.3 不监控 token 成本可以在日志里加一行prompt_tokens、completion_tokens、estimated_cost按 session 聚合报警。我见过太多团队直到账单出来才发现成本膨胀那时候优化已经滞后了。好的做法是每轮调用结束都写入成本指标在日粒度上做统计一旦输入 token 总量环比上涨超过 20% 就主动排查。6.4 多轮工具调用后不清理中间结果Agent 场景里最常见的是连续调用多个工具每个工具返回的原始 JSON 都留在 context 里。问题在于模型并不需要每个中间结果——它只需要最终结果和少量关键中间值。不清理的话50 轮工具调用下来context 里会堆满几百 KB 的原始 JSON这对后续决策毫无帮助只会拖慢生成。建议每个工具执行完立即判断该结果是否需要进入 context不需要就直接丢弃只保留最终汇总。6.5 误以为模型会自己知道被删掉的内容这是最隐蔽的坑。当历史消息被滑出窗口或被摘要替换后模型并不知道它曾经存在过。用户说我刚才不是告诉过你吗模型不会根据这件事的任何痕迹做出正确回应。如果你要支持这种回溯必须自己维护一个长期记忆存储并在检索命中时把相关片段主动放回 context。否则请至少在对话 UI 上清楚提示用户我只能记住最近 N 轮对话降低预期。我在实际项目里最后还会加一个小工具函数把estimateTokens的估算结果、消息数组长度、最后一次压缩时间输出到一个 health check 接口。这样即使前端没有任何反馈我也随时能知道某个 session 的 context 是否健康、要不要人工干预。这个小习惯帮我避免了不少线上问题。好的实践方式各有各的招但共性的原则永远清晰控制预算、留痕可查、按需检索、看清成本。把这几条做到位你的对话应用在长会话里也会稳得住。
返回列表