☰
LLM上下文管理实战:Context-Mode设计思路与Token预算调度
2026/10/8 11:46:20 网站建设 项目流程

说实话,我之前对"context-mode"的理解,很长一段时间都停留在"把用户消息拼进 prompt 再发给大模型"这种最朴素的阶段。直到做过几个带多轮对话、带工具调用、甚至带长期记忆的 Agent 项目,被上下文溢出、Token 爆炸、任务做到一半"失忆"这些问题反复毒打之后,我才正式把上下文管理当成一个独立的、需要单独设计的核心模块来处理。这个模块,业内习惯称之为 context-mode。

在 LLM 应用工程里,context-mode 不是一个花哨的功能开关,而是决定应用能否在真实生产环境里活下来的地基。大模型的输入窗口再长,也长不过用户连续几天的使用记录;上下文窗口再多,也装不下一个 Agent 在长任务执行中产生的全部中间过程。如果不做设计,应用就会面临三个非常典型的问题:一是历史对话无限膨胀,Token 成本失控;二是真正关键的信息被淹没在大量无关聊天记录里,模型召回率下降;三是当对话超过窗口上限时,要么粗暴丢弃早期内容,导致"失忆",要么全量截断,导致当前任务也被误伤。Context-mode 要解决的,就是这些问题。

这篇文章想分享的是我在实际项目中落地 context-mode 的完整思路:包括它的核心设计逻辑、Token 预算怎么算、历史上下文怎么分层、三种动态切换模式怎么触发,以及一套可以直接抄作业的代码流程。适合正在做 LLM 应用、Chatbot、Agent 项目,尤其是被多轮对话上下文问题困扰的工程师参考。对于自己搭模型玩的本地"炼丹"党,也有一定借鉴价值。

1. 整体设计思路:为什么 Context-Mode 不是简单拼字符串

1.1 我踩过的第一个坑:全量拼接导致 Token 飞涨

我先讲一个真实经历。第一个项目是一个企业知识库问答机器人,当时觉得大模型的上下文窗口动辄几万 Token,完全够用,于是很偷懒地把所有历史对话全部塞进 system prompt,每轮都全量发送。上线初期一切正常,但用了一周之后,一个活跃用户往下拉对话列表就会发现,聊天记录越来越长,我们的后台监控里,单次请求的 Token 消耗从最初的 2000 多涨到了接近 5 万,账单肉眼可见地往上飙。更难受的是,模型开始出现"前言不搭后语"的情况,明明用户五分钟前才说过自己用的是 Windows 系统,模型却在前一个问题上回答 Linux 的配置方案。原因很简单:信息太多了,模型真实的注意力都被高频词带走,早期关键信息反而失焦。

所以我在做 context-mode 设计时,第一原则就是:上下文不是越多越好,而是越精越好。不是把所有东西都塞给模型,而是在每一轮请求前,主动想清楚"这一轮任务真正需要哪些信息"。这是一个从"被动拼接"到"主动调度"的思维转变。

1.2 三种常见方案对比:全量、滑动窗口和混合式

社区里常见的上下文管理方案大致有三类。

第一种是全量保留,也就是我前面踩坑的方案。优点是实现简单、信息无损,缺点是成本快速失控,且长文本中关键信息被稀释,模型有效注意力面积有限。适合 demo 级应用,不适合生产。

第二种是滑动窗口。只保留最近 N 轮对话,更早的一律丢弃。优点是算法简单,Token 占用稳定,缺点是"一刀切"逻辑容易误删长期任务里的关键背景。比如一个用户三天前提供过一份采购清单附件,今天问"那个清单里第三项的供应商联系方式是多少",滑动窗口早就把三天前的记录冲掉了,模型只能回答"我没找到相关信息"。这种体验很伤用户。

第三种就是我认为最适合生产环境的混合式架构,也是 context-mode 的核心思想。它把上下文分成三层:系统指令层(固定不变的应用行为规范)、工作记忆层(当前正在进行任务的细节)、长期记忆层(跨越会话周期的用户偏好、事实标签、历史摘要)。每一层有不同的留存策略、更新频率和 Token 配额。context-mode 这个名字的"mode",含义就在这里——它不是一个静态结构,而是可以根据当前会话状态切换成不同的模式,比如"深入问答模式"、"多步任务执行模式"、"轻量闲聊模式"。每种模式下,三层上下文的分配比例和调度策略是不一样的。

1.3 为什么上下文分层比单纯压缩更可靠

刚开始做上下文压缩的时候,我试过直接把历史消息丢给大模型生成一段摘要,然后把摘要塞进 prompt。单看思路没什么问题,但实际操作了两个星期,发现一个致命细节:摘要过程本身引入的信息损耗是不可控的。比如用户随口说的一句"价格不是问题,质量第一",在摘要里很容易被概括成"用户关注质量",但如果这是一个销售场景,"价格不是问题"这个否定信息其实极其关键,模型需要关注的不是"重视质量",而是"做报价方案的时候不要过于纠结预算"。

所以 context-mode 的做法,不是把历史消息全部压成一段摘要,而是按信息的重要程度分级保留。能压缩的压缩,不能压缩的关键原文必须保留。比如用户提供的账号信息、时间节点、金额数值、明确的指令性语句,这些属于"高保真关键上下文",必须原文保留;而寒暄、语气词、冗余重复的表述属于"低价值上下文",可以压缩或直接丢弃。这就是为什么混合式设计比单一压缩方案更可靠——它把"信息有损压缩"控制在合理的范围内,避免错杀关键细节。

2. Token 预算与优先级分层:先把账算清楚再动手

2.1 设计 Token 预算的基本公式

如果说上下文分层是 context-mode 的灵魂,那么 Token 预算管理就是骨架。我一般会在代码里定义一组常量,把每一轮请求允许的最大 Token 数拆分成明确的组成部分。拆法并不复杂,核心是一个配额公式:

MAX_PROMPT_TOKENS = MODEL_CONTEXT_WINDOW * 0.7

为什么只取 70%?因为我需要预留 30% 给模型生成回复的空间。如果使用最大上下文窗口为 128K 的模型,我实际可用的 prompt 预算是约 89K,而不是把 128K 全部塞满。如果设置成 90% 甚至更高,模型很可能输出到一半就触发截断,尤其是长文档总结、长代码生成这类输出场景,生成 Token 消耗波动非常大,没有余量会很被动。

在这个大前提下,我再把 89K 预算继续拆成四块:

  • 系统指令(System):固定占 2K~4K。包括角色设定、行为约束、输出格式、安全限制。无论什么模式,这是最优先保证的,不允许被挤占。
  • 用户当前输入:按实际情况动态计算,一般不超过 4K。如果用户一次性贴了很长的文档,可以考虑额外分配,但要警惕"单条消息吃掉整个上下文"的情况。
  • 关键上下文片段:根据模式动态分配,一般占 30%~50%。
  • 历史摘要与长期记忆:剩余预算,一般占 20% 左右。

2.2 四层优先级:什么内容必须进上下文

有了总预算,下一步就是给不同类型的内容排优先级。我在实践中把上下文内容优先级分为 P0 到 P3 四层:

  • P0(必须包含):系统指令、当前用户最新消息、正在执行的工具调用返回结果、明确的约束条件(比如"只要 JSON 输出")。缺失会导致本轮任务直接跑偏。
  • P1(尽量包含):当前任务相关的关键事实,比如用户提供的账号、订单号、时间、数值参数、上一步的中间结论。这些内容一旦缺失,模型会给出错误的"猜测性回答"。
  • P2(按预算包含):早期对话中与当前主题弱相关但有背景价值的部分,以摘要形式存在即可。比如用户三天前提过"我们在做一个跨境电商项目",之后一直围绕物流问题提问,那段背景可以压缩成一句话摘要。
  • P3(默认排除):寒暄、重复表述、已过期的状态信息、权限校验日志、调试信息等。这些东西留在上下文里只会占据宝贵的 Token 预算。

每次构造 prompt 时,我都会跑一遍这个优先级逻辑。先说结论:宁可牺牲 P2 的历史细节,也绝不动 P0 的当前任务信息。这个取舍原则在开发中帮了我很多次。有一次用户正在让 Agent 逐步执行"数据处理流水线",连续调用了七八个工具,步骤中间某个环节报错。如果此时简单地把早期步骤摘要掉,模型就不知道自己已经执行到哪一步了,会从零开始重跑或者跳过关键步骤。而 context-mode 会把"已完成步骤"和"当前步骤"标记为 P1 甚至 P0,确保任务有连续性。

2.3 用 Token 计数器做硬性校验

分配预算不能只靠"感觉差不多",必须有硬性的计数校验。我在代码里引入一个简单的 tokenizer 封装,不要求精确到字节级,只要能和模型的分词器基本对齐即可。如果是 OpenAI 系模型,直接用tiktoken;如果是开源模型,可以用transformers的 tokenizer。每次组装完 prompt,先计数,如果超出 MAX_PROMPT_TOKENS,就按优先级从低到高逐级裁剪,直到满足条件。

实际测试中我发现,很多 Token 超限问题不是出在历史消息,而是出在工具返回结果。有一次 Agent 调用一个搜索接口,接口把一整页 HTML 原样返回了,光这部分就占了几千 Token。后来我在工具层加了个后处理,对返回结果做截断和结构化提取,问题立刻缓解。context-mode 要管的不仅是"模型输入侧",还包括"工具输出侧",这是一个很容易被忽视的隐藏成本点。

3. 动态模式切换:Context-Mode 的核心调度机制

3.1 三态模式:活跃态、压缩态、冻结态

如果只是做预算分配,那它本质上还是一个静态的上下文拼接器。真正让 context-mode 具备"智能"特质的,是它的动态模式切换机制。

我在项目中实现了三种会话模式。活跃态(Active):会话保持在短时间内,上下文以原文形式完整保留,用于即时问答和连续对话,体验最好,Token 消耗也最高。压缩态(Compressed):当会话变长或出现时间间隔后,将早期内容转化为结构化摘要,保留近期原文,Token 占用中等。冻结态(Frozen):长时间未活跃的会话,只保留核心长期记忆(用户画像、几个关键事实标签),其余全部归档,不参与 prompt 组装,几乎不占 Token。

模式之间的切换,不是主观决定的,而是靠一组条件触发。我把它做成了一个独立模块,每个会话状态由state字段标记,每轮请求到来时,先检查当前状态,再决定是否切换。

3.2 触发条件一:Token 阈值触发

最简单也最可靠的触发条件,是 Token 占用上限。每轮对话结束后,系统会计算当前会话的原始上下文总 Token 数,当它超过某个预设阈值(比如总预算的 80%),自动把状态从活跃态切换到压缩态。当一个会话的摘要 Token 也超过阈值时,进一步切换为冻结态,只保留长期记忆。

这个阈值设计有一个细节:不要设成 100%,留 20% 的缓冲。因为从"触发切换"到"完成压缩"之间需要时间,如果等到完全满载再处理,下一轮请求可能会因为瞬间溢出而出错。我一般设 80%,既能保证切换过程平稳,又不会过早压缩导致频繁的摘要计算。

3.3 触发条件二:时间窗口触发

第二个触发条件是时间窗口。用户可能连续几小时在一个会话里深入交流,然后隔了两天又回来继续问。这时候如果还把两天前的原文全部塞进上下文,不仅浪费 Token,而且容易造成语义割裂感——用户今天的诉求可能已经完全变了。

我采用的做法是:每隔 20 分钟检查一次会话活跃度。如果距离上一条消息超过 30 分钟,就把中间状态标记为"可压缩"。距离超过 24 小时的会话,直接进入冻结态。这样设计的好处是:即使用户在前一天的会话里聊过"怎么登录后台",今天回来问的却是"报表导出失败",这两个话题在上下文上完全没有强关联,保留昨天的全部原文就没有必要。当然,如果检查到两个话题的相关性比较高,比如今天的问题里明确引用了昨天的内容("昨天说的那个导出按钮在哪"),我会把触发状态回退,从冻结态临时切回压缩态,额外取出旧会话的关键部分。

3.4 触发条件三:多轮意图突变检测

这个触发条件我最想推荐,因为它能解决一个真实痛点:用户在同一个会话里连续问了很多问题,突然从"闲聊"切换到"要写一段 Python 代码"。如果没有意图突变检测,系统很容易把闲聊的摘要堆积进去,挤占代码生成需要的上下文空间。

实现并不复杂。我在每轮收到用户消息时,做一次轻量意图分类,类别包括"闲聊/问答/代码生成/工具调用/分析总结"等。同时维护一个滑动数组记录最近五轮意图。当最新意图与前五轮中超过 70% 的意图类别不一致时,判定为意图突变,立刻触发上下文重建:把当前意图类别相关的历史原文保留,其他类别的历史内容降级为摘要。这个机制在实际使用中效果非常明显,尤其是混合型项目里,用户前脚在问业务知识,后脚让"帮我把刚才的讨论整理成周报",如果周报生成只需要讨论摘要而不需要逐字对话记录,意图检测就能自动缩短上下文,提高输出质量。

3.5 切换的粒度控制:不要整场会话一把切

模式切换最容易犯的一个错误,是"整场会话一把切"。意思是一旦进入压缩态,就把所有历史原文全部变成摘要,不留任何原文。这不行。我一开始也是这么干的,结果发现用户经常会追问"你刚才说的那个方案细节是什么",而摘要里根本不会保留方案细节,于是模型就只能含糊其辞。

后来我改成句子级/条目级粒度。进入压缩态时,不是直接对整段对话生成一段摘要,而是把历史消息按"信息块"拆分,每一块单独判断:如果是关键事实(含数字、名称、时间、明确指令),保留原文;如果是推理过程,压缩成结论;如果是寒暄或者重复,直接丢弃。这样从活跃态进入压缩态之后,关键原文仍然在上下文中,只是冗余表述变少了。效果可以说是立竿见影——用户追问细节时,模型的回答准确率明显回升。

4. 实操:一个最小可用的 Context-Mode 实现流程

4.1 工具选型:别急着上框架

很多朋友做这类功能,第一反应是上 LangChain、LlamaIndex 这些框架。我的建议是:第一版先自己写,不要上框架。原因很简单,context-mode 的核心逻辑其实只有几十行代码可以表达清楚,自己手写反而更能理解每个环节为什么存在。框架提供的封装虽然方便,但出问题的时候定位困难,尤其是 Token 裁剪和模式切换这种偏自定义的逻辑,框架反而成了束缚。

我的推荐组合是:Python 后端 + Redis 存储会话状态 + tiktoken 做 Token 计数 + OpenAI 兼容接口(或任意本地模型服务)。如果你用的是本地模型,替换成 transformers 的 tokenizer 就行,整体架构完全不受影响。

Redis 在这里的定位是会话状态存储。每一个会话(conversation_id)对应一个哈希表,字段包括state(当前模式)、recent_text(近期原文,JSON 格式)、summary(摘要)、long_term(长期记忆标签)、intent_history(意图滑动数组)和last_active(最后活跃时间)。选 Redis 而不是直接存文件,是因为它支持过期时间,天然适合做会话的自动冻结,而且读写性能好,不会成为高并发下的瓶颈。

4.2 核心流程:组装前先调度

整个 context-mode 的执行流程,我在代码里拆成了四个阶段:

  1. 状态检查:拿到 conversation_id 后,先从 Redis 读出会话状态,判断当前处于什么模式。
  2. 模式评估:根据 Token 阈值、时间窗口、意图突变三个条件,决定是否要从当前模式切换到另一个模式。如果切换,执行对应的"状态迁移"动作。
  3. 上下文组装:按优先级分层,从系统指令、当前输入、近期原文、摘要、长期记忆中选取内容,拼接成最终的 prompt。
  4. Token 校验:用 tokenizer 计数,如果超限,按 P3 到 P1 的顺序裁剪,直到满足预算。

我给这段流程整理了一份伪代码,你可以直接参考着实现:

# 简化版 context-mode 调度核心 def build_prompt(conversation_id, user_input): state = redis.hgetall(f"session:{conversation_id}") # 1. 模式评估:是否切换 new_state = evaluate_mode(state, user_input) if new_state != state.get("state"): state = migrate_state(conversation_id, state, new_state) # 2. 按优先级组装上下文 segments = [] segments.append(("system", load_system_prompt())) # P0 segments.append(("input", user_input)) # P0 segments.append(("recent", state["recent_text"])) # P1 segments.append(("summary", state["summary"])) # P2 segments.append(("longterm", state["long_term"])) # P2 # 3. Token 校验与裁剪 prompt = assemble(segments) over = count_token(prompt) - MAX_PROMPT_TOKENS if over > 0: prompt = trim_by_priority(prompt, over) # 4. 更新会话状态 update_state(conversation_id, user_input, prompt) return prompt

全部逻辑都在一个函数里完成,没有魔法。你完全可以把它改造成任意语言版本。

4.3 状态迁移与摘要的实现细节

状态迁移是 context-mode 里最需要小心的地方。活跃态切到压缩态时,要对"近期原文"做一次信息拆分和摘要计算。这个过程我建议异步执行,避免阻塞当前请求。可以单独起一个后台任务,或者用 Redis 的过期后回调机制(没这个能力的话,也可以用定时扫描)。

摘要计算这一步,我推荐给大模型下这样的指令——不要只用一句话概括,而是输出一个结构化的 JSON,包含三部分:key_facts(关键事实原文数组,包含数字、名称、时间)、conclusion(简短结论)、outdated(已过期信息,丢弃即可)。这样做的好处是,压缩后的摘要保留了关键事实原文,后续组装上下文时可以把key_facts当成 P1 直接使用,而不会被摘要过程弄丢细节。

冻结态的迁移就更简单了:从摘要中提取长期记忆标签,写入long_term字段,然后清空recent_text和summary。比如摘要里出现"用户是产品经理、关注数据看板、负责三个项目"这类信息,可以抽成三个标签存起来。冻结会话重新激活时,系统优先加载long_term到上下文中,再根据需求决定是否恢复详细摘要。

4.4 一个完整的本地可运行示例

为了让你更直观地感受整个流程,我写了一个更具体的示例。假设后端是一个 Flask 应用,调用 OpenAI 兼容接口。核心代码如下:

# app.py 简化版示例 from flask import Flask, request, jsonify import redis, json, time import tiktoken app = Flask(__name__) pool = redis.Redis(host="localhost", port=6379, db=0) enc = tiktoken.encoding_for_model("gpt-4o") MAX_PROMPT_TOKENS = 8000 MODAL_BUDGET = 3200 def count_token(text): return len(enc.encode(text)) def make_completion(messages): # 这里替换成你自己的模型 API 调用 import openai resp = openai.chat.completions.create( model="gpt-4o", messages=messages, temperature=0.7 ) return resp.choices[0].message.content @app.route("/chat", methods=["POST"]) def chat(): body = request.get_json() conversation_id = body.get("conversation_id") user_input = body.get("message", "") key = f"session:{conversation_id}" state = pool.hgetall(key) if not state: state = { "state": "active", "recent_text": json.dumps([]), "summary": "", "long_term": json.dumps({}), "intent": json.dumps([]), "last_active": str(time.time()) } pool.hset(key, mapping=state) # 判断是否超 Token 阈值,触发压缩切换 recent = json.loads(state["recent_text"]) recent.append({"role": "user", "content": user_input}) raw_token_count = count_token(json.dumps(recent)) if raw_token_count > MODAL_BUDGET and state["state"] == "active": # 触发压缩过渡:只保留最近三轮原文,其余做摘要 keep_recent = recent[-6:] compress_payload = recent[:-6] summary_text = summarize(compress_payload) # 调用模型生成结构化摘要 state["summary"] = summary_text state["recent_text"] = json.dumps(keep_recent) state["state"] = "compressed" pool.hset(key, "summary", summary_text) pool.hset(key, "recent_text", json.dumps(keep_recent)) pool.hset(key, "state", "compressed") # 组装 prompt messages = [{"role": "system", "content": SYSTEM_PROMPT}] if state.get("summary"): messages.append({"role": "system", "content": f"历史摘要:{state['summary']}"}) for msg in recent[-6:]: messages.append(msg) # Token 校验:超限裁剪 total = count_token(str(messages)) while total > MAX_PROMPT_TOKENS and len(messages) > 2: # 优先丢 system 之外的第一个历史消息 messages.pop(1) total = count_token(str(messages)) answer = make_completion(messages) recent.append({"role": "assistant", "content": answer}) # 只保留近四轮原文,防止无限膨胀 pool.hset(key, "recent_text", json.dumps(recent[-8:])) pool.hset(key, "last_active", str(time.time())) return jsonify({"answer": answer})

这段代码故意做了大量简化,删减了意图突变检测和时间窗口触发,但骨架已经足够说明问题。核心就是"先评估状态,再压缩/组装,最后做 Token 校验"。

4.5 实操中的埋点与观测

代码能跑只是第一步。在真实环境里,context-mode 到底有没有生效、生效后对回答质量有没有影响,需要一个观测维度。我在项目里加了几个关键日志:每一轮的state值、raw_token_count、压缩次数、被裁剪掉的条目数、模型回复的字数。汇总到监控面板之后,发现一个很有意思的规律:压缩态的会话,平均响应延迟比活跃态降低了近 40%,因为 prompt 变短了,模型生成等待时间也相应减少。而回答质量并没有明显下降,因为关键事实被高保真保留了。

还有一个值得关注的点:摘要计算本身的延迟消耗。压缩态触发时,如果直接用大模型对长文本做摘要,一次可能耗时 3~5 秒,虽然对单次请求来说不算致命,但在高并发下会成为瓶颈。我后来做了两个优化:一是摘要生成异步化,用户这一轮先拿旧摘要顶着,压缩结果后台更新;二是摘要输入做了截断,只取原始文本的前 3000 Token 和末尾 1000 Token,优先保证开头结尾的信息密度。两个优化叠加后,压缩操作对用户请求的实际影响几乎降到了零。

5. 常见问题与现实排查:Context-Mode 落地避坑指南

5.1 问题一:摘要了等于没摘要,Token 还是超限

这是最常见的困惑。很多人的代码逻辑看起来完全没问题,但 Token 就是压不下来。排查时我先怀疑的是"摘要是不是走样了"。有次我抓日志发现,摘要模块输出的 JSON 格式字段里,key_facts把原文本的 5000 字几乎原样复制了一遍,完全没有起到压缩作用。原因是我交给模型的指令里写了"保留关键事实原文",模型把"原文"理解成了"保留整段原文"。解决方案是给摘要模型一个更硬的输出约束——key_facts数组里每个元素不能超过 50 个字,且最多输出 5 个元素。这样哪怕模型想多写,输出格式也把它卡死了。

另一个容易忽略的点是裁剪逻辑写错位置。有些代码是在组装 messages 之后才做 Token 裁剪,裁剪时把"用户当前输入"给误删了,导致模型收不到最新消息。我在代码里专门加了一条保护规则:messages列表中第一个和最后一个元素不允许被裁剪。最后一个其实是助手回复,但当前输入在组装时已经放到了最后的 user 消息,所以这条规则能保证当前输入绝不丢失。

5.2 问题二:长期会话的"上下文串扰"

所谓串扰,就是一个会话里的早期历史信息,突然干扰了当前问题的回答。最典型的现象是:用户两周前问过"如何申请发票",系统在长期记忆里存了一条"用户关注发票"。两周后用户问"这个季度的成本怎么核算",模型突然蹦出一句"您可以在发票管理系统里查看"。这就是长期记忆标签过粗导致的问题。我在排查中发现,长期记忆不应该只存话题标签,还应该存"时效性"和"关联条件"。比如"发票"这个标签,时效性是"已结束",关联条件是"仅当用户提到报销/发票/开票时激活"。这样在上下文组装时,只要当前用户输入没有触发关联条件,长期记忆标签就不加载。实现上不难,给每个标签加一个keywords字段,做一个简单的关键词匹配即可。

5.3 问题三:模式切换过于频繁

有些场景下,用户的问题长度波动极大,一会儿扔来一篇几千字的长文,一会儿只发"继续"。如果 Token 阈值设得太敏感,会话就会在活跃态和压缩态之间来回横跳,导致摘要计算非常频繁,系统性能白白浪费,用户体验也差。我后来加了一个状态切换冷却时间(cooldown):同一个会话在 5 分钟内最多只能切换一次模式。即使触发条件满足了,只要上次切换时间距现在不足 5 分钟,就维持原模式不变,只是把待切换的状态打一个标记,等到冷却结束后再执行。这个细节虽然小,但能显著减少无意义的重复计算。

5.4 问题四:知识库检索模块与 context-mode 打架

做带知识库的问答系统时,经常遇到一个矛盾:知识库检索出来的相关文档块,与 context-mode 维护的会话历史,都在抢同一份 Token 预算。我的做法是,给知识库文档块单独划分一个预算区间(比如 30%),不与会话历史混用。当文档块本身内容过多时,优先从历史摘要里裁剪,而不是裁文档块。因为对"知识问答"这种场景来说,知识库内容的相关性要远高于聊天历史;而在"闲聊"场景,知识库检索通常根本不会触发。把这个区分写清楚之后,系统在两种场景下的表现都变好了,不再出现"为了保留聊天记录,把真正有用的检索文档顶掉"的问题。

5.5 问题五:如何评估压缩对回答质量的影响

很多人做完 context-mode 之后,找不到一个客观的指标来衡量"压缩到底有没有损失关键信息"。只凭几个人肉测试感觉"还行"是不够的。我推荐一个我一直在用的方法:构造一组"回溯性问题",提前在对话历史里埋入一些关键事实(比如一串订单号、一个具体日期、一个价格数字),然后在一段时间后(模拟进入压缩态)让模型回答这些回溯性问题。如果模型能回答出精确数字和名称,说明关键事实保留成功;如果只能回答个大概甚至完全答错,就说明压缩策略过于激进。

把这组问题做成自动化测试集,每次修改 context-mode 的压缩策略后跑一遍,能非常直观地看出策略改动带来的质量波动。在具体标准上,我的经验是关键事实的准确率不能低于 90%。如果低于这个值,说明你为了省 Token 牺牲了太多用户核心体验,得不偿失。

6. 从"能用"到"好用":最后几点优化心得

做 context-mode 做到后期,技术层面其实已经没有太多的秘密了,真正拉开差距的反而是细节优化。这里分享几个我自己在项目里实践过的小技巧。

第一个技巧,是在系统指令层面加入"上下文时间感知"。模型本身不知道当前时间,但 context-mode 知道。在组装 prompt 时,注入一句"当前时间是 2025 年 6 月 25 日下午 3 点,历史摘要中的信息可能已过期,请优先参考用户最新消息"。这句话能有显著效果,尤其是在处理用户隔了很久重新激活的冻结会话时,模型会更倾向于接受用户当前的修正信息,而不是固执地沿用旧数据。

第二个技巧,是对关键上下文做显式的"防丢标记"。如果有 P0 级别的信息,可以在组装时把它单独作为一个system消息插入,而不是混在user历史里。因为模型对system消息的遵循度通常高于普通历史消息。我在一个数据清洗项目里,把用户反复强调的"保留空值列"这个指令,从历史对话中提取出来,单独放到 system 消息中,模型遵守指令的比例一下子从 70% 提到了 95%。context-mode 的价值就在这里——它不只是管理 Token,还在管理"模型的注意力",把重要的事情放到模型更容易注意到的地方。

第三个技巧,是后台预压缩。不要等到用户发起请求时才去压缩历史。我采用的做法是,每轮对话结束后,立刻在后台判断当前会话是否接近压缩阈值,如果接近,就异步生成摘要并更新状态。这样用户感知不到任何延迟,而每一轮请求的上下文都已经是"预压缩后"的状态。这个优化对交互体验的提升非常明显。

最后一个心得,是关于测试数据建设的。很多项目做 context-mode,测试时用一两条样例"感觉差不多"就上线了,结果在真实用户的长会话里翻车。最佳实践是,从第一天起就收集真实的匿名化对话数据,尤其是那种超长会话,用这批数据回放来验证模式切换的准确性。我经手过的两个项目,都是前期没有回放数据,上线后被一个 50 轮以上的长会话直接击穿。后来把回放机制补上了,再也没出现过类似的低级事故。

context-mode 这个东西,说起来不过是"管理上下文、分配 Token、切换模式"十来个字,但真正做进去,你会发现它本质上是一个"信息调度系统"。它需要你对模型的行为特性有感知,对业务的真实对话模式有统计,对信息的重要程度有判断。这不是一个只靠堆 prompt 技巧就能做好的模块,而是一个值得花心思反复打磨的基础能力。希望这篇文章里分享的设计思路、代码骨架和踩坑经验,能帮你在做自己的 LLM 应用时少走几段弯路。

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

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

立即咨询