news 2026/9/11 11:10:18

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大模型上下文管理实战:三种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 engine

3.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[]) => Promise<MessageItem>; retrieveFn?: (query: string, history: MessageItem[]) => Promise<MessageItem[]>; } class ContextEngine { private messages: MessageItem[] = []; private options: ContextEngineOptions; constructor(options: ContextEngineOptions) { this.options = { compressThreshold: 0.7, ...options }; } async addMessage(msg: MessageItem): Promise<void> { this.messages.push(msg); await this.maybeCompress(); } getContext(): MessageItem[] { return this.messages; } private async maybeCompress(): Promise<void> { 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): Promise<void> { 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): Promise<void> { 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.completionsresponses两个接口做统一包装,自动给每次调用打上 session 标签。

以下是简化版的嵌入式埋点思路,用buildWrapper生成一个带 sessionId 的调用函数:

import { randomUUID } from 'crypto'; function buildWrapper(sessionId: string, sessionMeta: Record<string, any>) { const eventName = 'llm_call'; return async function withSession<T>(fn: () => Promise<T>): Promise<T> { 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,使用方式完全对齐原函数,调用方不需要改任何代码。每个包装器创建时带sessionIdsessionMeta,这样同一 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. 跨会话、多 Agent:context-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_tokenscompletion_tokensestimated_cost,按 session 聚合报警。我见过太多团队直到账单出来才发现成本膨胀,那时候优化已经滞后了。好的做法是每轮调用结束都写入成本指标,在日粒度上做统计,一旦输入 token 总量环比上涨超过 20% 就主动排查。

6.4 多轮工具调用后不清理中间结果

Agent 场景里最常见的是连续调用多个工具,每个工具返回的原始 JSON 都留在 context 里。问题在于模型并不需要每个中间结果——它只需要最终结果和少量关键中间值。不清理的话,50 轮工具调用下来,context 里会堆满几百 KB 的原始 JSON,这对后续决策毫无帮助,只会拖慢生成。建议每个工具执行完,立即判断该结果是否需要进入 context,不需要就直接丢弃,只保留最终汇总。

6.5 误以为模型会自己"知道"被删掉的内容

这是最隐蔽的坑。当历史消息被滑出窗口或被摘要替换后,模型并不知道它曾经存在过。用户说"我刚才不是告诉过你吗",模型不会根据这件事的任何痕迹做出正确回应。如果你要支持这种回溯,必须自己维护一个长期记忆存储,并在检索命中时把相关片段主动放回 context。否则,请至少在对话 UI 上清楚提示用户"我只能记住最近 N 轮对话",降低预期。

我在实际项目里最后还会加一个小工具函数,把estimateTokens的估算结果、消息数组长度、最后一次压缩时间输出到一个 health check 接口。这样即使前端没有任何反馈,我也随时能知道某个 session 的 context 是否健康、要不要人工干预。这个小习惯帮我避免了不少线上问题。

好的实践方式各有各的招,但共性的原则永远清晰:控制预算、留痕可查、按需检索、看清成本。把这几条做到位,你的对话应用在长会话里也会稳得住。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 11:09:34

如何编译 BitNet W2A8 CUDA 内核并用 test.py 运行 GEMV 加速测试?

如何编译 BitNet W2A8 CUDA 内核并用 test.py 运行 GEMV 加速测试&#xff1f; 【免费下载链接】BitNet Official inference framework for 1-bit LLMs 项目地址: https://gitcode.com/GitHub_Trending/bitne/BitNet BitNet 仓库的 gpu/ 目录提供了一组针对 W2A8 推理&a…

作者头像 李华
网站建设 2026/9/11 11:05:25

云计算十年进化史:从OpenStack到Serverless的架构变迁与运维变革

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 11:03:22

POA优化BP神经网络的时间序列单步预测与MATLAB实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/11 10:59:41

微信聊天记录导出完整指南:WeChatMsg 用 3 条命令把微信记录留下来

微信聊天记录导出完整指南&#xff1a;WeChatMsg 用 3 条命令把微信记录留下来 【免费下载链接】WeChatMsg 提取微信聊天记录&#xff0c;将其导出成HTML、Word、CSV文档永久保存&#xff0c;对聊天记录进行分析生成年度聊天报告 项目地址: https://gitcode.com/GitHub_Trend…

作者头像 李华