跳到主要内容

Monorepo 中的编程智能体:为什么上下文窗口与 50 个服务的代码库无法兼容

· 阅读需 10 分钟
Tian Pan
Software Engineer

这是一个静默发生的失败模式:你要求编程智能体更新身份验证服务的令牌刷新端点。智能体生成了看起来很干净的代码——自信、注释详尽且类型安全。然而,它调用了一个三层目录之上的共享库中,在三个月前就被重命名的函数签名。由于 Mock 仍然使用旧的签名,该端点的测试通过了。直到代码进入预发布环境并拉取真实的库时,错误才浮出水面。

这在抽象意义上并不是“幻觉”。模型知道那个方法——它存在于训练数据中的某个地方,或曾在上下文中简短出现过。问题在于架构:智能体从未获得过它所调用的接口的当前版本。

这是大型 Monorepo 中编程智能体最核心的失败模式。并不是模型本身糟糕,而是它们在只见过部分的建筑物中,在没有地图的情况下运作。

规模的不匹配比看起来更严重

目前 LLM 的上下文窗口在 10 万到 200 万个 token 之间。这听起来很大,直到你衡量一个企业级 Monorepo 真正包含的内容。一个典型的包含共享库、基础设施即代码、生成的类型和测试套件的 50 服务仓库,其源代码可能跨越数千万个 token。

即使你只精选“相关”文件,也会遇到一个更微妙的问题:相关性正是你无法预先知道的内容。当一个变更涉及到共享工具时,相关文件是所有引用它的文件——而你只能通过遍历尚未加载的依赖图来发现这些文件。

更大的上下文窗口改善了情况,但并没有消除问题。对上下文利用率的研究发现,随着输入长度的增加,模型的表现变得越来越不可靠——模型失去了在极长上下文中均匀关注的能力。因此,即使你可以将 500 个文件塞入 Prompt 中,智能体对中间部分内容的处理方式也与边缘内容不同。你会得到不可预测的性能退化,而不是清晰的失败。

此外还有成本问题。按照目前的 token 定价,在任何有意义的工程规模下,在每次请求中无差别地包含 Monorepo 的大部分内容在财务上都是不可持续的。

这种天真的上下文填充方式——将看起来相关的文件塞进 Prompt——在工具索引质量明显下降之前,实际上限大约是 2,500 个文件。大多数严肃的 Monorepo 在多年前就跨过了这条线。

智能体在幻觉接口时究竟在做什么

当编程智能体使用错误的签名调用函数时,机械层面上发生了什么?

智能体需要调用一个跨包接口。它要么在本次会话的前期见过该接口(而会话已滚动),要么从它见过的代码库其他部分的类似模式中推断出来的,或者是从训练数据中针对类似库进行内插生成的。这些都不靠谱——但它们都能生成语法合法的输出,从而通过粗略的代码审查。

解决方案不是增加更多的错误处理或后处理。解决方案是确保智能体在编写调用之前,对接口有一个可靠、实时的视图。这需要“主动的上下文构建”,而非“被动的上下文填充”。

Repo-Map 索引:解决方案的第一层

处理这个问题最成熟的方法是仓库地图(Repo-Map)——一种能在合理的 token 预算内涵盖整个代码库的紧凑结构化索引。

Aider 的实现是典型的例子。Repo-Map 并不包含文件内容,而是列出文件及其关键符号:类名、函数签名、导出的类型。默认情况下,地图本身大约占用 1,000 个 token,但它为模型提供了整个仓库的拓扑结构。当智能体需要理解某个特定接口时,它知道该请求哪个文件——并且它可以请求该文件的实际内容,而不是凭记忆推断。

底层的选择算法是基于依赖图的个性化 PageRank。每个文件是一个节点;import 语句和符号引用是边。被频繁引用的文件得分更高,并在地图中获得更多展示。这自然地凸显了那些对任何给定变更都最重要的共享工具和核心抽象——即那些如果理解有误,会导致最多下游破坏的包。

Tree-sitter 将源文件解析为抽象语法树(AST),因此符号提取是感知语言的,而不是基于正则表达式的。Go 接口、TypeScript 类型导出和 Python 类定义都能被正确解析并在地图中统一表示。

这种方法不需要智能体在开始之前就知道什么是相关的。它预加载了结构意识,让智能体能够从全局概览导航到具体细节。

跨服务变更的依赖图遍历

Repo-Map 捕捉了签名,但没有捕捉运行时依赖关系——即跨服务边界的调用关系,以及一个变更的真实“爆炸半径”是什么。

这就是依赖图工具发挥作用的地方。像 Ctxo 和 Nx 项目图(project graph)这样的工具让智能体能够查询完整的依赖拓扑。智能体不再需要猜测哪些服务依赖于它正在修改的 auth 库,而是可以发起查询:哪些包导入了这个模块?答案是以列表形式返回的,而不是推断。

这种循序渐进的探索模式如下所示:

  1. 从领域层级开始——这个变更属于哪个限界上下文(Bounded Context)?
  2. 使用项目图识别受影响的包及其消费者。
  3. 获取这些特定包的实际文件内容。
  4. 在完全了解双方接口的情况下进行变更。

与简单的检索相比,关键区别在于智能体永远不需要同时将所有这些内容保留在上下文中。它分步遍历图,构建出一幅连贯的图景,而不是试图一次性摄取所有内容。

特别是在理解爆炸半径时——即如果你修改这个函数会破坏什么——图遍历比向量相似度(vector similarity)更可靠。语义相似度不知道两个服务是通过共享的 Protobuf Schema 耦合在一起的,但依赖图知道。

局部上下文构建:领域先于文件

Repo maps 和 dependency graphs 都指向一个更广泛的原则:上下文构建应该是局部化的(scoped),而非穷尽的。

当智能体(agent)失败时,人们的直觉往往是添加更多上下文。这通常是错误的。更多上下文意味着更多噪音,更多的 token 消耗在无关文件上,且模型更有可能在长提示词的中间丢失关键信号。正确的做法是更精准的范围界定(scoping)。

针对此问题的一个实用架构分为三个层级:

第 1 层:架构引导(Architectural steering)。 根目录下的 CLAUDE.mdAGENTS.md 文件描述了代码库的高层结构——服务所在地、命名规范的含义、哪些目录是生成的以及哪些是手动编写的。这只需消耗几百个 token,就能防止智能体在基础导航上浪费探索步骤。

第 2 层:领域范围内的文档(Domain-scoped documentation)。 每个主要服务或领域都拥有一个描述其规范、接口契约和依赖关系的文件。智能体只读取相关的那个文件——而不是全部。

第 3 层:按需文件检索(On-demand file retrieval)。 repo map 告诉智能体哪些文件包含它需要的符号。dependency graph 告诉它哪些包在作用域内。只有到那时,它才会提取实际的文件内容。

这种分层方法使任何给定任务的工作上下文保持精简且高信号,同时保留了智能体在需要时导航整个代码库的能力。

针对接口准确性的两阶段检索模式

针对接口幻觉(interface hallucination)这一特定问题,两阶段检索是最可靠的缓解方案。

在第一阶段,你检索候选对象——与任务在语义上相关的的文件、函数和类型。这是向量搜索发挥作用的地方:根据嵌入相似度(embedding similarity)寻找看起来相关的项。

在第二阶段,LLM 会根据对实际任务的理解对候选对象进行过滤和排序。这一步可以捕捉到语义相似度误导你的情况,比如它带你找到了一个类型的过时版本,或者找到了另一个包中同名的函数。过滤模型可以提问:这个符号对于调用上下文是否有正确的接口?这是当前的规范定义,还是遗留版本?

两阶段模式并不保证智能体能看到每个相关文件。但它显著降低了智能体给出自信且错误信息的概率。第一阶段广撒网;第二阶段去噪音。

这对你如何为 AI 构建 Monorepo 的启示

如果你正在运行一个 monorepo 并期望编码智能体跨仓库协作,你在仓库层级做出的结构决策将直接影响智能体的可靠性。

显式的接口契约比以往任何时候都重要。当接口表达为具有清晰导出路径的版本化类型时,dependency graph 可以找到它们,repo map 可以表示它们。隐藏在未记录规范中的隐式接口对两者都是不可见的。

领域标签(Domain tagging)有助于智能体在开始阅读前进行导航。一个标签系统——甚至只是按领域对服务进行分组的目录命名规范——能让智能体从一个有意义的抽象层级开始,而不是从文件系统根目录开始。

每个服务边界的引导文档都需要维护。这些是每个领域的 AGENTS.md 文件,描述了服务的功能、依赖项以及接口的组织方式。编写这些文件的成本很低,但对于那些原本不得不从头推断这一切的智能体来说,价值极高。

我们开头提到的智能体失败模式——调用一个已重命名的方法——是可以预防的。不是通过让模型变得更聪明,而是通过确保在模型开始生成代码之前,当前的接口定义是可查找、已索引并已呈现的。这是一个工具和结构问题,也有工具和结构的解决方案。

如今那些能实现可靠跨服务智能体协作的团队,并不是在使用更大的上下文窗口。他们正在构建让智能体能够精准导航的基础设施。

References:Let's stay in touch and Follow me for more thoughts and updates