ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LLM应用开发必备:context-mode上下文管理方案设计与实践

LLM应用开发必备:context-mode上下文管理方案设计与实践 做LLM应用开发的几乎每天都要和上下文context打交道。最近我把一套自用的context-mode设计整理成了一个通用方案——它不是什么花哨框架就是一套把对话上下文管起来的规则和代码。这篇文章就把这套方案的核心思路、实现细节、踩坑记录都摊开来讲适合正在用大模型做对话机器人、RAG应用、代码助手的开发者和技术人员参考。1. 为什么AI应用需要独立的上下文模式1.1 模型上下文不是越多越好很多人在接大模型API时第一个直觉就是所有历史消息都塞进去模型才能记得全。这个想法对但只对了一半。上下文窗口是有限资源即便现在主流模型已经把窗口做到128K甚至更大实际使用中也不可能把用户三个月内的聊天记录全部塞进每一次请求。先看一个我最近遇到的案例。有个客服机器人项目上线时采用保留最近20轮对话的简单策略结果用户反馈经常出现前后矛盾用户三天前说过已退款成功系统今天却还在追问退款进度。原因很简单——20轮窗口里没有三天前的信息所有历史被硬生生截断了。另一个极端是什么都能加进去就全加进去。我见过有人直接把整本《三体》塞进上下文然后问模型罗辑的老婆叫什么。模型确实答对了但这次调用花费了他6块钱的token费用而且响应时间肉眼可见地慢。这种用金钱和延迟换正确答案的方式显然不适合生产环境。context-mode的核心思想恰恰是把上下文从一个被动塞给模型的黑盒变成一套主动管理的系统。它明确区分哪些记忆必须保留哪些可以压缩哪些彻底丢弃在模型能力、成本、响应速度三者之间找平衡。1.2 context-mode到底解决什么问题一句话概括它解决的是模型说了不算、你说了才算的上下文管理问题。模型本身没有记忆所谓上下文其实就是你每轮请求里送进去的文本。只要你不送模型就不知道只要你送得太乱模型就会回答得乱七八糟。context-mode用自己的规则来决定每一轮请求里该带什么、带多少、以什么形式带。具体来说它帮你解决三个问题记忆衰减历史信息不会因为轮数超限而被一刀切而是按重要程度和时效性分级保存。成本膨胀长对话的token费用随上下文线性增长不加控制很容易一个月烧掉几万块。context-mode通过压缩和检索让每次请求只携带够用的信息。内容污染不相关的旧上下文会让模型产生幻觉、跑偏主题。比如聊技术方案时混入三年前的节日问候模型很可能一本正经地分析谢谢和红包的关系。我最早写context-mode的时候就是被这三个问题逼的。有一次在群里看到一条消息说把上下文清空后模型突然变聪明了底下人都在笑但我知道那其实是真事——上下文里的垃圾信息太多反而会把正确答案淹没了。2. context-mode的设计思路短时、长时、工作区2.1 三种上下文分层的逻辑我在实现context-mode时把所有上下文分成了三层短期工作区、长期存储区、临时拼接区。这三层各有分工组合起来就能覆盖大部分对话场景。短期工作区是最接近原始对话的工作记忆。它保存最近N轮原始消息但N不是靠拍脑袋定的而是根据token预算反推。比如你的API单次预算4000 token那短期工作区的上限就设为总预算的40%左右剩下60%要留给系统指令、检索结果、模型输出。N通常取6到16之间具体看单轮消息的平均长度。如果每轮平均200 token那么12轮大约2400 token正好占预算的六成。长期存储区负责保存有价值的旧信息但不是原始文本。这个区域会把之前对话里的关键实体、决定、用户偏好、未完成事项抽出来存成结构化键值对或者摘要文本。比如用户说我周五要出差回来再继续聊长期存储区不会存原话而是存一条用户出差至周五讨论事项暂停。临时拼接区是真正发送给模型的上下文。它每次请求时由系统动态组装先放系统指令再放长期存储区中与当前话题相关的条目最后放短期工作区里最近几轮对话。这样既保证模型有记忆又控制住token开销。用生活化类比短期工作区就是桌面上的便签纸长期存储区是仓库里的档案柜临时拼接区是每次开会前你挑出来摆在桌上的文件。挑文件的功夫就是context-mode的核心算法。2.2 关键参数窗口、重叠、阈值设计完分层接下来就是三个关键参数窗口大小、重叠比例、相关度阈值。窗口大小就是短期工作区保留的对话轮数。我推荐的方式不是写死而是动态计算# 伪代码展示窗口计算逻辑 max_context_tokens 8000 system_prompt_tokens 1000 reserved_output_tokens 2000 available_tokens max_context_tokens - system_prompt_tokens - reserved_output_tokens avg_tokens_per_round 150 window_size int(available_tokens * 0.4 / avg_tokens_per_round) print(f动态窗口轮数: {window_size})按照这个算法8000 token的预算下窗口大约能保留13轮对话。为什么只分40%因为剩下的60%要留给长期检索结果和必要的临时拼装内容。具体比例可以根据你的场景调整但记住一个原则短期工作区永远不要占满全部预算要给检索和输出留出弹性空间。重叠比例用于连续分块时的上下文连续性。如果你的对话历史很长需要做文本切块时块与块之间保留10%-20%的重叠。比如每块500 token重叠50到100 token。重叠部分通常包含上一块结尾的完整句子避免切块把语义从中间腰斩。相关度阈值决定长期存储区中的哪些条目值得进入临时拼接区。我用的是基于嵌入向量的余弦相似度初始阈值设为0.45。线上测试下来CV客服场景调到0.4正好太高会滤掉太多相关历史技术文档问答调0.6比较准因为问题意图明确低相关度的内容确实会干扰答案。这三个参数不是一成不变的context-mode的模式二字就体现在这里你可以预设多个模式比如快速问答模式深度长聊模式多轮任务模式每个模式对应不同参数然后动态切换。3. 实操落地从零搭建一个context-mode管理器3.1 基础数据结构与流程有了设计思路直接看代码更直观。我用Python写了一个简化版context-mode管理器核心就三个类ShortTermBuffer、LongTermStore、ContextAssembler。from typing import List, Dict, Optional import json class ShortTermBuffer: 短期工作区保存最近N轮原始消息 def __init__(self, max_rounds: int 12): self.max_rounds max_rounds self.messages: List[Dict] [] def add(self, role: str, content: str) - None: self.messages.append({role: role, content: content}) if len(self.messages) self.max_rounds: self.messages self.messages[-self.max_rounds:] def get_recent(self, rounds: int 0) - List[Dict]: if rounds 0: return self.messages return self.messages[-rounds:] class LongTermStore: 长期存储区保存摘要、实体和数据碎片 def __init__(self, store_pathmemory.json): self.store_path store_path self.items: Dict[str, str] {} self._load() def save(self, key: str, value: str) - None: self.items[key] value self._dump() def _load(self): try: with open(self.store_path, r) as f: self.items json.load(f) except FileNotFoundError: self.items {} def _dump(self): with open(self.store_path, w) as f: json.dump(self.items, f, ensure_asciiFalse) def query(self, keywords: List[str]) - Dict[str, str]: 把长期存储区按关键词匹配粗筛可换成向量检索 matched {} for k, v in self.items.items(): if any(kw in k or kw in v for kw in keywords): matched[k] v return matched class ContextAssembler: 临时拼接区组装最终发送给模型的上下文 def __init__(self, system_prompt: str, buffer: ShortTermBuffer, store: LongTermStore): self.system_prompt system_prompt self.buffer buffer self.store store def assemble(self, current_user_input: str, recent_rounds: int 8) - List[Dict]: # 从长期存储中检索 keywords extract_keywords(current_user_input) memory_items self.store.query(keywords) # 构建最终消息列表 messages [{role: system, content: self.system_prompt}] # 把长期记忆转成人话 if memory_items: memory_text 已知的历史信息\n \n.join( [f- {k}: {v} for k, v in memory_items.items()] ) messages.append({role: system, content: memory_text}) # 加短期工作区 messages.extend(self.buffer.get_recent(recent_rounds)) messages.append({role: user, content: current_user_input}) return messages这个简化版流程已经能跑通实际生产环境我会把LongTermStore换成向量数据库比如FAISS或Milvus把query方法从字符串匹配改成向量相似度检索。但整体分层逻辑是一样的。3.2 压缩与检索的具体实现长期存储区的内容不是凭空产生的它需要定期对满轮的对话做压缩——我称之为上下文蒸馏。简单做法是在对话轮数达到阈值时调用一次LLM让它把最近N轮对话总结成200字以内的摘要并提取关键事实。# 上下文蒸馏示例用一个较轻量的模型生成摘要 def distill_summary(messages: List[Dict], llm) - str: prompt f这是最近{len(messages)}轮对话。请提取 1. 已确定的事实和决定 2. 用户的核心诉求 3. 尚未解决的事项 并以结构化的方式输出200字以内。对话内容\n{messages} return llm.chat(prompt)蒸馏产生的摘要会写入LongTermStore同时原对话会从短期工作区中清除一部分。这样短期工作区不会无限膨胀长期记忆却沉淀了下来。检索环节是context-mode的灵魂。我用两种方式混合召回层把当前用户输入和长期存储的所有键值对向量化用余弦相似度召回Top5。重排层把Top5按时间衰减和关键词匹配度重排。比如一条三个月前的事实和一条昨天的内容如果关键词都匹配昨天的排在前面。3.3 与常见LLM API对接context-mode不绑定任何具体模型。调用OpenAI风格接口时只需把assemble返回的messages列表直接传出去response client.chat.completions.create( modelgpt-4o-mini, messagesassembler.assemble(current_user_input我的退款到了吗), temperature0.3 )如果是RAG场景文档问答你可以在assemble里再加一步把按问题检索到的文档片段插入到内存信息后面。我建议的顺序是系统指令 - 长期记忆摘要 - 检索到的文档片段 - 最近对话 - 当前用户输入。之前试过把文档片段放在系统指令前面模型经常忽略它调整顺序后效果明显改善。如果用LangChain也可以直接把ContextAssembler当作自定义Memory组件的底层逻辑。不过我不太推荐无脑用LangChain的默认Memory它往往只是简单拼接历史没有分层和回收机制这正是context-mode想替换的东西。4. 真实项目中的坑与排查实录4.1 token超限与截断失踪最大的坑出现在3次函数调用的长对话场景。用户连续问技术问题每次回答都长达2000 token短期工作区虽然设置了12轮上限但实际调用时还是报错token limit exceeded。排查后发现临时拼接区里除了最近对话还有系统指令、记忆摘要和检索片段。单纯按轮数管理窗口而不计算token很容易翻车。压测几次后我更新了策略每次assemble前先调一个tokenizer统计总长如果超限优先裁剪短期工作区中的老消息其次裁剪检索片段最后才砍系统指令。import tiktoken def trim_messages(messages, max_tokens7000): enc tiktoken.get_encoding(cl100k_base) total sum(len(enc.encode(m[content])) for m in messages) if total max_tokens: return messages # 从倒数第二条反向裁剪保留系统指令和当前输入 trimmed messages[:1] messages[1:] while len(trimmed) 2 and total max_tokens: # 找到最近对话中最老的一条 idx next(i for i in range(1, len(trimmed)-1) if trimmed[i][role] assistant) removed trimmed.pop(idx) total - len(enc.encode(removed[content])) return trimmed这段代码背后有个原则永远保留系统指令和当前用户输入裁剪只发生在历史消息上。4.2 上下文污染旧信息干扰新回答第二个高频问题是上下文污染。典型案例是用户先问我想去北京旅游隔两天又问帮我查一下深圳的天气系统却把北京的历史事实也拼了进来导致模型回答中混入如果您到北京请注意防寒这类废话。原因就在于我的query方法只做关键词匹配北京和深圳在城市实体上不冲突但旅游相关记忆被误召回了。解决方法是增加一个时效衰减字段。每条长期存储记录都带有last_access_time检索时综合三个因素相似度占60%、时效占30%、访问频率占10%。打分公式写出来def relevance_score(sim, days_since_last_access, access_count): time_penalty math.exp(-days_since_last_access / 7) freq_bonus math.log(access_count 1) return 0.6 * sim 0.3 * time_penalty 0.1 * freq_bonus / 57天是一个合理半衰期因为普通用户对话里一周前的临时事实基本可以被遗忘。4.3 缓存与成本控制第三个坑是重复调用浪费token。某些轮次中长期记忆摘要两次请求之间可能完全没变化如果每次都调用LLM重新生成摘要成本会翻倍。我后来在LongTermStore里加了缓存摘要缓存只有当短期工作区新增了超过5轮对话才触发蒸馏。检索缓存相同的问题模式比如我的订单直接命中之前组装的结果跳过一次重新检索。做完这两点实际案例中token消耗下降了约40%。但注意别损失回答质量——缓存只适用于完全相同的意图不能为了省钱而答非所问。4.4 context-mode的性能与成本权衡速查表参数或策略推荐值调整依据短期工作区占预算比例40% - 60%轮次越短响应越快但记忆越差蒸馏触发轮数每新增10轮对话信息密度高的场景可降至5轮检索召回条数Top5召回过多会引入噪声过少会信息缺失内存记录保存时长30天超过30天的临时事实基本无用缓存TTL5分钟短于对话节奏易失效长于5分钟易过期这张表是我多次压测后的经验值线上使用需要根据你的模型和业务再调。我在实际项目里踩过最深的坑就是早期把所有历史都丢进模型、追求所谓的上下文完整。后来改用分层、蒸馏、检索这套context-mode结构模型回答准确率提升了大概15%每次请求成本下降了将近一半。这个收益不一定能平移到所有场景但上下文是需要管理的这一点绝对适用于任何LLM应用。如果你也想在自己的项目里实践建议从最小规模开始先给短期工作区计数、把长期记忆存成JSON、每轮看一眼token消费。等这套逻辑跑通了再升级成向量检索和分布式存储也不迟。
返回列表