1. 为什么我要自己搭一套 Agent 评测框架
模型厂商的宣传页现在基本没法看了。随便打开一个榜单,清一色的“超越 GPT-4”“Agent 能力业界第一”“复杂任务完成率 95%+”。我一开始也信,直到把同一个任务分别丢给三个号称“Agent 能力顶尖”的模型,结果一个把文件删错了目录,一个在第三步就开始胡编工具返回值,还有一个干脆陷入了无限循环,跑了四十多轮还在那儿“让我再确认一下”。
问题不在于模型不行,而在于评测方式根本不对。市面上大多数榜单测的是“单轮问答”或者“固定轨迹的短任务”,而真实 Agent 场景是什么?是多轮工具调用、是中间状态会变、是工具会报错、是需要根据上一步结果动态调整策略。你拿选择题考一个要开车的司机,考满分也不代表他上路不撞车。
所以我花了大概三周时间,搭了一套自己的 Agent 真实任务评测框架。核心目标就一个:用接近生产环境的任务,测出模型在真实 Agent 场景下到底行不行。这套框架不追求大而全,追求的是“可复现、可归因、可对比”。跑完之后我把几个主流模型的真实表现拉了一张表,差距比宣传页上大得多,有些模型在简单任务上光鲜亮丽,一到多步工具编排就原形毕露。
这篇文章我会把这套框架的设计思路、任务设计方法、执行追踪机制、评分归因逻辑,以及我踩过的坑,全部摊开讲。适合正在做 Agent 应用开发、需要选型模型、或者想给自己项目搭评测体系的同学。不需要你有很深的评测背景,但最好跑过至少一个 Agent demo,不然有些坑你感受不到痛。
2. 评测框架的整体设计与选型思路
2.1 为什么不用现成的评测平台
一开始我也想过直接用 LangSmith、LangFuse 这类现成的可观测平台,或者拿 AgentBench、WebArena 这种学术 benchmark 凑合。但实际用下来有几个硬伤。
第一,任务不可控。学术 benchmark 的任务是固定的,模型厂商早就针对性优化过了,你测出来的分数和真实业务表现相关性很低。第二,归因太粗。现成平台大多只告诉你“任务成功/失败”,但 Agent 失败往往是中间某一步工具调用错了、或者状态没传递对,你需要知道是哪一步崩的。第三,工具环境不匹配。我的 Agent 要调的是文件系统、HTTP 接口、数据库查询这些真实工具,学术 benchmark 里的模拟环境跟真实差太远。
所以我的选择是:自建轻量框架,核心只做三件事——任务编排、执行追踪、评分归因。不搞可视化大屏,不搞分布式调度,就是一个能跑起来、能出报告、能对比的脚本集合。技术栈上我用了 Python + 一个简单的 YAML 任务定义 + SQLite 存执行轨迹。为什么用 SQLite 而不是 Postgres?因为评测数据量不大,单机跑几千条轨迹完全够用,而且零配置,换台机器拷个文件就能复现。
提示:不要一上来就追求框架的“通用性”。我第一版设计了一个支持插件化工具注册、支持多 Agent 协作的复杂架构,结果光调试框架本身就用了一周,真正跑评测的时间反而没多少。后来砍到只剩“任务定义 + 执行器 + 评分器”三个模块,效率反而高了。
2.2 框架的三个核心模块
整个框架我拆成了三层,每层职责非常清晰,互不干扰。
任务层:用 YAML 定义每个评测任务,包括任务描述、可用工具列表、初始状态、期望的最终状态、以及一个可选的“关键步骤检查点”。任务层不关心用哪个模型跑,只描述“要做什么”。
执行层:负责把任务喂给 Agent,记录每一轮的输入、模型输出、工具调用请求、工具返回结果、以及耗时和 token 消耗。这一层是框架的核心,所有归因分析的数据都来自这里。
评分层:分三个维度打分——最终状态正确性、关键步骤命中率、执行效率。最终状态正确性是硬指标,关键步骤命中率用来区分“蒙对的”和“真会的”,执行效率用来排除那些靠暴力穷举碰对的。
这三层解耦的好处是,换模型只需要改执行层的配置,换任务只需要改 YAML,评分逻辑可以独立迭代。我后来加了一个新的评分维度(工具调用参数合法性),只改了评分层,任务和执行层完全没动。
2.3 任务设计的核心原则
任务设计是整套框架里最花心思的部分。我总结了四条原则,每条都是踩坑踩出来的。
原则一:任务必须有明确的终态。比如“把 /data 目录下所有 .log 文件按日期归档到对应月份文件夹”,终态就是文件系统的一个确定状态。如果任务终态模糊,比如“帮我分析一下这份数据”,那评分就没法自动化,只能人工看,效率极低。
原则二:任务必须包含至少一次工具调用。纯推理任务测不出 Agent 能力,那是 LLM 的事。Agent 的核心价值在于“决策 + 执行”,所以任务里必须强制模型去调工具,而且工具要有真实副作用。
原则三:任务要有“陷阱”。我在每个任务里都埋了至少一个容易出错的点。比如工具返回一个格式略有异常的结果,看模型会不会正确处理;或者任务描述里有一个隐含的约束(比如“不要修改原始文件”),看模型会不会忽略。这些陷阱才是区分模型真实能力的关键。
原则四:任务要可复现。每个任务跑之前,环境要重置到初始状态。我一开始没做环境重置,结果第二个模型跑的时候看到的是第一个模型留下的烂摊子,数据全废了。后来每个任务都配了一个 setup 脚本和一个 teardown 脚本,跑之前先重置。
3. 核心细节解析与实操要点
3.1 任务定义文件怎么写
任务定义是整个框架的入口,我用的 YAML 格式,结构大概长这样:
task_id: file_archive_001 description: "将 /workspace/logs 下所有 .log 文件按 YYYY-MM 归档到 /workspace/archive/YYYY-MM/ 目录" tools: - list_files - read_file - move_file - create_dir initial_state: setup_script: "scripts/setup_file_archive.sh" expected_final_state: check_script: "scripts/check_file_archive.py" checkpoints: - step: "list_files 被调用" weight: 0.2 - step: "create_dir 在 move_file 之前被调用" weight: 0.3 - step: "所有 .log 文件被移动,无遗漏" weight: 0.5 max_turns: 20 timeout_seconds: 300这里有几个细节值得说。checkpoints是我这套框架和普通评测最大的区别。它不是检查最终结果,而是检查执行过程中的关键动作是否发生、顺序是否正确。比如“create_dir 在 move_file 之前被调用”这个检查点,能直接抓出那些不建目录就直接移动文件、导致移动失败的模型。
max_turns和timeout_seconds是硬性熔断。我见过模型在某个任务上跑了 80 多轮还在那儿“让我再试一次”,没有熔断的话评测脚本能跑一整天。20 轮和 5 分钟是我实测下来比较合理的值,大部分正常任务 5 到 10 轮就能完成。
注意:
checkpoints的权重加起来必须是 1.0,否则评分会溢出。我第一版没做校验,结果有个任务权重加起来 1.3,分数直接爆了,排查了半天才发现是 YAML 写错了。
3.2 执行追踪的数据结构
执行追踪我存了两张表,一张是runs,记录每次任务执行的元信息;一张是steps,记录每一轮的详细数据。
runs表的关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| run_id | TEXT | 唯一标识,格式 model_task_timestamp |
| model_name | TEXT | 模型名称 |
| task_id | TEXT | 任务 ID |
| start_time | DATETIME | 开始时间 |
| end_time | DATETIME | 结束时间 |
| total_turns | INTEGER | 总轮数 |
| total_tokens | INTEGER | 总 token 消耗 |
| final_score | REAL | 最终得分 |
| status | TEXT | success/failed/timeout/max_turns |
steps表的关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| step_id | INTEGER | 自增主键 |
| run_id | TEXT | 关联 runs 表 |
| turn_number | INTEGER | 第几轮 |
| model_output | TEXT | 模型原始输出 |
| tool_calls | TEXT | JSON 格式的工具调用列表 |
| tool_results | TEXT | JSON 格式的工具返回 |
| latency_ms | INTEGER | 本轮耗时 |
| tokens_used | INTEGER | 本轮 token 消耗 |
为什么要存原始输出?因为归因分析的时候,你需要回看模型当时到底说了什么。我遇到过一个 case,模型最终结果是对的,但中间有一轮它其实调错了工具,只是碰巧那个错误工具的返回值让它蒙对了。如果不看原始输出,你根本发现不了这种“假成功”。
3.3 评分归因的三层逻辑
评分归因是整套框架最有价值的部分。我把它拆成三层,从粗到细。
第一层:终态校验。跑完任务后,执行check_script,检查最终状态是否符合预期。这一层是 0 或 1 的硬判断,不通过直接判失败。
第二层:检查点命中。遍历checkpoints,看每个关键步骤是否在执行轨迹中出现。这一层是加权求和,得到 0 到 1 之间的分数。终态通过但检查点没命中的情况很常见,比如模型用了一个非常规但也能达到目的的方法,这时候检查点分数会低,提示你“这个成功可能不可复现”。
第三层:效率惩罚。根据总轮数和总 token 消耗,对分数做一个轻微惩罚。公式是final_score = checkpoint_score * (1 - 0.02 * max(0, turns - expected_turns))。为什么要有这一层?因为有些模型会靠“多试几次”碰对答案,如果不惩罚,它和一次做对的模型分数一样,这不公平。
三层加起来,最终得分能比较真实地反映模型能力。我实测下来,一个模型如果在简单任务上得分高但复杂任务上得分骤降,基本可以判定它的“规划能力”是短板,而不是“执行能力”问题。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
整套框架的依赖非常轻,核心就几个包:
pip install pyyaml openai sqlite3 pandas tabulateopenai包用来调模型 API,pyyaml解析任务定义,sqlite3是 Python 内置的,pandas和tabulate用来生成对比报告。没有用任何 Agent 框架,因为我要测的是模型本身的能力,不是框架的能力。如果你用 LangChain 或 CrewAI 跑评测,测出来的分数里混了框架的编排逻辑,归因就不准了。
目录结构我这样组织:
agent-eval/ ├── tasks/ │ ├── file_archive_001.yaml │ ├── api_query_002.yaml │ └── db_migration_003.yaml ├── scripts/ │ ├── setup_file_archive.sh │ ├── check_file_archive.py │ └── ... ├── runner.py ├── scorer.py ├── reporter.py └── eval.dbrunner.py负责执行任务,scorer.py负责评分,reporter.py负责生成对比报告。三个脚本独立,可以单独跑。
4.2 执行器的核心逻辑
执行器的逻辑不复杂,但有几个细节必须处理好。核心循环大概是这样:
def run_task(model_name, task_config): run_id = f"{model_name}_{task_config['task_id']}_{int(time.time())}" reset_environment(task_config['initial_state']['setup_script']) messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task_config['description']}] for turn in range(task_config['max_turns']): response = call_model(model_name, messages, task_config['tools']) record_step(run_id, turn, response) if response.has_tool_calls: results = execute_tools(response.tool_calls) messages.append(response.message) messages.append({"role": "tool", "content": results}) else: break final_state_ok = check_final_state(task_config['expected_final_state']) score = calculate_score(run_id, final_state_ok, task_config) save_run(run_id, score) return score这里的关键点是reset_environment。每个任务跑之前必须重置环境,否则上一个模型的残留状态会污染下一个。我一开始偷懒没做重置,结果第二个模型跑文件归档任务时,发现文件已经被第一个模型归档好了,它直接返回“任务已完成”,得分虚高。这个坑我踩了两次才长记性。
另一个关键点是execute_tools的错误处理。工具执行失败时,不能直接抛异常终止,而是要把错误信息作为工具返回值传回给模型,看模型能不能自己纠正。这才是真实 Agent 场景。我见过模型在工具报错后,能正确读取错误信息并调整参数重试,也见过模型直接忽略错误继续往下走。这两种行为在评分上必须区分开。
4.3 工具集的设计与实现
工具集我设计了六个基础工具,覆盖了大部分 Agent 场景:
list_files(path):列出目录内容read_file(path):读取文件内容write_file(path, content):写入文件move_file(src, dst):移动文件create_dir(path):创建目录http_request(url, method, body):发起 HTTP 请求
每个工具都有明确的参数 schema,用 JSON Schema 描述,传给模型的时候作为 function calling 的定义。工具实现本身很简单,但有两个细节要注意。
第一,工具要返回结构化结果。不要返回一大坨自然语言,而是返回 JSON,包含success、data、error三个字段。这样模型解析起来更稳定,评分时也更容易判断工具调用是否成功。
第二,工具要模拟真实环境的“不完美”。比如read_file遇到不存在的文件时,返回的错误信息不要写得太直白,而是模拟真实系统的报错格式。我测试下来,错误信息的措辞会显著影响模型的纠错能力。写得太清楚,所有模型都能纠错;写得太模糊,所有模型都懵。我最后选了一个中等模糊度的措辞,能区分出模型的真实理解能力。
4.4 跑一轮完整评测的流程
跑一轮完整评测的流程我固定成五步:
- 准备任务集:至少 10 个任务,覆盖文件操作、API 调用、数据处理三类场景,难度从简单到复杂分布。
- 配置模型列表:在
config.yaml里列出要对比的模型,包括模型名称、API 端点、温度参数(我统一用 0,保证可复现)。 - 执行评测:
python runner.py --config config.yaml --tasks tasks/,脚本会依次跑每个模型每个任务,结果写入 SQLite。 - 生成报告:
python reporter.py --db eval.db --output report.md,生成对比表格和失败案例分析。 - 人工复核:对得分异常的任务,回看
steps表里的原始输出,确认是模型问题还是任务设计问题。
第三步跑一轮 10 个任务 5 个模型,大概需要 40 分钟到 1 小时,取决于任务复杂度和模型响应速度。我一般晚上跑,第二天早上看报告。
5. 常见问题与排查技巧实录
5.1 模型“假成功”怎么识别
“假成功”是我遇到最多的问题。模型最终状态对了,但过程一塌糊涂。识别方法就是看检查点命中率。如果终态通过但检查点命中率低于 0.5,基本可以判定是假成功。
我遇到过一个典型案例:任务是“把 A 文件的内容追加到 B 文件末尾”。模型没有用read_file+write_file的组合,而是直接调了move_file把 A 覆盖到 B 上,然后重新创建了一个空的 A。最终 B 的内容确实包含了 A 的内容,但 A 被清空了,违反了“不要修改原始文件”的隐含约束。这种就是典型的假成功,终态检查如果只检查 B 的内容,会误判为成功。所以我的终态检查脚本里,额外加了对 A 文件完整性的校验。
5.2 工具调用参数错误的排查
参数错误是第二大类问题。模型知道要调哪个工具,但参数传错了。常见的有:路径少了斜杠、日期格式不对、JSON 字符串没转义。
排查这类问题,我一般直接看steps表里的tool_calls字段,对比工具 schema 看哪个参数不符合。如果同一个模型在多个任务上都犯同类参数错误,那基本可以判定它的 function calling 能力有系统性缺陷,不是偶发。
我整理了一张常见参数错误速查表:
| 错误类型 | 典型表现 | 排查方法 |
|---|---|---|
| 路径格式错误 | 相对路径 vs 绝对路径混用 | 检查 tool_calls 里的 path 参数 |
| 类型错误 | 数字传成字符串 | 对比 JSON Schema 的 type 定义 |
| 必填参数缺失 | 调用时少传了参数 | 检查 tool_calls 的 arguments 完整性 |
| 参数名拼写错误 | 用了近义词而非 schema 里的名字 | 对比 schema 的 property 名称 |
| 嵌套结构错误 | JSON 层级不对 | 解析 arguments 看结构 |
5.3 无限循环的熔断与归因
无限循环是 Agent 评测里最耗时的失败模式。模型反复调同一个工具,或者反复说“让我再确认一下”,就是不推进。
我的处理方式是双层熔断:max_turns到了强制终止,同时加了一个“重复动作检测”——如果连续三轮的工具调用完全相同,直接终止并标记为loop_detected。这个检测逻辑很简单,把最近三轮的 tool_calls 序列化后做字符串比对,相同就触发。
归因的时候,loop_detected的任务我会单独拉出来看。大部分情况下,循环是因为模型对工具返回结果的理解出了问题。比如工具返回了一个它没见过的错误码,它不知道该怎么处理,就反复重试同一个调用。这种 case 对改进 Agent 的 prompt 设计非常有价值。
5.4 评测结果不可复现怎么办
不可复现是评测框架的致命伤。我遇到过同一个模型同一个任务,跑两次结果不一样的情况。排查下来有三个原因。
第一,温度参数没设成 0。这个最基础,但很容易忘。我现在的config.yaml里强制温度 0,并且在 runner 里加了一个断言,温度不为 0 直接报错。
第二,环境重置不彻底。setup 脚本只重置了部分状态,有些临时文件没清掉。解决办法是 setup 脚本里加一句“清空整个工作目录再重建”,宁可多花几秒,也要保证干净。
第三,工具返回结果包含时间戳或随机数。这个最隐蔽。我有个 HTTP 工具返回的响应里带了服务器时间戳,导致模型每次看到的输入都略有不同。后来我在工具层加了一个“结果归一化”步骤,把时间戳、随机 ID 这类不稳定字段替换成固定值。
提示:如果你的评测结果波动超过 10%,先别怀疑模型,先检查这三个点。我敢说 90% 的不可复现问题都出在这三处。
6. 跑完之后的真实发现
跑完五个主流模型、十个任务、每个任务重复三次之后,我拉了一张汇总表。这里不点名具体模型,只说规律。
规律一:简单任务区分度极低。单步工具调用的任务,所有模型得分都在 0.9 以上,差距在误差范围内。这说明如果你只做简单 Agent,选哪个模型都差不多,宣传页上的差距在这个场景下不存在。
规律二:多步规划是分水岭。需要三步以上工具编排的任务,模型得分从 0.9 骤降到 0.4 到 0.7 不等。差距主要出在“中间状态管理”上——模型能不能记住上一步的结果,并据此决定下一步。得分低的模型普遍在第三步之后开始“失忆”,重复调用已经调过的工具。
规律三:错误恢复能力差异巨大。我在任务里故意让工具在某个特定条件下返回错误,看模型能不能自己纠正。有的模型一次纠错成功,有的模型连续五轮都在用同样的错误参数重试。这个能力在宣传页上基本没人提,但在真实生产环境里极其重要。
规律四:token 消耗和得分不成正比。得分最高的模型不是 token 消耗最多的。有个模型 token 消耗是别人的两倍,得分反而更低,因为它花了大量 token 在“自言自语”上,实际有效动作很少。
这套框架我后来扩展了一下,加了一个“任务难度自适应”模块——根据模型在简单任务上的表现,动态调整后续任务的难度。这样能在有限时间内更快地定位模型的真实能力边界。如果你也在做 Agent 选型,我的建议是别信榜单,自己搭一套轻量评测,跑十几个贴近业务的任务,比看一百份宣传页都管用。