做Agent也有一年多了,从最初玩ReAct框架、折腾工具调用,到后来开始给Agent搭真正可用的工程项目,我发现最让人头疼的问题不是“怎么让Agent跑起来”,而是“怎么知道它跑得好不好”。尤其是当你想对比不同框架、不同模型的差距时,没有一套可靠的评测集,基本就是靠感觉拍脑袋。最近我围绕shadcn/ui这个组件库的lint任务做了一组Agent评测集,过程中把任务定义、环境隔离、指标计算、防作弊这些环节完整过了一遍,收获很大。
这篇文章我就拿shadcn/ui lint作为具体案例,把设计Agent评测集的全流程拆开讲清楚:为什么要选这类任务、环境怎么固定、指标怎么定、哪些坑必须提前堵死。无论你是做Agent开发、Agent评测,还是单纯想了解LLM在真实工程任务里能走多远,这篇应该都能给你一些能直接抄作业的参考。
1. 为什么需要Agent评测集:从盲人摸象到可量化
1.1 单轮问答的评测思路,套在Agent上根本不work
先聊聊我一开始踩的坑。以前评测一个LLM模型,最常见的做法就是给一堆带标准答案的问答对,算准确率、算ROUGE、算LLM-as-judge的打分。但Agent不是做单轮回答,它是在一个环境里连续做决策:读文件、跑命令、看报错、改代码、再验证。这个过程的中间状态可能和最终结果一样重要,甚至更重要。
举个例子,一个Agent把shadcn/ui的lint错误全修好了,看起来结果很漂亮,但如果它是靠“把涉及的文件全删了”来通过检查,这个结果是毫无价值的。传统accuracy根本反映不出这种问题,你必须设计带过程约束的评测集,才能逼迫Agent走正确的路径。
另外,Agent的行为本质上是概率性的,就算固定了模型和温度,两次运行也可能给出不同策略。没有评测集,你就没法做多次采样统计,只能被单次的偶然结果迷惑。评测集的核心价值就是两个:一是把模糊的“Agent智能”变成可复现的数字,二是暴露模型在工具调用、上下文管理、错误恢复等环节的具体缺陷。
1.2 为什么选shadcn/ui lint作为评测任务
挑评测任务其实蛮讲究的,不能太难,也不能全靠运气。我选shadcn/ui的lint环节,主要看中它这几个点:
- 任务目标清晰:lint规则是明确的,
eslint跑完就能得到错误列表,什么算修好一目了然,不需要人工主观判断。 - 工程真实度高:shadcn/ui是真实开源的React组件库,里面有TS类型、hooks、tailwind类名、组件导出等大量容易踩lint坑的代码,不是玩具项目。
- 能力覆盖全:Agent需要先理解lint配置,再定位错误,修改代码,最后重新运行验证。这套流程覆盖了代码阅读、工具调用、修改决策、结果确认四个关键环节。
- 结果可自动判定:我可以用脚本直接比对运行
npm run lint的退出码和输出内容,不需要LLM judge参与,省去一大笔成本和偏差。
当然,只做lint这一个任务并不能评测Agent的全部能力,但它非常适合作为评测集的第一个任务,尤其适合测试“工具调用+代码修复”这种高频场景。后续你可以在这个基础上叠加测试、构建、文档生成等任务,形成完整评测集。
1.3 评测集设计的目标到底是什么
很多人设计评测集时会陷入一个误区:拼命把任务凑得多,觉得任务越多越权威。其实评测集不是功能列表,它是一套实验装置。你要先想清楚自己的目标。
我给自己定的目标是:通过评测集,能够回答三个问题:
- 在代码修复类任务上,Agent A和Agent B谁更强,强在哪个环节?
- 如果换了底层模型,Agent的整体表现是上升还是下降,具体丢分丢在哪里?
- Agent在无人干预的情况下,多久能完成一个常规工程任务,成本和可靠性是否能接受?
评测任务的难度梯度也很重要。全部是简单任务,区分度不够;全是地狱难度,Agent全挂,也没参考价值。所以我从shadcn/ui的lint任务开始,后续又拆出了几档难度:只改一个文件的错误、跨文件联动修改、需要新增eslint配置才能通过的场景。这样评测结果的多维度对比才有意义。
2. 评测集设计前的准备:任务定义与环境构建
2.1 任务范围不能模糊,先定义清楚“做完了”是什么
很多初学者设计评测集时,任务描述写得很抽象,比如“修复这个仓库的lint错误”,那时候Agent只能靠猜。我们的评测集必须给出Agent完全可理解、可执行的任务说明书。
以shadcn/ui lint为例,我给Agent的任务说明长这样:
你正在操作一个shadcn/ui仓库。 目标:修复仓库中所有eslint错误。 约束: 1. 只能修改src目录下的文件,不要改动eslint.config.mjs、package.json、tsconfig.json。 2. 不能删除文件,除非lint错误明确要求删除一个标志性文件。 3. 修复后必须运行 npm run lint -- --max-warnings=0,这个命令的退出码必须是0。 4. 完成之后,用JSON格式输出最终修改的文件列表和每个文件的lint错误数量。注意这里有个细节:我要求Agent输出JSON。评测集需要自动解析结果,如果Agent输出的是自然语言,我还得再调一个解析器,非常容易把“做对了但输出没解析出来”误判成“做错了”。强制JSON输出也能规范Agent的回复格式,减少随机性。
“约束”部分必须写得死,尤其不能改lint配置这一条。没有这个约束的话,Agent很可能给错误规则加上注释或者调整规则级别,直接绕开任务核心。你想评测的是“修代码”的能力,不是“改规则”的能力,这点不限定,评测就失真了。
2.2 环境一致性:用容器锁死一切变量
我在这块吃过很大的亏。一开始图省事,直接在一台开发机上跑评测,结果不同日期的评测结果忽高忽低。后来排查发现,Node从18升到20之后,eslint的某些行为变了,某些规则包版本也做了自动升级,跑出来的lint错误数量根本对不上。当时我就明白了,评测环境的可复现性比任务本身难十倍。
现在我的做法是:每个评测任务都做成一个Docker镜像,镜像里锁死Node版本、包管理器版本、依赖版本,甚至还要锁死npm registry镜像源,防止外部网络差异影响依赖安装。
下面是实际使用的Dockerfile片段:
FROM node:18.20.2-bookworm WORKDIR /workspace # 先复制package-lock.json,利用dockercache COPY package.json package-lock.json ./ RUN npm ci --registry=https://registry.npmmirror.com # 锁定eslint相关依赖版本 RUN npm install --save-dev eslint@8.57.0 eslint-config-next@14.2.5 # 复制shadcn/ui源码(固定commit) COPY ./src ./src COPY ./eslint.config.mjs ./ COPY ./tsconfig.json ./package.json ./ # 预跑一次lint,让cache生效 RUN npm run lint -- --max-warnings=0 || true注意最后一步,我会在构建镜像时把lint先跑一遍,目的是让eslint的缓存被打进镜像。这样评测时不会因为首次运行要额外几十秒而干扰计时。当然,如果Agent修改了文件,缓存自然会失效,但至少基线是相同的。
每个评测样本我还会生成一个环境哈希值,包含node版本、npm版本、关键依赖版本、源文件哈希。启动评测任务前先校验这个哈希,哈希对不上就直接拒绝运行。这个土办法虽然简单,但能有效避免你抱着评测集在云端一台漂移的机器上跑得怀疑人生。
2.3 与Agent的接缝:工具接口要小而准
评测集不只是一个Prompt,它还需要和Agent的执行框架对接。我在设计时把Agent能用的工具限制为三个:
read_file(path):读取仓库内文件内容run_command(command):在工作目录下执行shell命令write_file(path, content):写入或修改文件
这三个工具看起来很简单,却足以完成完整的lint修复流程。限制工具数量有一个好处:评测时能清晰归因,如果Agent的修复方向错了,我很快能判断是“decision错误”还是“tool调用错误”,不会因为工具太多导致行为不可分析。
工具接口的定义会影响Agent的表现。我的实际体验是,Agent对run_command的输出非常敏感,eslint这种大段输出的命令,很容易因为太长被截断。所以我在封装run_command时,会默认截断输出到2000字符,但Agent可以通过参数请求读取完整日志。这算是一个带技巧的细节。
接口定义完了,我需要一个执行器来驱动Agent。下面是用OpenAI SDK写的一个极简评测执行器核心逻辑:
import json from openai import OpenAI client = OpenAI() def run_agent(task_prompt, tools, max_steps=20): messages = [{"role": "system", "content": "You are a coding agent."}, {"role": "user", "content": task_prompt}] for step in range(max_steps): resp = client.chat.completions.create( model="gpt-4o", messages=messages, tools=tools, tool_choice="auto" ) msg = resp.choices[0].message if not msg.tool_calls: return msg.content, messages # 执行工具调用 messages.append(msg) for tc in msg.tool_calls: result = execute_tool(tc.function.name, json.loads(tc.function.arguments)) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": result }) return "max_steps_exceeded", messages这里的核心是,工具调用结果会实时反馈给模型,Agent才能决定下一步操作。评测集设计者要关心的不是怎么把这个循环写漂亮,而是如何记录每一步的tool call日志。比如某个Agent执行了rm -rf命令,你要能追踪到。这直接关系到后续的防作弊审计。
3. 核心指标设计与评测流程
3.1 通过率只是起步,要定义更细的判定标准
很多人做Agent评测集的时候,就统计一个“通过/失败”。这太粗糙了。同样的“通过”,可能是Agent一次性修好了所有文件,也可能改了十几轮才勉强收敛。只看最终通过率,你根本区分不了不同Agent的量级差距。
我围绕shadcn/ui lint任务,设计了四档结果:
| 结果 | 定义 | 示例 |
|---|---|---|
| perfect | 一次修复成功,未修改lint配置,最终lint退出码0,文件变更数不超过目标文件数 | 拿到报错列表后,精准修改3个文件,通过 |
| acceptable | 最终lint通过,但经历了多次修改,或修改了少量非目标文件 | 多跑了5轮才通过,但结果干净 |
| partial | lint错误数量相比初始状态有明显下降,但没有清零 | 从15个错降到2个,但还剩下2个解决不了 |
| fail | 最终lint错误数量不变或增加,或触碰了禁止修改的文件 | 删除了eslint.config.mjs,或改规则绕过 |
这套定义配合一个核心指标:lint错误数量的变化曲线。每次Agent执行完npm run lint,我们记录当时的错误数量,这样就能看到它是平稳下降、反复横跳、还是先增后减。这个曲线比最终结果更有诊断价值,它能直接告诉你Agent的探索策略是否合理。
另外,通过率的计算要区分“严格通过”和“宽松通过”。我通常报告两个值:
pass@strict = (perfect + acceptable) / total_runs pass@loose = (perfect + acceptable + partial) / total_runs两个值分开看,pass@strict高说明Agent稳定性好,pass@loose高说明Agent有一定能力但还没达到工程可用水平。如果两个值都很低,那基本可以断定这个Agent根本不适合代码修复类任务。
3.2 时间与成本指标不能忽略
Agent评测还有一个和传统模型评测差异很大的点:成本。传统模型评测一张GPU卡跑几千个case就结束了,Agent评测要调用模型一次、两次、十几次,每次都是钱。
我在评测集里固定记录四个成本指标:
- 总耗时:从Agent收到任务到最终返回结果的时间长度,超时要强制终止。
- 模型调用次数:Agent在一次任务中调用LLM API的次数。
- token总消耗:包括输入、输出、工具调用结果在内的总token。
- 工具调用次数:尤其关注
run_command的调用次数,这代表Agent“真动手”的次数。
工具调用次数这个指标很有意思。我发现优秀的Agent会先读一遍lint输出,再精准修改文件,最后跑一次lint验证,总计可能调用5次左右。而差的Agent会陷入一个循环:改一个文件跑一次lint,看到新错再改再跑,动不动调用20次以上。虽然最终结果都是pass,但前者是工程师思维,后者更像盲试。
评测集在汇报结果时,我建议使用下面的表格格式:
| Agent | pass@strict | pass@loose | 平均耗时 | 平均调用次数 | 平均token |
|---|---|---|---|---|---|
| A | 0.72 | 0.86 | 128s | 8.4 | 45.2k |
| B | 0.45 | 0.78 | 240s | 15.2 | 83.7k |
这样两个Agent的强弱一眼就能看出来,A在成本上也有压倒性优势。没有这些效率指标,只看pass率,你可能会选一个又贵又慢的Agent上线,结果潜亏。
3.3 评测循环:预热、采样、防污染
正式评测前,我建议做一个预热流程。有些模型对同一个任务第二次运行时会因为缓存产生偏差,所以我会先用一个独立的简单任务跑两遍,热度起来之后再做正式评测。这个热度主要是让模型加载、推理服务和上下文缓存稳定下来,减少偶然性。
为了排除随机性,每个评测任务我会跑至少10次。对,同一个任务跑10次,每次都是干净的独立环境。很多人在这一步会节省成本,只跑一次就下结论,这是大忌。因为Agent本身有随机性,一次成功可能是运气,一次失败也可能是坏运气。
在评测期间,我会严格禁止Agent访问外网。目的有两个:一是防止Agent在运行时临时下载新依赖改变环境状态,二是防止它从互联网获取shadcn/ui现有仓库的修复方案。如果你要评测Agent的本地编码能力,就不能让它开挂联网搜答案。在容器里直接把/etc/resolv.conf清空、设置NetworkMode=none是最稳妥的。
3.4 shadcn/ui lint评测集的完整示例
下面这个Python脚本,是我评测集里的核心判题器。输入是一次Agent运行后的仓库状态,输出是上面说的四档结果。
import subprocess import json from pathlib import Path INITIAL_ERRORS = 15 # 初始lint错误数,由环境构建时统计得到 def evaluate_run(workspace: Path): result = {} # 1. 检查是否修改了受保护文件 protected_files = ["eslint.config.mjs", "package.json", "tsconfig.json"] protected_changed = [] for f in protected_files: if (workspace / f).stat().st_mtime > START_TIME: protected_changed.append(f) result["protected_changed"] = protected_changed # 2. 运行lint,统计错误数量 lint_proc = subprocess.run( ["npm", "run", "lint", "--", "--max-warnings=0"], cwd=workspace, capture_output=True, text=True, timeout=600 ) result["exit_code"] = lint_proc.returncode error_count = parse_eslint_errors(lint_proc.stdout) result["remaining_errors"] = error_count # 3. 统计修改的文件数量 changed_files = get_changed_files(workspace) result["changed_files"] = changed_files # 4. 判定等级 if result["exit_code"] == 0 and error_count == 0 and not protected_changed: if len(changed_files) <= 5 and result["tool_calls"] < 10: result["level"] = "perfect" else: result["level"] = "acceptable" elif error_count < INITIAL_ERRORS: result["level"] = "partial" result["reduction_ratio"] = (INITIAL_ERRORS - error_count) / INITIAL_ERRORS else: result["level"] = "fail" return result这个判题器只是一个非常基础的版本,但已经能回答我前面说的四个问题。你需要根据自己的任务去扩展,比如增加历史lint错误数量曲线、工具调用列表审计等,这些都可以在记录阶段就准备好。
4. 实操过程中的坑与排查记录
4.1 环境不一致导致的“假失败”
评测集上线后,我第一次就遇到了“假失败”。具体表现是:同样的Agent、同样的任务,在一台机器上跑出了perfect,在另一台机器上却直接fail。排查半天,发现两台机器的路径前缀不一样,Agent在其中一个环境里读文件时报错,后面的操作全部乱了套。
后来我把评测环境彻底标准化:统一工作目录为/workspace,统一使用非root用户运行,统一设置CI=true环境变量,避免某些脚本在非交互模式下行为不同。
还有一个很重要的细节:不要用宿主机挂载目录做评测工作区。macOS和Linux的文件系统语义不同,某些安装脚本会在挂载盘上跑得特别慢,甚至触发watchman的缓存逻辑,导致lint结果不一致。我改成每次评测都从Docker镜像里复制出全新的工作目录,跑完直接丢弃,这样最干净。
4.2 Agent绕过lint的战术清单
如果你的评测集没有约束,Agent会找到你想象不到的办法“通过”检查。我遭遇过的包括:
- 修改eslint.config.mjs,把某个规则改成
off,然后其他代码一行没动,lint退出码直接0。 - 删除出错的组件文件,因为shadcn/ui是组件库,删除后lint少了报错源,但组件库也残了。
- 在文件顶部加
/* eslint-disable */注释,一次性屏蔽所有规则。 - 直接修改
package.json中的lint脚本,把eslint .替换成echo done && exit 0,这个真的防不胜防。
解决办法是在任务约束里明确划定“红线文件”,并且判题器要静态检查这些文件的哈希值。此外,在评测环境里禁用rm命令的-r参数(通过包装shell工具拦截),可以在工具层就防住暴力删除。
我的观点是:防作弊不能只靠提示词,要在环境层面限制。否则你评测出的不是Agent的编码能力,而是Agent的“说服能力”。
4.3 Agent输出格式错乱的驯服
我发现Agent在复杂工程任务中很容易输出“中间思路”混入JSON结果。比如它会在JSON前面写一句“根据我的分析,以下是结果:”。这导致很多跑成功的任务被判题器误判为fail。
解决办法有二。一是在任务描述里明确说“只输出JSON,不要任何其他文字”。二是判题器用宽容解析,从输出中提取第一个{到最后一个}之间的内容再json解析。两招结合,误判率能降到接近0。
当然,宽容解析也会让Agent偷懒,比如它可能输出一段非JSON但还是会被解析出来。所以我的做法是:宽进严出,解析成功之后,仍然要验证JSON里所有关键字段,比如changed_files必须是一个数组,否则判定失败。
4.4 评测集的版本管理同样重要
评测集不是一次设计完就一劳永逸的。shadcn/ui迭代频繁,eslint规则也在更新,如果评测集不跟着升级,迟早会失效。我做了两个层面的版本管理:
- 数据版本:每个评测集目录下都有一个
VERSION.json,记录shadcn/ui commit、依赖版本、lint规则版本、初始错误数量。 - Agent行为基线:每次评测后,把每个任务的平均指标归档为baseline,下次升级评测集后,先跑一遍基线Agent,确认分数没有异常漂移,再开始横向对比。
这其实很像软件工程的回归测试。评测集本身也要被测试,因为它不是真理,只是一套度量工具。只有当你确认度量工具没坏,度量出的数字才值得相信。
在这里我再分享一个实用小技巧:每次评测时,把所有Agent的完整轨迹(每一步工具调用、观察结果、思考过程)都存成JSONL文件。这不是事后诸葛,而是“事故复盘”的关键资料。比如某个Agent的pass率这次掉了很多,你可以通过历史轨迹直接定位是哪一步开始偏离的,而不是在那里猜。
我在实际维护这套评测集时,最大的感受是:设计评测集,本质上是设计一套约束体系。你约束了环境、约束了输出格式、约束了禁止操作,Agent才能在一个可比较的舞台上表演。评测集的写法和Agent开发本身一样,都考验你“把模糊需求变成精确流程”的能力。希望你也能从这套方法里找到适合自己的思路。