☰
LLM上下文管理实战:context-mode设计思路与多轮对话踩坑记录
2026/10/5 4:36:54 网站建设 项目流程

做 LLM 应用开发这一年多,我踩过最多坑的地方,不是模型选型,而是上下文。同样的模型、同样的提示词,只要上下文管理方式不一样,效果能差出一大截。为此我们内部做了一个代号叫 context-mode 的上下文管理模块,专门处理会话记忆、系统指令、工具调用之间的组织与调度。这篇文章就把这套东西的设计思路、落地细节和踩坑记录完整写下来,希望能给同样被上下文困扰的开发者省点时间。适合的人群很明确:正在做 AI Agent、对话机器人、RAG 问答产品,或者打算把大模型接进业务系统的后端工程师,读完可以直接把里面的方案抄过去改。

1. 先搞清楚 context-mode 到底要解决什么问题

1.1 上下文不是缓存,而是“记忆的组织方式”

很多人会把上下文简单理解成“聊天记录”,实现一个 list 把历史消息往里面塞,每次调用全量拼给模型。这种做法在 demo 阶段没问题,一旦用户量上来、prompt 越来越复杂,马上会出三件事:token 超限、关键信息被冲淡、模型开始自言自语。

我更愿意把上下文看成人的短期记忆。短期记忆容量有限,但人知道哪些重要、哪些不重要,会主动重读或忽略。context-mode 做的就是这件事:不是把所有消息堆在一起,而是按层级、按优先级组织,决定每一轮请求里,模型应该看到哪些内容、先看哪些、哪些根本不需要看。说到底,上下文不是缓存,而是记忆的管理策略。

举一个最简单的例子:一个客服机器人,系统提示词里写了身份是“售后专员”,结果用户在第 20 轮时发了一句“你其实是个骗子”,这条消息如果完整留在历史里,模型很容易被带偏,后面几轮答非所问。如果没有 context-mode 的隔离机制,这类问题几乎无解。

1.2 什么场景下必须引入 context-mode

不是所有应用都需要复杂的上下文管理。写个一次性问答工具,把用户问题直接塞给模型就行。但下面几类场景,我建议从一开始就引入 context-mode,否则后面重构成本会非常高。

第一类是多轮对话助手。用户会和同一个会话连续聊几十轮,模型需要记住用户偏好、之前的结论、还没完成的事项。简单的消息列表根本做不到“记住重点”,因为每轮消息权重一样,重要信息和闲聊全混在一起。

第二类是Agent 工具调用流程。Agent 每轮可能调用搜索、数据库、API 等多个工具,工具结果往往非常长。如果把这些原始返回全量塞进上下文,一个工具调用就能占掉几千 token,而且结果中真正有用的可能只有十几行。context-mode 需要承担“工具结果提炼器”的角色。

第三类是文档问答和 RAG 应用。检索出来的片段、用户的历史提问、当前问题需要同时进入模型。文档片段之间还有优先级区分:有的片段和当前问题强相关,有的只是背景。没有上下文组织,模型非常容易把背景当成主体,回答得又散又泛。

还有一种常见但容易被忽略的场景:多租户系统。不同用户、不同业务方需要不同的系统提示词,全局的一刀切配置根本跑不动。context-mode 至少要让系统层、会话层、业务层能够灵活叠加。

2. 核心设计:把上下文拆成三个层次

2.1 系统上下文(System Context)

系统上下文对应传统提示词里的 system prompt,负责定义人设、能力范围、输出格式、禁用规则。这一层的特点是:稳定性高,优先级最高,理论上不应该被用户输入或历史消息覆盖。

我在设计 context-mode 时,一开始只把它当成一个字符串,后来发现远远不够。系统上下文需要支持分段维护,比如“基础身份”和“本轮任务目标”分开存储。基础身份长期不变,任务目标则可能在每一轮由业务流程写入或更新。

实际操作中,我习惯给系统上下文里的每条规则设置权重。比如“必须返回 JSON”是强约束,权重设为 3;“可以使用中文回复”是弱偏好,权重只有 1。当上下文长度紧张时,弱优先级的规则可以先被截断,强约束必须保留。这样模型不会因为一段不重要的风格偏好被删掉而丢失核心逻辑。

还要特别提醒一点:系统上下文不要写成一大段话塞给模型。真实项目里,我会把身份、任务、约束、示例拆成四个 key,序列化时按顺序拼接。这样做的好处是可以只更新其中某一部分,不用频繁改全量字符串,也方便排查到底是哪条规则影响了模型行为。

2.2 会话上下文(Session Context)

会话上下文就是通常意义上的“历史消息”,但 context-mode 里它不再是纯数组,而是由“原始消息流”和“滚动摘要”两部分组成。

原始消息流用于保证近期信息完整,一般只保留最近 N 轮,比如 10 轮。滚动摘要是对更早内容的压缩总结,用于维系长期记忆。举个例子,用户在第 1 轮说自己“住在杭州,喜欢喝美式”,第 30 轮问“推荐一家我家附近的咖啡店”,如果没有摘要机制,模型早就忘了用户在哪。有了摘要,即使第 1 轮的原文被清掉,摘要里仍然有“用户住在杭州,喜欢美式咖啡”这一条关键事实。

维护摘要的方式我试过很多种,最稳定的是“增量摘要”:不是把所有旧消息重新扔给模型总结,而是拿旧的摘要加上即将被清掉的最近几轮消息,合并生成一个新摘要。这么做 token 消耗小,但是需要控制合并频率。合并得太频繁,每次调用都产生额外延迟;合并得太慢,摘要和你想要的实时状态会脱节。我一般是在原始消息超过 30 轮或者内容超过窗口 40% 时才触发一次摘要生成。

会话层还需要给消息打标。哪些消息是用户明确表达的需求,哪些只是连续对话中的语气词,哪些是模型追问后的澄清,分别打上不同的 tag。后续组装 prompt 时,可以过滤掉“低价值消息”,只保留带有效信息的内容。

2.3 工具上下文(Tool Context)

工具上下文是最容易被低估的一层。早期我做 Agent,直接把工具返回全文塞进 messages,结果是模型真的会“读论文”。一个搜索接口返回 30 条链接加 2000 字摘要,模型根本分不清该看哪条。

工具上下文在我的设计里分成两部分:工具调用记录和工具结果摘要。工具调用记录只保留函数名、入参、出参概要,让模型知道“刚才发生了什么”;工具结果摘要是在工具返回后,先用一个小模型或规则引擎提取关键实体、数据和结论,再把提炼后的结构化内容放进上下文。

一个典型的例子是用户问“昨天订单为什么没发货”。内部调用订单查询接口后,原始返回可能有几百行 JSON。context-mode 会在写入上下文之前把这段 JSON 压缩成:“订单 20240201-0001 状态:延迟发货,原因:库存不足,预计送达:明天”。模型拿到这个精炼结果后,回答明显更准确,也不会被无用的仓库编码和操作日志干扰。

需要特别注意的是,工具上下文的时效性很强。同一个工具在不同时刻调用,上下文里的记录要能区分版本。我习惯于给每条工具记录加上时间戳和调用序号,当 Agent 进入新一轮决策时,只保留最近两次调用结果,更早的结果进入摘要层。

3. 落地实现:一个最小可用的 context-mode 模块

3.1 数据结构怎么定义

先不说复杂框架,直接给一个可以用在真实项目里的 Python 版本。设计时我遵循一个原则:每一层都要有独立的数据结构,层与层之间通过 ContextManager 统一调度。

from dataclasses import dataclass, field from typing import Any, Optional from enum import Enum class MessageRole(str, Enum): SYSTEM = "system" USER = "user" ASSISTANT = "assistant" TOOL = "tool" class ContextLayer(str, Enum): SYSTEM = "system" SESSION = "session" TOOL = "tool" @dataclass class ContextMessage: role: MessageRole content: str layer: ContextLayer priority: int = 1 metadata: dict[str, Any] = field(default_factory=dict) token_count: Optional[int] = None timestamp: float = field(default_factory=time.time)

为什么用 dataclass 而不是普通 dict?因为我想强制每个消息都带 layer 和 priority。在后续组装和裁剪时,这两个字段决定了消息的去留。没有这两个字段,所有消息就退化成无差别数组,context-mode 也就失去了意义。

ContextManager 的核心职责是维护一个按层分类的消息列表,并提供 add、remove、retrieve、build_prompt 四个方法。add 负责写入,build_prompt 负责按规则组装成模型输入。我不建议直接在类里维护 dictionary 放一堆 key 的映射,否则一旦逻辑复杂,每次新增功能都要改数据结构,耦合会很严重。

3.2 token 估算与截断策略

token 估算是 context-mode 绕不开的基建。不同模型的 tokenizer 规则不一样,最准确的方式是用官方 tokenizer 库,比如 OpenAI 的 tiktoken,但实际项目里不可能每收到一条消息都实时调 tokenizer,太慢。

我采用的办法是分层估算法:系统层内容变化频率低,用 tiktoken 精确算一次并缓存;会话层的用户普通文本,按字符数除以 3 估算;工具层的 JSON,按结构化程度估算。这样做的误差大概在 5% 以内,但性能能高几十倍。

截断策略才是真正的难点。我定了一套优先级规则,按顺序依次执行:

  1. 系统层中 priority=3 的强约束全部保留。
  2. 系统层中 priority=1 的弱偏好可以裁剪掉。
  3. 会话层保留最近 10 轮,更早的交给摘要。
  4. 工具层只保留最近两次调用的结果摘要。
  5. 如果仍然超限,则按时间戳从旧到新删除 SESSION 层中 tag 为“闲聊”的消息。
def trim_to_limit(self, limit: int): base_tokens = self._system_tokens() tool_tokens = self._tool_tokens() if base_tokens + tool_tokens > limit: raise ValueError("System + Tool 层已经超过窗口上限,需要调整 max_tokens") remaining = limit - base_tokens - tool_tokens summary = summarize(self.session_messages[:-10]) self.session_messages = self.session_messages[-10:] current = base_tokens + tool_tokens + summary_tokens(summary) + self._recent_tokens() if current > limit: # 从最旧开始裁剪低优先级消息 self.session_messages = drop_low_priority(self.session_messages, limit - current)

这段代码是伪逻辑,但表达了我的核心思路:系统层和工具层是“硬占用”,会话层是“软空间”。如果有任何方式能把历史记忆变成摘要,尽可能保留摘要而不是原文。摘要虽然损失细节,但至少保住了主线。

3.3 状态持久化与恢复

应用重启后上下文不能丢,所以 ContextManager 必须支持序列化和反序列化。我选择了 JSONL 格式,一行一条消息,便于追加写入,也方便人肉查看。

def save(self, session_id: str): with open(f"{session_id}.jsonl", "w", encoding="utf-8") as f: for msg in self.all_messages: f.write(json.dumps(msg.to_dict(), ensure_ascii=False) + "\n") def load(self, session_id: str) -> "ContextManager": cm = ContextManager() if not os.path.exists(f"{session_id}.jsonl"): return cm with open(f"{session_id}.jsonl", "r", encoding="utf-8") as f: for line in f: cm.add_message(ContextMessage.from_dict(json.loads(line))) return cm

这里有一个细节:恢复后要重新估算 token,而不是直接沿用保存时的值。因为不同模型版本、不同 tokenizer 可能导致估算结果变化。如果恢复后直接拿旧值去做截断判断,很可能在模型层产生意外截断。

并发场景下,同一 session 可能被多个请求同时操作,所以写入要加锁或者用追加写。我踩过一次坑:收款回调服务和主服务同时往同一个 session 写入,最后直接写坏了两行数据。后来我把写入改成单进程队列,避免并发更新。

3.4 组装顺序与最终 prompt 的生成

所有消息最终要变成一串 prompt 发给模型。组装顺序在 context-mode 里很讲究,我建议的顺序是:系统上下文、工具调用记录、滚动摘要、近几轮原始消息、当前问题。

为什么要这样排?因为模型在识别长文本时,开头和结尾的信息往往是记忆最深的。把系统上下文放开头,等于一进场就告诉模型身份和规则;把当前问题放结尾,模型会自然把注意力集中在最近的诉求上。摘要放中间,相当于背景资料,既不抢焦点,又能提供必要信息。

def build_prompt(self, current_query: str) -> str: parts = [] parts.extend(self.system_messages) parts.extend(self.tool_records[-2:]) if self.summary: parts.append({"role": "system", "content": f"历史背景摘要:{self.summary}"}) parts.extend(self.session_messages[-10:]) parts.append({"role": "user", "content": current_query}) return parts

我实际测试下来,同样的模型,同样消息内容,只是把摘要从开头换到中间,回答质量就有明显提升。这是一个容易被忽略但性价比极高的优化点。

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

4.1 上下文越写越乱?先看优先级

我们上线 context-mode 后第一次出问题,是发现模型频繁忘记自己的设定。排查了很久,最后把线上 prompt 打出来一看,系统上下文被挤到了第 50 轮的消息之后,模型早就把最前面的规则丢了。

问题根源是组装时没有严格遵守层级优先级,历史消息和系统消息混着排。修复方式很简单:组装前先按 layer 分组,再按 priority 排序,最后再拼接。建议所有开发者排查上下文问题时,第一步先导出完整 prompt,检查系统上下文是否在开头位置,别急着改模型参数。

4.2 token 爆了怎么处理

有次一个用户连续调用了 6 次数据库查询,每个查询返回 3000 字。我们的窗口是 8000 token,结果还没轮到对话,系统+工具层已经爆了。当时没有做工具结果摘要,直接把原始 JSON 全塞进去了。

后来我调整了工具层的写入逻辑:所有工具返回都先经过 extractor,只保留核心字段。如果核心字段仍然太长,就再做一层结构化压缩。实测单个工具结果从 3000 token 降到 300 token,问题立刻缓解。想提醒一点:工具上下文爆炸是最隐蔽的问题,因为用户看不到工具返回值,只能看到模型“变笨了”,排查起来非常费劲。

4.3 多轮对话中的角色漂移

角色漂移的表现是:模型在开头还正常,聊到后面突然用第二人称回答,或者反过来替用户做决定。我最初以为是模型温度太高,调低之后还是有这问题。

后来发现是历史消息里用户的某些表达进入了系统层。比如用户说“你帮我把这单取消掉”,这条消息如果被标成高优先级,模型会误以为这是系统指令。解决方式是在写入 SESSION 层时统一标记来源,系统规则只能来自系统上下文,用户消息永远不能直接提升为系统指令。

4.4 快速排查速查表

现象可能原因处理方式
模型忘记人设系统上下文被历史消息挤到后面组装前按 layer 分组,强制系统层在前
回答越来越空历史摘要太粗,关键偏好丢失增加增量摘要更新频率,保留关键实体
同一问题反复问首次回答内容被后续轮次裁掉将首次回答中核心结论写入会话摘要
模型引用不存在的内容工具结果记录过期但仍留在上下文工具记录加时间戳,超过两轮的转入摘要
输出格式不稳定系统层中格式约束被裁剪把格式约束 priority 设为最高,永不裁剪
延迟明显变高每次请求都实时估算全部 token增加缓存,系统层和工具层只算变化部分

这张表不全面,但覆盖了我们线上遇到最多的问题。建议每个团队维护一份自己的速查表,因为不同业务的上下文冲突点差异很大。

5. 我对 context-mode 的一些体会

5.1 设计时最容易犯的错

第一次做 context-mode 时,我把系统想得太复杂,设计了七八个抽象类,结果上了线根本维护不动。后来推倒重来,只保留 ContextManager、ContextMessage、ContextLayer 三个概念,反而稳定下来了。

另一个容易犯的错是只考虑裁剪,不考虑“注入”。上下文管理不只是删东西,还要主动往里加东西。比如每轮对话结束后,把该轮的核心结论提取出来,作为“临时记忆”放在系统层,下一轮就能用上。很多人把 context-mode 做成碎片清理器,其实它更应该是一个记忆增强器。

还有一点:不要试图让 context-mode 对应用层完全透明。如果业务方完全不感知上下文的存在,就会写出一堆高冲突的 prompt。后来我在 ContextManager 里加了一个 debug 方法,可以输出每一轮最终发给模型的完整 prompt,业务同事看到实际内容后,很多问题不用解释就自己解决了。

5.2 给新手的建议

如果你刚开始做 LLM 应用,不要急着上完整 context-mode 框架。先用 messages 数组跑通流程,然后记录每次调用前后的 token 消耗和模型输出,等出现明显问题再引入分层管理。我从“数组时代”到“分层时代”的转折点,是用户报告模型越聊越糊涂,而不是因为哪个框架宣传得酷。

中间优先做一件事:把系统层固定住,确保每轮调用系统 prompt 都不变。这一步完成了,再管历史消息的摘要。最后才考虑工具上下文。顺序不能反,否则你会同时面对多个变量,根本定位不了问题。

5.3 最后分享一个小技巧

每次迭代 context-mode 之前,我都保留一份“上下文导出”功能,可以一键把某个 session 的完整上下文导出成 JSON 文件。遇到模型输出异常,直接把文件拿出来人工检查,比自己猜原因快得多。有人觉得这功能是自找麻烦,但我靠着它定位了不下十个隐性 bug。这个习惯,从第一天就应该有。

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

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

立即咨询