ARTICLE DETAIL

资讯详情

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

上下文模式设计指南:从会话记忆到注意力分配的最小实现

上下文模式设计指南:从会话记忆到注意力分配的最小实现 1. 先搞清楚context-mode 到底在解决什么问题第一次让我认真琢磨 context-mode 这四个字是在一个挺头疼的现场。当时我用某个 AI 编程助手改一个老项目前一天已经跟它聊了二十多轮把模块结构、命名规则、异常处理约定都交代过一遍。第二天打开新会话问它把刚才说的 default handler 补充完整它直接回答你在说什么 handler我那一瞬间的感受和所有用过失忆版聊天机器人的人一样这功能好是好但怎么就不能记住点东西呢。这时候你需要的不是一个更聪明的模型而是一个更聪明的上下文模式。context-mode 翻译过来就是上下文模式它本质上是一个功能开关决定系统在处理当前这次请求时到底携带哪些历史信息、周边信息以及怎么携带、携带多少。它不负责替你思考它负责决定思考的时候应该把哪些材料摊在桌面上。为什么这个东西最近忽然成了热词因为大量产品都开始遇到同一个尴尬模型越强用户越懒——大家都默认它能记住上下文可它一旦真的什么都记又会带来误判、发散、昂贵、隐私泄漏一堆新问题。于是 context-mode 就成了一个绕不开的设计话题。本文我会按自己的实际经验先讲清楚它解决什么再讲设计取舍然后给出一套可以直接抄走的最小实现代码最后聊聊我踩过的坑和验证方法。1.1 上下文模式的本质在记忆广度和注意力纯度之间找平衡把模型看成一位刚入职的员工。这位员工能力很强但有两种极端一种做法是你每次都只给它当前这一张工单它当然不会瞎忙活但也永远学不会你的项目惯例另一种做法是把你过去三年所有聊天记录、代码、邮件全部堆给它它反而会被无关信息干扰。上下文模式就是在中间设计出一个记忆管理机制什么时候该忘、什么时候该记、什么时候该翻旧账。在技术实现上影响上下文模式的变量主要有这几个历史消息的长度到底往前翻多少轮对话内容来源对话记录、代码片段、本地文件、数据库查询结果哪些要进提示词召回策略按时间顺序取还是按和当前问题的相关度取压缩方式超过窗口长度之后是直接截断还是先做摘要这四个变量组合起来基本就能决定一个上下文系统是聪明还是烦人。我在实际项目里最常用一句话来设计最少够用。只把和当前任务直接相关的上下文放进来而不是把整个历史都搬进提示词。后面我会给出一套可以抄走的实现框架现在先看几种典型场景方便你快速对号入座。1.2 三种典型场景快速对号入座场景一客服或文档助手。用户在同一个会话里连续提问前一句说我的订单号是 20240515下一句问这个订单几天能到如果助手没有上下文它根本不知道这个指的是什么。这里 context-mode 要解决的是代词和省略句的指代问题通常保留最近三到五轮对话就够了。场景二代码结对场景。AI 需要同时知道当前打开的文件、最近改动的 diff、以及会话里讨论过的设计约定。这不是简单地把文件全文塞进去而是要做取舍文件很大时只抽取和当前光标位置相关的函数、类、引用关系。context-mode 在这里更像一个项目经理决定把哪几个文件片段放进本轮对话。场景三多租户后台系统。每个用户、每个项目都有独立的数据边界。如果上下文模式做得不严谨A 项目里讨论的变量名、业务规则可能会在下一次请求里悄悄混进 B 项目。这种串号比回答得不好严重得多是设计红线。你可以对照自己的业务判断一下如果产品里已经有历史记录、会话、粘贴上下文这类设计那 context-mode 基本就是帮这些功能建立规则的那个大脑。它不抢模型的活它只是告诉模型这次睁眼看哪几页文件。2. 上下文模式设计的四个关键取舍很多团队第一次做 context-mode 的时候思路特别朴素把 chat history 全部拼起来再塞进 system prompt。这样做上线第一天可能没问题跑一周后问题会越来越多。我整理了这个阶段最值得花时间的四个设计点。2.1 收集范围不是全都要而是按需取收集上下文的第一步是搞清楚有哪些候选材料。常见来源包括来源典型内容要不要默认进 context备注当前会话历史用户和 AI 的历史问答要需要做轮次截断打开的文件/页面正在编辑的代码、文档按需文件太长时必须分段检索结果向量数据库召回的知识片段要加相关性阈值用户画像偏好、权限、身份信息谨慎权限信息尽量不在提示词里明文出现全局配置语言、模板、业务规则可以优先级最高但保持精简我的经验是给每个来源设一个优先级而不是平均用力。比如当前会话里刚讨论过的结论优先级应该高于一个月前的零散笔记用户正在看的这个文件优先级应该高于检索到的泛泛相关文章。实现上就是一个带优先级字段的 Collector先把材料收集齐再统一排序和裁剪。这里有个很容易犯的错以为上下文 对话记录。实际上对话记录只是其中一部分真正影响回答质量的是当前任务相关的那一小撮材料。我做项目时会把对话记录和任务材料分开存因为它们的过期速度和压缩方式完全不同。对话记录可以按时间窗口滑动任务材料更多按主题去重。2.2 上下文窗口的保鲜策略收集完材料之后下一个问题这些材料哪些还新鲜哪些已经过期我常用的保鲜策略有三种滑动窗口只保留最近 N 轮对话比如 10 轮。优点是实现简单、成本可控缺点是第 11 轮之前的关键约定可能被丢掉。时间衰减给每条上下文打个时间戳超过一定时间后优先级指数下降。适合用户上次提到的东西这类场景但不适合项目技术架构这种长期稳定信息。语义摘要每隔一段时间把旧对话压缩成一段摘要比如用户希望用 polars 替代 pandas已完成数据读取部分的改造。摘要类上下文不会占太多 token又能保留长期约定。三种策略可以组合使用不一定非要单选。我线上那个项目的方案是最近 10 轮用原文保留10 轮以前的对话做成摘要项目级的约定单独存放在一个 always-on 的短段落里。这样既不丢长期约定也不会被几千轮闲聊撑爆窗口。2.3 上下文切换时的边界划分想象你在用同一个 AI 助手管理三个不同项目。上一个项目的对话里提到这个项目要用 PostgreSQL切到下一个项目时如果上下文没有做边界处理模型可能真的会把 PostgreSQL 当成新项目的技术选型。这就是典型的上下文污染。边界划分要解决两件事垂直边界不同用户、不同租户之间的数据完全隔离。水平边界同一个用户的不同任务、不同会话之间上下文不能互相串场。我见过一个比较取巧的实现每次请求都带着一个context_id它决定了 ContextManager 要从哪个存储桶里取历史。切换项目就是切换 context_id。这个方案看似简单但非常有效。比在提示词里写请忽略上一个项目的对话靠谱一万倍。还有一个细节用户主动开新会话时很多系统只是清零了聊天数组却没有清理嵌入式缓存。结果是模型看起来不记得对话但检索模块还能搜到旧内容。高级一点的 context-mode 会在开新会话时重建一个隔离的检索索引而不是沿用全局索引。3. 实现一套最小可用的 context-mode理论讲了这么多终究要落到代码上。下面这套实现是我自己项目里抽出来的简化版核心就一个文件不依赖任何特定厂商的 API换到任何语言模型服务都能用。目标不是做产品级方案而是把设计思路跑通让你知道每行代码在干什么。3.1 模式定义与开关策略先定义四种模式from enum import Enum class ContextMode(str, Enum): OFF off # 完全不带上下文适合纯计算、翻译、改写 LIGHT light # 只带最近一次交互适合快速问答 FULL full # 带完整会话历史适合长任务推演 AUTO auto # 根据输入信号自动判断为什么需要 OFF 模式因为我踩过坑有些短问题比如帮我把这段 JSON 转成 YAML带上历史反而会让模型分心甚至把之前讨论里的错误延续下来。上下文不是必备品是增压器。很多时候不带上下文才是正确答案。AUTO 模式的判断逻辑我这里用一个很轻的启发式规则def auto_uses_context(user_input: str) - bool: # 出现代词或承接性短语时一般依赖上文的指代 signals [它, 他们, 这个, 这个项目, 上面, 刚才, 上次, 继续, 那] return any(token in user_input for token in signals)这就是一个很朴素但好用的基线。如果你做面向特定领域的工具可以把这套规则换成自己的意图识别模型。记住一个原则AUTO 模式宁可漏判不要误判。漏判最多让用户多写几个字误判会直接把答案带偏。3.2 核心实现ContextManager下面这个类负责收集、排序、裁剪上下文。注释我会写得尽量详细from dataclasses import dataclass, field from typing import Callable, Optional dataclass class ContextSource: name: str # 来源名称如 history、file、retrieved content: str # 内容文本 priority: int 5 # 数值越小越优先进入 created_at: float 0.0 # 时间戳用于保鲜判断 class ContextManager: def __init__(self, mode: ContextMode ContextMode.AUTO, budget: int 4000): self.mode mode self.budget budget # 上下文总预算单位近似 token self.history: list[dict] [] self.retriever: Optional[Callable[[str], list[str]]] None def bind_retriever(self, fn: Callable[[str], list[str]]): 绑定一个外部检索函数输入是用户问题输出是一批候选文本 self.retriever fn return self def add_message(self, role: str, content: str): self.history.append({role: role, content: content}) # 历史最多保留 100 条超过就丢弃最早的防止内存无限增长 if len(self.history) 100: self.history self.history[-100:] def should_use_context(self, user_input: str) - bool: if self.mode ContextMode.OFF: return False if self.mode in (ContextMode.LIGHT, ContextMode.FULL): return True # AUTO 模式看输入是否包含承接性信号 return auto_uses_context(user_input) def _collect(self, user_input: str) - list[ContextSource]: sources: list[ContextSource] [] # 1. 历史对话 if self.mode ContextMode.LIGHT and self.history: tail_text \n.join( f{msg[role]}: {msg[content]} for msg in self.history[-2:] ) sources.append(ContextSource(history, tail_text, priority1)) elif self.mode in (ContextMode.FULL, ContextMode.AUTO): history_text \n.join( f{msg[role]}: {msg[content]} for msg in self.history ) sources.append(ContextSource(history, history_text, priority1)) # 2. 外部检索结果 if self.retriever is not None and self.mode in (ContextMode.FULL, ContextMode.AUTO): for hit in self.retriever(user_input): sources.append(ContextSource(retrieved, hit, priority3)) return sources def build_prompt(self, user_input: str) - str: if not self.should_use_context(user_input): return user_input sources self._collect(user_input) # 按优先级排序从高到低取这里是演示token 数直接用字符数估算 ordered sorted(sources, keylambda s: s.priority) blocks [] used_chars 0 budget_chars self.budget * 4 # 粗略按 1 token ≈ 4 个中文字符估算 for src in ordered: piece f【{src.name}】\n{src.content} if used_chars len(piece) budget_chars: continue blocks.append(piece) used_chars len(piece) if not blocks: return user_input prefix 以下是与当前任务相关的上下文请优先参考。\n return prefix \n---\n.join(blocks) f\n\n用户的问题是{user_input}这段代码里有几个设计点是刻意的。第一LIGHT 模式取最近两条消息而不是一条。因为只取一条常常不够用户上一轮可能是在陈述背景这一轮才真正发问如果只取一轮背景信息就丢了。取两条是很便宜又很稳的折中。第二token 预算用字符数估算。正式产品里一定要接一个分词器但演示阶段用字符数可以帮你快速验证逻辑。真实项目里这个估算函数建议替换成tiktoken或其他语言对应的 tokenizer否则预算形同虚设。第三超过预算的内容直接丢弃而不是做摘要。这是为了让你先把主链路跑通。后面你可以把drop的分支换成调一次摘要模型把 src.content 压缩后再放进来。我见过很多团队一上来就搞摘要结果代码里全是魔法主链路都跑不稳。3.3 接入请求流程ContextManager 不关心你怎么把数据发给模型它只负责产出最终的 prompt 字符串。一个典型的接入流程是这样def chat_with_context(manager: ContextManager, client, user_input: str) - str: # 1. 决定要不要带上下文 if not manager.should_use_context(user_input): return client.generate(user_input) # 2. 构建带上下文的 prompt prompt manager.build_prompt(user_input) # 3. 记录这轮的问答供下一轮使用 manager.add_message(user, user_input) response client.generate(prompt) manager.add_message(assistant, response) return response这段代码特别短但你要注意add_message的调用时机。我见过不少新手把历史记录在build_prompt之后才追加导致这一轮的问题永远进不了上下文。正确的顺序应该是先add_message(user, user_input)再build_prompt再add_message(assistant, response)。严格来说add_message(user, ...)应该放在build_prompt之前这样上下文拼接时能把当前问题本身也考虑进去。不过如果模型接口会自动追加当前用户输入那放在之后也可以关键是团队内部要统一约定否则调试时会非常痛苦。3.4 让 AUTO 模式更聪明的两个低成本升级上面代码里的 AUTO 只是关键词命中实际用起来还是太糙。想升级又不想投入太多人力我有两个推荐方向。第一把当前场景作为判断条件。比如编辑器场景里用户正在改某个文件那上下文大概率需要带上当前文件中光标附近的代码如果用户只是在闲聊那历史上下文就不必带。这个升级几乎不依赖模型能力只靠产品埋点数据就能完成。第二把上一个动作作为判断条件。如果用户上一步是点击了一张图片、一段代码、一个报错信息那么即使这轮没有代词也应该把那个对象带进上下文。这个方案的实现就是一个简单的最近选中对象缓存成本极低但效果提升非常明显。4. 实测踩坑context-mode 翻车的五种姿势代码能跑通只是第一步。真正让我对这个话题有发言权的是后面这一堆翻车现场。我把它们按频率排序写出来你遇到的时候能少走很多弯路。4.1 上下文通胀塞得越满答得越偏我第一次把 FULL 模式放到真实场景跑用户反馈是模型变笨了。排查以后发现问题不在模型而在我把过去 100 轮聊天记录全塞进去了。那些对话里有大量试探性的、被否定的、最终改过三四版的方案。模型拿到这些互相矛盾的中间过程自然容易抓错重点。后来我做了两个调整一是只保留最终结论不保留过程二是每次对话结束让模型把当前会话浓缩成三条短状态比如已完成 A 模块重构待确认数据库索引方案用户偏好 error message 用中文。下一轮请求优先参考这三条状态而不是原文重放。这个做法解决了我 90% 的上下文通胀问题。4.2 陈旧上下文被反复调用另一个高频坑是上下文里保留着几天前的一个临时假设用户早已推翻但检索系统不知道每次提问都把它捞回来。模型一看上下文里有这个材料就继续沿着错误方向回答。要处理这个得在上下文源里加一个状态字段比如 active、superseded、archived。被用户明确否决的结论应该立刻标记为 superseded检索时排除。这个动作可以由模型自动完成当用户说不行换一个时产品触发一次上下文状态更新把那轮讨论标记为失效。4.3 多用户会话串号与数据隔离这个问题我说严重一点它是所有 context-mode 错误里唯一会造成事故的。串逻辑看着很简单就是一个 context_id 用错了。但真实系统里context_id 往往不是一个值而是一组值比如 user_id、team_id、project_id、session_id但凡有一个没带对就可能读到别人的数据。我在代码里处理这个问题时会刻意让 ContextManager 的context字段不是普通字符串而是一个白名单列表只有显式传入的 context_id 才能命中对应存储桶。并且会把history数组和retriever都绑定到同一个 context_id 上。不做这个强制绑定只靠开发人员记得传参早晚会出事。4.4 盲目截断导致语义断裂窗口超了怎么办很多人的第一反应是按 token 从后往前截。这会导致一个很滑稽的结果句子的后半截全被截掉剩下的是一堆有头无尾的残句。模型读上下文就像读一篇被撕掉后半页的文档理解效果当然差。我后来的做法是宁可保留更长的摘要也不要保留残缺的原文。具体来说截断之前先按句子边界切割并且给每个片段打分优先保留包含实体和动词的句子。打分策略可以很粗糙哪怕只是包含数量词的2 分、包含疑问的2 分都比纯按位置截断强得多。4.5 没有兜底策略一次性请求的高昂代价最后一个坑很隐蔽不是所有用户都会妥妥地把问题写在最后一句。有的用户会先甩一篇文章再问一句你总结一下。这种时候上下文模式只拼用户最后一句是不对的你得把用户当前输入的整体都当成上下文来源。我加了一个兜底策略如果用户本次输入超过 2000 字默认认为这是一次材料投喂型请求直接把这段材料作为上下文主体而不是把它塞进 user 消息里等待模型消化。这样模型不会因为用户消息过长而偷工减料。5. 怎么证明你的 context-mode 真正变好用了很多团队在 context-mode 上线后只有一个主观感受好像变聪明了点这不够。要验证一套上下文系统有没有用你必须能复现、能对比、能量化。5.1 自建上下文评测集我建议准备一份 50 条左右的评测集每条包含三个部分前置会话、用户当前问题、期望回答。特别注意包含以下类型指代型前置会话里定义过我们说的那个模块当前问题问它什么时候改完。考察上下文模式能否正确解析指代。干扰型前置会话讨论过不相干的 A 项目当前问题问 B 项目。考察上下文隔离是否严密。长程记忆型前置会话在第 40 轮提出一条约定第 100 轮再次引用。考察摘要和滑动窗口是否保留了关键约定。无上下文型用户直接问11上下文模式应该不携带任何历史。考察开关策略是否够干净。评测集建好后每次改代码都跑一遍回归。我个人的及格线是指代型 90% 以上答对干扰型 100% 不串号长程记忆型至少 70% 能还原约定。5.2 上线指标准确率之外还要看恢复成本准确率只是第一个指标。真正让我下定决心调整 context-mode 的是下面这三组数字平均响应延迟开启 FULL 模式后请求体积变大延迟会明显上升。如果延迟超过用户容忍范围即使答案更准产品也是失败的。token 成本上下文不是免费的。每多带 1KB 上下文平均每次请求就多付出固定成本。你可以算一笔账日活 10 万每次多 2000 token一天多多少钱。用户修正次数用户发出提问后多久会再追问一句不是我的意思是…。这个指标比主观满意度更能反映上下文理解效果。我建议做一个简单的 A/B 对比一部分用户走 FULL一部分走 LIGHT一部分走 AUTO跟踪这三组指标。不需要等很久一周就能看出明显差异。5.3 接下来可以让 context-mode 更聪明的方向如果你已经跑通了上面的基础实现下面这几个方向值得继续投入。第一意图驱动的动态预算。不同问题的上下文需求差异很大向量化检索相关问题需要大量知识片段纯格式转换问题则不需要。根据意图分配 token 预算是成本优化的下一个台阶。第二语义级摘要压缩。现在的摘要还是模型把一堆文字压缩成三行但摘要应该做到事实级保真人名、数字、时间、否定信息都不能丢。可以做一层规则校验摘要生成后再拿原文做一次比对如果重要的实体没出现在摘要里就补充进去。第三长期记忆的遗忘曲线。借鉴人脑记忆规律近期信息高权重远期信息只保留高显著性部分。每次访问都让显著性加一长期不访问的自动降权。这样 context-mode 就不只是窗口滑动而是真正有了记忆模型。最后说点我的真实体会。以前我总把 context-mode 当做一个 on/off 开关现在更愿意把它理解成一套注意力分配策略。我线上那个项目最终采用的是 AUTO LIGHT 的组合短会话什么都不带长会话只保留最近一两轮加自动摘要检索召回命中时才把相关片段放进去。效果比 FULL 模式稳定很多账单也更干净。这个取舍不一定适合所有场景但至少说明一点——context-mode 真正值钱的地方不是有多少上下文而是知道该给模型多少。
返回列表