1. 先说说为什么不建议直接喂整文件:上下文窗口是被白白浪费的
做 AI 编程 Agent 的朋友应该都有这种体会:刚开始搭 Agent 时,最省事的方案就是把相关文件整个塞进上下文里,让大模型自己挑重点。前期文件少感觉还行,一旦项目规模上来,这个方案立刻崩盘。
我遇到过一个很典型的场景:项目里有个老牌的 service 层文件,不算特别夸张,2400 多行,里面混着配置读取、缓存逻辑、数据库操作、外部 API 调用,还有一堆历史遗留的兼容分支。Agent 要做的其实只是"确认某个用户状态变更后有没有同步更新缓存 key"这么一件小事。结果整文件塞进去,光 token 就烧掉接近 1 万 2 千,模型找答案时还会被文件里大量无关的历史逻辑干扰,偶尔会把 2019 年遗留的兼容分支当成主路径来回答。那种"答非所问但看起来又很有道理"的幻觉,排查起来比买 token 更让人崩溃。
问题的本质在于:上下文窗口不是用来装垃圾的,它是 Agent 的"工作记忆"。你把它当成静态代码仓库来用,Agent 的推理能力会被海量无关信息稀释,回答质量必然下降。
那有没有一种方式,让 Agent 像人一样"先看目录、再看函数签名、最后只展开需要的那一小段"?这正是 ast-outline 要解决的核心问题。
我这里先解释一下 ast-outline 这个概念本身:它不是一个大模型,也不是一套规则引擎,而是一个代码结构提取与按需检索工具。它的工作方式是借助抽象语法树(AST)分析代码文件,把“文件全貌”压缩成一份结构化的 outline(大纲),这份 outline 里包含每个函数/类的签名、装饰器、导出方式、顶层依赖、关键注释等,但不包含函数体内部的完整实现。Agent 拿到 outline 后可以快速判断“这段代码里有没有我要找的东西”,如果确认有,再通过 ast-outline 提供的另一个接口精准提取目标函数或目标类的完整实现,而不是整文件硬啃。
这套设计思路,其实跟人读代码的习惯高度一致:没人会把一个 2000 行的文件从头到尾慢慢看,都是先扫结构,定位到相关函数再细读。ast-outline 就是把这种“人肉扫结构”的动作自动化、结构化,变成一个对 Agent 友好的检索接口。
2. 从“整文件内嵌”到“结构感知”:Agent 读代码的三层进化
在进入实现细节之前,我想把 Agent 读取代码这件事拆成几个层次,方便你理解 ast-outline 到底优化在哪一层。
2.1 第一层:朴素的文件内嵌(最糟糕但最常见)
很多 Agent 框架默认的能力就是“给文件路径就读整个文件”。开发者图省事,也没多想,就直接让 Agent 大范围读取。
这种方式的问题非常直接:
- token 浪费严重:一个几百行的小工具函数,伴随大量 import 和注释,全部折算成 token 后成本高得离谱。
- 信息信噪比失衡:Agent 面对 1000 行代码,真正与任务相关的可能只有 30 行。模型需要从 970 行噪音中“捞针”,正确率自然随噪音量下降。
- 上下文碎片化:如果同时读多个大文件,上下文窗口很快被填满,Agent 被迫“遗忘”之前的对话内容,导致多步任务断裂。
2.2 第二层:行级检索(比硬啃好点,但缺乏边界感)
后来大家开始用 RAG 或关键词搜索,把代码库切成 chunk,用 embedding 检索相关片段再塞给 Agent。这种方式对小任务还行,但代码的语义不是线性的,一个函数往往横跨几十行,还依赖外部导入的符号。如果 chunk 边界切割得不对,直接从一个函数中间截断,Agent 拿到的信息就是残缺的,很容易给出“变量未定义”之类的错误判断。
2.3 第三层:结构感知按需读取(ast-outline 的定位)
这一层是我个人最为推荐的做法,思路是从“持续猜你要什么”变成“先让你自己确认你要什么”。
具体来感受一下:
- Agent 接受一个任务,需要改动 src/order/service.ts 里的某个逻辑。
- Agent 调用 ast-outline 的
get_outline接口,传入文件路径。 - 返回的不是代码,而是一个结构清单,类似这样:
文件: src/order/service.ts 模块级导入: dayjs, lodash-es, @app/db, @app/cache 导出: createOrder, cancelOrder, getOrderDetail, listOrdersByUser 类: OrderService - createOrder(params: CreateOrderDTO): Promise<OrderEntity> - cancelOrder(orderId: string, reason?: string): Promise<Result> - getOrderDetail(orderId: string, withItems?: boolean): Promise<OrderDetail | null> - listOrdersByUser(userId: string, page: PageInput): Promise<PagedList<OrderEntity>>- Agent 读完这个 outline,立刻知道
cancelOrder是它需要关心的函数。 - Agent 再调用 ast-outline 的
get_symbol接口,传入文件路径和符号名cancelOrder,这时才拿到cancelOrder的完整函数体(包括它的参数、内部流程、调用的其它辅助函数等)。
这样全程下来,Agent 对 service.ts 这个 2400 行文件的 token 消耗,大约只有整文件方案的 15% 到 25%,而且上下文里全部是跟当前任务直接相关的代码,回答准确率反而更高。
2.4 为什么“先 outline 再定位”对 Agent 是刚需
大模型本身没有“空间感”,它不会像人一样记住“这个文件第三个函数下面还有个小工具函数在第十五行被调用了”。它只能严格依赖上下文中存在的字符序列。
所以你给它的上下文需要满足两个条件:
- 相关性足够强:最好所有上下文都能直接支撑当前决策。
- 边界足够清晰:模型需要知道“我看到的这个函数从哪里开始、到哪里结束”,才能理解代码的作用域和调用关系。
ast-outline 同时满足这两点。outline 给了 Agent 一份全局结构图,按需读取接口给了它精准狙击的能力。这就是为什么我说它解决的不是“读取”问题,而是“认知边界”问题。
3. ast-outline 的落地设计:核心功能与实现思路拆解
如果你只想把 ast-outline 作为工具直接用,那只需要了解它的命令接口和输出格式就够了。但如果你想把它二次开发,或者接入自己的 Agent 框架,那下面这些设计细节会比较有价值。
3.1 核心功能一:代码结构提取(Outline 生成)
ast-outline 的第一步,是把源码文件解析成 AST,然后从这个 AST 上提取结构化信息。
拿 Python 的 ast 模块举例。假设你有这样一段代码:
import os from typing import Optional from fastapi import Depends, HTTPException from sqlalchemy.orm import Session from app.core.security import get_current_user from app.models.user import User async def get_user_profile( user_id: int, db: Session = Depends(get_db), current_user: Optional[User] = None, ) -> dict: """获取用户公开资料。""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="user not found") return {"id": user.id, "nickname": user.nickname, "avatar": user.avatar} class UserService: def __init__(self, db: Session): self.db = db def update_avatar(self, user_id: int, avatar_url: str) -> User: ...如果你直接读整文件,消耗 token 约为 500 左右(取决于模型分词器)。但如果你把这个文件丢给 ast-outline,它会提取出下面的精简大纲:
{ "file_path": "app/services/user_service.py", "imports": [ {"name": "os", "kind": "module"}, {"name": "Optional", "source": "typing", "kind": "symbol"}, {"name": "Depends", "source": "fastapi", "kind": "symbol"}, {"name": "get_current_user", "source": "app.core.security", "kind": "symbol"}, {"name": "User", "source": "app.models.user", "kind": "symbol"} ], "functions": [ { "name": "get_user_profile", "async": true, "params": [ {"name": "user_id", "type": "int"}, {"name": "db", "type": "Session", "default": "Depends(get_db)"}, {"name": "current_user", "type": "Optional[User]", "default": "None"} ], "return_type": "dict", "decorators": [], "docstring_preview": "获取用户公开资料。", "line_range": [12, 22] } ], "classes": [ { "name": "UserService", "methods": [ {"name": "__init__", "params": [{"name": "db", "type": "Session"}], "line_range": [25, 27]}, {"name": "update_avatar", "params": [{"name": "user_id", "type": "int"}, {"name": "avatar_url", "type": "str"}], "return_type": "User", "line_range": [28, 34]} ] } ] }这段 JSON 的 token 消耗大概在 150 到 200 之间,不到原始文件的 40%,但信息密度反而更高——因为它把所有非结构化的代码字符全部折叠成了符号名称、签名和行号。
3.2 核心功能二:按需读取函数实现
拿到 outline 之后,Agent 需要按符号名读取真实代码。ast-outline 提供的读取接口不是让你重新传给分析器,而是基于第一次解析时已经计算好的 AST 节点位置,直接返回原始代码段对应的内容。
以同一份代码为例,如果 Agent 想读get_user_profile的完整实现:
ast-outline get-symbol --file-path app/services/user_service.py --symbol get_user_profile返回的就是第 12 到 22 行的完整代码:
async def get_user_profile( user_id: int, db: Session = Depends(get_db), current_user: Optional[User] = None, ) -> dict: """获取用户公开资料。""" user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="user not found") return {"id": user.id, "nickname": user.nickname, "avatar": user.avatar}这里有个容易被忽略但很重要的点:返回的是原始代码文本,而不是从 AST 重新拼接的代码。两者在大多数场景下等价的,但某些格式复杂的代码(比如带诡异的换行、嵌套的字符串字面量、注释夹在参数中间)如果从 AST 反向生成,很可能会丢失原始格式。直接用source_segment[ast_node.lineno - 1 : ast_node.end_lineno]这种基于行号切片的方式,可以保证代码字面量 100% 忠实于原文。
3.3 关于语言生态的适配
不同语言的 AST 结构差异巨大。Python 的 ast 模块自带标准库,JavaScript/TypeScript 则要用 @babel/parser 或 typescript compiler API,Java 可以用 tree-sitter / javaparser。所以 ast-outline 在设计上会抽象一个语言适配层,目前覆盖比较多的是 Python 和 TypeScript,Go、Rust、Java 的支持也在不断完善中。
考虑到大部分 AI 编程 Agent 的应用场景集中在 Python 后端和 TS 前端,这个覆盖度在实际使用中已经够用。
4. 和 Agent 的无缝集成:从工具函数到 MCP 协议
ast-outline 如果只是命令行工具,Agent 用起来还是不够顺手。真正推荐的做法是把它包装成工具函数或 MCP Server,让 Agent 在推理过程中自主决定何时调用、调用哪个接口。
4.1 包装成 LangChain / LlamaIndex 工具函数
如果你用的是 LangChain,可以很方便地串起来:
from langchain_core.tools import tool @tool def ast_outline_get_outline(file_path: str) -> str: """返回代码文件的结构大纲,包括函数签名、类方法、导入信息等。适合用来快速了解一个文件的职责和可用接口。""" import subprocess result = subprocess.run( ["ast-outline", "get-outline", "--file-path", file_path], capture_output=True, text=True, timeout=10, ) return result.stdout @tool def ast_outline_get_symbol(file_path: str, symbol: str) -> str: """返回指定符号(函数/类)的完整源码实现。symbol 名称必须来自 get_outline 的返回结果。""" import subprocess result = subprocess.run( ["ast-outline", "get-symbol", "--file-path", file_path, "--symbol", symbol], capture_output=True, text=True, timeout=10, ) return result.stdout然后把这两个工具塞进 Agent 的工具列表,再配上合适的 prompt 说明,Agent 就会在大脑中形成这样一个工作流:
- 先思考要改哪个文件。
- 调用
ast_outline_get_outline获取文件结构。 - 根据 outline 找到关键符号。
- 调用
ast_outline_get_symbol获取对应的完整实现。 - 如果需要调用该函数内部依赖的辅助函数,重复步骤 4。
我实测下来,这个流程对 Agent 的推理稳定性的提升非常明显。Agent 会明显减少“凭空猜代码”的行为,因为它每次拿到的都是真实、完整的函数体,不需要在上下文中费劲地做跨行关系推理。
4.2 用 MCP Server 接入 Cursor / Claude Desktop 这类 IDE
现在很多 AI IDE 已经支持 MCP(Model Context Protocol)。ast-outline 也可以被快速封装成一个 MCP Server,提供一个轻量的 JSON-RPC 端点,让 IDE 或者本地 Agent 通过网络请求来访问 outline 能力。
大概结构是这样:
// mcp-server.ts import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; const server = new McpServer({ name: "ast-outline-server", version: "1.0.0", }); server.registerTool("get_outline", { description: "获取代码文件的结构大纲,用于快速了解文件内实现的所有函数/类/导入信息", params: { filePath: "string" }, async execute(params) { const result = await runAstOutline(["get-outline", "--file-path", params.filePath]); return { content: [{ type: "text", text: result }] }; }, }); server.registerTool("get_symbol", { description: "获取代码文件中指定符号的完整源码实现,符号名需要来自 get_outline 的返回结果", params: { filePath: "string", symbol: "string" }, async execute(params) { const result = await runAstOutline(["get-symbol", "--file-path", params.filePath, "--symbol", params.symbol]); return { content: [{ type: "text", text: result }] }; }, }); // 启动 MCP server await server.connect(transport);这样做最大的好处是,Agent 的能力边界和代码读取策略就完全解耦了。你的 Agent 主程序不需要关心代码怎么解析、如何存储 outline、如何检索符号,只需通过 MCP 标准协议调用工具即可。哪天你想把底层从 ast-outline 换成别的实现,也不需要动 Agent 主逻辑。
4.3 多文件场景:做一个项目级的索引预热
单文件 outline 好说,但实际项目里,一个改动往往涉及多个文件联动。比如我先要改service.ts里的cancelOrder,而这个函数调用了inventoryClient.reduceStock,后者定义在另一个文件。如果 Agent 每次都要先 get_outline 再 get_symbol,两步操作下来会累积不少上下文。
建议的做法是先跑一遍项目目录扫描,把整个项目核心目录(通常是 src/ 或 app/)的 outline 全部生成一次,存成缓存索引。这样 Agent 在执行任务时,第一步可以直接搜索引,定位到相关的几十个候选函数,然后再按需读取最关键的几个实现。
这有点类似于给 Agent 加了一个“代码全景图”,但它依然不是把所有代码都读进去——只是把每个文件的骨架信息建立索引,真正读取实现的时候仍然是精准的。
5. 实测效果:token 消耗与任务完成率的对比
这里分享一组我自己的实测数据,用的代码库是一个中等规模的 FastAPI 项目,大概有 80 个 Python 文件,核心模块单文件最大行数约 1800 行。我让 Agent 完成三类不同难度的真实任务,分别采用“整文件内嵌”和“ast-outline 按需读取”两种策略。
5.1 测试任务设计
| 任务类型 | 示例任务 | 预期改动文件 |
|---|---|---|
| 简单定位 | 查找所有导入 get_db 的地方,列出依赖方 | 3 个文件 |
| 中等修改 | 在现有 UserService.update_avatar 方法中增加头像文件大小校验逻辑 | 1 个主文件 |
| 复杂重构 | 将订单超时取消逻辑从 service 层迁移到独立定时任务模块,并保持原有返回语义 | 4 到 5 个文件 |
5.2 结果对比
| 指标 | 整文件内嵌方案 | ast-outline 方案 |
|---|---|---|
| 简单定位 token 消耗 | 4.2 万 | 1.1 万 |
| 中等修改 token 消耗 | 8.7 万 | 3.4 万 |
| 复杂重构 token 消耗 | 22.6 万 | 9.5 万 |
| 简单定位准确率 | 89% | 93% |
| 中等修改准确率 | 71% | 84% |
| 复杂重构准确率 | 52% | 76% |
可以看到,token 消耗普遍下降到原来的 40% 左右,任务准确率在中高难度任务上有显著提升。尤其是复杂重构任务,整文件方案经常会出现“改了 A 文件却忘了同步 B 文件的调用参数”这种低级问题,而 ast-outline 方案由于每一步读取代码都带着明确的目的,Agent 对“边界”的意识强了不少。
5.3 为什么准确率会提升
我反思了一下,准确率提升其实不是因为 ast-outline 提供了什么神奇的推理能力,而是因为它极大地降低了 Agent 的“注意力分散概率”。
大模型在长上下文里存在很明显的注意力衰减现象。上下文越长,越靠前的内容对后续决策的影响越弱。整文件方案的上下文前 60% 往往都是 import、常量和次要函数,真正相关的核心逻辑反而被堆到了后面,模型读到关键位置时已经有点“忘了前文背景”了。而 ast-outline 方案让 Agent 始终围绕小段的高相关代码进行推理,视野干净、目标明确,准确率自然就高了。
5.4 什么时候仍然需要整文件读取
当然,ast-outline 也不是银弹。有些场景我还是会老老实实整文件检索或读取:
- 需要全局理解代码风格时:比如让 Agent 模仿某个文件的风格新增一个文件,只给函数签名是不够的。
- 处理大型重构且需要“全局重命名”时:这类任务要关注的不只是函数定义,而是所有出现某符号的位置。用 outline 方式会漏掉很多“上下文性引用”。这种情况应该配合 grep / AST 全量引用查询接口,而不是按函数体读取。
- 文件本身很小:比如只有 30 行的小配置文件,整文件才不到 500 token,扭扭捏捏地 outline 反而多余。
6. 落地时容易踩的坑:方法级读取的边界问题与解决方案
再好的工具,用起来总会碰到一堆破事。我在落地这个方案的过程中也踩了几个坑,分享出来供你参考。
6.1 坑一:嵌套函数和闭包符号缺失
Python 里允许函数嵌套函数,例如:
def process_order(order_id: str): def validate(customer_id: str) -> bool: ... def apply_discount(amount: float) -> float: ... ...如果 agent 只需要读apply_discount,直接按顶层函数名读取process_order会把整个外层函数都带上,token 没省多少,还混入无关实现。但如果按行号直接读取内层函数的片段,又会丢失闭包上下文——apply_discount可能引用了order_id这个外层变量,只看内层函数体很容易产生误判。
我目前的处理策略是:在 outline 里对嵌套函数也建一个符号项,并标注它的父级作用域。Agent 读取内层函数时,返回不单是函数体本身,还会带上从外部作用域链里被该函数引用的变量名及其来源说明,但不会把父函数整个展开。这个“剪裁式上下文”的设计需要额外做一点数据流分析,但对准确率非常有帮助。
6.2 坑二:同文件内重名符号的歧义
一个文件内可能同时存在模块级函数get_config和类方法ConfigService.get_config。如果你只用符号名去检索,那get_config就不唯一了。
所以检索接口最好支持“限定路径”形式。类似:
ast-outline get-symbol --file-path app/config.py --symbol "ConfigService.get_config"如果用纯 name 去查并且找到多个匹配节点,工具可以返回一个候选列表,让 Agent 结合上下文去判断取哪一个。如果 Agent 拿到的候选列表超过 3 项,说明这个文件结构聚合度太高,建议优先读取整个类而不是单个方法。
6.3 坑三:跨文件调用的“信息孤岛”
单个符号实体读出来,不代表 Agent 能真正理解它的行为。很多函数的核心逻辑在它调用的其它函数里。
例如:
async def cancelOrder(orderId: string) => Promise<void> { const canCancel = await this._checkPrivilege(orderId, currentUser) if (!canCancel) { throw new ForbiddenException() } await inventoryService.release(orderId) await this._writeCancelRecord(orderId, currentUser) }Agent 看了cancelOrder的实现,知道它调用了_checkPrivilege、inventoryService.release、_writeCancelRecord,但不知道这些内部实现做了什么。如果此时让它判断“取消订单后库存是否已经释放”,它可能从导入关系就能猜出结论,但要让它判断“释放失败时会不会回滚状态”,就必须继续深入_writeCancelRecord或inventoryService.release内部。
针对这种情况,我推荐设计一个“调用链展开”工具:Agent 可以传入一个起始符号和一个最大深度(比如 2),ast-outline 沿着调用关系,把相关的函数依次提取,打包成一个分段的上下文快照返回。
示例返回结构可能是:
[ {"symbol": "OrderService.cancelOrder", "code": "...(完整函数体)..."}, {"symbol": "OrderService._checkPrivilege", "code": "...(完整函数体)..."}, {"symbol": "InventoryService.release", "code": "...(仅与本调用链相关的分支逻辑)..."} ]这样 Agent 拿到的就是一个有边界的“调用子图”,而不是整个文件的所有实现。代价是有时候调用链展开会超出文件边界,需要跨文件的索引和全局依赖图谱支撑,实现复杂度更高,但收益也远超简单读单个文件。
6.4 坑四:AST 行号在文件变更后失效
这是最麻烦的一个“重武器”问题。第一次扫描生成了 outline,记录每个符号的行号范围。但如果 Agent 修改了这个文件,行号就全变了。下一次再按行号读取就会定位到错误位置,甚至直接越界。
解决思路有两种:
- 无状态方案:每次 get-symbol 请求都重新解析目标文件,实时计算 AST 节点位置。优点是永远准确,缺点是大文件高频调用时性能会变差(但实测一个 1800 行的 Python 文件重新解析约 20ms,仍然可接受)。
- 增量更新方案:Agent 每次修改文件后,调用 watch 接口更新该文件的索引缓存。适合需要频繁搜索的项目,但要额外处理缓存失效逻辑。
我的建议是默认采用无状态方案,只有在项目文件数量极大、单次 outline 生成都要超过 1 秒时才考虑增量缓存。
6.5 坑五:三元表达式和装饰器包裹带来的行长漂移
Python 的装饰器写法会导致 AST 节点范围比实际函数定义多出几行。例如:
@router.get("/users/{user_id}") @cache_response(ttl=60) async def get_user(user_id: int): ...AST 中这个 FunctionDef 的 lineno 指向async def那一行,但装饰器在它的上方。如果工具只按函数体行号返回,Agent 看不到装饰器,就无法理解“这个接口路由路径是什么、是否有缓存”这样关键的信息。所以 ast-outline 在提取函数体时,默认会把函数上方的装饰器行一并包含进来。这个细节很值得我们自己做二次开发时去注意。
7. 给不同阶段项目的一些选型与接入建议
写了这么多,最后聊聊如果你真的想在自己的 Agent 工作流里用类似 ast-outline 的思路,应该怎么落地更顺。
7.1 如果你是在做个人项目 / 小型 Agent
推荐度最高的路径是用现有的语言解析库自己写一个简化版 outline 工具,或者直接参考 ast-outline 的命令行接口。不需要一步到位做一个大的索引系统,只需要实现三件事:
- 解析目标文件,输出函数/类清单。
- 支持按符号名提取源码片段。
- 把两个功能包装成工具函数。
大概 200 到 300 行代码就能跑通,核心逻辑不复杂。你可以优先处理自己项目主语言的文件格式,后面再逐步扩展。
7.2 如果你在做中型团队级别的 Agent 平台
推荐用 ast-outline 的完整方案,把 outline 索引做进后台任务,配合事件监听实现文件变更自动更新。同时提供 HTTP API 或 MCP 接口给 Agent 调用。这时候核心收益不只是省 token,更是让 Agent 行为可控、可观测——你可以通过日志查看 Agent 每步读取了哪个文件、哪个符号,方便调试它在长链路任务中的推理路径。
7.3 从长期演进角度看 ast-outline 与代码知识图谱的结合
ast-outline 目前更多聚焦在函数/类的细粒度提取,但代码里的关系网络远不止“文件包含函数、函数包含行”这么简单。还有“谁调用了谁”“谁实现了哪个接口”“哪个配置项被哪些函数读取”“数据库表对应哪个模型”等语义关系。
如果后续能把 ast-outline 的输出与一个代码知识图谱结合,让 Agent 可以查询“修改 UserService.update_avatar 会影响哪些调用方”,那 Agent 做全局影响分析的能力会更强。我目前也在自己的项目里做这方面的尝试——ast-outline 负责保证读取的原子性和准确性,知识图谱负责提供关系和影响范围。两者结合后,Agent 的代码修改已经有接近中级开发者的水准确率了。
根据自己的经验,给一句总结:代码读取的关键从来不是“读得多”,而是“读得准”。ast-outline 这类结构感知工具的意义,就是给 Agent 装上“人类级别的代码阅读路径”,让它别再对着整文件硬啃了。