ARTICLE DETAIL

资讯详情

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

AI编程助手上下文控制:context-mode插件如何精准投喂代码

AI编程助手上下文控制:context-mode插件如何精准投喂代码 如果你跟我一样把 AI 编程助手当成日常搭档大概也经历过这样的崩溃瞬间让 AI 改一个函数它把整个仓库的无关代码全读进去然后一本正经地给你一个根本跑不起来的方案反过来只给它当前文件的三十行它又会漏掉关键的依赖定义直接答非所问。这个“喂多少上下文才算刚好”的度真的很难把握。我折腾了几个月最后做了个取名 context-mode 的 IDE 插件核心思路很简单上下文不应该是一个固定的窗口大小而应该是按模式切换的——窗口模式、符号模式、调用栈模式、仓库检索模式。这篇文章我把整个设计思路、实现要点和踩过的坑原原本本写出来希望对正在做类似工具的人有点参考价值。1. 为什么会有 context-modeAI 编码助手的上下文控制问题先说结论AI 编码助手的能力上限很大程度上取决于你给它看了什么。模型本身的能力当然重要但“上下文”才是日常使用中最容易翻车的地方。这里的上下文不是聊天记录里那几条来回消息而是“代码世界中当前这次修改到底涉及哪些文件、哪些函数、哪些定义”。1.1 上下文不是越大越好而是越准越好很多人的第一反应是那就把所有相关的代码都喂给 AI 呗。我自己最初也这么干过把整个模块的代码拼成一个大文本塞给模型结果分两种情况模型确实能读到相关信息但注意力被大量无关代码稀释经常抓错重点改出来的代码风格混乱、逻辑绕弯。上下文太长之后模型的“等效注意力”急剧下降尤其当无关代码里有相似命名的函数时它甚至会把 A 模块的实现套到 B 模块的需求上。还有一个更现实的问题成本。现在很多 AI 编码助手按 token 收费或者有上下文长度上限。你把整个仓库塞进去一次可能就好几千个 token改十个文件就得重新传十次时间和钱都烧不起。所以 context-mode 的第一步就是把“上下文”从一个大水池拆成几个可以按需切换的水龙头。每个模式产出不同粒度的代码片段AI 拿到的永远是一份经过筛选的材料而不是一个未经整理的仓库。1.2 设计前的三个约束在动手写代码之前我给自己定了三个约束这三个约束后来帮了大忙第一上下文提取必须基于语法结构不能靠正则。正则匹配“找函数名”在简单的例子里看着没问题但遇到嵌套函数、类方法、装饰器、多行签名就全崩了。必须用能容错的语法解析器因为用户代码不一定是语法完整的。第二提取速度必须足够快不能阻塞编辑器。用户只是把光标移到了另一个函数上你如果花两秒才生成上下文那这个插件就是负收益。所以要用增量解析并且要设计缓存。第三模式之间要能自动切换。不是让用户手动去点一个“上下文模式”按钮那样太反人类。理想状态是用户打开一个 diff、把一个错误面板的条目点开、或者把光标移进某个函数体context-mode 自动根据当前情境选择合适的模式。这三个约束直接决定了后面所有的技术选型。Tree-sitter 解决了第一个增量解析和缓存解决第二个事件驱动架构解决第三个。1.3 context-mode 的定位不是替代 AI 助手而是给所有 AI 助手提供素材我一开始想把这个插件绑定到某一款具体的 AI 工具上后来发现路走窄了。因为市面上的 AI 编码助手五花八门有的有自己的 Agent 模式有的只做自动补全还有的是通用对话界面。它们的共同需求其实是一份“你现在需要了解的代码上下文”。所以 context-mode 干脆做成了一个通用层它负责把当前编辑状态转换成结构化上下文然后通过一个简单的接口暴露出去。无论你把它接到 Cline、Continue还是自己写脚本调大模型 API都能用。这个设计后面证明非常正确因为不同 AI 工具的上手成本完全不同但“如何获取上下文”这件事是共通的。2. 核心模式拆解window / symbol / stack / repo 各自管什么context-mode 总共有四种模式名字都很直白。下面逐个讲清楚它们各自解决什么问题以及我为什么要设计成这样。2.1 window 模式当前文件的可视窗口最简单但最常用window 模式的输出很简单把光标附近的代码块原样提取出来。默认行为是“当前光标所在函数 上下各 N 行注释”N 默认是 10。这个模式适合什么场景呢最常见的是让 AI 补全一个局部逻辑比如“在这个 for 循环里加一个去重逻辑”这时候你根本不需要知道函数外面的世界只需要让 AI 看到循环结构本身、相关的局部变量、以及循环体前后的上下文。它的实现难度最低但却是所有模式里用得最多的。因为大部分编码动作都是局部修改。而且这个模式对 latency 极度敏感用户敲完提示词的瞬间就希望上下文已经准备好了所以 window 模式必须做到“零感知”。我后来在 window 模式里加了一个很有意思的小功能把光标所在行的缩进层级也提取进去。因为模型经常在输出代码时把缩进搞乱给它看当前缩进级别能大幅减少“对不齐”的翻车。2.2 symbol 模式只要函数签名和当前实现不要无关代码symbol 模式是 context-mode 真正区别于“把整个文件塞进去”的第一个模式。它做的事情是解析当前文件的 AST找出光标所处的那一个具名函数/类/方法然后只提取这个符号的签名、文档注释、主要实现体以及它直接引用到的本文件内其他符号的定义。举个例子你在handleUserLogin这个函数里这个函数内部调用了validatePassword和createSession。symbol 模式不会把整个auth.ts文件给你它只给你handleUserLogin的完整实现外加validatePassword和createSession这两个直接依赖的定义。如果这两个依赖函数又引用了别的东西默认不再递归展开除非用户手动要求。这个设计来自一个很痛的教训早期我天真地把整个文件都发给 AI结果文件里有一个叫currentUser的全局变量不断地被各处修改AI 读完整个文件之后反而搞不清currentUser在handleUserLogin这个语境里到底是什么值。只有把“当前函数 直接引用符号”这个小集合交给它它才能真正聚焦。2.3 stack 模式沿着调用链往上回溯把相关函数打包stack 模式解决的是“跨函数、跨文件”的追踪问题。它和 symbol 模式最大的区别是symbol 只看“当前函数直接引用了谁”而 stack 模式会沿着调用关系向上回溯——也就是反向查找“谁调用了当前函数”再递归找出“谁调用了那谁”。举个具体场景你在修userService里的一个方法这个方法报了一个 “null pointer” 错误。问题往往不在方法本身而在调用方的使用方式。比如某个 controller 在调用这个方法之前没有做空值校验。如果你只给 AI 看userService里的方法实现它可能根本不知道为什么会有空值传进来。stack 模式默认向上回溯两层。它会用 LSP 的textDocument/references或者本地索引找到当前函数的所有调用点然后把调用点的上下文也一起打包。注意这里不需要全量调用点只需要一两个有代表性的调用点否则上下文又会爆炸。我在实现的时候发现一个非常实用的技巧优先选择“在 git diff 中出现过的调用点”。因为如果调用点最近被改过那这个 bug 大概率就是这次改动引起的。把 diff 信息叠加在 stack 上下文上AI 的诊断准确率会高很多。2.4 repo 模式仓库级检索但绝不整仓投喂最后一个模式是 repo 模式。它对应的是“这个 bug 可能在任何地方”的场景比如一个只有在运行时才会暴露的错误信息。这时候光看当前文件和调用链都不管用得做仓库级检索。很多 AI 工具在这个场景下的做法是直接把仓库发给 AI让 AI 自己翻。我的看法是这是最糟糕的做法。一个中等仓库轻松几百个文件让 AI 从头读到尾不仅 token 爆炸而且很多机器生成的代码lock 文件、编译产物、前端脚手架纯属噪音。repo 模式的做法是先对仓库做一个轻量级索引包括文件路径、文件大小、最近修改时间、每个文件里的符号列表。当用户问一个跨仓库的问题时先用关键词或错误信息进行检索筛选出最相关的 5-10 个文件再用 symbol 模式提取这些文件中的相关部分。实际实现我用了一个很朴素但效果很好的方案把仓库里的所有符号名、文件名、git 提交信息加入一个简单的倒排索引用户输入关键词时按相关性排序。这个方案比训练一个 RAG 模型轻得多而且对于一个代码仓库来说符号名通常已经包含了足够强的语义信息。模式上下文大小典型场景单次 token 成本window极低补全循环、局部逻辑调整500-1500symbol低修改函数实现、添加方法1000-4000stack中追踪 bug、排查调用链问题3000-8000repo高跨仓库定位问题源头5000-15000这张表是我自己长期使用后粗略统计的不同项目差异很大但能看出一个趋势不是所有问题都需要消耗大量 token。mode 选得对AI 看得准钱包也不受罪。3. 实现细节用 Tree-sitter 把代码切片成“可投喂”的上下文这一节是 context-mode 的核心技术部分。前面说的那些模式听起来很美好真正实现起来却有不少门道尤其是“如何把一段代码准确地切出来”。3.1 为什么选 Tree-sitter而不是正则或 LSP 的符号信息很多插件在“找到当前函数”这件事上会选择直接调 LSP 的textDocument/documentSymbol它确实能返回文档结构但它有几个问题第一LSP 返回的符号位置有时是模糊的比如大多数实现对方法内的匿名函数、嵌套闭包支持不好第二你不能从 LSP 拿到底层源码文本还得自己根据 range 再截取第三LSP 需要语言服务启动、连接对这个插件来说太重了。正则更不用说根本处理不了嵌套结构。所以我选了 Tree-sitter。它有几个无可替代的优势增量解析编辑器里每个字符的修改它都能在 O(修改大小) 时间范围内更新语法树而不是整个文件重新解析。这对 5000 行以上的文件尤其重要。容错解析用户正在打字的时候代码可能处于不完整状态Tree-sitter 依然能够给出一个部分正确的 AST只是把错误节点标记出来。这意味着光标移动时上下文也能稳定提供。多语言支持tree-sitter 的语言库覆盖很广JavaScript、TypeScript、Python、Go、Rust、C/C、Java、Ruby 都有现成的实现。我在插件里只需要按文件类型加载对应的 grammar。代价就是编译成本和提高接入复杂度。但相比收益这个代价完全值得。3.2 AST 节点反查从光标位置定位到“最内层函数”实现 mode 的第一步是把光标位置映射到 AST 节点。Tree-sitter 提供了一个namedDescendantForPosition接口可以直接拿到光标所在的具名节点。import Parser from web-tree-sitter; const parser new Parser(); const lang await Parser.Language.load( /path/to/tree-sitter-typescript.wasm ); parser.setLanguage(lang); const tree parser.parse(sourceCode); const root tree.rootNode; const node root.namedDescendantForPosition({ row: cursor.line, column: cursor.character, });拿到最内层节点之后再往上遍历它的祖先节点找到第一个类型是“函数声明/箭头函数/方法声明”的节点。这里有一个重要的细节不能只找最内层的函数节点。因为在嵌套闭包的场景下光标可能在回调函数里但用户实际想改的是外层业务函数。我后来的策略是找到从光标到根节点路径上的所有函数节点组成一个“函数链”。默认取最内层函数但如果最内层函数是类似.map((item) ...)这种匿名回调就退回到外层具名函数。查找函数链的伪代码如下function findFunctionChain(node): FunctionNode[] { const chain []; let current node; while (current) { if (isFunctionNode(current)) { chain.unshift(current); } current current.parent; } return chain; }把函数链里的第一个具名函数作为主符号后面的内部函数作为辅助上下文这样在闭包场景下 AI 既能看清整体逻辑也能看到细节实现。3.3 用 Tree-sitter query 提取“符号 直接依赖”定位到函数节点之后下一步是提取函数签名和具体内容。这个可以用 Tree-sitter 的 query 语法也可以在节点上直接遍历子节点提取。我推荐用 query因为它的表达能力更强而且很多语言的手册里都有现成示例。这是 TypeScript 里提取函数声明和参数列表的 query 示例(function_declaration name: (identifier) name parameters: (parameters) params body: (statement_block) body) (method_definition name: (property_identifier) name parameters: (formal_parameters) params body: (statement_block) body)拿到的name、params、body就是三个具体的 AST 子节点然后取它们的文本就得到了完整函数。注意body可能非常长比如一个 500 行的函数体。我实际提取时做了个截断函数体默认最多保留 200 行超出部分在上下文里用注释注明“剩余 X 行已省略”防止把 token 浪费在一个函数内部。接下来是“直接依赖”。这一步的做法是遍历主函数体里的所有identifier和call_expression提取出函数内引用的其他名称。这一步不用做到完美只需要收集所有可能的引用名然后去当前文件的符号列表里匹配同名函数。匹配到的就是直接依赖。class SymbolIndex { symbolsByFile new Mapstring, SymbolInfo[](); findDirectDependencies(functionNode, filePath): SymbolInfo[] { const referencedNames collectIdentifiers(functionNode); const fileSymbols this.symbolsByFile.get(filePath) ?? []; return fileSymbols.filter((s) referencedNames.has(s.name)); } }这就是 symbol 模式的核心逻辑。简单、快、可靠。3.4 增量解析和缓存让“取上下文”这件事快到感知不到Tree-sitter 本身支持增量解析但插件要配合编辑器的编辑事件才能发挥这个优势。具体做法是在文件每次编辑时记录编辑的前后文本和偏移调用tree.edit()方法把编辑应用到现有的语法树中而不是整体重新 parse。tree.edit()需要你提供一个Edit对象包含起始位置、旧文本长度、新文本长度和新插入文本的前几个字符。tree.edit({ startIndex: change.rangeOffset, oldEndIndex: change.rangeOffset change.text.length, newEndIndex: change.rangeOffset change.rangeLength, startPosition: change.range.start, oldEndPosition: { row: ..., column: ... }, newEndPosition: { row: ..., column: ... }, }); // 增量解析 const newTree parser.parse(updatedSource, tree);细心的读者会发现parser.parse传了第二个参数tree这就是 Tree-sitter 的增量解析入口它会把旧的语法树作为起点而不是从零开始。实测下来对于 2000 行的文件整体解析可能耗 5-10ms增量解析只要 0.5-1ms。这个差异在快速敲代码时体感很明显。缓存方面我做了两层全局符号索引缓存文件未变更时直接从缓存拿符号列表。模式输出缓存模式、文件路径、光标位置组合起来作为 key同一 key 且文件未变更时直接返回上次生成的上下文。缓存失效事件就是文件保存或者大段编辑。小编辑不影响光标所在函数的结构时也可以不失效只更新位置映射。4. 与 LSP 和 Git Diff 联动自动发现“应该被注意”的代码Tree-sitter 解决了“如何从当前文件切上下文”的问题但真实编码场景里问题往往分布在多个文件、多个历史版本之间。context-mode 的下一层是把 LSP 的诊断信息和 Git Diff 融入上下文让 AI 看到“刚才改了什么”“哪里报错了”。4.1 从 Git Diff 提取“本次改动涉及的符号”在 stack 模式和 symbol 模式下我都加入了“diff-aware 增强”。具体逻辑是读取当前文件相对 HEAD 的 diff解析每个 hunk 头里的行号范围然后把这些行号映射回 Tree-sitter 的节点上。git diff的 hunk 头长这样 -10,6 10,7 function foo() {-10,6表示旧文件从第 10 行开始连续 6 行。10,7表示新文件从第 10 行开始连续 7 行。把新旧行号拿到之后用这些行号去 AST 里反向查找对应的函数节点。如果一个函数体的起始行号落在 diff 的 hunk 范围内就认为这个函数“被本次改动影响”。然后把这些函数的名字和 diff 片段单独收集起来。这个功能有什么用呢最典型的场景是你刚改完parseConfig这个函数然后在loadConfig里报了个错误。你问 AI “为什么这里会报错”如果 AI 没有 diff 信息它只知道loadConfig的实现不知道你刚把parseConfig的返回结构改了。有了 diff-aware 增强context-mode 就会自动把这两个函数一起打包AI 一眼就能看出新结构不兼容。这个功能在多人协作的仓库里尤其好用。因为还有另一种情况你什么都没改但报错出现了。那多半是同事刚改了某个公共函数。此时把“最近 5 次提交涉及的文件和符号”提取出来跟当前报错位置做交叉匹配AI 的诊断效率会有质的提升。4.2 用 LSP 诊断信息让 AI 看到“编辑器底部的红色波浪线”错误列表里的信息AI 通常是看不到的。所以我把 LSP 的诊断信息做成了合法上下文。实现方式也简单订阅 LSP 的textDocument/publishDiagnostics通知收集当前文件或整个 workspace 的 error、warning 和 info 信息。注意只需要收集 error 和 warninginfo 一般是风格提示塞给 AI 反而干扰判断。每条诊断包含位置、严重级别和消息。这些诊断信息怎么用呢最好的方式是跟 window 模式结合。比如光标在某个函数里函数体内两行有编译错误context-mode 会在上下文的开头插入一段这样的文字当前文件 diagnostics: - [error] src/userService.ts:42:7 Cannot find name authToken - [error] src/userService.ts:58:3 Type undefined is not assignable to parameter of type Config然后紧接着是函数定义。AI 读的时候先看到错误再看代码就会非常清楚“改这个函数要解决哪两个问题”。我实测下来这个功能对 TypeScript 这种编译错误频繁的语言特别有效。4.3 事件驱动的模式选择什么时候自动切到哪种模式现在四种模式和两个数据源Tree-sitter、LSP、Git diff都有了最后一步是设计“模式选择”的规则。我最初的设想是给用户四个按钮后来发现没人会去点。于是改成了一套基于上下文的自动选择逻辑如果当前文件有未保存的编辑默认用 window 模式因为这个时候 AST 都不稳定没必要做复杂的跨文件提取。如果用户打开了 git diff 面板并选中了一个 hunkcontext-mode 自动切到 diff-aware symbol 模式提取该 hunk 涉及的所有新旧函数。如果用户点击 LSP 错误面板里的某条错误context-mode 自动定位到对应函数切换到 stack 模式因为这个错误本身可能来自调用链更上层。如果用户输入的查询里包含错误信息文本或者跨文件关键词就触发 repo 模式。这套规则的实现不复杂核心是给每个来源diff、diagnostics、光标都附带上“模式建议”然后在事件合并时取优先级最高的建议。我用了一个简单的优先级表diagnostics diff cursor。因为用户点开错误面板时通常意图最明确。4.4 与外部工具的接口一个请求一段上下文最后context-mode 要暴露一个统一的取上下文接口无论是哪个 AI 工具都能调用。我定义了一个简单的协议{ mode: stack, filePath: src/userService.ts, cursorLine: 42, includeDiagnostics: true, includeDiff: true, maxContextSize: 12000 }返回的结构也尽量简单就是一个带标题的 Markdown 文本标题上标明[Context-Window]、[Context-Symbol]、[Context-Stack]这样的标记。这样 AI 工具拿到之后不用再做二次解析直接拼在系统提示词里就行。5. 实测效果与翻车记录context-mode 的真实边界这一节是对前面所有设计的检验。我用 context-mode 实际跑了自己的几个项目也做了一些对照实验。结果有惊喜也有翻车。我会把翻车的地方重点写出来因为这些才是没法靠自己脑补获得的经验。5.1 三个真实场景从局部修改到跨文件定位第一个场景是局部逻辑修改。我在一个 open-source 项目里让 AI 给某个工具函数加一个“超时重试”逻辑。旧方式是把整个 800 行的工具文件发给 AIAI 给出的代码总是试图复用文件里某个不相关的重试工具导致逻辑混乱。换成 context-mode 的 window 模式后AI 只看到目标函数本身和它引用的类型定义给出的方案干净了很多而且不用再反复粘贴修改。第二个场景是跨文件问题追踪。我在项目里发现一个诡异的现象createOrder这个接口偶尔会返回 500。报错信息在日志里但代码里找不到直接抛错的地方。旧方式是我把一个 service 文件整个丢给 AIAI 翻了半天没找到。换成 stack 模式后context-mode 自动把createOrder的调用方、以及调用方最近改动过的几个函数一起打包AI 立刻定位到是 controller 层在await之前提前释放了数据库事务。这个结果让我自己都很意外因为如果没有调用链信息人类排查这个 bug 至少要花小半天。第三个场景是仓库级检索。我让 AI 回答“我们这个仓库里所有用到legacySession的地方有哪些能不能统一迁移掉”。repo 模式用它自建的符号索引把 30 多个引用点分门别类列出AI 基于这些入口文件生成的迁移方案非常完整。旧方式是让 AI 自己搜结果它漏了一半因为有些引用是通过const legacy legacySession这种别名方式引入的普通的关键词搜索抓不到但符号索引能抓到。5.2 翻车记录闭包、模板语言和缓存失效有好就有坏下面列出我实际踩过且修复了的坑。第一个坑是嵌套闭包。Tree-sitter 在 TSX 文件里光标落在 JSX 回调内部时最内层节点往往是一个jsx_expression或者箭头函数。我之前直接从最内层开始提取结果上下文里只有一个 3 行的 map 回调AI 完全不知道外层在干嘛。修复方案就是前面提到的“函数链”——永远把从最内层到根节点的所有函数一起作为上下文最内层函数放后面外层函数放前面。第二个坑是模板语言和预处理。我在一个 HTML 模板文件里测试时Tree-sitter 的 HTML grammar 不支持模板内部的函数表达式导致 symbol 模式提取为空。同样的问题也出现在 C/C 的宏定义上。tree-sitter-c 的 AST 里预处理器指令是独立节点宏展开后的实际调用关系无能为力。最后的做法是给 context-mode 加了一个 fallback当 AST 提取不到函数时回退到 LSP 的documentSymbol和definition虽然慢一点但结果可靠。第三个坑是缓存失效。最初我对“小编辑”做了缓存不过期的优化结果用户在函数体内插入一个变量声明时因为函数体的行号范围变了但我的缓存还持有旧行号导致上下文里的函数定义和实际代码对不上。这个问题非常隐蔽AI 会基于错误的代码进行修改改完一跑就挂。最后的修复方案简单粗暴只要当前编辑使得 syntax tree 中某个函数的 start/end 位置发生变化就立即把这个函数相关的所有缓存清掉。宁可牺牲一点速度也不能给 AI 错代码。5.3 实际配置建议与参数参考如果你也想复制这套方案下面是我的默认配置可以作为起点[context-mode] default_mode symbol [context-mode.window] around_lines 10 include_indent true [context-mode.symbol] max_body_lines 200 max_direct_dependencies 5 include_doc_comment true [context-mode.stack] max_depth 2 prefer_recent_diff true max_caller_files 3 [context-mode.repo] index_file_count 50 use_diff_incremental true [context-mode.cache] context_ttl_ms 5000 invalidate_on_symbol_change true几个参数的取舍说明一下max_body_lines 200不是越大越好。AI 在超长函数面前会失去对关键路径的判断力宁可截断函数体只留头部注释和签名也要把“函数意图”讲清楚。stack.max_depth 2是实际使用后的平衡点。深度超过 3 层调用链的上下文会呈指数膨胀而且很多上层调用跟当前问题无关。prefer_recent_diff true非常关键。把这个开关打开之后stack 模式会优先把 diff 中出现的调用点排在前面上下文更相关。实测这个选项对诊断准确率的影响最大。5.4 一个还没解决但仍值得做的方向上下文之间的“关联度评分”现在的 context-mode 还是基于规则的模式切换没法做到真正的“个性化”。比如有人写的代码风格极简函数都很短symbol 模式默认给的 200 行上限就永远用不到而有些人一个函数就 500 行200 行截断又导致 AI 丢失重要信息。我后续想做的改进是给每个候选上下文片段计算一个“关联度评分”综合考量四个因素行号距离、AST 父子关系、diff 同时出现频率、以及历史对话中用户对这个上下文的采纳率。评分高的上下文排在前面不需要预先划分固定模式。这个方向本质上是把“上下文工程”从手工规则变成量化模型我认为这是所有 AI 编码工具在工程化上的下一步。从实际体验看context-mode 现在给我的最大收获不是“AI 回答更准了”而是我作为人类开发者开始有意识地思考“这个改动真正依赖哪些代码”。这种思考习惯一旦建立你写代码时也会更克制不会为了省事把所有东西都堆在一个文件里。如果你也想在项目里试这套方案建议从一个模式开始先把 symbol 模式做好让 AI 每次只看到当前函数和直接依赖你很快就会感受到模型在有边界输入下的表现提升。
返回列表