ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型上下文管理实战:从Token预算到分层策略的工程指南

大模型上下文管理实战:从Token预算到分层策略的工程指南

1. 上下文管理到底在管什么

第一次听到“上下文管理”这个词,很多人会下意识觉得这是架构师才需要操心的事,跟日常写业务代码没关系。但实际情况恰恰相反——你写的每一个函数、每一次接口调用、每一轮对话交互,背后都藏着一套上下文流转的逻辑。管得好,系统跑得稳、响应快、成本低;管得不好,轻则答非所问,重则内存溢出、请求超时、账单爆炸。

我先把话说清楚:上下文管理,指的是在一个系统处理任务的过程中,对“当前任务所依赖的全部信息”进行收集、裁剪、组织、传递和回收的一整套机制。这些信息可能包括对话历史、用户状态、工具调用结果、检索到的文档片段、系统提示词等等。它要解决的问题很朴素——在正确的时间,把正确的信息,以正确的体量,送到正确的位置。

为什么现在这个话题突然热起来了?因为大模型应用的爆发让上下文从“几个变量”变成了“几万到几十万token的文本流”。以前写个Web服务,上下文就是session里存个用户ID;现在做一个智能助手,上下文可能是几十轮对话加上一堆知识库检索结果。体量变了,复杂度就指数级上升。你不可能把所有东西一股脑塞进去,那样既贵又慢还容易让模型“分心”。所以上下文管理从一个边缘话题变成了核心工程问题。

这篇文章适合谁看?如果你正在做AI应用开发、对话系统、RAG检索增强生成、Agent工具编排,或者任何需要维护“有状态交互”的系统,那这篇内容就是给你写的。我会从设计思路讲到实操细节,从参数计算讲到踩坑排查,尽量把这件事说透。即使你目前只写传统的后端服务,里面关于状态裁剪和传递的思路也一样有参考价值。

2. 整体设计思路与方案选型

2.1 为什么不能“全量塞入”

先算一笔账。假设你用的是某个主流大模型,上下文窗口是128K token。听起来很大对吧?但实际算一下:一轮对话平均消耗500 token,128K大概能装256轮。可问题是,你还要留空间给系统提示词、工具定义、检索结果、模型输出。真正能分给历史对话的可能只有一半不到。而且token越多,推理成本越高——很多API是按输入token计费的,你每次把全部历史传进去,费用是线性增长的。

更关键的是,模型对上下文的注意力不是均匀分布的。业界有个被反复验证的现象叫“中间遗忘”:模型对上下文开头和结尾的信息记得比较牢,中间部分容易被忽略。你把100轮对话全塞进去,模型反而可能漏掉第37轮里用户说的关键偏好。所以全量塞入不仅贵,效果还未必好。

我试过一个极端案例:某客服机器人把用户过去三个月的所有对话记录都拼进上下文,结果模型在回答“我的订单到哪了”时,居然引用了一个月前另一笔订单的物流信息。这就是上下文污染——无关信息干扰了当前任务的判断。

2.2 三种主流策略的取舍

目前业界处理上下文,大致有三条路线,各有各的适用场景。

第一条是滑动窗口。只保留最近N轮对话,超出部分直接丢弃。实现最简单,成本可控,适合闲聊类、实时性要求高的场景。缺点是会丢失早期的重要信息,比如用户在开头说的“我对花生过敏”,聊到第50轮推荐餐厅时模型就忘了。

第二条是摘要压缩。把历史对话定期用模型总结成一段简短摘要,替换掉原始对话。这样能保留长期记忆,token占用也小。但摘要本身有信息损耗,而且每次摘要都要调一次模型,增加延迟和成本。适合长周期任务,比如持续几天的项目协作助手。

第三条是检索增强。把所有历史存到向量数据库,每轮根据当前问题检索最相关的片段拼进上下文。这样理论上记忆无限,且只取相关的。缺点是需要额外的检索基础设施,检索质量直接影响效果,而且检索本身也有延迟。

实际工程中,这三种策略往往是组合使用的。我目前比较推荐的架构是:近期对话用滑动窗口保留原文,中期对话做摘要压缩,远期记忆走检索。这样兼顾了实时性、连贯性和长期记忆。下面这张表可以帮你快速判断该选哪种:

策略实现复杂度token成本信息保留度适用场景
滑动窗口低低仅近期闲聊、实时问答
摘要压缩中中中长期长周期任务、项目助手
检索增强高中高理论上无限知识密集、个性化服务
组合策略高可控高生产级AI应用

2.3 上下文的分层模型

我在实际项目中总结出一个分层思路,把上下文按“生命周期”和“重要性”分成四层,每层用不同的管理策略。

第一层是系统层,包括系统提示词、角色设定、工具定义。这部分基本不变,每轮都带上,但要控制篇幅。我见过有人把系统提示词写了三千字,结果每轮都在烧钱。建议系统提示词控制在500 token以内,工具定义按需加载。

第二层是会话层,就是当前这轮对话的短期历史。用滑动窗口管理,保留最近10到20轮。这层是模型理解当前意图的主要依据,要保证质量。

第三层是记忆层,跨会话的长期信息。比如用户的偏好、历史决策、重要事实。这层用摘要或结构化存储,按需注入。

第四层是知识层,外部检索结果、文档片段。这层用检索增强,每轮动态获取,用完即弃。

分层的好处是每层可以独立优化。比如系统层可以缓存,会话层可以裁剪,记忆层可以定期整理,知识层可以调检索参数。混在一起管理就会很乱。

3. 核心细节解析与实操要点

3.1 token预算的分配方法

上下文管理的核心约束是token预算。你得先知道总共能用多少,再决定怎么分。假设模型窗口是128K,我的分配方案是这样的:

  • 系统提示词与工具定义:预留8K
  • 模型输出预留:预留16K(因为输出也要占窗口)
  • 检索结果:预留32K
  • 历史对话:剩余约72K

然后根据平均每轮对话的token数,反推能保留多少轮。如果一轮平均800 token,72K大概能放90轮。但实际我不会放满,会留20%的缓冲,所以实际保留约70轮。超过部分触发摘要或丢弃。

这个计算不是一次性的,要在代码里动态调整。比如检索结果这次只用了10K,那历史对话就可以多放一些。我通常写一个预算管理器,每轮开始前根据各部分的实际占用重新分配。

注意:不同模型的token计算方式不一样,中文和英文的token比例也不同。中文大致是1个汉字对应1到2个token,英文是1个单词对应1到1.5个token。做预算时要用实际模型的分词器算,别凭感觉估。

3.2 对话历史的裁剪策略

裁剪不是简单地砍掉最老的。我试过几种策略,效果差异很大。

最粗暴的是FIFO,先进先出,砍最老的。问题是用户最早说的关键信息可能就没了。

改进版是保留首尾。保留第一轮对话(通常包含用户的核心诉求)和最近N轮,中间砍掉。这比纯FIFO好,但中间可能有重要转折。

我目前用的是重要性加权。给每轮对话打个分,评分维度包括:是否包含用户明确偏好、是否包含决策结论、是否包含实体信息(人名、时间、数字)。评分低的先砍。评分可以用规则做,也可以用小模型做。规则版大概长这样:

def score_message(msg): score = 0 if msg.role == "user": score += 1 if any(kw in msg.content for kw in ["记住", "重要", "偏好", "不要", "必须"]): score += 3 if re.search(r'\d{4}-\d{2}-\d{2}|\d+元|\d+个', msg.content): score += 2 if len(msg.content) > 200: score += 1 return score

然后按分数从低到高砍,直到满足预算。这套规则不完美,但比FIFO强不少。实测下来,关键信息保留率从60%提升到了85%左右。

3.3 摘要压缩的触发时机与写法

摘要压缩不能太频繁,否则延迟高;也不能太稀疏,否则上下文爆了才压缩就来不及。我的做法是设置一个水位线:当历史对话token占用超过预算的80%时,触发压缩。压缩时把最老的一半对话拿出来,让模型总结成一段话,替换掉原文。

摘要的提示词很关键。我试过几种写法,最后稳定用的是这个结构:

请将以下对话压缩为一段不超过200字的摘要,必须保留: 1. 用户明确表达的偏好和禁忌 2. 已达成的结论和决策 3. 涉及的具体实体(人名、时间、数字、地点) 4. 未解决的问题 不要包含寒暄和重复内容。

这样压出来的摘要信息密度高,后续模型能快速抓住重点。我对比过,用这个提示词比让模型“自由总结”的关键信息保留率高30%以上。

实操心得:摘要不要只做一次。长期任务里,摘要本身也会越积越多,需要做“摘要的摘要”。我一般设置两级:一级摘要保留最近三次压缩的结果,二级摘要把更早的合并。这样层级清晰,不会无限膨胀。

3.4 检索增强的注入方式

检索增强听起来简单——搜到相关片段拼进去就行。但实际有几个坑。

第一个坑是检索粒度。按整篇文档检索,可能一篇里只有一段相关,其他都是噪音。按段落检索,又可能丢失上下文。我目前用“段落加标题”的方式,每个片段带上所属文档的标题和前后各一段作为上下文。这样既精准又不失语境。

第二个坑是注入位置。检索结果放在系统提示词后面、历史对话前面,还是放在最后?我试过几种,发现放在历史对话之后、用户当前问题之前效果最好。因为模型对靠近末尾的内容注意力更强,检索结果紧挨着问题,相关性判断更准。

第三个坑是数量控制。检索太多片段会稀释注意力。我一般控制在3到5个片段,每个片段不超过500 token。如果检索结果相关性分数低于阈值,宁可不注入,也别硬塞。

3.5 工具调用结果的上下文处理

Agent类应用里,工具调用结果往往很长。比如调一次搜索API返回几千字,调一次数据库返回一堆JSON。这些结果如果全塞进上下文,很快就爆了。

我的处理原则是:工具结果只保留模型决策所需的最小信息。具体做法是在工具返回后,先用一个轻量模型或规则做一次“结果提炼”,提取关键字段和结论,原始结果存到外部存储,上下文里只放提炼后的摘要和引用ID。如果模型后续需要细节,再通过引用ID去取。

这样上下文占用能降低70%以上。代价是多了一次提炼调用,但相比省下的token成本和提升的响应速度,这笔账是划算的。

4. 实操过程与核心环节实现

4.1 搭建上下文管理器的完整流程

下面我以一个对话系统为例,走一遍完整的实现流程。假设你用Python,框架不限,核心逻辑是通用的。

第一步,定义上下文的数据结构。我用一个类来管理,包含四个队列:system_prompt、history、memory、retrieval。每个队列有独立的token计数和预算上限。

class ContextManager: def __init__(self, max_tokens=128000): self.max_tokens = max_tokens self.system_prompt = "" self.history = [] # list of {role, content, score} self.memory = [] # list of summary strings self.retrieval = [] # list of retrieved chunks self.budget = { "system": 8000, "output": 16000, "retrieval": 32000, "history": 72000 }

第二步,实现token计数。用模型对应的分词器,别自己估。如果拿不到分词器,用tiktoken之类的通用库近似也行,但要留10%的误差缓冲。

import tiktoken enc = tiktoken.get_encoding("cl100k_base") def count_tokens(text): return len(enc.encode(text))

第三步,实现历史裁剪。每轮对话结束后,把新消息加入history,然后检查是否超预算。超了就按重要性评分裁剪。

def trim_history(self): total = sum(count_tokens(m["content"]) for m in self.history) if total <= self.budget["history"]: return # 按分数升序排列,砍低分的 self.history.sort(key=lambda m: m["score"]) while total > self.budget["history"] and self.history: removed = self.history.pop(0) total -= count_tokens(removed["content"]) # 恢复时间顺序 self.history.sort(key=lambda m: m["timestamp"])

第四步,实现摘要压缩。当history被砍掉的内容积累到一定量,触发摘要。把砍掉的内容送给模型总结,结果存入memory。

def compress(self, removed_messages): text = "\n".join([f"{m['role']}: {m['content']}" for m in removed_messages]) prompt = f"请将以下对话压缩为不超过200字的摘要,保留偏好、结论、实体和未解决问题:\n{text}" summary = call_llm(prompt) self.memory.append(summary) # memory也要控制总量,超过就做二级摘要 if sum(count_tokens(s) for s in self.memory) > 8000: self.merge_memory()

第五步,组装最终上下文。每轮请求前,按顺序拼接:system_prompt + memory + retrieval + history + 当前问题。拼接时再次检查总token,超了就继续裁。

def build_context(self, current_query): parts = [self.system_prompt] parts.extend(self.memory) parts.extend(self.retrieval) parts.extend([f"{m['role']}: {m['content']}" for m in self.history]) parts.append(f"user: {current_query}") full = "\n".join(parts) # 最终检查 while count_tokens(full) > self.max_tokens - self.budget["output"]: # 优先砍retrieval,再砍history if self.retrieval: self.retrieval.pop() elif self.history: self.history.pop(0) else: break full = self.rebuild(parts) return full

这套流程跑下来,一个完整的上下文管理器就成型了。代码不复杂,关键是每一步的阈值和策略要调好。

4.2 参数调优的实操记录

上面代码里的数字不是拍脑袋定的,是我在实际项目中反复调出来的。分享几个关键参数的调整过程。

history预算从50%调到56%。一开始我给history分50%的预算,发现长对话里模型经常“失忆”。后来分析发现,检索结果实际用量往往低于预留的32K,所以把history提到56%,检索降到25%。调整后,长对话的连贯性明显改善。

摘要触发水位从90%降到80%。90%触发时,经常出现“压缩完还是超”的情况,因为压缩本身也要时间,期间新消息又进来了。降到80%后,有足够缓冲,压缩从容多了。

检索片段数从8个降到4个。一开始觉得检索越多越好,结果模型被噪音干扰,回答质量反而下降。降到4个后,相关性判断更准。这个数字跟你的检索质量有关,检索准的话可以少放,检索糙的话放多了也没用。

输出预留从8K提到16K。这个坑我踩得比较惨。有次模型生成长报告,输出到一半被截断了,因为输出预留不够。后来统一提到16K,再没出过截断问题。

4.3 一个完整的对话轮次演示

光看代码可能还不够直观,我模拟一轮完整对话,把上下文的变化过程展示出来。

假设用户第一轮说:“帮我规划一个三天的北京行程,我不吃辣,预算3000元。”这轮结束后,history里有一条消息,评分很高(包含偏好“不吃辣”、实体“北京”“三天”“3000元”)。

第二轮用户说:“第一天想去故宫。”history加一条,评分中等。此时总token还远没到预算,不触发任何裁剪。

对话进行到第30轮,history累计token接近预算的80%。触发压缩,把前15轮拿出来总结。摘要里保留了“不吃辣”“预算3000”“三天”“故宫”等关键信息,原文从history移除,摘要进memory。

第31轮用户问:“第二天推荐吃什么?”组装上下文时,system_prompt + memory(含摘要)+ 最近15轮history + 当前问题。模型看到摘要里的“不吃辣”,推荐了不辣的餐厅。如果没有摘要,这个偏好可能在第30轮时已经被裁掉了。

这个演示说明了一个核心点:上下文管理不是等爆了再处理,而是持续在后台运行。每一轮都在做预算检查、评分、裁剪、压缩。用户感知不到,但效果差异巨大。

5. 常见问题与排查技巧实录

5.1 模型“失忆”的排查思路

模型突然忘记之前说过的信息,是最常见的问题。排查时按这个顺序走:

先查history是否被裁掉了。打印当前history的所有消息,看关键信息还在不在。如果不在,说明裁剪策略有问题,要么提高预算,要么改进评分规则。

再查摘要是否丢失了信息。如果关键信息在摘要环节丢的,检查摘要提示词是否覆盖了该类型信息。比如用户说“我对海鲜过敏”,摘要提示词里如果没有“禁忌”这一项,就可能被漏掉。

最后查注入位置。有时候信息在上下文里,但模型没注意到。试试把关键信息挪到更靠近末尾的位置,或者用特殊标记(如【重要】)标出来。

我整理了一个速查表:

现象可能原因排查方法解决方向
忘记早期偏好history被裁剪打印history检查提高预算或改进评分
忘记中期结论摘要丢失检查摘要内容优化摘要提示词
忽略已注入信息位置太靠前调整注入顺序挪到靠近末尾
混淆不同会话记忆层污染检查memory隔离按会话分区存储
回答与检索矛盾检索噪音检查相关性分数提高阈值或减少片段

5.2 token超限的紧急处理

有时候线上突然报token超限,请求直接失败。这时候需要一套紧急处理流程。

第一步,定位超限来源。打印各部分token占用:system多少、memory多少、retrieval多少、history多少。通常超限都是某一部分异常膨胀,比如检索返回了一个超长文档,或者history里混进了一条巨长的工具结果。

第二步,紧急裁剪。如果retrieval超了,直接砍到只剩最高分的2个片段。如果history超了,按评分砍掉最低的30%。如果system超了,检查是不是工具定义重复加载了。

第三步,加兜底逻辑。在build_context的最后加一个硬截断,如果拼完还是超,直接从末尾截断到预算内。虽然粗暴,但能保证请求不失败。当然,截断后要记日志,事后分析原因。

注意:硬截断可能截掉当前问题,导致模型答非所问。所以硬截断只能作为最后手段,且截断后要在日志里标记,方便排查。

5.3 成本失控的优化经验

上下文管理做不好,成本会失控。我见过一个案例,某应用每轮请求平均消耗50K token,按调用量算下来每月成本高得离谱。后来做了几项优化,成本降了60%。

优化一,系统提示词瘦身。原来系统提示词写了2000字,精简到500字,每轮省1500 token。精简方法是把不常用的工具定义改成按需加载,把冗长的角色描述改成要点式。

优化二,检索结果去重。检索返回的片段经常有重复内容,去重后平均减少30%的检索token。

优化三,历史摘要替代原文。把超过10轮的对话全部摘要化,history token占用降低50%以上。

优化四,缓存系统提示词。很多模型API支持提示词缓存,系统提示词不变的部分可以缓存,费用大幅降低。这个要查你用的API是否支持。

优化五,按需检索。不是每轮都检索,而是判断当前问题是否需要外部知识。简单寒暄、确认类问题不触发检索,省下检索token。

这几项加起来,效果很显著。关键是每项都不复杂,但要有意识去做。

5.4 多轮对话中的角色混淆问题

多轮对话里,模型有时候会把用户说的话当成自己说的,或者把工具返回的内容当成用户输入。这是上下文拼接格式不清晰导致的。

我的解决方法是严格标记每段内容的来源。用户消息用[用户]开头,助手消息用[助手]开头,工具结果用[工具结果]开头,检索内容用[参考资料]开头。这样模型能清楚区分。

另外,history里不要只存content,要存role。拼接时按role加标记。我试过不加标记直接拼,角色混淆率大概5%;加了标记后降到1%以下。

还有一个细节:工具结果和检索内容要明确告诉模型“这是外部信息,不是用户说的”。我通常在检索内容前加一句“以下是从知识库检索到的参考资料,请基于此回答,但不要直接复述”,效果不错。

5.5 上下文管理的测试方法

上下文管理逻辑复杂,不测试很容易出bug。我一般做三类测试。

单元测试:针对裁剪、摘要、组装每个函数单独测。构造超长history,验证裁剪后token在预算内;构造含关键信息的对话,验证摘要后关键信息还在。

集成测试:模拟完整的多轮对话,跑50轮,每轮检查上下文组装结果。重点看关键信息是否在预期轮次后仍然存在。

压力测试:构造极端情况,比如单轮输入10K token、检索返回20个片段、history有200轮。验证系统不崩溃、不超限、响应时间可接受。

测试用例要覆盖边界:空history、空检索、超长单条消息、全是低分消息等。这些边界往往就是线上故障的来源。

6. 进阶技巧与长期维护

6.1 上下文质量的监控指标

上线后不能不管,要持续监控。我关注这几个指标:

  • 平均上下文token占用:反映成本趋势,突然升高要排查。
  • 裁剪触发频率:太频繁说明预算不够或对话太长。
  • 摘要压缩比:压缩后token除以压缩前,反映摘要效率。
  • 关键信息保留率:抽样检查,看重要信息是否还在。
  • 首token延迟:上下文越长延迟越高,反映用户体验。

这些指标可以打到监控面板上,设置告警阈值。比如裁剪频率超过每10轮一次,就该考虑调预算了。

6.2 上下文管理的演进方向

这套东西不是一成不变的。随着模型窗口变大、成本下降,策略也要调整。比如窗口从128K涨到1M,滑动窗口就可以放更多轮,摘要频率可以降低。但核心思路不变:在预算约束下最大化信息价值。

另外,结构化记忆是个值得投入的方向。与其让模型自由总结,不如用结构化格式存记忆,比如JSON格式的偏好列表、事实列表。这样检索和注入都更精准,也不容易丢失信息。我目前在一些项目里试点,效果比纯文本摘要好。

还有一个方向是自适应预算。根据当前问题的复杂度动态调整各部分预算。简单问题少给检索,复杂问题多给历史。这需要先做问题分类,再映射到预算配置。实现起来有点复杂,但收益明显。

6.3 我踩过的几个坑

最后分享几个我实际踩过的坑,帮你省点时间。

坑一:用字符数估token。早期图省事,用字符数除以2估token,结果中文场景下严重低估,频繁超限。后来老老实实用分词器,再没出过问题。

坑二:摘要提示词太笼统。一开始写“请总结以下对话”,结果模型总结得又长又水,关键信息还丢了。后来改成明确列出要保留的信息类型,质量立马上来了。

坑三:忘记给输出留预算。有次模型生成长文,输出到一半被截断,用户看到半截回答。后来固定预留16K给输出,问题解决。

坑四:检索结果不设上限。有次检索返回了30个片段,上下文直接爆了。后来加了硬上限,最多5个片段,每个不超过500 token。

坑五:memory无限增长。摘要越积越多,最后memory本身就成了负担。后来加了二级摘要,memory总量控制在8K以内。

这些坑都不难解决,但不知道的话就会反复踩。希望你看完能直接绕过。

这套上下文管理的思路和实现,我在几个项目里跑了一年多,整体稳定。核心就一句话:把上下文当成有限资源来经营,每一token都要花在刀刃上。具体策略可以根据你的场景调整,但预算意识、分层思维、持续监控这三条是通用的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进