智能体评测实践

"改一句提示词、加一个工具,效果到底是变好还是变差?"手点几次不可靠——智能体有随机性,必须用回归测试集 + 批量跑 + 统一打分来度量。本章以第 18 章的 LangGraph ReAct 智能体为对象:准备 JSONL 用例、批量执行、用"期望工具比对 + LLM-as-judge 要点打分"算出通过率与平均成本,并给出失败样本的人工检查循环。

先想清楚:评测哪几个信号

一个 Agent 运行要测的不止"答得对不对":

  • 工具选得对不对:该检索的问题是否真的调用了检索工具;
  • 答案要点够不够:关键信息是否覆盖(用要点列表而非整句比对);
  • 成本与耗时:多轮空转、反复调错工具都会烧 token,回归里要记录。

准备:把 Agent 入口统一成 run_agent()

第 18 章是"一张图",这里包一层统一接口,后续跑批只认这个函数(你的模块/函数名不同就改这一处):

# agent_adapter.py
from langchain_core.messages import HumanMessage

def run_agent(question: str) -> dict:
    """执行一次 Agent 运行,返回 answer / tools / tokens 供评测采集。"""
    from agent_demo.react_graph import graph          # 第 18 章编译后的图
    out = graph.invoke({"messages": [HumanMessage(question)]})
    msgs = out["messages"]
    answer = next((m.content for m in reversed(msgs) if m.content), "")
    tools = [c["name"] for m in msgs
             for c in getattr(m, "tool_calls", []) or []]      # 实际调用过的工具名
    chars = sum(len(str(getattr(m, "content", ""))) for m in msgs)
    return {"answer": answer, "tools": tools,
            "approx_tokens": chars // 2}                        # 近似值,正式用 usage

回归集:JSONL 用例

每个用例记四件事:问题、期望工具、答案要点、备注:

{"id": "r01", "question": "25 加 17 等于多少?", "expect_tools": ["add"], "expect_points": ["42"], "note": "基础加法"}
{"id": "r02", "question": "瑞林工具站的客服电话是多少?", "expect_tools": ["search_docs"], "expect_points": ["400"], "note": "必须检索,不许瞎编"}
{"id": "r03", "question": "你好", "expect_tools": [], "expect_points": [], "note": "寒暄不应调工具"}

要点写成"答案里必须出现的关键词/短句",比整句匹配宽容,也比纯人肉看省事。

跑批 + 两个自动判据

import json, time

def case_pass(case: dict, run: dict) -> dict:
    """判据一:工具集合必须覆盖 expect_tools;要点留给 judge 判。"""
    used = set(run["tools"]); expect = set(case.get("expect_tools") or [])
    return {"tool_ok": expect <= used,
            "tool_detail": f"期望{expect or '∅'} 实际{used or '∅'}"}

def run_all(dataset_path: str):
    results = []
    with open(dataset_path, encoding="utf-8") as f:
        cases = [json.loads(line) for line in f if line.strip()]
    for c in cases:
        t0 = time.time()
        run = run_agent(c["question"])
        results.append({**c, **case_pass(c, run),
                        "answer": run["answer"], "tokens": run["approx_tokens"],
                        "latency": round(time.time() - t0, 2)})
    return results

判据二:LLM-as-judge 要点打分

"要点是否覆盖"用关键词匹配太脆(模型换个说法就误杀),让大模型当裁判更稳——给它评分标准,返回结构化结论(第 04 章的 with_structured_output 思路):

from pydantic import BaseModel, Field
from langchain_openai import ChatOpenAI
import os

class Verdict(BaseModel):
    match: bool = Field(description="要点是否被回答覆盖")
    reason: str = Field(description="一句话理由,指出缺了哪个要点")

def build_judge():
    judge_llm = ChatOpenAI(
        model=os.getenv("LLM_MODEL", "deepseek-chat"),
        api_key=os.getenv("LLM_API_KEY"),
        base_url=os.getenv("LLM_BASE_URL"),
        temperature=0,
    ).with_structured_output(Verdict)
    return judge_llm

def judge_points(case: dict, answer: str) -> Verdict:
    if not case.get("expect_points"):
        return Verdict(match=True, reason="无要点要求")
    prompt = (
        f"问题:{case['question']}\n期望要点:{';'.join(case['expect_points'])}\n"
        f"候选回答:{answer}\n判断回答是否覆盖所有要点,只输出结论。"
    )
    return build_judge().invoke(prompt)     # 每次新建 judge 会慢,实际可复用实例

汇总报告:通过率与平均成本

PRICE_IN_1M, PRICE_OUT_1M = 2.0, 8.0     # 元/百万 token,示例单价,按你的服务商改
def report(results: list) -> dict:
    n = len(results)
    tool_pass = sum(1 for r in results if r["tool_ok"])
    point_pass = sum(1 for r in results if judge_points(r, r["answer"]).match)
    total_tokens = sum(r["tokens"] for r in results)
    cost = total_tokens / 1_000_000 * (PRICE_IN_1M + PRICE_OUT_1M) / 2  # 粗略估算
    return {"cases": n, "tool_pass_rate": f"{tool_pass/n:.0%}",
            "point_pass_rate": f"{point_pass/n:.0%}",
            "avg_latency": f"{sum(r['latency'] for r in results)/n:.2f}s",
            "est_cost": f"{cost:.4f} 元"}

每次跑批把报告存一份带时间戳的文件,改动提示词/工具后对比曲线即可判断"改好了还是改坏了"。

失败样本人工检查循环

自动判据只负责"筛出来",真正提分靠人看失败样本:

def dump_failures(results, path="failures.jsonl"):
    with open(path, "w", encoding="utf-8") as f:
        for r in results:
            if not (r["tool_ok"] and judge_points(r, r["answer"]).match):
                f.write(json.dumps(r, ensure_ascii=False) + "\n")

拿到 failures.jsonl 后逐个问三句:① 工具没调/调错 → 是描述不清还是模型不知道何时用(改 docstring/提示词);② 要点缺失 → 加提示词约束或补上下文;③ 明显是该补的边界 → 把该样本加进回归集。改完重跑同一批,直到通过率不再上涨,再更新评测集继续下一轮。 小结:评测 = 稳定的 run_agent 入口 + JSONL 用例(问题/期望工具/要点)+ 批量执行,工具命中用集合比对、要点命中用 LLM-as-judge,输出通过率与平均成本报告;失败样本进入"人看 → 修 → 补用例 → 重跑"的闭环,评测集随修复越攒越厚。

笔记加载中…