Nodejs也能写Agent - 20.LangGraph篇 - Agent 评估与测试
上一篇我们用「查天气并总结」把 Chain 和 Graph 左右对照完了——能跑、能升级、能加记忆。但说句扎心的:你改了一行 Prompt、换了个模型、升级了 LangGraph,行为可能悄悄退化,而你还在靠「手动试几条」自我安慰。
传统测试写 expect(add(1, 2)).toBe(3),结果确定。Agent 不是:同一输入,temperature > 0 时说法会变;ReAct 环里工具调用次数、顺序也可能飘。没有系统化评估,边缘 case 的退化进不了 CI,合并后再救火成本翻倍。
这一篇把评估体系立起来:golden set、两种判分方式、本地 eval runner,以及怎么把通过率当成合并门槛。
老规矩,本文以官网最新文档核对过(How to evaluate agents、Agent Evals)。Agent 入口继续用博客 9 的
createAgent——别再抄createReactAgent。本篇主路径是本地轻量 runner(适合 Ollama、零 LangSmith 账号也能跑);LangSmith /agentevals作升级选项点到为止。

一、为什么 Agent 需要评估
| 挑战 | 说明 |
|---|---|
| 非确定性 | 同一 prompt,temperature > 0 时输出可能不同 |
| 多步推理 | ReAct 环里 tool 调用顺序、次数可能变化 |
| 回归风险 | 改 prompt、换模型、升级依赖后,行为可能静默退化 |
| 成本与延迟 | 除了「对不对」,还要盯 token 与端到端耗时 |
单元测试测的是确定性逻辑(解析器、路由函数、工具本体)。Eval 测的是 Agent 端到端行为:该不该调工具、参数对不对、最终答案合不合理。两者互补,谁也替不了谁。
二、评估什么
生产里常见维度:
| 维度 | 衡量什么 | 典型指标 |
|---|---|---|
| 正确性 | 最终回答是否符合预期 | pass rate、关键词命中、语义相似 |
| 工具调用 | 是否调对 tool、参数是否靠谱 | tool name match、args 校验 |
| 延迟 | 端到端响应时间 | p50 / p95 latency |
| Token 成本 | 输入 + 输出用量 | avg tokens / query |
| 鲁棒性 | 空输入、超长输入、异常工具返回 | 不崩溃、有友好错误 |
| 安全性 | 注入、越权 tool | 拒绝率(后文安全篇再展开) |
起步建议:
- ReAct / 工具 Agent:至少同时看「最终答案」+「是否调用了期望工具」——只看文本,模型可能蒙对答案却根本没走 tool。
- RAG:再加检索相关性、引用是否瞎编(本篇不展开检索评测细节)。
三、数据集:先有 golden set
评估质量 ≈ 数据集质量。三种来源:
| 来源 | 特点 |
|---|---|
| Golden set | 人工精选,覆盖核心场景 + 已知边缘 case;起步首选 |
| 合成数据 | LLM 批量变体,扩面快;要人工抽检,防分布漂移 |
| 生产抽样 | 最贴真实;成本高,适合后期 |
建议起步:先建 10~20 条 golden set——happy path + 3~5 条边缘 case(空输入、不会的工具、超长问句),再逐步加。
单条 case 建议字段:
/** 单条评估用例 */
export interface EvalCase {
id: string;
input: string;
/** 期望回答包含的关键词(部分匹配即可 pass) */
expectedKeywords?: string[];
/** 期望调用过的 tool 名称 */
expectedTools?: string[];
/** 分类标签,便于按场景统计 */
tags: string[];
}
export const goldenSet: EvalCase[] = [
{
id: "calc-01",
input: "3 加 5 等于多少?",
expectedKeywords: ["8"],
expectedTools: ["calculator"],
tags: ["calculator", "react"],
},
{
id: "calc-02",
input: "100 加 25 是多少?",
expectedKeywords: ["125"],
expectedTools: ["calculator"],
tags: ["calculator", "react"],
},
{
id: "chat-01",
input: "你好,介绍一下自己",
expectedKeywords: ["助手", "帮助"],
tags: ["chat"],
},
];
四、两种判分:确定 vs 裁判
官网把 Agent 评估大致分成两路(见 Agent Evals):
| 方式 | 何时用 | 代价 |
|---|---|---|
| 确定性检查 | 你知道期望工具 / 关键词 / 参考轨迹 | 快、稳、几乎零成本 |
| LLM-as-judge | 开放题、要评「是否合理」「轨迹是否高效」 | 多一次 LLM 调用,有偏差 |
确定性这边:本篇用关键词 + expectedTools;更严的 trajectory match(严格顺序 / 无序 / subset / superset)可用 agentevals 的 createTrajectoryMatchEvaluator——知道参考轨迹时优先它。
开放题这边:给 judge 一张 rubric,固定模型、temperature: 0,关键 case 保留人工复核。
Rubric 示例
请根据以下标准对 Agent 回答评分(1~5 分):
5 分:完全正确,信息准确,表述清晰
4 分:基本正确,有小瑕疵
3 分:部分正确,有遗漏或轻微错误
2 分:大部分错误
1 分:完全错误或无关
只输出一个整数分数。
极简 judge 骨架(本地 Ollama)
import { ChatOllama } from "@langchain/ollama";
import * as z from "zod";
const judgeLlm = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });
const scorer = judgeLlm.withStructuredOutput(
z.object({ score: z.number().min(1).max(5), reason: z.string() })
);
async function judgeAnswer(question: string, answer: string) {
return scorer.invoke(
`问题:${question}\n回答:${answer}\n\n按 1~5 分打分,并给一句理由。`
);
}
还有 pairwise:同一输入比 Agent A / B 谁更好,适合 Prompt 或模型 A/B。judge 本身也会偏——别把分数神化。
五、本地 Eval Runner(主路径)
不接 LangSmith 也能先跑起来:读 golden set → 逐条 invoke → 记 pass/fail、延迟 → 打汇总。
import { createAgent } from "langchain";
import { ChatOllama } from "@langchain/ollama";
import { tool } from "@langchain/core/tools";
import * as z from "zod";
import type { EvalCase } from "./dataset";
import { goldenSet } from "./dataset";
const calculator = tool(
async ({ a, b }: { a: number; b: number }) => String(a + b),
{
name: "calculator",
description: "两数相加",
schema: z.object({ a: z.number(), b: z.number() }),
}
);
const llm = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });
const agent = createAgent({
model: llm,
tools: [calculator],
systemPrompt: "需要计算时必须调用 calculator 工具。",
});
function checkKeywords(content: string, keywords?: string[]): boolean {
if (!keywords?.length) return true;
const lower = content.toLowerCase();
return keywords.every((k) => lower.includes(k.toLowerCase()));
}
function extractToolNames(
messages: { tool_calls?: { name: string }[] }[]
): string[] {
const names: string[] = [];
for (const msg of messages) {
for (const call of msg.tool_calls ?? []) {
names.push(call.name);
}
}
return names;
}
async function runSingleEval(caseItem: EvalCase) {
const start = Date.now();
const result = await agent.invoke({
messages: [{ role: "user", content: caseItem.input }],
});
const latencyMs = Date.now() - start;
const lastContent = String(result.messages.at(-1)?.content ?? "");
const keywordOk = checkKeywords(lastContent, caseItem.expectedKeywords);
const actualTools = extractToolNames(result.messages);
const toolOk =
!caseItem.expectedTools?.length ||
caseItem.expectedTools.every((t) => actualTools.includes(t));
const pass = keywordOk && toolOk;
return {
id: caseItem.id,
pass,
latencyMs,
reason: pass ? undefined : `keywordOk=${keywordOk}, toolOk=${toolOk}`,
};
}
async function runEval(passRateThreshold = 0.9) {
const results = [];
for (const c of goldenSet) {
// 串行更省本地 Ollama;要速度可改成有限并发
results.push(await runSingleEval(c));
}
const passed = results.filter((r) => r.pass).length;
const rate = passed / results.length;
const avgLatency =
results.reduce((s, r) => s + r.latencyMs, 0) / results.length;
console.log(
`通过率: ${passed}/${results.length} (${(rate * 100).toFixed(1)}%)`
);
console.log(`平均延迟: ${avgLatency.toFixed(0)} ms`);
for (const r of results.filter((x) => !x.pass)) {
console.log(` FAIL ${r.id}: ${r.reason}`);
}
if (rate < passRateThreshold) {
process.exitCode = 1; // CI gate
}
}
// await runEval();
跑「3 加 5」这类 case 时,你真正在验的是:既说对了 8,又真的调了 calculator——这比只 includes("8") 靠谱得多。
六、升级:LangSmith 与轨迹评估(短)
本地 runner 够你起步。需要实验对比、历史趋势、团队共享仪表盘时,再上 LangSmith:
- 建 dataset(每条 example 有 inputs / 可选 reference outputs)
- 写 target:
(inputs) => agent.invoke(inputs) - 挂 evaluators,调用
evaluate(JS 里自带异步)
示意:
import { evaluate } from "langsmith/evaluation";
import {
createTrajectoryLLMAsJudge,
TRAJECTORY_ACCURACY_PROMPT,
} from "agentevals";
const trajectoryEvaluator = createTrajectoryLLMAsJudge({
model: "openai:o3-mini", // 生产 judge 常用强模型;本地可换
prompt: TRAJECTORY_ACCURACY_PROMPT,
});
await evaluate(
async (inputs) => {
const result = await agent.invoke(inputs);
return result.messages;
},
{
data: "your_dataset_name",
evaluators: [trajectoryEvaluator],
}
);
agentevals 还提供 trajectory match(strict / unordered / subset / superset):有参考工具序列时,优先确定性匹配,又快又稳。细节见官网,本篇不展开成第二教程。
七、回归测试与 CI
把 eval 脚本挂进 CI(PR 或夜间定时):
# 示例:跑完通过率不达标则 exit 1
pnpm eval
| 做法 | 作用 |
|---|---|
| 通过率阈值(如 90%) | 低于阈值 → job 失败,挡住合并 |
| 记录延迟 / 失败 case | 看是变慢还是变笨 |
| 与单元测试分工 | pnpm test 测纯逻辑;eval 测 Agent 行为 |
别指望 eval 替代单测:工具函数、State reducer、路由纯函数该单测还是单测。
常见坑
- 手动试几条就当过关:盖不住边缘 case,Prompt 小改会静默退化。
- golden set 全是 happy path:缺空输入、工具失败、超长输入 → 通过率虚高。
- LLM-as-judge 无 rubric / temperature 乱飘:分数不可比;固定 rubric +
temperature: 0。 - 只评最终文本、不评工具调用:模型可能蒙对答案却没走 tool,上线就露馅。
- eval 不进 CI:退化合并后才发现,修复成本高。
- 一上来就绑死 LangSmith:本地 runner 先通;仪表盘是加速器,不是起步前置。
- 还在用
createReactAgent写 eval target:主路径换成createAgent。
更多推荐

所有评论(0)