先说明一下,这篇文章里的“context-mode”不是某个特定开源仓库的名字,而是我在实际项目里因为反复折腾上下文管理,最后沉淀下来的一套设计思路和配套工具。你可以把它理解成一套“对话上下文管理模式”:解决的是大模型应用里最常见、也最容易翻车的问题——怎么让模型在长对话里既记住该记住的,又不被一堆废话撑爆上下文。
我会用一套虚构但完全可落地的库接口来演示核心代码。这套接口我自己在本地跑过,逻辑完全真实,如果你正在做一个带长期记忆的AI助手、知识库问答机器人,或者任何需要长时间保持对话状态的产品,这份经验都应该能直接帮上忙。
1. 上下文不是无限缓存:先搞懂“context-mode”到底在解决什么问题
先说一个反直觉的结论:大部分AI应用做不好,不是模型不够强,而是上下文喂得太“脏”。很多人以为把历史消息一股脑塞给大模型就叫“有记忆”,结果对话超过十轮之后,模型开始答非所问,或者是把早期用户随口说的一句玩笑话当成了硬性指令。这不是模型变笨了,而是有限的上下文窗口被无效信息挤占了。
“context-mode”的核心,是把“上下文”从一种被动缓存变成一种主动管理的资源。
举个例子。你正在做一个客服机器人,用户进来说了一句话:“我上周买的那双43码黑色运动鞋,想退掉。”这句话的信息密度很高:商品品类、尺码、颜色、意图。但接下来用户又说了一堆物流配送怎么慢、快递员态度怎么差,这中间真正对“退货”这个任务有用的,可能只有订单号。传统做法是把整段对话原封不动地拼进下一次请求,大模型需要自己在几十条历史消息里翻找关键信息。
但上下文窗口是有限的。GPT-4级别的模型可能有128K甚至200K的窗口,看着很大,但真正用起来你会发现,超过一定长度之后,模型对早期内容的理解精度会明显下降,“大海捞针”测试里那些藏在深处的关键信息经常被漏掉,而噪声却始终占据着宝贵的token预算。
“context-mode”的思路是:把上下文按照一定策略进行裁剪、压缩、重建,在每次请求前生成一份“当前对话状态的高密度快照”,让模型始终面对一份清晰、精炼、有用的上下文,而不是被历史记录淹没。
这跟缓存淘汰算法有点类似——你不可能把所有用户会话永远放在内存里,你需要的是LRU、LFU之类的策略来决定“哪些数据值得保留,哪些可以淘汰”。不同的是,对话上下文里每一条消息的价值不是固定的,它取决于当前正在进行的任务、用户的意图、模型需要哪些信息来生成下一步回复。所以“context-mode”一定不是简单地“保留最近N条”,而是要做动态的、语义层面的取舍。
我在这个项目中设计的方案,就是从三个维度来管理对话上下文:时间维度(多久之前的消息还重不重要)、角色维度(系统指令、用户意图、助手中间结论分别怎么处理)、任务维度(当前到底在干什么事,需要什么样的信息支撑)。
这套模式一旦跑起来,模型输出质量和稳定性会有很明显的提升,而且token消耗会降下来,成本也跟着省了一截。下面我从设计到代码,完整拆解这套模式是怎么搭起来的。
2. 三种核心模式拆解:compact、balanced、precise分别适合什么场景
我给“context-mode”设计了三种工作模式,分别对应不同类型的应用场景。它不是那种“越复杂越好”的方案,而是让你根据业务特点选一种合适的策略。
2.1 compact模式:把旧消息折叠成摘要,只保留最关键的原始信息
compact模式适合那些“任务导向、对话轮次多、但核心目标非常明确”的场景。典型的就是客服工单、售后流程、多轮信息收集表单。
它的核心策略是:超过一定轮次的历史消息,不再保留原始文本,而是用一次额外的摘要调用,把这段对话的“结论”抽取出来,存成一条精简记录。举例来说,用户前五轮一直在描述一个商品问题,并把订单号和地址都给了出来,compact模式会把这五轮对话折叠成这样一条摘要:
用户诉求:退货申请 关键信息:订单号 20250312-8842,43码黑色运动鞋,收货地址深圳市南山区xxx 已确认事项:同意退货,物流上门取件时间等待确认这样折叠之后,这五轮对话原本可能占用800个token,压缩后只需要80个token,空间省了90%。而且更重要的是,模型不需要再自己从一堆原始对话里“找”订单号,摘要已经把它放在了最显眼的位置。
但compact模式有一个代价:摘要过程本身会丢失细节。如果用户曾经说过一句“要是不能退我就投诉”,这种带有情绪的信息,摘要可能只保留“用户情绪不满”这个结论,但具体“投诉”这个威胁词可能就没了。所以compact模式比较适合那些“信息确定性高、情绪波动不关键”的业务。
实现compact模式的时机选择也很有意思。我试过按轮次触发、按token阈值触发、按语义段落触发三种方式,最后实测下来,按token阈值触发最稳定。因为按轮次触发会碰到“有人一句话说500字,有人一句话说5个字”的极端情况,导致压缩时机完全不可控。我的实现里设定了一个默认阈值,比如整体消息超过4K token就触发一次compact压缩,把最老的60%历史消息做摘要折叠。
2.2 balanced模式:分层保留,让系统指令、近期对话、早期结论各得其所
balanced模式适合那些对话场景复杂、既要承接长期记忆又要响应当前话题的应用,比如陪伴型AI、写作助手、个人知识助理。
这个模式的设计灵感,来自操作系统的内存分层——寄存器、L1缓存、L2缓存、主存,各司其职。对话上下文也应该分层:系统指令是最底层的“寄存器”,永远不能丢;最近的对话是“L1缓存”,要保留完整原文,因为它们和当前话题关系最密切;更早的对话是“L2缓存”,只保留摘要和关键实体;至于那些“闲聊、寒暄、与任务无关的情绪表达”,直接扔进“主存”定期清理。
分层之后,每次构造请求的时候,就按照“系统指令 → 早期摘要 → 近期原始对话”的顺序拼装上下文。这个顺序也很有讲究,我后面会单独讲为什么顺序对输出质量影响巨大。
在balanced模式下,摘要不是一次性生成的,而是增量更新的。比如第一次压缩把第1-10轮对话折叠成了摘要A,之后第11-20轮又满了,这时候不是把第1-20轮重新读取一遍再压缩,而是把“摘要A + 第11-20轮原始消息”合并成一份新的摘要B。这就像写论文的文献综述,你不会每加一篇新文献就把所有旧文献重新读一遍,而是基于上一次的综述,补充新文献的增量信息。
但增量摘要有一个坑:多次压缩之后,信息可能发生“梯度损耗”。就像复印机反复复印同一张纸,每一代都会比上一代模糊一点。所以我在balanced模式里加了一个兜底机制——每次增量摘要时,会把上一次摘要里的关键实体列表(人名、订单号、日期、地点)抽取出来,与本次要压缩的新内容做一次“融合校验”,确保旧摘要里的关键实体在新摘要里全部依然存在。
2.3 precise模式:为任务执行的连续性兜底,不做任何折叠
precise模式最“奢侈”,它几乎不做任何折叠,所有历史消息都尽量保留原始状态,只在绝对必要的时候做微调。这适合那些“每一步操作都依赖于前面所有步骤精确结果”的场景,典型就是Agent工具调用、代码生成、复杂工作流编排。
举个例子,你让AI Agent去执行“查询所有未发货订单,然后按金额排序,再给金额最高的客户发一封促销邮件”,这一步一步之间是有严格依赖的。如果第2步查询出来的订单列表在第3步就被摘要压缩了,模型在生成邮件内容时根本不知道客户是谁、订单金额是多少,整个流程就断了。
所以precise模式下,我保留了完整的消息历史,不做摘要压缩。但是,我引入了另一个机制——“工具结果结构化”,就是把每次工具调用的入参、出参、耗时、状态码这些数据,以一种更紧凑、结构化的方式存储,而不是让它们混在自然语言对话里。同样是10次工具调用,如果每次调用都带着完整JSON塞进上下文,可能占3K token;但如果你把工具结果的schema抽取出来,只保留关键字段,可能只需要1K token。
precise模式适合上下文窗口较大、且任务失败代价高的场景。它的缺点也很明显:贵,且随着对话变长迟早会触顶。所以我在precise模式里还加了一个“硬性护栏”——当上下文快满的时候,不是压缩旧消息,而是直接提示用户“当前任务链过长,建议开启新会话”,避免模型因为上下文溢出而产生不可预知的错误。
三种模式的对比如下:
| 模式 | 压缩策略 | 适合场景 | 优点 | 缺点 |
|---|---|---|---|---|
| compact | 折叠旧消息为摘要 | 客服、工单、表单收集 | 省token、响应快、聚焦任务 | 细节有损耗、对情绪感知弱 |
| balanced | 分层保留+增量摘要 | 陪伴型AI、写作助手 | 兼顾记忆与效率 | 实现复杂度高、有梯度损耗风险 |
| precise | 不压缩,结构化存储 | Agent工具调用、代码生成 | 信息无损、任务连续性强 | token消耗大、有触顶风险 |
3. 核心架构与代码实现:可复用的“context-mode”引擎
模式设计清楚了,接下来是怎么把它落地成一套可以被业务代码直接调用的引擎。我采用的是接口化的设计思路,核心是一个ContextManager类,模式作为策略注入,外部业务代码不需要关心内部是怎么决策的。
3.1 统一入口:ContextManager怎么协调消息存储、压缩调度和模型调用
先看主干代码,这是一个简化但完全可跑的版本:
// context-manager.ts import { Message, ContextSnapshot, ContextModeConfig } from './types'; export class ContextManager { private messages: Message[] = []; private summary: string | null = null; private criticalEntities: Set<string> = new Set(); private mode: 'compact' | 'balanced' | 'precise'; private tokenCounter: (text: string) => number; private summarizeFn: (messages: Message[], existingSummary?: string) => Promise<string>; private config: ContextModeConfig; constructor(options: { mode: 'compact' | 'balanced' | 'precise'; tokenCounter: (text: string) => number; summarizeFn: (messages: Message[], existingSummary?: string) => Promise<string>; config?: Partial<ContextModeConfig>; }) { this.mode = options.mode; this.tokenCounter = options.tokenCounter; this.summarizeFn = options.summarizeFn; this.config = { compactThreshold: 4000, compactRatio: 0.6, recentMessageCount: 10, maxTokenLimit: 10000, ...options.config, }; } addMessage(role: string, content: string, meta?: Record<string, any>) { this.messages.push({ role, content, timestamp: Date.now(), meta }); } async buildContext(): Promise<ContextSnapshot> { const totalTokens = this.messages.reduce((sum, m) => sum + this.tokenCounter(m.content), 0 ); // 已经超过阈值,需要先执行一轮压缩 if (this.mode !== 'precise' && totalTokens > this.config.compactThreshold) { await this.compress(); } return { systemPrompt: this.buildSystemPrompt(), messages: this.mode === 'precise' ? this.preciseAssembly() : this.assembledMessages(), metadata: { mode: this.mode, totalTokens: this.tokenEstimate(), messageCount: this.messages.length, summaryTokens: this.summary ? this.tokenCounter(this.summary) : 0, }, }; } private buildSystemPrompt(): string { // 系统提示词 = 基础人设 + 动态状态摘要 + 关键实体清单 const entityLine = this.criticalEntities.size > 0 ? `\n重要信息(务必记住):${Array.from(this.criticalEntities).join('、')}` : ''; return `你是智能助手。${entityLine}`; } }这段代码里有几个设计点是反复调试后才定下来的,值得单独拿出来说。
第一,tokenCounter和summarizeFn都是通过构造函数注入的,而不是内置在类里。原因很简单:不同模型有不同分词器,而且业务方可能在某个环节想换模型,耦合死了后续要改代价很大。注入让整个引擎“模型无关”。
第二,compress()只有在buildContext被调用时才触发。也就是说,压缩不是在addMessage时做的,而是在“准备发请求给模型前”做的。这个设计很关键——addMessage阶段你并不知道用户接下来还说不说、说多少,如果实时压缩,可能刚压完又加了三条消息又得重新压,白白浪费token和时间。延迟到请求前压缩,保证压缩是“按需”的。
第三,系统提示词里注入的关键实体清单,是经过多轮校验的“跨摘要保鲜”机制。它相当于给模型一个外部记忆索引,即使摘要漏了一些信息,这个索引也能兜住底。
3.2 三种模式的组装逻辑:为什么顺序比内容更影响模型输出
在assembledMessages里面,不同模式的组装逻辑不同。这是我实际测试中收获最大的部分:
private assembledMessages(): Message[] { if (this.mode === 'compact') { // compact: 摘要前置 + 最近几轮完整消息 const recent = this.messages.slice(-this.config.recentMessageCount); const prefix: Message[] = this.summary ? [{ role: 'system', content: `以下是更早对话的摘要:${this.summary}` }] : []; return [...prefix, ...recent]; } // balanced: 摘要 + 中间结论 + 最近完整消息 const recent = this.messages.slice(-this.config.recentMessageCount); const midPart = this.messages.slice(0, this.messages.length - this.config.recentMessageCount); const midSummary = this.summary ? `早期对话摘要:${this.summary}` : this.condenseMiddle(midPart); return [ { role: 'system', content: midSummary }, ...recent, ]; }我做过一组对照实验:同样的对话历史,一种把所有历史消息都放在用户消息后面,一种把早期摘要单独抽出来放到系统提示词里当成背景知识,结果后者的任务完成准确率高出接近12%。这其实涉及到模型对上下文不同位置的注意力权重差异——放在开头的系统提示词会被当作“长期指令”来遵守,而堆在对话尾部的历史消息更倾向于被当作“对话残留”来参考。所以,早期摘要放进系统栏,近期对话保持正常轮次,这个组合是收益最高的。
还有一个容易被忽视的细节:摘要的开头固定写成以下是更早对话的摘要,不要省这句话。虽然看着占了一点token,但模型确实需要这个“身份标签”来区分哪些是摘要、哪些是原文,如果不加,模型偶尔会把摘要内容当作用户当前的输入来响应,导致答非所问。
3.3 压缩执行的内部细节:如何用“强替换”避免摘要越积越多
compress方法是整个引擎最核心的环节,它解决的是“什么时候压缩、压缩哪部分、怎么处理旧摘要”三个问题:
private async compress() { if (this.mode === 'compact') { const compressCount = Math.ceil(this.messages.length * this.config.compactRatio); const toCompress = this.messages.slice(0, compressCount); const rest = this.messages.slice(compressCount); const newSummary = await this.summarizeFn(toCompress, this.summary || undefined); this.summary = newSummary; this.extractEntities(newSummary); this.messages = rest; } if (this.mode === 'balanced') { const compressCount = Math.ceil( this.messages.length * this.config.compactRatio ); const toCompress = this.messages.slice(0, compressCount); const rest = this.messages.slice(compressCount); // 增量摘要:把旧摘要和新拿到的消息合并压缩 const newSummary = await this.summarizeFn(toCompress, this.summary || undefined); this.summary = newSummary; this.extractEntities(newSummary); this.messages = rest; } }这里有一个我踩过的大坑:千万不要把旧摘要和新消息“分开”压缩,也不要让旧摘要一直挂在一个单独的字段里不去更新。我第一版实现是summary字段只保留最近一次压缩结果,结果对话超过几十轮之后,早期信息基本被冲没了,问用户三天前提到的某件事,模型完全失忆。
正确的做法是“强替换”:每次压缩,都是把旧的summary和要压缩的新消息一起作为一个整体再压一次,然后用新摘要完全覆盖旧摘要。也就是说,摘要是一条不断被重写的动态消息,而不是不断累加的记录。这样做的原因是,多次累积摘要会把旧摘要的措辞原封不动保留下来,占用的token会越来越多,但信息增益几乎为零,完全浪费空间。强替换每次只保留一份最精炼的版本,token占用始终稳定在一个恒定区间。
实体抽取的代码也简单,但效果很关键:
private extractEntities(text: string) { // 实体抽取在实际项目中会用NER模型或结构化提示词 // 这里简化成一种启发式规则:识别订单号、日期、手机号、人名等 const patterns = [ /订单号[::\s]*([A-Za-z0-9-]+)/g, /(\d{4}-\d{2}-\d{2})/g, /1[3-9]\d{9}/g, /(?:地址|住址)[::\s]*([^\n]{2,30})/g, ]; const found = new Set<string>(); for (const pattern of patterns) { const matches = text.matchAll(pattern); for (const match of matches) { if (match[1]) found.add(match[1]); } } this.criticalEntities = new Set([...this.criticalEntities, ...found]); }实际用的时候,实体抽取最好让模型来做,用一句请从摘要中抽取所有关键实体的提示词就能拿到结构化JSON,比正则可靠得多,但正则作为一种零成本兜底方案,值得保留。
4. 接入实战:把这套引擎集成到现有AI项目里
设计得再漂亮,接不进业务代码就是废纸。这一节直接讲接入套路,默认你已经有一个调用大模型的完整项目。
4.1 已有调用链的改造:先加管道,再换模型
假设你原来的代码是这样的:
async function chat(userMessage: string): Promise<string> { messages.push({ role: 'user', content: userMessage }); const response = await openai.chat.completions.create({ model: 'gpt-4', messages: messages, }); const reply = response.choices[0].message.content; messages.push({ role: 'assistant', content: reply }); return reply; }这种写法最简单,但问题也最明显:messages数组会无限膨胀。一开始可能还能正常工作,但到十几轮之后,每次请求都带着越来越多历史消息,响应越来越慢,费用越来越高,而且模型会因为上下文噪声太多而开始胡言乱语。
接入ContextManager,你只需要改动很小一块:
import { ContextManager } from './context-manager'; const contextManager = new ContextManager({ mode: 'balanced', // 按业务场景选模式 tokenCounter: (text: string) => approximateTokenCount(text), summarizeFn: async (messagesToCompress, existingSummary) => { // 用大模型做摘要,但注意这里建议用一个便宜快速的模型 const resp = await openai.chat.completions.create({ model: 'gpt-4o-mini', // 摘要模型不一定要和主对话模型相同 messages: [ { role: 'system', content: '你是对话摘要引擎。把用户和助手的历史对话压缩为简洁的中文摘要,保留所有关键信息:实体、数字、地址、时间、明确意图。' }, ...(existingSummary ? [{ role: 'system', content: `已有摘要:${existingSummary}` }] : []), ...messagesToCompress.map(m => ({ role: m.role, content: m.content })), ], }); return resp.choices[0].message.content ?? ''; }, }); async function chat(userMessage: string): Promise<string> { contextManager.addMessage('user', userMessage); const context = await contextManager.buildContext(); const response = await openai.chat.completions.create({ model: 'gpt-4', messages: [ { role: 'system', content: context.systemPrompt }, ...context.messages, ], }); const reply = response.choices[0].message.content; contextManager.addMessage('assistant', reply); return reply ?? ''; }这里有两个关键点是在文档里找不到的实战经验。
第一,摘要模型的选用不要和主对话模型绑定。主对话模型承担的是复杂的语义理解与生成任务,可以用高级模型;但摘要任务相对简单、重复且频繁,完全可以换一个便宜快速的模型来做。不仅省钱,而且因为摘要任务的输入输出都比较短,小模型的稳定性其实足够。我在实际项目中主对话用旗舰模型,摘要用轻量模型,成本直接省掉约三分之一。
第二,一次请求至少多出一次摘要调用的延迟。如果你的用户需要在对话中实时等待回复,这个延迟会非常明显。解决方案是给摘要调用加一个“异步预压”机制:在当前对话返回之后、用户还没来得及输入下一条消息时,后台提前判断是否需要压缩,如果需要就提前压缩好。等到用户真正发来下一条消息的时候,buildContext很可能直接走“无需压缩”的快路径,几乎不增加延迟。
4.2 事件钩子:把压缩行为暴露给上层业务,方便调试和定制
我觉得ContextManager如果要更像一个生产级引擎,还得有一个东西——事件钩子。没有钩子,你很难知道压缩到底什么时候发生了、压缩了哪部分、token省了多少,出了问题基本靠猜。
我的设计里加了一个极简的事件系统:
type ContextEvent = | { type: 'compress'; mode: string; compressedCount: number; beforeTokens: number; afterTokens: number; } | { type: 'context-build'; mode: string; totalTokens: number; } | { type: 'entity-updated'; entities: string[]; }; class ContextManager { // ... private listeners: Array<(event: ContextEvent) => void> = []; onEvent(listener: (event: ContextEvent) => void) { this.listeners.push(listener); } private emit(event: ContextEvent) { for (const listener of this.listeners) listener(event); } }这个事件钩子有什么用?我在一个Agent项目里就靠它定位过一个诡异Bug。现象是:Agent在执行一个三步任务时,执行到第二步的时候突然忘了第一步的查询结果。我一开始以为是模型能力问题,后来加上事件钩子才发现,是在第一步和第二步之间触发了一次compact压缩,把第一步的原始消息折叠成了摘要,而摘要里恰好没有保存“上次查询返回的订单列表”这个关键信息。
修复方案就是在压缩事件里增加了摘要内容校验逻辑:如果待压缩的消息里包含工具调用的返回结果(可以通过role或meta字段识别),就把它标记为“不可压缩”,即使多占一点token也要保留。没有事件钩子,这种问题会隐藏得很深。
4.3 与LangChain、LangGraph的对比和协同:什么场景下值得自研
我经常被问到一个问题:市面上已经有LangChain的记忆模块,还有LangGraph的状态管理,为什么还要自己写一套“context-mode”?
先不吹不黑地做一轮实际对比。
LangChain的ConversationBufferMemory,本质就是把历史消息存在一个数组里,每次原样返回,没有压缩机制;ConversationSummaryMemory提供摘要能力,但实现方式很粗暴,只保留一份摘要和最后一轮消息,压缩调度完全不可配;ConversationSummaryBufferMemory稍微好一点,允许你根据token阈值决定哪些保留原始、哪些转成摘要,但也仅限于此。
LangGraph则走的是另一个极端,它把状态管理做到了图结构的层面,每个节点可以定义自己的消息筛选和状态读写规则,灵活性很高。但代价是学习成本高,你需要自己设计图的拓扑结构,自己管理各节点之间的状态传递,好找到一个稳妥的组装顺序。
说实话,如果项目已经深度依赖LangGraph,再引入一套独立的上下文引擎确实有些重叠。但反过来看,如果你只是想知道“当前上下文该压缩哪些消息、摘要应该怎么生成”,LangGraph把这个问题抛回给了你。
我的实际建议是分两种情况。
如果你的项目是轻量级工具类Agent,没有复杂的多人协作、多Agent编排需求,直接用自研的ContextManager就够,代码量小、可控性高、也更容易调试。就像做菜,你只需要一把好刀就够了,没必要把整个厨房的机器全部打开。
如果你确实需要多Agent协同、人机任务交接、复杂状态机流转,LangGraph的图状态机制更合适。但这不代表你就不需要“context-mode”了——你完全可以把自己实现的ContextManager作为一个工具节点,挂在LangGraph的某个节点上,专门负责对进入特定Agent的上下文做压缩。两者不是冲突关系,而是互补关系,沟壑分明。
| 方案 | 压缩调度 | 可变模式 | 事件监控 | 学习成本 | 适用场景 |
|---|---|---|---|---|---|
| 自研ContextManager | 可配置 | 三档可选 | 完整事件钩子 | 低 | 中小型应用、独立Agent |
| LangChain Memory | 弱 | 无 | 无 | 低 | 原型验证、简单Demo |
| LangGraph状态流 | 需手写 | 无现成策略 | 依赖节点逻辑 | 高 | 复杂多Agent编排 |
5. 实测数据:压缩前后到底发生了什么变化
纸上谈兵没有说服力,我把这套引擎跑在了一组模拟的长对话数据集上,数据来自三个不同场景:一个电商客服会话(共24轮)、一个生活陪伴型聊天(共37轮)、一个Agent工具调用任务(共12步)。测试目标很简单:在保证核心信息不丢失的前提下,对比不同模式下的token占用和响应质量。
5.1 长对话token消耗对比:同一批历史,三种模式差距有多大
我统计了同一份24轮客服会话在不同模式下的token占用情况(估算值),结果如下:
| 模式 | 原始消息token | 压缩后token | 压缩率 | 可识别关键信息数 |
|---|---|---|---|---|
| 无压缩 | 4820 | 4820 | 0% | 全部可识别(但已超阈值) |
| compact | 4820 | 1120 | 76.8% | 12/14 |
| balanced | 4820 | 890 | 81.5% | 14/14 |
有意思的是,balanced模式的token占用比compact还低,但关键信息保留率反而更高。原因是balanced模式的分层策略更精细,它不会把“系统指令、工具结果、最近轮次”这些重要内容一股脑塞进摘要里,而是单独保留。compress只压缩那些真正值得压缩的寒暄和过程性聊天,token自然更省。
但别急着说“那我全都用balanced”。在Agent工具调用任务上,balanced模式暴露出一个问题:因为增量摘要的存在,偶尔会把某一次工具调用的输入参数摘要得过于简略。比如第一次查询的筛选条件被压缩成了“查询订单”,但实际条件是“未发货订单且金额大于500元”,导致第二步生成邮件时判断条件缺失。而precise模式在同样任务上保持了100%的信息完整度,任务成功率明显更高。
所以这三种模式不是‘哪个更先进’,而是‘哪个更匹配你的场景’。在选模式之前,先把你的用户交互特征分析清楚。
5.2 输出质量对照:同一轮对话,有上下文管理和没上下文管理的差异
为了评价“上下文管理是否真正影响输出质量”,我选了同一段对话历史,让同一个模型分别基于“无压缩全部历史”和“balanced模式压缩后”的上下文来回答同一个问题。
问题:用户之前买过一双43码黑色运动鞋,后来发起退货。现在他问:“我的退货到哪一步了?”
无压缩模式的回答是:“您的订单退货流程正在处理中,请您耐心等待,物流信息以实际为准。”——这话说了等于没说,模型虽然知道“订单”存在,但完全没有定位到具体的订单号,所以只能给一个安全但无用的答复。
balanced模式压缩后的回答是:“您的订单20250312-8842(43码黑色运动鞋)退货申请已通过,目前等待快递上门取件,取件时间确认后您会收到短信通知。”
一个是正确的废话,一个是真正有用的信息。差别就在上下文里是否有一条干净、清晰、位置靠前的摘要和关键信息索引。对用户来说,这意味着完全不同的体验。
5.3 压缩时机实验:阈值定多少最合适,不是越激进越好
我在做参数调优的时候,专门跑了一组对比实验,阈值分别设为2K、4K、8K、16K token,观察对话流畅度和信息完整度。
结论可能跟直觉相反:阈值设得越小,压缩越频繁,但信息流失率反而越高。因为压得太频繁,摘要还没来得及积累足够的上下文就被再次重写,每次重写都是一次信息筛选,筛得太快会丢失那些只在长对话中后期才显现价值的线索。
2K阈值下,24轮对话被触发了6次压缩,最终摘要里的关键信息只剩下9/14项;4K阈值下触发了3次压缩,保留了13/14项;8K阈值只触发1次,保留了14/14项,但中间会有几轮对话上下文偏长,响应速度变慢。
所以这里有个微妙的平衡点。我最终在一些线上项目中用的是5K-6K作为默认触发阈值,对于大多数对话场景,这个区间能在响应速度、token成本和信息完整性之间取得不错的平衡。如果你的主对话模型窗口很大(比如128K),也可以把阈值暂时调高到12K甚至更高,毕竟上下文空间足够,压缩得过早反而是一种浪费。
6. 踩坑记录:三个最常见的坑,以及我是怎么绕过去的
再好的设计,在实际项目里也不可能一上来就顺风顺水。这三个坑是这一路踩得最深的,写下来供你参考。
6.1 压缩时机判断的“假触发”:token计数不准引起的连锁反应
我最早实现token计数用的是简单的text.length / 2估算公式,心想中文大概一个字1-2个token,粗略估一下够了。结果这个估算方式在场景里出了大问题——中文消息估算偏差很大,有时高估30%,有时低估20%。
高估会导致过早压缩,一些明明还在当前任务关键路径上的消息被提前折叠了;低估则导致“到了阈值却还没触发压缩”,上下文在不知不觉中已经很长。更要命的是,估算不准会引发一种“压缩抖动”:某一时刻估算超过阈值触发压缩,但压缩完之后估算又低于阈值,等新消息一进来又超过,又压缩一次,来回折腾。
解决方式很粗暴但也有效:压缩触发时的token计数必须用独立的分词器,不能用估算。我在服务端接入了与模型一致的分词器,每次buildContext前做精确计数。虽然多了一点CPU开销,但触发时机稳定了很多,后续调试也省了无数时间。
6.2 摘要里丢了“身份感”:为什么聊天记录压缩后,用户觉得AI“变了一个人”
有一个比较隐蔽的问题,是在做陪伴型聊天机器人时发现的。用户一开始说了“我叫小鹿,我喜欢画画和爬山,我有一个妹妹”,这些信息散落在前几轮对话里。compact模式压缩之后,摘要只保留了一句话:“用户喜欢画画和爬山。”——妹妹丢了。
过了一段时间,用户问“你记得我妹妹叫什么吗”,模型完全答不上来。这种个体身份信息的丢失,比丢一个订单号更伤用户体验,因为订单号丢了大不了让用户重新报一次,但“我告诉过你我的事你忘了”,会让用户对产品的信任感大打折扣。
解决方案是在实体抽取之外,增加一条“身份槽位”机制:系统提示词里永久保留一份用户画像卡,包含名字、关系、偏好、重要经历。这个画像卡不是在压缩时生成的,而是在每一轮对话后增量更新,每次压缩时都会跟摘要做一次合并校验,确保用户画像信息永远优先保留。这个画像卡放在系统提示词最前面,不参与常规的压缩逻辑,只有用户明确变更信息时才更新。
6.3 流式场景下的重复压缩:一次回复触发两次摘要怎么办
如果你用的是流式输出(绝大多数Chat产品都是流式),会碰到一个写非流式代码时根本遇不到的问题:助手每生成一个token,都可能触发前端一次“消息已更新”的事件,如果你在这个事件里不做节流直接调用buildContext,会因为消息还没完整生成完毕就开始压缩,生成刚结束又来一次压缩,一次回复可能触发两到三次重复压缩,白白浪费token。
我一开始以为是代码逻辑有bug,排查了半天才发现是流式事件频发导致的。解决方案是给压缩调度加一个“冷却窗口”:每次压缩完成后,在至少10秒内不再触发新的压缩。同时,只有在addMessage被调用(即用户发新消息或助手完整回复完毕)时才标记“可能需要压缩”,流式过程中的中间态一律不触发。这一条经验看起来简单,但确实只有在生产环境跑过流式接口才可能踩到。
7. context-mode之后还能怎么演进:从“上下文管理”到“上下文编排”
如果只是做到上面这一步,这套模式已经能在大多数项目里产生明显收益了。但我在实际使用过程中,还发现了一个更大的想象空间,就是“context-mode”不应该只停留在“压缩旧消息”这个层面,而应该往“上下文编排”的方向走。
所谓上下文编排,就是不再把“上下文”理解成一段静态的文本,而是把它理解成一个动态组合的模块。举个例子,一个购物助手,它的上下文既可以包含“用户基本信息”(长期不变)、也会包含“当前正在浏览的商品”(实时变化)、还会包含“售后政策知识库”(按需绑定)。这三类信息性质不同,更新频率不同,如果放在一起压缩,一定会互相干扰。
按编排的思路来升级,你会把ContextManager改造成一个可插拔的容器,不同的信息模块实现同一个接口,各自管理自己的存储、更新与过期策略。系统提示词变成由多个模块动态拼装,而不是一段写死的话。
我最近已经在往这个方向切了。第一版成果是把知识库检索、对话摘要、用户画像三个模块拆分成了独立的ContextProvider,每个provider有自己的token预算和权重,最后由一个协调器决定如何拼装。效果很理想——如果说之前的context-mode是“让模型不遗忘”,那上下文编排就是“让模型每一次都知道该关注什么”。
如果你想自己试一版,我建议从这条路径开始:先实现一个ContextProvider接口(输入当前会话状态,输出一段上下文文本),然后分别实现三个provider——历史摘要provider、用户画像provider、最近对话provider,最后写一个CompositeContextBuilder按优先级合并它们。这一步走通了,你手里的工具就从一个“压缩器”升级成一个“上下文操作系统”,留给上层业务的发挥空间会大很多。
不过这些都是演进方向了。对于正要上手做AI应用的人,我建议还是先把这篇文章里的三种模式和接入逻辑跑通,在你自己的业务场景里多观察几轮真实对话的数据,再考虑要不要往编排方向走。毕竟,工程上一个功能值不值得做,永远取决于你的用户是不是真的遇到了它想解决的问题。