LLM应用上下文管理实战:从token超限到context-mode策略设计
2026/9/10 11:59:32 网站建设 项目流程

如果你和我一样在做 LLM 应用,大概率碰到过这种场景:用户在对话框里聊了十几轮,程序突然抛出一个 token 超限的报错;或者你把所有历史消息一股脑塞给模型,结果它越聊越笨,连用户刚说的需求都抓不住。我最早遇到这些问题时,第一反应是换更大的模型、加更长的上下文窗口,后来发现这只是把问题往后推,并没有真正解决。

最后我沉淀了一个叫 context-mode 的小模块,它的核心职责只有一件事:在每轮调用模型之前,根据当前对话状态决定哪些内容该进上下文、以什么形式进、保留多少。如果你正在做聊天机器人、AI 客服、文档问答这类应用,这篇文章里的思路和代码应该能帮你少走很多弯路。

1. 为什么要做 context-mode:LLM 应用的真实痛点

刚开始做 LLM 应用时,我觉得上下文管理很简单,把 messages 数组原样传给模型就行。等用户量上来、对话轮次变多,我才发现这件事远比想象中复杂,而且绕不过去。

1.1 上下文窗口从来都不是真的“无限大”

现在很多模型都宣称支持 128k、甚至 200k 的上下文窗口,听起来很够用。但实际跑起来你会发现,窗口越大,单次请求的 token 越多,费用越高,首字延迟也越明显。用户连续聊上几个小时,哪怕是 128k 也会被撑爆。

我之前做过一个客服机器人,用户可以在同一个会话里反复咨询不同订单,一聊就是大半天。结果第 80 轮对话时,请求直接失败,因为历史消息加起来已经接近窗口上限。后来我尝试买更大窗口的模型,成本直线上升,但问题只是从“必然爆”变成了“晚一点爆”,本质没有变。

这里的核心事实是:上下文不是无限的存储,而是每轮调用都要花钱和时间的资源。我们不能指望模型自己“记住”所有东西,应用层必须主动管理。

1.2 塞得越多,模型不一定越聪明

我一开始的优化方向很朴素:既然窗口有限,那就尽量把窗口塞满,让模型“看到”所有信息。结果上线后用户反馈更差了,模型经常被无关历史带偏。

后来我查了不少资料,发现一个很常见的现象:模型对长上下文中间部分的内容感知会变弱,也就是所谓的“lost in the middle”。还有,历史消息里如果混着大量过期的、矛盾的信息,模型反而会不知道该信哪条。

举个例子,用户在问退货政策,但历史记录里还留着他上一周和客服吵架的内容,模型在回答时可能会把那些情绪化表达当成当前诉求,回复就容易跑偏。对 LLM 来说,背景不是越多越好,关键是内容相关、结构清楚、时效正确。

这个认知让我把重点从“塞满窗口”转向“筛好窗口”,context-mode 就是从这时候开始设计的。

1.3 手写 if-else 管上下文,项目还没上线就乱了

早期没有独立模块时,我是这样管上下文的:

if len(messages) > 10: messages = messages[-10:]

看起来很简单,但放到真实项目里,问题马上来了。有的接口要保留系统提示词,有的要带上知识库片段,有的要做多轮意图判断。不同路由各自写了一套裁剪逻辑,代码里到处是total_tokens > 7000这种魔法数字。

上线后我根本不敢改,因为改了 A 接口的阈值,可能影响 B 接口的行为;加了新的上下文策略,又要重新梳理所有历史逻辑。更麻烦的是,线上出问题时很难定位究竟是哪一段逻辑把关键信息丢掉的。

所以我才决定做 context-mode,把上下文管理从业务代码里抽出来,变成一套可解释、可配置、可测试的策略层。

2. context-mode 怎么设计:从“删历史”变成“管策略”

既然要抽成独立模块,就不能只做“截断最近几条”这种单一功能。我把它做成了一套策略系统,核心是一组可组合的上下文模式。

2.1 四种模式,覆盖绝大部分对话场景

我先建立了一个表格,把常见对话场景拆开看,最终收敛出四种模式:

模式适用场景核心策略成本特点
strict短对话、单轮问答全部消息原样保留低,但只适合短对话
slide中长对话只保留最近 N 轮中等,常用兜底方案
summary超长对话把旧消息压缩成摘要有额外摘要成本
retrieval知识密集问答从外部知识库检索相关片段需要接入向量库

严格说,这四种模式不是互斥的。比如一个长对话场景,最近几轮用 slide,更早的内容用 summary,遇到知识类问题时再触发 retrieval。关键是让模块有统一的策略入口,而不是每个接口自己搭一套。

2.2 模式不是写死的,是策略路由出来的

有朋友问我,是不是每个对话一开始就要定好用哪种模式?不是。我一开始也这么想过,后来发现用户诉求是动态的:可能前 5 轮在闲聊,第 6 轮突然问一个很具体的知识型问题,这时如果还用滑窗模式,不触发知识库检索,回答质量就会很差。

所以 context-mode 里加了一个简单的路由逻辑,每次调用模型前先做判断:

  1. 如果当前用户问题包含明显的知识查询意图,优先进入 retrieval 模式。
  2. 如果整体 token 数低于 strict 阈值,直接用 strict,不做任何裁剪。
  3. 如果对话轮次超过 slide 阈值,但总 token 还在预算内,用 slide。
  4. 如果 token 预算已经比较紧张,用 summary 把最旧的一部分压成摘要。

这个策略用代码描述并不复杂,但它解决了一个很实际的问题:上下文管理不能靠运营同学手工定死,而要跟着每一次真实请求动态决策。

2.3 两个抽象:ContextPolicy 和 ContextPacker

为了不让策略代码散落在业务里,我抽了两个核心抽象:

  • ContextPolicy:决定当前对话该走哪种模式,以及哪些内容被保留、摘要、检索。
  • ContextPacker:把策略决定的结果拼装成最终发给模型的 messages 数组。

ContextPolicy解决的是“留什么”,ContextPacker解决的是“怎么拼”。两者解耦之后,新增一种模式时不需要动调用方代码,只需要实现一个新的 Policy,再注册到路由里。这个设计让我后续加了不少自定义策略,业务侧几乎零改动。

3. 落地实现:一个能跑的 context-mode 最小版本

说了这么多设计思路,接下来给出一套可以直接跑起来的最小实现。我用 Python 写的,结构很轻,接入 OpenAI 兼容接口时只需要把最终生成的 messages 传进去。

3.1 数据结构和 token 估算

先定义基础结构:

from dataclasses import dataclass, field from typing import Callable, List @dataclass class Message: role: str content: str metadata: dict = field(default_factory=dict) @dataclass class ContextDecision: mode: str messages: List[Message] summary: str = "" retrieved: List[str] = field(default_factory=list)

Message我故意加了一个metadata字段,后面存消息时间、来源渠道、知识库文档 ID 都很方便。metadata虽然不会直接传给模型,但会在策略判断时用上,比如“超过 10 分钟的消息优先压缩”。

token 估算这块,生产环境最好用模型官方的 tokenizer,比如 tiktoken。但为了快速验证,我写了一个启发式估算函数:

def estimate_tokens(text: str) -> int: chinese_chars = sum('\u4e00' <= c <= '\u9fff' for c in text) other_chars = len(text) - chinese_chars return int(chinese_chars * 1.5 + other_chars * 0.3) + 4

中文字符按 1.5 算,英文字符按 0.3 算,再叠加一个 4 token 的基础损耗。这个数字不绝对准确,但足够用来判断“该不该换策略”。

3.2 三行代码实现的滑窗策略

滑动窗口是最常用、也最好理解的策略。我直接取最近 N 条消息,丢掉更早的:

class SlidePolicy: def __init__(self, keep_last_n: int = 12): self.keep_last_n = keep_last_n def decide(self, history: List[Message], budget: int) -> ContextDecision: recent = history[-self.keep_last_n:] return ContextDecision(mode="slide", messages=recent)

实际用的时候有个细节:不能简单按“轮”取,因为用户可能一次发很长的内容,12 轮消息可能已经超过预算。更稳的做法是先按预算倒推,从后往前累加,直到 token 数接近阈值为止。

class BudgetSlidePolicy: def __init__(self, max_tokens: int = 4000): self.max_tokens = max_tokens def decide(self, history: List[Message], budget: int) -> ContextDecision: selected = [] used = 0 for msg in reversed(history): tokens = estimate_tokens(msg.content) if used + tokens > self.max_tokens: break selected.append(msg) used += tokens selected.reverse() return ContextDecision(mode="slide", messages=selected)

这样既能最大化利用上下文预算,又不会因为某条超长消息把整个窗口撑爆。

3.3 摘要策略和知识库策略

摘要策略稍微复杂一点,因为它要调用一次模型或者一个摘要服务。我把摘要函数抽象成可注入的回调,这样在本地测试时可以传一个假的函数,线上再换成真正的 LLM 调用。

class SummaryPolicy: def __init__(self, summarize_fn: Callable[[List[Message]], str], trigger_tokens: int = 5000): self.summarize_fn = summarize_fn self.trigger_tokens = trigger_tokens def decide(self, history: List[Message], budget: int) -> ContextDecision: total = sum(estimate_tokens(m.content) for m in history) if total <= self.trigger_tokens: return ContextDecision(mode="strict", messages=history) split_idx = len(history) // 2 old_part = history[:split_idx] recent_part = history[split_idx:] summary = self.summarize_fn(old_part) return ContextDecision(mode="summary", summary=summary, messages=recent_part)

summarize_fn内部我一般让模型把旧消息压缩成几类信息:用户诉求、已解决事项、待跟进事项、用户的语气或偏好。摘要不是单纯“给原文写简介”,而是要为后续对话保留有用的决策上下文。

知识库策略需要对接向量检索,所以我也用了回调注入:

class RetrievalPolicy: def __init__(self, retriever: Callable[[str], List[str]], top_k: int = 3): self.retriever = retriever self.top_k = top_k def decide(self, history: List[Message], budget: int) -> ContextDecision: query = history[-1].content if history else "" chunks = self.retriever(query)[:self.top_k] recent = history[-4:] return ContextDecision(mode="retrieval", messages=recent, retrieved=chunks)

这里我特意只保留最近 4 条对话,避免知识库片段和历史消息同时占用大量 token。检索片段加多了会让模型抓不住重点,top_k 控制在 3 到 5 之间一般比较合适。

3.4 上下文预算怎么规划:一个可抄作业的例子

整个 context-mode 最容易踩坑的地方,是预算分配。我见过有人直接把 128k 窗口全塞满,结果响应慢、成本高,效果还很一般。我的建议是,不要按“模型最大窗口”分配,而是按“产品可接受成本”分配。

假设我用的是 gpt-4o-mini 这类模型,模型窗口很大,但为了让单次请求成本可控,我给应用设定一个工作窗口 8000 token:

用途预算
system prompt800
输出预留1200
RAG 知识片段2500
历史对话3500

这样算下来,历史对话只有 3500 token 可用。如果每条消息平均 150 token,大约可以放 23 条。实际跑的时候,我还会留出 10% 的安全余量,所以滑窗阈值设置成最近 15 条左右,剩下交给摘要。

这样做的好处是,每次调用模型之前,context-mode 已经用低成本规则算好了“只能给模型看什么”,而不是把压力全部甩给模型。

4. 实测记录与常见问题排查

任何上下文方案上线后都会遇到新问题。我把实际调试中遇到最多的几类问题整理成了一份速查表,后面展开讲。

4.1 摘要模式为什么会把“人设”记住丢了

我最初做摘要时,直接用系统提示词让模型“总结这段对话”,结果效果很不稳定。有个客服场景,用户明明之前说“我比较急,希望尽快处理”,摘要里却没有体现,模型后面回答得像冷冰冰的机器人。

原因是摘要提示词只让模型压缩事实,没有要求它保留用户状态和沟通风格。后来我改成两段式摘要:一段是“事实摘要”,记录用户诉求和处理进度;一段是“状态摘要”,记录用户的情绪、偏好、语气。合并到上下文时,把状态摘要放在更靠近最后一条消息的位置,模型回答就会自然很多。

注意:摘要不能只“缩短文本”,它本质上是一种信息丢失策略。你必须在摘要里显式声明哪些信息必须保留,那些才是产品体验的底线。

4.2 滑窗大小调到几才合适

滑窗太短,用户问一句“我刚才不是说过了吗”,模型就接不上;滑窗太长,上下文预算被普通轮次占满,知识片段和系统提示词放不进去。

我的经验是,先用一个能覆盖“用户在对话中回头确认信息”的最短轮数来测试。对大多数客服场景,最近 8 到 15 轮是一个合理的起步区间。上线后,可以用并行的方式统计:当用户问题里出现“刚才”“之前”“你没听懂吗”等词时,模型是否还记得对应信息。如果经常忘记,就把窗口调大;如果经常回答混乱,就检查是不是历史噪声太多。

更稳的做法是“滑窗 + 摘要兜底”:最近几轮原样保留,更早的内容走摘要。这样既不会让模型完全失忆,也不会让上下文窗口被普通消息占满。

4.3 RAG 片段和实时对话怎么拼才不会打架

在一个文档问答机器人里,我遇到一个典型问题:用户先聊了几句日常问候,上下文里有“今天天气不错”这种内容,然后突然问“退货政策是什么”。如果直接把知识库段落塞进 messages,模型可能会把“退货政策”和前面闲聊强行关联,回答显得怪异。

我的解法是把检索片段和对话历史分开,并且在每个片段前面加上来源标签,让模型知道这是参考资料,而不是用户说的话。

for chunk in decision.retrieved: messages.append({ "role": "system", "content": f"[参考资料] {chunk}" })

同时,我只会对“看起来像在问知识类问题”的最后一轮用户消息触发检索,而不是每一轮都检索。这个判断可以用一个很轻的意图分类器,或者简单用关键词正则。触发条件宁可保守一点,也不要让上下文每轮都被无关段落污染。

4.4 三种模式成本对比:我自己的测试数据

我用一个 20 轮对话模拟了三种模式的效果,固定每轮用户输入 300 token、模型输出 200 token,比较第 20 轮请求的实际输入量:

模式第 20 轮输入 token相对成本回答连贯性
strict 全部保留6000100%高,但会爆窗
slide 保留最近 6 轮180030%中,可能丢早前信息
slide + summary120020%较高,摘要质量决定上限

如果加上 RAG,第 20 轮通常是“历史对话 1000 + 检索片段 900”,整体输入大约 1900 token,成本不到 strict 的三分之一,但回答质量取决于检索命中率。

这个表告诉我们一个规律:context-mode 本身不会提升模型能力,它的价值是把 token 预算花在刀刃上。过分追求“零成本”会让模型变成没有记忆的应答机,得不偿失。

5. 一些后续想说的经验

项目做到后期,我越来越觉得 context-mode 更像一套“上下文治理”的理念,而不是一个固定功能的库。下面这几点是我实际体验中最有价值的经验。

5.1 给不同业务做纵深优化,别想一个模式打天下

最早我想把上下文管理做成一个通用中间件,所有接口共用一套策略。后来发现不同业务差别很大:客服场景需要保留用户身份和订单状态,内容创作场景需要保留风格和历史段落,知识问答场景需要高频检索。

现在我的做法是,context-mode 提供统一的路由和打包机制,但每个业务可以注册自己的 Policy,并自定义摘要指令和检索触发条件。这样既保持了代码层面的统一,又不牺牲业务效果。

5.2 接入可观测性,不然线上出了问题全是玄学

上下文管理是典型的“看不见摸不着”的模块,用户觉得回答不对劲,但你很难说是模型问题还是上下文问题。因此我给每次请求都记录了策略标签、token 估算值、丢弃的消息条数、摘要内容,以及触发路由的原因。

上线后我经常做一件事:随机抽一批线上对话,回放它们的决策记录。如果某类问题频繁出现,我就顺着决策记录看是滑窗切太多、摘要丢失信息,还是检索片段选错了。这套可观测性带来的收益,比优化算法本身还大。

5.3 后续扩展方向

我目前在做的一个扩展是“基于用户意图和任务类型的动态预算调整”。比如用户只是问“几点营业”,就不需要给 8000 token 的预算;但如果是写方案、做分析,预算可以放宽。另一个方向是给摘要策略加自动评估,用模型打分判断摘要是否保留了关键信息,分数低就触发重建。

context-mode 从最开始的十几行脚本,长成了现在包含策略路由、预算管理、可观测性的完整模块,过程中最大的体会是:LLM 应用的瓶颈,很多时候不在模型本身,而在于我们怎么管理给模型看的东西。希望这套思路也能帮到你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询