☰
LLM应用上下文管理实战:从对话窗口到混合模式
2026/10/4 5:08:26 网站建设 项目流程

做 LLM 应用做到第三个项目的时候,我几乎被同一个问题逼疯:对话一长,模型就开始“失忆”;文档一多,token 直接爆表;用户上一秒还在聊需求,下一秒问了个早前的问题,模型答得驴唇不对马嘴。后来我才意识到,问题不在模型本身,而在我们从来没有认真设计过“上下文”这件事。把一套上下文管理模式(context-mode)真正落到项目里之后,这些坑才算一个一个填平。

这篇文章想聊聊 context-mode 到底是什么、为什么非做不可,以及抛开花哨概念之外,怎么用最简单直接的方式把它实现出来。内容偏实战,适合正在做 AI 应用、聊天机器人、知识库问答的工程师,也适合刚入门、想搞明白“上下文管理”到底在管什么的朋友。我会把原理、计算、代码、踩坑全部揉在一起讲,尽量让不同基础的人都能拿走直接用的东西。

1. 什么是 Context-Mode:从一个老问题说起

1.1 没有 Context-Mode 的时候,应用长什么样

先回忆一下最原始的写法:把所有对话历史一股脑拼进 prompt,然后丢给模型。demo 阶段这样跑确实没问题,两三轮对话效果还像模像样。但只要对话超过十几轮,或者中间插入了一段长文档,问题就全来了——模型开始忘记用户最早提的需求,回答越来越跑偏,甚至把之前说过的错误信息当成事实反复引用。

我见过一个客服机器人项目,上线第一周就翻车了。用户在第十轮问“那我刚才说的那个订单号是多少”,模型一本正经地编了一个订单号出来。原因很简单:原始对话早就被截断了,模型根本没见过那个订单号,但它不会承认自己不知道,而是会用概率去“猜”一个看起来合理的答案。这种幻觉不是模型不够聪明,是上下文管理没做到位。

Context-Mode 解决的就是这件事。它本质上是一套“上下文治理”策略:什么时候保留哪些信息、保留多少、以什么形式保留、什么时候该丢弃或压缩,都由一套明确的规则和流程来控制,而不是把决策权交给 prompt 的自然拼接。

1.2 Context-Mode 的三种典型落地形态

在我实际接触过的项目里,context-mode 通常以三种形态出现,按复杂度从低到高排:

第一种是对话窗口模式,也是最常见的形态。系统只保留最近 N 轮对话,更早的内容要么直接丢弃,要么压缩成一段摘要塞进系统提示词里。适合闲聊、客服、日常助手这类对“近期上下文”依赖强的场景。

第二种是检索模式。上下文不是靠“记住”,而是靠“查”。每次用户提问时,先从知识库或历史记录里检索出最相关的几段内容,拼到 prompt 里作为上下文。适合知识库问答、文档助手这类需要引用具体事实的场景。

第三种是混合模式,也是我认为真正能扛住生产压力的方案。系统先判断当前问题是延续型还是新开型:延续型走对话窗口,直接引用最近几轮内容;新开型走检索,把窗口里的旧内容压缩掉,腾出空间给新检索到的段落。两种模式通过一个路由逻辑动态切换。

这三种形态不是互斥的,成熟系统往往是三者嵌套。后面我讲实现方案的时候,会重点演示混合模式怎么把前两者组合起来。

2. 为什么需要 Context-Mode:三笔账必须算清楚

我见过不少团队觉得这是个“优化项”,不是“必做项”。我的观点很明确:只要你做的是多轮对话或长文本类应用,上下文管理就是刚需。原因不复杂,我们一笔一笔算。

2.1 第一笔账:Token 预算是物理天花板

大模型的 context window(上下文窗口)是硬限制。拿常见的 128K 窗口模型来说,128K token 听起来很大,但实际用起来缩水严重。我做一个粗略估算:一个 128K 窗口,system prompt 占掉 2K~4K,安全余量留 10%(约 13K),剩下真正能用的对话和文档空间大概 110K 左右。

110K 能放多少对话?按中文场景估算,1 个汉字约等于 1~1.5 个 token,一轮普通问答(用户提问 + 模型回答)大约 500~1000 token。110K 也就够 110~220 轮对话。听起来不少,但一旦涉及长文档分析,情况完全不同——一份 30 页 PDF 转成文本大概 2~3 万个 token,两三个文档塞进去,窗口已经快满了。

模型不是人,它没有“选择性注意”机制,窗口里塞什么它就看什么。如果你不管控上下文,等到窗口满了,系统只能做最简单粗暴的处理:从中间截断。而截断的位置往往恰好是把关键信息切掉的危险区。Context-Mode 的本质,就是把“截断”这个被动行为变成“管理”这个主动行为。

2.2 第二笔账:成本和延迟随上下文指数级上升

Token 不只是容量问题,更是钱的问题和速度的问题。大模型的计费直接跟 input token 数量挂钩,API 的 attention 计算量也跟序列长度成正比。我把一组真实的计费数据摆出来(按主流 API 的常见定价):系统提示词和用户消息都算 input token,模型回复算 output token,而 output token 定价通常是 input 的 3~4 倍。

很多团队只盯着 output 费用,忽略了 input 端的隐性膨胀。同一个应用,context 管理做得好,input token 可能稳定在 3K 以内;做得不好,几轮对话后就膨胀到 10K、20K。同样规模的用户量,月成本差三到五倍很正常。

延迟方面同样明显。我做过一次压测,同样的问题,3K 上下文时首 token 延迟约 0.8 秒,20K 上下文时涨到 2.5 秒以上。用户感知最明显的就是“转圈时间”,而很多转圈,其实都是无效上下文拖累的。

2.3 第三笔账:信息密度决定回答质量上限

用一个生活化的类比:模型的工作台就那么大(context window),你摆上去的东西越多,它找工具就越慢、出错率越高。但如果工作台上只有一把螺丝刀,它能干的活也就有限。所以关键不是“放多少”,而是“放什么”。

上下文信息密度指的是:在有限的 token 预算内,能支撑模型完成当前任务的有效信息占比。一段 500 token 的旧会话寒暄,信息密度几乎为零,但它会挤占原本可以用来承载用户真实需求的 500 token。Context-Mode 的另一个核心目标,就是提高窗口内的信息密度——把寒暄压缩掉、把重复内容去重、把背景知识摘要化,让每一寸窗口都花在刀刃上。

这几笔账算完,“为什么要做”基本没有悬念了。真正难的是“怎么做”,接下来进入机制层面。

3. 核心机制拆解:Context-Mode 内部到底做了什么

3.1 分块策略:怎么把长文本切成合适的 Chunk

不管是对话历史还是知识库文档,进入上下文之前都得分块。分块的目标不是“切小”,而是“切得语义完整”。我见过不少新手直接按固定字符数切,比如每 500 字符一刀切下去,结果把一句完整的话、一段逻辑、一个代码块从中间劈开,检索召回的时候全是残片,模型自然答不对。

实践下来比较可靠的分块维度有三个。第一是结构维度:文档里的章节标题、段落边界、列表项、代码块天然就是分块点,优先按这些切。第二是语义维度:用分隔符(句号、换行、Markdown 标题)先做候选切点,再评估每块的字数是否落在目标区间。第三是重叠维度:相邻两个 chunk 之间保留少量重叠(比如 50~100 个字符),避免检索时边界内容被漏掉。

我常用的参数组合是:目标 chunk 长度 800~1200 token,重叠 10%。这个区间是我在多个项目里试出来的折中值——太小则碎片化严重、召回时要拼多段,太大则单块内部主题混杂、向量检索精度下降。

3.2 记忆管理:摘要、遗忘与优先级排序

对话历史的处理是 context-mode 里最容易出彩也最容易翻车的地方。核心手段有三个:摘要、遗忘、优先级。

摘要是把早期对话压缩成结构化摘要,例如“用户是某电商平台的运营,正在咨询 618 大促的优惠券配置,偏好低价优先”。摘要不用保留完整原话,只要保住对后续回答有影响的关键事实。我建议摘要也分层:全局摘要保留用户的长期偏好和目标,滑动摘要只覆盖最近几轮。两层配合,比一层大摘要效果好得多。

遗忘策略不能简单地“时间久了就删”。要给每条历史消息算一个“生存价值”,来源有三个维度:是否包含用户提供的硬性事实(订单号、预算、时间)、是否被用户后续明确引用、是否与当前 topic 同主题。满 50 轮或超过 token 阈值时,把生存价值最低的一批先压缩成摘要,而不是从头一刀切。

优先级排序更关键:把最新一轮对话、用户当前明确提到的事实、系统需要执行的指令排在窗口最前或最后(模型对开头和结尾的注意力通常更强),中间放历史背景和参考文档。这个排序策略用好了,成本几乎为零,效果提升却很直观。

3.3 模式切换:按场景路由的触发逻辑

混合模式的路由逻辑是整套机制的大脑。我实现过一个相对简单的触发规则,准确率已经够用:先做意图判断,区分“延续当前话题”和“开启新话题”。

延续型问题的典型信号包括:出现指代词(“它”“那个”“刚才”)、对前文内容的追问、在现有任务列表上追加条件。新开型问题的信号包括:提到新实体、以“帮我做XX”开头、涉及之前没出现过的专有名词。

实现上不一定要用复杂模型,先用正则加少量规则能拦下六成场景,剩下的用一个小分类模型或 LLM 调用兜底。规则层的好处是快、省钱、可控,模型层负责处理边界情况。路由结果决定走对话窗口还是检索链路,同时触发相应的摘要和清理动作。

注意:模式切换切忌“每次请求都重新判断”导致抖动。我给路由结果加了个 sticky 机制——一旦进入检索模式,接下来 2~3 轮优先保持检索模式,避免用户连续追问时反复横跳,上下文被拆得七零八落。

4. 实操:从零实现一个可用的 Context-Mode

这一部分我直接把项目里跑通过的代码骨架整理出来,按三种模式从简到繁拆开讲。环境依赖很简单:Python 3.10+,openai 或任意兼容 SDK,加上一个向量库(示例用轻量的 Chroma,生产可换 pgvector 或 Milvus)。

4.1 基础架构与数据模型

先把上下文会话的数据结构定义清楚。我习惯用统一的消息模型,给每条消息打标签,这是后面做摘要、遗忘、排序的前提。

from dataclasses import dataclass, field from typing import Optional from enum import Enum class MsgType(str, Enum): USER = "user" ASSISTANT = "assistant" SUMMARY = "summary" MEMORY = "memory" FACT = "fact" class MsgRole(str, Enum): ACTIVE = "active" # 当前窗口内 ARCHIVED = "archived" # 已压缩/归档 @dataclass class Message: msg_id: str role: str # user / assistant / system content: str msg_type: MsgType token_len: int timestamp: float survival_score: float = 0.0 # 生存价值 extra_meta: dict = field(default_factory=dict) @dataclass class Conversation: session_id: str messages: list[Message] global_summary: str = "" # 全局摘要 window_limit: int = 8000 # 当前窗口 token 上限

每个 session 维护一个 messages 列表,active 状态的消息会进入 prompt,archived 的只保留在本地日志里。token_len 在写入时就算好,避免每次拼 prompt 都重新数一遍,性能差距很大。

4.2 实现对话窗口模式:滑动窗口 + 摘要压缩

滑动窗口的模式好理解:维护最近 N 轮,超出的部分压缩进摘要。下面这段代码实现了“窗口满时压缩最旧消息”的核心逻辑:

def compress_old_messages(conv: Conversation, model_fn) -> None: """把窗口内最早的一批消息压缩进全局摘要""" # 按生存价值排序,优先压缩低价值的旧消息 old_msgs = [m for m in conv.messages if m.msg_type in (MsgType.USER, MsgType.ASSISTANT) and m.role == MsgRole.ACTIVE] old_msgs.sort(key=lambda m: (m.timestamp, m.survival_score)) total_token = sum(m.token_len for m in old_msgs) if total_token <= conv.window_limit: return to_compress = [] used_token = 0 for m in old_msgs: if used_token + m.token_len > conv.window_limit * 0.6: break to_compress.append(m) used_token += m.token_len # 被压缩的消息拼成一段,交给摘要模型 raw_text = "\n".join(f"{m.role}: {m.content}" for m in to_compress) prompt = ( "你是对话摘要器。把下面的对话压缩成简洁的中文摘要," "保留硬性事实、用户偏好、尚未完成的指令,丢掉寒暄和重复内容。\n" f"对话内容:\n{raw_text}" ) summary = model_fn(prompt, max_tokens=500) merged = conv.global_summary + "\n" + summary if conv.global_summary else summary # 截断全局摘要,避免无限膨胀 conv.global_summary = truncate_by_tokens(merged, 1500) # 原消息降级为 archived for m in to_compress: m.role = MsgRole.ARCHIVED # 把新摘要作为一条特殊消息插入窗口最前面 conv.messages.insert(0, Message( msg_id=f"summary_{int(m.timestamp)}", role="system", content=f"对话背景摘要:{conv.global_summary}", msg_type=MsgType.SUMMARY, token_len=estimate_tokens(conv.global_summary), timestamp=m.timestamp, ))

这段代码里的关键决策有两个。第一,不是窗口满了才压缩,而是先对消息算生存价值、按时间排序后再挑低价值的压;第二,插回窗口的不是整段历史,而是压缩后的摘要。这两点直接决定了滑动窗口的质量上限。如果直接写“把最早 N 轮砍掉”,用户问起 20 轮前提过的需求,模型依然一无所知。

4.3 实现检索增强模式:向量化与召回

检索模式负责“从外部资料里找答案”。链路不复杂:文档分块 → 向量化入库 → 查询时向量召回 → 按相关性过滤后拼入 prompt。代码骨架如下:

import chromadb from openai import OpenAI client = OpenAI() collection = chromadb.Client().get_or_create_collection("kb_docs") def build_index(doc_chunks: list[dict]) -> None: """chunks: [{chunk_id, text, meta}]""" embeddings = [ client.embeddings.create(model="text-embedding-3-small", input=c["text"]).data[0].embedding for c in doc_chunks ] collection.add( ids=[c["chunk_id"] for c in doc_chunks], embeddings=embeddings, documents=[c["text"] for c in doc_chunks], metadatas=[c["meta"] for c in doc_chunks], ) def retrieve(query: str, top_k: int = 4) -> list[str]: q_emb = client.embeddings.create( model="text-embedding-3-small", input=query ).data[0].embedding hits = collection.query(query_embeddings=[q_emb], n_results=top_k) docs = [h for h in hits["documents"][0]] return docs

召回之后还有一个容易被忽略的动作:相关性过滤。向量检索返回的 top_k 是“近似度”最高的,但不代表“相关度”够用。我通常会给召回结果加一道轻量过滤:要么用模型打个分,要么用关键词/时间戳做硬过滤。比如用户问的是“昨天的报表”,那就先过滤掉所有非昨天的文档再送进模型,别把向量库当成万能钥匙。

注意:检索 token 预算要单独设上限。我的项目里检索上下文最多占窗口的 50%,剩下空间必须留给对话历史和指令。很多团队把检索结果无脑塞进去,结果模型被大段文档淹没,连基本指令都执行不好——这是典型的“上下文反噬”。

4.4 实现混合模式:按意图路由

混合模式的核心是路由函数。先判断当前问题该走对话窗口还是检索链路,再决定上下文组装方式。代码逻辑如下:

import re TOPIC_CONTINUE_PATTERNS = [ r"那|后来|然后|继续|再说|刚才|上面|这个|那个", r"^还|^再|^另外", r"第\d+种方案|上一个问题|你刚才说的", ] TOPIC_NEW_PATTERNS = [ r"帮我看下|帮我查一下|写一份|整理一下", r"什么是|为什么|怎么用", ] def route_query(query: str, recent_msgs: list[Message]) -> str: """返回 'conversation' 或 'retrieval'""" if query.startswith("!"): # 强制检索指令,给用户留一个后门 return "retrieval" cont_score = sum(1 for p in TOPIC_CONTINUE_PATTERNS if re.search(p, query)) new_score = sum(1 for p in TOPIC_NEW_PATTERNS if re.search(p, query)) # sticky 机制:如果最近一轮是 retrieval,且本轮有指代,就继续 retrieval if recent_msgs and recent_msgs[-1].extra_meta.get("mode") == "retrieval": if cont_score > 0: return "retrieval" if new_score > cont_score: return "retrieval" return "conversation" def build_prompt(session: Conversation, query: str) -> str: mode = route_query(query, session.messages[-3:]) if mode == "conversation": # 对话窗口模式:摘要 + 最近N轮 + 当前提问 recent = [m for m in session.messages[-6:] if m.msg_type in (MsgType.USER, MsgType.ASSISTANT)] context_parts = [] if session.global_summary: context_parts.append(f"背景摘要:{session.global_summary}") context_parts.append("\n".join(f"{m.role}: {m.content}" for m in recent)) else: # 检索模式:摘要 + 检索结果 + 最近2轮 + 当前提问 docs = retrieve(query, top_k=4) context_parts.append("参考资料:") context_parts.extend(f"- {d}" for d in docs) recent = [m for m in session.messages[-2:] if m.msg_type in (MsgType.USER, MsgType.ASSISTANT)] context_parts.append("\n".join(f"{m.role}: {m.content}" for m in recent)) context_parts.append(f"用户:{query}") prompt = "\n\n".join(context_parts) return prompt

路由层做得“轻”是刻意的。正则模式快、零成本、可解释,能覆盖大部分生产请求。更复杂的语义边界才交给模型兜底。这样既保证稳定性,又控制成本。

4.5 组装请求与参数选择

最后是把所有素材拼进 API 请求。这里我给出一个生产环境验证过的组装顺序和参数组合,可以直接抄:

def call_with_context(session: Conversation, query: str, model: str = "gpt-4o-mini") -> str: prompt = build_prompt(session, query) response = client.chat.completions.create( model=model, messages=[ {"role": "system", "content": SYSTEM_INSTRUCTION}, {"role": "user", "content": prompt}, ], temperature=0.3, # 知识/客服场景建议低温,减少幻觉 max_tokens=1024, stream=False, ) answer = response.choices[0].message.content # 把本轮交互写回会话,供下一轮使用 session.messages.append(Message( msg_id=str(uuid4()), role="user", content=query, msg_type=MsgType.USER, token_len=estimate_tokens(query), timestamp=time.time(), )) session.messages.append(Message( msg_id=str(uuid4()), role="assistant", content=answer, msg_type=MsgType.ASSISTANT, token_len=estimate_tokens(answer), timestamp=time.time(), )) return answer

SYSTEM_INSTRUCTION 建议把模式的说明放进去,比如“当参考资料存在时,优先引用资料内容;当资料与用户问题无关时,明确说明”。temperature 我习惯在 0.2~0.4 之间调,检索类项目压到 0.2,自由问答类可以放到 0.7,但别超过这个太多,否则同样上下文下幻觉率会明显上升。

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

5.1 上下文被截断后回答质量断崖式下跌

症状:对话进行到中途,模型突然开始重复回答、答非所问,甚至声称自己“看不到之前的信息”。

排查思路:先看截断逻辑。很多团队用的是最简单的“字符截断”,比如 prompt 超过 4000 字符就把中间部分直接删掉,这是灾难的根源。手动拼 prompt 前先跑一遍 token 统计,确认截断位置在哪里——如果截断点恰好落在用户上一轮提问或关键事实附近,大概率就是问题所在。

修复办法:把“截断”改成“压缩”。参考 4.2 的摘要逻辑,不要删内容,而是把旧内容转成摘要后重新插入。摘要模型可以用小一号的模型,成本可以接受,效果却天差地别。

5.2 上下文污染:不相关信息挤占窗口

症状:用户问 A 领域问题,模型却把 B 领域的资料一起引用,答案变得混乱又冗长。

这是检索模式特有的坑。向量召回很“贪”,top_k 设成 5,哪怕只有 2 条真正相关,它也会凑满 5 条给你。凑数的内容不仅没用,还会污染注意力。我踩过的最狠一次是在法律文书问答里,模型把“因”和“因果”两个不同概念的文档混在一起引用,答出来的结论完全跑偏。

修复办法:给检索结果加两过滤——先用关键词和元数据硬过滤(如时间、来源、领域),再用模型或规则做相关性确认。top_k 宁小勿大,宁缺毋滥。另外,我给每条资料单独标注来源编号,让模型在回答时注明“根据[资料2]”,既方便审计,也减少模型自由发挥的空间。

5.3 Token 计费与实际消耗对不上

症状:后台显示单次请求才 3K token,账单却比预期高很多。

大部分时候不是计费出问题,而是没算对 input token 的构成。system prompt、检索资料、历史对话、摘要全部都会计入 input。我做过一次审计,发现某项目每条请求里,历史对话占了 60% 以上的 token,真正的当前问题只占不到 10%。这就是没做上下文管理的经典特征。

修复办法:上线前做一个 token 构成仪表盘,按“system / 历史 / 检索 / 当前问题”四个维度拆解每次请求的 token 用量。只要历史占比超过 50%,就该检讨滑动窗口和摘要策略。另外,别忽略 embedding 费用,检索模式的向量化调用也是一笔常被遗忘的开支。

5.4 多会话状态同步的坑

症状:用户开了多个会话,在 A 会话里设置了一个偏好,切到 B 会话提问时,模型完全不知道这个偏好。

Context-Mode 如果只做“单会话内管理”,跨会话就断片了。用户会把应用当作统一助手,但每个 session 都是独立窗口。我的处理方式是引入一个全局“用户画像层”:每个用户维护一份长期记忆(偏好、历史实体、常用指令),在每次组装 prompt 时作为背景摘要注入。这个画像层与单会话窗口并行,互不干扰。

实现上给 Conversation 加一个 user_profile 字段,用独立的摘要链路维护,只保存“跨会话仍然有效”的信息,比如用户的行业、目标、禁忌,而不是流水账式的事实堆砌。

5.5 问题速查表

下面这张表是我在实际项目里沉淀下来的快速定位清单,出问题时按这个顺序排查,大部分情况都能在十分钟内定位:

症状常见根因首选排查方向快速缓解动作
回答跑题、答非所问窗口被低价值历史占据检查 token 构成占比压低历史占比至 30% 以下
关键事实被遗忘截断策略粗暴看截断点是否落在关键消息改为摘要压缩,不要硬删
引用来源混乱检索结果未过滤检查 top_k 和相关性阈值增加硬过滤和来源编号
延迟与成本飙升输入 token 膨胀看历史/检索占比启动滑动窗口 + 摘要
跨会话记忆丢失无全局画像层检查 session 边界增加 user_profile 注入

写在最后的一点经验

聊完机制和代码,分享几条从项目里踩出来的心得体会。

第一条:Context-Mode 不是一次性搭好就能躺着用的。它的核心挑战在于“动态平衡”——摘要需要更新、检索阈值需要调整、路由规则需要根据线上日志迭代。我建议每两周复盘一次上下文命中率,最简单的办法是抽样看 100 条用户问题,人工判断模型用到的上下文是否“恰好是当时最该用到的”。我做过一次复盘,发现 40% 的问题其实根本不需要历史上下文,但我们的系统每次都把 6 轮历史塞进去,白白浪费 token 和延迟。

第二条:给上下文管理体系加“可观测性”比加功能更优先。每次请求记录下路由模式、token 构成、召回文档列表、截断位置,这些日志将来就是优化的唯一依据。没有日志的 context-mode 就像闭着眼开车,出了问题根本无从排查。

第三条:先小步跑通再优化。不要一上来就上混合路由和分层摘要,先用最简单的滑动窗口跑两周,把基准确立了,再逐步加检索、加路由、加画像层。我见过太多团队一步到位把系统搞得太复杂,最后反而分不清问题出在模型、检索还是路由上。

最后再分享一个小技巧:给用户在界面上留一个“强制重新开始”的按钮,本质上是清空当前上下文。这个功能成本极低,但能解决大量因上下文污染导致的低质量回答——与其让模型在混乱上下文里硬撑,不如让用户一键重置,对口碑的挽回效果立竿见影。Context-Mode 做得再好,也要记住:它服务的是用户,不是模型。

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

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

立即咨询