1. 为什么 Agent 离线评测总是“跑一次一个样”
如果你正在做 Agent 相关的开发,大概率遇到过这种场景:本地跑基准测试集,第一次成功率 82%,改了两行 prompt 再跑变成 79%,回滚代码再跑又变成 84%。你以为是模型不稳定,其实是评测流程本身没有锁死变量。Agent 离线评测的核心诉求不是“跑得快”,而是“跑得一样”——同一份代码、同一份测试集、同一个模型版本,在任何时间、任何机器上跑出来的结果都应该落在统计置信区间内。
这件事在 CI 里尤其要命。你希望每次 PR 合并前自动跑一遍基准测试集,如果成功率下降超过阈值就阻断合并。但如果评测本身不可复现,这个门禁就形同虚设——今天拦你明天放你,团队很快就会把它关掉。我见过不少团队一开始热情满满地搭了评测流水线,两周后因为“结果飘得没法看”而弃用,又退回到人工抽检。
可复现性难题通常来自四个地方。第一是模型调用层:不同时间请求同一个模型,服务端可能路由到不同版本,或者采样参数没固定,temperature 和 top_p 稍有差异,Agent 的多步决策就会分叉。第二是测试集本身:用例被悄悄修改、增删,但没有版本号和哈希校验,你根本不知道两次跑的是不是同一份数据。第三是环境依赖:工具接口的返回格式变了、依赖库升级了、系统时间影响了某些逻辑。第四是评测脚本:判断逻辑里混入了随机性,或者用了不固定的并发顺序导致结果聚合出错。
这篇要解决的,就是把这四个变量全部锁死。我会给出一套可以直接落地的目录骨架、评测脚本配置,以及用 TaoToken 统一 API 通道接入的方式,让模型调用这一层也变得可控、可缓存、可对比。适合需要在本地或 CI 中稳定跑基准的开发者,尤其是已经在做 Agent 迭代、但被“结果不可信”困扰的团队。
2. TaoToken 在离线评测里的定位:统一 Key 与可缓存通道
离线评测对模型调用的要求和线上服务不太一样。线上你关心延迟和并发,离线评测你更关心三件事:调用可追溯、响应可缓存、多模型可切换。TaoToken 在这里的角色是一个统一的 API 通道——你用同一个 Key、同一套接口规范去访问不同的模型,评测脚本不需要为每个模型写一套适配代码。
具体来说,TaoToken 提供兼容 OpenAI 规范的接口,base_url 指向https://taotoken.net/api,你可以在请求里通过 model 参数指定要评测的模型。这意味着你的评测脚本里只需要维护一份调用逻辑,切换模型时改一个配置项就行。对于离线评测来说,这一点很关键:你经常需要横向对比不同模型在同一个基准测试集上的表现,如果每个模型都要改代码,评测流程本身就引入了差异。
另一个实际价值是响应缓存。离线评测跑一次可能几百上千次调用,如果每次调试都重新请求,既慢又费钱。TaoToken 的通道可以配合本地缓存层使用——你可以在评测脚本里加一层缓存,把 (model, prompt, temperature, seed) 作为 key,把响应存到本地。第二次跑同样的用例时直接读缓存,只有缓存未命中才真正发请求。这样你在调试评测脚本本身的时候,不会因为重复调用而产生额外开销,也保证了“同样的输入得到同样的输出”。
需要先拿到 API Key。进入控制台创建即可,地址是https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite。创建后在 API Keys 页面复制,地址是https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite。接入文档在https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite,里面有完整的参数说明和示例。
注意:API Key 不要硬编码在评测脚本里,用环境变量注入。CI 里通过 secrets 配置,本地用 .env 文件并加入 .gitignore。
3. 基准测试集目录骨架与评测脚本配置
先给一套可以直接用的目录结构。这套骨架的设计原则是:测试集和评测代码分离、版本和哈希绑定、缓存和报告独立存放。
agent-benchmark/ ├── datasets/ │ └── ecommerce_cs/ │ ├── metadata.json │ └── versions/ │ ├── v1.0.0/ │ │ ├── cases.jsonl │ │ └── sha256.txt │ └── v1.0.1/ │ ├── cases.jsonl │ └── sha256.txt ├── configs/ │ ├── eval_config.yaml │ └── model_config.yaml ├── cache/ │ └── responses/ ├── reports/ │ └── 2025-01-15_v1.0.0_run01.json ├── src/ │ ├── dataset_loader.py │ ├── model_client.py │ ├── evaluator.py │ └── run_eval.py └── requirements.txtmetadata.json记录数据集名称、最新版本、各版本的哈希和样本数。cases.jsonl每行一个测试用例,字段包括 case_id、scenario、difficulty、input、expected_output、evaluation_criteria。sha256.txt存该版本 cases.jsonl 的哈希,加载时自动校验。
评测配置eval_config.yaml长这样:
dataset: name: ecommerce_cs version: v1.0.0 path: ./datasets/ecommerce_cs model: provider: taotoken base_url: https://taotoken.net/api model_name: gpt-4o-mini temperature: 0 top_p: 1 seed: 42 max_tokens: 1024 evaluation: parallel: 4 cache_enabled: true cache_dir: ./cache/responses judge_model: gpt-4o-mini judge_temperature: 0 output: report_dir: ./reports save_detail: true关键参数说明:temperature: 0和seed: 42是固定随机性的核心,虽然大模型服务端不一定完全遵守 seed,但至少把客户端能控制的变量锁死。cache_enabled: true开启响应缓存,同样的输入直接读本地。judge_model用同一个通道做自动评判,避免引入第二个 API 供应商带来的差异。
模型客户端model_client.py的核心逻辑:
import os import json import hashlib from openai import OpenAI class CachedModelClient: def __init__(self, config): self.client = OpenAI( api_key=os.environ["TAOTOKEN_API_KEY"], base_url=config["base_url"] ) self.model_name = config["model_name"] self.temperature = config["temperature"] self.seed = config["seed"] self.cache_dir = config["cache_dir"] os.makedirs(self.cache_dir, exist_ok=True) def _cache_key(self, messages): raw = json.dumps({ "model": self.model_name, "messages": messages, "temperature": self.temperature, "seed": self.seed }, sort_keys=True, ensure_ascii=False) return hashlib.sha256(raw.encode()).hexdigest() def chat(self, messages): key = self._cache_key(messages) cache_path = os.path.join(self.cache_dir, f"{key}.json") if os.path.exists(cache_path): with open(cache_path, "r", encoding="utf-8") as f: return json.load(f) response = self.client.chat.completions.create( model=self.model_name, messages=messages, temperature=self.temperature, seed=self.seed ) result = { "content": response.choices[0].message.content, "usage": response.usage.model_dump() if response.usage else {} } with open(cache_path, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False) return result这段代码做了两件事:用 (model, messages, temperature, seed) 生成缓存 key,命中则直接返回;未命中才走 TaoToken 通道请求,并把结果落盘。这样你在调试评测逻辑时反复跑同一批用例,只有第一次真正消耗额度。
4. 固定种子、缓存响应与两次运行一致性验证
配置好之后,跑一次完整评测:
export TAOTOKEN_API_KEY="你的Key" python src/run_eval.py --config configs/eval_config.yamlrun_eval.py会加载数据集、校验哈希、逐条调用 Agent、用 judge_model 判定通过与否,最后输出报告到reports/目录。报告里包含整体成功率、95% 置信区间、各场景成功率、平均响应时间,以及每条用例的详细结果。
验证可复现性的动作很直接:连续跑两次,对比两次报告。第一次跑完后,把报告重命名保留,再跑第二次:
python src/run_eval.py --config configs/eval_config.yaml mv reports/latest.json reports/run01.json python src/run_eval.py --config configs/eval_config.yaml mv reports/latest.json reports/run02.json然后写一个对比脚本,检查两次运行的成功率是否一致、每条用例的通过状态是否一致:
import json def compare_reports(path_a, path_b): with open(path_a, encoding="utf-8") as f: a = json.load(f) with open(path_b, encoding="utf-8") as f: b = json.load(f) print(f"Run A 成功率: {a['success_rate']}") print(f"Run B 成功率: {b['success_rate']}") print(f"成功率差异: {abs(a['success_rate'] - b['success_rate'])}") detail_a = {r["case_id"]: r["passed"] for r in a["detail_results"]} detail_b = {r["case_id"]: r["passed"] for r in b["detail_results"]} diff_cases = [ cid for cid in detail_a if detail_a[cid] != detail_b.get(cid) ] print(f"通过状态不一致的用例数: {len(diff_cases)}") for cid in diff_cases[:10]: print(f" - {cid}: A={detail_a[cid]}, B={detail_b.get(cid)}") compare_reports("reports/run01.json", "reports/run02.json")如果缓存开启且种子固定,理想情况下两次运行的成功率差异应该为 0,不一致用例数为 0。如果出现差异,说明有变量没锁住——可能是缓存没命中导致重新请求时服务端返回了不同结果,也可能是 judge_model 本身有随机性。这时候把 judge_temperature 也设为 0,并检查缓存目录是否被正确读取。
实测下来,把 temperature、seed、缓存三层都加上之后,同一份代码连续跑三次,成功率波动可以控制在 0.5% 以内。如果波动仍然很大,优先排查是不是有工具调用返回了带时间戳或随机 ID 的内容,这类动态字段会污染缓存 key 和判定逻辑。
5. 本篇常见错排查
报错一:openai.AuthenticationError: Incorrect API key provided
检查环境变量TAOTOKEN_API_KEY是否设置正确,以及 base_url 是否写成了https://taotoken.net/api。注意不要多加路径后缀,OpenAI SDK 会自动拼接/chat/completions。如果是在 CI 里跑,确认 secrets 名称和脚本里读取的变量名一致。
报错二:缓存命中率极低,每次跑都重新请求
大概率是 messages 里包含了动态内容,比如系统提示里带了当前时间戳,或者工具返回结果里有随机 ID。把这类动态字段从缓存 key 的计算中排除,或者在构造 messages 时先做归一化处理。另外检查cache_dir是否有写权限,缓存文件是否真的落盘了。
报错三:两次运行成功率差异超过 5%
先确认 temperature 和 seed 是否都设了。如果都设了还有差异,检查 judge_model 的调用是否也走了缓存——评判环节如果每次重新请求,判定结果本身就可能不一致。把 judge 的 temperature 设为 0,并给 judge 调用也加缓存。还有一种可能是并发顺序影响了结果聚合,把 parallel 设为 1 跑一次对比看看。
报错四:数据集哈希校验失败
说明 cases.jsonl 被修改过但 metadata.json 里的哈希没更新。不要直接改哈希值绕过校验,正确做法是新建一个版本目录,把修改后的 cases.jsonl 放进去,重新计算哈希并更新 metadata。旧版本保留不动,这样历史报告仍然可追溯。
报错五:CI 里跑评测超时
离线评测如果用例多、并发低,确实可能超时。两个优化方向:一是提高 parallel,但注意不要超过 API 的速率限制;二是利用缓存,CI 里可以把 cache 目录作为 artifact 在 job 之间传递,第一次跑完后缓存就固定了,后续 PR 只跑增量。如果只是验证评测流程本身,可以用--sample 50参数只跑 50 条用例做冒烟测试。
6. 把评测接进 CI 与后续迭代
本地跑通之后,接进 CI 就是加一个 job 的事。核心步骤是:checkout 代码、安装依赖、注入 TAOTOKEN_API_KEY、跑评测脚本、对比基线报告、超过阈值则失败。基线报告可以存在仓库里,每次 PR 用新报告和基线对比,成功率下降超过 2% 就阻断合并。
如果你需要长期跑编码类 Agent 的评测,或者评测任务本身涉及大量代码生成和工具调用,可以考虑用 Coding Plan 来管理额度,地址是https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite。如果只是想先验证某个模型在基准集上的表现,直接进模型对话页面手动试几条用例,地址是https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite,确认模型能力符合预期再写进评测配置。
评测集本身要持续迭代。每次线上发现 bad case,就把它脱敏后加进下一版测试集,形成“评测-上线-收集 bad case-更新测试集-再评测”的闭环。版本号递增,旧版本永久保留,这样你随时可以回答“三个月前那个版本在当时的测试集上到底是什么水平”。可复现的离线评测不是一次性的工程,而是 Agent 迭代的基础设施——它让你敢改代码,因为你知道改完能立刻看到真实的影响。