在开发一个AI辅助工具时,我被一个问题卡了很久:模型总是忘记用户前面提过的需求。后来我给自己写了一个名为context-mode的小模块,核心就是解决上下文怎么分配、怎么保留、怎么隔离的问题,说白了就是给大模型造一个"有记忆的聊天室"。这个模式适合做智能客服、Agent工作流、Copilot类应用的开发者参考,尤其是那些被Token超限、多轮对话失忆、多会话串号折磨过的人,这个设计能让你对模型看到的内容有完全的控制权。1. context-mode的核心思路:先管好上下文,再谈模型能力1.1 从一次"模型失忆"聊起有一次我在调试一个内部问答工具,用户先问了"我们部门的年度预算是80万元",隔了几轮又输入"那给我按这个预算推荐三个采购方案"。结果模型一本正经推荐了五套,完全没有把80万当上限,原因其实不复杂:对话系统每次请求模型时,模型不会自己记得上一轮说了什么,它只看到你在请求里拼了哪些历史消息。如果我当时只把最近两轮对话拼进去,预算信息就丢了。这个现象我见过太多次,尤其是刚开始做AI应用的人,总以为聊天记录自然会保留。实际上,模型是"无状态"的,状态必须由应用自己维护。context-mode就是专门解决这个问题的:它不改变模型的能力,只改变"模型能看到什么"。你可以把它理解成一个给模型看的小抄本,小抄本上写什么、写多少、怎么写,都由你的业务逻辑决定,而不是每次都把全部垃圾数据塞给模型。1.2 设计原则:显式、可控、可裁剪我最早的做法很粗暴:把用户和AI的对话全部存下来,每次都一股脑发给模型。结果有两个问题,一是Token经常超限,二是模型被无关信息干扰,回答质量反而下滑。后来我总结出三条设计原则,context-mode就是围绕这三条原则来实现的:上下文是资源,要有预算:模型窗口是有限的,不能把历史当无限存储。要像记账一样管理Token,每条消息有成本,每句历史有价格。上下文是状态,要按会话隔离:不同用户、不同任务之间绝不能互相污染。一个A用户说过的偏好,绝不能出现在B用户的请求里。上下文是知识,要按需压缩与检索:不是所有历史都需要全量保留,该摘要的摘要,该丢弃的丢弃,该重新取回时再取回。这三条原则听起来很朴素,但真正做到位并不容易。很多失败案例都是因为违反其中某一条。比如有人为了省Token,把所有历史压缩成一小段摘要,结果模型丢失了用户明确的否定信息;有人为了图简单,在全局变量里存了一份聊天记录,结果两个会话互相串号。context-mode的核心就是把这些问题制度化,形成一整套可复用的逻辑。2. 上下文存储与数据结构:不设计好数据结构,后面全是坑2.1 用一条消息结构装下完整上下文在实现context-mode的时候,我第一件做的事是重新设计消息结构。很多人直接用模型的原始消息格式,只保留role和content,这在简单场景没问题,但一旦你想做多轮摘要、来源追溯、会话隔离,就会后悔当初没多存几个字段。我最终使用的消息结构长这样:{ "id": "msg_001", "session_id": "sess_8f3a", "role": "user", "content": "我们部门的年度预算是80万元", "timestamp": "2025-06-12T10:30:00Z", "importance": 0.8, "source": "user_input", "meta": { "scene": "budget_planning", "mentioned_entities": ["年度预算", "80万元"] } }为什么要加这么多字段?session_id用于隔离会话,timestamp用于做滑动窗口裁剪,importance和meta是我为将来做摘要和检索预留的信息。当然,不是每条消息都必须填这些,但数据结构上预留好,总比以后加字段迁移要简单得多。实际发送给模型的时候,再把它简化成标准消息格式。这样做的好处是:存储层和应用层解耦,底层不管模型API怎么变化,我的历史记录始终是完整的。2.2 Token预算到底怎么算不搞清Token预算,context-mode基本没法落地。模型窗口不是让你全填满的,因为你还得留出模型生成回复的空间。如果请求里把窗口占满,模型可能只输出几个字就截断,甚至直接报错。我的经验公式是:可用历史Token = 模型窗口 × 0.8 - 系统提示Token - 工具定义Token - 预留输出Token这个公式里的0.8是安全系数,因为对话和JSON格式有时候会额外消耗Token,留20%余量能少踩很多坑。举个例子,某个模型窗口是8000 Token,我的系统提示词占了1000,工具定义占了200,我希望至少留1200给模型生成回答,那么可用历史就是8000 × 0.8 - 1000 - 200 - 1200 = 4000 Token。具体怎么估算Token?我用的是语言模型自带的tokenizer离线统计,这样最准确。如果你不想引入额外依赖,也有个粗略的规则:英文大约是4个字符算1个Token,中文大约1个汉字算1到1.5个Token。这里要注意,同一个短语在不同模型里Token数可能不一样,最好通过实际调用tokenizer来验证,别指望经验值永远准确。2.3 压缩策略:不是简单截断,而是有层次的摘要当历史消息超过预算时,最简单粗暴的做法是只保留最近几轮,但这样前期说过的关键信息可能会丢掉。我采用的是分层摘要策略:第一层:当历史Token超过预算的60%时,触发"平滑压缩"。把最早的几条消息合并成一段摘要,用摘要消息替换掉原始消息。比如用户说"我们的预算80万""重点是服务器和软件采购""劳务外包不考虑",我通过一个摘要模型生成:"用户预算80万元,关注服务器与软件采购,排除劳务外包。" 这段摘要放在消息列表的最前面,后面紧跟着最近未压缩的对话。第二层:当压缩后的摘要还是太长时,触发"硬裁剪"。只保留最近N轮原始消息,再往前的全部丢弃。但我会在系统提示词里加一句话:"以下是省略的更早期对话摘要:……"给模型一个暗示,让它知道历史不那么完整。硬裁剪不能做得太频繁,否则多轮对话质量会明显下降。我建议不要把摘要逻辑写得过于复杂,尤其是初期版本。简单方案是先固定一个阈值,超了就摘要最老的那几条。等跑通之后再引入基于重要性的判断,比如只有importance大于0.6的消息才值得保留,否则直接丢弃。3. context-mode核心流程实现:从初始化到每轮更新3.1 初始化与首次注入我习惯把context-mode封装成一个模块,对外只露出几个方法:new_session、add_message、build_request_messages、clear_session。这样业务层不需要知道内部细节。初始化流程很简单,但有一个细节很容易漏:必须在第一轮对话前就确定初始上下文,包括系统角色设定、用户画像、知识库摘要。举个例子:session = ContextSession( session_id="sess_8f3a", system_prompt="你是一个负责采购流程的助手,回答要简洁,不允许编造数据。", max_context_tokens=4000, )然后加入第一条用户消息:session.add_message("user", "我们部门的年度预算是80万元")当你调用模型时,build_request_messages会先输出系统提示词,再加入所有存储的历史消息。注意顺序很重要:系统提示词要在最前面,因为很多模型对系统提示词的注意力偏向非常高,放后面容易被其他消息稀释。3.2 多轮对话中的更新与裁剪逻辑每轮对话结束后,我会做三件事:追加用户消息、追加Assistant回复、重新计算Token预算。这里的关键点是,必须在追加完成后立刻检查是否超限,而不是等到下一次请求前再检查。因为下一次请求前你可能已经忘了这一步,而且调试的时候日志里也没有"本轮触发压缩"的记录。我实际写的更新逻辑大概是这样的:def add_message(self, role, content, importance=0.5): msg = create_message(role, content, importance) self.messages.append(msg) self.current_tokens += estimate_tokens(content) if self.current_tokens > self.max_context_tokens * 0.6: self.compress_history()compress_history里面做的就是上一节提到的分层摘要。压缩也算是一次消息变更,所以应该留下日志。我常用的是把每次压缩前的消息总Token数和压缩后的Token数都记到日志里,这样后续调参时有依据。3.3 场景隔离:多会话并存不串号这里一定要用面向对象或字典来维护每个会话的独立状态,千万不要用全局列表。我见过太多次用全局变量存消息,导致A用户看到B用户信息的惨剧。我用的核心类大概是:class ContextSession: def __init__(self, session_id, system_prompt, max_context_tokens): self.session_id = session_id self.system_prompt = system_prompt self.messages = [] self.current_tokens = estimate_tokens(system_prompt) self.max_context_tokens = max_context_tokens外部再用一个ConcurrentManager去管理所有会话,每个会话按session_id存取。业务里新增会话时,先判断session_id是否存在,存在就加载旧会话,不存在就新建。这样既不会串号,也能保证用户断线重连后上下文还在。4. 让context-mode更好用的配置与应用技巧4.1 核心配置项与推荐参数我把context-mode的配置项整理成了一个表,方便不同团队直接抄作业。注意参数不是固定的,要根据模型品类的不同动态调整。配置项类型推荐值作用说明modestringauto可选off关闭上下文管理、strict严格预算、auto自动压缩max_tokensint模型窗口×0.8所有历史消息的最大Token预算compress_thresholdfloat0.6当前Token用量超过这个比例触发压缩summary_modelstring按需选择用于生成摘要的模型,便宜快捷即可hard_limitint模型窗口×0.95超过则强制硬裁剪,不能突破enable_loggingbooltrue打开压缩和裁剪日志我在实际项目里建议先在strict模式下跑测试,也就是超了就直接报错,不许静默裁减。这样能帮你快速发现自己对话设计中的Token浪费在哪里,等优化得差不多了再切到auto模式。4.2 与工具调用联动:别让工具结果白跑当代Agent应用越来越常见,模型会调用搜索、计算器、读文件等外部工具,这时候工具调用的结果一定要作为上下文的一部分保存下来,否则模型在下一轮会完全不知道刚刚查询过什么。我在context-mode里专门预留了tool_result这类角色的消息,它和普通assistant消息不同,我会在meta里标注这是工具结果,并附带时间戳。如果这个工具结果特别长,比如一份文件全文,我不会原封不动保存,而是先做一个提取式摘要,只保留工具结果里与当前任务相关的关键片段。这样既保留了工具的作用,又不会很快耗尽Token预算。4.3 我的调参心得说点实操中的个人经验。如果任务是数学推理、代码生成这类需要连续步骤的,我不建议频繁做摘要,因为中间的推导步骤一旦丢失,后面的结论就不可信。这时我会把compress_threshold调高到0.8,尽量保留原始对话。如果任务是客服问答、资料查询,用户偏好信息比完整对话更重要,我会开启基于importance的摘要策略,把用户历史偏好单独放在一个"长期记忆"区域,每次请求都注入到系统提示词里。如果模型是那种上下文窗口很小的老款,我会默认硬裁剪为最近5轮对话,然后再把更早内容用一句话摘要带过。宁可压缩得狠一点,也别拼到窗口边界,边界附近模型的注意力往往已经很混乱了。5. 常见问题与排查实录5.1 上下文越谈越偏,到底是谁的锅这个现象我称为"上下文污染"。常见原因是历史里混入了大量角色标注错误的消息。比如有些API要求系统提示词必须由开发者设置,但是业务代码里把用户内容也塞进了system角色,模型就会把用户的话当成系统指令来听,自然越聊越偏。我的排查方法是:每个会话单独开一条debug记录,每次请求前把实际发给模型的消息列表打印出来。然后逐条检查role,确认system、user、assistant、tool_result的顺序和内容都没问题。这条流程虽然笨,但解决了我80%的"越聊越偏"问题。5.2 Token超限时是抛错还是静默截断第一次做这个模块时,我在Token超限时直接抛出异常,结果用户操作到一半程序报错,体验很差。后来我改成两级处理:一级是自动摘要压缩,二级是滑动窗口裁剪。只有在两级都失败时才抛异常。经验是:不要静默丢弃消息不留下任何标记。你应该在裁剪后向模型注入一句提示:"由于上下文长度限制,部分较早内容已被摘要。" 这样模型会知道自己看不到全部历史,至少能避免它产生"用户突然换了个话题"的错觉。5.3 多会话串号,最隐蔽的一个坑串号问题通常出现在高并发场景。我用ConcurrentManager处理后以为万事大吉,没想到有一次测试时发现B用户收到了A用户刚才提到的人名。查了很久,最后发现问题出在工具调用环节:工具执行结果没有带上session_id,异步回调时写入了默认全局上下文。所以我现在要求所有工具调用参数里必须显式传递context_id或者session_id,在回调函数里根据这个标识去找对应的ContextSession对象。这不是上下文模块本身的问题,而是外部异步代码对会话标识的传递不完整。排查时除了看ContextSession日志,还要看外部工具调用的关联ID。5.4 日志与可视化排查context-mode这类模块最需要的就是"能解释自己做了什么"。我强烈建议打开详细日志,至少记录以下内容:每次请求实际发送的Token总数和消息条数。每次触发了摘要压缩,还是硬裁剪,压缩前后的Token数。被摘要替换的原始消息ID列表。会话归属的session_id,以及是否出现session_id缺失。每次工具调用前后的上下文增量变化。我在开发时还会加一个hash值计算:对每次请求的消息列表做一个轻量哈希,记录到日志里。这样如果同一个问题前后两次回答不一样,可以快速定位是不是上下文状态发生了非预期变化。更高级的玩法是做一个可视化的"上下文时间线",把每轮消息和每次压缩动作按时间排列在界面上,鼠标点一下就能看到某条摘要由哪几条原始消息生成。这个功能我还没完全做完,但已经帮我在一次复杂调试中节省了大量时间。说回正题,经过这一轮打磨,我最深的体会是:上下文管理不是模型的外挂补丁,而是AI应用里真正决定体验的一等公民。你别指望模型聪明到能从一团乱麻里找到重点,你给它什么,它就只能在什么基础上思考。context-mode这个名字看起来像是一个功能开关,实际上它逼着你去思考"每一轮对话里,模型到底需要知道什么"。想清楚这一步,你会发现很多对话质量问题都迎刃而解了。最后分享一个小技巧:每次压缩完摘要之后,可以把原始消息里的关键实体摘出来,存成一份实体清单,随系统提示词一并注入。这样即便对话被裁短,模型依然知道用户关心的是"预算80万"和"排除劳务外包",这两个核心锚点不会丢。这个做法成本极低,但对长对话的稳定性提升非常明显。
在开发一个AI辅助工具时,我被一个问题卡了很久:模型总是忘记用户前面提过的需求。后来我给自己写了一个名为context-mode的小模块,核心就是解决上下文怎么分配、怎么保留、怎么隔离的问题,说白了就是给大模型造一个"有记忆的聊天室"。这个模式适合做智能客服、Agent工作流、Copilot类应用的开发者参考,尤其是那些被Token超限、多轮对话失忆、多会话串号折磨过的人,这个设计能让你对模型看到的内容有完全的控制权。1. context-mode的核心思路:先管好上下文,再谈模型能力1.1 从一次"模型失忆"聊起有一次我在调试一个内部问答工具,用户先问了"我们部门的年度预算是80万元",隔了几轮又输入"那给我按这个预算推荐三个采购方案"。结果模型一本正经推荐了五套,完全没有把80万当上限,原因其实不复杂:对话系统每次请求模型时,模型不会自己记得上一轮说了什么,它只看到你在请求里拼了哪些历史消息。如果我当时只把最近两轮对话拼进去,预算信息就丢了。这个现象我见过太多次,尤其是刚开始做AI应用的人,总以为聊天记录自然会保留。实际上,模型是"无状态"的,状态必须由应用自己维护。context-mode就是专门解决这个问题的:它不改变模型的能力,只改变"模型能看到什么"。你可以把它理解成一个给模型看的小抄本,小抄本上写什么、写多少、怎么写,都由你的业务逻辑决定,而不是每次都把全部垃圾数据塞给模型。1.2 设计原则:显式、可控、可裁剪我最早的做法很粗暴:把用户和AI的对话全部存下来,每次都一股脑发给模型。结果有两个问题,一是Token经常超限,二是模型被无关信息干扰,回答质量反而下滑。后来我总结出三条设计原则,context-mode就是围绕这三条原则来实现的:上下文是资源,要有预算:模型窗口是有限的,不能把历史当无限存储。要像记账一样管理Token,每条消息有成本,每句历史有价格。上下文是状态,要按会话隔离:不同用户、不同任务之间绝不能互相污染。一个A用户说过的偏好,绝不能出现在B用户的请求里。上下文是知识,要按需压缩与检索:不是所有历史都需要全量保留,该摘要的摘要,该丢弃的丢弃,该重新取回时再取回。这三条原则听起来很朴素,但真正做到位并不容易。很多失败案例都是因为违反其中某一条。比如有人为了省Token,把所有历史压缩成一小段摘要,结果模型丢失了用户明确的否定信息;有人为了图简单,在全局变量里存了一份聊天记录,结果两个会话互相串号。context-mode的核心就是把这些问题制度化,形成一整套可复用的逻辑。2. 上下文存储与数据结构:不设计好数据结构,后面全是坑2.1 用一条消息结构装下完整上下文在实现context-mode的时候,我第一件做的事是重新设计消息结构。很多人直接用模型的原始消息格式,只保留role和content,这在简单场景没问题,但一旦你想做多轮摘要、来源追溯、会话隔离,就会后悔当初没多存几个字段。我最终使用的消息结构长这样:{ "id": "msg_001", "session_id": "sess_8f3a", "role": "user", "content": "我们部门的年度预算是80万元", "timestamp": "2025-06-12T10:30:00Z", "importance": 0.8, "source": "user_input", "meta": { "scene": "budget_planning", "mentioned_entities": ["年度预算", "80万元"] } }为什么要加这么多字段?session_id用于隔离会话,timestamp用于做滑动窗口裁剪,importance和meta是我为将来做摘要和检索预留的信息。当然,不是每条消息都必须填这些,但数据结构上预留好,总比以后加字段迁移要简单得多。实际发送给模型的时候,再把它简化成标准消息格式。这样做的好处是:存储层和应用层解耦,底层不管模型API怎么变化,我的历史记录始终是完整的。2.2 Token预算到底怎么算不搞清Token预算,context-mode基本没法落地。模型窗口不是让你全填满的,因为你还得留出模型生成回复的空间。如果请求里把窗口占满,模型可能只输出几个字就截断,甚至直接报错。我的经验公式是:可用历史Token = 模型窗口 × 0.8 - 系统提示Token - 工具定义Token - 预留输出Token这个公式里的0.8是安全系数,因为对话和JSON格式有时候会额外消耗Token,留20%余量能少踩很多坑。举个例子,某个模型窗口是8000 Token,我的系统提示词占了1000,工具定义占了200,我希望至少留1200给模型生成回答,那么可用历史就是8000 × 0.8 - 1000 - 200 - 1200 = 4000 Token。具体怎么估算Token?我用的是语言模型自带的tokenizer离线统计,这样最准确。如果你不想引入额外依赖,也有个粗略的规则:英文大约是4个字符算1个Token,中文大约1个汉字算1到1.5个Token。这里要注意,同一个短语在不同模型里Token数可能不一样,最好通过实际调用tokenizer来验证,别指望经验值永远准确。2.3 压缩策略:不是简单截断,而是有层次的摘要当历史消息超过预算时,最简单粗暴的做法是只保留最近几轮,但这样前期说过的关键信息可能会丢掉。我采用的是分层摘要策略:第一层:当历史Token超过预算的60%时,触发"平滑压缩"。把最早的几条消息合并成一段摘要,用摘要消息替换掉原始消息。比如用户说"我们的预算80万""重点是服务器和软件采购""劳务外包不考虑",我通过一个摘要模型生成:"用户预算80万元,关注服务器与软件采购,排除劳务外包。" 这段摘要放在消息列表的最前面,后面紧跟着最近未压缩的对话。第二层:当压缩后的摘要还是太长时,触发"硬裁剪"。只保留最近N轮原始消息,再往前的全部丢弃。但我会在系统提示词里加一句话:"以下是省略的更早期对话摘要:……"给模型一个暗示,让它知道历史不那么完整。硬裁剪不能做得太频繁,否则多轮对话质量会明显下降。我建议不要把摘要逻辑写得过于复杂,尤其是初期版本。简单方案是先固定一个阈值,超了就摘要最老的那几条。等跑通之后再引入基于重要性的判断,比如只有importance大于0.6的消息才值得保留,否则直接丢弃。3. context-mode核心流程实现:从初始化到每轮更新3.1 初始化与首次注入我习惯把context-mode封装成一个模块,对外只露出几个方法:new_session、add_message、build_request_messages、clear_session。这样业务层不需要知道内部细节。初始化流程很简单,但有一个细节很容易漏:必须在第一轮对话前就确定初始上下文,包括系统角色设定、用户画像、知识库摘要。举个例子:session = ContextSession( session_id="sess_8f3a", system_prompt="你是一个负责采购流程的助手,回答要简洁,不允许编造数据。", max_context_tokens=4000, )然后加入第一条用户消息:session.add_message("user", "我们部门的年度预算是80万元")当你调用模型时,build_request_messages会先输出系统提示词,再加入所有存储的历史消息。注意顺序很重要:系统提示词要在最前面,因为很多模型对系统提示词的注意力偏向非常高,放后面容易被其他消息稀释。3.2 多轮对话中的更新与裁剪逻辑每轮对话结束后,我会做三件事:追加用户消息、追加Assistant回复、重新计算Token预算。这里的关键点是,必须在追加完成后立刻检查是否超限,而不是等到下一次请求前再检查。因为下一次请求前你可能已经忘了这一步,而且调试的时候日志里也没有"本轮触发压缩"的记录。我实际写的更新逻辑大概是这样的:def add_message(self, role, content, importance=0.5): msg = create_message(role, content, importance) self.messages.append(msg) self.current_tokens += estimate_tokens(content) if self.current_tokens > self.max_context_tokens * 0.6: self.compress_history()compress_history里面做的就是上一节提到的分层摘要。压缩也算是一次消息变更,所以应该留下日志。我常用的是把每次压缩前的消息总Token数和压缩后的Token数都记到日志里,这样后续调参时有依据。3.3 场景隔离:多会话并存不串号这里一定要用面向对象或字典来维护每个会话的独立状态,千万不要用全局列表。我见过太多次用全局变量存消息,导致A用户看到B用户信息的惨剧。我用的核心类大概是:class ContextSession: def __init__(self, session_id, system_prompt, max_context_tokens): self.session_id = session_id self.system_prompt = system_prompt self.messages = [] self.current_tokens = estimate_tokens(system_prompt) self.max_context_tokens = max_context_tokens外部再用一个ConcurrentManager去管理所有会话,每个会话按session_id存取。业务里新增会话时,先判断session_id是否存在,存在就加载旧会话,不存在就新建。这样既不会串号,也能保证用户断线重连后上下文还在。4. 让context-mode更好用的配置与应用技巧4.1 核心配置项与推荐参数我把context-mode的配置项整理成了一个表,方便不同团队直接抄作业。注意参数不是固定的,要根据模型品类的不同动态调整。配置项类型推荐值作用说明modestringauto可选off关闭上下文管理、strict严格预算、auto自动压缩max_tokensint模型窗口×0.8所有历史消息的最大Token预算compress_thresholdfloat0.6当前Token用量超过这个比例触发压缩summary_modelstring按需选择用于生成摘要的模型,便宜快捷即可hard_limitint模型窗口×0.95超过则强制硬裁剪,不能突破enable_loggingbooltrue打开压缩和裁剪日志我在实际项目里建议先在strict模式下跑测试,也就是超了就直接报错,不许静默裁减。这样能帮你快速发现自己对话设计中的Token浪费在哪里,等优化得差不多了再切到auto模式。4.2 与工具调用联动:别让工具结果白跑当代Agent应用越来越常见,模型会调用搜索、计算器、读文件等外部工具,这时候工具调用的结果一定要作为上下文的一部分保存下来,否则模型在下一轮会完全不知道刚刚查询过什么。我在context-mode里专门预留了tool_result这类角色的消息,它和普通assistant消息不同,我会在meta里标注这是工具结果,并附带时间戳。如果这个工具结果特别长,比如一份文件全文,我不会原封不动保存,而是先做一个提取式摘要,只保留工具结果里与当前任务相关的关键片段。这样既保留了工具的作用,又不会很快耗尽Token预算。4.3 我的调参心得说点实操中的个人经验。如果任务是数学推理、代码生成这类需要连续步骤的,我不建议频繁做摘要,因为中间的推导步骤一旦丢失,后面的结论就不可信。这时我会把compress_threshold调高到0.8,尽量保留原始对话。如果任务是客服问答、资料查询,用户偏好信息比完整对话更重要,我会开启基于importance的摘要策略,把用户历史偏好单独放在一个"长期记忆"区域,每次请求都注入到系统提示词里。如果模型是那种上下文窗口很小的老款,我会默认硬裁剪为最近5轮对话,然后再把更早内容用一句话摘要带过。宁可压缩得狠一点,也别拼到窗口边界,边界附近模型的注意力往往已经很混乱了。5. 常见问题与排查实录5.1 上下文越谈越偏,到底是谁的锅这个现象我称为"上下文污染"。常见原因是历史里混入了大量角色标注错误的消息。比如有些API要求系统提示词必须由开发者设置,但是业务代码里把用户内容也塞进了system角色,模型就会把用户的话当成系统指令来听,自然越聊越偏。我的排查方法是:每个会话单独开一条debug记录,每次请求前把实际发给模型的消息列表打印出来。然后逐条检查role,确认system、user、assistant、tool_result的顺序和内容都没问题。这条流程虽然笨,但解决了我80%的"越聊越偏"问题。5.2 Token超限时是抛错还是静默截断第一次做这个模块时,我在Token超限时直接抛出异常,结果用户操作到一半程序报错,体验很差。后来我改成两级处理:一级是自动摘要压缩,二级是滑动窗口裁剪。只有在两级都失败时才抛异常。经验是:不要静默丢弃消息不留下任何标记。你应该在裁剪后向模型注入一句提示:"由于上下文长度限制,部分较早内容已被摘要。" 这样模型会知道自己看不到全部历史,至少能避免它产生"用户突然换了个话题"的错觉。5.3 多会话串号,最隐蔽的一个坑串号问题通常出现在高并发场景。我用ConcurrentManager处理后以为万事大吉,没想到有一次测试时发现B用户收到了A用户刚才提到的人名。查了很久,最后发现问题出在工具调用环节:工具执行结果没有带上session_id,异步回调时写入了默认全局上下文。所以我现在要求所有工具调用参数里必须显式传递context_id或者session_id,在回调函数里根据这个标识去找对应的ContextSession对象。这不是上下文模块本身的问题,而是外部异步代码对会话标识的传递不完整。排查时除了看ContextSession日志,还要看外部工具调用的关联ID。5.4 日志与可视化排查context-mode这类模块最需要的就是"能解释自己做了什么"。我强烈建议打开详细日志,至少记录以下内容:每次请求实际发送的Token总数和消息条数。每次触发了摘要压缩,还是硬裁剪,压缩前后的Token数。被摘要替换的原始消息ID列表。会话归属的session_id,以及是否出现session_id缺失。每次工具调用前后的上下文增量变化。我在开发时还会加一个hash值计算:对每次请求的消息列表做一个轻量哈希,记录到日志里。这样如果同一个问题前后两次回答不一样,可以快速定位是不是上下文状态发生了非预期变化。更高级的玩法是做一个可视化的"上下文时间线",把每轮消息和每次压缩动作按时间排列在界面上,鼠标点一下就能看到某条摘要由哪几条原始消息生成。这个功能我还没完全做完,但已经帮我在一次复杂调试中节省了大量时间。说回正题,经过这一轮打磨,我最深的体会是:上下文管理不是模型的外挂补丁,而是AI应用里真正决定体验的一等公民。你别指望模型聪明到能从一团乱麻里找到重点,你给它什么,它就只能在什么基础上思考。context-mode这个名字看起来像是一个功能开关,实际上它逼着你去思考"每一轮对话里,模型到底需要知道什么"。想清楚这一步,你会发现很多对话质量问题都迎刃而解了。最后分享一个小技巧:每次压缩完摘要之后,可以把原始消息里的关键实体摘出来,存成一份实体清单,随系统提示词一并注入。这样即便对话被裁短,模型依然知道用户关心的是"预算80万"和"排除劳务外包",这两个核心锚点不会丢。这个做法成本极低,但对长对话的稳定性提升非常明显。