实战项目:Codebase Agent——让 Agent 读懂代码库
一句话定位:把 Agent 主循环架上四件只读工具,让它在一个真实代码库里自己找答案——本文给出业务 SOP 拆循环的方法、最简工具集的选法、两道上下文闸门,与一个 85 行可跑的迷你实现。前置:工具执行机制——工具执行五站与最简工具集法则在那篇拆过;上下文压缩体系——压缩五级阶梯与工作记忆在那篇备好。
先从「零件齐了却不敢开工」说起
零件确实齐了:Agent 基础里主循环会思考、行动、观察,工具执行机制里知道一次调用怎么注册、校验、回填,上下文压缩体系里知道上下文满了怎么层层设防。
可真要写一个去读公司代码库的 Agent,编辑器开着,第一行反而下不去手——
第一反应往往是堆料:把读文件、搜代码、查提交记录、语义搜索……十几个工具全挂上,再给代码库建个向量索引。结果工具一多,模型选错工具的概率跟着涨——工具执行机制篇给过数字:用上工具搜索与精选清单后,选对率从 49% 提到 74%,方向是做减法不是加工具;索引基建没搭稳,裸循环转两圈上下文就满了。
一个能读懂代码库的 Agent,最少需要哪几样东西?
答案比想象中短:一条从业务 SOP 拆出来的任务循环、四件只读工具、两道上下文闸门、一道刹车。本篇把这四样拼成一个 85 行的迷你实现,每一行都真跑过,跑完再对照 Claude Code 看产品级多了什么。
全景图:四件套拼出一个最小闭环
图 1:mini Codebase Agent 全景——①业务 SOP 拆循环供给任务 ②四件只读工具 ③工具结果过两道闸门再进记忆 ④步数刹车兜底
渐进拆解:新员工读代码库的四件套
类比:把这套东西想象成一个新员工入职读代码库。师傅不给整库权限,给一份作业指导书——这是业务 SOP;规定只许问四类问题——这是最简工具集;工牌每次只放行几页资料,笔记本只许记结论不许整页抄——这是两道闸门;师傅还说了「最多问八次,问不完回来找我」——这是刹车。四样规矩没有一样是限制他读懂代码的,全是防止他把时间烧在原地。
① 业务 SOP 拆循环:任务从哪来
Agent 循环本身不会凭空知道「查什么」——任务得从业务流程里来。客服要答一句「满 100 减 20 怎么算」,一线的操作流程(SOP,标准作业程序)是:搜关键词定位到规则所在文件 → 读相关那一段 → 把规则讲给客户听。把这三步写下来,每一步要么是「调某件工具」,要么是「判断信息够不够了」——SOP 就这样拆成了循环能执行的步骤。图 2 给出这条映射:
图 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 主干,再三个「假模型」场景。
第一步,建一个练习仓库。 在任意目录下依次做三件事:
- 建
mini-repo/目录。 - 放入下面两个文件。
- 初始化 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%)。前者管每一笔的上限,后者管总量的增长。
参考与延伸
- SWE-bench:真实仓库缺陷修复基准的官网与榜单,Verified 为其中经人工复核筛选的 500 条子集(2026 年仍在维护更新)
- Why SWE-bench Verified no longer measures frontier coding capabilities(OpenAI,2026-02):停用公告全文,含测试缺陷审计与污染探测的具体数字
- 下一站:AI 原生应用实战——把 Agent 装进用户摸得到的产品里