:设计决策与工程实践)
类似于上下文模式这种概念很多人在开发终端工具时都遇过——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_factorylist) created_at: float 0.0 def sorted_items(self) - List[ContextItem]: # 按权重排序稳定保留锚点优先级 return sorted(self.items, keylambda 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(reverseTrue) for _, full in candidates[:self.max_file_items]: try: with open(full, r, encodingutf-8, errorsignore) as f: snippet f.read(1200) items.append(ContextItem( sourceffile:{os.path.relpath(full, self.root)}, contentsnippet, tstime.time(), weight80, is_anchorTrue )) except Exception: continue return items def collect_git_state(self) - List[ContextItem]: try: branch subprocess.check_output( [git, branch, --show-current], cwdself.root, textTrue ).strip() status subprocess.check_output( [git, status, --short], cwdself.root, textTrue ).strip() content f当前分支: {branch}\n工作区变更:\n{status[:800]} return [ContextItem(sourcegit:status, contentcontent, tstime.time(), weight60, is_anchorTrue)] 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, encodingutf-8, errorsignore) as f: lines f.readlines()[-max_lines:] content 最近的命令历史:\n .join(lines) return [ContextItem(sourceshell:history, contentcontent, tstime.time(), weight50)] 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 leanhas_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_items5) decider ModeDecider() assembler SnapshotAssembler(max_total_tokens4000) while True: try: query input(\n ).strip() except (EOFError, KeyboardInterrupt): break if not query: continue snapshot ContextSnapshot(modelean, created_attime.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(maxlen10)来做会话历史的滑动窗口逻辑很简单新消息进来最旧的消息被挤掉。跑了几天之后发现一个诡异的现象——用户明明刚问完帮我分析 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 就明白了根本不用猜。