ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Context-Mode实战:大模型上下文管理的三种模式与工程实践

Context-Mode实战:大模型上下文管理的三种模式与工程实践 1. context-mode 到底是个什么东西如果你最近在写大模型相关的应用不管是聊天机器人、Agent、还是 RAG 问答系统大概率绕不开一个词context-mode。我最早看到这个说法是在一个开源项目的配置项里当时没太当回事以为是“上下文模式”的普通开关。但真正自己上手做多轮对话功能、给企业搭知识库问答的时候才发现context-mode 不是一行配置那么简单它直接决定了你的应用是“好用”还是“能跑”决定了你的账单是“可控”还是“失控”。简单说context-mode 指的是你的应用在跟大模型交互时用什么样的策略来组织和管理历史对话上下文。它回答的是几个非常实际的问题——把所有历史都发给模型吗还是只保留最近几轮聊了很久之后前面聊过的重点怎么留住多人同时在用每个人的上下文怎么隔离这篇文章是我自己在几个真实项目中反复调 context-mode 的经验总结里面没有太多教科书理论全是踩坑、对比参数、看 token 账单之后留下的实操笔记。适合正在做 LLM 应用、对话机器人、Agent 编排的后端开发者参考也适合想了解上下文管理逻辑的产品和技术负责人当个速查手册。2. 三种主流 context 模式的底层逻辑2.1 full-context 模式直观但昂贵full-context全量上下文模式逻辑最简单把用户从第一句话到当前这轮的所有消息全部塞进 prompt 里发给模型。我第一次做聊天机器人就用的这个模式因为实现起来最不费脑子。一个数组往里面 push 消息满了就清空或者继续存每次请求把整个数组传过去。实测效果确实好模型记得所有细节用户上一周说过什么它都还记得体验很“聪明”。但问题出在钱和速度上。我们算一笔账。假设用户平均一轮对话输入 100 个 token输出 150 个 token聊了 30 轮之后第 31 轮你发给模型的内容就有将近 7500 个 token。如果这是调用 GPT-4 级别的模型按输入价格算每一轮成本在持续上升聊到 50 轮的时候光上下文就有 1 万多 token一次请求的成本已经是最开始的十几倍。更糟的是延迟。模型处理时间跟输入长度正相关上下文越长首字返回越慢。用户聊到后面会明显感觉“变笨了”不是模型退化是它要读的东西太多了。所以 full-context 适合的场景很窄测试环境、内部工具、短会话场景或者你的模型上下文窗口足够大——比如 128k 甚至 200k——且用户对话轮次本来就不多。2.2 sliding-window 模式取舍的艺术sliding-window滑动窗口模式是我现在最常用的方案也是多数成熟产品默认的选择。核心思路很简单只保留最近 N 轮对话。比如窗口大小设为 10 轮那么第 11 轮请求发出时只带第 2 轮到第 11 轮的内容第 1 轮已经被滑出窗外。用生活类比解释一下这就像你在微信聊天的“最近联系人”列表只显示最近聊过的人早前的要被顶掉。实现上比 full-context 稍微麻烦一点但也不复杂维护一个固定长度的队列超过长度就 pop 最早的元素。关键参数就是窗口大小 N——它决定了“记忆长度”和“成本/延迟”之间的平衡点。N 设置多少合适我的经验是分场景客服机器人8~12 轮足够用户描述问题一般不会拖太长编程助手类 Agent15~20 轮代码上下文往往需要跨多轮引用闲聊陪伴类可以到 20 轮以上但配合摘要模式效果更佳滑动窗口的问题也显而易见被滑出去的内容永久丢失。如果用户在第 2 轮说过“我住在深圳预算 5000 以内”到第 12 轮推荐产品时模型已经忘了这个约束结果就是频频给出不合适的推荐。2.3 summary 模式用压缩换记忆summary 模式也叫摘要模式或压缩模式是对滑动窗口丢失记忆的一种补偿。它的做法是当对话快要超出窗口上限时把这之前的对话交给模型做一轮总结压缩成一段摘要文本然后作为“历史记忆”前置到新的上下文中。举个例子用户聊了 20 轮你设置窗口为 10 轮。当第 21 轮开始时系统会先调用一次模型把第 1~10 轮的内容总结成“用户是深圳用户预算 5000偏好日系品牌已排除 A 和 B 两款产品”然后新的上下文中就带着这段摘要 第 11~20 轮的原始消息。这样既控制住了 token 总量又保留了关键信息。代价是多了一次额外的摘要调用会多花一点钱和时间但如果配置合理整体成本仍然远低于 full-context。我这里直接给出一个常用的混合方案滑动窗口 周期摘要。当对话轮数超过窗口大小的一半时触发对过期轮次的摘要压缩而不是等到全部溢出才处理。这样摘要生成的时机更均匀上下文里的“记忆”始终保持一份最新摘要 最近 N 轮原始消息的结构。2.4 三种模式的横向对比模式记忆能力Token 成本延迟实现复杂度首选场景full-context最强全记忆最高随轮数线性增长最高最低短会话、内部工具sliding-window中仅最近 N 轮可控稳定在峰值中低客服、常见问答summary较强关键信息经压缩保留中摘要调用增加少量中高中长会话、Agent 记忆管理3. 实操从零实现一个 context-mode 管理器3.1 设计思路与目录结构动手之前先想清楚一件事context-mode 不是一个什么魔法库你完全可以用几十行代码自己实现而且自己实现的好处是可控性极高——日志清楚、行为可预期、想加什么逻辑都方便。我建议的目录结构是这样的app/ ├── context/ │ ├── manager.py # 上下文管理器核心逻辑 │ ├── modes.py # 各模式的策略实现 │ ├── summarizer.py # 摘要生成封装 │ └── storage.py # 上下文持久化Redis/内存 └── main.py # 入口与 API 路由这里把模式策略独立成模块而不是塞进 manager是为了后续每加一种新模式不需要动主逻辑。3.2 核心数据结构消息队列与模式状态先说数据结构。不管哪种模式底层都需要一个有序的消息列表。我推荐用 Python 的deque来承载它有线程安全的append和popleft天然适合滑动窗口的淘汰逻辑。from collections import deque import time import uuid class Message: def __init__(self, role: str, content: str): self.id str(uuid.uuid4()) self.role role # user / assistant self.content content self.timestamp time.time() class ContextWindow: def __init__(self, max_rounds: int 10): self.messages deque(maxlenmax_rounds * 2) # 每轮含 userassistant 两条 self.summary # 摘要缓存 self.mode sliding # 当前模式注意deque的maxlen参数非常好用当队列超过上限时左侧元素自动被弹出省去了手写淘汰逻辑的麻烦。我第一版没用maxlen自己写判断结果多出不少边界 bug。3.3 三种模式统一入口build_context无论哪种模式对外暴露的核心方法只有一个build_context()它把当前的所有状态组装成一份发给模型的 prompt 消息列表。class ContextManager: def __init__(self, mode: str sliding, max_rounds: int 10): self.window ContextWindow(max_rounds) self.mode mode def add_message(self, role: str, content: str): self.window.messages.append(Message(role, content)) self._maybe_trigger_summary() def build_context(self) - list[dict]: if self.mode full: return self._build_full() elif self.mode sliding: return self._build_sliding() elif self.mode summary: return self._build_summary() else: raise ValueError(f未知模式: {self.mode}) def _build_full(self): return [ {role: m.role, content: m.content} for m in self.window.messages ] def _build_sliding(self): # deque 已经通过 maxlen 完成了滑动这里只需按格式组装 return [ {role: m.role, content: m.content} for m in self.window.messages ] def _build_summary(self): messages [ {role: m.role, content: m.content} for m in self.window.messages ] if self.window.summary: messages.insert(0, {role: system, content: f历史对话摘要{self.window.summary}}) return messages这里有个容易忽略的细节_build_sliding和_build_full看起来一样但行为完全不同。因为deque(maxlenN)在add_message阶段就已经执行淘汰了所以 sliding 模式下数据天然只保留最近 N 轮而 full 模式下我没设置 maxlenmessages 会无限增长。所有模式差异都提前到写入阶段处理build 阶段只做格式化逻辑清晰很多。3.4 摘要触发的时机与实现摘要模式的灵魂在_maybe_trigger_summary这个方法。我的触发策略是这样的当当前消息超过max_rounds的一半时每次新增消息前先清理一次“过期的老消息”并把它们一并送入摘要。import openai class Summarizer: def __init__(self, model: str gpt-4o-mini): self.model model def summarize(self, messages: list[dict]) - str: prompt_messages [ {role: system, content: 你负责压缩对话历史。请保留所有关键信息用户偏好、约束条件、已确定的事实、未解决的问题。用简洁的中文输出不超过300字。}, {role: user, content: 请总结以下对话\n \n.join(f{m[role]}: {m[content]} for m in messages)} ] resp openai.ChatCompletion.create( modelself.model, messagesprompt_messages, temperature0 ) return resp.choices[0].message.content触发逻辑我放在 ContextManager 里def _maybe_trigger_summary(self): if self.mode ! summary: return threshold self.window.maxlen // 2 if len(self.window.messages) threshold: # 把最早的一半送入摘要并移除 to_summarize list(self.window.messages)[:threshold] for m in to_summarize: self.window.messages.remove(m) if self.window.summary: to_summarize.insert(0, {role: system, content: f之前摘要{self.window.summary}}) self.window.summary self.summarizer.summarize(to_summarize)温度设成 0这一步很重要。摘要任务属于信息提取而非创作温度高会导致每次摘要措辞不稳甚至丢失细节。踩过一次坑温度默认 0.7 跑出来的摘要把“预算5000”总结成了“预算适中”差点导致推荐系统出错。3.5 多会话隔离context-mode 落地的隐藏关卡单独跑一个 ContextManager 很容易但生产环境里你的服务同时面向几百上千个用户每个人都有自己的上下文。这就引出一个关键设计每个用户/每个会话一个独立的 ContextManager 实例。我推荐用一个简单的注册表来管理class SessionRegistry: def __init__(self): self._sessions: dict[str, ContextManager] {} def get_or_create(self, session_id: str, mode: str sliding) - ContextManager: if session_id not in self._sessions: self._sessions[session_id] ContextManager(modemode) return self._sessions[session_id]前期我用全局单个 ContextManager 测试功能一切正常一上并发就出大问题用户 A 问的问题被用户 B 看到了。这不是模型幻觉是典型的上下文串台事故。所以从第一天起就把 session_id 作为上下文的第一层级隔离键。真要上多实例部署这个 SessionRegistry 需要换成 Redis 之类的分布式存储序列化方式可以选用 JSON把 Message 队列和摘要字段都存进去import redis, json class RedisRegistry: def __init__(self, redis_client: redis.Redis): self.redis redis_client self.ttl 60 * 60 * 24 # 会话保留24小时 def save(self, session_id: str, cm: ContextManager): data { mode: cm.mode, summary: cm.window.summary, messages: [ {role: m.role, content: m.content, ts: m.timestamp} for m in cm.window.messages ], } self.redis.setex(fctx:{session_id}, self.ttl, json.dumps(data))序列化时记得要带上消息时间戳。我丢了两次时间戳导致排查“为什么某个用户的历史莫名丢失”时完全无从下手恢复数据也没法按时间排序。4. 工具选型什么时候该自己写什么时候该用框架4.1 自研 vs LangChain 的取舍前几年刚出现 LangChain 这类框架时很多人直接用它自带的 memory 组件我当时也试过。LangChain 提供了多种 memory 类比如ConversationBufferMemory类似 full-context、ConversationBufferWindowMemory类似 sliding-window、ConversationSummaryMemory类似 summary 模式。听起来很省事但实际用下来发现两个问题一是抽象层级太厚。框架把消息处理逻辑黑盒化出了问题你想看中间态很难得扒源码。有一次摘要触发时机跟预期不符我翻了半天文档才知道它有自己的一套触发规则。二是升级不兼容。不同版本之间 API 变化频繁2023 年到 2024 年就经历了好几次 breaking change改造成本比自研还高。我的建议是如果你只是做原型验证或 demo用框架没问题但如果是正式产品尤其是需要精细控制 token 成本和记忆行为的场景强烈推荐自研这个 context 管理器。核心逻辑不过一百多行代码维护成本远比追随框架迭代低得多。4.2 存储层选型建议context 管理除了内存操作还涉及持久化。三种常见选型存储优点缺点适合场景内存 dict零依赖、速度快重启丢失、多实例不共享单机开发/测试Redis高性能、支持 TTL、天然分布式需要额外部署生产环境多实例SQLite/Postgres可查询、可分析读写速度不如 Redis需要审计/离线分析生产环境我首选 Redis。理由很简单TTL 自动过期功能太适合会话管理了能自动清理僵尸会话不用自己写定时任务。我的 TTL 设置是 24 小时超过一天没活跃的会话直接删除——大多数用户也不会隔一天继续上次的聊天。5. 常见问题与排查技巧实录5.1 上下文串台多用户共用 ContextManager现象用户 B 在对话中提到了用户 A 的信息往往很隐蔽比如用户 B 问“刚才那个方案多少钱”模型回答的是用户 A 对话里的方案价格。排查思路第一件事看日志确认每次请求带的 session_id 是否正确再确认后端创建的 ContextManager 是否是按照 session_id 隔离的。常见原因有两个SessionRegistry 用了全局单例或者前端没有正确传递 session_id。解决方案在每次请求进来时强制校验session_id request.headers.get(X-Session-Id) if not session_id: raise HTTPException(400, 缺少 X-Session-Id 请求头) cm registry.get_or_create(session_id, modecfg.mode)这个坑我踩得最疼第一次上线时由于这个 bug 被测试部门一口气报了好几个“隐私泄露”级别的缺陷直接被拉去复盘。5.2 摘要丢失关键数字信息现象使用 summary 模式后用户明确说过的数量、价格、日期经常在摘要后丢失或变形。比如“需要 500 件”被缩成“需要一批”。根因摘要模型在压缩时自动做了“泛化”它认为“一批”比“500件”更概括但对业务来说数字恰恰是最关键的约束条件。解法在摘要的 system prompt 里强约束“必须保留所有数字、专有名词、否定信息”。还可以在总结后加一道校验def _verify_summary(self, original: str, summary: str) - bool: # 简单做法: 提取原文中的所有数字检查摘要是否都包含 import re numbers re.findall(r\d, original) missing [n for n in numbers if n not in summary] if missing: # 需要修正或重新摘要 return False return True如果校验失败就原样保留那部分历史消息而不做摘要宁可多花几个 token 也不能丢信息。条件允许的话让摘要模型直接参照这些数字重新生成。5.3 成本失控token 消耗暴涨现象部署一段时间后账单涨幅远超预期。排查步骤先加 token 日志每次请求记录prompt_tokens、completion_tokens、total_tokens观察是否单次 prompt 太长——说明上下文模式可能是 full-context 且对话轮数很多观察是否摘要调用过于频繁——配置了 summary 模式且阈值太小导致每次对话都触发摘要我遇到过最极端的情况摘要阈值设置不合理每新增一条消息就触发一次摘要而摘要本身消耗的 token 比原始对话还多成本直接翻倍。经验参数摘要触发阈值不要小于 10 轮。摘要调用频率应该控制在“每 5~10 轮一次”而不是每轮一次。同时摘要模型用一个便宜的型号比如 gpt-4o-mini把成本压到最低。5.4 上下文顺序错乱导致模型理解偏差现象模型偶尔“忘了”最近的用户要求答非所问。根因有时候不是模型忘了而是 prompt 组装时消息顺序不对。模型对 prompt 末尾的内容注意力最强如果历史消息被错误地追加到了末尾最新的用户问题反而被淹没在中间。解法在 build_context 里强制确保最后一条消息一定是当前的 user 消息def build_context(self, current_user_message: str) - list[dict]: ctx self._build_sliding() # 移除可能的同轮旧消息 if ctx and ctx[-1][role] user: ctx ctx[:-1] ctx.append({role: user, content: current_user_message}) return ctx这个细节极其容易被忽略但影响巨大。很多“模型变笨了”的反馈追根究底就是因为消息排列顺序出了问题。6. 一些我认为特别值得记住的实操心得最后聊几句我自己的体会不展开讲每一条都是从项目里趟出来的。第一context-mode 一定要做成可配置项而不是硬编码。同一个产品不同业务线可能对记忆的需求完全不同。客服机器人要短记忆导购机器人要长记忆内部知识库问答可能要全量。上线之后通过配置中心动态调整模式省去改代码重发布的折腾。第二监控 token 是 context-mode 的基本盘。我在生产环境给每次请求都打了日志按 session 维度聚合 token 消耗。这样哪条会话开销异常一眼就能发现也能为优化模式参数提供数据支撑。第三不要迷信单一模式。我最终上线的方案其实是组合式的默认 sliding-window窗口 10 轮当会话超过 15 轮时自动切换成 summary 模式摘要集中在过期消息上同时保留最近 5 轮原文。这个组合在记忆、成本、延迟三者之间取得了不错的平衡实测平均单次请求 token 消耗比 full-context 下降约 60%用户侧“记得前面聊过什么”的满意度反而更高。第四也是我认为最重要的上下文管理不是一个技术问题而是一个产品问题。你需要想清楚你的用户到底需要多长的记忆。这个答案要来自数据分析不是来自开发者的直觉。花一天时间把用户对话轮次的分布统计出来比任何技术选型都更帮助决策。context-mode 这个组件说复杂也不复杂说简单也有很多暗坑。希望这篇梳理能帮你少走一些弯路。如果你正在做对话类产品建议从自己的场景出发先用最简单的 sliding-window 跑起来再逐步引入摘要和动态切换每一步都加上日志和监控你会对“上下文”这三个字有完全不同的理解。
返回列表