☰
LLM上下文管理实战:从Token计算到多轮对话记忆
2026/10/7 1:54:51 网站建设 项目流程

去年年中我接手了一个智能客服项目的性能优化,用户反馈最多的问题不是答得不对,而是“聊着聊着就忘了之前说过什么”。明明模型本身能力很强,可一旦进入多轮对话,它就像喝了失忆药水,顾客半小时前报过的订单号,转头就“抱歉,您能再提供一次吗”。排查了半天,问题不在模型,而在我写的业务代码——每次调用都只把用户最新一句话丢给模型,完全没有接入和利用好 context-mode(上下文模式)这件事。

这篇文章就是把这半年里折腾上下文管理的经验完整捋一遍,从底层原理到配置路径,从Token计算到踩坑实录。适合正在做LLM应用开发、尤其是涉及多轮对话、Agent、工具调用的同学参考,前端后端都适用,另外也给那些想搞懂“上下文到底是怎么被喂给模型的”的非工程师朋友留了一条入门路径。先说明,这里的 context-mode 我理解为“上下文管理模式”,它不是一个固定的产品按钮,而是一套从 API 参数到应用层策略的工程组合拳。

1. context-mode 到底是什么:从一次对话失忆事故说起

1.1 事故现场:顾客以为AI“笨”,其实是没上下文

那个客服项目接入的是通用大模型API,上线第一个月数据挺好看,到了第二个月差评开始冒出来。典型场景是这样的:

顾客说:“我上周买了你们家那个降噪耳机,型号是 WH-1000XM5,收到之后发现右耳有电流声,能换吗?”

模型第一次回答得很好:先道歉,再确认订单信息,然后给出换货流程。

顾客接着问:“那如果我换成黑色的,大概要等多久?”

结果模型直接愣住了,回答“请问您说的黑色是指哪款产品呢?”

顾客当时就炸了——我上一句话刚说过型号啊。但实际上,如果开发者在调用API时只把“那如果我换成黑色的,大概要等多久”这句话传给模型,它确实什么都不知道。这不是模型的错,是我的代码在“裸奔”。

LLM本身是无状态的。每次 API 调用都是一次独立的推理过程,模型睁眼就是新世界。你希望它“记得”什么东西,唯一的办法就是每次请求时,把需要记住的东西全部塞进输入里。context-mode 的核心,就是决定每一次请求里到底要塞什么、塞多少、以什么结构塞。

1.2 上下文模式的职责边界:它不是魔法,是工程

我见过不少团队一开始把 context-mode 理解成“模型自带记忆”,这是最大的误区。实际上它可以拆成三个层面:

  • 上下文构建:把系统指令、历史对话、工具返回结果、当前用户输入,按正确顺序和格式组装成 messages 数组。
  • 上下文控制:决定窗口满了之后怎么办——是截断最老的对话,还是压缩成摘要,还是从向量库检索相关记忆。
  • 上下文生命周期管理:区分多用户、多会话的上下文隔离,防止串号,保证时效性。

说直白一点,context-mode 就是“你替模型经营一段记忆”,模型本身没有硬盘,你给它看什么它就只知道什么。这套经营策略,才是上下文模式这个项目的核心资产。

1.3 为什么“上下文模式”在2025年忽然成了热门词

这两年大家突然集中讨论 context-mode,背后有三个推动力。

第一,工具调用和 Agent 变得复杂。一个 Agent 可能要在一次任务里调用三四次外部工具,每次工具的返回结果都要放进上下文,不管理就等于丢数据。第二,大上下文窗口的出现反而制造了新问题。以前窗口只有4K Token,大家被迫精打细算;现在动不动 128K、200K,反而出现了“全都往里扔”的摆烂心态,成本暴涨不说,模型还会在超长上下文里“迷失”——我们圈子里管这叫 Locating in the Middle(中间丢失现象),也就是模型对长输入的开头和结尾记忆深刻,中间一大段内容关注度明显下降。第三,各家云厂商开始把上下文管理产品化,比如 OpenAI 就提供过上下文重写(Context Rewriting)能力,模型自动把冗长历史改写成精简摘要再传递。这说明从底层到应用层,大家都在为“上下文怎么管”这件事找更优解。

了解完背景,下面我先把上下文窗口内部的运转机制讲透,这是后续所有策略的地基。

2. 上下文窗口的物理边界:Token 计算与分配原理

2.1 窗口不是橡皮筋:超了就报错,满了就“迷路”

很多刚上手的朋友以为窗口大就是无限大,其实上下文窗口是模型能够处理的最大 Token 数,超过直接报错(常见的有 context_length_exceeded 这类错误)。即便没超,模型面对过长的输入也可能代价高昂。

我用一个生活化的类比来解释:上下文窗口就像一张办公桌。你桌面空间有限,不可能把所有文件一次性铺开,只能放最需要的几份。系统提示词是你钉在桌上的便签,历史对话是处理中的人事档案,工具返回值是刚送来的快递单,而用户当前的输入是你正在写的这封回信。桌子上堆的东西越满,你找东西越慢,越容易翻不到底层那份文件。“窗口越大越好”这个直觉,在工程上站不住脚。

2.2 精确估算 Token:中英文的算法完全不同

要设计上下文策略,第一步是把 Token 估算做准。模型计费、窗口控制都基于 Token,而不是字符数。这里有一个粗糙但实用的经验系数:

  • 纯英文:1 个 Token 约等于 4 个字符(0.75 个单词左右)。
  • 纯中文:通常 1 个汉字约等于 1.5 到 2 个 Token,具体取决于分词器。
  • 中英混合、代码、JSON:浮动很大,JSON 的结构符号非常吃 Token。

我平时在项目里会写一个快速估算的小工具,不需要精确到个位,但心里大概有数。比如用 Python 写一个简易版:

# context_tools.py def estimate_tokens(text: str, lang: str = "mixed") -> int: """ 粗略估算文本的 Token 数。 注意:这只是工程估算,不是分词器的精确结果。 """ if not text: return 0 chars = len(text) if lang == "zh": # 中文场景:每汉字约 1.6 token return int(chars * 1.6) elif lang == "en": return int(chars / 4.0) else: # 混合场景:按保守系数 1.2 估算 return int(chars * 1.2) print(estimate_tokens("帮我查一下这个月的销售数据", "zh")) # 输出: 25

真正上线前,我会用模型自带的 tokenizer(比如 tiktoken)跑一次离线校准,把上面这些经验系数修正成适合当前业务的分词特征。这个工作一定要在写上下文管理逻辑之前做,不然后面阈值全是拍脑袋。

2.3 窗口里的五个区域:别让系统提示词喧宾夺主

我把一个完整的上下文请求拆成五个区域,顺序固定:

区域内容优先级典型 Token 量级
系统指令角色设定、行为规范高,必须常驻200-1000
工具定义函数的 JSON Schema 描述高,随需求注入500-2000
历史对话之前的 user/assistant 往返中,可裁剪动态
工具返回最近一次工具调用的结果高,及时性最强动态
当前输入用户此刻的问题最高50-500

这里有一个特别容易踩的坑:系统指令写得太详细太长,导致历史对话没地方放。我见过有人把系统提示词写到 4000 Token 的“作文”,结果用户聊到第三轮就触发上下文截断。系统提示词的黄金区间是 300-800 Token,写清楚角色、输出格式、禁忌即可,细节放业务代码里去判断,而不是全堆给模型。工具定义也很占空间,SSE 流式请求时尤其明显。如果你的 Agent 挂了一堆工具,请按“本次会话可能用到的”动态注入,不要一股脑把所有工具的 Schema 都塞进去,这一条能直接砍掉 30% 以上的 Token 消耗。

理解窗口之后,我们才能真正去“配置” context-mode。

3. 正确开启 context-mode 的完整配置路径

3.1 第一步:在 API 层把消息结构建对

当前主流 API 的通用事实标准是 messages 数组,角色分为 system、user、assistant、tool。没有“开一个开关”那么简单,所谓的“开启上下文模式”,本质是把每一次调用都按照正确结构构建消息。先看一段最基础的配置代码:

# chat_with_context.py from openai import OpenAI client = OpenAI(api_key="your-api-key") system_prompt = "你是一名资深客服专员,回答简洁、专业,始终关注用户诉求。" # 模拟两轮历史对话 conversation_history = [ {"role": "user", "content": "我上周买了 WH-1000XM5 降噪耳机,右耳有电流声,能换吗?"}, {"role": "assistant", "content": "非常抱歉给您带来不便,这款耳机支持 7 天无理由换货。请提供订单号,我帮您登记换货申请。"}, {"role": "user", "content": "订单号是 JD882341,麻烦你了。"}, {"role": "assistant", "content": "已查到您的订单,换货流程已经发起,预计 3 个工作日内审核完成。"}, ] # 当前用户输入 current_input = "那如果我换成黑色的,大概要等多久?" messages = [ {"role": "system", "content": system_prompt}, ] + conversation_history + [ {"role": "user", "content": current_input} ] response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, temperature=0.3, ) print(response.choices[0].message.content)

看到关键点了吗?conversation_history是每次都要传的历史。context-mode 的“记住上下文”,在 API 层就是靠不断累积 messages 数组实现的。如果历史清了,记忆就清了,没有任何隐含的状态服务器帮你记着。

但这又带来第二个问题:对话一直持续,历史无限膨胀,总会有塞不下的一天。所以应用层必须做状态管理。

3.2 第二步:在应用层用会话 ID 管理上下文状态

不要小看这一步,它才是 context-mode 的最核心工程部分。我的做法是引入 Redis 来存会话上下文,以 session_id 为 key,每次请求时取出、组装、再写回。

# session_manager.py import redis import json r = redis.Redis(host="localhost", port=6379, db=0) SESSION_TTL = 60 * 30 # 30 分钟无操作自动过期 def get_session_history(session_id: str) -> list[dict]: """从 Redis 取历史消息""" raw = r.get(f"ctx:{session_id}") if raw: return json.loads(raw) return [] def update_session_history(session_id: str, messages: list[dict]) -> None: """写回历史""" r.setex(f"ctx:{session_id}", SESSION_TTL, json.dumps(messages)) def reset_session(session_id: str) -> None: """主动清空会话""" r.delete(f"ctx:{session_id}")

这只是一个雏形,真正的生产级方案还要考虑历史交接(跨终端续聊)、超时策略、以及一个人多会话的并发并发问题。这里我强烈建议不要把上下文管理逻辑散落在业务接口里,而是抽成独立的 ContextManager 服务类,所有需要上下文的业务都调它。半个小时后我讲到的那些坑,大部分源于上下文代码到处都是、没有收敛。

3.3 第三步:结合不同平台的上下文特性做适配

不同模型厂商对上下文有自己的约束和额外能力。OpenAI 走的是 messages 标准,并在新模型上支持 Context Rewriting(自动将早期冗长历史改写成摘要)。Claude 的 API 大体兼容 messages 结构,但它的 System Prompt 是独立字段(英文叫 system),不是塞进 messages 数组里的。国内模型比如 Qwen 系列,有些兼容 OpenAI 格式,有些需要自己拼接历史字符串。

我的个人习惯是:在 ContextManager 里面封装一层适配器,把 Redis 里存的消息结构统一成通用格式,再通过适配器转换成各个模型 API 需要的格式。这样换模型厂商时,只改适配层,而不动业务代码和缓存结构。真实项目里换模型是常态,提前抽象一层,后期能省很多事。

配置跑通之后,接下来面临的是这个模式最硬核的问题——上下文放不下了怎么办。

4. 多轮对话中的上下文策略:截断、压缩与持久化

4.1 滑动截断:简单粗暴,但会丢关键信息

最常见的策略是滑动窗口。只保留最近 N 轮对话,超出部分直接丢弃。实现起来很简单:

# context_truncate.py MAX_HISTORY_ROUNDS = 8 # 保留最近 8 轮 user/assistant 往返 def truncate_history(history: list[dict]) -> list[dict]: if len(history) <= MAX_HISTORY_ROUNDS * 2: return history # 注意保留 system 消息(假设 history[0] 可能是 system) start = len(history) - MAX_HISTORY_ROUNDS * 2 return history[start:]

优点是小项目跑起来省心,缺点也很明显——早期关键信息会凭空消失。比如客服场景里用户第一轮就报了订单号,如果那段被滑动窗口截掉了,后面模型再聪明也猜不出来。所以实际项目中我不推荐只用裸截断,最好配合下面的摘要方案一起用。

4.2 摘要压缩:用一次模型调用换一段“记忆浓缩”

摘要压缩是目前性价比最高的方案。思路是:当历史超过阈值时,把最早的几段对话让模型压缩成一段摘要,把摘要作为一条 synthetic 消息放在会话列表里。

我在一个合同评审项目里就这么干的。流程是:

  1. 历史 Token 超过设定阈值(比如 8000 Token)。
  2. 取出最早的几轮对话,拼成一个“准备被压缩”的消息块。
  3. 调用一次模型,指令是:“把以下对话压缩成简洁摘要,保留关键实体、时间、结论。”
  4. 把摘要作为一个 role=system 的合成消息(或 role=assistant 的带标记消息)插回历史头部,替换掉原始早期消息。

核心代码逻辑如下:

# compress_history.py SUMMARY_SYSTEM = "将用户和助手的对话压缩成中文摘要,保留关键人物、订单号、时间、结论。控制在150字内。" def compress_early_history(history: list[dict]) -> list[dict]: if len(history) <= 10: return history early = history[:6] # 最早的6条消息 rest = history[6:] # 其余保留 prompt = "" for msg in early: role = "用户" if msg["role"] == "user" else "助手" prompt += f"{role}: {msg['content']}\n" client = OpenAI(api_key="your-api-key") resp = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": SUMMARY_SYSTEM}, {"role": "user", "content": prompt} ], temperature=0.2 ) summary_msg = {"role": "system", "content": f"[历史摘要] {resp.choices[0].message.content}"} return [summary_msg] + rest

这套机制的效果很直观:把 20 轮对话压到一条 150 字的摘要 Token,能让整个窗口的可用空间多出 50% 以上。代价是每遇到一次压缩就多一次模型调用,注意控制触发频率,别每轮都压。

4.3 向量持久化与检索式上下文:把记忆变成“查档案”

如果业务对早期记忆的要求非常高,滑动窗口和摘要都撑不住,那就得上检索式方案。思路是把每一轮历史对话向量化后存入向量数据库(比如 Chroma、Pinecone 或者 Redis 自带的 Vector Set),当用户发起新请求时,先用相似度检索找出与当前问题最相关的历史片段,只注入命中片段作为上下文。

这一步本质上做的是“记忆召回”,和 RAG 知识库检索是两回事——RAG 检索的是外部知识,这里检索的是对话自身的记忆。我做过一次完整实现:

  • 每轮对话结束后,把该轮 user 和 assistant 的内容拼接成文本块,调用 embedding 模型生成向量。
  • 存储时带上 session_id、timestamp、message_id 元数据。
  • 新请求到达时,先用当前 user 输入做向量检索,取 top-3 命中,注入到 messages 数组里。

这套方案的 Token 效率最高,但也有额外工程成本:多一个向量化调用,多一套存储,还要做好误召回管理。召回错误的旧记忆,比没有记忆更灾难性。我的建议是先用摘要压缩,场景复杂度上来之后再加向量检索。

策略层说完,下面用一组实测数据让大家直观感受上下文模式开启前后的差距。

5. 实测对比:开启前后 Token 消耗与响应质量变化

5.1 测试场景设计:同一段对话,两种处理方式

我用一个模拟跨境电商客服的用例,固定了 10 轮对话,内容包含订单号、产品型号、退货原因、换货颜色、预计时效等 5 个关键信息点。在两种模式下分别跑 20 次测试:

  • 裸模式(无上下文管理):每次只传当前用户输入,不传历史。
  • context-mode 完整流程:系统提示词 + 最近 3 轮历史 + 压缩摘要 + 当前输入。

记录三个指标:单轮平均 Token 消耗、关键信息召回率(模型答对第 1 轮订单号的概率)、首字响应时间。

5.2 实测数据与结果总结

指标裸模式context-mode变化
平均单轮 Token 消耗58 Token412 Token+610%
关键信息召回率35%95%+171%
首字响应时间0.8s1.2s+0.4s
用户发火概率(体感)高低大幅下降

有的朋友看到 Token 消耗涨了 6 倍可能会慌。但实际上,裸模式下一次答错导致用户重发、重问的消耗才是大头。我算过一笔账:裸模式 10 轮对话消耗总共约 580 Token,但中途经常需要用户重复说明导致轮数翻倍,最终反而更贵;context-mode 多消耗的 Token 换来了有记忆的连续对话,业务转化率高了不止一个层级。

5.3 复盘:这个数据告诉我们什么

第一,上下文管理是用 Token 换准确率,账要算清楚,不能只看单轮成本。如果模型答错一次需要用户重发一次,一次重发的 Token 成本足够覆盖三四轮的上下文开销了。第二,响应时间延迟 0.4 秒在客服场景完全可以接受,假如你做了摘要压缩而不是历史全量塞入,延迟差别会更小。第三,95% 的关键信息召回率来自“系统提示词 + 摘要 + 最近历史”三件套的组合,缺任何一块都达不到这个成绩。

数据好看不代表没有坑。下面这些坑,都是我一行行代码、一个个夜班填过来的。

6. 我踩过的三个上下文相关的大坑

6.1 只给模型喂最后一条消息:新手最常犯的“裸奔”问题

第一个坑我在第 1 节已经铺垫过了,但这里还是想说完整。当时我一个同事优化的接口,代码里直接把用户提交的内容塞进 messages,历史完全没传。结果模型连用户在第一轮提供的用户名都要反问。排查过程很简单——打印一下实际发送给 API 的 messages 数组结构,问题一目了然。

修法也更简单,就是把历史从 Redis 取出来拼上去。但这件事给我留下一个教训:上下文模式不是默认开启的能力,必须显式实现。调试任何一个 LLM 应用时,第一件事永远是打印你实际发了什么给模型。与其猜模型为什么不记得,不如看看自己到底给模型看了什么。

6.2 系统提示词写成了“小作文”,挤崩了整个上下文

第二个坑是我自己踩的。早期做电商智能导购,我把系统提示词写到了将近 3000 字,包括品牌调性、话术模板、违禁词列表、售后规则、促销活动规则,恨不得把运营手册全塞进去。结果上线后用户只要聊到第五轮,历史就被截断的只剩下两轮,模型频繁“失忆”。

后来我把系统提示词精简到 400 字,大部分固定规则挪到业务代码里用逻辑判断,系统提示词只保留角色、语气、关键输出格式。同时这些高频调用的知识,我是通过 RAG 检索按需注入的,而不是常驻系统提示词。一句话经验:系统提示词是“岗位职责说明书”,不是“百科全书”。

6.3 多用户共用上下文缓存导致“串号”

第三个坑是在一个 To B 项目里,我们给每个商家做客服机器人。刚开始用 Redis 存上下文,key 只用了商家 ID,结果商家 A 的顾客问完售后,商家 B 的顾客紧接着提问,模型居然还记得上一个顾客的发货地址。上线第二天就被客户投诉“数据泄露”,吓得我们立刻排查。

根因是上下文 key 设计缺少隔离维度——当时只想“一个商家一个机器人”,但没考虑“一个商家有多个顾客会话”。修复方案是给 key 加上 session_id 维度,同时调整过期策略,按会话维度存储上下文。这里也提醒所有做多租户和多人并发场景的朋友,上下文隔离是第一优先级,至少用 session_id 区分单次会话,必要时再叠加 user_id、tenant_id,隔离维度的设计要一开始就想清楚,不然后期改造成本极高。

后来我又在项目里加了一个小能力:每次请求响应后,会把用户给回答的点赞/踩反馈也记录进摘要数据里。下次同类问题出现时,模型会优先采用上次被点赞的回答风格。这个“偏好记忆”让客服满意度在一个季度里又提升了近十个百分点。上下文模式的想象力不止于“记得你说过什么”,更在于“记得你喜欢什么”。

最后分享一个我个人的实操体会:上下文管理这件事,永远不要想着“一次性到位”。我一开始想着把所有方案全部上齐,系统复杂到自己都维护不动。后来把上下文抽象成独立的服务,再配合开关配置——先跑截断,再上摘要,最后按需加向量。每个阶段都灰度验证之后再往前推。LLM 应用开发本来就是在成本、延迟、记忆三者的钢丝上跳舞,而 context-mode 是你手里那根最重要的平衡杆。慢慢调,总能找到最适合你业务的节奏。

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

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

立即咨询