Agent 平台上线几个月后,我们遇到了一个尴尬的循环:
改了 Lily 的 system prompt → 不知道质量变好还是变差 → 不敢上线 → 不改了
不像传统软件——改了代码有单元测试兜底——Agent 的 prompt 调整、模型更换、工具增删,影响的不仅是"跑不跑得通",更是"回答得好不好"。后者没法用 assert status == 200 来验证。
这篇文章讲我们如何从零构建一个 Agent 评测体系:测试集设计、工具桩、LLM-as-Judge、以及把 CLI 跑批产品化为平台原生功能的全过程。
1. 为什么 Agent 平台需要 eval
传统软件的回归测试测正确性——1+1 必须等于 2。Agent 的质量需要测的是:
| 维度 | 传统测试 | Agent 评测 |
|---|---|---|
| 契约 | assert response.code == 200 |
输出是否合法 JSON、字段是否齐全 |
| 行为 | mock.method.calledOnce() |
该调 send_message 的时候是否调了 |
| KB 引用 | — | 回答是否引用了正确知识库 |
| 语义 | — | 回答是否有帮助、是否准确、是否在职责范围内 |
前三个可以程序化断言;最后一个只能靠 LLM 打分——但你得给它正确的上下文。
2. 测试用例设计
2.1 用例 Schema
一条用例 = 输入 + 目标 Agent + 期望结果:
{
"id": "gen_001",
"input": "你好,请做一下自我介绍",
"agent": "village_guide_qa",
"expect": {
"contract": "text",
"tools": [],
"kbRefs": ["doc_village_overview"],
"judgeRubric": "回答应介绍自己是平台的AI助手,语气友好。"
}
}
expect 四个维度可单独开关:
- contract =
"json":输出必须是合法 JSON 对象 - tools =
["bt_web_search_tavily"]:必须调用了这些工具 - kbRefs =
["doc_xxx"]:必须引用了这些知识库文档 - judgeRubric = 自然语言评分标准
2.2 "拒绝回答"也是有价值的用例
早期我们的 gen_007 "解释向量数据库" 让 Judge 给问答助手打了低分——因为它没答出来。但后来意识到:Agent 在知识范围外诚实拒绝、引导用户寻求其他帮助,恰恰是正确的行为。
修正后的 rubric:
{
"id": "gen_007",
"input": "请详细解释一下什么是向量数据库",
"agent": "village_guide_qa",
"expect": {
"judgeRubric": "应检索知识库,如无相关内容应诚实告知用户暂未覆盖,不编造技术解释。拒绝回答/引导求助是正确的。"
}
}
评测不应只测"Agent 能不能答对",还应测"Agent 是否有边界感"。
3. 工具桩:评测的第一步
评测跑的是真实 LLM 调用——这没问题。但工具调用不行:Tavily 搜索花钱、MCP 工具可能修改业务数据、send_message 可能拉起子 Agent 产生连锁反应。
3.1 架构
EvalMode = NONE | STUB | LIVE
默认 STUB:外部 API 工具全部替换为预制桩,内建工具(calculator、current_time、knowledge search)保持真实。
LIVE:所有工具真实调用。仅空间 owner/admin 可发起,前端二次确认。
3.2 桩实现的核心坑
从 @Profile("eval")(Spring 启动时决定)到 EvalMode(per-request 运行时决定)的迁移,我们在自检中踩了四个坑:
坑 1:按名字匹配但名字对不上。 最初 STUBBED_TOOLS 用 toolId(如 bt_web_search_tavily)匹配,但 ToolCallback.getToolDefinition().name() 返回的是数据库里的 name 列(如 Tavily联网搜索)。两边对不上,桩静默失效 —— eval 在打真实 Tavily。
修复:注入 ToolDefinitionMapper,建 DB name → toolId 映射,按回调名反查。
坑 2:位置对齐假设。 ToolProvider 内部 selectBatchIds 返回 DB 字母序,不是入参序。按数组位置对齐会打错工具。
修复:按 name→toolId 反查匹配,与顺序、去重、过滤全部解耦。
坑 3:桩改了回调名。 最初用 stubCallback(toolId, ...) 注册,回调名变成了 "bt_send_message",而模型学的是 "send_message"——模型调用的名字和注册的名字对不上,Spring AI 直接报错。
修复:桩保留原回调的 name、description、inputSchema,只替换执行 lambda。
坑 4:lambda 泛型擦除。 FunctionToolCallback.builder(name, (input, ctx) -> ...) —— 没有 .inputType(Map.class) 时,Spring AI 在 build() 里 Assert.notNull 直接抛。
修复:.inputType(Map.class)。
这四个坑的共同教训:桩不能只看"执行替换"是正确的,必须保证"接口契约"一致性——LLM 通过函数名、参数 schema 来调用工具,桩如果改了这些,模型就调不到了。
4. LLM-as-Judge:让评审理解上下文
4.1 第一版:只看文本
int score = judge.score(userInput, text, rubric);
text 来自 SSE t 事件的拼接——只有最终回复文字。工具调用发生在 ts/tr 事件中,Judge 根本不知道。
结果:代码确实调了 bt_current_time,Judge 却说"未调用工具取时间"。
4.2 第二版:注入工具调用摘要
judgeText += "\n[系统记录] 本轮调用了以下工具: " + String.join(", ", toolsCalled);
4.3 第三版:注入思考过程 + 角色感知
[思考过程](截断 3000 字符)
...
[最终回复]
...
[系统记录] 本轮调用了以下工具: bt_current_time
Judge 提示词增加三条准则:
- 如果用户问题不在 Agent 的知识范围内,Agent 诚实说明、引导用户是正确的行为,不应扣分。
- 不要因为 Agent 没做它职责之外的事而扣分。
- 重点评估:回答是否准确、是否有帮助、是否基于可用信息。
4.4 评分不是终点
Judge 分是设计为 辅助诊断 的,不是"质量门禁"的通过/失败。一条用例可能 Judge 给了 7 分但工具调用全对(gen_003 的案例),这时候需要人工判断 7 分是否合理。Judge 分是检测变化的信号,不是绝对的真相。
5. 从 CLI 到产品化 UI
5.1 架构演进
CLI 阶段(Phase 3):EvalRunner(ApplicationRunner,--eval.run=true)直接调 ReasoningAppService.chat() → 收集 SSE → 断言 → 输出 JSON 报告。
产品化阶段(EU-1~6):抽取 EvalEngine 为共用核心,CLI 和 UI 两条路径共享。
CLI 路径: --eval.run=true → EvalRunner → EvalEngine.runOne() → report.json
UI 路径: EvalPage → EvalController → EvalRunService(异步跑批) → EvalEngine.runOne()
关键决策:
EvalMode从@Profile("eval")改为ReasoningAppService.chat()的显式参数(重载,默认NONE),桩在请求级启用,不影响同服务器的正常对话流量- ThreadLocal 不用于传递 eval 模式——WebFlux 线程切换会丢失
- 测试集存储 DB 为主、JSONL 为种子:跟 Agent 配置一致的
Config-as-Code播种策略
5.2 前端交互
Agents 页 → Agent 卡片「评测」入口
→ EvalPage(两个 Tab)
├─ 测试集:用例列表 + 增删改 + 启用开关 + 内联编辑
└─ 运行历史:内联展开详情 → 进度条 + 逐条结果 + 三层断言 + SSE 执行轨迹 + 完整输出
「对话存为用例」:Reasoning 对话页每条用户消息 hover 出「存为评测用例」→ 后端用 Phase 1 trace 反查该轮调用的工具序列,自动预填 expect.tools。
6. 经验总结
桩的质量决定 eval 的质量。 如果桩没正确拦截工具调用,eval 跑的是"半真实半桩"的结果,不可复现、不可比对。我们四轮返工全在修桩——名字匹配、位置对齐、接口契约、泛型擦除——直到最后一条用例真跑通了才算数。
Judge 要看到 Agent 看到的。 只给 Judge 看文本输出是不够的——思考过程、工具调用记录、角色职责都要给。否则 Judge 会在信息不全的情况下给出误导性评分。
用例设计是一阶工作,不是"跑起来了再补"。 gen_007 的教训——Agent 拒绝回答本身就是正确的行为,但最初的 rubric 写了"应给出定义"。用例不对,评测就是在错误的标准上打分。
产品化是"能跑通"的试金石。 CLI 阶段我们跑了好几次——每次代码 review 都说"应该没问题"。直到产品化后第一次实跑,才暴露出 inputType、位置对齐这些问题。不是 review 不仔细,是 eval 不真跑就没有真反馈。
eval 不是一次建完就扔的。 从测试用例(持续补充边界行为)、到 Judge 提示词(随业务迭代调整准则)、到前端交互(紧凑排版、实时进度、下钻详情),eval 体系本身也是持续迭代的产物。