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 直接报错。

修复:桩保留原回调的 namedescriptioninputSchema,只替换执行 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. 经验总结

  1. 桩的质量决定 eval 的质量。 如果桩没正确拦截工具调用,eval 跑的是"半真实半桩"的结果,不可复现、不可比对。我们四轮返工全在修桩——名字匹配、位置对齐、接口契约、泛型擦除——直到最后一条用例真跑通了才算数。

  2. Judge 要看到 Agent 看到的。 只给 Judge 看文本输出是不够的——思考过程、工具调用记录、角色职责都要给。否则 Judge 会在信息不全的情况下给出误导性评分。

  3. 用例设计是一阶工作,不是"跑起来了再补"。 gen_007 的教训——Agent 拒绝回答本身就是正确的行为,但最初的 rubric 写了"应给出定义"。用例不对,评测就是在错误的标准上打分。

  4. 产品化是"能跑通"的试金石。 CLI 阶段我们跑了好几次——每次代码 review 都说"应该没问题"。直到产品化后第一次实跑,才暴露出 inputType、位置对齐这些问题。不是 review 不仔细,是 eval 不真跑就没有真反馈

  5. eval 不是一次建完就扔的。 从测试用例(持续补充边界行为)、到 Judge 提示词(随业务迭代调整准则)、到前端交互(紧凑排版、实时进度、下钻详情),eval 体系本身也是持续迭代的产物。