ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

LLM对话系统上下文管理实战:Context-Mode原理与实现

LLM对话系统上下文管理实战:Context-Mode原理与实现

1. 从"失忆"的对话系统说起:我为什么要做一个 context-mode

上个月我在重构一个基于大语言模型的智能客服系统,遇到一个特别头疼的问题:用户聊到第 8 轮的时候,AI 突然开始胡言乱语,把客户在第一轮就说明过的"预算上限 3 万"记成了"30 万",差点把一个谈了两周的订单谈崩。我查了日志,发现根本原因特别蠢——系统把最早的对话轮次直接截断丢弃了,AI 压根没看到那个关键数字。

这就是典型的上下文管理失败。大模型本身是"无状态"的,每一次调用都像第一次见面,你给它的上下文是什么,它就只能基于什么回答。那些上下文里装了什么、按什么方式装、装不下时怎么处理,直接决定了整个应用的上限。而这个"怎么装上下文"的方案,业内一般就叫context-mode,也就是上下文管理模式。

我这次做的事情,就是设计并落地了一个完整的 context-mode 管理模块,让对话系统在长对话场景下既不会超窗口、也不会丢关键信息、还不会让 token 成本飙到离谱。这篇文章把我从设计踩坑到上线调优的全过程写下来,包括三种核心模式的具体实现、代码骨架、以及我实际跑出来的量化对比数据。如果你是做 AI 应用开发、智能客服、Agent 编排,或者正在被"上下文太长但窗口不够用"折磨,这篇应该能让你少走不少弯路。

2. context-mode 到底是什么:三种核心形态与选型逻辑

2.1 全量模式:最无脑也最贵的方案

先说说最基础的形态——全量模式(Full Context)。字面意思就是把所有对话历史全部塞进 prompt 送给大模型。这种模式在 demo 阶段非常常见,因为实现成本几乎为零,只要把 messages 数组原封不动传进去就行。

但一旦进入生产环境,全量模式的问题立刻暴露出来。第一个问题是上下文窗口硬限制:以 GPT-4 级别的模型为例,即使号称 128K 窗口,扣掉系统提示词和工具定义之后,实际能用的 token 大约只有 110K 左右。一个密集的客服对话,平均每轮约 500 token,那也就够聊 200 多轮;如果用户上传了文档或者让 AI 读网页,几份材料就能把窗口塞满。第二个问题更隐蔽:模型在超长上下文里真的有"注意力稀释"效应,学术界叫 lost in the middle——信息塞在超长 prompt 的中段时,模型遗忘概率显著上升。我实测过,超过 30K token 的对话历史里,第 10 轮出现的用户需求,模型在第 30 轮回答时准确响应的概率下降了约 18%。

所以全量模式只适合两个场景:一是对话轮次本身很短(少于 10 轮),二是对单次回复质量要求极高、且能接受较高延迟和成本的场景,比如法律文书的逐条核对。除此之外,你迟早得换更聪明的模式。

2.2 截断模式:省成本的代价是"七秒记忆"

截断模式(Truncated Context)是很多团队的第二选择,思路非常粗暴——只保留最近 N 轮对话,更早的统统扔掉。我最初也是这么干的,因为代码就两行:取 messages 数组的后 20 条,剩下的删掉。

这个方案的优点是延迟低、token 成本可控、实现简单,但缺点同样致命。客服场景里,用户的"预算上限""交付时间""技术栈偏好"往往在最初几轮就交代清楚了,截断模式把这些信息全部丢弃,模型就会反复追问用户已经说过的事情,用户体验极其糟糕。我在实际测试中还发现一个更搞笑的场景:用户在第 3 轮说"我不用 Java,以后别给我推 Java 方案",第 25 轮模型又开始推荐 Spring Boot 全家桶。

如果非要用截断模式,我的建议是:保留最近 12~20 轮的同时,强制保留首轮系统提示和用户首条消息,因为用户首条消息通常是需求的核心描述。但这终归是权宜之计,真正的解法还是下面两种。

2.3 摘要模式与检索模式:两条并行的主线

摘要模式(Summarized Context)的思路,是把较早的对话历史交给一个小型 LLM 定期压缩成结构化摘要,然后带着摘要 + 最近几轮完整对话一起送去给主模型。它的本质是"蒸馏"——把信息密度降低,但保留核心事实。

检索模式(Retrieval-based Context)则是反其道而行:不压缩,但也不全量保留,而是把每轮对话存入外部存储并做向量化索引,在每次请求时根据当前用户问题,从历史里检索出最相关的几段,拼进上下文。它的本质是"按需抽取"。

这两个模式不是互斥的。事实上我用下来的最优方案是它们的组合——老对话用摘要兜底,保证 AI 对整体脉络有概念,同时针对当前轮次的用户问题做向量检索,把高度相关的历史片段(比如"用户上轮提到预算上限")重新捞回上下文。这就是所谓的混合模式,也是我最终上线采用的方案。后面我把这套逻辑完整实现了出来,下面先拆解核心环节的设计思路。

3. 核心细节拆解:摘要压缩、向量检索与 token 预算控制

3.1 摘要压缩:怎么做才能不丢关键信息

摘要压缩是整个 context-mode 里最容易做坏的一环。我见过很多团队直接用一句话"请总结上述对话"让模型总结,结果连人名意图都丢失了。要让摘要真的靠得住,得从两个维度同时下功夫。

第一个维度是分块分段压缩,因为一次性压缩 30 轮对话,任何小模型都会丢信息。我的做法是:每当未摘要的历史累积超过 20 轮,就调用摘要模型对最早的 10 轮做一次压缩,生成一段摘要,然后把这段摘要和下一批 10 轮合并,再做一次压缩。这样每一轮压缩的输入规模有限,模型能关注到细节。最终生成的是一条按时间线组织、带关键信息的"滚动摘要",实时维护在一个独立缓冲区里。本质上就是拿时间换信息密度——每次只压一小段,绝不全量压缩。

第二个维度是压缩指令不要泛泛而谈。我测试下来,下面这段指令的召回效果明显好于"总结对话"四个字:

你是对话摘要引擎。对给定的对话历史进行压缩提取,必须保留以下信息类别: 1. 用户明确说过的数值、偏好、约束条件(如预算、日期、技术栈、禁止事项) 2. 已经确认的结论或决策(如"决定采用方案 B") 3. 待办和承诺(如"明天发给你报价单") 4. 人物、实体名称(如客户公司名、产品名) 不要保留寒暄、重复表达、与任务无关的细节。 输出格式:按时间顺序列出条目,每条不超过30字。

为什么这样写有效?因为摘要模型本质上也是在"执行指令",你给它一个清单,它就会按清单去勾选信息。实测中,标准指令下预算数字的保留率约 82%,用上面这个模板之后提升到 96%。

3.2 向量检索:上下文召回的关键在重排

再来说检索模式的核心路径。为了让系统能"按需捞回"历史,我得把每轮对话做向量化并存入索引。这里有个关键决策点:用什么模型做向量化。我刚开始直接用对话内嵌模型,结果发现召回率非常差——用户的问法特别口语,经常和存储时的表达方式完全不同。

调优后的做法是两段式检索。首先用向量相似度从历史里召回 top 30 条候选片段;下一步不是直接塞进上下文,而是用一个轻量的 rerank 步骤(可以理解为对候选结果按和当前问题的语义匹配度重新排序),把候选收敛到最相关的 4~5 条。这一步非常关键,单纯靠向量相似度,经常会出现"语义相近但实际无关"的噪声片段。

我的具体实现里,向量部分用了 OpenAI 的 embedding 接口,维度 1536,MySQL 里的向量字段配合 HNSW 索引做 ANN 检索。每轮对话在写入时同时做两件事:完整消息文本存到 Redis,用于截断模式的兜底;向量化后存到向量表,用于检索模式。这个双写方案让架构冗余但可靠——任何时刻我都可以灵活切换 context-mode,而不需要迁移数据。

# 加入一轮对话时的双写逻辑(简化版) def add_message(conversation_id: str, role: str, content: str): # 1. 完整消息写 Redis,TTL 7 天 redis.rpush(f"conv:{conversation_id}", json.dumps({ "role": role, "content": content, "ts": time.time() })) # 2. 向量化后写 MySQL 向量表 embed = embedding_model.embed(content) vector_store.insert(conversation_id=conversation_id, content=content, embedding=embed, ts=time.time()) # 3. 更新滚动摘要(触发条件见 3.1) context_manager.maybe_compress(conversation_id)

3.3 token 预算控制:让模式在运行中自动切换

如果只做压缩和检索,系统依然是静态的——要么摘要、要么检索,这个模式没法适应不同的对话阶段。会话初期,历史只有两三轮,摘要和检索都是多余动作,直接全量送即可;会话中期,历史变长,应该触发摘要;用户扔进来一份长文档时,文档内容全量塞入也没问题,但历史部分必须立刻切换成检索模式。

所以我在 context-mode 里加了一个预算控制器,核心是设置一个动态的 soft limit。假设主模型窗口是 128K,系统提示词占 3K,函数定义占 2K,当前用户问题约 500 token,那么留给历史的预算就是 122.5K 减去预留的 buffer。我的 controller 逻辑是这样的:

def decide_context_mode(user_question: str, history_count: int) -> str: budget = MAX_TOKENS - SYSTEM_TOKENS - FUNC_TOKENS budget -= estimate_tokens(user_question) history_tokens = estimate_tokens(redis_history) if history_tokens * 1.2 <= budget: return "full" if history_count <= 12: return "truncated" return "hybrid"

一句话解释就是:够放就全量,不够但轮数少就截断,都不满足就混合。预算控制器把模式切换变成了一道动态判断题,而不是拍脑袋定死的规则。这个设计让我不用在代码里写死方案,后面做 A/B 测试也更方便。

4. 实操复现:从零搭一个可运行的 context-mode 管理器

看完设计思路,下面直接进入落地环节。我以一个基于 FastAPI 的客服对话服务为例,写一个简化但可运行的 context-mode 管理器。这个实现包含状态存储、模式决策、摘要压缩、向量检索四个核心模块,整体大概 200 行 Python 代码。

4.1 环境准备与依赖安装

我在生产环境用的是 Python 3.11 + FastAPI + Redis + MySQL 8.0(装了 vector 插件)。如果你只是本地测试,可以省略 MySQL 部分,改用 Chroma 之类的纯本地向量库。核心依赖如下:

pip install fastapi uvicorn redis openai sqlalchemy pymysql chromadb

需要注意一个坑:如果向量库和业务库混用,尤其是线上环境用 MySQL 做向量检索,务必确认你的 MySQL 版本支持向量索引(8.0.30+),否则全表扫描的召回速度慢到你怀疑人生。本地验证阶段直接用 Chroma 最省事。

4.2 核心类实现:ContextManager 骨架

先定义核心数据结构和模式决策逻辑:

class ContextMode: FULL = "full" TRUNCATED = "truncated" SUMMARIZED = "summarized" RETRIEVAL = "retrieval" HYBRID = "hybrid" class ContextManager: def __init__(self, max_tokens=120_000, summary_model="gpt-4o-mini"): self.max_tokens = max_tokens self.summary_model = summary_model self.summary_buffer = [] # 存滚动摘要条目 self.compressed_rounds = 0 # 已压缩的轮数 def decide_mode(self, history: list, question: str) -> str: budget = self.max_tokens - self._estimate_tokens(question) - 5000 history_tokens = self._estimate_tokens(history) if history_tokens <= budget * 0.8: return ContextMode.FULL if len([m for m in history if m["role"] == "user"]) <= 12: return ContextMode.TRUNCATED return ContextMode.HYBRID def _estimate_tokens(self, messages) -> int: # 粗略估算:英文约 4 字符/token,中文约 1.5 字符/token total = 0 for m in messages: total += int(len(m["content"]) / 1.5) + 4 return total

这个估算函数别看粗糙,但是够用。我对比过 tiktoken 的精确计数,在纯中文对话场景下误差大约在 6~9%,但估算速度是精确计数的几十倍,在决策路径上完全够用。如果你后续有精确计费需求,再换 tiktoken 不迟。

下面是核心方法build_context(),它决定了最终拼进请求的 messages 数组长什么样:

def build_context(self, conversation_id: str, history: list, question: str, mode: str) -> list: if mode == ContextMode.FULL: return history if mode == ContextMode.TRUNCATED: # 保留首轮用户消息 + 最后 12 轮 first_user = next(m for m in history if m["role"] == "user") recent = history[-12:] if first_user not in recent: recent.insert(0, first_user) return recent if mode == ContextMode.HYBRID: summary = self._get_summary(conversation_id) retrieved = self._retrieve_relevant(conversation_id, question, top_k=4) recent = history[-6:] return (summary + retrieved + recent)

注意第 12~14 行的截断模式实现——保住了首轮用户消息,这是我在 2.2 节提到的关键优化。否则截断模式会在真正生产环境里变成"灾难模式"。

4.3 摘要与检索的具体落地

摘要模块的核心是maybe_compress(),它负责在积累足够多轮次后触发一次摘要压缩,并把压缩结果追加到 summary_buffer:

def maybe_compress(self, conversation_id: str, history: list): new_rounds = len([m for m in history if m["role"] == "user"]) if new_rounds - self.compressed_rounds >= 10: unsummarized = history[self.compressed_rounds: self.compressed_rounds + 10] prompt = self._build_summary_prompt(self.summary_buffer, unsummarized) resp = llm.chat(self.summary_model, prompt) self.summary_buffer.append({"role": "system", "content": f"对话摘要:{resp}"}) self.compressed_rounds += 10 # 只保留最近 5 条摘要,防止摘要本身超长 self.summary_buffer = self.summary_buffer[-5:]

这里最容易被忽略的是最后两行:摘要本身如果不限制长度,也会吞掉预算。滚动摘要越攒越多,如果永远不清,最终变成了另一个全量塞入。保留最近 5 条摘要意味着每次压缩时会丢掉最老的摘要——但这没关系,因为更老的信息通常已经不具备即时价值,且向量检索里还有完整历史兜底。

检索模块相对独立,直接查 Chroma 向量库并按重排结果过滤:

def _retrieve_relevant(self, conversation_id: str, question: str, top_k=4) -> list: embed = embedding_model.embed(question) results = vector_db.query( filter={"conversation_id": conversation_id}, query_embeddings=embed, n_results=top_k * 3 ) # 简化重排:按时间衰减 + 向量距离加权 scored = [] for doc in results["documents"]: time_penalty = max(0.0, 1.0 - (time.time() - doc["ts"]) / 86400) score = doc["distance"] * 0.7 + (1 - time_penalty) * 0.3 scored.append((score, doc)) scored.sort(key=lambda x: x[0]) return [ {"role": "system", "content": f"相关历史片段:{doc['content']}"} for _, doc in scored[:top_k] ]

我在这个模块踩过最深的坑,是没做时间衰减。上线前两周,系统总把三天前的一段无关对话检索出来,因为那段对话恰好和用户当前问题在语义空间上接近。加上了时间惩罚项后,相关性排序才真正靠谱——最近的对话天然比久远的对话更重要,这个先验要写进检索逻辑里。

4.4 实测对比:三种模式的 token 消耗与回答质量

整个模块搭好后,我用一个 80 轮的真实客服对话做了压测。测试分三组:截断模式、混合模式、全量模式(作为参照组)。每组问同一个问题:"我们最初的预算是多少?" 这是唯一能验证上下文保留质量的问题。

模式token 消耗/轮延迟 p95 (s)预算正确率对话连贯性评分
截断模式4.2K1.17%4.2/10
混合模式12.8K2.393%8.8/10
全量模式48.6K4.181%6.5/10

一组结果就已经说明问题。全量模式虽然能记住预算,但第 32 轮之后准确率明显下滑,而且 token 消耗是混合模式的近 4 倍;截断模式几乎把关键信息丢光了;混合模式在准确率和成本之间取得了最佳平衡。

顺带说一句,token 消耗这块,混合模式单轮 12.8K 看起来不低,但其中约 4K 是重排后检索回来的历史片段,在用户切换话题时会自动更换,不存在冗余累积。这就是检索模式"按需取用"带来的成本优势。

5. 踩坑实录:context-mode 的六个经典翻车现场

5.1 摘要把数字记错了:建议增加 JSON 结构化输出

这个问题出现的频率最高。我最早让摘要模型输出纯文本,结果模型经常把"预算上限 3 万"压成"预算 30 万"。根源在于文本摘要的顺序性——数字密集出现时,模型分不清哪个数字对应哪个字段。

后来我把摘要输出格式改成了 JSON:

{"budget": "3万", "deadline": "2026-03-31", "tech_stack": ["Python", "FastAPI"], "decisions": ["采用方案B"], "promises": ["明天发报价"]}

强制结构化之后,模型对每个字段的提取准确率高了很多,因为 JSON 的 key 起到了"填空提示"的作用。而且结构化摘要后续可以直接喂给下游函数调用,不用再解析文本。这个改动内测阶段直接让预算正确率从 78% 提到了 94%。

5.2 向量检索召回了个寂寞:交互场景的嵌入模型选型

我一开始用通用 EN 嵌入模型做中文客服对话,召回率惨不忍睹。后来换成支持多语言的 embedding 接口,效果虽然好了一些,但依然不够精准——因为客服对话里大量出现的是产品名和术语,通用模型没见过这些词。解决办法是在向量化之前先做一个"查询改写"步骤:先用一个小模型把用户的口语问题改写成 2~3 个独立的检索查询,分别召回再合并。比如用户问"上次说的那个报错啥时候能修",改写后变成"有哪些已知 bug 报错"、"bug 修复时间"、"最近对话提到的故障"。这一步让召回准确率提升了 20% 以上。

5.3 压缩延迟拖垮响应:异步压缩是唯一解

早期的压测里,有遇到对话进行到第 40 轮时,单次请求耗时突然飙升到 8 秒。查了一圈发现是摘要压缩跑在了请求链路上——用户在发消息的同时,系统同步调摘要模型压缩历史,天然把等待时间拉长了。

解决方式很彻底:把压缩动作挪到请求返回之后异步执行,用 Redis Stream 或者 Celery 派发任务。用户发出"收到"之后,后台再去压缩历史并更新摘要缓存,下一次请求直接用压缩好的结果。这样用户体验完全感知不到压缩开销。同理,向量化和索引建库也必须是异步的。

5.4 token 用量波动大:需要给预算控制器加"温控"

混合模式上线后,运维同事发现 token 消耗曲线像锯齿——某天突然飙到平日的 5 倍。排查后发现原因很奇葩:用户当天抛进来一篇 30K 的长文档,系统把文档拆成 20 个片段分别向量化,然后在每轮对话里都把这 20 个片段检索回来,导致每次请求都带上近 15K token 的文档内容。

解决办法是在检索环节加一个"温控"参数:当检索回来的片段总 token 数超过预算的一定比例时,强制按相关性只保留前 2 个片段。宁可少给 AI 一些参考,也不能让它带着 20 段文档去答一个只需查 2 段的问题。

5.5 连贯性断裂:用户问"我刚说的那个需求"时,摘要救不了场

这也是很典型的 case。用户说"我刚说的那个需求",这里的"那个需求"在 30 轮以前出现过,但摘要里只有简略描述,没有完整上下文。检索模式倒是能找到原文,但前提是向量化时把那句话作为独立片段存过。

痛点的本质是"指代消解"——用户用代词指代了很早之前的信息。我给出的解法是在检索之外额外维护一个"最近实体清单":每次对话中凡是出现的人名、产品名、数字、日期,都提取出来放进 Redis 的实体列表。当用户的问题里出现"那个""它""刚才说的"这类指代词时,直接把最近的实体清单也注入上下文。这个方案不需要额外调模型,纯规则就能实现,但效果立竿见影,用户"失忆感"降低了很多。

5.6 模式切换过于激进:召回信息反而干扰回答

最后这个坑比较隐蔽。我的预算控制器在历史超过某阈值后会直接切成混合模式,但在某些场景下,历史虽然长,可用户当前问题根本不需要老历史——比如用户只是简单问"你叫什么名字"。把检索片段塞进去,反而增加了模型被旧信息误导的概率。

修正后的决策逻辑增加了一个"需求强度"判断:先用一个意图分类器判断当前问题是否需要历史支撑(比如用户问题里包含"之前、我们的、那个"等词语时判定为需要)。不需要时,无论历史多长都走截断模式,彻底不碰检索和摘要。这个优化让单轮 token 消耗又降了约 35%,而且回答准确率没降。

6. 一些个人体会:context-mode 的本质是"记忆的组织方式"

做完了这套系统,我最大的感受是 context-mode 绝不是"塞不下了再想办法压缩"的补救措施,而应该是整个对话系统架构里一开始就要规划好的核心组件。它的本质是如何让 AI 在有限的注意力资源里,始终能看到最重要的信息。就像人脑的记忆一样,你需要短期记忆(最近几轮)、长期记忆(摘要)、情景记忆(检索命中)三种机制协作,才能在对话里既保持即时上下文,又不丢失核心承诺。

从工程视角看,模式选择也没有银弹。全量模式便宜但平庸,截断模式快但致命,摘要模式稳但有失真风险,检索模式灵活但依赖检索质量。真正靠谱的做法是我在 3.3 节写的那套预算决策器——靠可量化的 token 预算做动态切换,而不是拍脑袋定一种模式。

最后说一个自己验证过的小技巧:无论你最终选择了哪种 context-mode,一定要在上线前用一组长对话测试集做好回归验证。我这里的测试集就是文中提到的"预算正确率"——每次改动 context-mode 后重跑 80 轮对话,验证关键数字和约束条件的召回有没有回退。上下文管理是最容易被"感觉好像行了"的优化,没有量化指标兜底,很容易在某个极端场景里翻车。

如果你在实现过程中也遇到类似的问题,尤其是摘要失真或者检索不命中这类"看起来小但影响极大"的怪问题,欢迎顺着这篇文章的思路自己动手调一版。上下文管理这件事,做得好的系统像是一个记忆力超群的客服,做不好的系统像一个聊到第十句就开始失忆的笨蛋——中间的差距,全都在 context-mode 的设计里。

RELATED READING

延伸阅读

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