ARTICLE DETAIL

资讯详情

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

AST代码大纲:让AI编程Agent告别整文件硬啃的高效按需读取方案

AST代码大纲:让AI编程Agent告别整文件硬啃的高效按需读取方案 你注意过没有AI编程Agent在改代码时最大的开销往往不在模型推理本身而在“读文件”这个动作上。拿一个几千行的老模块让模型改很多Agent会真的把整个文件塞进上下文然后在一堆import和无关函数里迷失方向要么输出格式跑偏要么直接跟你说“超出上下文长度”。这时候通常会冒出一种需求让Agent按需读代码而不是整文件硬啃。我基于这个想法做了个工具名字就叫ast-outline。它的核心思路很简单用AST把文件抽成一份带行号、带结构、带符号名的大纲Agent先花很少的token看清地图再针对某个函数精确读取源码区间。这篇文章就是整套方案的完整复盘我为什么做它、中间怎么设计、对接Agent时踩过哪些坑以及实测下来到底能省多少token。1. 直接整文件硬啃到底亏在了哪里先说一个真实场景。几个月前我让Agent在一个老项目里加一个新的Metrics上报接口项目里有个controller文件约1800行里面塞了几十个路由函数还有一堆几乎没人用的历史兼容逻辑。Agent走的是默认路径拿read_file工具把整个文件读进去再开始思考改哪里。结果模型读完前400行已经开始忘记重点是哪个函数了当它终于看到真正的路由定义时上下文里已经被大量的“无关样板代码”塞满最后生成的补丁把另一个接口给改了。所有用过这类Agent的人大概率都遇到过这种问题。所以第一步我们先算笔账看看整文件硬啃到底亏在哪些地方。1.1 token消耗的大头往往不是目标代码代码文件有一个特性能正常工作的大型文件大部分内容是模块化积累下来的历史代码。对一次具体改动而言真正需要关注的往往是一个类里的两三个方法、一个函数体、若干常量定义。但整文件读入时你的token消耗和文件总量成正比而不是和“任务相关的代码量”成正比。我拿一个有代表性的文件做过统计一个约2000行的Java服务类包含8个public方法、6个private方法、一堆字段和getter/setter。如果目标只是修改其中一个方法内的日志逻辑真正需要让模型看到的代码量大约60行到100行就够但整文件读入会消耗约18000到25000 token。这个倍数关系不是3倍5倍可能是二十倍甚至更高。尤其要注意的是Agent交互是多次请求。第一次整读文件后如果后续对话里模型需要再次确认某段逻辑很多Agent实现会把文件内容继续留在上下文中。上下文是滚动累积的不是一次性的。一个任务下来光文件读取反复花掉的token就会让你肉疼。1.2 噪声让模型分心比浪费token更致命浪费token还好办顶多是花钱。更麻烦的是噪声信息会让模型产生错误联想。大模型有个特点它会模仿你给它的上下文中的风格和模式。当你把一个2000行文件整读进去里面充斥着历史遗留的错误处理写法、旧版鉴权逻辑、甚至几个相互矛盾的编码风格时模型在生成代码时会不自觉地去“学习”这些噪声。它可能照抄一个已经被标记废弃的工具方法可能模仿了某段被注释掉的逻辑甚至因为看到大量try-catch包着return null的模式就把你要的新接口也写成了吞异常的风格。我给团队内部Agent换掉整读策略后明显感觉到生成代码的“风格污染”变弱了。原因很简单模型读到的内容从“整个混沌仓库”变成了“精准命中的代码片段”它没有机会去模仿那些无关代码。1.3 截断方案也是一样的坑也许你会说我们不做整读我们让Agent只读文件前N行或者后N行不就行了问题是你需要的那段代码可能恰好就不在你截断的范围内。一旦截断错了Agent还意识不到自己漏了信息它会在已有片段基础上强行推理最后生成一个看似合理但完全不符合原文件上下文逻辑的补丁。这种失败比上下文超长失败更难查因为报错不一定在表面上。比如函数A在文件前100行定义真正修改点却在第500行的调用处截断到300行的话Agent根本不知道函数A的完整签名。它可能创建一个新函数而不是复用已有的。所以结论很直接在大文件场景里问题不是“读得不够多”而是“读得不够准”。2. ast-outline的设计让代码在Agent脑中变成一张带行号的地图围绕“读得准”我设计了一个轻量工具名字就叫ast-outline。目标非常具体把源代码文件先解析成一棵语法树然后把语法树投影成一份结构清单这份清单包含类、方法、函数、接口、关键变量定义的名字、行号范围、参数列表等摘要信息。Agent拿着这份清单就能快速决定下一步要精确读取哪个区间。它本质上做的是“先给地图再进胡同”。2.1 基本思路AST的价值不在“完整”而在“可裁剪”很多接触过编译原理的同学对AST的第一印象是“一种完整表示代码的树结构”。这个说法没错但对Agent场景反而有害——完整语法树数据量巨大。某个文件如果用解析器导出原始AST JSON体积可能是源文件的5到10倍。直接把AST塞给模型等于用一个更大的文件替代原文件这是方向性错误。ast-outline做的是反向操作解析AST是为了能准确识别出“哪些节点是命名定义”然后只保留这些定义节点的骨架信息。函数体内部的所有语句、循环、条件分支、赋值表达式这些对结构清单来说暂时都不重要。我们要保留的是“文件里有这些东西它们从哪里开始到哪里结束”至于内部怎么实现属于下一步按需读取的范畴。这就像看书时不把整页文字背下来而是先扫目录第1章第2节在第42页我只需要翻到第42页去细读那部分即可。2.2 一个最小的outline长什么样先看一个具体的Python文件示例。假设文件叫src/notifier.py内容包含一个类、两个方法# src/notifier.py import smtplib from typing import List EMAIL_TEMPLATE hello {name} class Notifier: def __init__(self, smtp_host: str): self.host smtp_host self._connected False def connect(self) - bool: # 这里省略具体实现 return True def send(self, to: List[str], subject: str, body: str) - int: # 略 return 0 def default_notifier(config: dict) - Notifier: return Notifier(config[host])经过ast-outline处理之后给模型看的大纲大概长这样## src/notifier.py (6 definitions) imports: smtplib typing.List module_vars: EMAIL_TEMPLATE: str|Literal [line 5] def default_notifier(config: dict) - Notifier [lines 26-28] class Notifier [lines 7-24] def __init__(self, smtp_host: str) [lines 8-11] def connect(self) - bool [lines 13-15] def send(self, to: List[str], subject: str, body: str) - int [lines 17-24]这个格式有几个特点每个定义节点都带行号区间Agent可以据此发起第二次精确读取。import只保留模块名函数体完全丢弃。有嵌套关系类的方法挂在类下面不会丢失归属信息。文本量极小通常一个几百行文件的大纲只有几百到一千个字符折算token约200到300个。2.3 为什么大纲比“全文目录”更适合Agent有人会问LSP、IDE里的Outline早就有了这算什么新东西区别在于消费对象。IDE的Outline给人看人脑有很强的视觉补全能力看个名字就知道大致内容也不在乎行号是否精确到个位数。而AI编程Agent是一个需要通过文本接口做决策的程序它需要的是明确的路径信息帮助它决定下一次调用读文件的哪一行到哪一行。紧凑的符号密度一份长度可控的上下文可以覆盖整个目录而不只是一个文件。机器可读或半结构化的格式便于在工具调用中稳定解析。我用“代码地图”来类比传统全文读取像是直接丢给你一整本《战争与和平》让你找某人第一次出现在第几页ast-outline相当于先给你一份人物索引和章节梗概。Agent当然最终还是要翻书但它翻到具体页再读而不是抱着整本书一遍遍啃。3. 动手实现一个轮廓提取器如果你只想解决问题不一定非要自己写整套AST工具链。但如果你觉得“整文件硬啃”这个痛点真实存在亲手实现一遍会极大帮助你理解方案边界。下面是我的实现路径完整程度可以当作一份最小可复刻参考。3.1 选型为什么是tree-sitter而不是各个语言自带parser做多语言工程时第一个要决策的事就是用什么解析器。我一开始想用各语言自己的AST模块比如Python用ast库JavaScript用babel/parserJava用javaparser。但这样会导致Agent工程需要按语言维护一大堆解析代码接口对齐成本高到不想写。最终我选了tree-sitter理由有三个它通过一个统一的Parser接口和各类语言grammar提供解析能力支持Python、JS/TS、Java、Go、Rust、C/C等主流语言。tree-sitter的语法定义文件更像“活文档”查询语法树时可以按node.type来做过滤不需要为每个语言定制复杂逻辑。tree-sitter天然支持语法错误容错即使源码不完整或者中间有坏块也能生成部分合法的语法树。这个特性对“AI正在修改一半的文件”这种场景极其友好。3.2 利用node类型识别“定义节点”tree-sitter对每种语言都会产生一大类node.type。例如Python函数定义是function_definition类是class_definitionJavaScript函数定义可能是function_declaration或method_definition类是class_declaration。穷举这些类型会累死而且语言一多就失控。更好的方式是观察tree-sitter生成的node-types.json。每个语法包都会带这个文件里面描述了该语言里所有可能的节点类型以及每个节点是否有name字段。ast-outline的启发式策略遍历整棵语法树对每个节点判断它的type是否出现在“定义类节点”的集合里。如果这个节点有name字段则认为它是一个可命名定义。记录它的kindtypenamestart_pointend_point。如果它是函数或方法记录参数列表的关键字结构。对Python我会额外判断class_definition节点下直接包含的function_definition这种情况下函数的kind标记为method方便在大纲里体现归属关系。对JS/TSclass_declaration字段里同样可能嵌套method_definition处理逻辑一致。3.3 代码骨架解析并生成简化大纲我写一个Python版本的最小实现片段。它依赖tree_sitter和对应语言的Python绑定整体过程是“解析→遍历→投影”。from tree_sitter import Language, Parser import tree_sitter_python as tsp # 关键映射你想在outline中保留的语法节点类型 DEFINITION_NODE_TYPES { function_definition, class_definition, decorated_definition, } class OutlineBuilder: def __init__(self): self.result [] def handle_node(self, node): if node.type not in DEFINITION_NODE_TYPES: return name_node node.child_by_field_name(name) if name_node is None: return kind method if self._is_method(node) else node.type.split(_definition)[0] params_text self._extract_params(node) entry { kind: kind, name: name_node.text.decode(utf8, errorsreplace), params: params_text, start_line: node.start_point[0] 1, end_line: node.end_point[0] 1, } self.result.append(entry) # 递归处理类节点内部的函数定义 if node.type class_definition: self._walk_children(node, prefix_childrenTrue) def _walk_children(self, node, prefix_children: bool): for child in node.children: if child.type in {function_definition, method_definition}: self.handle_node(child) def build(self, source_bytes: bytes, filepath: str): parser Parser(Language(tsp.language())) tree parser.parse(source_bytes) root tree.root_node self.result [] self._traverse(root) return self._format_markdown(filepath) def _traverse(self, node): # 先处理当前节点再递归孩子 self.handle_node(node) for child in node.children: self._traverse(child)再配合一个调用入口把结果渲染成上文那种Markdown大纲。注意一个细节为了处理装饰器场景我会把decorated_definition也考虑在内然后继续下钻到里面的function_definition或class_definition去拿名字。3.4 输出协议与缓存每次实时解析整个文件如果很慢Agent任务体验会下降。我加了一层缓存按路径 文件大小 mtime 文件hash做key解析结果序列化成JSON落到.ast_outline_cache/目录里。下次Agent再向ast-outline请求同一个文件的大纲时如果hash没变就直接从磁盘读缓存。这个缓存还有一个额外收益对同一个Agent会话连续多次请求不同文件的大纲时只有首次会触发完整解析。加上tree-sitter本身解析速度很快一个2000行文件通常在50ms以内最终对Agent决策路径的影响几乎可以忽略。4. 接入Agent的“按需读取”循环不是把大纲丢给模型就完事有了大纲提取器距离“Agent按需读代码”还差一步怎么把它接到Agent的推理循环里。这部分的坑比解析器本身多得多。很多工具类项目只提供“生成结构”的能力没有认真设计Agent如何消费最后就只能拿它生成一份永远不会被自动调用的报告。4.1 Agent新增三个工具调用替代裸read_file我给自己的Agent框架扩展了三个工具而不是直接删掉其实用的read_file。这三个工具构成一个小闭环1. read_outline(path) 返回文件大纲包含符号名、类型、行号区间、参数摘要。 2. read_region(path, start_line, end_line) 精确读取指定行区间通常用于查看目标函数实现。 3. resolve_symbol(path, symbol_name) 根据大纲中的符号名直接返回该符号定义位置的代码片段。Agent在改代码时的自然行为变成先read_outline看结构再决定是read_region还是resolve_symbol。它在决策时消耗的token比原先少了非常多因为它再也不用把整文件导入上下文。4.2 在指令中注入“先看大纲”的偏好纯工具加上了但模型不调用是另一个常见问题。我需要在系统提示里明确告诉AI编程Agent遇到代码文件时如果文件可能超过300行或者你没把握准确位置优先调用read_outline而不是read_file只有当你确认目标函数后才用read_region。系统提示的措辞也很关键。不能说“可以访问大纲”而要给出一个更细粒度的决策树。我实际在用的提示语大概是每次读取代码前先判断目标是否指向某个明确的符号类/函数/方法 - 如果明确优先使用 resolve_symbol 或 read_outline 定位后再 read_region。 - 如果需要了解某个文件的整体结构使用 read_outline。 - 避免一次性读取超过300行的原始代码除非你明确知道该行区间就是修改点。这种指令方式让“按需读取”从推荐动作变成Agent的默认路径。4.3 递归展开策略最多深入多远按需读取最怕什么怕Agent顺着调用链一发不可收拾A读BB读CC又读A最后读了一堆片段上下文依然爆炸。因此一定要给递归行为设边界。我做了三个约束深度约束单次任务的符号展开深度默认限制为 3 层。也就是说Agent可以看入口函数、入口调用的函数、那个函数里再调用的核心函数但不鼓励继续查第四层。子节点数量约束如果某个类的方法超过40个大纲里只显示前40个方法和一个省略标记避免Agent因为好奇心把整个类的方法都读一遍。循环检测用一个visit set记录已经读取过的符号。如果Agent尝试resolve_symbol一个已经查过的函数直接返回“该符号已在上文获取过请参考前文内容”防止它重复执行。4.4 大纲不是万能的它负责找“位置”不负责找“字符串”当你需要查找某个字符串常量、某个魔法数字、某条日志关键字时AST大纲完全帮不上忙。这是设计边界不该硬拗。比如你要改一条报错信息里的英文提示用大纲翻开十来个方法都找不到因为它是字符串字面量不是符号定义。实际使用中我和Agent的混合策略是如果问题描述里含明确符号函数名、类名、字段名走outline路线如果含字符串、正则、配置key走grep路线。一个Agent工程里rg工具和outline类工具是互补关系不存在谁替代谁。这个定位想清楚之后整个接入方案才稳定下来。5. 一次不完全对照实验省了多少token又救回了多少失败的修复光说设计没有说服力。我在内部项目里做了一组对照实验选择6个真实的代码修改任务目标文件大小从300行到9000行不等。任务类型包括加接口、修bug、改返回结构、替换废弃API。对比基线是“Agent直接按原方案整读文件”对照方案是“ast-outline 按需读取”。5.1 实验方法说明我尽量控制变量同一个任务同样的模型版本同样的系统提示只改变“读文件”的工具链路。Agent的workflow分别叫基线模式和outline模式。每一次任务允许最多20轮工具调用超时未完成则视为失败。实验规模不大属于工程场景上的快速验证结论仅供趋势参考。结果如下任务类型目标文件规模基线模式token消耗outline模式token消耗基线是否完成outline是否完成修改Web控制器接口1800行约72k约21k完成但出现一次跑偏完成修复RPC服务空指针600行约25k约9k完成完成给遗留工具类加兼容方法3300行触发上下文溢出约18k失败完成替换废弃API调用跨3个文件各400-800行约35k约16k完成完成给大型状态机增补状态9000行触发上下文溢出约33k失败完成错误栈定位崩溃原因混合目录约2万行约60k约24k部分完成完成token节省量我取平均大约60%-70%在两个大文件任务中基线已经无法完成核心原因是上下文溢出导致Agent不再能稳定调用工具。outline模式即使在9000行文件任务里仍然能完成因为它每轮最多只读一个300行以内的函数片段。5.2 成功率的提升从哪里来节省token并不自动等于成功率提升这是两件事。实际观察里成功率的提升主要来自两个机制第一Agent不会在读到目标函数之前就“累”了。大模型在超过一定上下文长度后对中部内容的注意力衰减很厉害。整文件读法下模型窗口里装着大量位于文件前中段的历史代码当真正需要的函数在文件后部时模型常常把前面的旧逻辑当成当前事实生成错误补丁。outline模式下模型只有在决定精确读取后才看到目标函数注意焦点始终集中。第二失败了也更容易自查。Agent工具调用的可观察性变强了因为它每一步读取的是明确行区间父级诊断可以直接看到“它读了哪一段为什么读那一段”。整文件模式只能看到“它读了整个文件然后自己在那瞎猜”。5.3 一个反例什么时候按需读取会误事不是所有场景都适合大纲优先。我有一次让Agent重构一个配置类这个类的字段顺序本身就隐含路由表结构共有40多个字段而且字段注释是这个对象的唯一文档。outline模式只列出字段名模型看不到字段之间的联系结果把路由前缀顺序改错了。后来我调整了规则如果对象被模型判定为“配置结构”“数据模型”那么即便文件很大也应该读取完整定义区域而不要只读片段。这个反例说明一个很重要的道理按需读取不等于越小越好而是要在“任务需要全局视野”的时候能主动升级成整段读取。Agent不能只会一种读取策略。6. 踩过的坑和最后留下的注意清单这大半年里ast-outline从最初几百行的Python脚本一路演进到带缓存、带递归控制、带多种输出格式的小工具。过程中踩了不少坑有些坑如果不写下来后面人用同样的思路可能又得重新趟一遍。6.1 语法错误和半成品文件正常解析器直接罢工AI编程Agent最常处理的文件往往就是“正在被修改、还没改完”的文件。整段代码缺失、括号不匹配、缩进错误这些情况对语言的官方parser来说可能是致命伤但tree-sitter能容忍错误并返回partial tree。可就算tree-sitter也会有边界如果一个函数体内部出现无法恢复的语法错误它的行号区间可能会跨越整个剩余文件。大纲里就会出现一个“幽灵方法”行号范围大到覆盖后续所有代码。我的应对措施是对每个定义节点做一次“内部完整性检查”检查它的结束行和父节点结束行是否接近如果发现跨度异常超过某个阈值就把该节点的end_line截断到父节点范围内并追加一个truncated: true标记。这样模型知道这段索引不可完全信任不要试图一次读取整个巨大区间。6.2 注释和docstring该不该进大纲刚开始我的大纲完全不包含注释结果模型经常通过函数名猜不出函数用途。比如一个叫_handle_sync的函数谁知道它是同步数据库数据还是同步消息队列如果函数没有docstring只有实现细节光看签名和行号Agent还是容易误判。后来我在outline里增加了“文档首行”字段对Python取docstring的第一个句子对JS/TS取函数上方最近的三行注释过滤掉license级的大段头注释。这个方法明显提升了模型对函数意图的判断准确度token增加却很少。6.3 行号失效和缓存污染比想象中麻烦只要Agent开始改文件文件内容就变了。如果ast-outline缓存了旧版大纲那么模型后续用旧行号发起read_region可能读到完全不同的代码段。解决思路是大纲结果上打一个source_version字段用文件内容的hash表示。当Agent执行编辑后工具层主动让该文件的缓存失效并要求下一次读取时强制重新解析。另一个更彻底的方案是让ast-outline支持AST节点路径定位方式比如指定绝对路径到函数定义而非行号但改动量稍大我还在陆续推进。6.4 多语言特性宏、装饰器、类字段带来的差别如果只用Python做demo很多问题会被隐藏。项目铺到Java、C、Go之后细节差异开始轰炸C: 函数声明和定义分离function_definition有时只是一个空壳声明。还要关注模板函数的template_declaration。Go: 方法定义和函数定义在tree-sitter里类型不同方法有receiver提取函数签名时需要拼上receiver信息。Python: 类级别的字段赋值经常是理解状态机的关键但class_definition下直接挂expression_statement节点如果完全不提取Agent看不懂类初始化了哪些字段。Java: 注解大量使用但注解行为本身可能改变函数语义比如Transactional。outline默认不展开注解但对关键注解应该提取出来。没有万能语言规则需要设计成一个可配置的映射表每接一种语言就补一次映射。6.5 输出格式模型不是你的API客户端大纲如果以原始JSON格式回传模型反而不好消化。实践下来效果最好的是按层级缩进的Markdown代码块而不是JSON对象def login(user, pwd) [line 20-35]原因很简单模型在预训练阶段见多了Markdown列表能高效解析缩进和冒号结构而JSON嵌套需要模型额外在脑内做一次花括号配对更容易出错。真正给外部程序消费的解析结果才用JSON输出。两套格式一份给人/模型看一份给代码逻辑用。6.6 如果一个功能只适合“整文件硬啃”别硬犟最后想说一个心态问题。ast-outline大幅度提升了Agent在大型代码文件上的表现但它没有解决所有问题。有些代码修改本身需要全文件视野比如把整个类从“同步实现”重构为“异步实现”改变所有成员方法签名。这种情况下按函数片段读取会遗漏调用点造成大量重构错误。我现在的判断标准是修改点是局部还是全局如果目标只影响文件里一个符号用outline如果目标是全局性重构那就老老实实整文件读取甚至要用多文件联合索引。AI编程Agent的代码读取策略永远应该跟着任务的边界走而不是跟着某个工具走。如果让我只保留一条经验我会说给Agent喂代码和管理人类阅读代码是一个道理没人会捧着一整本书去找一句话先看目录再翻页必要时才读整章。想明白这一点ast-outline是否被采用就不重要了因为你随时可以照这个思路做出自己的版本。
返回列表