ast-outline:用AST给AI编程Agent打造按需读代码的骨架地图
如果你写过 AI 编程 Agent 类的工具大概率见识过这种名场面只是想让 Agent 把某个工具函数从 A 模块挪到 B 模块结果它反手就把整个 5000 行的 A 文件糊给模型然后吐出一堆带着诡异缩进的输出。我最早调试自己的 Agent 时也被这个问题折磨得不轻。后来我用抽象语法树AST做了一个轻量级的上下文提取工具——ast-outline核心思想就一句话让智能体先看代码的骨架地图再按需点进去读它真正需要的那几段代码。这篇文章就把我对“按需读代码”这件事的完整思考和落地过程写出来。如果你正在做 Copilot 挑战赛、自建 IDE 助手或者想给 Agent 接入几十万行的老仓库这篇文章应该能帮你少走一些弯路。它不聊大模型理论只讲一个很具体的问题当 Agent 需要理解代码时我们到底应该把什么交给模型。1. AI Agent 硬啃文件的两种窘境与一条温和出路1.1 为什么会默认选择“全量读取”很多 AI 编程 Agent 第一个版本都是这么做的给工具配一个read_file函数后端一拿到路径就把整个文件读出来送到模型上下文里。这个方案不是没人想过优化而是它实在太好实现了。模型看到完整文件确实能减少“找不到符号定义”的抱怨尤其小项目里效果也够用。但问题是项目会变大。一个文件可能从 100 行长到 2000 行再到 8000 行。文件内容本身也开始“鱼龙混杂”上层是 state 定义中间是十几个不相关的工具函数底下挂着几个只被旧业务引用的废弃方法。Agent 明明只想改其中一个函数却被迫阅读全部无关上下文。再加上现在主流模型的上下文窗口虽然动辄 128K、200K看起来很大但窗口不是免费的。你把一堆无关代码喂进去它们既挤占预算又干扰注意力。上下文一长模型在后续工具调用里遗漏指令的概率也明显增加。我实测过几次同一个修改任务用完整 3000 行文件做输入时模型生成的补丁质量比只给目标函数时差不少甚至会出现“把不相关代码也一起改坏”的神操作。1.2 上下文污染、token 爆炸与“剪不断理还乱”的切块全量读取的直接代价可以拆成三笔账token 成本一个 6000 行的 Java 类按平均每行 3~4 个 token 算轻松超过 2 万 token。Agent 在完整分析一个仓库时还要分多次读取多个文件几十万 token 很快就烧掉了。信息噪声代码里大量内容与当前任务无关。模型和人类一样输入里的无关细节越多注意力就越容易被带偏。上下文污染最终表现为“改错位置”或“引入重复定义”。上下文预算挤占几百 K 的窗口看着大但对于一个大型任务Agent 可能还要携带系统提示、代码风格指南、最近几次操作记录和可能的报错栈。文件内容占得越多留给推理和纠错的空间就越少。有人会说既然全量文件太大那把文件按固定长度切块总行了吧。这是另一个常见的坑。固定窗口切块比如每 50 行一段会主动切断函数、类、条件块。模型看到的经常是一个从if中部开始的代码块或者一段没有函数头的函数体。它既没法确认这里的缩进属于谁也没法判断变量作用域更别提函数里面还嵌套着一个 lambda 闭包的情况。切完之后 Agent 为了搞清片段归属又要继续请求相邻分片最终请求次数不一定比全量少理解效果还不一定好。这个问题的本质是模型需要的是“与决策相关的符号和逻辑关系”而不是“文本的全部字节”。我们需要一个中间层把代码库按人类理解代码的方式折叠成一个可导航的结构化索引再让 Agent 按需展开。这就是 ast-outline 出现的起点。2. ast-outline 是什么从“无脑读文件”到“先看地图再访问代码”2.1 大纲里到底放了什么东西ast-outline 并不是一个复杂的模型也不是一套 RAG pipeline。它做的事情用一句话就能说清先用编译器前端常用的 AST 技术把代码文件里所有“值得被 Agent 知道的符号”抽取成一份紧凑大纲当 Agent 决定需要看某个符号的完整实现时再按符号准确定位并裁出对应的代码片段。这个思路的类比是地图 App。你去一座陌生城市不会把每条街道的每栋楼都仔仔细细看一遍而是先看城市总览找到目标地标再切换到街区层级最后只查看目标建筑入口。ast-outline 扮演的就是这个“总览到街区”的过渡层。一份典型 outline 文件包含这些信息顶层符号function、class、method、interface、struct、enum等。符号名称与签名如参数列表、返回值类型。在文件中的精确位置起始行、结束行以及所属源码文件路径。符号之间的父子关系比如类 A 包含哪些方法方法 B 里调用了哪些同类方法。文档注释与可见性能辅助模型判断这个函数是公共 API 还是内部实现。依赖线索当前文件从哪些模块 import 了哪些名称或者声明了哪些外部依赖。这段信息不像源代码那么完整但它的 token 密度极高。模型依靠大纲能快速回答“这个文件里有没有我要找的符号”“它的调用方式是什么”“我要不要展开第 17 行那个函数”这类问题。真正需要函数体时再去按名读取。2.2 一个可运行的 outline 数据样例为了更直观我贴一段我实际生成的大纲 JSON做了简化。假设我们有一个叫order_service.py的文件函数大约有 300 行{ file: src/services/order_service.py, lang: python, symbols: [ { name: OrderService, kind: class, start_line: 9, end_line: 186, doc: 订单核心服务外部只允许调用 create_order 与 cancel_order, children: [ { name: create_order, kind: method, start_line: 15, end_line: 47, params: [user_id: int, items: list[CartItem]], returns: OrderResult, doc: 创建订单入口处校验库存 }, { name: _apply_discount, kind: method, start_line: 89, end_line: 121, params: [amount: Decimal], returns: Decimal } ] }, { name: calculate_shipping, kind: function, start_line: 201, end_line: 232, params: [address: Address, weight_kg: float], returns: float } ], imports: [from .cart import CartItem, from .inventory import ensure_stock] }这份大纲与完整文件相比token 数量大约是后者的 8% 到 15%但已经足够让 Agent 完成第一步判断我要找的是哪个类、哪个方法、它在文件的什么位置、大概覆盖哪一段。如果 Agent 的任务是“给create_order增加一个日志打点”看到大纲后就知道自己只需要读取第 15 到 47 行最多再带上前几行看类装饰器。2.3 让 Agent 走的读取协议outline、symbol、deps 三步循环大纲本身是不够的因为模型还需要真正看代码。所以我在 ast-outline 里设计了一组轻量工具接口供 Agent 在推理循环中调用。为了降低模型的认知负担我把接口收敛成三个动作get_outline(path_or_dir)返回文件或目录的大纲通常一次调用能看到整个模块的符号分布。get_symbol(path, symbol_name)传入文件路径与符号名返回该符号覆盖的精确源码片段必要时带上代码前的缩进与注释。get_dependencies(path, symbol_name)返回该符号直接依赖的符号名列表比如它调用了哪些外部函数、引用了哪些 import方便 Agent 决定是否继续展开相关的其他文件。这个三步循环很像人类开发者在 IDE 里的操作先在文件树里找个大概位置再用 CtrlF 跳到函数定义发现依赖后按住 Ctrl 点进另一个文件看看。为了让 Agent 别一上来就把所有符号都请求一遍我在系统提示里塞了这么一段话“在请求代码前先查看大纲。当前任务需要修改的符号不得超过三个展开超过五个符号时请再次检查任务范围。”实测下来这个约束比在接口层限制次数更好用因为模型能理解“控制范围”的含义。3. 实现 ast-outline 的完整过程3.1 为什么没选正则、没有直接上 LSP、最后选了 AST最开始我试图用正则粗暴提取函数名因为成本最低。但正则的脆弱程度远超想象。且不说嵌套括号和装饰器这种反例光是支持语言多了以后每个语言都要写一套不同的正则。遇到 C 模板或者 TypeScript 的高级类型正则很容易要么漏提要么错提。正则最适合做快速搜索不适合做结构提取。那直接用 LSPLanguage Server Protocol不是更标准吗理论上确实标准它可以给出行号、定义跳转、引用查找但有两个问题。一是大部分 LSP 服务是常驻进程我为了给 Agent 做索引不可能为每个项目都启动一套 Java/Go/TS 的语言服务器资源开销太大。二是 LSP 返回的很多信息如 workspace symbol、semantic tokens对“文件大纲”这个需求过于细密把它包装成轻量接口反而要写不少胶水代码。最后我选择了 AST。抽象语法树可以被看成源码在编译器眼中的真实骨架。解析完成后我们通过遍历语法树节点就能拿到函数、类、方法、导入语句等结构化元素。AST 的好处是稳定、可预测、不需要像 LSP 那样理解完整类型系统。虽然它不如 LSP 那么“懂语义”但提取大纲恰恰只需要语法层面的确定性信息。这里多说一句AST 和语义信息是有分工的。大纲是用来定位“代码在哪”不是用来理解“代码含义”。含义交给模型读源码即可。3.2 用 Tree-sitter 搭一个多语言解析底座综合考虑解析鲁棒性和多语言支持我最终选择了 Tree-sitter。它的容错性很强即使源码有语法错误也能返回一棵部分解析的语法树不会像传统 parser 一样直接崩溃这点对真实仓库非常重要。我的实现以 Python 为主语言本体解析用了tree-sitter和tree_sitter_language_pack。简单初始化过程如下from tree_sitter_language_pack import get_parser LANG_EXTENSIONS { .py: python, .js: javascript, .ts: typescript, .tsx: tsx, .java: java, .go: go, .rs: rust, .c: c, .cpp: cpp, } def build_parser(file_path: str): ext file_path.suffix.lower() lang_name LANG_EXTENSIONS.get(ext) if not lang_name: raise ValueError(f暂不支持该类型: {ext}) return get_parser(lang_name)Tree-sitter 对每个 Language 支持一组节点类型。比如 Python 里函数定义节点类型是function_definition类定义是class_definition在 TypeScript 里函数定义可能是function_declaration类是class_declaration。为了让解析器不再语言差异上散架我在配置里建立了一个“节点类型→通用符号类型”的映射表把所有语言中“用来声明一个可调用或可实例化单元”的节点统一映射到function/class/method/interface这四类。3.3 从语法树里捞出函数、类、导入关系解析出 SyntaxTree 之后就可以遍历树节点来生成大纲了。以 Python 文件为例我会用下面的方式提取函数定义并记录范围与注释def extract_python_outline(root_node, lines): symbols [] stack list(root_node.children) while stack: node stack.pop() if node.type in (function_definition, class_definition): name_node node.child_by_field_name(name) if not name_node: continue start node.start_point[0] end node.end_point[0] # 尝试取前面的 docstring 或注释作为文档 doc _find_doc(node, lines) symbol { name: lines[name_node.start_point[0]][name_node.start_point[1]:name_node.end_point[1]], kind: class if node.type class_definition else function, start_line: start 1, end_line: end 1, children: [], } if node.type class_definition: for child in node.children: if child.type function_definition and child.parent node: symbol[children].append(...) symbols.append(symbol) # 继续遍历子节点 stack.extend(node.children) return symbols这里的代码只是示意。真正实现时我会把“docstring 提取”和“子符号归属”拆成独立函数。关键点是Tree-sitter 的每个节点都带有行列号因此拿到符号名的同时也拿到了它在源码里的精确区间。这个区间不能只记起始行不然多个嵌套函数或同名列很容易混淆必须记录“整个定义块的完整范围”。导入关系的提取也类似在 AST 里找到 import 语句相关的节点类型读取被导入的模块名和别名。对于一个 3000 行的文件靠这段逻辑就能在几十毫秒内产出完整大纲整个过程不依赖网络、不依赖编译环境。3.4 给 Agent 开放的检索与切片接口大纲建好后下一步是把它封装成 Agent 能直接用的工具函数。我这里没有单独跑一个 HTTP 服务而是把 ast-outline 作为 Python 包打进 Agent 进程在 LSP 工具列表里注册了三个函数。get_symbol的实现要解决一个很实际的问题怎么把一个符号范围精确切出来。Tree-sitter 的行号是“从 0 开始的字节位置和行列”我通常会将start_point[0] 1作为用户在编辑器里看到的第一行并默认从符号定义所在的上一行开始截取以保留前一个装饰器或文档注释。结束行则算到end_point[0]再把首尾多余的空行压缩掉。默认情况下我会给裁剪代码加一个不超过 6 行的上下文窗防止 Agent 看到孤立代码时缺少缩进基准。一个简化版的接口实现如下def get_symbol(path: str, symbol_name: str, context_lines: int 3): parser build_parser(path) source path.read_text(encodingutf-8) lines source.splitlines(keependsTrue) tree parser.parse(bytes(source, utf-8)) outline _extract_outline(tree.root_node, lines) target _find_symbol(outline, symbol_name) if not target: return f符号 {symbol_name} 不在大纲中请先查看 outline 确认拼写。 start max(0, target[start_line] - 1 - context_lines) end min(len(lines), target[end_line] context_lines) snippet .join(lines[start:end]) return f### {path}:{target[start_line]}-{target[end_line]}\n{snippet}这种接口让 Agent 拿到的是“可直接粘贴进 prompt 的代码片段”而不是一个裸行号。实践中我发现模型对行号的理解并不可靠但把代码片段连同一个醒目标题塞给它它反而不会跑偏。另外即使返回的片段里因为上下文行夹带了相邻注释也不会伤害理解。后续索引实现里我给每个文件生成了一个隐藏索引缓存文件内容不变就不要重新解析节省重复调用的时间。4. 在真实项目里替换“整文件硬啃”之后我看到了什么4.1 场景一改工具函数我先用一个非常常见的任务来测修改某个工具函数的行为。测试仓库里有一个utils/date_time.py文件约 1100 行包含十几个时间和时区处理函数。任务是让 Agent 把format_iso函数里的默认时区从 UTC 改成 Asia/Shanghai。对照组是传统做法给 Agent 提供完整文件让它自己阅读并修改。实验组走 ast-outline先调用get_outline(utils/date_time.py)拿到全部符号位置接着调用get_symbol(utils/date_time.py, format_iso)拿到该函数大约 35 行的实现直接替换。结果是对照组输入约 3500 个 token产生了 600 多 token 的修改思路分析而且第一版补丁误改了一个同样含utc字符串的时间序列化函数。实验组输入全部加起来约 600 token包括大纲和函数体修改一步到位。最明显的变化不是省 token而是模型不再“自由发挥”去改动无关代码。因为你想让它看到的边界outline 已经切好了。4.2 场景二定位 bug 调用链另一个我很高频使用的场景是排查 bug。旧做法里Agent 会先读问题描述比如某个接口返回了 500然后自己从入口文件开始逐行横扫。碰到函数调用就点进去读整个文件再返回出来一个晚上能读完半个服务。用 ast-outline 后我会把入口文件和异常堆栈一起给 Agent并让它先用大纲找到堆栈中每个候选函数。比如异常发生在create_order它从大纲里看到这个函数引用了_apply_discount和get_user_balance自然知道下一步该去读这两个辅助函数而不是顺着全文件把calculate_shipping也读一遍。整个排查链路呈树状收敛Agent 的每一次展开都是自己根据依赖关系选择的不需要人类把它按头喂到目标行。我之前在一次真实 bug 修复中做过统计传统全量读取平均需要 7 到 10 次文件读取每次带上 800 到 2500 行内容而使用 outline 后Agent 总共只用了 4 次读取其中 3 次都是 200 行以下的片段读取定位速度提升明显。这种收益在小助手项目里可能不明显但一旦代码库大了模型反复读文件的延迟会非常可观。4.3 场景三多文件仓库任务里的上下文预算最后看一个多文件任务。任务要求是在不影响旧接口的前提下给模块新增一个导出函数并在 API 路由层注册。这个任务本质上要触碰至少三个层级的文件业务模块、路由注册文件、参数校验文件。旧方案让 Agent 扫描时它会非常诚实地把每个可能相关的文件都完整读一遍常常不到几轮工具调用上下文就累积了几万 token。假如中途模型发现自己读漏了某个文件的依赖又要回头补充读取上下文窗口会进一步膨胀次数多了就会触发“忘记原始任务”的毛病。ast-outline 在这类场景下最大的作用是把“前期侦查成本”摊薄。Agent 可以先对目标目录递归调用get_outline得到一份类似目录树的符号索引。然后它只在需要改动代码时读取具体符号区域其余部分都保持折叠状态。我实际体验下来同一个多文件任务旧方法消耗大约 5.6 万 tokenast-outline 消耗约 1.1 万 token同时生成结果的成功率还提高了因为 Agent 不会被无关函数带偏。下面用一个简化表格说明不同读取方式在典型任务中的表现读取方式输入 token 占比定位准确性最常见副作用适合场景全文件读取100%容易漏看或改错上下文混乱、改到无关代码单文件小任务模型需要全貌固定窗口切块40%~70%中低截断函数边界后理解错误几乎没有场景天然适配ast-outline 按需读取10%~30%高需要模型严格按流程先看大纲中大型仓库、跨文件改动、bug 排查当然这个对比不是严格学术实验但它反映了我在多个日常任务里的稳定感受。5. 这玩意儿不是银弹AST 方案落地时的坑与补救5.1 宏、装饰器和“反手给你一个 eval”的代码AST 的价值建立在源码确实由静态语法构成的前提下。但现实世界中总有一些代码不是这么听话。最常见的坑是宏。比如 Rust 的macro_rules!展开结果、C 语言里通过#define生成的函数、TypeScript 里某些编译插件注入的代码这些在源码里并不存在对应的具体函数定义。ast-outline 根据静态文本解析时看不到展开后的内容因此 Agent 可能会告诉用户“在仓库里找不到你提到的函数”。对这个问题的补救方案是增加一处fallback逻辑如果按符号名找不到 outline 记录就回退到基于文本的符号搜索可以调 LSP 或 ripgrep把疑似函数文本作为补充信息返回。这种兜底不是完美的但总比让 Agent 直接认输要强。还有一类麻烦是 Python 装饰器和动态__getattr__。装饰器本身不会影响函数名提取但装饰器里包裹的复杂 wrapper 会让 start_line 与实际可执行代码错开。为了不让模型对“函数定义位置”产生误解我在生成函数片段时会刻意向上多取一行把装饰器、注解区域包含进来。5.2 索引热更新源文件变了大纲还停在昨天Agent 一旦开始交互式地改代码它就处于一个“边改边看”的过程。假设 Agent 第一次根据旧大纲选定了某个函数接着自己修改了一个文件第二次请求大纲时如果继续返回旧 index那它后续读到的代码片段和实际文件内容就会对不上甚至把刚改好的代码又改回去。这其实不是 AST 工具的智力问题而是一个典型的缓存一致性问题。我一开始图省事直接用文件更新时间判断是否重建大纲结果发现当 Agent 写文件和读文件发生在同一秒内时mtime 会出现相等情况索引没触发更新踩了好几次坑。后来我把策略改成“用文件内容 hash 作为缓存的 key”并在get_symbol返回前动态判断当前磁盘内容是否已经变化。如果变了就立刻重新解析并替换缓存保证 Agent 永远拿到的不是陈旧数据。5.3 返回“片段”和返回“行号”是两件不同的事很多基于工具的 Agent 设计者习惯把行号作为定位手段比如“读取 file.py 的第 23 到 45 行”。但模型对行号的感知能力非常弱行号稍微偏移就会把错误代码当作目标代码进一步引发幻觉。我在早期版本里确实让 get_symbol 返回一个 JSON只包含路径和行号区间结果模型拿到后经常自己叠行号展开读出来的东西乱七八糟。后来我把返回格式彻底改成“代码块 文件路径 起始行”三层结构。也就是模型拿到的不是一行提示而是### src/services/order_service.py:15-47 def create_order(self, user_id: int, items: list[CartItem]) - OrderResult: ...这样模型只需要把注意力放在代码块上不需要做行号数学。这个改动让后续生成补丁的可执行率提升了不少。给 Agent 的接口要尽量降低它对“物理位置”的心智负担尽量把需要的答案直接喂到嘴里。5.4 Agent 抓取策略没有边界照样会爆上下文最后一个我要吐槽的坑出现在 Agent 的“自律性”上。ast-outline 本身只负责提供大纲和按需切片但如果 Agent 在循环中疯狂调用get_symbol它完全可以做到和全量读取一样的上下文消耗区别只是更慢。所以接口层只解决“能不能”策略层还必须要让 Agent 明白“取多少”。我给 Agent 定的策略实际上是三条经验看大纲先于看源码大纲能解决的任务绝不打开源码如果要修改某个函数优先看函数定义 它直接调用的依赖不需要看当前文件的姐妹函数展开文件不能只看一个符号需要结合几个发散引用时每个文件最多展开不超过三个符号超过范围就要重新审视自己的计划是否太宽。这些规则放在 Agent 的 system prompt 里比在代码里写死更容易迁移。由于模型在执行元任务时并不总能记得规则我还会在get_symbol的返回值里附带一行小提醒当前已展开符号数量与限制。这种带状态的提示比“堵”更有效。6. 最后聊聊我对这个思路的后续打算把 ast-outline 做出来之后我最大的感触是AI Agent 在代码库里的工作方式正在从“靠上下文窗口硬扛”慢慢转变成“用前端编译器技术缩小决策范围”。如果我们想让 Agent 更快、更准、成本更低就得给它提供一个既能看宏观又能下钻微观的地图系统。AST 恰恰是建立地图最好的底层材料。我接下来准备在这个基础上做两件事一是把 outline 的能力从“单文件提取”扩展到“跨文件依赖图”让 Agent 在请求某个符号时顺便能看到它影响到了哪些文件的哪些函数二是尝试把行级 blame 信息加入大纲这样 Agent 在修改历史代码前能先读到最近改动的人和时间降低被旧逻辑坑住的概率。如果你也正在被“Agent 把整个项目当作一本书从头读到尾”这件事困扰我建议你先别急着一次做大而全的代码理解系统。先花一个周末把文件大纲抽出来再给 Agent 加三个检索工具跑几个真实任务对比下 token 和在错误率大概率会打开一扇新的大门。