LLM Zoomcamp Agent 评估实战:用 A→Q→A′ 框架与 LLM Judge 同时评判答案质量和工具调用轨迹
2026/9/17 17:34:59 网站建设 项目流程

LLM Zoomcamp Agent 评估实战:用 A→Q→A′ 框架与 LLM Judge 同时评判答案质量和工具调用轨迹

【免费下载链接】llm-zoomcampLLM Zoomcamp - a free online course about real-life applications of LLMs. In 10 weeks you will learn how to build an AI system that answers questions about your knowledge base. Register here 👇🏼项目地址: https://gitcode.com/GitHub_Trending/ll/llm-zoomcamp

在 LLM Zoomcamp 的 Evaluation 模块中,前几课已经用 LLM-as-a-Judge 评估了固定 RAG 管线的输出。本课(14-agent-evaluation.md)把同样的评估思路延伸到Agent:不仅要评判 Agent 的最终答案,还要保存并评判它在作答之前发起的工具调用轨迹(trajectory)。读完本文,你将掌握如何在 llm-zoomcamp 项目中复用模块 01 的 ToyAIKit Agent,批量生成 A→Q→A′ 评估记录,并用一个双维度 LLM Judge 同时给出"答案质量"与"轨迹质量"两个分数,进而定位问题到底出在检索环节还是模型推理环节。

从 RAG 评估到 Agent 评估:多出来的"轨迹"维度

在 RAG 评估中,我们使用经典的 A→Q→A′ 结构:

  • A:FAQ 中的原始答案(ground truth)
  • Q:由该答案生成的学生问题
  • A′:我们的系统(RAG 管线)生成的答案

如果 A′ 与 A 语义等价,说明系统表现良好。关于这套结构及其离线评估的前提,可以回顾模块内的 12-rag-answers.md(生成 RAG 答案)和 13-llm-as-judge.md(LLM 评判答案)两课。

Agent 评估使用完全相同的 A→Q→A′ 结构,唯一区别是 A′ 不再来自固定管线,而是来自一个能自主调用工具的 Agent。因此,除了最终的 A′,我们还额外保存了轨迹——在本课中,轨迹特指 Agent 在产出最终答案之前发起的工具调用序列(函数名与参数),而不是完整的消息历史。

question ─┐ answer_orig ─┼─► 评估记录(A→Q→A′ + trajectory) answer_agent ─┤ tool_calls ───┘

这样一份记录让 Judge 能同时看到两件事:answer_agent是否与answer_orig一致,以及工具调用是否对这个问题合理。也就是说,最终答案与 Agent 行为可以在同一处被一起评估

加载数据:ground truth 问题、FAQ 文档与搜索索引

评估从数据准备开始,加载方式与 RAG 评估一课完全一致。首先读入 ground truth 问题(该文件的两列分别为questiondocument,见 ground_truth-new.csv):

import pandas as pd df_ground_truth = pd.read_csv("data/ground_truth-new.csv") ground_truth = df_ground_truth.to_dict(orient="records")

接着加载 FAQ 文档并构建搜索索引。这里用到的是 ingest.py 中的两个函数:load_faq_data会从 DataTalks Club 的 FAQ 接口抓取各课程文档;build_index基于minsearch构建索引,其中text_fields指定了参与全文检索的字段(questionsectionanswer),keyword_fields指定了用于过滤的字段(course):

from ingest import load_faq_data, build_index documents = load_faq_data() documents_llm = [] for doc in documents: if doc["course"] == "llm-zoomcamp": documents_llm.append(doc) documents = documents_llm index = build_index(documents)

最后建立"文档 ID → 文档"的查找表,用于后续取出每条 ground truth 记录对应的原始答案:

doc_idx = {} for doc in documents: doc_idx[doc["id"]] = doc

运行 Agent:复用 ToyAIKit 的 Runner 并记录完整消息历史

运行环节直接复用模块 01(01-agentic-rag 课程目录)中引入的ToyAIKitAgent——它负责处理 Agent 循环(agent loop),并把完整的消息历史保存在结果对象中。在模块 01 的 pyproject.toml 中可以看到toyaikit>=0.0.11依赖;本模块的 pyproject.toml 则依赖openai>=2.38.0minsearchpandaspython-dotenv等。

先设置模型客户端(API Key 通过.env提供):

from dotenv import load_dotenv from openai import OpenAI from toyaikit.llm import OpenAIClient load_dotenv() openai_client = OpenAI()

定义 Agent 的搜索工具。它包装了前面构建的index.search,注意其中的boost_dictfilter_dict——这与 evaluation_utils.py 中RAGWithUsage.search使用的参数一致,即question权重 1.0、answer权重 2.0、section权重 0.1,并按course == "llm-zoomcamp"过滤:

def search(query: str) -> list[dict]: """ Search the FAQ database for entries matching the given query. """ return index.search( query, num_results=5, boost_dict={"question": 1.0, "answer": 2.0, "section": 0.1}, filter_dict={"course": "llm-zoomcamp"} )

然后创建 Runner。developer_prompt(即课程助教的指令)要求 Agent 必须先用搜索工具再作答,这与模块 01 中RAGBase的指令风格一致(见 rag_helper.py 中的INSTRUCTIONS):

from toyaikit.tools import Tools from toyaikit.chat.runners import OpenAIResponsesRunner agent_tools = Tools() agent_tools.add_tool(search) instructions = """ You're a course teaching assistant. Answer student questions based on the FAQ search results. Use the search tool before answering. """.strip() runner = OpenAIResponsesRunner( tools=agent_tools, developer_prompt=instructions, llm_client=OpenAIClient(model="gpt-5.4-mini") )

Runner 的loop方法执行完整 Agent 循环,返回的结果对象包含三个关键部分:

  • last_message:最终回答
  • all_messages:完整的消息历史
  • cost:本次运行中所有 LLM 调用的总成本

针对第一条 ground truth 问题运行:

rec = ground_truth[0] result = runner.loop(prompt=rec["question"])

查看完整消息历史:

result.all_messages

提取轨迹:只保留工具调用,不把整段历史发给 Judge

对本课而言,轨迹只包含工具调用——我们不需要把完整消息历史发送给 Judge,那样既浪费 token 又干扰评判。下面的函数遍历消息历史,只抽取type == "function_call"的消息,记录函数名与参数:

def extract_tool_calls(messages): tool_calls = [] for message in messages: if isinstance(message, dict): continue if message.type == "function_call": tool_calls.append({ "name": message.name, "arguments": message.arguments, }) return tool_calls

对上面的例子:

tool_calls = extract_tool_calls(result.all_messages) tool_calls

你会看到类似这样的输出——Agent 针对"能否按自己的节奏学习并最终拿到证书"这个问题,发起了一次search调用,查询词几乎完整覆盖了问题中的关键词:

[ { "name": "search", "arguments": "{\"query\":\"own pace certificate at the end self-paced course certificate\"}" } ]

保存 A→Q→A′ 评估记录

现在取出原始答案,并把问题、Agent 答案、原始答案、工具调用、成本、文档 ID 组装成一条完整的评估记录:

doc_id = rec["document"] original_doc = doc_idx[doc_id] answer_orig = original_doc["answer"]
agent_result = { "question": rec["question"], "answer_agent": result.last_message, "answer_orig": answer_orig, "tool_calls": json.dumps(tool_calls), "cost": result.cost.total_cost, "document": doc_id, } agent_result

其中answer_agent是交给 LLM Judge 评估的字段;tool_calls让 Judge 能看到 Agent 是如何走到这一步的。tool_calls必须用json.dumps包裹,以便正确转义其中的特殊字符(例如双引号),避免后续写入 CSV 或拼进 Judge 提示词时发生格式错误。

批量处理:并行运行 Agent 并核算成本

把上面的逻辑封装成函数,处理单条 ground truth 记录:

def generate_agent_answer(rec): doc_id = rec["document"] original_doc = doc_idx[doc_id] result = runner.loop(prompt=rec["question"]) tool_calls = extract_tool_calls(result.all_messages) answer_record = { "question": rec["question"], "answer_agent": result.last_message, "answer_orig": original_doc["answer"], "tool_calls": json.dumps(tool_calls), "cost": result.cost.total_cost, "document": doc_id, } return answer_record

Agent 的loop一次运行可能涉及多轮 LLM 调用,批量处理耗时较长,因此使用ThreadPoolExecutor并行执行,并用 evaluation_utils.py 中提供的map_progress助手显示进度条(其内部基于tqdm对每个 future 注册回调,任务完成一个就更新一次进度):

from concurrent.futures import ThreadPoolExecutor from evaluation_utils import map_progress with ThreadPoolExecutor(max_workers=6) as pool: agent_answers = map_progress(pool, ground_truth[:50], generate_agent_answer)

这里先取前 50 条问题作为样本。转为 DataFrame 并统计总成本:

df_agent = pd.DataFrame(agent_answers)
df_agent["cost"].sum()

保存结果:

df_agent.to_csv("data/agent-answers.csv", index=False)

至此我们得到了与之前完全相同的 A→Q→A′ 数据,外加每次 Agent 运行的工具调用。课程材料中的该文件生成于 2026 年 5 月 29 日,共使用 50 条 ground truth 问题。由于 ToyAIKit 为每次运行单独记录 Agent 成本,可以直接对cost列求和——50 次 Agent 运行的总成本为 $0.06993300,约 7 美分

仓库中已提交了这份预生成的样例文件 data/agent-answers.csv,其列为question, answer_agent, answer_orig, tool_calls, cost, document。如果你不想自己运行 Agent,可以直接读取它:

df_agent = pd.read_csv("data/agent-answers.csv") agent_answers = df_agent.to_dict(orient="records")

(课程原始讲义还提供了用wget从课程仓库拉取该文件的命令,将命令中的PREFIX替换为课程仓库的 raw 文件基址即可,与仓库内的文件路径结构一致。)

什么样的轨迹才算"好"?

一个好的轨迹并不意味着"工具调用次数多"——它应该意味着"以有助于回答问题的方式使用了可用工具"。对于本课的搜索型 Agent,好的轨迹具备以下特征:

  • 搜索查询与用户问题相关;
  • 查询包含了问题中的重要关键词;
  • Agent 避免用相同参数重复搜索;
  • 如果搜索不止一次,后续查询是对前一次的有效细化(refinement)
  • 通常只调用 1 次搜索;
  • 对于较难的问题,2~3 次调用可以接受;
  • 超过 3 次搜索必须有明确理由;
  • 工具调用必须支撑最终答案;
  • Agent 既不能过早停止,也不能无理由地持续搜索。

这份清单就是下面 Judge 指令中"轨迹质量"评分标准的直接来源。

定义双维度 Judge:答案分数 + 轨迹分数

先定义一个 Pydantic 结构化输出类型,包含两个分数及其对应的推理说明:

from pydantic import BaseModel, Field from typing import Literal class AgentEvaluation(BaseModel): answer_reasoning: str = Field( description="Reasoning about whether the final answer is correct." ) answer_score: Literal["good", "bad"] = Field( description="'good' if the final answer matches the original answer." ) trajectory_reasoning: str = Field( description="Reasoning about whether the tool calls were useful." ) trajectory_score: Literal["good", "bad"] = Field( description="'good' if the tool calls were reasonable for the question." )

Judge 指令明确了两条评估主线:答案质量(无需逐字一致,但必须包含相同的关键信息)与轨迹质量(查询相关性、关键词覆盖、重复/冗余调用、多次搜索的细化程度、调用次数是否合理、是否支撑最终答案):

agent_judge_instructions = """ You are an expert evaluator. You will be given: 1. A question from a student 2. The original answer from the FAQ (ground truth) 3. An answer generated by an AI agent 4. The tool calls made by the agent Evaluate two things: Answer quality: - Does the agent answer match the original answer? - It does not need to be word-for-word identical. - It should contain the same key information. Trajectory quality: - Were the search queries relevant to the question? - Did the queries include important keywords from the question? - Did the agent avoid duplicate or unnecessary tool calls? - If it made multiple searches, did the later searches refine the query? - Was the number of search calls reasonable? Usually 1 is enough, 2-3 can be okay, and more than 3 needs a clear reason. - Did the tool calls support the final answer? Mark answer_score as 'good' if the final answer is correct. Mark trajectory_score as 'good' if the tool calls were reasonable. """.strip()

Prompt 模板把问题、原始答案、Agent 答案与工具调用四部分拼装起来:

agent_judge_prompt = """ Question: {question} Original Answer (ground truth): {answer_orig} Agent Answer: {answer_agent} Tool Calls: {tool_calls} """.strip()

evaluate_agent_answer:结构化输出 + 自动重试

接下来封装 Judge 调用函数。注意两个细节:一是tool_calls字段可能以字符串形式从 CSV 读入,需要先json.loads还原成列表,再以缩进格式(indent=2)拼进 prompt,方便 Judge 阅读;二是复用llm_structured_retry做结构化输出调用:

import json from evaluation_utils import calc_total_price, llm_structured_retry def evaluate_agent_answer(rec, model="gpt-5.4-mini"): tool_calls = rec["tool_calls"] if isinstance(tool_calls, str): tool_calls = json.loads(tool_calls) prompt = agent_judge_prompt.format( question=rec["question"], answer_orig=rec["answer_orig"], answer_agent=rec["answer_agent"], tool_calls=json.dumps(tool_calls, indent=2), ) result, usage = llm_structured_retry( openai_client, agent_judge_instructions, prompt, AgentEvaluation, model=model, ) return result, usage

从源码看,evaluation_utils.py 中的llm_structured_retry最多重试 3 次(max_retries=3),失败后按2 ** attempt秒做指数退避;底层的llm_structured通过client.responses.parse(..., text_format=output_type)请求模型以指定 Pydantic 结构返回,并同时返回response.output_parsed与 token 用量response.usage。这保证了 Judge 输出一定能被解析成AgentEvaluation对象。

单条测试:

agent_eval, usage = evaluate_agent_answer(agent_answers[0]) agent_eval

结果解读:当答案不佳时,轨迹分数会告诉我们问题是否始于工具使用。

  • 答案坏、轨迹好:模型可能没有很好地利用检索到的上下文;
  • 两者都坏:Agent 很可能搜索了错误的内容,也可能过早停止了。

批量运行 Judge 并统计结果

把 Judge 逻辑封装成可并行的记录处理函数,返回结构化结果与 token 用量:

def judge_agent_record(rec): agent_eval, usage = evaluate_agent_answer(rec) result = { "question": rec["question"], "document": rec["document"], "answer_score": agent_eval.answer_score, "answer_reasoning": agent_eval.answer_reasoning, "trajectory_score": agent_eval.trajectory_score, "trajectory_reasoning": agent_eval.trajectory_reasoning, } return result, usage

同样用map_progress并行处理所有 Agent 答案:

with ThreadPoolExecutor(max_workers=6) as pool: results = map_progress(pool, agent_answers, judge_agent_record)

把结果与用量分开收集:

agent_evaluations = [] usages = [] for evaluation, usage in results: agent_evaluations.append(evaluation) usages.append(usage)

创建 DataFrame 并按 token 用量核算 Judge 成本(evaluation_utils.py 中calc_price的价格常量为输入 token $0.75/百万、输出 token $4.50/百万):

df_agent_eval = pd.DataFrame(agent_evaluations)
calc_total_price(usages)

检查两类分数的分布:

df_agent_eval["answer_score"].value_counts()
df_agent_eval["trajectory_score"].value_counts()

保存评估结果:

df_agent_eval.to_csv("data/agent-evaluations.csv", index=False)

课程材料中的实际结果(生成于 2026 年 5 月 29 日,共评判 50 条 Agent 答案,样例文件见 data/agent-evaluations.csv,其列为question, document, answer_score, answer_reasoning, trajectory_score, trajectory_reasoning):

  • 答案分数:Good 45,Bad 5;
  • 轨迹分数:Good 49,Bad 1;
  • Judge token 用量:输入 29,228 tokens,输出 6,984 tokens;
  • 按上述价格计算,Judge 总成本$0.053349,约 5 美分

可以看出:49 条轨迹被判定为合理,但只有 45 条答案合格——也就是说,大部分"坏答案"并非源于工具用错了,而是模型对已检索到的上下文利用不佳。这正是双维度评估的核心价值:它能帮你把问题定位到"检索/工具使用"还是"模型推理"层,而不是笼统地归咎于整个 Agent。仓库预生成的评估文件中也能看到类似案例:一条answer_score=badtrajectory_score=good的记录里,Judge 明确指出搜索查询与关键词覆盖都没问题,是最终回答的框架与 ground truth 不够对齐。

小结与下一步

本课的核心方法论可以浓缩为三步:

  1. 记录:用与 RAG 相同的 A→Q→A′ 结构运行 Agent,并额外保存工具调用轨迹(extract_tool_calls);
  2. 评判:用双维度 LLM Judge(AgentEvaluation)同时打分——答案质量(answer_score)与轨迹质量(trajectory_score),并附带推理说明;
  3. 定位:交叉对比两个分数,判断问题出在工具使用还是上下文利用,为后续优化指明方向。

整个评估流程依托的离线数据与工具链——ingest.py、evaluation_utils.py、rag_helper.py 以及 ToyAIKit 依赖——都已在仓库中可直接查看与运行。如果需要回顾 Agent 循环本身的实现细节,可回到 模块 01 的 Agent 代码;想要了解整个 Evaluation 模块的课程地图,可查看模块 README 与 11-evaluation-intro.md(RAG 与 Agent 评估导论);本课的最终评估结果与后续的 15-next-steps.md(评估框架、监控与资源)衔接,构成从离线评估走向线上监控的完整闭环。

【免费下载链接】llm-zoomcampLLM Zoomcamp - a free online course about real-life applications of LLMs. In 10 weeks you will learn how to build an AI system that answers questions about your knowledge base. Register here 👇🏼项目地址: https://gitcode.com/GitHub_Trending/ll/llm-zoomcamp

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询