工程实战 · Codebase Agent
学习舱 / 工程实战 / Codebase Agent

实战项目:Codebase Agent——让 Agent 读懂代码库

整理中 深度 · 应用 更新 2026-09-09

前置 工具执行、上下文压缩

实战项目:Codebase Agent——让 Agent 读懂代码库

一句话定位:把 Agent 主循环架上四件只读工具,让它在一个真实代码库里自己找答案——本文给出业务 SOP 拆循环的方法、最简工具集的选法、两道上下文闸门,与一个 85 行可跑的迷你实现。前置:工具执行机制——工具执行五站与最简工具集法则在那篇拆过;上下文压缩体系——压缩五级阶梯与工作记忆在那篇备好。

先从「零件齐了却不敢开工」说起

零件确实齐了:Agent 基础里主循环会思考、行动、观察,工具执行机制里知道一次调用怎么注册、校验、回填,上下文压缩体系里知道上下文满了怎么层层设防。

可真要写一个去读公司代码库的 Agent,编辑器开着,第一行反而下不去手——

第一反应往往是堆料:把读文件、搜代码、查提交记录、语义搜索……十几个工具全挂上,再给代码库建个向量索引。结果工具一多,模型选错工具的概率跟着涨——工具执行机制篇给过数字:用上工具搜索与精选清单后,选对率从 49% 提到 74%,方向是做减法不是加工具;索引基建没搭稳,裸循环转两圈上下文就满了。

一个能读懂代码库的 Agent,最少需要哪几样东西?

答案比想象中短:一条从业务 SOP 拆出来的任务循环、四件只读工具、两道上下文闸门、一道刹车。本篇把这四样拼成一个 85 行的迷你实现,每一行都真跑过,跑完再对照 Claude Code 看产品级多了什么。

全景图:四件套拼出一个最小闭环

mini Codebase Agent:四件套拼成一个最小闭环 ① 业务 SOP 拆循环 查规则、修 bug 的流程 拆成「查一下、读一段」的步骤 Agent 主循环 思考:下一步查什么 行动:调一件工具 观察:结论进记忆 ② 最简工具集(只读四件) list_files 列清单 grep 搜关键字 read_file 读某段 git_status 看改动 ③ 两道闸门 闸门一:超长先截断 闸门二:回填结论不回填原文 在结果进记忆之前做减法 ④ 刹车 步数硬上限 到顶强制收尾,不许转下去 每圈计数,超限由 ④ 兜底 读法:SOP 供给任务,循环转起来,工具只留四件,结果过两道闸门才进记忆,八步兜底

图 1:mini Codebase Agent 全景——①业务 SOP 拆循环供给任务 ②四件只读工具 ③工具结果过两道闸门再进记忆 ④步数刹车兜底

渐进拆解:新员工读代码库的四件套

类比:把这套东西想象成一个新员工入职读代码库。师傅不给整库权限,给一份作业指导书——这是业务 SOP;规定只许问四类问题——这是最简工具集;工牌每次只放行几页资料,笔记本只许记结论不许整页抄——这是两道闸门;师傅还说了「最多问八次,问不完回来找我」——这是刹车。四样规矩没有一样是限制他读懂代码的,全是防止他把时间烧在原地。

① 业务 SOP 拆循环:任务从哪来

Agent 循环本身不会凭空知道「查什么」——任务得从业务流程里来。客服要答一句「满 100 减 20 怎么算」,一线的操作流程(SOP,标准作业程序)是:搜关键词定位到规则所在文件 → 读相关那一段 → 把规则讲给客户听。把这三步写下来,每一步要么是「调某件工具」,要么是「判断信息够不够了」——SOP 就这样拆成了循环能执行的步骤。图 2 给出这条映射:

一条 SOP 怎么拆进循环:以「查清折扣规则」为例 SOP 第 1 步:搜关键字定位文件 「折扣规则写在哪个文件?」 SOP 第 2 步:读那一段代码 「规则原文是什么?」 SOP 第 3 步:把规则讲清楚 「讲给提出问题的人听」 循环要素:一次工具调用 grep("def apply_discount") 循环要素:再一次工具调用 read_file("pricing.py", 9, 13) 循环要素:终止判断 信息够了 → finish,给出答案 读法:左列是人处理业务的步骤,右列是循环里对应的一步——每步要么调工具,要么判终止

图 2:SOP 拆循环——把「查折扣规则」的三步业务流程映射为两次工具调用与一次终止判断

拆的时候有一条分界线要划清:步骤完全固定、每一步不依赖上一步答案的流程,写成固定流程(Workflow)更省钱;步骤取决于上一把查到了什么的,才值得用循环(Loop)。「查规则」属于后者——读哪一段,取决于搜到了哪个文件。

② 最简工具集:为什么恰好是这四件

四件工具各对应一类不可替代的问题:list_files 答「库里有什么」,grep 答「关键字在哪」,read_file 答「这段写了什么」,git_status 答「现在改了什么」。少一件都有盲区——没有 grep,找一个函数就得把全库遍历读一遍。而多一件的代价,工具执行机制篇算过账:工具定义堆积吃上下文预算,名字相近还会抬高选错率。最简工具集防的不是功能不足,是选择的不确定性——两个工具都能回答同一个问题时,模型每一轮都要多赌一次。

那加一个「聪明」工具会破坏什么?最常见的候选是语义搜索:「直接问语义,不用猜关键词,多好。」破坏的恰恰是最简工具集换来确定性——grep 和语义搜索的职责边界是模糊的(搜「折扣」该用哪个?没有标准答案),模型从「确定地选」变成「每轮赌一次」;语义搜索还要求先建索引,检索质量退化时错误会静默发生(召回不到却看不出)。它不是不该加,而是不该在这个阶段加——库大到 grep 盲翻不过来时,正确动作是上检索基建(见边界节),而不是往四件工具里塞第五件。

四件工具最常接的两类任务。 迷你循环跑得最多的活分两型:代码理解——定位、读一段、讲清楚,场景 A 就是它的最小形;测试生成与修复——开工前先 git_status 确认工作区干净(改前改后责任分明),读失败的测试与对应实现,给出修改,最后跑一遍测试证明修好了。「改」与「证」两步正对应三道刹车里的完成验证——说修完了必须拿测试结果证明。本篇只实做只读的理解类;修复类一旦真的动笔改文件,权限与沙箱那档工程跟着上场(见边界节)。

③ 两道闸门:工具结果不许原样进记忆

循环里真正的上下文杀手是工具结果:读一个文件动辄几十行,循环转几圈,上下文就被原文堆满。迷你实现里设两道闸门,各取前置篇一件产品级机制的最小形。

闸门一截断——read_file 默认只放行 40 行,超出部分打回,并在结尾告知「该文件共 N 行,已截断,续读请改 start/end」,模型据此决定要不要读下一段。截断对应读文件工具自身的输出上限——前置篇讲过,Read 类工具正是靠输出上限控制体量,产品里其他大结果工具走的是「落盘留预览」那条路,且前提是不切断回读;续读提示把「被截断」变成「分窗读」的路标,路没堵死。

闸门二结论回填——工具结果不原样存进记忆,只存一句压缩后的结论。它的名字与前置篇的「结果回填」只差一个字,分工不同:结果回填保证每个调用必有配对结果,结论回填管的是结果以什么形态进记忆。结论回填对应工作记忆里「只留当前工作集」的纪律——真产品的摘要走压缩阶梯,最重时派子代理做九段走查;迷你版用「超限只留前 160 字符」先把这个意识立起来。

④ 刹车:循环必须有个强制出口

Agent 基础给过三道刹车:硬上限、完成验证、异常检测。本篇只装第一道——步数硬上限,到顶强制收尾。它防的场景在动手节真跑给你看:模型(或写错的脚本)反复读同一段,没有刹车就一直转下去。真实产品里另外两道刹车也都在岗:完成验证是「说改完了」必须拿测试结果证明,异常检测是连续失败自动熔断交人——这两道在写代码类任务里比只读任务更要紧。

动手:85 行的 mini Codebase Agent

整个实现连注释带空行 85 行(下面两块原样拼成一个文件),零第三方依赖,Python 3 标准库就够。分两块贴:先 Agent 主干,再三个「假模型」场景。

第一步,建一个练习仓库。 在任意目录下依次做三件事:

  1. 建 mini-repo/ 目录。
  2. 放入下面两个文件。
  3. 初始化 git:git init && git add -A && git commit。

第一个文件 pricing.py:

"""价格计算模块。"""


def get_price(sku):
    """查基础价:真实项目里这里连数据库,演示里直接返回。"""
    return {"A001": 80, "A002": 120}[sku]


def apply_discount(price, user):
    """满 100 减 20,仅限会员。"""
    if user.is_member and price >= 100:
        return price - 20
    return price

第二个文件 legacy.py 是个 62 行的「历史遗留订单状态机」。行数按 62 行来——场景 B、C 的脚本把窗口参数按 62 行写死了;具体内容不限。若你改了行数,把脚本里的 41-62 与答案里的 62 一并同步,本文实跑数字以 62 行版为准。

第二步,Agent 主干。 四件工具、分发器、两道闸门、主循环、刹车,全在这里(注释里的编号对应图 1):

import subprocess
from pathlib import Path

ROOT = Path("mini-repo")

# —— ② 四件工具:每个返回「给模型看的字符串」,超长先截断(闸门一)——
def list_files(sub=""):
    base = ROOT / sub
    return "\n".join(sorted(str(p.relative_to(ROOT)) for p in base.rglob("*") if p.is_file()))

def read_file(path, start=1, end=40):
    lines = (ROOT / path).read_text().splitlines()
    tail = max(0, len(lines) - end)
    shown = lines[start - 1:end]
    note = f"\n...(该文件共 {len(lines)} 行,已截断,续读请改 start/end)" if tail > 0 else ""
    return f"{path} 第 {start}-{start + len(shown) - 1} 行\n" + "\n".join(shown) + note

def grep(pattern):
    hits = [f"{p.relative_to(ROOT)}:{i}: {line.strip()}"
            for p in ROOT.rglob("*.py")
            for i, line in enumerate(p.read_text().splitlines(), 1)
            if pattern in line]
    return "\n".join(hits) or "无命中"

def git_status():
    out = subprocess.run(["git", "status", "--short"], cwd=ROOT,
                         capture_output=True, text=True).stdout
    return out.strip() or "工作区干净"

TOOLS = {"list_files": list_files, "read_file": read_file,
         "grep": grep, "git_status": git_status}

def dispatch(name, args):                       # 校验 + 执行 + 回填的最小版
    if name not in TOOLS:
        return f"未知工具 {name},可用:{sorted(TOOLS)}"   # 错误回传,不炸循环
    try:
        return TOOLS[name](**args)
    except Exception as e:
        return f"工具报错:{e}"

def condense(text, limit=160):                  # 闸门二:回填结论不回填原文
    return text if len(text) <= limit else text[:limit] + "…(已摘要)"

MAX_STEPS = 8                                   # ④ 刹车:步数硬上限

def run_agent(task, model):                     # 主循环:思考→行动→观察
    memory = [f"任务:{task}"]                  # 工作记忆:只带任务与结论
    for step in range(1, MAX_STEPS + 1):
        reply = model(memory)
        if reply["type"] == "finish":           # 终止条件:模型自判信息够了
            return reply["answer"], step
        result = dispatch(reply["tool"], reply["args"])
        print(f"[第 {step} 步] {reply['tool']}({reply['args']})\n{result}\n")
        memory.append(f"{reply['tool']} 的结论:{condense(result)}")
    return "达到步数上限,强制收尾", MAX_STEPS

dispatch 是工具执行五站的教学压缩版:校验站缩成「查名字在不在」,执行间就是函数调用,回填口保证每个调用必有配对结果——出错也回传错误字符串而不是炸循环,模型看到报错还能换姿势重试。

第三步,三个「假模型」场景。 model 参数是个函数:拿记忆、返回一步决策。教学里用脚本假扮,接真模型时把它换成调用模型接口的函数即可(密钥放环境变量,别写进代码):

# —— 教学用「假模型」:按脚本决策;换成调用模型 API 的函数,就是真 Agent ——
def model_understand(memory):                   # 场景 A:代码理解
    n = len(memory)
    if n == 1:
        return {"type": "tool", "tool": "grep", "args": {"pattern": "def apply_discount"}}
    if n == 2:
        return {"type": "tool", "tool": "read_file", "args": {"path": "pricing.py", "start": 9, "end": 13}}
    return {"type": "finish", "answer": "折扣规则定义在 pricing.py 第 9 行:满 100 减 20,仅限会员。"}

def model_long_file(memory):                    # 场景 B:长文件分窗读
    n = len(memory)
    if n == 1:
        return {"type": "tool", "tool": "read_file", "args": {"path": "legacy.py"}}
    if n == 2:
        return {"type": "tool", "tool": "read_file", "args": {"path": "legacy.py", "start": 41, "end": 62}}
    return {"type": "finish", "answer": "已通读 62 行:前半是状态转移表与判定函数,后半是拆单、导入导出与校验。"}

def model_looping(memory):                      # 场景 C:死循环模型——反复读同一段
    return {"type": "tool", "tool": "read_file", "args": {"path": "legacy.py", "start": 41, "end": 62}}

if __name__ == "__main__":
    print("=" * 24, "场景 A:代码理解", "=" * 24)
    answer, steps = run_agent("找出折扣规则的定义位置并说清规则", model_understand)
    print(f"第 {steps} 步完成:{answer}\n")
    print("=" * 24, "场景 B:长文件分窗读", "=" * 24)
    answer, steps = run_agent("通读 legacy.py 的订单状态机", model_long_file)
    print(f"第 {steps} 步完成:{answer}\n")
    print("=" * 24, "场景 C:死循环刹车", "=" * 24)
    answer, steps = run_agent("通读 legacy.py 的订单状态机", model_looping)
    print(f"刹车在第 {steps} 步触发:{answer}")

实跑输出(已验证,只摘关键行):

======================== 场景 A:代码理解 ========================
[第 1 步] grep({'pattern': 'def apply_discount'})
pricing.py:9: def apply_discount(price, user):
[第 2 步] read_file({'path': 'pricing.py', 'start': 9, 'end': 13})
pricing.py 第 9-13 行
def apply_discount(price, user):
    """满 100 减 20,仅限会员。"""
    ...
第 3 步完成:折扣规则定义在 pricing.py 第 9 行:满 100 减 20,仅限会员。
======================== 场景 B:长文件分窗读 ========================
[第 1 步] read_file({'path': 'legacy.py'})
legacy.py 第 1-40 行
...(该文件共 62 行,已截断,续读请改 start/end)
[第 2 步] read_file({'path': 'legacy.py', 'start': 41, 'end': 62})
legacy.py 第 41-62 行
(22 行代码原样返回,无截断提示——这次读到了底)
第 3 步完成:已通读 62 行:前半是状态转移表与判定函数,后半是拆单、导入导出与校验。
======================== 场景 C:死循环刹车 ========================
(第 1–8 步输出完全相同:反复 read_file 同一段)
刹车在第 8 步触发:达到步数上限,强制收尾

对着输出读,四件套各就各位:

  • 场景 A:SOP 三步对应两调一收,grep 命中带行号、read_file 按行号取窗——任务从业务流程来,循环只负责转。
  • 场景 B:闸门一真的开火了——62 行的文件第一窗只放 40 行(实测全文 1557 字符,第一窗输出 1131),截断提示让模型知道还有下文,第二窗把剩下的 22 行补完。
  • 场景 C:刹车开火——同一段 479 字符的窗口被读了 8 遍,若结果原样回填,记忆要吃 3832 字符。闸门二把它压成 8 句结论共 1328 字符(每条 166),省下 65%,刹车则保证第 8 步必停。
  • git_status:在这三个只读场景里没派上活——它是修复类任务开工前的第一问,到边界节再请它出场。

接真模型只差一步:把 model(memory) 换成一个调模型接口的函数——把四件工具的定义连同记忆发给模型,让它返回「调哪个工具、什么参数」或「finish」。四件工具按所用接口的 tools 参数规格各写一条定义(名字、描述、参数 Schema),随请求下发;消息与参数的请求写法照调用篇的基本功即可。换完先拿场景 A 的任务跑一遍,对着轨迹检查:它是不是也先 grep 再读?步数会不会超?——这就是你的第一份真实轨迹。

边界与取舍

库大了,四件工具会先在 grep 上破防。 grep 是字面匹配:搜「打折」找不到叫 applyDiscount 的实现,十万行级的库盲翻窗口也撑不住。此时的升级路线不是加第五件工具,而是换检索基建——按语法结构切块(AST,抽象语法树)、字面加语义双路检索、答案引用带行号,RAG 进阶里的混合检索与重排在代码语料上原样适用。判断线画在「重复度」上:查得勤、库又大,值得基建;一周查两次的小库,grep 够用。

写操作是另一档工程。 本篇四件工具全是只读,这是刻意的:读错了顶多白跑一圈,写错了改的是别人的代码。一旦加「改文件」工具,权限模式、沙箱边界、改完可回滚(先看工作区干不干净——git_status 在修复任务里的真正用途)、改完拿测试证明(完成验证刹车)就都得跟着上,这些在 Agent 安全边界里展开。

评测怎么做——golden set 从哪来

这个项目的评估集(golden set,带标准答案的考卷)有三条来源:

  • 真实工单改写成任务——最贴生产,答案就是当年怎么修的。
  • 从修复类提交记录反推——仓库自带题目与标准答案。
  • 公开基准当起步练习题。

公开基准这条路有个刚发生的前车之鉴:SWE-bench 是「给一个真实仓库的缺陷单、Agent 交修复补丁、以测试是否通过计分」的公开考卷,其由 OpenAI 组织人工复核筛选的 500 条子集(SWE-bench Verified)曾是行业通用考卷——2026 年 2 月 OpenAI 发文宣布停报。停报的两个理由都写在审计数字里:

  • 一是测试本身有缺陷:138 道模型高频失败题(占全集 27.6%)里 59.4% 存在实质问题,其中 35.5% 的测试考了题面没要求的实现细节、18.8% 查了题面没要求的功能,其余是别的类型问题。
  • 二是训练污染:红队探测发现头部模型能背出原始修复补丁,半年里分数仅从 74.9% 爬到 80.9%。

公开基准当练习题可以,当考卷会饱和、会污染;自家业务的 golden set 才是护城河——建法照旗舰项目那套评估闭环:先建集、一次一变量、两锅分账(把「该找到的没找到」与「找到了答错」分开记账)。

指标至少三样:

  • 任务成功率。
  • 平均步数——刹车常触发就是任务拆得不对。
  • 工具选对率。

对照 Claude Code:产品级多了什么

跑完迷你版再开产品,差距集中在四处,每一处都是拿复杂度换能力:

维度迷你版(85 行)Claude Code(官方文档与社区逆向口径)
工具面四件全量常驻十几件内置,搜索命中才展开完整定义(懒加载)
长文件处理窗口截断 + 续读提示大结果落盘留预览(逆向口径默认五万字符),读文件类靠自身输出上限
结果进记忆一句结论回填压缩五级阶梯,最重派子代理九段走查,压完重建工作集
写操作与刹车只读 + 步数硬上限权限模式、沙箱、完成验证、异常检测多道并施

每多一行都是维护成本——产品版的每一项都对应真实任务量:先有任务,再有机制。这与最简工具集是同一条纪律。

最后的坏味道预警

如果你的 Agent 总在第五六步「忘了」自己查过什么,缺的不是更强的模型,是闸门二——它把结论抄进了记忆却没把任务带走;如果它翻来覆去在读同一个文件,缺的不是更多工具,是任务拆解——SOP 里没告诉它「查到行号就够了」。这两个坑的系统性解法在状态机与 Checkpoint(中断恢复与死循环防护)里展开。

自测

最简工具集为什么够用?加一个「聪明」的工具会破坏什么?

够用的判据是「四类不可替代的问题各有其件」:列清单、搜关键字、读片段、看改动——查清一个规则或定位一个缺陷,这四问已经覆盖全程。加聪明工具(如语义搜索)破坏的是选择的确定性:它与 grep 职责边界模糊,模型从「确定地选」变成「每轮赌一次」,选错率随工具相似度上升;它还引入索引基建与静默失效(召回不到看不出)。正确时机是库规模与查询频率压到 grep 真撑不住时,且换的是检索基建而非第五件同类工具。

Codebase Agent 的评测怎么做——golden set 从哪来?

三条来源:真实工单改写(最贴生产)、修复类提交记录反推(自带标准答案)、公开基准当起步练习。公开基准的教训要记住:SWE-bench Verified 这张曾经的行业考卷 2026 年 2 月被 OpenAI 停报——27.6% 的高频失败题里 59.4% 有实质问题(多为测试过窄或过宽,其余是别的类型),且头部模型已能背出原始补丁(污染)。所以自家集才是护城河,建法照评估闭环:先建集、一次一变量、两锅分账。指标三样起:任务成功率、平均步数、工具选对率。

工具结果不截断直接回填,会对循环造成什么后果?两道闸门各防什么?

爆在上下文窗口:读任务每步产出几十行原文,转几圈就把预算吃光,实测里同一段 479 字符的窗口被死循环读 8 遍就要 3832 字符。闸门一(截断)防「单次结果过大」——默认窗口放行、超出打回并附续读提示,把「通读长文件」变成「分窗读」;闸门二(结论回填)防「结果跨步累积」——记忆里只存压缩后的结论,8 遍原文压成 8 句(实测省 65%)。前者管每一笔的上限,后者管总量的增长。

参考与延伸

本节点大纲与自测题(5 个子点 · 3 问)

大纲

  • 业务 SOP 拆 Loop:从操作流程到 Agent 循环
  • 最简工具集:list_files / read_file / grep / git_status 的设计
  • 代码理解、测试生成与修复两类典型任务
  • 长文件摘要与工具结果压缩:读代码库的上下文管理
  • 与 Claude Code 的对照:产品级 Codebase Agent 的取舍

自测——答不上来就是缺口

  • 最简工具集为什么够用?加一个「聪明」的工具会破坏什么?
  • Codebase Agent 的评测怎么做——golden set 从哪来?
  • 工具结果不截断直接回填,会对循环造成什么后果?两道闸门各防什么?