ARTICLE DETAIL

资讯详情

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

大模型context-mode上下文管理:多轮对话的核心实践

大模型context-mode上下文管理:多轮对话的核心实践 去年我在给一个对话式应用做功能迭代时遇到了一个很典型的问题单次问答响应质量很高但只要用户连续追问几轮模型就开始前言不搭后语甚至把我设定的角色要求都给忘了。排查到最后问题出在一个长期被我忽略的环节——context-mode也就是上下文模式。说白了就是怎样把“聊过的天”变成模型看得懂、用得上的输入。这个看起来很简单的事情里面有大量细节窗口怎么算、历史怎么截、记忆怎么存、并发怎么隔离。这篇文章我把自己从设计到落地的完整过程整理出来包括代码、参数和踩过的坑希望能帮你少走弯路。1. 为什么需要context-mode先搞懂上下文管理要解决什么问题1.1 从单轮问答到多轮对话的坎如果你只是做一个“输入一句、输出一句”的翻译工具或问答插件确实不需要上下文模式。但一旦产品要承担“客服”“助手”“陪练”这类角色用户就天然会假设系统记得他几分钟前说过的话。比如用户先问“帮我推荐一部科幻电影”然后追一句“不要太空歌剧”如果系统不记得上一轮这个“不要”就完全失效了。背后的原因在于大多数大语言模型的接口都是无状态的——服务端不会替你保存任何会话信息它只负责根据你这次提交的messages数组生成回复。所以所谓的context-mode核心就是管理这个messages数组每次请求应该带哪些历史消息、带多少轮、按照什么顺序排列、中间是否穿插检索到的外部知识。这个数组排布得是否合理直接决定了模型是“记得住”还是“装傻”。我在最初设计时犯过一个错为了省事直接把所有历史消息原封不动地塞进messages。结果大概聊到十几轮请求就报超限错误或者被系统强行截断后模型根本没有看到最新的关键信息。后来才明白context-mode不是简单的“保留聊天记录”而是“在有限的上下文预算里尽可能保留最有价值的信息”。这个概念一旦想通后面所有设计都有了主线。1.2 context-mode的两种典型形态深入做之后我发现上下文模式至少可以分成两种形态它们解决的不是同一个问题但很多产品会一起用。第一种是会话级上下文也就是最常见的多轮对话历史。用户和助手在一段连续会话中互发消息我们需要把这些消息按时间顺序存起来并在后续请求中带回模型。这种模式的诉求是“连贯”通常是滑动窗口式地保留最近N轮对话。第二种是检索级上下文也叫知识增强上下文。当用户的问题涉及私有文档、历史工单、产品手册时我们不可能把所有资料全塞进prompt于是需要先把用户当前问题作为query去向量库或搜索引擎中召回相关的片段再把片段作为上下文注入到请求里。这种模式的诉求是“精准”核心在于召回质量和重排策略。我在项目中把两种模式做了结合先按会话级上下文维护最近对话同时把对话中的关键实体和用户偏好抽出来形成长期记忆当模型判断需要外部信息时再走检索级上下文把相关资料带进来。后文我提到的“context-mode模块”就是基于这个设计实现的。理解这两种形态你就不会被“上下文”这个宽泛的词带偏——先搞清楚你的产品需要哪种再决定怎么写代码。2. 上下文管理器设计关键参数与取舍2.1 核心数据结构消息队列既然要管理的是messages数组那么数据结构一定得选对。我用的是一个简单的消息对象列表每个消息包含role、content和timestamp三个字段必要时再加上一个token_count字段用于缓存计算。很多教程喜欢用纯数组存字符串但实际开发中你会发现没有时间戳会非常难定位问题。比如用户隔了半小时回来继续聊你到底要不要保留半小时前的历史如果没有timestamp这个问题根本无法回答。我在设计里给每条消息都加了timestamp还会额外加一个session_id这样可以很方便地按会话维度做清理和隔离。在Python里我直接用了一个dataclass来定义消息结构这样读写清晰且不容易出bug。列表本身是内存结构但如果服务重启后会丢所以我会在合适的时机把消息序列化到Redis或数据库里。内存中的队列只负责构建本次请求的messages而持久化的存储负责解决“恢复历史会话”的问题。这两者必须分开千万不要混用——我第一次就是图方便把数据库里的所有记录都当成有效上下文结果用户历史数据一多token直接爆炸。2.2 窗口大小与token计算不估算就是灾难上下文模式里最核心的一个参数是“窗口大小”也就是我们允许历史消息占多少token。这个值不能拍脑袋定得结合两个因素模型本身的最大上下文长度以及本轮回复需要预留多少token。举个例子假设模型支持8k上下文那么我通常会预留至少1k给系统提示词再预留1.5k给当前用户问题和最终回复。这样留给历史消息的预算就是8k减1k减1.5k等于5.5k。这5.5k才是我们能分配给历史消息的“真实预算”。如果你不做这个减法只盲目按8k去塞历史很容易出现模型回复被截断或者生成的回答只剩一半。计算历史消息占用token时千万别靠猜。不同模型使用的分词器不一样同样的中文在cl100k_base和o200k_base下的token数可能相差较大。我推荐用官方tokenizer库来精确计算比如tiktoken。我在代码里封装了一个count_tokens函数并在添加消息时顺手把token数存到消息对象里这样每次构建请求时不需要重新对所有历史消息做分词只要累加缓存值就行。这个优化看起来不起眼但高并发场景下能省下大量CPU时间。2.3 上下文裁剪策略截断与摘要的权衡当历史消息的token总量超过预算时我们面临一个选择是直接丢掉最早的消息还是把一部分历史压缩成摘要这两种策略各有利弊我实际用下来截断简单可靠、不会引入额外成本但会丢失重要细节摘要可以保留更多“语义压缩”后的信息但摘要本身需要调用一次模型有成本和延迟而且摘要可能损失关键数字或用户原话。我的做法是分层处理。对于最近三轮以内的消息永远保留原样因为即时对话的上下文最相关。对于更早但还没超出预算的消息保留原始内容。只有当预算真的不够时才触发一次“历史摘要”把超出部分的消息交给一个专门的摘要模型要求它提炼出用户的目标、已确认的信息、遗留事项。这个摘要会被放在system prompt里作为长期记忆而原始消息则从列表中淘汰。这样既能控制token又能让模型在聊了十几轮后依然记得用户最开始的诉求。切换到具体实现时要注意摘要不是越频繁越好。我在调试中观察到每次摘要约消耗3到5秒如果每轮对话都触发用户的等待体验会非常差。所以我会给摘要加一个“水位线”只有当被裁剪的原始消息数量超过5条或者token超过预算的30%时才执行摘要。低于这个阈值宁可多丢几条早期的寒暄话也先做截断。3. 实操实现从零写一个context-mode模块3.1 依赖与初始化别用错tokenizer这个模块我基于Python实现主要依赖两个库openai用于调用对话模型和tiktoken用于计算token。如果你是Node.js或Go环境思路完全一样只是换一个官方SDK即可。初始化时第一件事就是加载正确的tokenizer。我用的模型是gpt-4系列和gpt-3.5-turbo系列它们都用cl100k_base这个编码。如果你用的是更新型号记得去官方文档确认编码名称用错的话token统计偏差会很大。我踩过的一个坑是在本地环境用自己训练的BPE模型估算token结果和线上API实际计费差了将近20%排查了很久才发现是编码不一致。import tiktoken def get_tokenizer(model_name: str): # 根据模型名选择合适的编码 if gpt-3.5-turbo in model_name or gpt-4 in model_name: return tiktoken.get_encoding(cl100k_base) # 其他模型的编码请以官方文档为准 enc tiktoken.encoding_for_model(model_name) return enc def count_tokens(enc, text: str) - int: return len(enc.encode(text))初始化之后我会顺手测试几条中英文混合文本打印出token数和API返回的usage做一次比对。这样能尽早发现tokenizer配置错误而不是等到线上计费偏差了再回头查。3.2 上下文管理器的核心类实现下面是我实现的核心简化版ContextManager。它的职责有三个添加新消息、按预算裁剪历史、构建最终的messages列表。我没有把持久化逻辑放进来只展示内存中的核心流程方便你理解。from dataclasses import dataclass from typing import List, Dict, Any, Optional import time dataclass class Message: role: str # system / user / assistant content: str timestamp: float token_count: int class ContextManager: def __init__(self, system_prompt: str, model: str, max_context_tokens: int 8000, reserve_for_output: int 1500, reserve_for_system: int 1000): self.system_prompt system_prompt self.model model self.max_context_tokens max_context_tokens self.reserve_for_output reserve_for_output self.reserve_for_system reserve_for_system self.history: List[Message] [] self.enc get_tokenizer(model) self._sys_tokens count_tokens(self.enc, system_prompt) def add_message(self, role: str, content: str): msg Message( rolerole, contentcontent, timestamptime.time(), token_countcount_tokens(self.enc, content) ) self.history.append(msg) self._trim_history() def _history_budget(self) - int: return (self.max_context_tokens - self.reserve_for_output - self.reserve_for_system - self._sys_tokens) def _trim_history(self): budget self._history_budget() # 从前往后丢弃直到token总占用不超过预算 while self.history and sum(m.token_count for m in self.history) budget: # 注意我们至少要保留最近一条消息不能全丢 if len(self.history) 1: break removed self.history.pop(0) print(f[trim] drop message: {removed.role}: {removed.content[:30]}...) def build_messages(self) - List[Dict[str, str]]: messages [{role: system, content: self.system_prompt}] for msg in self.history: messages.append({role: msg.role, content: msg.content}) return messages这段代码的核心在于add_message每次都会调用_trim_history保证任何时刻历史消息总量都在预算内。这是我认为最重要的工程习惯不要等到请求前才裁剪而是每写入一条消息就立刻裁剪。这样即使中途有并发请求内存中的数据也始终是健康的。不过你也会发现pop(0)简单粗暴会把最早的消息直接丢掉。如果要接上摘要逻辑可以在丢弃前把被淘汰的消息喂给摘要模型生成一条“记忆摘要”。摘要内容不能存到普通消息列表里否则还会重新占token我通常把它拼到system prompt中去或者作为单独的memory字段加载进构建函数。3.3 接入LLM调用流程完整请求示例有了管理类下一步是把它接入实际的对话接口。我写了一个processor函数接收用户输入返回模型回复并把回复追加到上下文中。from openai import OpenAI client OpenAI() def chat_with_context(cm: ContextManager, user_content: str) - str: # 先追加用户消息触发一次裁剪 cm.add_message(user, user_content) # 构建 messages发送给模型 messages cm.build_messages() response client.chat.completions.create( modelcm.model, messagesmessages, max_tokenscm.reserve_for_output, temperature0.7 ) reply response.choices[0].message.content.strip() # 把助手回复也追加进历史 cm.add_message(assistant, reply) return reply实际测试时我会打印每次请求的usage信息包括prompt_tokens和completion_tokens然后对比自己管理类里的token估计值。如果偏差超过5%我会先检查是不是漏算了一些隐藏token比如不同模型的messages数组在编码时会在每条消息前后附加元数据token。官方SDK的usage是最终实际值本地估算不可能做到百分百一致但只要偏差稳定在5%以内就可以接受。我在真实项目中用这套代码处理了日均几万次请求内存占用非常稳定没有出现历史无限增长的问题。核心原因就是裁剪策略做得足够保守预算计算里给system和output都留了富余量。多留一点模型不会死少留一点回复会被截断体验立刻崩。3.4 进阶在context-mode里加入向量记忆召回会话级上下文解决的是“最近聊了什么”但用户很可能在50轮前提到过一个关键需求如果已经超出窗口被裁剪了模型就会彻底忘记。这时候需要检索级上下文来兜底。我自己的方案是在构建messages之前先把当前用户输入和历史对话中的“关键摘要”一起向量化从长期记忆库中召回top3相关片段拼进system prompt。为了不把文章变得过于复杂我在这里给一个简化思路把每一轮用户消息和助手回复拼成一条文本切块后计算embedding存入向量数据库。当新问题到来时用同样的embedding模型计算query向量检索出最相关的历史块拼成一段“长期记忆”文本插入system prompt。def build_with_retrieval(cm: ContextManager, user_content: str, retriever): query_vec embed(user_content) related_chunks retriever.search(query_vec, top_k3) memory_block \n.join([chunk[text] for chunk in related_chunks]) system_prompt cm.system_prompt f\n\n用户长期记忆中可能相关的信息\n{memory_block} cm.set_system_prompt(system_prompt) # 然后继续走普通的chat流程 return chat_with_context(cm, user_content)这个方法的关键是“召回相关片段”而不是把整个历史都塞回去。判断相关性的方式有很多最简单的是基于关键词重合更可靠的是embedding余弦相似度。我在上线初期为了减少依赖先用BM25做了关键字召回效果够用后来数据量大了之后才切换到向量召回。经验是起步阶段别急着上向量库先用简单的文本检索跑通流程等发现召回确实不准了再升级避免一开始就背太大的技术债。4. 常见问题与坑context-mode上线的各种翻车现场4.1 token超限不是等到报错才处理最常见的坑是“请求报错才想起来有超限这回事”。超限往往发生在某条用户消息特别长的时候——比如用户粘贴了一段文章进来。这时即便历史消息很少单条消息本身已经超过预算。如果还按“丢弃最早消息”的逻辑裁剪丢掉所有历史也救不了这轮请求。我的解决办法是给单条消息设置上限。当单条用户消息超过预留的输入token时优先截断或做摘要压缩而不是硬塞进messages。具体来说设定一个threshold比如单条消息最多占用历史预算的60%。如果超过就对该消息做一次“内容摘录”保留前几个要点。这样可以避免因单条超长消息导致整段会话崩溃。另外还要在调用前用safe_check预估总token超过模型上限直接返回友好错误提示而不是让SDK抛异常给用户。4.2 历史被截断后模型“失忆”有段时间我发现用户聊到第八九轮时模型会突然忘记用户一开始指定的“回答风格要简洁”这类指令。排查后发现问题不在系统提示词而在早期的用户消息中包含了风格约定而那条消息正好被滑动窗口截掉了。模型并不是真的失忆是它根本没见过这条消息。这个坑让我意识到有些信息不应该只存在于普通的历史消息中而应该被提取为“会话元信息”放到system prompt里长期保留。比如用户偏好、关键目标、禁止事项这些一旦从对话中识别出来就单独存储不被窗口裁剪。我实现了一个简单的“意图抽取器”在每轮对话结束后用正则加上少量模型调用提取这些关键信息然后更新system prompt。这样即使历史原始消息被丢弃模型的长期记忆也依然牢靠。4.3 会话串号并发场景下的消息混乱上线后碰到的另一个问题很隐蔽多个用户同时访问时A用户的历史消息跑到了B用户的对话里。最开始我以为是token缓存串了后来发现是自己造的ContextManager实例是全局单例。多线程环境下所有用户共用一个history列表自然就串号了。解决方式很直接为每个会话创建一个独立的ContextManager实例并且用thread-local或者按session_id加锁。如果是异步框架要特别注意contextvars的隔离。我在重构后把所有会话状态都挂在session_id为key的字典里每来一个请求就去取对应的实例没有则新建。这样做之后串号问题彻底消失。因此我强烈建议任何要长期保存对话状态的设计都必须把“隔离”放在第一优先级。4.4 成本与延迟不要每次都重新做摘要关于摘要好多人会想到“每当历史超限就调一次摘要模型”。我刚上线时就是这么干的结果用户每轮对话延迟增加了三五秒账单也飙升。后来我改成“只在连续追加多条消息后才做摘要”并且把摘要结果缓存起来——如果新一轮对话中token没有显著增长就直接用上一轮生成的摘要不去重复调用模型。这里有一个经验上下文裁剪一定要做“增量处理”而不是“全量重建”。我用一个标志位dirty来标记历史是否发生变化。只有新的消息被添加后才需要触发裁剪和摘要如果只是读取历史或构建请求直接返回上次的结果即可。这样大大减少了高并发下的CPU和模型调用开销也避免了很多无谓的费用。下面是我在实际项目中常见问题的速查表列在这里供你参考问题现象可能原因推荐处理方式请求报超过token限制单条消息过长或历史未按预算裁剪设置单条消息上限增量裁剪保留应急报错多轮后模型不记得早期信息早期消息被窗口丢弃抽取关键意图到system prompt长期保存不同用户对话串号全局单例管理历史按session_id隔离实例延迟突然升高每次请求都做摘要或全量重建增加dirty标志缓存摘要结果token计费比预计高tokenizer编码不匹配用官方tiktoken并核对usage回复被截断不完整输出预留token太少调整reserve_for_output至少留15004.5 几个值得坚持的工程习惯最后聊几个让我受益很大的细节。第一把所有上下文管理的参数做成可配置项而不是硬编码在代码里。我用了一个config类里面写着max_context_tokens、reserve_for_output、history_budget等。这样上线后调整参数不用重新发版直接改配置文件就行。第二所有裁剪行为都要打日志。我每次丢弃或摘要都会记录下用户ID、丢弃的消息内容和token变化。一开始觉得这些日志没意义但几次线上问题排查全靠这些日志定位。谁被丢了、什么时候丢的、丢完之后token还剩多少这些是排查“模型失忆”问题的唯一线索。第三写单元测试时不要只测正常流程一定要测边界。比如用户消息超过预算、历史消息刚好卡在阈值附近、并发请求同时触发裁剪。这些问题在真实业务里一定会遇到在开发期埋下测试用例比上线后熬夜修bug好太多了。就我个人的实际体会来说context-mode看起来只是一个“拼接历史消息”的小功能但做好它需要你对token计算、数据结构、并发隔离和缓存策略都有足够细致的把控。没有哪种方案是银弹用户画像不同窗口大小、摘要频率都要跟着调整。如果你在开发中也遇到类似问题我建议先从打印每次请求的token用量开始把线上数据看清楚后再动代码——这一步能帮你避开绝大多数隐性坑。后来我还在这个模块上做过一次小的扩展把裁剪掉的关键实体自动补进搜索索引里整体召回质量又提升了一截。上下文管理的路很长但只要把基础的数据结构和预算逻辑做扎实后面加任何新能力都不会太难。
返回列表