LLM上下文管理实战:从Token计算到多轮对话记忆
发布时间:2026/10/7 1:55:05 作者:尧图编辑部 阅读量:1,286

去年年中我接手了一个智能客服项目的性能优化用户反馈最多的问题不是答得不对而是“聊着聊着就忘了之前说过什么”。明明模型本身能力很强可一旦进入多轮对话它就像喝了失忆药水顾客半小时前报过的订单号转头就“抱歉您能再提供一次吗”。排查了半天问题不在模型而在我写的业务代码——每次调用都只把用户最新一句话丢给模型完全没有接入和利用好 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_keyyour-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( modelgpt-4o-mini, messagesmessages, temperature0.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(hostlocalhost, port6379, db0) SESSION_TTL 60 * 30 # 30 分钟无操作自动过期 def get_session_history(session_id: str) - list[dict]: 从 Redis 取历史消息 raw r.get(fctx:{session_id}) if raw: return json.loads(raw) return [] def update_session_history(session_id: str, messages: list[dict]) - None: 写回历史 r.setex(fctx:{session_id}, SESSION_TTL, json.dumps(messages)) def reset_session(session_id: str) - None: 主动清空会话 r.delete(fctx:{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 消息放在会话列表里。我在一个合同评审项目里就这么干的。流程是历史 Token 超过设定阈值比如 8000 Token。取出最早的几轮对话拼成一个“准备被压缩”的消息块。调用一次模型指令是“把以下对话压缩成简洁摘要保留关键实体、时间、结论。”把摘要作为一个 rolesystem 的合成消息或 roleassistant 的带标记消息插回历史头部替换掉原始早期消息。核心代码逻辑如下# 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_keyyour-api-key) resp client.chat.completions.create( modelgpt-4o-mini, messages[ {role: system, content: SUMMARY_SYSTEM}, {role: user, content: prompt} ], temperature0.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 Token610%关键信息召回率35%95%171%首字响应时间0.8s1.2s0.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 是你手里那根最重要的平衡杆。慢慢调总能找到最适合你业务的节奏。