如何从零开始构建LLM评测框架

2025-08-12   出处: Confident AI  作/译者:Jeffrey Ip/Yilia

假设一个场景:当我正准备第 44 次修改我的提示模板时,收到了经理的消息:“嘿 Jeff,希望你今天一切顺利。你看到新开源的 Mistral 模型了吗?听说它比你现在用的 LLaMA-2 效果更好,我想让你试试看。”

哦不,我心里想,又来这一套。

这种令人沮丧的打断(我指的是新模型的不断发布)正是为什么,我作为 DeepEval 的创建者,今天要教你如何构建一个LLM评测框架,来系统性地为你的LLM系统找出最佳超参数。

想仅需一个终端命令就能知道是否该使用新发布的 Claude-3 Opus 模型,或者应该采用哪种提示模板吗?让我们开始吧。

什么是LLM评测框架?

LLM评测框架是一个软件包,旨在根据一系列不同标准评测和测试LLM系统的输出。LLM系统(可能仅指LLM本身)在不同标准下的表现通过LLM评测指标量化,这些指标根据任务需求采用如LLM-as-a-judge 等评分方法。这一过程被称为LLM系统评测。

典型的LLM评测框架架构

这些评测指标的分数最终将构成你的评测结果,你可以用它来识别LLM系统随时间推移出现的回归(有些人称之为回归测试),甚至用于比较不同LLM系统之间的表现。

例如,假设我正在构建一个基于 RAG 的新闻文章摘要聊天机器人,帮助用户快速浏览今日早间新闻。我的LLM评测框架需要包含以下内容:

  1. 一份LLM测试用例列表(或评测数据集),即一组LLM输入-输出对,作为“被评测对象”。
  2. LLM 评测指标,用于量化我希望我的聊天机器人评测标准表现良好的“评测器”。

针对此特定示例,两个适用的指标可以是摘要质量和上下文相关性指标。摘要质量指标将衡量摘要是否相关且无虚构内容,而上下文相关性指标则判断我的 RAG 管道的检索器能否为摘要检索到相关文本片段。

一旦你有了测试用例和必要的LLM评测指标,就能轻松量化LLM系统中不同超参数组合(如模型和提示模板)如何影响评测结果。

看起来很简单,对吧?

构建LLM评测框架的挑战

构建一个稳健的LLM评测框架颇具挑战,这就是为什么我花了 5 个多月的时间来开发 DeepEval,这个开源的LLM评测框架。

通过与数百名开源用户的紧密合作,我们总结出两大主要挑战:

  1. LLM评测指标难以做到精确可靠。 事实上,这一挑战之艰巨,以至于我曾专门撰文详述关于LLM评测指标所需了解的一切。当前大多数LLM评测指标采用LLM-Evals 形式,即利用LLMs来评测LLM输出。尽管LLMs具备卓越的推理能力,使其成为评测LLM输出的理想选择,但它们有时可能不够可靠,必须通过精心设计的提示工程才能提供可信的评分。
  2. 评测数据集/测试用例难以做到全面覆盖。准备一个涵盖生产环境中可能出现的所有边缘案例的评测数据集,是一项既困难又难以完美完成的任务。遗憾的是,这也是一个极其耗时的问题,因此我强烈建议使用LLMs生成合成数据

基于以上考量,接下来我们将逐步讲解如何从零开始构建你自己的LLM评测框架。

分步指南:构建LLM评测框架

1.框架搭建

第一步涉及构建必要的基础设施,以将一个平庸的LLM评测框架提升至卓越水平。任何评测或测试框架都包含两个组成部分:待评测对象(“被评测者”),以及我们用来评测被评测者的工具(“评测者”)。

在此情境下,“被评测对象”是一个LLM测试用例,它包含了供“评测者”LLM评价指标用来给你的LLM系统打分的信息。

更具体地说,一个LLM测试用例应包含如下参数:

  1. 输入:你的LLM系统的输入。请注意,这不包括提示模板,而是字面意义上的用户输入(稍后我们会明白原因)。
  2. 实际输出:你的LLM系统针对给定输入的实际输出。我们称之为“实际”输出是因为…
  3. 预期输出:针对给定输入,你的LLM系统应有的输出结果。例如,数据标注员会在此处为特定输入提供目标答案。
  4. 上下文:针对给定输入无可争议的客观事实。上下文与预期输出常被混淆,因二者在事实上颇为相似。举例说明,上下文可能是包含解答输入问题所需全部信息的原始 PDF 页面,而预期输出则是你希望答案呈现的方式。
  5. 检索上下文:在 RAG 系统中检索到的文本片段。顾名思义,此概念仅适用于 RAG 应用场景。

请注意,对于LLM测试用例,只有输入和实际输出参数是必需的。这是因为某些LLM系统可能仅是一个LLM本身,而其他系统可能是需要诸如检索上下文等参数进行评测的 RAG 管道。

关键在于,不同的LLM架构有不同的需求,但总体上它们非常相似。以下是如何在代码中设置LLM测试用例的方法:

from typing import Optional, List

class LLMTestCase:
    input: str
    actual_output: str
    expected_output: Optional[str] = None
    context: Optional[List[str]] = None
    retrieval_context: Optional[List[str]] = None

记住在运行时检查类型错误!

2. 实施LLM评测指标

假设我们正在构建一个基于 RAG 的客户支持助手,我们需要测试检索器是否能针对一系列不同的用户查询检索到相关的文本片段(即上下文相关性)。在此场景下,我们将实现上下文相关性指标,并利用它来测试广泛的用户查询,为此我们需要准备包含不同输入的多样化测试用例。

假设我们正在开发一款基于RAG架构的智能客服助手,亟需验证其检索器能否针对多样化的用户查询请求精准召回相关文本片段(即上下文相关性指标)。在此场景下,我们需构建覆盖多场景的差异化测试用例集——通过输入参数的多元化配置,系统性评估检索器在复杂用户意图解析与上下文匹配上的鲁棒性。

这是一个示例LLM测试用例:

# ...previous implementation of LLMTestCase class

test_case = LLMTestCase(
  input="How much did I spend this month?"
  actual_output="You spent too much this month.",
  retrieval_context=["..."]
)

我们需要一个能根据输入和检索上下文提供相关性评分的指标。请注意,此处实际输出内容并不重要,因为生成实际输出属于生成器的职责范畴,而非检索器。

我们将使所有指标的评分输出范围在 0 到 1 之间,并附带一个通过阈值,以便在任一指标对测试案例不达标时能及时察觉。我们将采用基于 QAG 的LLM评分器来计算上下文相关性得分,具体算法如下:

  1. 对于检索上下文中的每个节点,使用LLM来判断其是否与给定输入相关。
  2. 统计所有相关与不相关的节点。上下文相关性得分即为相关节点数除以总节点数。
  3. 检查上下文相关性得分是否≥阈值。若是,则标记该指标为通过,并继续下一个测试用例。
# ...previous implementation of LLMTestCase class

class ContextualPrecisionMetric:
  def __init__(self, threshold: float):
    self.threshold = threshold

  def measure(self, test_case: LLMTestCase):
    irrelevant_count = 0
    relevant_count = 0
    for node in test_case.retrieval_context:
      # is_relevant() will be determined by an LLM
      if is_relevant(node, test_case.input):
        relevant_count += 1
      else:
        irrelevant_count += 1

    self.score = relevant_count / (relevant_count + irrelevant_count)
    self.success = self.score >= self.threshold
    return self.score

###################
## Example Usage ##
###################
metric = ContextualPrecisionMetric(threshold=0.6)
metric.measure(test_case)
print(metric.score)
print(metric.success)

上下文相关性可能是最简单的 RAG 检索指标,你会注意到它不出所料地忽略了节点定位/排名等重要因素。这一点很重要,因为相关性更高的节点应在检索上下文中排名更靠前,因为它极大影响最终输出的质量。(实际上,这是由另一个名为上下文精确度的指标来计算的。)

我已为你留下 is_relevant 函数供你实现,但如果你对一个实际案例感兴趣,这里是 DeepEli 对上下文相关性的实现。

3. 实现合成数据生成器

虽然这一步是可选的,但你可能会发现生成合成数据比创建自己的LLM测试用例/评测数据集更为便捷。这并不意味着生成合成数据是一件简单的事情——LLM生成的数据可能听起来和看起来重复,且可能无法准确反映底层数据分布,这就是为什么在 DeepEval 中我们不得不多次改进或复杂化生成的合成数据。

生成合成数据是基于给定上下文生成输入-(预期)输出对的过程。不过,我建议避免使用“平庸”(即非 OpenAI 或 Anthropic)的LLMs来生成预期输出,因为这可能会在数据集中引入虚构的预期输出。

以下是我们创建 EvaluationDataset 类的方法:

class EvaluationDataset:
  def __init__(self, test_cases: List[LLMTestCase]):
    self.test_cases = test_cases

  def generate_synthetic_test_cases(self, contexts: List[List[str]]):
    for context in contexts:
      # generate_input_output_pair() will a function that uses 
      # an LLM to generate input and output based on the given context
      input, expected_output = generate_input_output_pair(context)
      test_case = LLMTestCase(
        input=input, 
        expected_output=expected_output,
        context=context
      )
      self.test_cases.append(test_case)

  def evaluate(self, metric):
    for test_case in self.test_cases:
      metric.measure(test_case)
      print(test_case, metric.score)

###################
## Example Usage ##
###################
metric = ContextualRelevancyMetric()
dataset = EvaluationDataset()
dataset.generate_synthetic_test_cases([["..."], ["..."]])
dataset.evaluate(metric)

再次,我省略了LLM生成部分,因为根据你使用的LLM,可能会有独特的提示模板。但如果你需要快速解决方案,可以借用 DeepEval 的合成数据生成器,它允许你传入整个文档,而非需要自行处理的字符串列表:

pip install deepeval
from deepeval.dataset import EvaluationDataset

dataset = EvaluationDataset()
dataset.generate_goldens_from_docs(
    document_paths=['example_1.txt', 'example_2.docx', 'example_3.pdf'],
    max_goldens_per_document=2
)

为了简单起见,“goldens”和“test cases”在这里可以理解为同一事物,唯一的区别在于 goldens 并不立即准备好进行评测(因为它们没有实际输出)。

4. 速度优化

你会注意到,在 evaluate()方法中,我们使用了一个 for 循环来评测每个测试用例。随着评测数据集中测试用例数量常达数千个,这种做法可能会变得非常缓慢。你需要做的是让每个指标异步运行,这样 for 循环就能在所有测试用例上同时并发执行。但要注意,异步编程可能会变得非常混乱,尤其是在已有事件循环运行的环境中(例如 colab/jupyter notebook 环境),因此妥善处理异步错误至关重要。

回到上下文相关性指标实现,使其异步化:

import asyncio

class ContextualPrecisionMetric:
  # ...previous methods

  ########################
  ### New Async Method ###
  ########################
  async def a_measure(self, test_case: LLMTestCase):
    irrelevant_count = 0
    relevant_count = 0

    # Prepare tasks for asynchronous execution
    for node in test_case.retrieval_context:
        # Here, is_relevant is assumed to be async
        task = asyncio.create_task(is_relevant(node, test_case.input))
        tasks.append(task)

    # Await the tasks and process results as they come in
    for task in asyncio.as_completed(tasks):
        is_relevant_result = await task
        if is_relevant_result:
            relevant_count += 1
        else:
            irrelevant_count += 1

    self.score = relevant_count / (relevant_count + irrelevant_count)
    self.success = self.score >= self.threshold
    return self.score

然后,将评测函数修改为使用异步的 a_measure 方法替代:

import asyncio

class EvaluationDataset:
  # ...previous methods

  def evaluate(self, metric):
      # Define an asynchronous inner function to handle concurrency
      async def evaluate_async():
          tasks = []

          # Schedule a_measure for each test case to run concurrently
          for test_case in self.test_cases:
              task = asyncio.create_task(self.a_measure(test_case, metric))
              tasks.append(task)

          # Wait for all scheduled tasks to complete
          results = await asyncio.gather(*tasks)

          # Process results
          for test_case, result in zip(self.test_cases, results):
              print(test_case, result)

      asyncio.run(evaluate_async())

DeepEval 的用户反馈称,这能将评测时间从数小时缩短至几分钟。如果你希望构建一个可扩展的评测框架,速度优化绝对是不容忽视的关键环节。

5. 缓存结果与错误处理

(从此刻起,我们将逐一讨论每个概念,而非深入具体实现细节)

另一个常见场景是——当你评测一个包含 1000 个测试用例的数据集时,它在第 999 个测试用例上失败了,现在你不得不重新运行 999 个测试用例,只为完成最后一个的评测。这显然不够理想,对吧?

考虑到每次指标运行的成本可能很高,你会希望有一种自动缓存测试用例结果的方法,以便在需要时使用。例如,你可以设计你的LLM评测框架来缓存成功运行的测试用例,并在遇到上述场景时选择性地使用这些缓存。

缓存机制的实现过于复杂,不便在本文中详述。我个人在构建 DeepEval 时,仅这一功能就花费了超过一周的时间。

另一种选择是忽略引发的错误。这要简单得多,因为你只需将每个指标执行包裹在 try-catch 块中即可。但是,如果必须重新运行每个测试用例来执行之前出错的指标,忽略错误又有什么意义呢?

说真的,如果你想要自动化、内存高效的缓存来存储LLM评测结果,直接用 DeepEval 就对了。

6. 记录超参数

LLM评测的终极目标,是找出适用于你的LLM系统的最佳超参数。这里的超参数指的是模型、提示模板等。

为此,你需要将超参数与评测结果关联起来。这部分相对直接,但真正的难点在于如何根据不同超参数组合的筛选条件获取相应的指标结果。

这是一个用户界面问题,DeepEval 通过与 Confident AI 的集成也解决了这一难题。

7. CI/CD 集成

最后,如果LLM评测没有自动化,那么LLM评测框架又有什么用呢?

你需要重构你的LLM评测框架,使其不仅能在笔记本或 Python 脚本中运行,还能适应以单元测试为标准的 CI/CD 流程。幸运的是,在之前实现上下文相关性的部分,我们已经包含了一个可作为“通过”标准的阈值,你可以将其整合到如 Pytest 这样的 CI/CD 测试框架中。

但缓存、忽略错误、重复指标执行以及在 CI/CD 中并行化评测呢?DeepEval 支持所有这些功能,并集成了 Pytest。

结论

在本文中,我们了解了为什么LLM评测很重要,以及如何构建自己的LLM评测框架以优化超参数的最佳组合。然而,我们也认识到从零开始构建LLM评测框架的难度,这些挑战源于合成数据生成、LLM评测指标的准确性与鲁棒性、框架效率等方面。

若你旨在深入理解LLM评估机制的技术内核,自行搭建评估框架无疑是绝佳学习路径。但若你追求开箱即用的企业级解决方案,请直接采用DeepEval——我们已为你完成所有底层技术攻坚。该工具链具备永久免费开源可定制(Apache 2.0协议)特性,集成14+项研究级评估指标,支持与Pytest的CI/CD深度集成,囊括本文所述全部优化技术方案,并配备可视化仪表盘(提供直观的评估结果交互界面)。


声明:本文为本站编辑转载,文章版权归原作者所有。文章内容为作者个人观点,本站只提供转载参考(依行业惯例严格标明出处和作译者),目的在于传递更多专业信息,普惠测试相关从业者,开源分享,推动行业交流和进步。 如涉及作品内容、版权和其它问题,请原作者及时与本站联系(QQ:1017718740),我们将第一时间进行处理。本站拥有对此声明的最终解释权!欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,与我们的编辑和其他窝友交流。
/46 人阅读/0 条评论 发表评论

登录 后发表评论
最新文章