类似于上下文模式这种概念,很多人在开发终端工具时都遇过——AI 助手在对话里跟你衔接不上,刚交代完背景,下一句它就"失忆"了。我在这块折腾了一阵子,今天把 context-mode 从设计到落地踩过的路完整梳理一遍,包括它解决什么问题、怎么设计、怎么用代码实现,以及几个我实测翻过车的地方。
1. 先聊清楚:context-mode 到底在解决什么问题
1.1 三种没有上下文时的"断片"现场
最早我是在做一个终端问答工具时被逼着研究 context-mode 的。工具本身不复杂,就是在命令行里问 AI 一些问题,让它结合当前项目的信息给我答案。起初我天真地认为,只要把用户的问题原样丢给大模型就够了。结果很快就出现了三种很典型的"断片"现场。
第一种是对话衔接断裂。比如我先问"帮我看一下src/main.py里那个异步任务为什么超时",AI 给了分析;我再追问"那如果我把超时时间调到 30 秒,会不会影响后面的重试机制",它直接懵了——因为它压根不知道我说的"超时时间"是哪个函数的参数,更不知道"重试机制"和前面那段代码有什么关系。
第二种是项目状态缺失。我明明在这个 Git 仓库里切到了feature/xxx分支,刚刚改完一个文件还没提交,问 AI"帮我看下我现在的改动会不会破坏已有的单测",它却完全不清楚当前分支、当前工作区状态、最近改了哪些文件,于是只能给一堆正确的废话。
第三种是输出风格漂移。上午还在写代码,让它"用简洁的技术语言回答";下午切到写文档场景,想问"给我写一段面向新手的说明",它又用代码注释的口吻给我整了一段,因为缺少"当前使用场景"这类软上下文。
这些问题的本质其实是同一个:传统的查询接口是"无状态"的,而真实使用场景天然带有大量背景信息。context-mode 这个设计,就是用来把"背景信息"显式地组织成一个上下文快照,让工具在合适的时机把它喂给模型,而不是每次都在零基础上凭空猜。
1.2 context-mode 不是什么玄学,而是一个明确的设计决策
我刚接触 context-mode 的时候,以为它是什么高深的自适应算法,后来发现它的核心远比我想象的朴素。所谓 context-mode,本质上就是回答两个问题:哪些信息值得放进上下文?以什么结构放进去?它既不是把所有历史记录都一股脑塞给模型——那样 token 会爆炸,也不是靠某个模型"记住"之前说过的话——大模型本身没有持续记忆,它只是每次都把所有内容重新读一遍而已。
你可以把它类比成新同事入职时的交接文档。一段对话就是一个"项目",上下文的取舍就是交接文档的内容选择:需要包含项目背景、当前进度、关键文件位置和约定俗成的术语表,但不需要把公司五年来的每一封邮件都背下来。context-mode 的核心,就是通过一套规则来决定"这份交接文档有多厚、包含什么章节"。
所以它通常是一个三元组的组合:采集器(Collector)+决策器(Decider)+组装器(Builder)。采集器负责从当前环境里收集信息;决策器判断当前这个问题是否需要丰富上下文,以及需要到什么程度;组装器负责把上下文和用户问题组合成一份结构化的 Prompt。后面我在第三部分会给出一个完整的代码骨架,这里先把设计层面的问题说透。
2. 设计 context-mode 前,必须想清楚的四个问题
别急着写代码。我第一版 context-mode 就是直接上手写的,结果功能是能跑,但稍微一换应用场景就束手束脚。后来我重新梳理,发现真正需要提前想清楚的问题就这么四个。
2.1 上下文从哪来:显式提供还是自动采集
这是一个方向性问题。显式提供的意思是让用户自己声明"我希望你参考这些内容",比如拖一个文件进来、贴一段代码、写一段背景说明。自动采集则是工具通过观察环境自行获取,比如读取当前目录的文件列表、解析 Git 状态、翻看最近的命令历史。
我的建议是两者都要,但以显式提供的权重更高。自动采集的好处是省事,但问题是噪声特别大。举个实际例子,我最初做自动采集时把当前目录下所有.md文件的前 50 行全部取出来当上下文,结果用户正在做一个 Python 项目,目录里那份README.md写的是团队团建规则,模型老是在回答里莫名其妙提到"周五羽毛球活动"。
自动采集必须经过一层"相关性过滤"。文件,看修改时间和扩展名;Git 状态,看 is_git 仓库和当前分支以及工作区是否有未提交变更;命令历史,取当前会话最近的几十条。显式提供的部分则作为最高优先级,用户给什么就用什么,不猜测、不裁剪。后来我把这两者的关系定成"显式覆盖自动、自动按权重降级",整体让工具稳重了很多。
2.2 时间窗口与滑动窗口的取舍
上下文不是越多越好,这里有两个机制要选:固定窗口还是滑动窗口。固定窗口好理解,比如"只看今天修改过的文件""只看最近一小时内的事件";滑动窗口则是保留最近 N 条交互记录,旧的自然被丢弃。
我只提醒一个容易被低估的问题:真实场景下"最近"不一定最重要。你在终端里查一个 bug,最关键的上下文是"最早一次报错时的堆栈"而不是"三分钟前我 ls 了一下目录"。只用时间维度去裁剪上下文,会让工具看起来反应迟钝——它明明拿到了所有信息,回答却答非所问。
所以我给窗口加了"锚点"机制。锚点就是那些和当前任务强相关的不可丢弃信息,比如:当前命令直接引用的文件名、Git 当前分支、最近一次提交的 message、显式指定的文件路径。滑动窗口负责"滚动丢弃"常规记录,锚点负责"永久钉住"关键信息。实际效果是,用户就算隔了 20 条指令再问"刚才那个报错",模型依然能接上,因为它每次组装上下文时都会重新把锚点放进去。
2.3 上下文的权重体系:哪些信息优先进入模式
采集器拿到的信息种类很多,如果一视同仁,Prompt 会被大量低价值内容占满。我后来引入了一个简单的权重体系,给每种上下文标一个分值,分值高的先进,直到预算耗尽:
| 上下文类型 | 权重分值 | 说明 |
|---|---|---|
| 用户显式提供的内容 | 100 | 比如拖拽进来的文件、粘贴的代码,必须优先使用 |
| 当前命令相关文件的摘要 | 80 | 通过文件名或路径关联判断 |
| 当前 Git 状态与最近提交 | 60 | 分支名、未提交变更、最近一次提交说明 |
| 当前会话最近 5 条交互 | 50 | 只保留精简后的内容,不做全文堆叠 |
| 目录结构快照 | 30 | 仅目录树,不含文件内容 |
| 环境信息 | 10 | 操作系统、当前时间、运行时版本等 |
这个表不是死的,不同工具体感差异很大。核心思想只有一条:先保命、再保准、最后才是丰富。命保住了,也就是用户不会得到完全无意义的回答;准保住了,也就是能命中关键信息;充足的内容是在前面都满足之后才考虑的事。
2.4 模式切换的策略:无感知切换还是显式切换
context-mode 既然带"mode"这个词,就必须回答一个关键问题:什么时候用完整上下文,什么时候用极简上下文?
我一开始很天真,想做一个完全自动的决策器,通过分析用户的问题来判断要不要带上下文。跑了一阵子之后发现,自动决策永远做不到 100% 准确。你问"明天天气怎么样",显式提供的内容是 50 个代码文件,模型依然会被代码干扰;你问"这个函数的复杂度如何",如果缺少当前文件内容,模型只能泛泛而谈。
更好的策略是"默认带、允许关、关键场景强制带"。默认带上下文意味着普通问题优先参考环境信息;允许关是指在配置里提供--no-context或环境变量开关,让用户主动关闭;关键场景强制带则是指当检测到问题中涉及"当前文件""这个仓库""刚才"等词时,系统无论如何都注入上下文。
我后来在实践中还加了一个"上下文预览"机制:在真正发送 Prompt 之前,先打印一份摘要,告诉用户"我要基于这些内容回答:README.md(前 50 行)、src/main.py 的关键函数签名、Git 当前分支 feature/xxx",用户能看到即将发送什么,也方便调试。这个设计成本很低,但非常实用,它让模式切换从黑盒变成了可解释的透明过程。
3. 手写一个支持 context-mode 的终端问答工具
窗口期想清楚之后,我写了一个精简但完整可用的实现,核心代码就几百行。这里我按骨架拆开讲,给出足够多的代码细节,方便你直接改造成自己的工具。
3.1 项目骨架与数据模型
我选了 Python 来写,因为它的数据类特性让上下文结构表达起来很清晰,而且方便接入各种模型 API。先放数据模型:
from dataclasses import dataclass, field from typing import List, Optional from collections import deque import os, time, subprocess, re @dataclass class ContextItem: source: str # 来源标识,如 file:src/main.py, git:status content: str # 上下文内容本体 ts: float # 采集时间戳 weight: int = 1 # 权重分值,决定进入 Prompt 的优先级 is_anchor: bool = False # 是否为不可丢弃的锚点 @dataclass class ContextSnapshot: mode: str # lean / rich / minimal items: List[ContextItem] = field(default_factory=list) created_at: float = 0.0 def sorted_items(self) -> List[ContextItem]: # 按权重排序,稳定保留锚点优先级 return sorted(self.items, key=lambda it: (not it.is_anchor, -it.weight))这里ContextItem是上下文的最小单位,ContextSnapshot是某一时刻的上下文快照。快照设计得比较轻,排序逻辑单独放在方法里,方便后续调策略。
3.2 上下文收集器:文件、命令历史与系统状态
采集器是上下文模式的入口。我只做三件事:收集最近修改的文件摘要、读取当前 Git 状态和最近的 shell 历史。
class ContextCollector: def __init__(self, project_root: str, max_file_items: int = 5): self.root = project_root self.max_file_items = max_file_items def collect_files(self) -> List[ContextItem]: items = [] candidates = [] for dirpath, dirnames, filenames in os.walk(self.root): dirnames[:] = [d for d in dirnames if not d.startswith('.') and d != 'node_modules'] for fn in filenames: if fn.endswith(('.py', '.md', '.txt', '.js', '.json', '.log')): full = os.path.join(dirpath, fn) stat = os.stat(full) candidates.append((stat.st_mtime, full)) candidates.sort(reverse=True) for _, full in candidates[:self.max_file_items]: try: with open(full, 'r', encoding='utf-8', errors='ignore') as f: snippet = f.read(1200) items.append(ContextItem( source=f"file:{os.path.relpath(full, self.root)}", content=snippet, ts=time.time(), weight=80, is_anchor=True )) except Exception: continue return items def collect_git_state(self) -> List[ContextItem]: try: branch = subprocess.check_output( ["git", "branch", "--show-current"], cwd=self.root, text=True ).strip() status = subprocess.check_output( ["git", "status", "--short"], cwd=self.root, text=True ).strip() content = f"当前分支: {branch}\n工作区变更:\n{status[:800]}" return [ContextItem(source="git:status", content=content, ts=time.time(), weight=60, is_anchor=True)] except Exception: return [] def collect_shell_history(self, max_lines: int = 8) -> List[ContextItem]: hist_path = os.path.expanduser("~/.zsh_history") if not os.path.exists(hist_path): hist_path = os.path.expanduser("~/.bash_history") try: with open(hist_path, 'r', encoding='utf-8', errors='ignore') as f: lines = f.readlines()[-max_lines:] content = "最近的命令历史:\n" + "".join(lines) return [ContextItem(source="shell:history", content=content, ts=time.time(), weight=50)] except Exception: return []几个小细节值得一提。文件采集做了目录深度限制和文件类型过滤,否则任何项目的上下文都会爆炸;Git 状态里我把git status --short而不是git diff放进上下文,因为 diff 常常太长,而状态摘要足够让模型理解"是有改动、改了什么层级"。
3.3 模式决定器:什么情况下才"开"上下文
决策器负责判断当前问题需要的上下文模式。我定义了三档:minimal(完全不注入上下文)、lean(只注入高权重锚点)、rich(完整组装)。
class ModeDecider: def __init__(self): # 命中任一关键词即认为需要 rich 模式 self.rich_markers = [ "刚才", "上次", "这个", "这些", "当前", "本项目", "为什么", "哪里", "修复", "重构", "报错", "错误" ] def decide(self, query: str, snapshot: ContextSnapshot, has_explicit_context: bool = False) -> str: q = query.lower() if not snapshot.items and not has_explicit_context: return "minimal" if has_explicit_context: return "rich" for mk in self.rich_markers: if mk in q: return "rich" return "lean"has_explicit_context用来表示用户主动附加了文件或路径,只要出现就必须进 rich。自动判断用关键词列表虽然看起来粗糙,但在工程上非常可控,不依赖模型二次猜测。
3.4 组装 Prompt 并接入模型
组装器把上下文快照按权重拼装成 Prompt,注意这里的 token 预算是核心难点。
class SnapshotAssembler: def __init__(self, max_total_tokens: int = 4000): self.max_total_tokens = max_total_tokens def assemble(self, query: str, snapshot: ContextSnapshot) -> List[dict]: if snapshot.mode == "minimal": return [{"role": "user", "content": query}] if snapshot.mode == "lean": items = [it for it in snapshot.items if it.is_anchor or it.weight >= 60] else: items = snapshot.sorted_items() # 粗略按字符估算 token,保留预算给 query budget = self.max_total_tokens - 300 context_parts = [] used = 0 for it in items: est = len(it.content) // 3 # 1 token 约等于 3 个英文字符 if used + est > budget: continue context_parts.append(f"[{it.source}]\n{it.content}") used += est # 显式告知模型上下文的边界 sys_msg = "你是一个终端助手。请优先基于以下上下文回答问题,如果上下文不足,请直接说明。\n\n上下文开始\n" sys_msg += "\n\n---\n\n".join(context_parts) sys_msg += "\n\n上下文结束" return [ {"role": "system", "content": sys_msg}, {"role": "user", "content": query}, ]这里 token 估算用的字符除以 3,虽然不精确,但用于预算控制足够稳定。真正接入大模型时,你可以替换成 tokenizer 的精确统计。
完整的主循环也很简单:
def main(): root = os.getcwd() collector = ContextCollector(root, max_file_items=5) decider = ModeDecider() assembler = SnapshotAssembler(max_total_tokens=4000) while True: try: query = input("\n> ").strip() except (EOFError, KeyboardInterrupt): break if not query: continue snapshot = ContextSnapshot(mode="lean", created_at=time.time()) snapshot.items.extend(collector.collect_files()) snapshot.items.extend(collector.collect_git_state()) snapshot.items.extend(collector.collect_shell_history()) snapshot.mode = decider.decide(query, snapshot) messages = assembler.assemble(query, snapshot) # 这里替换为你实际接入的模型调用 # reply = chat_completion(messages) print("=== 生成的 prompt 预览 ===") for m in messages: print(f"【{m['role']}】") print(m["content"][:800]) print("========================") if __name__ == "__main__": main()跑起来的效果就是:你问普通问题时它会生成一个 lean 的上下文,只包含 Git 状态和当前分支;你问"刚才那个报错怎么回事",它会自动提升成 rich 模式,把最近修改的文件内容也带上。这个骨架已经能解决开头三种"断片"场景中的前两种。
4. 我实测翻车的三个细节,你可能也会遇到
代码写出来是一回事,真正用起来又是另一回事。我在这里说三个我实际踩过的坑,有的是逻辑漏洞,有的是工程盲区,希望你在设计 context-mode 时能绕开。
4.1 滑动窗口的尾部截断:最隐蔽的语义丢失
我最初用deque(maxlen=10)来做会话历史的滑动窗口,逻辑很简单:新消息进来,最旧的消息被挤掉。跑了几天之后发现一个诡异的现象——用户明明刚问完"帮我分析 src/utils.py 里那个函数的复杂度",紧接着问"那它依赖了哪些外部库",模型却答得乱七八糟。
查了半天,问题出在我没意识到deque(maxlen)的淘汰机制是无差别的。当用户在中间插了一两条无关指令(比如"现在几点了""查一下 pip 包版本"),窗口里最老但最关键的代码分析指令就被挤掉了。等用户再追问时,模型眼里"刚才"指的东西已经不存在了。
修复方式是我前面提到的锚点机制。我把"包含代码文件路径、错误信息、特定动词(分析/修复/重构)"的交互标记做特殊保留,即使它们被挤出滑动窗口,也会单独进入一个anchor_history列表。组装上下文时,anchor_history永远优先于普通窗口。这个改动让"跨条追问"的成功率从大概 60% 提升到了 90% 以上。
4.2 模式切换后的缓存幽灵:记忆错乱比没记忆更糟
这个坑出现在我引入缓存之后。为了省 token,我给相同模式的快照加了缓存:如果用户连续问多个问题,且模式和文件都没变化,就直接复用上一次的上下文。结果在切换分支或文件后,出现了"幽灵上下文"现象——我问"当前分支的测试怎么跑",它回答的还是上一个分支的目录结构。
问题根源在于缓存键设计得太粗,只包含mode和root,完全忽略 Git 状态和时间戳。更隐蔽的是,模式从rich切换到lean时,旧的 rich 缓存没有被主动踢出,导致模型一直拿丰富但过期的信息回答问题。
现在我的缓存键必须包含四样东西:项目根目录哈希、当前 Git 分支名、最近一次 git commit 的哈希、上下文中文件集合的修改时间列表。任何一个变化都会让缓存失效。另外我加了显式的缓存版本号,每出一版新的上下文组装逻辑,就把版本号 +1,从机制上杜绝旧缓存残留。
4.3 Token 预算被"旁路信息"悄悄吃掉
上下文收集器如果做得太 greedy,很快就会把预算全吃掉。我有一次在上下文里顺手加了系统状态采集:环境变量、CPU 负载、内存占用、系统日志片段。结果一个本来只需要 1200 token 的回答,光是环境变量就打印了好几千 token,模型看了半天无关信息,核心问题反而回答得敷衍。
这暴露了一个问题:采集器不能只问"能不能采",还要问"该不该采"。我后来定了两个原则:一是旁路信息默认低权重,除非用户显式要求,否则不超过总预算的 10%;二是给组装器加一个保护逻辑,当预算不足时,优先裁剪非锚点内容,而不是平均压缩所有内容。
我还做了一个"上下文使用报告",每次组装完成后打印一行摘要,比如:
context: rich | items: 7 | 估算token: 2130/4300 | 裁剪: env,shell:history这个报告对调试特别有用,你能直观看到哪些来源吃了多少 token,也能反过来优化采集器的优先级。我的经验是,context-mode 的性能问题十有八九不是模型不行,而是"把脑子用在了不该用的地方"。
5. context-mode 的进阶玩法:从单工具到团队工作流
单个工具跑顺之后,你会发现 context-mode 其实是一套可以复用的方法论。我后来把它扩展到了团队工作流里,做了三件事,都还挺有价值。
5.1 让上下文通过配置文件在团队内共享
每个项目根目录放一个.ctxmode.yaml,统一约定什么文件算重要、什么信息需要锚定、最大 token 预算多少。团队成员拿到同一份配置,工具行为就完全一致,不会出现"我这边能答出来、你那边答不出来"的尴尬。
这个配置文件很轻:
project: my-service max_tokens: 4000 anchors: - path: README.md weight: 90 - path: src/**/*.py weight: 70 - path: tests/**/*.py weight: 60 ignore: - vendor - generated shell_history: false我有意识地把"哪些信息重要"从代码逻辑里抽离到配置里,这样非技术背景的同事也能参与调整上下文策略。配置解析器只需把锚点文件路径逐个解析成ContextItem即可,非常简单。
5.2 用事件流替代轮询:上下文实时性的升级路线
最初的采集器是轮询式的——每次提问都重新扫一遍文件和 Git 状态。这在大型项目里会产生明显延迟,特别是文件很多时,扫描目录树要几百毫秒。我后来加了一个基于文件系统事件的监听器,文件变更时主动刷新对应条目的内容并更新时间戳。
用 Python 的watchdog库就能实现,代码量不大,核心就是把collect_files()改成事件驱动的增量更新。配合之前的缓存键设计,文件一变,上下文里的对应 item 立刻更新,组合 Prompt 时无需重新扫描。这个升级让工具的实时性从"每问必扫"变成了"变更就更新",在大型 monorepo 里的体感提升特别明显。
5.3 给自己的 context-mode 建立一套简单指标
没有衡量就无从优化。我给 context-mode 建了三项指标,每天在测试集上跑一轮:
- 上下文命中率:在给定的上下文快照下,模型回答中是否包含正确答案所需的关键信息。可以人工标注 50 条测试问题,逐条打标。
- 上下文冗余率:组装后的 Prompt 中,实际被模型参考的信息占全部注入信息的比例。冗余率高于 60% 时,说明采集策略太贪,需要压缩。
- 模式切换准确率:对比决策器的
mode输出和人工标注的理想模式,算出精确率与召回率。
这个评测体系不需要太复杂,关键在测试集要贴近真实使用场景。我每改一次权重表,就跑一遍这三项指标,数字会直接告诉我改动到底是变好还是变坏。这是我从"凭手感调参"走向"靠数据调参"最重要的一步。
6. 最后聊几句个人体会
我在做这个 context-mode 之前,一直觉得上下文处理是大模型应用的隐形瓶颈。现在回头看,它的复杂度不在某个算法的精妙,而在于取舍和约束:采什么、信什么、丢什么、按什么顺序组合。一个设计良好的上下文机制,哪怕调用的模型能力差一档,回答质量也可能远超那个没做上下文管理的强模型。这是我做这个项目得到的最深体会。
如果你也要做类似的东西,我最后给一条具体建议:先把收集器做小、做可控,再逐步加功能。不要一开始就把所有信息源都接进来,否则你会被各种上下文噪声搞得焦头烂额,而且很难定位问题到底出在采集、决策还是组装环节。从一个文件采集器、一个 Git 状态采集器、一套最简单的权重表起步,跑通之后再一层层往上叠,这条路我已经替你验证过了,很稳。
最后再分享一个小技巧:给你的 context-mode 工具加一个--debug参数,把组装后的完整 Prompt 打印出来。debug 开启时你会看到模型的完整输入,很多"它为什么答成这样"的问题,其实看一眼 Prompt 就明白了,根本不用猜。