智能体评测实践
"改一句提示词、加一个工具,效果到底是变好还是变差?"手点几次不可靠——智能体有随机性,必须用回归测试集 + 批量跑 + 统一打分来度量。本章以第 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,输出通过率与平均成本报告;失败样本进入"人看 → 修 → 补用例 → 重跑"的闭环,评测集随修复越攒越厚。