
聊 AI 辅助编程的人多了不知道你们有没有碰到过这种情况让模型帮忙看代码结果它只盯着当前打开的那个文件完全不管旁边调用它的模块或者反过来一上来就把整个仓库塞进去还没开始干活 Token 预算先爆了。我前阵子给团队的终端 AI 助手做了个功能代号就叫context-mode说白了就是给“上下文”装上一个开关和一套调度策略让模型知道该看什么、什么时候看、一次看多少。今天这篇就把我整个设计思路、踩坑过程和最终落地方案完整写出来希望能给同样在做 AI 工具、或者正在被上下文问题折磨的朋友一点参考。这个功能解决了什么问题一句话概括让 AI 在“什么都不知道”和“什么都往里塞”之间找到一个可控制的中间态。它适合谁适合自己做 AI 辅助工具的人适合在团队里维护 Coding Agent 的工程师也适合重度用 AI 写代码、想弄明白上下文机制到底怎么回事的开发者。你可能不需要完整复现我的实现但里面的取舍逻辑、参数计算和绕过坑的方法大概率是通用的。1. 内容整体设计与思路拆解1.1 上下文模式到底在解决什么先说一个很多人容易忽略的事实模型对“上下文”的理解并不是“给它越多越好”。注意力机制的本质是让模型在生成下一个 token 时关联到输入序列里最相关的部分但当输入序列里塞满了大量无关内容它反而不知道该把注意力放在哪儿。在实践中我观察到两个典型现象一是对文件 A 提问模型正确回答了但如果你要求它同时参考文件 A 和文件 B 的关系它就经常答偏二是仓库大一点、相关文件超过十来个模型就开始“选择性失明”明明某个文件就在上下文里回答方案时却像压根没见过。这背后是上下文窗口的分区效应。模型的注意力在超长输入下会被分散距离较远的早期内容很容易被稀释。就算硬容量扛得住效果也不见得会更好。所以做context-mode的核心思路不是“扩大窗口”而是“减少噪音提高信噪比”。1.2 两条路线的取舍全量注入 vs 按需注入我最早设想的是最简单粗暴的方案把所有匹配到的文件全部拼进 Prompt。试了几天就放弃了理由很现实仓库里有大量历史遗留文件、配置文件、生成的代码模型根本用不上纯占地方。就算窗口放得下输出质量也下降因为无关内容成了干扰项。每次请求的 Token 成本看着就往上涨团队里一天跑几百次请求账单根本扛不住。换成按需注入之后质量、成本、响应速度三个指标同时改善。按需注入的核心是“让代码自己告诉我们它需要什么”借用的是编译器依赖分析的思路——你想让 AI 改一个函数至少要让它看到这个函数的调用方、被调用方、相关数据结构而不是整个仓库。1.3 我的最终架构分层注入 权重衰减整个机制是三层结构常驻层用户手动指定的核心文件或者最近高频访问的文件每次请求都带上。候选层通过依赖分析和关键词匹配从仓库里筛出来的相关文件集合带上评分。动态层根据当前对话的主题漂移和用户反馈实时调整候选层的排序和裁剪范围。评分越高文件在上下文里的位置越靠前、占的 Token 预算越大。评分低的直接丢弃。这套机制跑起来之后模型的回答准确率有明显提升更重要的是它不再会“假装看见”根本没在上下文里的文件了。2. 核心细节解析与实操要点2.1 上下文切片怎么把大仓库变成小块这一步是整个context-mode里最费心思的地方。你不能把整个文件作为一个单元丢进去一个几千行的文件光文件内容就吃掉一大半窗口。我最后采用的是“逻辑切片 引用锚点”的方式。先按语法树把文件拆成不同的逻辑块函数定义、类定义、导入区、配置区、顶层变量。每个块有独立的行号范围、符号名和依赖关系。当模型需要引用某个符号时我优先提取“定义这个符号的代码块”以及“这个符号被引用的代码块”而不是整个文件。比如用户问“帮我看看handleLogin这个函数怎么会报错”系统会定位handleLogin的定义块。通过语法分析找到它调用的辅助函数定义块。找到它依赖的全局变量、常量定义块。找到调用handleLogin的上层入口代码块。这几个块加在一起通常不超过原文件体量的五分之一但信息完整度比全文件注入高得多。这里有个关键点切块的时候必须保留“引用锚点”也就是每个块都要带上文件路径和原行号这样模型回答时能准确指出“第几行有问题”而不是泛泛而谈。2.2 四大开关深度、白名单、黑名单、预算任何一个上下文模式光有切片还不够必须给用户一个可以说话的入口。我在配置里开放了四个核心参数这里详细拆一下。深度等级1 到 5。等级 1 只注入当前文件和直接依赖等级 5 会沿着依赖关系递归三层以上并同时注入调用链上游和下游。默认是 2因为大多数情况下两层的依赖关系足够模型理解了。白名单列表用户手动指定必须包含的文件或目录。它的优先级高于评分系统哪怕评分再低只要在白名单里就强制注入。黑名单列表用户指定永远不注入的内容比如node_modules、dist、build、seed文件之类。这个看上去很简单实际价值极高因为不少仓库里这些目录动辄几百兆一旦被扫进去整个模式直接瘫痪。Token 预算整个上下文允许的最大 Token 数。这是个硬上限所有注入内容必须在这个预算内做取舍。这四个开关我都写进了配置文件并且支持运行时动态调整不需要重启服务。实测下来白名单和黑名单的合理配置对效果的影响远大于模型本身的选择。2.3 关键参数的计算逻辑有一说一参数计算是新手最容易懵的地方。我直接给出我的公式大家可以直接套用。核心预算分配比常驻层占 20%一般是用户明确的几个核心文件或者对话持续过程中始终需要引用的基线文件。候选层占 60%根据评分分配给本次实际要用的代码块。动态层占 10%留给对话过程中新出现的相关信息比如模型回答里引入了新的文件引用。预留 10%防止总长度溢出给模型输出留足空间。单个文件的注入上限我用的是这个经验公式min(文件总行数, 逻辑块行数 × 3 200)。也就是说逻辑切片之后最多允许该文件内容扩大到逻辑块大小的三倍再加 200 行这 200 行用于给文件头部的导入区和上下文注释做一个兜底。这个参数是我反复调了好几次才定下来的你可以根据自己的场景微调但总体思路是“不因单个文件而挤占其他文件的份额”。2.4 工具选型与现实约束实现context-mode我用的语言是 Python原因很简单团队已有的依赖分析工具就是基于 Python 的 AST 做的直接复用ast模块就可以生成语法树不需要额外引入重型编译器前端。对于一般项目来说这个选择足够了。如果有一天需要支持更复杂的语言特性和跨文件类型分析我会考虑换用树解析器但那是后话现阶段没必要过度设计。另外一个现实约束是延迟。每轮对话前都做一次全量文件扫描是不可接受的一次扫描可能要花好几秒用户早就跑了。我的做法是“静态索引 增量更新”启动时对仓库做一次完整索引只记录文件结构、导入关系、符号表不记录文件全部内容之后监听文件变更事件只更新发生变化的那部分文件。实测下来一个一万多文件的项目启动索引大概三四秒增量更新基本在几十毫秒内完成完全可接受。3. 实操过程与核心环节实现3.1 环境准备与目录设计我把这个功能做成了独立模块而不是直接写死在项目主代码里。目录结构大致如下context-mode/ ├── config.yaml ├── engine/ │ ├── indexer.py # 仓库索引构建和增量更新 │ ├── slicer.py # 语法树切块 │ ├── scorer.py # 候选文件评分 │ ├── injector.py # 最终上下文拼装 │ └── gating.py # 白名单/黑名单/预算控制 ├── adapters/ │ ├── python_adapter.py │ ├── js_adapter.py │ └── generic_adapter.py └── cli.py配置文件的格式我用的是 YAML比 JSON 更适合手写维护。核心配置长这样mode: depth: 2 budget_tokens: 8000 budget_alloc: persistent: 0.2 candidate: 0.6 dynamic: 0.1 reserve: 0.1 limits: max_file_inject_lines: 400 max_candidates: 20 whitelist: - src/core/ - README.md blacklist: - node_modules/ - dist/ - build/3.2 索引构建AST 信息抽取写索引的时候我只存三类信息文件路径、文件内定义的符号集合、符号之间的导入关系。不存文件原文所以整体体积很小。处理 Python 文件时我直接用标准库ast处理 JavaScript/TypeScript 时本想用专门的解析器后来为了快速落地先写了一个正则加状态机的简易解析器只识别import、export、function、class这些常见模式。对于一个小团队的内用工具这已经够用了。这算是一个权衡功能完整性优先语法覆盖度可以慢慢补。构建索引的核心代码如下import ast from pathlib import Path def build_index(root: Path): index {} for path in root.rglob(*.py): if any(part in {node_modules, dist, build, .git} for part in path.parts): continue try: tree ast.parse(path.read_text(encodingutf-8)) except SyntaxError: continue symbols set() imports set() for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): symbols.add(node.name) elif isinstance(node, ast.ImportFrom): if node.module: imports.add(node.module.split(.)[0]) elif isinstance(node, ast.Import): for alias in node.names: imports.add(alias.name.split(.)[0]) index[str(path)] {symbols: symbols, imports: imports} return index这段代码谈不上优雅但胜在简单直接。实际跑起来一万多个 Python 文件构建时间三秒上下内存占用也就一两百 MB够用。3.3 切块的规则与实现切片器是核心中的核心。我的切块策略分三步通过ast拿到所有函数和类的定义行区间。每个定义块记录起止行号、所属文件、依赖的外部符号。把“外部依赖符号”与索引中的符号表做匹配建立跨文件引用关系。以handleLogin为例最终注入上下文的内容样式是文件: src/auth/login.py 位置: 第 42-58 行 代码: def handleLogin(username, password): user fetch_user(username) if not check_password(user, password): raise LoginError(invalid credentials) return create_session(user) 相关引用: - src/auth/user.py#fetch_user (第 120-135 行) - src/auth/session.py#create_session (第 30-45 行)这样模型在生成回答时能看到完整调用链而不会把函数当成孤岛。3.4 预算控制与候选裁剪所有候选文件进入最终上下文前都要过一遍预算裁剪。我先按评分从高到低排序逐个往里塞直到达到候选层预算的 60% 上限。超过上限但评分仍然较高的文件会在下一次对话中通过增量注入补上。这里有一个很重要的技巧不要把裁剪后的文件信息直接丢掉而是以“待选列表”的形式保留在会话状态里。当模型给出的回答提到某个不在上下文里的文件时系统可以提醒用户“该文件未注入需要追加吗”这种交互方式比静默裁剪体验好太多用户至少知道当前上下文边界在哪里。3.5 一个实际例子用 Context Mode 做代码审查说再多原理不如跑一遍真实场景。我拿团队里一个请假审批系统的小模块做了测试结构大概是routes.py定义接口service.py写业务逻辑models.py定义数据模型utils.py放通用工具函数。用户的问题“检查一下销假接口的权限校验逻辑有没有漏洞。”深度等级设为 2 时系统定位到routes.py里的销假路由处理函数自动向上找到service.py里的cancel_leave函数再找到utils.py里的require_permission装饰器同时把models.py里LeaveRecord和User两个模型的字段定义带上。注入的代码块一共 340 行占用 Token 大约 2600远低于预算上限。模型给出的反馈是“require_permission只检查了当前用户是否已登录但没有校验用户是否是该条请假记录的创建者任何人只要拿到申请编号就能操作销假。”这个漏洞如果不带上下文模型根本看不见因为它默认不会去翻service.py的权限函数。带了上下文后回答立刻就有了靶向性。这就是context-mode的价值不是让模型更聪明而是让模型“看到”它需要看到的东西。4. 常见问题与排查技巧实录4.1 上下文溢出不是提示词写错了是预算失控刚开始做的时候几乎每跑几分钟就遇到一次“上下文溢出”报错。后来一查绝大多数情况是候选层里混进了超大文件。比如有个文件 3000 多行切块后虽然只注入必要逻辑块但逻辑块之间跳转引用了十几个变量系统把这些变量定义也一并追加上去体量直接爆炸。排查的方法是给每次注入加日志记录每个文件实际注入的行数和 Token 数。看到问题后我在裁剪逻辑里加了一条规则单个逻辑块的追加引用不得超过 5 个超过就丢弃并记录日志。这个经验换成一句话预算不只是总量控制更要做单文件上限控制否则一个“膨胀文件”就能搞垮整次对话。4.2 候选文件缺失模型答得自信但内容完全偏了另一种常见情况是模型回答得头头是道但提到的文件根本不是当前项目里真实存在的。我排查后发现原因是候选层评分时只做了符号名匹配没有做“符号是否存在”的校验。有个模块里引用了send_email但实际代码里根本没有这个函数索引构建阶段没有报错等到注入阶段就把这个不存在的信息丢给了模型。修法是在构建索引时额外记录每个文件的“未解析引用”也就是导入关系里对不上的符号。这些符号要么是外部依赖要么是代码真的有问题要么就是仓库里有旧文件被删了。在context-mode里我选择把这些未解析引用从候选评分里降权不让它们成为判断“相关文件”的主要依据。同时把这类信息单独暴露到调试面板里对找历史遗留问题很有帮助。4.3 白名单误伤把不相关的文件强制塞进去有次同事反馈“为什么我每次都带上config.py但模型回答里从没用到它”我一看配置白名单里写了config.py而这个文件里全是不可变的常量定义跟大多数问题都没关系白占预算还因为长期出现分散了模型的注意力。白名单的正确用法是放“必须参照基线”的文件比如项目架构说明、环境变量说明、数据库表结构定义。业务代码不要往白名单里放让评分系统去动态决定。从那以后我调整了白名单的语义只允许放文档类和全局配置类文件。4.4 动态层不够用多轮对话中标注的上下文经常漂移多轮对话场景下用户第二问、第三问往往已经偏离了初始问题。如果每次都只按第一问来注入上下文第二问实质上是“裸奔”的。我最后的解法是每一轮都重新计算候选层但保留常驻层和前两轮对话中的高评分文件作为衰减记忆。衰减因子固定为 0.5。也就是上上轮评分 80 分的文件在下一轮计算时当作 40 分参与排序。这样既能保持上下文连续性又不会让旧文件一直占着位置。实测下来对“刚才提到的那个问题如果换个角度处理呢”这类追问效果提升很大。4.5 性能优化索引增量更新的三个细节增量更新这块我踩了几个强迫症级别的坑Python 的ast在解析超大文件时会卡顿对于单个超过 5000 行的文件我直接跳过语法树解析退回正则扫描的兜底方案。文件监听用的是watchdog库但在 macOS 上偶尔会有事件丢失的情况所以我同时做了一层定时全量校准每隔十分钟重扫一次变更时间戳异常的文件。写索引时用sqlite而不是直接堆 Python 字典因为索引文件量一旦上来字典的序列化和反序列化速度完全跟不上。这三个细节的效果立竿见影之前团队用着用着索引就和真实仓库不同步改了好几次都没修干净现在稳定运行了两个多礼拜基本没再出过幺蛾子。5. 后续还能怎么扩展目前这个context-mode的定位是“单请求上下文调度”后续我打算在几个方向继续完善。会话级记忆把用户确认过“这个文件有用”的信号回馈到索引里让评分系统慢慢学习每个人的偏好。语义检索现在的候选层主要靠依赖关系和符号匹配还不够智能。后续计划引入向量检索对用户问题做语义嵌入把语义上相关的文件也拉进候选层这可能会显著提升跨模块问题的准确性。多 Agent 协作当一个任务太复杂时把不同的上下文切片分给不同的模型实例主 Agent 只负责汇总避免单个上下文窗口变成瓶颈。最后分享一点个人体会做上下文管理最忌讳一上来就上大模型、堆窗口、塞提示词。先把“需要什么内容”这个问题想清楚后面的模型调用反而简单了。context-mode这个名字起得挺准它本质上不是个“模式”而是你对上下文的一种主动管理意识。先用好这个意识再去调工具你会发现 AI 工具的能力边界比想象中大得多。