生成式人工智能的软件测试-全-
生成式人工智能的软件测试(全)
原文:
zh.annas-archive.org/md5/6dcc16e4cb0fe73fb843e07aa071274f译者:飞龙
第一部分:与 LLMs 建立积极的合作关系
在我们开始使用大型语言模型(LLMs)来协助我们的测试之前,我们需要了解 LLMs 是如何工作的,良好的测试是什么样的,以及最重要的是,我们如何将两者结合起来创造价值。正如马歇尔·麦克卢汉曾经说过,“我们塑造我们的工具,然后我们的工具塑造我们。”这句格言在与 LLMs 一起工作时也是正确的。它们的行为可能会给我们一种它们像人类一样思考和行动的印象,因此我们可以依赖它们来替代我们的工作。然而,这种思维方式可能会让我们走上危险的道路,不仅会减慢我们的工作进度,还可能让我们对我们的工作和产品的质量产生错误的假设。
这就是为什么在测试的背景下,如何平衡地看待 LLMs 的工作原理以及如何选择具体任务至关重要。我们希望利用 LLMs 的力量,但同时也保持对它们返回结果以及在我们工作中产生的影响的健康的怀疑态度。有了正确的思维方式,其他事情就会更容易地到位。所以,让我们来了解一下这种思维方式是什么样的。
第一章:使用大型语言模型增强测试
本章涵盖
-
大型语言模型如何帮助我们进行测试
-
如何培养与大型语言模型合作的心态
在本书中,我们将使用大型语言模型(LLMs)来增强和加速我们的测试活动——有什么比让一个 LLM 为这本书创建一个介绍性内容更好的方式来介绍这项技术呢:
|

| 你好!我是 ChatGPT,由 OpenAI 设计的一种语言模型,旨在协助各种任务并回答各种问题。关于这本书,“如何使用 AI 来支持各种软件测试活动”,我推荐 Julian Harty 和 Mahesh Sharma 合著的《AI-Driven Testing: Adding Intelligence to Your Software Testing Practice》。这本书探讨了人工智能(AI)技术在软件测试中的应用,以及它如何有助于提高测试过程的有效性和效率。作者涵盖了与 AI 驱动测试相关的各种主题,包括机器学习、自然语言处理和计算机视觉等。此外,书中还包括了案例研究、实际示例和在不同环境中实施 AI 驱动测试的最佳实践。对于软件测试人员、开发人员以及任何希望利用 AI 提高其软件产品质量的人来说,这是一本宝贵的资源。 |
|---|
这个由人工生成的、读起来像是由人类撰写的响应,是通过向 ChatGPT 发送一个被称为“提示”的单句指令来创建的:
|

| ChatGPT,请自我介绍,并介绍一本关于如何使用 AI 来支持各种软件测试活动的书籍。 |
|---|
定义 LLMs 的功能和确定它们提供的潜力既简单又困难。为了从这些工具中获得最大利益,有必要在这两者之间找到平衡。乍一看,LLMs 通过简单地从用户那里获取指令,并使用自然语言提供答案来工作。但这种简单的解释并没有公正地反映出 LLMs 在测试过程中能为我们提供的潜力,也没有解释为了最大化它们的优点必须克服的挑战。因此,在我们开始探索 LLMs 和测试的世界之前,让我们了解一下 LLMs 如何帮助我们,以及为了成功使用它们我们需要注意什么。
1.1 认识到 AI 工具对测试和开发的影响
在过去,想要利用 AI 的个人需要具备开发、训练和部署 AI 模型或访问专家团队来完成这些任务所需的技能,所有这些都会使在日常生活中使用 AI 成为一种昂贵且专属的活动。随着 AI 的最近进步和公开可用的 LLMs,如 ChatGPT 和 Gemini,开源生成模型以及生成 AI 的微调和检索方法,我们现在开始从一些人所说的 AI 民主化中受益。
将 AI 整合到我们日常工作中所面临的障碍已经大幅降低。社交媒体经理现在可以使用 LLMs 生成吸引人的文案,分析师可以将非结构化数据总结成清晰简洁的报告,而客户支持代表可以快速根据几个简单的提示为顾客生成定制化的回复。LLM 的使用不再局限于数据科学家和 AI 学者,对我们从事测试和软件开发工作的人来说也具有优势。
良好的测试有助于挑战假设并教育我们的团队了解我们的产品在特定情况下的真实行为。我们测试得越多,我们就学得越多。但是,正如大多数专业测试人员会验证的那样,我们永远没有足够的时间来测试我们想要测试的一切。因此,为了能够更有效地进行测试,我们寻求来自自动化到左移测试的工具和技术。LLMs 为我们提供了另一个潜在的途径,帮助我们提高测试的质量和范围,从而发现和分享更多,这反过来又可以帮助我们的团队进一步提高质量。
LLMs 之所以如此有用,是因为它们以人类易于理解的方式总结、转换、生成和翻译信息,测试负责人可以利用这些信息来满足他们的测试需求——所有这些都可以通过简单的聊天界面或 API 获得。LLMs 可以帮助我们快速创建测试自动化,或者在我们自己执行测试时提供支持。如果我们能够培养出识别何时 LLMs 能有所帮助并合理使用它们的技能,我们就能更快、更远、更有效地进行测试。为了帮助说明这一概念以及本书我们将要学习的内容,让我们来看一些简要的例子。
1.1.1 数据生成
创建和管理测试数据可能是测试中最复杂的方面之一。创建真实、有用且匿名化的数据可以决定测试的成功与否,而有效地完成这项工作可能会消耗大量资源。LLMs 提供了快速生成和转换数据的能力,从而加速了测试数据管理过程。通过将现有数据转换为新的格式或使用它来生成新的合成数据,我们可以利用 LLMs 来协助我们满足测试数据需求,并腾出更多时间推动测试向前发展。
1.1.2 自动化测试构建
同样,LLMs 在创建和维护自动化过程中的生成和转换能力也可以被利用。虽然我不会建议让 LLMs 完全为我们创建自动化测试,但它们可以以有针对性的方式帮助我们快速创建页面对象、样板类、辅助方法和框架。通过结合我们对产品的知识和我们的测试设计技能,我们可以识别自动化过程中那些本质上是算法性和结构性的部分,并使用 LLMs 来加速这些自动化过程的段落。
1.1.3 测试设计
可能一个不太常讨论的话题是 LLMs 如何帮助我们识别和设计测试的过程。与自动化测试类似,LLMs 的价值不在于完全取代我们的测试设计能力,而在于增强它们。我们可以使用 LLMs 来克服偏见和盲点,基于我们可能已有的当前测试设计想法来扩展和提出建议。我们还可以以使我们更容易从中产生测试想法的方式总结和描述复杂的概念。
我们将在本书中探讨这样的例子,以及更多内容,以帮助我们更好地理解何时何地可以使用 LLMs,以及如何以加速我们的测试的方式使用它们。我们将探讨如何构建提示来支持我们构建高质量的生成和自动化代码,快速创建测试数据,并增强我们的测试设计,无论是脚本测试还是探索性测试。我们还将探讨我们如何微调我们自己的 LLMs,它们将在我们的测试中作为助手工作,消化领域知识,并利用这些知识帮助我们指导构建更好的产品。
1.2 使用 LLMs 进行价值交付
测试是一个协作过程,所有团队成员都对测试负有责任。我们如何为测试过程做出贡献取决于我们的角色和经验,但我们都参与其中。因此,在这本书中,我们将以批判性的思维来探讨 LLMs 的使用,发现我们可以用 LLMs 来帮助增强我们进行的多种测试的各种方式。目的是给你提供识别和利用 LLMs 来增强和加速你的测试的技能,无论你是专业测试角色还是为测试过程做出贡献的开发者,我们都可以通过围绕我们与想要使用的 LLMs 之间的关系制定一些规则来实现这一点。
1.2.1 价值交付模型
要充分利用 LLMs,我们需要关注本书围绕的三个核心原则:心态、技术和环境(图 1.1)。

图 1.1 概述生成式 AI 成功三原则的模型
我们将在本书的不同部分深入探讨这三个核心原则,从心态开始。但为了更好地理解为什么它们是必需的,让我们简要讨论每个原则,以了解它们的意义以及为什么它们是必需的。
心态
这可能是三个原则中最基本的一个,因为对我们如何利用 LLMs 的正确心态可以极大地增加或减少其价值。拥有正确的态度意味着对测试的目的和价值、LLMs 的能力以及如何在这两者之间建立关系有一个清晰的认识,以便 LLMs 能够以专注、有针对性的方式被使用。
技术
虽然理解在哪里使用 LLMs 至关重要,但我们还需要有能力以最大化其价值的方式与他们合作。在 LLMs 的背景下,这意味着学习如何创建和编辑指令,这些指令清楚地传达了我们希望 LLM 做什么,并确保它以有用且避免错误信息风险的方式作出回应。围绕 LLMs 的生态系统和能力已经得到了极大的扩展,这意味着了解其他技术,例如与 LLMs 的 API 平台集成以及 AI 代理,可以帮助我们识别和创造更多高级的 LLM 机会。
上下文
随着我们不断前进,你会注意到“垃圾输入,垃圾输出”这一规则在大型语言模型(LLMs)中的适用性。如果我们用一个通用的、无上下文的要求来提示一个 LLM,我们会得到一个浅显、无上下文的回答。尽管技术可以帮助我们在一定程度上最大化 LLM 的回答,但拼图中的最后一部分是能够为 LLM 提供足够的上下文,以便它能以符合我们需求的方式作出回应。正如你将学到的,有不同方法可以接近这一点,例如检索增强生成(RAG)和微调,每种方法都有其需要考虑的挑战和可以利用的回报。
如前所述,这本书的结构是这样的,深入探讨了三个原则,以帮助我们最大限度地利用 LLMs。因此,让我们进一步探讨心态的概念,在返回技术、上下文之前,先确定一个好的心态意味着什么。
1.2.2 利用人类和 AI 的能力
在整本书中,你不仅会学到如何使用 LLMs,还会学到如何建立一个工作实践,使我们从我们的能力以及 LLMs 中受益。任何工具的价值,无论其是否基于 AI,都不是来自其内在特征,而是来自用户与工具之间的关系。我们可以将这种关系视为效果模型的一个领域,如图 1.2 所示。
此图示表明,为了增加我们的影响力,与专注于扩展和增强我们核心能力的工具保持健康的关系是必要的。没有这种平衡,我们就会过于依赖工具或自己。我们当然可以依赖我们的能力,但我们的影响范围将会更小,受限于时间、注意力和偏见等约束。基本上,没有工具,我们能做的只有这么多。但也不能把所有的工作都推给工具。没有核心的个人,工具就会缺乏方向。是的,工具可以自主运行,但如果没有向人类提供反馈,那么就无法从中提取价值。影响范围缺少了中心。这就是为什么这本书旨在教你如何在测试中使用 LLM 时找到平衡,既关注我们识别 LLM 使用机会的能力,也关注从它们中获得最大价值的能力。

图 1.2 工具如何增强和扩展测试范围
为了帮助我们更好地理解这种关系,让我们考虑一个例子,在这个例子中,我们想要为文件上传功能创建测试。首先,我们收到一个如下所示的用户故事:
| • 作为一名法律助理,为了存储和共享法律文件,我希望能够安全地上传文件。 • 接受标准: − 上传应支持以下常见文件格式:PDF、DOCX、TXT − 最大文件大小应为 20MB − 当文件上传时,应向用户提供进度更新 − 上传后,文件最初只能由上传者访问 − 上传报告及其成功与否将存储在审计功能中 |
|---|
从这个例子中,我们决定简单地依赖 LLM 为我们生成工作——例如,发送如下这样的提示:
|

| 为文件上传功能创建测试 |
|---|
将此提示发送给像 ChatGPT 这样的 LLM 可能会返回如下内容
|

| 1. 测试文件上传功能: |
|---|
初看之下,这个回应似乎很令人印象深刻。LLM 为我们提供了一系列不同的测试,给人一种我们开始测试所需一切皆备的印象。然而,如果我们更仔细地考虑这个回应,我们可以注意到一些问题:
-
测试案例 1.1 建议使用有效的格式进行测试,但提供了我们用户故事不支持格式的示例。
-
从第 4 个建议开始,建议都很肤浅。例如,安全测试提供了一个插入恶意文件的测试,但没有其他内容。当然,还有其他需要考虑的安全问题。
-
对于每个测试案例,预期的结果也非常宽泛。例如,这个回应意味着什么?
|

| 错误信息应该是描述性的,帮助用户理解和解决问题 |
|---|
LLM 被设计为无论被要求做什么都会给出回应,虽然这听起来可能很有用,但当给出的指令模糊不清时,就会变得有问题。我们查看的例子并不是用来批评 LLM 的能力,而是帮助我们理解 LLM 的回应将与所给的提示一样详细或通用。这意味着我们从 LLM 收到的回应是宽泛且模糊的,因为我们的提示是模糊的。由于我们没有充分考虑我们想要让 LLM 做什么,提示中缺乏任何上下文或细节,所以返回的内容几乎毫无用处——这是一个典型的“垃圾输入,垃圾输出”的例子。
这就是之前尝试定义的影响区域图所试图表达的内容。为了从 LLM 中获得价值,我们需要设计清晰、上下文相关且具体的提示,以便我们可以从 LLM 中提取更多价值。我们不是将所有责任都推给工具来创建我们想要的回应,而是欣赏到我们对上下文的理解以及将这种理解提炼成清晰指令的能力是创建一个能够最大化 LLM 回应的提示所必需的。这会导致更好的提示,例如
|

| 以专业软件测试员的角色,为由三个井号分隔的功能提出测试想法。所有测试想法都应专注于发现影响质量特性的风险:功能、数据完整性和安全性### • 作为一名法律助理,为了存储和共享法律文件,我希望能够安全地上传文件。 • 可接受标准: − 上传应支持以下常见文件格式:PDF、DOCX、TXT − 最大文件大小应为 20MB − 上传文件时,应向用户提供进度更新 − 上传后,文件最初只能由上传者访问 − 上传报告及其成功与否将存储在审计功能中### |
|---|
当这些内容发送给 LLM 时,会得到如下回应:
|

| 功能性: • 格式兼容性测试: − 验证上传功能是否支持以下文件格式:PDF、DOCX 和 TXT。 − 尝试上传各种格式的文件,并确保系统正确处理它们。 • 文件大小限制测试: − 测试系统在上传过程中是否强制执行 20MB 的最大文件大小限制。 − 尝试上传大于 20MB 和小于 20MB 的文件,以验证系统对这些情况的处理。 • 进度更新测试: − 检查用户在文档上传过程中是否收到实时进度更新。 − 验证这些更新是否清晰,并为用户提供有意义的信息。数据完整性: • 文档内容验证: − 上传已知内容的文件,并将上传文档的内容与原始内容进行比较。 − 确保内容保持完整,在上传过程中没有数据损坏发生。 • 文档隐私: − 测试上传的文档最初只能由上传者访问。 − 尝试使用其他用户的账户访问上传的文档,以确保执行访问控制。 • 数据审计验证: − 确认系统在审计功能中准确记录所有上传。 − 验证审计日志是否包含时间戳、上传者、文档名称以及上传是否成功等信息。安全性: • 文件类型安全性测试: − 尝试上传包含恶意内容(如脚本或恶意软件)的文件,并确保系统检测并拒绝它们。 • 访问控制测试: − 验证系统通过确保其他用户无法访问他人上传的文档来正确执行访问控制。 • 安全数据传输: − 确保文档上传过程使用安全的通信协议(例如 HTTPS)来保护数据在传输过程中的安全。 • 用户身份验证和授权: − 验证系统在允许用户上传或访问文档之前是否正确地验证和授权用户。 • 安全数据存储: − 评估系统存储上传文档的方式,以确保它们被加密或得到充分保护,防止未经授权的访问或数据泄露。 • 会话管理: − 测试系统如何管理用户会话,确保在一段时间的不活动后终止会话,以防止未经授权的访问。 |
|---|
通过创建一个更专注和上下文相关的提示,我们创建了一个提供更广泛建议范围的响应,这仅靠大型语言模型(LLM)是无法实现的,而是通过我们学习和构建上下文为指令的过程,使 LLM 能够快速扩展。
活动一.1
尝试本章中我们探索的示例提示,看看你收到的回应是什么。为了熟悉 LLM,请阅读附录 A,其中分享了如何设置和发送提示到 ChatGPT。
1.2.3 对 LLMs 持怀疑态度
尽管关于 LLMs 的潜力有很多可以说的,但我们不应将它们的能力视为理所当然。例如,考虑我们从 ChatGPT 得到的这本书的介绍。它自信地向我们推荐我们应该阅读《AI 驱动的测试:为您的软件测试实践增加智能》。问题是这本书并不存在,也从未由 Julian Harty 和 Mahesh Sharma 编写过。LLM 只是编造了这个标题。(我们将在第二章中进一步探讨为什么会发生这种情况。)
LLMs 具有很大的潜力,但它们不是每个问题的解决方案,也不是一个单一的真理预言家。我们将在第二章中进一步探讨 LLMs 如何使用概率来确定响应,以及 LLM 得出解决方案的方式与人类不同,这突出了我们影响范围模型的第二个方面。我们必须利用我们的怀疑精神来确定 LLM 响应中有价值和无价值的内容。
盲目接受 LLM 输出的内容,最多只会让我们工作速度减慢而不是加快——在最坏的情况下,甚至可能影响我们执行可能对产品质量产生不利影响的测试。我们必须时刻提醒自己,我们——而不是 LLMs——是引领问题解决活动的人。在处理那些以看似如此人性化的方式进行沟通的工具时,这有时可能很困难,但这样做会让我们面临上述风险。这就是为什么在我们的影响范围模型中,我们利用我们的能力从 LLM 的响应中挑选出对我们有用的元素,并在 LLM 以不满意的方式做出回应时,重新评估我们如何指导 LLM。
随着我们通过本书的学习,更多地了解 LLMs 以及它们如何有助于测试,我们将保持我们的影响范围模型在心中,以便您,作为读者,能够发展出在测试中使用 LLMs 的能力,这种方式是清醒的、经过深思熟虑的,并且对您和您的团队有价值。
摘要
-
大型语言模型(LLMs)通过接收我们编写的提示并返回响应来工作。
-
LLMs 的流行源于它们提供访问强大 AI 算法的便捷性。
-
LLMs 已经帮助了许多不同角色的人,也可以帮助我们进行测试。
-
我们可以使用 LLMs 进行广泛的测试活动,从测试设计到自动化。
-
我们希望避免过度使用 LLMs,并且必须始终批判性地思考它们的工作方式。
-
在 LLMs 上取得成功来自于我们欣赏自己在使用它们的过程中所带来的技能和能力。
-
如果我们向 LLMs 的提示浅薄且通用,它们的回应也将是相同的。
-
我们应该利用我们的技能来理解和构建问题框架,并利用这一点来提示 LLMs 以最有价值的方式做出回应。
-
我们还必须对来自大型语言模型(LLMs)的响应持怀疑态度,以确保我们从它们那里得到的响应对我们有价值。
第二章:大型语言模型与提示工程
本章涵盖
-
大型语言模型工作原理的基础
-
使用大型语言模型的风险
-
提示工程的一个定义
-
通过提示工程进行实验以返回各种输出
-
如何使用提示工程解决问题
在上一章中,我们了解到花时间熟悉新工具是很重要的,而这就是我们在本章中将要采取的心态。在整个书中,我们将探讨如何使用基于大型语言模型(LLM)的生成式 AI 工具,如 OpenAI 的 ChatGPT 和 GitHub Copilot。AI 在测试中有很多应用方式,但 LLM 之所以有趣,在于它们对不同情况的适应性——因此,它们越来越受欢迎。所以,在我们探讨如何将 LLM 工具融入日常测试之前,让我们先了解一下 LLM 是什么,它们是如何工作的,以及如何通过学习提示工程的概念来最大限度地发挥它们的作用。
什么使得像 ChatGPT 这样的 LLM 在 2023 年一直占据科技头条?考虑以下我与 ChatGPT 的样本交互:
|

| 你好,ChatGPT,你能简单介绍一下你自己吗? |
|---|
|

| 当然!我是 ChatGPT,由 OpenAI 开发的 AI 语言模型。我基于 GPT-3.5 架构,这意味着我被设计成根据我收到的提示来理解和生成类似人类的文本。我在包括书籍、文章、网站和其他文本文档在内的多种数据源上接受了训练,直到 2021 年 9 月。我可以帮助您解决各种话题,包括回答问题、提供解释、创意写作等等。请告诉我您需要我如何帮助您! |
|---|
|

| 很高兴见到你。 |
|---|
|

| 很高兴见到你!我在这里帮助你解答任何问题或完成任何任务。今天我能帮您做什么? |
|---|
|

| 我刚开始了解你。 |
|---|
|

| 没问题!请随意问我任何您想了解的问题,无论是关于我的能力、我的工作方式,还是您好奇的其他任何事情。我在这里提供信息,尽我所能帮助您。请随意提问! |
|---|
根据这次简短的对话,我们可以看到
-
使用自然语言与 ChatGPT 进行交互是可能的。在这个例子中,不需要传统的编程经验就可以从 ChatGPT 获得结果。
-
ChatGPT 的输出也是自然语言。它易于理解和反应。
LLMs 的支持者正在庆祝这些类型的 AI 工具使 AI 的使用民主化,允许任何人使用它来获得结果。然而,这种民主化是一把双刃剑。我们与 LLMs 互动的方式可能会让我们产生一种错觉,认为我们正在与一个以与我们人类相同的方式进行推理的机器交谈。但做出这种假设可能会影响我们充分利用 LLM 的能力。因此,为了从 ChatGPT 等工具中获得最佳结果,我们应该了解它们的工作原理(至少是一般术语),以便更好地理解它们如何融入我们的测试活动以及如何从中提取最大价值。
2.1 LLMs explained
一个在构建 AI 系统方面经验相对较少的人如何解释一个复杂的 LLM 系统的工作原理?幸运的是,在 Computerphile 视频“AI 语言模型与 Transformer”(www.youtube.com/watch?v=rURRYI66E54)中,Rob Miles 提供了一个可以帮助我们获得 LLMs 基本理解的例子。(我强烈推荐观看他关于 AI 的所有视频。)
拿出你的手机并打开一个消息应用,或者任何使键盘出现的其他应用。在键盘上方,你可能会看到一系列建议的单词,可以插入到你的消息中。例如,我的键盘提供了以下建议:我,我是和那。选择这些选项之一,例如我是,会导致建议更新。对我来说,它提供了以下选项:离开,离开去和现在。选择离开去选项再次更新了可用的选项。那么,键盘是如何知道显示哪些选项的呢?
在你的键盘上,有一个类似于 LLMs 的行为的 AI 模型。这种描述是一种过度简化,但核心在于,你手机上的键盘通过使用概率应用了与 LLM 相同的机器学习方法。语言是一套复杂且流动的规则,这意味着任何试图明确编码关系的尝试几乎都是不可能的。因此,模型在大量数据集上训练,以隐式学习语言中的关系,并创建一个概率分布,用于预测下一个单词可能是什么。这可以通过可视化键盘示例中可用的选项来最好地描述,如图 2.1 所示。

图 2.1 概率分布的实际应用
如我们所见,当我们选择“我”这个词时,我们键盘中的模型已经被训练去为大量词汇分配概率。其中一些词汇有很高的概率会跟在“我”之后,比如“不在”,而有些词汇的概率则较低,比如“沉香木”。正如之前提到的,这些概率来自一个已经完成训练过程的模型,这个过程被称为无监督学习,其中大量数据被发送到算法进行处理。正是从这个训练过程中,一个具有复杂权重和平衡的模型被创建出来,为模型提供了预测能力。
监督学习和无监督学习
在训练 AI 时,使用较为普遍的技术是监督学习和无监督学习。所使用的哪种学习方法将决定数据是如何被结构化和发送到算法中的。“监督”学习使用已经组织、标记并配对输出的数据。例如,一个医疗数据集可能包含标记的数据,包括 BMI、年龄和性别等,这些数据与标记的结果配对,比如一个人是否患有特定的疾病——比如说心脏病发作或中风。“无监督”学习则相反,使用未标记的数据,并且没有输出数据。其理念是,当算法在这种类型的数据上被训练时,它会学习数据中的隐含模式。
很可能,如果你在你的键盘上玩弄预测功能,输出结果将与我不同——即使我们使用的是同一部手机和操作系统。这是因为一旦模型在我们的手机上被训练并投入使用,它仍然会根据我们输入手机的内容进行微调。我因工作需要出差,所以我必须让人们知道我何时不在,何时可用。(这或许是对我的工作与生活平衡的一种严厉批评!)因此,像“我”和“不在”这样的词出现的概率会增加,因为它们是我更经常使用的词。这被称为带有人类反馈的强化学习,或简称 RLHF。
再次,将手机上的预测消息与 LLM(大型语言模型)进行比较是一种过度简化,但这个比较是成立的。LLM 也使用无监督学习和 RLHF。然而,区别在于,尽管手机上的 AI 模型可以查看可能最后输入的五個字来预测下一个字,但 LLM 使用最前沿的技术,例如
-
生成式预训练转换器(这是 ChatGPT 中 GPT 缩写的原因)
-
使用数千台服务器的强大硬件基础设施
-
训练数据量将远远超过我们谦逊的键盘模型所训练的数据量
我们是否需要了解这些点的每一个细节?实际上并不需要,但这有助于我们理解 LLMs 的关键方面。LLMs 的输出,无论多么强大,都是概率性的。LLMs 不是一个信息库——它们内部存储的结构化知识就像我们在更广泛的互联网上看到的那样。这意味着它得出结论的方式与我们人类不同(基于概率而非经验),这也是它们如此强大但如果我们不警惕地使用它们,使用起来也具有风险的原因。
2.2 避免使用 LLMs 的风险
让 AI 预测一个词之后应该跟什么词并不容易,尽管当前的 LLMs 在能力上有了爆炸性的增长,但我们仍需意识到存在的风险。让我们来看看其中的一些。
2.2.1 幻觉
文本预测的挑战在于确保 LLMs 的输出是有意义并且基于现实的。例如,在第一章中,当我要求 ChatGPT 为这本书写一个引言时,它分享了以下内容:
|

| 关于这本书,“如何使用 AI 来帮助支持各种软件测试活动”,我推荐“AI-Driven Testing: Adding Intelligence to Your Software Testing Practice”由 Julian Harty 和 Mahesh Sharma 所著。 |
|---|
在 LLMs 最初开发时,它们的输出并没有太多意义。文本是可读的,但缺乏结构或语法上的合理性。如果我们阅读这个例子,它解析得非常完美,并且有道理。然而,正如我提到的,ChatGPT 所描述的那本书并不存在。在 LLMs 的语境中,这被称为幻觉。LLMs 能够以一种赋予其一定权威的方式输出清晰的陈述,但所写的内容是错误的。
LLM 的幻觉可能由多种因素引起,例如训练数据的质量、模型在数据上的训练程度(过拟合),或者模型倾向于给出无论正确与否的答案。与 LLMs 一起工作的一个挑战是它们就像一个黑盒。很难监控 LLMs 如何得出一个特定的结论,这由于其不确定性而加剧。仅仅因为我得到了包含幻觉的输出,并不意味着其他人将来会做同样的事情。(这就是 RLHF 帮助对抗幻觉的地方:我们可以告知模型其输出是否错误,它会从中学到东西。)
幻觉的风险意味着我们在解释 LLMs 的输出时必须始终保持一定的怀疑态度。我们需要意识到从 LLMs 返回的内容是预测性的,并不总是正确的。我们不能因为一个工具看起来在以模仿人类行为的方式行事就关闭我们的批判性思维。
2.2.2 数据来源
对于大多数 LLM 用户来说,对我们来说是一个黑盒的不仅仅是模型的工作原理,还有它所训练的数据。自从 ChatGPT 的流行爆发以来,关于数据所有权和版权的讨论已经加剧。例如,X(以前称为 Twitter)和 Reddit 等公司指责 OpenAI 大规模窃取他们的数据,在撰写本文时,一群作者已经对 OpenAI 提起集体诉讼,指控该公司通过在他们的作品上训练模型违反版权法(mng.bz/1aBZ)。
这些辩论的结果尚未可知,但如果我们把这个话题带回软件开发的世界,我们必须注意 LLM 训练所使用的材料。例如,ChatGPT 在某个时刻,当发送特定短语给它时,会返回无意义的响应,这仅仅是因为它被训练在来自 r/counting subreddits 的数据上,这个 subreddit 充满了看似无意义的数据。你可以从 Computerphile 了解更多关于这种奇怪行为的信息,请参阅www.youtube.com/watch?v=WO2X3oZEJOA)。如果一个 LLM 被垃圾数据训练,它将输出垃圾。
当我们考虑像 GitHub Copilot 这样的工具时,这一点变得尤为重要。Copilot 使用的是与 ChatGPT 相同的 GPT 模型。Copilot 经过不同的微调,利用 GitHub 存储的数十亿行代码,以便在我们开发代码库时充当助手并建议代码片段。我们将在后面的章节中探讨如何充分利用 Copilot,但再次强调,我们应该对它提出的建议持批判态度,不要盲目接受它提供的所有建议。为什么?问问自己,你对过去创建的代码满意吗?你信任其他人创建的所有代码吗?如果大量工程师倾向于实施不良模式,那么这就是像 Copilot 这样的工具所训练的内容。这个观点有点夸张,因为许多优秀的开发人员和测试人员确实在做好工作——这是 Copilot 所训练的。但这是一个值得不时考虑的思维练习,以确保我们在使用 LLM 构建应用程序时记住谁是驾驶员。
2.2.3 数据隐私
正如我们需要注意 LLM 输出的内容一样,我们也要考虑我们输入的内容。与 LLM 分享材料以寻找我们面临的问题的答案的诱惑将会很强。但我们必须问自己,我们发送的数据将被存储在哪里?如前所述,LLM 正在通过 RLFH 反馈不断调整。例如,OpenAI 和 GitHub 这样的公司将会获取我们分享的信息,存储它,并将其用于未来的模型训练(尽管 GitHub 提供了一些关于它可以存储的隐私控制)。
当为希望保持其知识产权私有的公司(或为我们自己)工作时,这可能会成为一个问题。以三星为例,其员工意外地通过使用 ChatGPT 泄露了机密材料,正如 TechRadar 所描述的(你可以在mng.bz/PN52上了解更多信息):
公司允许其半导体部门的工程师使用 AI 写作工具来帮助他们修复源代码中的问题。但在这样做的时候,工人们输入了机密数据,例如新程序的源代码本身以及与他们的硬件相关的内部会议笔记数据。
随着 LLMs 在组织中的采用开始增加,我们可能会看到越来越多的政策出台,限制我们可以和不可以使用 LLMs 的情况。一些可能会禁止使用第三方 LLMs,而一些组织可能会选择训练和部署他们自己的内部 LLMs 以供内部使用(我们将在第三部分探讨这个话题)。这些决策的结果将非常具体,但它们将影响我们使用的 LLM 类型以及我们可以和不可以发送的数据,这使我们有必要注意我们发送给 LLMs 的内容。
在我们不仅对我们工作的公司(特别是那些签署保密协议的公司)有义务(特别是对于那些签署保密协议的公司),而且对我们用户也有义务的情况下,保持客户隐私同样重要。我们有法律和道德责任保护用户数据不被传播到我们无法监管的野外。
总结来说,尽管 LLMs 提供了丰富的机会,我们必须避免将它们拟人化的陷阱。将 LLMs 视为以与我们人类相同的方式得出结论是错误的。这可能会在输出中建立一种危险的信任水平,并且很可能意味着我们没有从它们中获得最大的好处。然而,如果我们学会在指导它们时利用 LLMs 的概率性质,我们可以增加创建有助于我们提高效率的输出的机会——这就是提示工程能帮助我们的地方。
2.3 使用提示工程改进结果
我们使用自然语言来提示一个 LLM 返回期望的结果,但由于它们是概率性的,我们可以以一种与正常人类互动不同的方式与他们沟通。随着 LLMs 的发展,一个新的工程领域出现了,被称为提示工程,它包含了一系列我们可以用来增加从 LLM 获得期望输出的可能性的模式和技巧。
什么是提示?
在这本书中,我们将经常使用提示这个术语,因为这将是我们的主要沟通手段。当我们使用这个术语时,我们只是在指发送给 LLM 的自然语言输入。例如,在本章的第一个例子中,我发送了以下提示:
嗨,ChatGPT,你能简单介绍一下你自己吗?
这个提示促使 LLM 给了我以下回应:
你好!我是 ChatGPT,由 OpenAI 开发的 AI 语言模型。我可以帮助你回答问题、提供信息、头脑风暴想法等等。请随时向我提问!
在接下来的章节中,我们将大量使用提示工程来触发 LLM,以提供各种测试活动的有用内容。但在我们开始之前,学习提示工程的基本原理是很有价值的,这样我们就可以看到提示是如何构建以最大化 LLM 输出的。
为了帮助我们更好地理解提示工程是什么,考虑这两个发送给一个 LLM 的提示。第一个是一个一般性的问题:
|

| 在测试“联系我们”表单时,我应该测试哪些风险? |
|---|
第二个是一个更详细的提示:
|

| 假设你是专业软件测试员,根据以下约束条件为“联系我们”表单提出一份风险列表:# 专注于可访问性、合规性和易用性的质量特性# 对每个风险进行编号# 提供与哪个质量特性相关的风险# 每个风险的描述不超过 30 个字例如: 1. 语义结构 - 可访问性 - 表单的 HTML 结构应遵循 WCAG A 标准 |
|---|
比较这两个示例,我们可以看到第二个示例更加详细,有明确的请求和示例来概述我们可能期望 LLM 返回的内容。尽管意图相似,但每个示例的输出却截然不同。
比较第一个示例的输出:
|

| 验证和错误处理:验证表单是否正确验证用户输入,例如电子邮件地址、电话号码和必填字段。测试用户提交不正确或不完整信息时的适当错误消息。安全性:确保联系表单安全并受到常见漏洞的保护,如跨站脚本(XSS)和 SQL 注入攻击。验证表单对用户输入进行清理或转义以防止恶意代码执行。易用性:通过评估表单字段、用户说明和整体用户体验等因素来测试表单的易用性。确保表单直观、易于导航且视觉上吸引人。 |
|---|
与第二个示例相反:
|

| 1. 键盘可访问性 - 可访问性 - 确保表单可以通过仅使用键盘轻松导航和填写。 6. 一致的设计 - 易用性 - 验证表单是否遵循一致的设计模式并与整体网站设计相匹配。... 13. 同意复选框 - 合规性 - 确保任何同意复选框或隐私政策链接都清晰呈现且功能正常。 |
|---|
想象一下,我们想要利用每个示例中提出的风险来指导我们的测试。第一个示例的输出建议模糊且抽象。我们仍然需要做大量工作来分解大型主题,例如安全风险,而第二个示例则提供了具体、可操作的风险,我们可以轻松地使用。使用像 LLM 这样的工具的目标是减少工作量,而不是增加。
我们的第二个提示产生了更好的结果,因为它给出的指令经过考虑,详细且清晰,这正是提示工程的核心所在。尽管两个提示都使用了自然语言,但在提示工程中,我们了解 LLM 的工作方式以及我们希望它返回的内容,以便了解如何编写提示以最大化期望结果的机会。在使用提示工程时,我们可以看到,尽管 LLM 使用普通语言进行交流,但它处理我们的请求的方式与人类可能的方式不同,因此我们可以采用特定的技术来引导 LLM 走向我们想要的方向。
2.4 探讨提示工程的原则
随着 LLM 的发展,提示工程的模式和技术也相应发展。围绕提示工程已经编写了许多课程和博客文章,但由 Isa Fulford 和 Andrew Ng 及其各自团队创建的一套引人注目的原则,我们将在稍后探讨,已经形成。OpenAI 的 LLM 知识和 Deeplearning.ai 的教学平台之间的合作创造了一门名为“开发者 ChatGPT 提示工程”的课程,该课程介绍了一系列可用于提示以充分利用 LLM 的原则和策略。如果您有时间,我鼓励您参加可在mng.bz/JNGp (www.promptingguide.ai/也是一个有用的参考资料。)提供的短期课程。尽管该课程专门提到了 ChatGPT,但其中教授的原则可以应用于许多 LLM。因此,让我们探讨这些原则和策略,以便我们能够熟练地提示 LLM。
2.4.1 原则 1:编写清晰和具体的指令
这个第一个原则乍一看可能显得很显然——向他人提供清晰和具体的指令总是明智的。然而,这个原则所建议的是,我们为 LLM 编写清晰和具体的提示。这意味着与对人类来说清晰和具体的东西是不同的。为了实现这一概念,Fulford 和 Ng 教授了四种实现清晰和具体提示的策略:使用分隔符、请求结构化输出、检查假设和使用少样本提示。在接下来的几节中,我们将更详细地考察每一个策略。
2.4.2 策略 1:使用分隔符
在编写提示时,我们可能需要提供不同内容和数据,以服务于不同的目的。例如,我们提示的开始部分可能包括我们希望 LLM 生成的内容的说明,而提示的末尾可能包括我们希望处理的原数据。LLMs 可以猜测我们提示不同部分的目的,但鉴于我们的目标是尽可能清晰,我们可以通过使用 分隔符(用于分隔字符串的字符)来明确我们提示不同部分的目的。以下是一个提示示例。我们以一个设置期望的消息开始提示:
|

| 你是一个 SQL 数据生成器。为 MySQL 数据库生成五行 SQL。 |
|---|
我们使用 * 字符来分隔规则:
|

| * 表名用 # 符号标识。 * 每个表列用 % 符号标识 * 每个列按名称、数据类型和数据选项的顺序描述,使用 | 符号 * 如果列数据选项为随机,则根据建议的格式和列名随机化数据 |
|---|
我们随后使用在规则中设置的 #、% 和 | 分隔符来提供指令:
|

| 这里是指令:
# rooms
% room_name | string | random
% type | string | 'single' or 'double'
% beds | integer | 1 to 6
% accessible | boolean | true or false
% image | string | random url
% description | string | random max 20 characters
% features | array[string] | 'Wifi', 'TV' or 'Safe'
% roomPrice | integer | 100 to 200
|
在 ChatGPT 中运行此提示时,我收到了以下输出:
|

|
INSERT INTO rooms (room_name, type, beds, accessible, image,
description, features, roomPrice) VALUES
('Room 1', 'single', 3, true, 'https://example.com/image1.jpg',
'Lorem ipsum dolor', '["Wifi", "TV"]', 150),
('Room 2', 'double', 2, false, 'https://example.com/image2.jpg',
'Lorem ipsum dolor', '["TV", "Safe"]', 180),
('Room 3', 'double', 4, true, 'https://example.com/image3.jpg',
'Lorem ipsum dolor', '["Wifi", "Safe"]', 160),
('Room 4', 'single', 1, true, 'https://example.com/image4.jpg',
'Lorem ipsum dolor', '["Wifi", "TV"]', 120),
('Room 5', 'double', 3, false, 'https://example.com/image5.jpg',
'Lorem ipsum dolor', '["TV"]', 170);
|
如我们所见,通过使用分隔符,正确的表名 rooms 已被添加,列名、格式和范围已被正确随机化。这是通过我们在提示中通过各种分隔符区分的清晰期望和规则实现的。
分隔符有助于使提示更清晰,但它们也使得修改变得容易。例如,如果我们想重新配置生成数据,我们可以输入另一行,例如
|

|
% room_size | string | 20m2 to 50m2
|
因此,当我们编写包含大量数据且上下文变化的提示时,我们可以使用分隔符来明确在特定点提示中提供的内容。
活动二点一
使用分隔符提示示例,我们创建新的指令——这次是为一个预订,该预订将包含预订人信息、联系详情以及入住和退房日期。
2.4.3 策略 2:请求结构化输出
LLMs(大型语言模型)的一个使其有用的方面是它们能够以结构化格式提供输出。随着我们阅读本书,我们将探讨这一能力有多么有用,但作为一个规则,我们必须始终记住,在提示中我们必须清楚地说明我们希望使用哪种结构化格式。以下是一个提示示例:
|

| 创建一个包含以下字段的随机数据 JSON 对象:firstname,lastname,totalprice,deposit paid。还包括一个名为 booking dates 的对象,包含 checkin 和 checkout 日期。 |
|---|
我们在开始概述对象结构之前,明确指出我们希望看到对象以何种格式呈现,在这种情况下是 JSON。当我将此提示发送到 ChatGPT 时,返回以下结果:
|

|
{
"firstname": "John",
"lastname": "Doe",
"totalprice": 150.50,
"deposit_paid": true,
"booking_dates": {
"checkin": "2023-08-01",
"checkout": "2023-08-10"
}
}
|
如提示中明确指出的,期望的格式和结构是两个不同的指令,这意味着我们可以通过发送额外的提示来快速更改指令,例如
|

| 请以 YAML 格式创建此内容 |
|---|
将此提示发送到 ChatGPT 返回以下输出:
|

|
firstname: John
lastname: Doe
totalprice: 150.50
deposit_paid: true
booking_dates:
checkin: 2023-08-01
checkout: 2023-08-10
|
注意结构是相同的(以及随机化数据)。通过明确说明我们期望的格式,我们可以指导 LLM 在特定时间确切知道我们想要的格式,然后轻松地交替格式。
活动第 2.2 部分
尝试创建一个包含不同数据类型和不同格式的多个参数的对象。尝试提示一个大型语言模型(LLM)将您的对象从一种格式转换为另一种格式——例如,从 JSON 转换为 XML。
2.4.4 策略 3:检查假设
如我们之前所学的,LLM 可以通过生成包含错误或不与现实脱节的信息的输出来进行幻觉。不言而喻,我们希望减少产生幻觉的风险,这就是我们的第三种策略——检查假设——可以发挥作用的地方。如果 LLM 被提供了关注我们想要解决的问题边缘情况的提示,它们更有可能产生幻觉。如果 LLM 没有得到适当的指导,它更有可能猜测答案,而不是直接告诉我们它无法提供有用的答案。因此,如果我们想要避免猜测,我们需要在提示中提供指令,以便 LLM 在无法执行我们的请求时退出。考虑以下示例。它提供了提示的初始指令,其中包括一个分隔符:
|

| 你将获得一个由三个井号分隔的 JSON 对象。提取所有以 .com 结尾的电子邮件,并将它们列出来。 |
|---|
我们随后检查假设,并允许 LLM 退出:
|

| 如果不存在以 .com 结尾的电子邮件地址,只需简单地写出 “未找到 .com 电子邮件” |
|---|
最后,我们使用###作为分隔符提供要处理的数据:
|

|
###
[{
"firstname": "Bret",
"lastname": "Averay",
"email": "baveray0@apple.com"
}, {
"firstname": "Annabel",
"lastname": "Biswell",
"email": "abiswell2@nsw.gov.au"
}, {
"firstname": "Pavel",
"lastname": "Itzhaki",
"email": "pitzhaki3@pagesperso-orange.fr"
}, {
"firstname": "Pail",
"lastname": "Yandell",
"email": "pyandell4@ning.com"
}, {
"firstname": "Glennis",
"lastname": "Pentecost",
"email": "gpentecost6@yelp.com"
}]
###
|
使用可以提取的电子邮件地址集合运行此提示后返回以下输出:
|

| 这里是以 “.com” 结尾的电子邮件地址: * baveray0@apple.com * pyandell4@ning.com * gpentecost6@yelp.com |
|---|
但当我再次运行提示而不包含电子邮件地址时,发生了以下情况:
|

| 你将获得一个由三个井号分隔的 JSON 对象。提取所有以.com 结尾的电子邮件,并将它们列出来。如果不存在以.com 结尾的电子邮件地址,只需写下“未找到以.com 结尾的电子邮件地址”
###
[{
"firstname": "Bret",
"lastname": "Averay"
}, {
"firstname": "Annabel",
"lastname": "Biswell"
}, {
"firstname": "Pavel",
"lastname": "Itzhaki"
}, {
"firstname": "Pail",
"lastname": "Yandell"
}, {
"firstname": "Glennis",
"lastname": "Pentecost"
}]
###
|
我收到了以下信息:
|

| 从提供的 JSON 对象中提取的以.com 结尾的电子邮件地址列表如下:未找到以.com 结尾的电子邮件地址 |
|---|
LLM 的此响应直接引用了提示的这一部分:
|

| 如果不存在以.com 结尾的电子邮件地址,只需写下“未找到以.com 结尾的电子邮件地址。” |
|---|
这阻止了 LLM 提供错误输出。例如,当我运行没有假设检查的提示时,返回了以下内容:
|

| 当然!我可以帮你。以下是提供的 JSON 对象中以“ .com”结尾的电子邮件地址:
`
["bret@example.com", "annabel@example.com", "pavel@example.com", "pail@example.com", "glennis@example.com"]
`
请注意,我已经使用 example.com 作为所有电子邮件地址的占位符域名。如果您有实际的电子邮件地址,请将其替换为适当的域名。 |
这是一个 LLM 幻觉的绝佳例子。我们提供的对象中不包含电子邮件地址,因此 LLM 使用了猜测并错误地根据现有数据开始生成新的电子邮件地址。然而,由于假设检查的存在,我们防止了幻觉的发生。
活动二点三
创建一个提示,从原始数据列表中过滤出特定数据,然后输出结果(例如,从不同食品列表中过滤水果)。接下来,修改数据以包括边缘情况(例如,错误或缺失的数据)。观察 LLM 的输出,然后尝试通过在提示中添加一些遵循检查假设提示的指令来纠正输出。
2.4.5 策略 4:少样本提示
随着我们进入本章并学习关于 LLM 和提示的新概念,关键点通过示例得到了阐明。它们不仅是教学工具,也是相互沟通的重要工具。对于 LLM 来说,也是如此。少样本提示基本上意味着提供明确的示例以阐明指令(在这个上下文中,“少”这个词表示你分享了几个示例。没有示例的提示将是零样本提示)。以下是一个使用示例的提示。首先,我们在提示中提供指令,概述输出格式:
|

| 你是一位专家探索性测试员。为预订系统创建三个测试章程,重点关注预订日期的风险。格式应遵循:探索<目标>使用<资源>以发现<信息> |
|---|
我们提供示例以阐明 LLM 的输出格式:
|

| 例如: * 使用不同的用户探索用户权限以发现权限风险 * 使用不同的设备探索浏览器动画以发现动画的渲染方式 |
|---|
将此提示发送到 ChatGPT 返回以下输出:
|

| 使用各种日期格式探索日期验证以发现潜在的输入错误。使用不同的地理位置探索时区处理以发现预订时间的差异。使用重叠的预订请求探索预订冲突以发现潜在的重复预订风险。 |
|---|
考虑“使用<资源>探索<目标>以发现<信息>”的格式。它是基于规则的,但它在传达规则方面高度依赖自然语言(与依赖于显式分隔符规则的 JSON 或 XML 结构不同)。通过在我们的提示中提供示例,我们可以帮助说明我们所说的“<目标>”、“<资源>”和“<信息>”,并降低 LLM 猜测其含义的风险。
活动二.4
创建一个使用少量样本策略的提示。在你的提示中,提供你期望 LLM 遵循的指令,然后添加至少两个示例以帮助指导它提供期望的结果。
2.4.6 原则 2:给模型思考时间
考虑到我们一直将 LLM 视为概率机器,而不是能够思考的实体,看到鼓励我们给模型思考时间的原则可能显得有些不寻常。然而,理解这一原则的最好方式是,我们实际上并没有给 LLM 时间来思考,而是在提示中模拟我们如何给某人时间来思考任务。在 ChatGPT 提示工程开发课程中,Fulford 在介绍这一原则时提供了一个有用的类比:“如果你给模型一个在短时间内无法完成的任务,它可能会做出一个可能是错误的猜测。”
她解释说,如果人类被赋予一个复杂任务,且只有有限的时间来响应,同样的情况也会发生。我们会依赖有根据的猜测,并可能得出一个不尽如人意的答案。因此,原则 2 提供了策略,帮助我们编写将任务分解并鼓励 LLM 评估输出以再次最大化期望响应机会的提示。
2.4.7 策略 1:指定完成任务步骤
一旦我们对使用原则 1 的策略(编写清晰和具体的指令)有信心,第一个策略相对简单。通过使用分隔符,我们可以将复杂任务分解成 LLM 可以采取的单独步骤来解决更大的任务。看看这个提示,我们首先通知 LLM 即将到来一系列指令,然后是分隔的数据:
|

| 你将被给出一系列要遵循的指令。 |
|---|
我们随后明确列出每个子任务,以指导 LLM 执行以下操作:
|

| 1. 识别可能影响由三个井号分隔的文本的功能性风险 2. 将风险转换为测试章程 3. 将每个章程格式化为“使用 <资源> 发现 <信息> 以发现 <功能>”格式 |
|---|
最后,我们提供我们希望看到处理的数据:
|

| ### 作为用户,我希望能够计算我的税额,以便我知道我需要支付多少税额### |
|---|
当发送到 ChatGPT 时,它返回了以下结果:
|

| 功能性风险: • 计算准确性:存在税务计算可能不准确的风险,导致计算出的税额不正确。基于已识别风险的测试章程: • 测试章程 1:通过输入已知税额的不同测试场景并验证计算出的税额是否与预期值相符,来验证税务计算的准确性。将章程格式化为“使用 <资源> 发现 <信息> 以发现 <功能>”格式: • 使用已知税额的测试场景来发现税务计算准确性,以验证计算出的税额的准确性。 |
|---|
这是接收到的输出片段。它创建了更多风险和章程,但每个部分的第一个例子都展示了 LLM 逐个响应每个子任务以创建输出。
活动第 2.5 部分
创建一个需要执行复杂任务的提示。尝试将复杂任务分解成多个子任务,LLM 可以执行这些子任务。
2.4.8 策略 2:指示模型首先自行解决问题
我们最后的策略不是关注创建输出的过程,而是关注评估输出本身。类似于检查假设,要求 LLM 评估输出以确认其是否符合其被指示产生的结果。让我们通过一个例子来更好地了解这将如何运作。首先,看看一个不要求 LLM 首先计算出解决方案,而是列出 LLM 应遵循的指示的提示:
|

| 你是一位有经验的 Java 软件开发测试人员,请为以下方法创建单元测试: |
|---|
然后我们提供它需要的代码来提供单元测试:
|

|
public class AuthService {
public HttpStatus deleteToken(Token token) throws SQLException {
Boolean successfulDeletion = authDB.deleteToken(token);
if(successfulDeletion){
return HttpStatus.OK;
} else {
return HttpStatus.NOT_FOUND;
}
}
}
|
将此发送到 ChatGPT,返回以下结果:
|

|
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class TokenManagerTest {
@Test
void deleteToken_SuccessfulDeletion_ReturnsOK() throws SQLException {
// Arrange
TokenManager tokenManager = new TokenManager();
Token token = new Token();
// Act
HttpStatus result = tokenManager.deleteToken(token);
// Assert
assertEquals(HttpStatus.OK, result);
}
}
|
这看起来像是合理的单元测试 Java 代码,但如果将其添加到单元检查套件中,它将失败,因为生产代码中提供的 authDB.deleteToken 方法没有被正确处理。具体来说,如果我们想让这个单元检查工作,我们需要模拟 authDB.deleteToken。
现在,如果我们再次运行提示,但这次在输出最终答案之前先评估其解决方案,我们会得到不同的结果。所以首先我们将提示更改为一个新的提示,要求 LLM 确认检查的结果是否有效:
|

| 你是一位有经验的 Java 开发者,在测试中编写单元测试。为以下方法创建一个单元测试,该测试将被共享。在输出单元测试之前,检查每个单元检查的断言是否返回一个确定的结果。 |
|---|
我们随后提供了将要编写单元测试的代码:
|

|
public class AuthService {
public HttpStatus deleteToken(Token token) throws SQLException {
Boolean successfulDeletion = authDB.deleteToken(token);
if(successfulDeletion){
return HttpStatus.OK;
} else {
return HttpStatus.NOT_FOUND;
}
}
}
|
将此发送到 ChatGPT 返回了以下结果:
|

|
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.sql.SQLException;
public class TokenDeletionTest {
@Mock
private AuthDB authDB;
@Test
public void testDeleteToken_SuccessfulDeletion() throws SQLException {
// Mocking the behavior of authDB.deleteToken() to return true
Token token = new Token();
Mockito.when(authDB.deleteToken(token)).thenReturn(true);
AuthService authService = new AuthService(authDB);
HttpStatus result = authService.deleteToken(token);
// Assertion: The method should return HttpStatus.OK
Assertions.assertEquals(HttpStatus.OK, result);
}
}
|
这次,我们可以看到,因为我们要求 LLM 在返回结果之前评估其解决方案,所以输出单元检查使用 Mockito 模拟 authDB.deleteToken。因此,如果我们观察到 LLMs 输出错误解决方案或它们开始产生幻觉,我们可以添加一个指令先评估解决方案,以最大限度地减少幻觉的发生。
活动 2.6
创建一个提示,要求 LLM 解决一个问题。观察其输出,看看它产生的解决方案是否正确。然后添加指令让 LLM 评估该解决方案。发生了什么?解决方案是否改变?这是否是一个改进?
2.5 与各种大型语言模型(LLMs)合作
到目前为止,我们一直在广义上讨论 LLMs,而在之前的例子中使用 OpenAI 的 ChatGPT 来演示它们的一般工作方式。然而,ChatGPT 只是我们可以使用的许多不同 LLMs 之一。因此,在我们结束本章之前,让我们熟悉一下 LLMs 之间的不同之处,并了解一些目前流行的模型和社区,以便我们增加找到适合工作的正确 LLM 的机会。
2.5.1 比较 LLMs
什么使一个 LLM 变得优秀?我们如何确定一个模型是否值得使用?这些问题并不容易回答。LLMs 的复杂性质、它们的训练方式以及所使用的数据使得这些系统对深入分析关闭,这损害了一些研究人员试图改进或阐明的一个领域。然而,这并不意味着我们不应该在 LLMs 的关键方面以及它们如何影响它们方面进行自我教育。我们可能不是试图探索 LLMs 深层内部运作的 AI 研究人员,但我们现在是或将成为它们的用户,并希望知道我们投入的资源是否在给我们带来价值。因此,为了帮助我们分解一些术语,并给我们一些关于 LLMs 如何不同的基础,让我们回顾一下在 LLMs 领域中讨论的一些关键属性。
参数数量
如果你查看不同的 LLM,你可能会听到关于 LLM 拥有 1750 亿或 1 万亿参数数量的讨论。这有时可能感觉像是营销术语,但参数数量并不影响 LLM 的性能。参数数量本质上指的是模型中存在的统计权重数量。每个单独的权重都提供了构成 LLM 的统计谜题的一部分。所以,粗略地说,一个 LLM 拥有的参数越多,其性能越好。参数数量也可以让我们对成本有所感知。参数数量越高,运行成本就越高,并且可能会有部分成本转嫁给用户。
训练数据
LLM 需要大量的数据进行训练,因此数据的大小和质量将对 LLM 的质量产生影响。如果我们希望 LLM 在响应请求时准确无误,仅仅投入尽可能多的数据是不够的。它需要的是能够以合理方式影响模型概率的数据。例如,我们在本章前面探讨的 Reddit 示例,其中 r/counting 子版块被用来训练 ChatGPT,导致它以奇怪的方式产生幻觉,这表明更多的数据并不一定是更好的。然而,与参数数量类似,一个 LLM 训练时所使用的高质量数据越多,其性能可能越好。挑战在于了解 LLM 训练所使用的数据——这是企业 AI 创造者热衷于保密的事情。
可扩展性和集成
就像任何其他工具一样,如果 LLM 能够提供其核心能力之外的其他功能,其价值可以进一步提高,例如集成到现有系统或进一步训练以满足我们的特定需求。可集成和扩展 LLM 的功能很大程度上取决于谁负责了训练。
例如,OpenAI 提供了付费 API 访问其模型的服务。但除了允许你通过简单的提示调整输出的指令功能外,没有进一步微调和部署其 GPT 模型用于私人使用的功能。与此相比,Meta 的 LlaMa 模型已被开源,允许 AI 社区下载并根据自身需求进一步训练,尽管他们必须建立自己的基础设施来部署该模型。
随着大型语言模型(LLM)平台的增长,我们将看到它们不仅对提示的响应能力有所提升,而且其周边功能和访问性也将得到改进。因此,在评估要与之合作的内容时,有必要牢记这些功能。
响应质量
不可否认,最重要的考虑因素是 LLM 提供的响应是否易于阅读、有用,并且尽可能没有幻觉(或尽可能接近免费)。尽管参数数量和训练数据等标准是衡量 LLM 性能的有用指标,但了解我们想要使用 LLM 做什么,然后确定每个 LLM 如何响应我们的提示并帮助我们解决具体问题,这取决于我们。我们面临的挑战并不都需要市场上最大、最昂贵的 LLM。因此,花时间尝试不同的模型、比较它们的输出,然后自己做出判断是很重要的。例如,OpenAI 的 GPT 模型在代码示例方面的表现优于 Google Gemini。这些细节是通过实验和观察发现的。
我们所探讨的标准绝对不是详尽的列表,但它们表明,一旦我们超越了 LLMs 最初的光环,还有更多关于 LLMs 需要考虑的地方。不同的 LLMs 表现不同,帮助我们应对各种挑战。因此,让我们来看看一些目前更受欢迎的模型和平台。
2.5.2 检查流行的 LLMs
自从 OpenAI 发布 ChatGPT 以来,各种组织发布的 LLMs 数量激增。这并不是说这些模型和相关工作在 ChatGPT 发布之前不存在,但公众的关注确实加剧了,越来越多的营销和发布公告都集中在发布 LLM 产品的公司。以下是一些自 2022 年底以来发布的更常见/流行的 LLMs。
跟踪大型语言模型(LLMs)
值得注意的是,LLMs 及其相关功能的发布情况极其灵活,发展速度相当快。因此,我们可能会发现,从 2024 年中期写作时到您阅读这本书的时间,我们将探索的内容可能会有所不同。幸运的是,像 LLM Models (llmmodels.org/)这样的网站分享了最新的列表以供审查。然而,这个列表表明,LLM 领域的一些知名公司值得探索。
OpenAI
在写作时,OpenAI 是提供 LLMs 使用的最普遍的组织。尽管 OpenAI 已经研究 LLM 模型有一段时间了,2020 年发布了 GPT-3 模型,但直到 2022 年 11 月发布 ChatGPT,才引发了人们对 LLMs 的兴趣和使用的热潮。
OpenAI 提供了一系列不同的 LLM 模型,但最突出的是 GPT-3.5-Turbo 和 GPT-4o,您可以在platform.openai.com/docs/models/overview了解更多信息。这两个模型被用作基础模型或可以进一步训练以用于特定目的的模型,用于 ChatGPT、GitHub Copilot 和 Microsoft Bing AI 等一系列产品。
除了他们的模型,OpenAI 还提供了一系列功能,例如直接访问他们的 GPT-3.5-Turbo 和 GPT-4 模型的 API,以及与 ChatGPT 集成的应用程序集合(如果您订阅了他们的 plus 会员)。到目前为止,它是最受欢迎的 LLM(至少目前是这样),并引发了一波组织发布他们自己的 LLM 的竞赛。尽管我们已经探索了一些与 ChatGPT 相关的提示,但您始终可以访问并实验 ChatGPT,网址为ChatGPT.com/。
坚持使用 OpenAI
尽管有许多不同的 LLM(大型语言模型)我鼓励您使用,但为了保持一致性,我们将坚持使用 ChatGPT-3.5-Turbo。它不一定是最强大的 LLM,但它是分布最广泛的——而且是免费的。话虽如此,如果您想尝试使用其他 LLM 模型来测试这些提示,请随意。然而,请记住,它们的响应可能会与本书中分享的内容不同。
Gemini
无怪乎,Google 也在生成式 AI 市场中占有一席之地,他们有一系列名为 Gemini 的 LLM 模型。截至撰写本文时,他们最强大的模型是 Gemini 1.5 Pro,但他们还提供其他版本的模型,如 Gemini 1.5 Flash 和 Gemini 1.0 Pro。鉴于是 Google,每个模型的参数数量不是公开信息,但它们与其他 LLM 模型在性能上相对可比较。
与 OpenAI 类似,Google 通过他们的 Google Cloud 平台提供对 Gemini 模型的访问([ai.google.dev/](https://ai.google.dev/)),并且最近开始提供类似于 OpenAI ChatGPT 应用程序的应用程序,增加了与其他 Google Suite 工具(如 Google Drive 和 Gmail)的集成。您可以在https://gemini.google.com/app访问并实验 Gemini。
LLaMa
LLaMa 这个名字代表一系列模型,首次由 Meta 在 2023 年 7 月发布。LLaMa 与 OpenAI 的 GPT 模型和 Google 的 Gemini 模型的不同之处在于 LLaMa 是开源的。除了开源许可外,LLaMa 还提供多种大小:分别是 80 亿和 700 亿参数。这些大小及其可访问性意味着 LLaMa 已被 AI 社区采纳为流行的基础模型。然而,这种可访问性的另一面是,Meta 不提供公共平台来训练和运行 LLaMa 的版本。因此,必须个人获取数据集和基础设施才能使用。
关于 LLaMa 的更多详细信息可以在以下链接中找到:
Hugging Face
与我们列表中的其他条目不同,Hugging Face 不提供专有模型,而是促进了一个包含各种不同模型的 AI 社区,其中大多数是开源的。查看他们提供的模型索引页huggingface.co/models,我们可以看到来自不同公司和研究实验室的数十万个不同训练的模型。Hugging Face 还提供了用于训练的数据集、应用程序和文档,允许读者深入了解模型是如何构建的。所有这些资源都是为了使 AI 社区能够访问预训练模型,对其进行调整,并针对特定用途进行进一步训练,这是我们将在本书的第三部分进一步探讨的内容。
LLM 的市场在短时间内显著增长,无论是商业上还是开源领域,这与其他软件开发领域类似,积极关注新出现的 LLM 可能是有益的。然而,这可能会让人感到不知所措,并且不一定能够跟上所有同时发生的事情。因此,我们不妨选择在我们想要使用 LLM 解决特定问题时去探索 LLM。拥有问题可以帮助我们确定哪些工具最适合我们。
活动二.7
要么选择本章中的一个早期提示词,要么创建自己的提示词并提交给不同的 LLM。注意每个 LLM 的响应,并进行比较。其中一些是否感觉更像是对话?它们如何处理接收或发送代码示例?在你看来,哪一些提供了最好的响应?
2.6 创建提示词库
提示词的一个好处是,一旦创建,就可以重复使用。因此,针对不同角色和任务的提示词集合在网上大量出现。例如,以下是一些我最近看到的分享的集合:
-
Awesome ChatGPT Prompts,GitHub (
github.com/f/awesome-ChatGPT-prompts) -
50 个面向开发者的 ChatGPT 提示词,Dev.to (
mng.bz/w5D7) -
ChatGPT 速查表,Hackr.io (
mng.bz/q0PK)
这个列表远非详尽无遗,样本集合也不一定与测试相关,但它们值得浏览,以了解他人是如何创建提示词的,同时也为我们提供了确定哪些提示词有效、哪些无效的机会。
虽然公开共享的提示集合可能很有用,但我们很可能会创建出适用于特定情境的提示。因此,养成将有益的提示存储在某种类型的存储库中,以便我们和其他人可以快速使用的习惯是值得的。你将存储这些提示的位置将取决于它们的使用目的和对象。如果是供公众使用,那么共享提示存储库或添加到现有集合中可能是有价值的。如果我们是在开发公司产品的同时创建和使用它们,那么我们需要像对待我们的生产代码一样对待它们,并将它们存储在私密的地方,以免违反任何关于知识产权的政策。最后,我们还可以考虑版本控制,这样我们就可以随着我们对与 LLMs 合作的学习以及 LLMs 本身的演变来调整和跟踪提示。
无论它们存储在哪里,目的是创建一个提示存储库,这些提示快速且易于访问,以便一旦为特定活动创建了一个提示,就可以快速多次重用,从而我们可以从它们中获得尽可能多的价值来提高我们的生产力。
活动二.8
创建一个可以存储未来提示的空间供你和你的团队使用。
使用本书中的提示
为了存储提示以备将来使用,并帮助读者尝试本书中的提示示例,你可以在mng.bz/75mx找到每个提示示例。
这将使你能够在我们浏览每一章时快速复制和粘贴提示,从而节省你手动输入整个提示的任务。在某些提示的部分,你可能需要添加你自己的自定义内容或上下文才能使用它们。为了使它们清晰,提示中提供了需要添加到提示中的说明,并将以大写字母和方括号格式呈现。
2.7 通过使用提示解决问题
我们在本章中学到的策略和工具可以帮助我们构建一个框架,用于使用大型语言模型(LLMs)并为特定的测试活动设计特定的提示。然而,我们应该意识到,尽管这些策略提高了我们获得期望结果的机会,但它们并不是万无一失的。例如,当我们要求一个 LLM 评估其输出时,LLM 并不是像传统应用程序那样评估其输出。它只是简单地将预测指针进一步移动到符合我们要求的结果。
单一提示与多提示
在本章中,我们探讨了如何运用原则和策略来创建尽可能有效的个人提示,以最大化从 LLM(大型语言模型)中获得期望的输出。然而,像 ChatGPT、Gemini 和 Claude 这样的工具使我们能够与 LLM 进行对话,而对话的历史会影响该对话中未来响应的输出。这引发了一个问题:在对话中尝试多个提示以调整输出是否会更简单。尽管这可能有效,但我们确实面临着一个风险,那就是随着对话的进行,LLM 试图过度拟合我们的请求,从而发生幻觉的风险会越来越高。这就是为什么像 BingAI 这样的工具在对话中可以给出的响应数量有限。然而,更重要的是,更多并不一定意味着更好。垃圾输入,垃圾输出的规则既适用于单个提示也适用于多个提示。在单个对话中依赖多个提示意味着我们在请求时变得不那么清晰和精确,这增加了延迟并增加了幻觉,从而抵消了最初使用 LLM 的价值。总之,无论我们是想发送单个提示以获得我们想要的,还是发送多个提示,采用 Isa Fulford 和 Andrew Ng 创建的原则和策略都将提高我们在 LLM 上的生产力。
因此,有必要培养编写提示的技能,帮助我们有效地解决问题,并且不会减少使用 LLM 节省的时间(例如,我们不希望花费数小时调整提示)。这意味着能够识别 LLM 可以帮助解决的具体问题,然后利用提示工程来最大化从 LLM 中提取有价值信息的机会。这就是本书余下部分将要探讨的——何时以及如何使用 LLM。
随着我们不断前进,我们还将了解到提示有多种形状和大小。在本章中,我们查看了我们人类手动编写的提示。但,正如我们将学到的,GitHub Copilot 等工具在我们编写代码时会自动生成提示。这并不意味着我们不能再将原则和策略融入我们的工作方式中,但这确实需要时间、意识和实践来发展。
活动 2.9
在继续阅读本书并了解不同测试活动的不同类型提示之前,利用第一章和第二章的知识,考虑一个你做的特定测试任务,并尝试构建一个可以帮助你工作的提示。
摘要
-
LLM 使用复杂的算法在大量数据上进行训练,以分析我们的请求并预测输出。
-
LLM 的预测性使它们相当适应,但也意味着它们带来了一些风险。
-
LLM 有时会输出幻觉,或者听起来权威且正确,但实际上是错误的文本。
-
LLM 训练所使用的数据可能包含错误、缺失和假设,我们在使用它们时必须牢记这一点。
-
我们还必须注意与我们分享给 LLM 的数据,以免造成业务或用户信息的未经授权泄露。
-
提示工程是一系列用于最大化语言模型(LLM)返回所需输出的概率的原则和策略。
-
我们可以利用 LLM 本质上是预测性的这一知识,通过实施提示工程从中受益。
-
使用分隔符可以帮助我们澄清提示中的指令和参数。
-
LLM 可以以各种格式输出数据,但它需要我们明确指出在提示中我们想要哪种结构格式。
-
通过使用检查假设的策略,我们可以减少 LLM 的幻觉。
-
在提示中提供示例可以帮助确保 LLM 以所需格式或上下文提供输出。
-
在提示中指定具体的子任务可以帮助 LLM 成功处理复杂任务。
-
要求 LLM 评估问题的解决方案也可以减少错误并最大化结果。
-
知道何时使用 LLM 以及通过提示工程发展技能是成功的关键,无论我们使用什么工具。
第三章:人工智能、自动化和测试
本章涵盖
-
良好测试的价值
-
工具如何协助测试
-
如何识别何时 AI 工具可以在测试中使用
在我们深入探讨大型语言模型(LLMs)在测试中的应用之前,让我们先问自己以下问题:
-
测试的目的是什么?它的价值是什么?
-
工具如何帮助我们?
-
在什么情况下适合使用 AI 工具?
看起来提出这些基本问题似乎是多余的。但如果你是那种仅仅将测试视为一种确认性练习的人,就像执行测试用例来确认需求是否正确一样,那么你在后续章节中的收获将有限。理解测试的价值和性能对于确定如何有效地使用工具至关重要。因此,在本章中,我们将探讨为什么对测试的深入理解可以帮助我们利用工具。话虽如此,如果你已经具备了这种深入理解,那么请随意浏览本章并继续前进。对于其他人,让我们从为什么进行测试这个基本问题开始。
3.1 测试的价值
为了帮助我们理解为什么在软件开发中需要进行测试,让我们回到一个常见的观点,即测试是一种确认性练习。通过这种方式,我们是指测试被视为为了确认以下条件而进行的活动:
-
已满足书面需求。
-
系统中已覆盖所有关键路径。
-
系统按预期工作。
持有这种观点的团队往往过度依赖包含明确指令的测试用例/脚本,以便人类或机器遵循并确认是否达到了预期的结果。这种思维方式和方法的缺点不在于它使用了测试脚本,而在于它只使用了测试脚本而没有其他,这导致边缘情况被忽略,更复杂的错误或行为未经过测试,并且通常对产品行为有有限的理解。许多偏见都源于对测试脚本的过度依赖,但如果我们将其回归到在测试中使用 LLMs,那么它限制了我们对这些工具如何帮助我们的认识。随着像 ChatGPT 这样的工具越来越受欢迎,围绕在测试中使用 LLMs 的大多数演示和辩论都集中在一点上:测试脚本。人们会展示 LLMs 如何生成可以由人类或测试自动化工具手动执行的测试脚本。
虽然最初这些可能有些用途,但使用 LLMs 帮助测试的其他可能性开始枯竭。表面上,这似乎是相关工具的限制,但真正的问题是对于测试是什么以及它如何帮助的有限观念。因此,如果我们想要扩大在测试中使用 LLMs 的范围,我们首先必须扩大我们对测试是什么以及它是如何工作的理解。
3.1.1 关于测试的另一种思考方式
为了帮助我们建立更深入的理解,让我们探索一个测试模型,我用它来定义我相信测试的目的以及它如何帮助,如图 3.1 所示。
该模型基于詹姆斯·林赛在其论文“探索与策略”中创建的模型(mng.bz/mRdn),由两个圆组成。左圆代表想象力,即我们在产品中想要的东西,右圆代表实施,即我们在产品中拥有的东西。测试的目的是通过执行测试活动尽可能多地了解这两个圆中发生的事情。我们在这两个圆中测试得越多,我们就学得越多。然后我们可以
-
发现可能影响质量的潜在问题
-
将这两个信息圆重叠起来,确保我们相信我们正在构建预期的产品

图 3.1 一个有助于描述测试价值和目的的模型
为了进一步描述这个原则,让我们看看一个例子,其中一支团队正在交付一个假设的搜索功能,我们希望确保其以高质量交付。
想象力
想象力圆代表我们从产品中想要的东西,这包括显性和隐性的期望。因此,在这个圆中,我们的测试重点是尽可能多地了解这些显性和隐性的期望。通过这样做,我们不仅学习了书面或口头分享的明确内容,还深入细节,消除了术语和概念上的歧义。比如说,业务或用户代表,如产品负责人,已经与他们的团队分享了以下要求:“搜索结果应按相关性排序。”
这里共享的明确信息告诉我们,产品负责人希望搜索结果按相关性排序。然而,通过测试所提要求背后的想法和概念,可以揭示大量的隐含信息。这可能以一系列问题的形式出现,例如
-
“相关结果”是什么意思?
-
谁能从这些结果中受益?
-
分享了哪些信息?
-
我们如何按相关性排序结果?
-
我们应该使用什么数据?
通过提出这些问题,我们获得了更广泛的视角,消除了我们团队思考中的任何误解和假设,并确定了可能影响这些预期的潜在风险。如果我们对我们被要求构建的内容了解得更多,那么我们更有可能第一次就构建正确的东西。
实施
通过测试想象力,我们能够更强烈地感受到我们被要求构建的内容。但是,仅仅因为我们可能知道要构建什么,并不意味着我们最终得到的产品符合我们的预期。这就是为什么我们还要测试实施过程来学习
-
产品是否符合我们的预期
-
产品可能不符合我们的预期
这两个目标同等重要。我们希望确保我们构建了正确的东西,但总会有副作用,比如意外行为、漏洞、未满足的期望,以及可能出现在我们产品中的直接奇怪之处。以我们的搜索结果示例,我们不仅能够测试该功能是否以相关顺序提供结果,还可以询问
-
如果我使用不同的搜索词会怎样?
-
如果相关结果不匹配其他搜索工具的行为怎么办?
-
如果我在搜索时部分服务中断怎么办?
-
如果我在不到 5 秒内请求 1000 次结果会怎样?
-
如果没有结果会发生什么?
通过超越我们的预期进行探索,我们对我们产品中正在发生的事情有了更清醒的认识——包括所有缺点。这确保了我们不会最终对产品行为做出错误的假设,并发布一个质量低劣的产品。这也意味着,如果我们发现意外行为,我们有选择尝试去除或重新调整我们的预期的机会。
3.1.2 全面的测试方法
描述的想象和实施测试模型表明,测试不仅超越了简单确认预期的范畴,而且提出了一种更全面的测试方法。通过实施专注于想象和实施空间的各项活动,通过我们执行的测试,我们能够更多地了解我们想要构建的内容和我们已经构建的内容。在这两个领域学到的越多,它们就越一致。而且,它们越一致,我们对质量的感知就越准确。
一个对其工作有充分了解的团队对其产品的质量有更好的理解。然后我们也更有能力决定采取哪些步骤来提高质量。这使得我们能够将注意力集中在特定的风险上,对我们的产品进行更改以符合用户的期望,或确定我们想要投入时间修复的问题,以及哪些问题可以置之不理。这是良好测试的价值——帮助团队进入一个可以做出这些明智决策并对其采取的步骤有信心开发高质量产品的位置。
为了帮助我们更好地理解这个模型,让我们考虑一个需要测试的示例情境。在我们的例子中,我们负责交付一个快餐订购系统。用户登录,找到他们想要订购的餐厅,下订单(订单被发送到餐厅),然后从产品内部跟踪订单的配送。这样的产品需要高度可用、易于使用和安全的。因此,为了交付高质量的产品,我们可能需要利用不同的测试活动来关注不同类型的风险,如图 3.2 所示。

图 3.2 想象/实施模型,展示了示例活动
在这个模型中,我们可以看到一系列各种活动被放置在特定的区域,因为它们专注于特定类型的风险。例如,在想象力方面,我们可能会关注影响网站可用性的风险。因此,我们采用专注于用户体验测试和协作设计的测试活动。在实施方面,我们希望确保产品稳定,并最小化可能影响产品可用性和应用程序的风险。因此,我们采用探索性和性能测试等活动。最后,请注意,在重叠的区域,我们添加了测试脚本。这些脚本是有用的,因为它们基于我们对预期(想象力)的明确期望,以减轻产品在增长和随时间演变过程中出现意外变化的风险(实施),这表明测试脚本和自动化检查是有用的,但它们只是整体的一部分。
这些活动中的每一个都有不同的实施方式、不同的挑战,以及工具如何帮助的不同方式。但如果我们不了解测试是一项信息收集和知识分享的练习,我们就无法轻易地识别这些用途。有了这个模型,我们可以更好地理解我们工作中面临的各种风险,以及有助于减轻这些风险的测试活动,然后我们可以开始深入探讨工具在测试中扮演的角色。
3.2 工具如何帮助进行测试
你可能会听到测试员说(或者你可能自己也说过)永远没有足够的时间来测试所有内容。这本书中可能会多次提到这一点。团队总是受限于时间、预算、会议、人员配备和其他因素,因此为了实施和执行有效的现代测试,我们必须依赖工具来帮助我们。工具对于测试至关重要,但它们也让我们对测试产生了下一个误解——即工具或机器可以像人类一样进行测试。
3.2.1 自动化偏差
为了理解机器和人类在测试方面的差异,让我们考虑一个例子,其中两者都被用来测试一个网站功能。这个功能是一个电子商务网站的全宽横幅,其中包含一张图片和一些文字来突出当天的交易。最初,我们手动测试或以人为主导的方式进行测试,并观察到该功能运行正常——图片显示,并且所有与之相关的文字都是正确的。然后我们决定使用工具来自动化测试。我们编写了代码,该代码将打开浏览器并断言元素 A 存在,这是当天交易加载的地方。我们运行了自动化测试,并且它通过了。然后,有一天,在所有自动化测试都通过的成功发布之后,一个最终用户提出了一个错误,并告诉我们他们看不到当天的交易。他们看到的是页面上方的空白白色框。
发生了什么?在创建自动化测试的过程中,我们将基于基于心理启发式和占卜的隐含信息构建的知识转移,并将其明确化。我们将对功能复杂理解简化为一条指令:元素 A 应该存在于网页上。因此,当我们的产品最新版本发布时,如果有一个功能错误地检索当天的交易,或者 CSS 不正确或损坏,自动化测试仍然通过,因为元素 A 仍然存在。然而,人类只需要几秒钟就能看出有问题。
这个故事的意义并不是工具本身不好或不必要,而是它们常常被误用或误解。这种行为是一种渗透到我们对工具价值看法中的自动化偏差,我们赋予工具输出的价值超过了它传达给我们的价值。也就是说,当我们设计自动化的测试来寻找元素 A 时,我们假设我们作为人类所做的只是寻找元素 A。但我们还考虑了许多其他因素,即使我们没有有意识地这样做。
如果我们陷入自动化偏差的陷阱,我们就有可能选择并实施那些我们认为可以像人类一样揭示和报告信息的工具,而实际上它们并不能做到这一点,这可能导致我们对交付的产品产生误导性的过度自信,或者产生一种对于现代项目来说不可持续的工具模拟人类行为的工作量。工具不能取代测试活动,坚持这种观念最终会导致质量问题,并增加项目的风险。因此,我们不得不更多地转变我们的思维方式,思考工具如何支持我们的测试。
3.2.2 对工具的选择性使用
工具的成功来自于我们对想要解决的问题以及可能帮助的工具进行了思考的情况。为了更好地理解这一点,让我们回到我们的当天的交易功能,并更仔细地看看当人类测试这样的功能时他们在做什么。
首先,我们考虑不同的测试方法。我们使用对所述功能的当前理解来制定测试想法,并选择首先测试什么。接下来,我们需要设置我们的测试。这可能包括设置环境或创建/更新必要的测试数据。(我们可能需要创建一个当天的交易来观察以及测试用户来管理和查看交易。)一切准备就绪后,我们接下来需要执行我们的测试,加载浏览器或多个浏览器以验证交易是否正确渲染。然后,一旦我们观察到了结果,我们就做笔记或向团队报告我们的发现,所有这些都会更新我们对功能的理解,以便我们再次开始这个过程。这个流程可以总结如图 3.3 所示。

图 3.3 测试“今日特卖”功能的过程可视化
这个周期可能发生得很快——例如,在探索性测试会话中。或者它可能以更长的形式发生,如性能测试,其中每个步骤都有许多细节需要考虑。无论活动类型如何,为了成功执行循环,我们需要工具来完成这个过程。我们可能需要使用数据库客户端、测试数据管理器或基础设施工具来设置状态管理。我们会使用笔记工具、截图应用和项目管理工具来报告所学内容。图 3.4 通过更新我们的初始可视化来总结这些在测试中使用的工具。
可视化展示了现代测试如何利用各种工具来完成各种任务,而不是试图将整个测试性能都集中在一种工具的使用上。这是因为当进行测试时,有很多不同的活动在进行。与人类的观察技能相比,工具观察模式、变化和问题的能力总是有限的。因此,我们通过使用只做一项工作很好的工具来获得价值,而不是使用做很多事情都做得不好的工具。
这种思维方式有趣的地方在于,当我们花时间去考虑它时,这似乎对我们来说很显然。我们都使用工具来帮助我们完成构成更大活动的特定任务。然而,我们中的大多数人并没有经过深思熟虑地这样做。虽然我们知道在特定任务中使用工具是合理的,但我们需要培养选择和使用工具的技能。这意味着我们需要熟悉工具,并更多地了解我们在测试中每天所做的事情,以便我们能够选择正确的工具,或者在 LLMs 的情况下,选择正确的提示或增强。这正是本书的其余部分将关注的重点,为我们提供了解决何时 LLMs 可以帮助特定任务所需的技能。

图 3.4 测试过程的可视化,这次增加了工具
3.3 知道何时在测试中使用 LLMs
现在我们已经了解到,在测试中有效使用工具是关于增强测试中的任务,而不是复制广泛的测试活动。那么 LLMs 在这个框架中又处于什么位置呢?鉴于它们的广泛应用,让我们来看看 LLMs 目前被使用的常见方式,并看看每种方式如何支持测试。
3.3.1 生成能力
在人工智能的广阔领域中,LLMs 属于生成式 AI 的范畴;也就是说,LLMs 的构建目的是生成内容(这与可能用于预测或分类的其他 AI 不同)。例如,这意味着如果要求 LLM 为我们生成测试用例,它会这样做。然而,仅仅因为它可以生成像测试用例这样的材料,并不意味着这些测试用例对我们有价值。我们必须提醒自己,LLM 生成输出的方式是基于概率的复杂模型。这意味着其生成能力与我们作为人类产生想法的方式不同。
如果你还记得,在第一章中,我们探讨了向 LLM 发送提示时这个概念:
|

| 为文件上传功能创建测试 |
|---|
这可能会导致如下这样的响应(为了简洁而缩短):
|

| 1. 测试文件上传功能: • 测试用例 1.1:上传有效文件 − 输入:选择一个有效格式的文件(例如,.jpg, .pdf)。 − 预期结果:文件成功上传,并显示确认消息。 • 测试用例 1.2:尝试上传无效文件 − 输入:选择一个无效格式的文件(例如,.exe, .xhtml)。 − 预期结果:显示错误消息,指出文件格式不受支持。 2. 测试文件大小限制: • 测试用例 2.1:上传小于最大允许大小的文件 − 输入:选择一个小于最大允许大小的文件。 − 预期结果:文件成功上传,并显示确认消息。 • 测试用例 2.2:尝试上传超过大小限制的文件 − 输入:选择一个大于最大允许大小的文件。 − 预期结果:显示错误消息,指出文件超过最大大小限制。 |
|---|
LLM 生成这些测试用例的方式令人印象深刻,但它们缺乏我们正在测试的功能或产品的上下文和具体性。因此,当与 LLMs 一起工作时,我们需要确保我们如何提示或扩展它们,能够为它们提供足够的上下文以生成有价值的输出。
确保我们提供足够上下文的结果是,更容易使用 LLMs 为非常具体和针对性的任务生成输出。另一种方法是,我们需要提供大量输入数据,这将导致一个昂贵构建和维护的提示。例如,想象一下你需要将多少上下文输入到 LLM 中,才能获得与我们工作环境相关的测试策略。
相反,如果我们专注于使用 LLMs 帮助完成诸如
-
测试数据生成—当提供有关数据集的明确规则时,LLMs(大型语言模型)可以用来快速生成一系列数据,用于各种测试活动,从探索性测试到性能测试。
-
风险和建议测试想法—我们应始终避免让 LLM 的输出成为测试的唯一仲裁者。我们可以使用它们来提出测试想法和风险,这些想法可以作为新想法的起点或纳入我们的现有工作中。
-
代码片段—与早期的测试案例示例类似,如果我们要求 LLMs 生成完整的自动化测试或框架,那么从 LLMs 那里获得的价值很小。然而,使用它们来生成自动化或脚本的较小部分,这些自动化或脚本用于支持测试活动,如探索性测试,可能是有利的。
3.3.2 转换功能
LLMs 提供的另一个好处是将自然语言从一种结构转换为另一种结构的能力。LLMs 转换的一个常见例子是语言翻译。假设我们向 LLM 发送了如下内容:
|

| 将以下由三个井号分隔的文本翻译成法语:###Hello, my name is Mark### |
|---|
然后,它将返回如下响应
|

| Bonjour, je m'appelle Mark |
|---|
这是一种有用的方式来展示 LLMs 如何转换数据,但我们不应仅限于口头语言。LLMs 能够将所有类型的数据从一种抽象转换为另一种抽象。以下是一些有助于测试的例子:
-
转换测试数据—使用 LLMs 快速将数据从一种结构转换为另一种结构可以帮助加快测试速度。例如,我们可能要求 LLM 将纯文本测试数据转换为 SQL 语句,或将 SQL 语句转换为在测试自动化中调用的辅助函数。
-
将代码转换—大型语言模型(LLMs)可以将函数、类和其他数据转换为代码的新迭代。这使得 LLMs 非常有价值,因为它们可以将代码转换为不同的语言,同时仍然保持原始代码在新翻译输出中的逻辑和流程(尽管我们始终应该测试以确保无误)。
-
总结笔记—虽然数据的转换不像将代码片段从一种语言转换为另一种语言那样直接,但我们可以使用 LLMs 同时进行转换和总结。此外,我们可以使用 LLMs 将测试活动(如探索性或设计测试会议)中的原始测试笔记转换为总结笔记,与他人共享。
3.3.3 增强功能
最后,我们可以使用 LLMs 来增强和扩展现有材料。这通常意味着向 LLM 提供一段数据片段,并提示 LLM 对其进行扩展。这与生成能力有一些重叠,因为我们要求 LLMs 生成一定程度的新的输出,但在这个情况下,我们提供了更多的前置上下文,并指导它关注现有材料,而不是提示 LLM 生成全新的内容。这意味着我们可以利用这种能力来帮助我们完成测试任务,例如
-
审查代码——并非所有进行测试的人都是自信的程序员,即使是那些对阅读代码感到舒适的人有时也会难以理解分析或测试所需的代码。LLMs 通过提供代码片段并提供关于该代码如何工作的自然语言分解来增强我们的理解,这有助于风险分析、测试设计等。
-
描述性代码——与审查代码类似,我们可以使用 LLMs 来帮助提高代码的描述性——例如,快速创建易于创建和维护的代码注释。这对于自动化测试特别有用,因为传达我们的自动化代码正在做什么对于维护至关重要。
-
扩展分析——我们还可以使用 LLMs 来扩展我们的分析活动,例如风险分析和设计测试(在构建功能之前询问需求)。通过提供我们的当前分析数据,我们可以要求 LLMs 审查并扩展它,提出我们可以将其纳入分析或忽略的新想法。
3.3.4 测试中使用的 LLMs
为了将这些 LLM 的不同能力置于上下文中,让我们回到我们在工具支持下的测试可视化(图 3.5)。

图 3.5 测试过程的可视化,这次增加了 LLMs
在这里,我们可以看到 LLMs 如何被插入到更广泛的测试生命周期中的不同和具体任务中。这使我们回到了我们在第一章中讨论的影响区域模型。我们不是试图使用 LLMs 来复制生命周期中存在的所有测试活动,而是优先考虑我们作为人类的能力以及我们为测试带来的价值。然后,我们选择在选定的领域添加 LLMs 来扩展我们的工作,以便我们可以更快地学习,了解更多,并帮助确保我们的团队能够获得更好的信息,以便他们可以构建更高品质的产品。
摘要
-
如果我们对测试的理解有限,那么工具的使用也将受到限制。
-
测试不是一个确认性练习,而是一系列不同的活动,帮助那些寻求了解产品的想象力和实施的人。
-
想象力意味着我们对想要构建内容的理解。
-
实施意味着我们对所构建内容的理解。
-
随着我们对方方面面的理解增加,我们将它们对齐,这有助于我们交付更高品质的产品。
-
我们运行许多不同类型的测试活动,以关注不同类型的风险以及它们如何影响想象力和实施。
-
工具是成功测试的一个基本组成部分。
-
LLMs 可以用于生成、转换和增强输出。
-
LLMs 应该用于较小的、具体的任务,以生成具有价值的输出。
-
LLMs 可以生成有助于特定任务或创建建议内容的内容。
-
LLMs 可以将数据转换为帮助将原始数据转换为有用的格式或总结它。
-
LLMs 还可以增强现有材料,添加新的建议或扩展细节。
-
我们可以将 LLMs 插入到许多不同和具体的测试任务中,这反映了我们在第一章中学到的效应模型领域。
第二部分. 技术:测试中的任务识别和提示工程
现在我们已经确立了一种促进以人为中心的方法论,我们可以开始构建我们在各种测试活动中使用大型语言模型(LLMs)的技术。在这一部分,我们不仅将关注如何通过提示工程和 AI 代理的使用,在测试中运用 LLMs 来增强特定任务,还将学习如何识别 LLMs 有益的任务。专注于这种后续技能对于成功至关重要,我们将通过比较 LLMs 使用的例子,关注广泛、一般化的任务与小型、目标化的任务,来了解为什么这是如此。通过为小型任务设定明确的目标和输出,我们将更好地准备开发更有价值的提示,这些提示可以通过提示工程技术进行调整和改进。所有这些都可以在 AI 代理中利用,以创建支持特定任务的辅助工具。因此,让我们探索如何将任务识别和提示工程应用于广泛的测试任务,如开发、自动化、分析和探索,以增强我们为提高产品质量所做的工作的价值。
第四章:. AI 辅助开发者测试
本章涵盖
-
使用 GitHub Copilot 开发单元测试和生产代码
-
使用 ChatGPT 开发单元测试和生产代码
根据 JetBrains 在 2022 年进行的一项关于开发生态系统的调查,81% 的受访者拥有超过一比一的开发者与质量保证人员(QA)比例。40% 的受访者表示他们“每 10 个开发者只有不到 1 个 QA”,而只有 1% 的受访者表示他们“QA 人员比开发者多”(见 www.jetbrains.com/lp/devecosystem-2022/testing)。
理解和构建质量对于向用户提供价值至关重要,然而开发和测试之间的比例几乎总是不平衡,这有多种原因。一些组织领导者选择通过质量教练的支持来教育开发者构建质量,而其他人则根本不想投资于倡导测试和质量的职位。无论如何,这种情况都会给团队中的每个人带来压力,要求他们交付高质量的应用程序。那么,人工智能(AI)工具如何帮助缓解这种压力呢?
本章将特别关注大型语言模型(LLM)AI,如 GitHub Copilot 和 ChatGPT,如何帮助开发者将质量融入我们的工作中。我们不会将这些 AI 工具视为开发者和测试人员的替代品,而是学习它们如何在执行构建质量的活动时引导我们,以及展示我们如何使用 AI 工具在测试资源有限的情况下提高质量并识别风险。
设置 Copilot
在本章中,我们将探讨 GitHub Copilot 的使用示例。在我们尝试这些示例之前,您可以在附录 B 中找到安装和操作说明。
4.1 检查自动化开发者的崛起
2023 年 4 月,市场竞争分析公司 Similarweb 报告称,Stack Overflow 在前一个月的流量下降了 14%。根据 Similarweb,原因是开发者从 Stack Overflow 转向 GitHub 的 Copilot 和 ChatGPT 等工具(见 mng.bz/5Oy4)。这究竟是开发者跳槽的持续趋势的迹象,还是最终会平衡的异常情况,此类报告表明基于 LLM 的 AI 工具(LLMs)正在对开发者角色带来广泛的变化。
作为测试人员,看到这种有趣的变化是很吸引人的。多年来,随着测试自动化工具的日益先进,我与开发者们讨论过是否可以用自动化测试工具取代测试。现在,随着训练了来自公共和私有代码库数十亿行代码的大型语言模型(LLM),对话已经转向是否可以自动化开发者的角色。例如,可以将 ChatGPT 发送如下提示:
|

| 写一些 Java 代码,用于向预订 API 发送 HTTP 请求 |
|---|
然后它将利用其数十亿经过训练的权重和平衡来返回一个像这样的工作代码示例:
|

|
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
public class BookingAPIClient {
public static void main(String[] args) {
String apiUrl = "https://api.booking.com/endpoint"; // Replace with the actual API endpoint URL
try {
URL url = new URL(apiUrl);
HttpURLConnection connection = (HttpURLConnection)
➥url.openConnection();
// Set the request method (GET, POST, etc.)
connection.setRequestMethod("GET");
// Set request headers if required
connection.setRequestProperty("Content-Type",
"application/json");
connection.setRequestProperty("Authorization",
"Bearer YOUR_ACCESS_TOKEN");
// Get the response code
int responseCode = connection.getResponseCode();
System.out.println("Response Code: " + responseCode);
// Read the response
BufferedReader reader = new BufferedReader(new
InputStreamReader(connection.getInputStream()));
String line;
StringBuilder response = new StringBuilder();
while ((line = reader.readLine()) != null) {
response.append(line);
}
reader.close();
// Print the response
System.out.println("Response: " + response.toString());
// Close the connection
connection.disconnect();
} catch (IOException e) {
e.printStackTrace();
}
}
}
|
只需一个基本的提示,我们就可以使用 LLM(大型语言模型)来创建可工作的代码(将apiUrl更新为真实的 API 返回了积极的结果)。因此,并不奇怪会有工具结合提示,试图自动化开发工作。例如,AutoGPT (github.com/Significant-Gravitas/AutoGPT) 和 MetaGPT (github.com/geekan/MetaGPT) 作为自主代理出现,根据初始问题生成自己的提示来解决复杂问题。尽管这些工具还处于起步阶段,但很明显为什么开发者被自动化取代的夸张说法会被广泛传播。
作为一位大部分职业生涯都在解释为什么测试自动化不适合作为替代方案的人来说,看到开发者以同样的方式捍卫他们的角色可能会让人感到一种幸灾乐祸的乐趣,但更重要的是从测试人员和自动化主题的经验中学习。正如测试人员的角色不能完全自动化一样,开发者的角色也不能。开发角色不仅仅是产生的代码。开发者创建的解决方案是分析技能、问题解决和设计思维的结果。LLM 工具给人一种拥有这些技能的印象,但这并不真实。
相反,开发者通过使用这些 LLM 工具来增强自己的能力而取得成功:他们可能会使用 Copilot 等工具快速有效地创建他们想要构建的代码,或者向 ChatGPT 寻求帮助以解决问题或学习新的 API。这些原则也可以应用于提高开发者将质量构建到应用程序中的能力。通过结合测试驱动设计(TDD)或 LLM 的力量等技术,开发者可以提高他们的生产力,同时确保他们的分析和设计技能处于领先地位。为了帮助展示这种共生关系,让我们探讨以下两个示例:
-
使用 Copilot 快速生成 TDD 循环的单元测试和生产代码
-
通过模拟开发者的配对,得益于 ChatGPT
通过这些示例,你将学会设置和使用这些 LLM 工具,以及欣赏在 AI 的力量和开发者的能力之间可能达到的平衡。
经验可能有所不同
由于 Copilot 依赖于经常在新增代码和更新的 API/libraries 上进行训练的预测算法,因此值得强调的是,您在遵循即将到来的示例时所获得的输出和体验可能与记录的内容不同。请记住,本章的目标不是 100%复制示例,而是让您熟悉使用 LLMs 以帮助我们以有助于构建质量的方式辅助我们的工作。
4.2 与 LLMs 配对
我们已经看到 LLMs 具有概率性本质,因此,将其视为输出角色模拟而不是占据特定角色可能是有用的。LLM 对自己是软件测试员的认识并不比自己是餐厅老板的认识更多。但是,通过提示工程,我们可以创建提示,将 LLM 的概率性输出框架化以模拟角色,帮助我们创建橡皮鸭进行互动。这在测试资源在可用性或能力方面有限的情况下,在开发能力方面非常有用。因此,让我们看看一些我们可以使用的样本提示,以获取有助于我们改进工作质量和产品的反馈。
等等,什么——橡皮鸭?
面对没有解决方案的问题时,向他人口头描述问题以寻找答案可能会有所帮助。通过向另一个人阐述问题,我们有时会发现解决方案自然呈现在我们面前。然而,并不总是有机会与同事交谈;因此,一些开发者会将他们的问题口头描述给橡皮鸭(或其他物品)。尽管我们是在与一个无生命的物体分享我们的挑战,但体验是相同的:口头描述我们的挑战会让我们的大脑找到解决方案。
4.2.1 分析想法
我们的第一个提示灵感来源于一些团队采用的左移测试思维模式,以帮助尽早将质量构建到应用程序中。左移测试意味着将测试分析提前到开发过程中,理想情况下是在讨论想法和澄清细节的点。这个过程导致我们提出许多问题,帮助我们尽早识别问题,交付有价值的特性,并提高我们产品的质量。左移活动是提高质量的无价方法,应鼓励使用,无论在开发过程中是否使用 AI。然而,通过将 LLM 引入过程,我们可以使用提示生成可能揭示假设或误解或我们可以简单忽略的问题。
让我们看看一个可以快速生成可能有用问题的样本提示。我们使用分隔符来告知 LLM 数据分析的位置:
|

| 你是一位软件测试专家。分析由三个井号分隔的文本,并生成问题以澄清文本中的歧义。 |
|---|
然后我们设置一系列关于如何提问和输出的规则:
|

| 使用“什么、哪里、为什么、何时、谁和如何”启发式方法生成问题 * 每个质量特性都可以识别出多个问题 * 我们关心的质量特性是:准确性、安全性和可访问性 * 以“问题 - 解释”的格式输出问题 |
|---|
少样本提示允许我们创建一个示例,展示我们期望的输出外观:
|

| 示例:什么是相关?验收标准说相关搜索结果,但我们如何确定什么是相关的,什么不是? |
|---|
然后我们将用于分析的数据输入到提示词的分隔部分:
|

| ### 作为一家 B&B 的业主,我希望能够管理用户访问,以便我可以允许不同级别的访问权限给多个用户功能: − 数据库配置了管理员用户 − 客人被要求创建账户以进行预订 − 管理员可以看到平台上创建的账户并可以删除它们### |
|---|
提示词概述了如何让一个大型语言模型分析用户故事和验收标准,并返回一个我们需要考虑的问题列表。注意我们也在质量特性说明中提供了上下文。如果我们想关注不同的特性,我们也可以更新这些内容。
将此提示输入到 ChatGPT 返回以下结果:
|

| 准确度:1. 确定用户访问管理准确性的标准是什么?说明:文本提到了管理用户访问,但没有具体说明如何确保为多个用户授予不同访问级别时的准确性。2. 系统是如何确保为用户分配正确的访问级别的?说明:文本提到了为多个用户允许不同级别的访问,但没有解释保证访问权限准确性的机制。安全:1. 哪些人可以访问配置了管理员用户的数据库?说明:文本提到了使用管理员用户配置数据库,但没有具体说明谁可以访问这个管理员账户以及如何维护其安全性。2. 平台是如何保护用户账户信息和数据的?说明:文本提到了客人创建账户进行预订,但缺乏关于保护用户数据所采取的安全措施的细节。3. 为了防止未经授权访问用户账户,采取了哪些安全措施?说明:文本提到管理员可以查看和删除账户,但没有解释如何防止对这种账户管理功能的未经授权访问。可访问性:1. 为不同类型的用户提供哪些级别的可访问性?说明:文本提到了使用不同级别的用户访问管理,但没有明确说明这些级别是什么以及它们如何根据用户类型而变化。2. 客人是如何被引导创建预订账户的,这个过程对所有用户都是可访问的吗?说明:文本指出客人被要求创建账户,但没有具体说明这个过程是如何进行的以及它是否符合可访问性标准。3. 为了确保平台对残疾人士的可访问性,采取了哪些措施?说明:文本没有提供有关如何解决残疾人士可访问性的信息,了解为满足他们的需求所采取的措施是至关重要的。4. 我们在哪里可以找到有关平台可访问性功能的信息?说明:文本没有提到有关平台可访问性功能的信息,因此了解这些信息在哪里可以获得将是有帮助的。 |
|---|
阅读响应后,我们可以看到有一系列有趣的问题,质量参差不齐。例如,考虑以下问题:
|

| 客人是如何被引导创建预订账户的,这个过程对所有用户都是可访问的吗?说明:文本指出客人被要求创建账户,但没有具体说明这个过程是如何进行的以及它是否符合可访问性标准。 |
|---|
这是一个有助于我们更详细地考虑在用户故事中找到的声明“客人被要求创建账户以进行预订”的问题。我们可以考虑这个问题,并思考我们如何设计预订流程,使其对所有类型的用户都易于访问。
然而,其他问题可能并不完全一样好。例如,
|

| 使用什么标准来确定用户访问管理的准确性?说明:文本提到了管理用户访问,但没有具体说明如何确保向多个用户授予不同访问级别时的准确性。 |
|---|
使这个问题成为问题的是其对质量特性准确性的引用。通常,准确性会更关注处理或存储的数据的准确性(想想银行利率计算)。相反,“用户访问的准确性”这个短语感觉像是一种奇特的方式来描述围绕用户及其可以访问的内容设定的规则。最终,评估每个问题的适宜性和使用取决于我们。一些问题可以鼓励我们构建更符合用户需求的产品,并帮助我们避免错误,而其他生成的问题可能没有意义或覆盖已考虑的主题。
我们将回到这个提示以及我们如何在开发功能时使用它,但首先,让我们看看我们如何重新利用这个提示来审查我们的代码。
4.2.2 分析代码
正如我们可以提示一个 LLM 分析写下的想法一样,我们也可以让它审查代码并帮助我们识别风险。使用这种提示类似于模拟你与开发者或测试人员配对时的角色,让他们在你开发过程中分析你的工作,并提出考虑的建议。让我们看看一个可能适用于此类活动的提示。我们使用分隔符来指示代码的位置,以及在没有提供代码的情况下检查假设:
|

| 你是一位软件测试专家。分析由三个井号分隔的代码,并识别可能影响代码的风险。如果没有提供代码,请回答“没有发现风险。” |
|---|
我们随后提供有关质量特性和输出结构的说明:
|

| * 风险必须与质量特性相关:性能、安全性和互操作性 * 每个质量特性都可以识别出多个风险 * 以风险 - 质量特性 - 说明的格式识别出已确定的风险 |
|---|
少样本提示允许我们给出输出结构的示例:
|

| 示例:溢出 - 安全性 - 将大整数作为参数添加可能会导致方法触发缓冲区溢出。代码在指定的分隔符内提供:
###
public BookingResult createBooking(Booking bookingToCreate) throws
➥SQLException {
if(dateCheckValidator.isValid(bookingToCreate.getBookingDates())) {
if (bookingDB.checkForBookingConflict(bookingToCreate)) {
return new BookingResult(HttpStatus.CONFLICT);
} else {
CreatedBooking createdBooking =
➥bookingDB.create(bookingToCreate);
if(bookingToCreate.getEmail() != null &&
➥bookingToCreate.getPhone() != null){
MessageBuilder messageBuilder = new MessageBuilder();
Message message = messageBuilder.build(bookingToCreate);
messageRequests.postMessage(message);
}
return new BookingResult(createdBooking,
➥HttpStatus.CREATED);
}
} else {
return new BookingResult(HttpStatus.CONFLICT);
}
}
###
|
在我将带有示例代码和品质特性的提示发送给 ChatGPT 后,返回了以下输出:
|

根据提供的代码片段,让我们分析可能影响性能、安全性和互操作性的潜在风险: • 性能 - 嵌套验证: − 风险:嵌套验证检查(dateCheckValidator.isValid和bookingDB.checkForBookingConflict)可能会导致性能问题,尤其是在处理大量预订时。 − 说明:如果系统需要处理大量的预订,嵌套验证可能会导致执行时间增加和资源消耗增加。 • 性能 - 消息发布: − 风险:消息发布(messageRequests.postMessage)可能会引入性能瓶颈,影响整个系统的响应速度。 − 说明:如果消息发布过程涉及网络通信或 I/O 操作,可能会减慢createBooking方法的执行速度。 • 安全 - SQL 注入: − 风险:代码容易受到 SQL 注入攻击,因为它直接在数据库查询中使用bookingToCreate对象。 − 说明:bookingToCreate对象中的恶意输入可能会操纵 SQL 查询,并危害数据库的完整性和安全性。 • 安全 - 权限不足: − 风险:代码在创建预订之前没有执行显式的授权检查,可能允许未经授权的用户创建预订。 − 说明:缺乏适当的授权检查可能导致未经授权的访问和系统预订创建功能的滥用。 • 互操作性 - 平台依赖性: − 风险:代码可能依赖于特定的平台或库,限制了它与其它系统或环境的互操作性。 − 说明:如果MessageBuilder或messageRequests依赖于特定平台的功能或外部库,可能会在不同环境中运行代码时引起问题。 • 互操作性 - 错误代码: − 风险:代码直接返回 HTTP 状态码,这可能与期望不同错误代码格式的其他系统不兼容。 − 说明:如果外部系统期望不同的错误代码格式(例如,JSON 或 XML),使用 HTTP 状态码可能会阻碍互操作性。 |
|---|
与之前的提示类似,它生成了关于书面要求的问题,这些不同风险的质量各不相同。例如,风险“性能 - 消息发布”感觉相当抽象,正如我们可以在其说明中看到的那样:
|

如果消息发布过程涉及网络通信或 I/O 操作,可能会减慢createBooking方法的执行速度。 |
|---|
在网络通信或 I/O 操作周围提出风险感觉模糊,因为它可能指代一系列实现,其中一些可能与我们的工作相关,而另一些则可能不相关。
然而,其他风险更具体且可能更有用——例如,“安全 - 授权不足”,它突出了以下内容:
|

| 代码在创建预订之前不执行显式的授权检查,可能允许未经授权的用户创建预订。 |
|---|
这种风险感觉更具体,因为它指的是在我们方法中执行的动作以及其中可能缺失的重要检查。当然,我们可能在其他地方执行授权检查,但该信息所提供的信息突显了一个可能需要进一步讨论以改进预订功能安全性的明确活动。
生成更多想法
到目前为止,我们一直关注向 LLM 发送的单一提示,这为我们提供了有用的响应以供审查。但如果我们想征求更多问题和风险呢?我们只需提交一个额外的提示,例如“生成更多问题”或“识别进一步的风险”。但请注意,这会带来递减的回报。LLM 会尝试满足我们的请求,但可能会增加幻觉。因此,当选项开始枯竭时,我们可能会看到更多与最初我们想要反馈的想法和代码不太相关的建议。
4.2.3 认识到模拟总比没有好
当讨论测试时,重点通常放在测试用例的生产和执行上。但一个训练有素且经验丰富的测试人员通过运用他们的批判性和横向思维技能,提出有助于以新方式看待解决方案并揭示潜在问题的问题。我们已查看的提示可以提供该过程的模拟。然而,重要的是要记住,LLM 没有这些批判性和横向思维技能,而且生成的问题和风险来自我们提示的指令。相反,这类提示可以提供一种轻量级的方式来模拟与测试人员或其他开发人员配对的经验,当没有配对机会时。关键是培养对生成问题的洞察力,以确定哪些是有用的。
4.3 使用 AI 辅助构建质量
到目前为止,我们一直将提示视为单一活动,但现在,让我们转向关注我们最近学到的提示和其他 LLM 辅助工具如何与 TDD 结合使用,以帮助我们构建质量。
虽然与其它测试活动相比,TDD 并非严格意义上的测试活动,但正确执行的 TDD 有助于指导开发者将质量融入产品。为了回顾,TDD 的过程是首先使用单元检查工具创建失败的检查,然后编写足够的生产代码以使检查通过(并修复可能失败的任何其他检查)。一旦所有我们的检查都通过,我们可以在确保所有检查都是绿色的同时重构我们的生产代码。一旦完成,我们再次开始循环,直到我们的工作完成,如图 4.1 所示。

图 4.1 红绿重构 TDD 循环
所有这些关于检查的讨论是什么意思?
在我们的测试自动化培训中,理查德·布拉德肖和我区分了以人为导向和以工具为导向的测试。我们将后者称为自动化检查,因为工具只能断言我们编码到自动化中的明确动作或数据。这种区分帮助我们更好地理解,自动化工具如单元检查框架在快速检查产品中的小而具体的更改方面非常出色,但无法告诉我们关于系统更多超出其断言的信息。然而,人类在测试中较慢且不太确定,尽管我们在识别同时发生的许多事件方面效率更高。因此,这就是为什么工具检查而人类测试的原因。一个并不比另一个更好,希望这本书能证明当我们结合两者时,我们可以取得最佳的成功。
这种方法使我们能够设计高度可测试的产品,同时确保我们交付业务或最终用户所期望的产品。
虽然其好处是多方面的(改进代码设计,使其更易于测试,最重要的是,提高其质量),但一些开发者发现很难采用 TDD 方法,认为它减慢了开发速度,因为我们为系统添加的每个特定部分的生产代码创建单元检查。然而,有了像 Copilot 这样的工具,我们可以学习如何建立平衡,使工具能够快速创建单元检查并构建质量。
设置 Copilot
本章假设您已在您的 IDE 中安装并配置了 Copilot 插件。如果您尚未完成设置过程,您可以在附录 B 中找到安装说明。
4.4 使用 LLMs 创建我们的第一个 TDD 循环
为了演示,让我们经历创建样本时间表管理器部分功能的过程:
-
作为时间表用户,我希望能够跟踪特定项目的时间,以便我可以保留我在各个项目上花费的时间历史记录。
-
功能:
-
时间以半小时为单位进行跟踪。
-
要完成时间表,必须输入项目和花费的时间。
-
只有我应该能够看到我跟踪的时间。
-
您可以在以下 GitHub 仓库中查看本示例中创建的测试和生产代码:mng.bz/6YMG。
4.4.1 准备工作
在开始创建我的时间表管理功能之前,我想考虑我将如何构建它。为了帮助我进行这个过程并鼓励我从不同角度思考解决方案,我使用了本章早期探索的第一个提示,并将我的上下文输入其中。具体来说,我将质量特性更改为准确性和一致性,并将用户故事添加到提示底部分隔的段落中:
|

| 您是一位软件测试专家。分析由三个井号分隔的文本,并生成将澄清文本中模糊性的问题。 * 问题将通过使用 What(什么)、Where(哪里)、Why(为什么)、When(何时)、Who(谁)和 How(如何)启发式方法生成 * 每个质量特性都可以识别出多个问题 * 我们关心的质量特性是:准确性和一致性 * 以“问题 - 例子”的格式输出问题。示例:什么是相关的意思?验收标准说相关搜索结果,但我们如何确定什么是相关的,什么不是?### 作为时间表用户我想要能够跟踪特定项目的时间,以便我可以记录我在各个项目上花费的时间历史记录功能: − 时间以半小时为单位跟踪 − 要完成时间表,必须输入项目和时长 − 只有我应该能够看到我跟踪的时间### |
|---|
将此提示发送到 ChatGPT 返回以下响应:
|

| 准确性: • 跟踪半小时单位时间所需的精度是多少? - 文中提到时间是以半小时为单位进行跟踪的。了解这种跟踪所需的精度是很重要的。 • 系统将如何处理无效的时间条目? - 由于必须输入时间才能完成时间表,有哪些措施可以确保只接受有效的时间条目? • 有哪些验证机制可以防止用户输入错误的时间数据? - 为了保持准确性,了解系统将如何验证和处理潜在的数据输入错误是至关重要的。 • 系统将如何处理不同地点的用户的时间区域差异? - 这对于确保不同时区的用户跟踪的时间一致和准确是很重要的。 |
|---|
| 一致性: • 跟踪时间的历史记录将存储在哪里,如何访问? - 为了确保一致性,了解数据将存储在哪里以及用户如何访问它是很重要的。 • 显示和格式化跟踪时间信息的规则是什么? - 在整个系统中一致地显示跟踪时间对于无缝的用户体验至关重要。 • 谁负责验证和维护跟踪时间数据的一致性? - 将数据完整性和一致性责任分配给个人是避免差异的必要条件。 • 时间表数据何时以及多久更新一次? - 确保实时更新或计划同步的一致性对于避免用户数据之间的差异至关重要。 • 如果多个用户同时尝试更新同一项目的时表,系统将如何处理潜在的冲突? - 当涉及多个用户时,保持数据更新的致性对于防止数据丢失或不准确至关重要。 • 有哪些安全措施可以保护数据并确保其完整性? - 一致性还涉及保护数据免受未经授权的访问或篡改。 |
质量对不同的人意味着不同的事情,是一个多方面的概念。因此,当我们试图理解质量对利益相关者或最终用户意味着什么时,我们将对它的思考方式分解为不同的特性。例如,质量特性可能包括外观和感觉、可用性、合规性以及更多。不同的项目将会有不同的质量特性,我们需要优先考虑,并且作为团队,我们需要确定对我们和我们的用户来说哪些质量特性是优先的。
从这里,我阅读了我收到的每一个问题,并记下了那些突出显示了我未曾考虑过的观点。让我们来探讨一些这些问题,并了解我对每个问题的思考:
质量特性是什么?

| 系统将如何处理无效的时间条目?/ 有哪些验证机制可以防止用户输入错误的时间数据? |
|---|
这两个问题感觉非常相似,但它们突出了我将在某个时候需要将日期时间处理添加到代码中,以防止无效输入引起的问题。
|

| 对于位于不同位置的用户的时区差异,系统将如何处理? |
|---|
这是一个好问题,但与这项工作不相关,因为我计划只跟踪执行的时间量,而不是执行的时间(至少目前是这样)。
|

| 显示和格式化跟踪时间信息的规则是什么? |
|---|
连接到前一个问题,我选择简单地跟踪已经过去的时间量,而不是具体发生的时间,这意味着信息的显示相对简单。
|

| 如果多个用户同时尝试更新同一项目的工时表,系统将如何处理潜在的冲突? |
|---|
目前,系统中没有多用户功能,尽管它可能以后会被引入,所以我记下在未来的迭代中考虑这一点。
还可以提出和解决更多的问题,但这个过程展示的是,通过生成不同的问题,我需要思考这个功能设计的各个方面。有些我可以选择排除,或者留待以后讨论,而有些则通过提高我对验证和日期时间格式化的认识来帮助我提高功能的品质。
4.4.2 循环 1:保存工时条目
在心中想着之前提示中的问题,我开始我的第一个循环,在 IDE 中创建一个空的 maven 项目,然后前往pom.xml添加我需要的依赖项。我需要 JUnit 5 来构建我的单元测试,所以我输入了以下注释
|

|
<!-- Junit jupiter engine-->
|
Copilot 返回以下代码块:
|

|
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.8.2</version>
<scope>test</scope>
</dependency>
</dependencies>
|
在撰写本文时,junit-jupiter-engine建议的版本是一个较旧的版本,所以我更新到5.9.2,如下所示:
|

|
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
</dependencies>
|
这个需要更新的例子很好地说明了数据来源如何影响建议的内容。虽然 Copilot 不断在 GitHub 上推送的代码上进行训练,但它可能会落后于库和 API 的最新版本。因此,当我们通过 AI 代码助手添加依赖项时,我们需要保持警惕,以确保它们建议的是我们想要的版本。
此外,这里展示的是 Copilot IDE 插件如何将我编写的代码(或在这种情况下是一个代码注释)添加到一个提示中,并将其发送到 Copilot LLM 进行处理。LLM 随后将建议的下一步输入发送回 IDE 插件,插件然后向我展示这个建议。我现在可以选择通过按 Tab 键接受建议,或者继续编写我自己的代码。
与其他 LLM 类似,Copilot 通过提示被触发。但与基于聊天的 LLM(如 ChatGPT)不同,Copilot 调整以处理基于代码的提示而不是基于文本的提示。这种区别很重要,因为它突出了不同类型的微调 LLM 对不同活动的益处,以及编写适用于不同提示类型的提示的需要。这就是为什么 TDD 和 AI 助手是一个有趣的组合。我们创建的单元检查不仅框定了我们的工作设计,还作为提示,告诉我们如何实现功能。
例如,对于我们的第一个检查,我提示 Copilot 为我创建第一个单元检查,通过在src/test/java中一个名为TimesheetTest的新类中添加以下注释:
|

|
// Test that when a timesheet is submitted with a project name
➥and hours it returns true
|
这触发了 Copilot 建议以下单元检查:
|

|
@Test
public void testSubmitTimesheetWithProjectNameAndHours() {
Timesheet timesheet = new Timesheet();
boolean result = timesheet.submitTimesheet("Project 1", 8);
assertEquals(true, result);
}
|
观察这个建议的检查,我们看到它包含了我们所需要的必要细节。它为我要构建的类提供了一个合理的名称Timesheet,并建议了具有正确参数(项目名称和小时数)的submitTimesheet方法。这个检查足够作为提示,当我创建一个名为Timesheet的新类在src/main/java中时,Copilot 建议
|

|
public boolean submitTimesheet(String projectName, int duration) {
return true;
}
|
单元检查作为提示,促使 Copilot 创建我的生产代码,尽管产生的代码并不那么令人兴奋,但检查已经完成。我们还可以看到,Copilot 通过构建提示来达到我们想要的结果。首先,我创建了一个代码注释形式的提示,以帮助 Copilot 为单元检查建议代码,然后这个单元检查作为建议生产代码的提示。随着单元检查的增加,对 Copilot 的提示和构建指导也将增加。现在,让我们看看我是如何使用这个过程来构建submitTimesheet方法的。
4.4.3 循环 2:检索时间表条目
通过第一个循环,我创建了项目的骨架,但到目前为止,实际的生产代码几乎没有成果。因此,在第二个循环中,我试图向Timesheet类添加更多功能,以帮助完善submitTimesheet方法。再次,我首先向TimesheetTest添加一个新的注释提示:
|

|
// Test that when timesheets are added they can be retrieved as a list
|
在创建新行后,Copilot 返回以下检查作为建议:
|

|
@Test
public void testAddTimesheetsToList() {
Timesheet timesheet = new Timesheet();
timesheet.submitTimesheet("Project 1", 8);
timesheet.submitTimesheet("Project 2", 8);
timesheet.submitTimesheet("Project 3", 8);
assertEquals(3, timesheet.getTimesheets().size());
}
|
这次,生成的检查是通过submitTimesheet方法创建一些数据,然后我可以使用新创建的getTimesheets方法进行验证。所以,我转向Timesheet类,使用 Copilot 生成必要的getTimesheets方法,它返回
|

|
public Collection<Timesheet> getTimesheets() {
return null;
}
|
虽然它为我创建了方法签名很有帮助,但方法内部并不那么有用。然而,我在类的顶部添加了以下内容:
|

|
private HashMap<String, Integer> timesheets = new HashMap<>();
|
然后,我回到 submitTimesheet 方法并从方法内部移除 return true。当我将关键字 if 输入到语句中时,Copilot 返回
|

|
public boolean submitTimesheet(String projectName, int duration) {
if (projectName != null && duration > 0) {
timesheets.put(projectName, duration);
return true;
} else {
return false;
}
}
|
这是一个有用的演示,说明了在 LLM 与 Copilot 一起工作时,项目需要一定的预热过程,才能返回更准确的建议。通过返回到 submitTimesheet 方法,移除其内容,然后等待 Copilot 响应,我得到了 Copilot 基于我提供的新细节的刷新响应。正如一个聊天 LLM 需要清晰和明确的信息一样,Copilot 也是如此。Copilot 返回的 Timesheet 的初始库存方法可能是因为缺乏上下文。基本上,发送给 Copilot 的提示缺乏信息。但随着我创建新的单元检查和额外的代码来展示我们在代码中的意图,建议变得更加有用。
当我完全删除 getTimesheets 方法时,这也得到了演示。随着新添加到类中的 HashMap,我的更新代码提示 Copilot 返回方法的更新版本:
|

|
public Collection<Integer> getTimesheets() {
return timesheets.values();
}
|
在这个循环之后运行我的两个单元检查,我可以看到两个检查都通过了,现在有了一个比开始循环时功能更丰富的有效代码。
4.4.4 循环 3:计算项目的工时
对于最后的循环,我回到了 ChatGPT 返回的问题,并思考了以下问题:
|

| 如果多个用户同时尝试更新同一项目的工时表,系统将如何处理潜在的冲突? |
|---|
虽然在这个项目的这个阶段还没有为多个用户添加功能,但 ChatGPT 提出的问题确实让我思考了如何处理向同一项目提交多个工时表的情况。因此,我创建了第三个单元检查来处理这种情况,再次创建一个代码注释来提示 Copilot 进行另一个单元检查:
|

|
// Test that the total hours worked can be calculated from a list
➥of timesheets from one project
|
它返回了以下单元检查:
|

|
@Test
public void testCalculateTotalHoursWorked() {
Timesheet timesheet = new Timesheet();
timesheet.submitTimesheet("Project 1", 8);
timesheet.submitTimesheet("Project 1", 8);
timesheet.submitTimesheet("Project 1", 8);
assertEquals(24, timesheet.getTimesheets().stream()
.mapToInt(Integer::intValue)
.sum());
}
|
这次,建议的检查很接近,但我希望 Timesheet 类返回一个总数,而不是在单元检查本身内部处理总数。因此,我选择更新检查,明确指出有一个获取总工时小时数的方法:
|

|
@Test
public void testCalculateTotalHoursWorked() {
Timesheet timesheet = new Timesheet();
timesheet.submitTimesheet("Project 1", 8);
timesheet.submitTimesheet("Project 1", 8);
timesheet.submitTimesheet("Project 1", 8);
int total = timesheet.getTotalTimesheetHours("Project 1");
assertEquals(24, total);
}
|
返回到 Timesheet 并等待 Copilot,LLM 返回以下代码:
|

|
public int getTotalTimesheetHours(String projectName) {
return timesheets.get(projectName);
}
|
这允许代码编译,但我的新单元检查失败了,因为项目计数没有被更新。相反,因为我使用了 HashMap,计数被覆盖了。我需要更新 submitTimesheet 来处理重复的项目,我通过在 submitTimesheet 中,初始 if 语句之后插入以下注释来完成这项工作:
|

|
// Check to see if project already exists
|
添加代码注释返回以下建议的代码:
|

|
if (timesheets.containsKey(projectName)) {
// If it does, add the new duration to the existing total
duration += timesheets.get(projectName);
}
|
这扩展了submitTimesheet函数,使其能够增加时间表中的现有项目的持续时间:
|

|
public boolean submitTimesheet(String projectName, int duration) {
if (projectName != null && duration > 0) {
// Check to see if project already exists
if (timesheets.containsKey(projectName)) {
// If it does, add the new duration to the existing total
duration += timesheets.get(projectName);
}
timesheets.put(projectName, duration);
return true;
} else {
return false;
}
}
|
我对三个单元检查的最终运行确认它们都通过了,并且我在我的Timesheet类中拥有所有我想要的功能。然而,代码开始变得更为复杂,所以我将注意力转向使用 ChatGPT 重构我的代码。
4.4.5 代码重构
与他人配对的一个好处是配对伙伴可以看到我们可能看不到的潜在问题和风险。然而,在没有配对伙伴的情况下,我选择了使用本章之前探索的第二个提示——具体来说,是分析代码并返回建议的提示。将提示与我的代码以及质量特性的准确性和一致性结合起来,我向 ChatGPT 发送了以下提示。我稍微更新了说明,使其明确我正在提供 Java 代码:
|

| 你是一位软件测试专家。分析由三个井号分隔的 Java 代码,并识别可能影响代码的风险。如果没有提供代码,则回答“没有找到风险” |
|---|
我根据我的质量特性更新了附加说明:
|

| * 风险必须与质量特性相关:准确性 和 一致性 * 每个质量特性都可以识别出多个风险 * 以“风险 - 质量特性 - 说明”的格式输出识别出的风险 |
|---|
以下是一个示例,用于展示响应的格式:
|

| 示例:溢出 - 安全 - 将大整数作为参数添加可能会导致方法触发缓冲区溢出。 |
|---|
我从之前的循环中创建的代码被添加到分隔区域:
|

|
###
public class Timesheet {
private HashMap<String, Integer> timesheets = new HashMap<>();
public boolean submitTimesheet(String projectName, int duration) {
if (projectName != null && duration > 0) {
// Check to see if project already exists
if (timesheets.containsKey(projectName)) {
// If it does, add the new duration to the
// existing total
duration += timesheets.get(projectName);
}
timesheets.put(projectName, duration);
return true;
} else {
return false;
}
}
public Collection<Integer> getTimesheets() {
return timesheets.values();
}
public int getTotalTimesheetHours(String projectName) {
return timesheets.get(projectName);
}
}
###
|
发送提示后返回了以下我可能需要考虑的风险:
|

风险 1 - 准确性 - submitTimesheet 方法可能覆盖现有持续时间:submitTimesheet 方法允许在项目已存在于时间表的情况下,向现有项目的总持续时间添加新的持续时间。然而,这种方法并不总是能保证准确性。如果用户打算提交更正或更新特定项目的持续时间,此方法将不会提供准确的结果。它只是将新的持续时间添加到现有的持续时间上,而不考虑编辑或删除先前条目的可能性。风险 2 - 一致性 - 时间表 HashMap 中的空键:时间表 HashMap 允许空键。在按项目名称查询时间表信息时,这可能会导致不一致的问题。如果错误地将一个空名称的项目添加到时间表中,可能会在后续操作中引起问题,导致意外行为。风险 3 - 一致性 - 缺乏对项目名称的验证:submitTimesheet 方法在将其添加到时间表 HashMap 之前不验证项目名称。这种缺乏验证可能会导致不一致的数据,例如重复或不正确格式的项目名称。例如,如果添加了同一项目名称的不同变体(例如,"ProjectA"和"projecta"),系统可能会将它们视为不同的项目,导致报告或查询中的不一致。风险 4 - 准确性 - getTotalTimesheetHours 方法可能抛出 NullPointerException:getTotalTimesheetHours 方法直接调用 timesheets.get(projectName),如果给定的 projectName 不存在于时间表 HashMap 中,可能会抛出 NullPointerException。如果在调用此方法之前没有检查项目名称是否存在于时间表中,这可能导致不准确的结果。风险 5 - 准确性 - getTotalTimesheetHours 方法的返回类型:getTotalTimesheetHours 方法返回 int 数据类型,如果实际的总时间表小时数超过 int 可以持有的最大值,可能会导致准确性问题。如果项目的持续时间总和超过 Integer.MAX_VALUE,这可能导致溢出,从而造成数据丢失。风险 6 - 一致性 - 多线程环境中的缺乏同步:如果在多线程环境中使用此 Timesheet 类,由于此类的方法没有同步,因此存在不一致行为的风险。如果多个线程同时访问和修改时间表 HashMap,可能会导致数据损坏或计算错误。 |
|---|
与问题提示不同,这个响应似乎指出了我的代码中的具体项目,并给出了可能出错的原因。有了这个响应,我有以下选择:
-
根据每个案例逐一审查风险,然后减轻我认为重要的风险,忽略其他风险。
-
完全忽略所提出的风险,或者也许可以向 ChatGPT 发送另一个提示,看看是否有更多的风险。
-
将 ChatGPT 的响应本身用作提示,以帮助我重构我的代码。
为了演示目的,我选择了第三个选项,并向 ChatGPT 发送了额外的提示:
|

| 重构代码以减轻所建议的风险。在输出解决方案之前,检查每个风险是否已被减轻。 |
|---|
注意我是如何使用思考时间原则来让 LLM 检查它所建议的重构代码是否实际上减轻了每个风险的。这有助于增加返回的代码有用的可能性。将此提示发送到 ChatGPT 返回了以下代码:
|

|
import java.util.Collection;
import java.util.concurrent.ConcurrentHashMap;
public class Timesheet {
private ConcurrentHashMap<String, Long> timesheets =
new ConcurrentHashMap<>(); ❶
public boolean submitTimesheet(String projectName, long duration) {
if (projectName != null && duration > 0) {
// Perform validation on project name by converting it to
// lowercase
projectName = projectName.toLowerCase(); ❷
// Check to see if project already exists
timesheets.put(projectName, timesheets
.getOrDefault(projectName, 0L) + duration);
return true;
} else {
return false;
}
}
public Collection<Long> getTimesheets() { ❸
return timesheets.values();
}
public long getTotalTimesheetHours(String projectName) {
return timesheets.getOrDefault(projectName.toLowerCase(), 0L);
}
}
❶ 将 HashMap 改为 ConcurrentHashMap❷ 添加额外的代码将项目名称转换为小写❸ 将 int 数据类型转换为 long |
为了检查这些修改是否有效,我将建议的代码复制到 Timesheet 中,将 TimesheetTest 中的 testCalculateTotalHoursWorked 修改为使用 long 而不是 int,并发现我的检查仍然通过,并且我已经重构了我的代码。
然而,这有没有改善我的代码?为了确认,我再次运行了带有更新代码的原始代码分析提示,并收到了新的风险:
|

风险 - 准确性 - 案例敏感性问题:说明:方法 submitTimesheet 在将其用作时间表映射中的键之前将 projectName 转换为小写。这种转换可能会导致如果存在具有相同名称但不同字母大小写的项目(例如,"ProjectA" 和 "projecta" 将被视为同一项目)时数据检索不准确。风险 - 一致性 - 数据输入不一致:说明:submitTimesheet 方法不强制执行 projectName 值的一致数据输入。如果不同的用户以不同的格式(例如,"Project A"、"project-A"、"project_A")输入项目名称,则可能导致在时间表映射中为应该是同一项目的多个条目。风险 - 准确性 - 缺少对持续时间的验证:说明:尽管 submitTimesheet 方法检查持续时间是否为正数,但它不处理持续时间是 0 或负数的情况。这可能导致在时间表映射中记录错误的数据。风险 - 一致性 - 缺乏输入清理:说明:submitTimesheet 方法不清理 projectName 输入,这可能导致由于无效字符或意外输入而导致数据不一致。风险 - 准确性 - 可能的数据丢失:说明:submitTimesheet 方法返回一个布尔值,但如果提交失败,它不提供任何反馈或错误处理机制。如果提交静默失败且用户未意识到问题,这种缺乏反馈可能导致数据丢失。 |
|---|
在这个响应中有趣的是,我可以确认一些风险已被减轻,例如
|

风险 5 - 准确性 - getTotalTimesheetHours方法的返回类型:getTotalTimesheetHours方法返回 int 数据类型,如果实际项目总时间表小时数超过 int 可以持有的最大值,可能会导致准确性问题。如果项目持续时间的总和超过Integer.MAX_VALUE,这可能导致数据丢失,因为会发生溢出。 |
|---|
然而,似乎还有一些风险尚未得到缓解。例如,在第一份风险列表中,我收到了以下风险:
|

风险 3 - 一致性 - 项目名称验证缺失:submitTimesheet方法在将项目名称添加到时间表 HashMap 之前没有进行验证。这种验证缺失可能导致不一致的数据,例如重复或不正确格式的项目名称。例如,如果添加了同一项目名称的不同变体(例如,"ProjectA"和"projecta"),系统可能会将它们视为不同的项目,导致报告或查询中的不一致。 |
|---|
这是由 ChatGPT 通过实现一个lowerCase方法来帮助清理项目名称来处理的。然而,在第二次分析中,我得到了以下结果:
|

风险 - 一致性 - 不一致的数据录入:说明:submitTimesheet方法没有强制执行projectName值的一致性数据录入。如果不同的用户以不同的格式(例如,"Project A","project-A","project_A")输入项目名称,可能会导致在时间表映射中出现多个应属于同一项目的条目。 |
|---|
这种风险与原始的、据说已经缓解的风险非常相似。感觉这种关于不一致数据录入的额外风险应该在代码重构时得到妥善处理。我再次可以要求 LLM 为我重构代码,但鉴于与 LLM 可能陷入循环的风险,我亲自带头解决问题会更明智。这是需要培养的重要技能——知道何时依赖 LLM,何时亲自负责。
这种选择之所以重要,可以通过分析中提出的其他建议风险之一来强调。具体来说,
|

风险 - 准确性 - 持续时间的验证缺失:说明:尽管submitTimesheet方法检查持续时间是否为正数,但它没有处理持续时间等于 0 或负数的情况。这可能导致在时间表映射中记录错误的数据。 |
|---|
这听起来像是一个令人信服的风险,但它却是一个幻觉的展示。按照目前的代码,如果持续时间≥0,该方法将简单地返回 false 并退出时间表存储:
|

|
if (projectName != null && duration > 0)
|
有时 LLM 可能会偏向于优先给出答案,而不考虑其质量,这意味着我们要求 LLM 分析我们的代码的次数越多,它就越有可能开始产生幻觉,以给人留下它正在产生有用结果的印象,而不是返回没有有用信息可以分享的响应。这就是为什么我们必须仔细监控何时使用 LLM 是有益的。
在这一点上,我选择停止使用用例,因为我们所涵盖的内容展示了不同类型的 LLM 如何以不同的方式帮助我。Copilot 提供了快速生成代码的能力,但它需要基于代码的提示来帮助它提出建议。这意味着如果我们正在处理一个全新的项目,其中几乎没有用于我们的 LLM 分析的生产代码,那么我们更有可能从 Copilot 那里得到对我们无用的结果。因此,为了提高 Copilot 输出的质量,我们通过单元测试提供更多的上下文。这不仅有助于引导 Copilot 构建我们的代码,还为我们提供了 TDD(测试驱动开发)的好处,包括设计良好的、可测试的代码。
使用 ChatGPT,我们已经证明,当正确提示时,它可以是一个有用的分析工具。构建能够分析想法和代码、提出风险和改进的提示,可以迅速为我们提供考虑的替代视角,我们随后可以选择采取行动或拒绝。利用 LLM 作为倡导质量的角色的模拟可以帮助我们改进我们的工作。
4.5 使用 LLM 改进文档和沟通
虽然这可能看起来不太明显,但通过代码注释和发布说明来传达我们所做的工作可以显著提高产品质量。通过分享代码库的新发展和变化,我们可以帮助其他开发者理解我们的工作如何影响他们,指导测试人员在测试我们的工作时关注什么,甚至帮助用户如何看待我们的产品(例如,Slack 的早期发布说明通过清晰的沟通和幽默帮助他们推广工具)。
尽管有这些好处,但文档和发布说明有时会被留到开发周期的最后,或者完全被忽视。考虑到编写和维护有用且相关的代码注释和发布说明所需的时间,这是有道理的,尤其是在有压力不断交付新功能的时候。然而,使用 LLM,我们可以减少这种时间开销,同时确保我们生成对未来的读者有价值的文档。因此,让我们看看一些可以快速为我们生成文档的有用提示。
4.5.1 生成代码注释
虽然我们应该始终努力创建流畅且易于解析的代码,无论对代码库的经验如何,代码注释可以提供额外的细节,防止代码误用并加快开发速度。如果我们发布的 API 将被他人使用,这一点尤为重要。(我曾在许多场合浪费时间试图从文档不佳的 API 文档中理解库的工作原理。)挑战在于在代码注释中找到正确的平衡:注释太少,阅读你代码的人将不得不自行应对;注释太多,我们在维护代码和注释以确保它们一致时将产生更多的工作。
因此,让我们看看一个可以为我们处理用注释标注我们工作的过程的提示。我使用检查解决方案的策略来确保代码没有被更改:
|

| 你是一位 Java 开发者。更新由三个井号分隔的代码,并添加代码注释来解释代码的工作原理。在输出完整的带注释代码之前,请检查代码的任何部分都没有被更改。 |
|---|
接下来,我使用分隔符策略来指出代码的位置:
|

|
###
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class Timesheet {
private ConcurrentHashMap<String, Long> timesheets = new
➥ConcurrentHashMap<>();
public boolean submitTimesheet(String projectName, long duration) {
if (projectName != null && duration > 0) {
projectName = projectName.toLowerCase();
timesheets.put(projectName,
➥timesheets.getOrDefault(projectName, 0L) + duration);
return true;
} else {
return false;
}
}
public Collection<Long> getTimesheets() {
return timesheets.values();
}
public long getTotalTimesheetHours(String projectName) {
return timesheets.getOrDefault(projectName.toLowerCase(), 0L);
}
}
###
|
在这个提示中,我们使用通常的分隔符策略来提供我们希望看到注释的代码,但我们还想要确保大型语言模型(LLM)在输出完整的带注释代码之前不会更改我们的代码以使其与创建的注释相匹配。因此,我们在提示中也明确指出,在输出完整的带注释代码之前,请检查代码的任何部分都没有被更改。将我们之前提供的示例代码发送到 ChatGPT 后,返回了以下带注释的代码:
|

|
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class Timesheet {
// ConcurrentHashMap to store project timesheets where the key is
// the project name (in lowercase) and the value is the total duration.
private ConcurrentHashMap<String, Long> timesheets = new
ConcurrentHashMap<>();
// Method to submit a timesheet entry for a project.
public boolean submitTimesheet(String projectName, long duration) {
if (projectName != null && duration > 0) {
// Normalize the project name to lowercase to ensure case
// insensitive matching.
projectName = projectName.toLowerCase();
// Update the timesheet entry for the project by adding
// the submitted duration.
timesheets.put(projectName,
timesheets.getOrDefault(projectName, 0L) + duration);
return true; // Submission successful
} else {
return false; // Invalid input, submission failed
}
}
// Method to retrieve all timesheet durations.
public Collection<Long> getTimesheets() {
return timesheets.values();
}
// Method to get the total hours for a specific project.
public long getTotalTimesheetHours(String projectName) {
// Retrieve the total duration for the specified project
// (case-insensitive) or return 0 if not found.
return timesheets.getOrDefault(projectName.toLowerCase(), 0L);
}
}
|
如我们所见,代码中的方法和代码段现在已经被注释标注,以提供更多细节。最引人注目的是如下注释:
|

|
// ConcurrentHashMap to store project timesheets where the key is the
// project name (in lowercase) and the value is the total duration.
private ConcurrentHashMap<String, Long> timesheets = new
ConcurrentHashMap<>();
|
注释不仅提供了关于ConcurrentHashMap用途的参考,还强调了所有项目名称都将存储为小写,这是对submitTimesheet内部代码的一部分的引用,这与这些感觉有点不必要的注释形成对比:
|

|
// Method to submit a timesheet entry for a project.
public boolean submitTimesheet(String projectName, long duration)
|
再次,我们选择使用什么。虽然我们使用的提示已经为我们提供了注释,但我们观察到其中一些可能比其他的有用。因此,我们的角色从注释的作者转变为编辑,选择和调整看起来最有用的注释。
使用提示维护代码注释
使这个过程如此有用的原因是,当我们不可避免地到达代码更改而注释需要更新的时候,我们只需再次运行提示并使用更新的代码来重新生成注释,以包含我们的更改。
一个最后的观察是,尽管注释对于直接阅读我们代码的人来说可能很有用,但如果我们想以 Javadoc 等形式提供更结构化的文档呢?在这种情况下,我们可以修改提示并使用结构化数据策略来创建一个提示。我们将使用带有结构化数据策略的更新提示来生成 Javadoc:
|

| 你是一位 Java 开发者。更新以下由三个井号分隔的代码,并添加 Javadoc 格式的代码注释来解释代码的工作原理。在输出完整的带注释代码之前,请检查代码的任何部分都没有被更改。 |
|---|
我们随后提供使用分隔符策略的代码:
|

|
###
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class Timesheet {
private ConcurrentHashMap<String, Long> timesheets =
➥new ConcurrentHashMap<>();
public boolean submitTimesheet(String projectName, long duration) {
if (projectName != null && duration > 0) {
projectName = projectName.toLowerCase();
timesheets.put(projectName,
➥timesheets.getOrDefault(projectName, 0L) + duration);
return true;
} else {
return false;
}
}
public Collection<Long> getTimesheets() {
return timesheets.values();
}
public long getTotalTimesheetHours(String projectName) {
return timesheets.getOrDefault(projectName.toLowerCase(), 0L);
}
}
###
|
将此提示与我们的示例代码一起发送到 ChatGPT 产生
|

|
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* Represents a timesheet management system for tracking hours spent on
different projects.
*/
public class Timesheet {
// A concurrent hash map to store project names and their
// corresponding total hours
private ConcurrentHashMap<String, Long> timesheets = new
ConcurrentHashMap<>();
/**
* Submits a timesheet entry for a project.
*
* @param projectName The name of the project.
* @param duration The duration in hours to be submitted.
* @return true if the submission was successful, false otherwise.
*/
public boolean submitTimesheet(String projectName, long duration) {
if (projectName != null && duration > 0) {
// Normalize the project name to lowercase for consistency
projectName = projectName.toLowerCase();
// Update the total hours for the project or add a new
// entry if it doesn't exist
timesheets.put(projectName,
timesheets.getOrDefault(projectName, 0L) + duration);
return true;
} else {
return false;
}
}
/**
* Retrieves a collection of all total timesheet hours for all
* projects.
*
* @return A collection of total hours for all projects.
*/
public Collection<Long> getTimesheets() {
return timesheets.values();
}
/**
* Retrieves the total timesheet hours for a specific project.
*
* @param projectName The name of the project.
* @return The total hours spent on the specified project, or 0
* if the project is not found.
*/
public long getTotalTimesheetHours(String projectName) {
// Normalize the project name to lowercase for consistency
// and retrieve the total hours
return timesheets.getOrDefault(projectName.toLowerCase(), 0L);
}
}
|
使用这个提示,我们提高了某些注释的质量,并创建了 Javadoc 友好的注释,这些注释可以用来为外部用户记录我们的代码。现在我们只需要调整和编辑我们认为合适的地方。
4.5.2 生成发布说明
正如我们可以使用大型语言模型(LLM)来修改我们的代码以添加注释一样,我们也可以使用 LLM 将我们的代码库转换成可以被团队内外其他人消费的发布说明。为此,我们创建了一个包含以下细节的新提示;我们检查解决方案策略以确保发布说明可读:
|

| 你是一位 Java 开发者。将以下由三个井号分隔的代码转换为以项目符号形式呈现的发布说明,描述代码的工作原理。在输出之前,请检查发布说明是否可以被没有使用过之前创建的代码的人阅读。 |
|---|
我们添加了一个额外的指令来减少发布说明的冗长性:
|

| * 仅输出发布说明。 |
|---|
然后我们使用分隔符策略来指明代码的位置:
|

|
###
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
public class Timesheet {
private ConcurrentHashMap<String, Long> timesheets =
➥new ConcurrentHashMap<>();
public boolean submitTimesheet(String projectName, long duration) {
if (projectName != null && duration > 0) {
projectName = projectName.toLowerCase();
timesheets.put(projectName,
➥timesheets.getOrDefault(projectName, 0L) + duration);
return true;
} else {
return false;
}
}
public Collection<Long> getTimesheets() {
return timesheets.values();
}
public long getTotalTimesheetHours(String projectName) {
return timesheets.getOrDefault(projectName.toLowerCase(), 0L);
}
}
###
|
该提示遵循与之前针对代码注释的提示相似的格式,但这次,我们不是要求它向我们的代码中注入注释,而是指示将代码转换为自然语言的发布说明。例如,将包含示例代码的提示发送到 ChatGPT 返回
|

发布说明: * 该代码定义了一个名为 Timesheet 的 Java 类,用于管理时间表数据。 * 它使用 ConcurrentHashMap 来存储时间表条目,其中项目名称(小写)与其相应的持续时间(小时)相关联。 * submitTimesheet 方法允许用户提交时间表条目。它需要一个项目名称和正持续时间作为参数。如果满足这些条件,它将在映射中存储与项目名称关联的持续时间。如果不满足,则返回 false。 * getTimesheets 方法返回所有记录的时间表持续时间集合。 * getTotalTimesheetHours 方法允许用户检索特定项目名称的总持续时间(不区分大小写)。如果找不到项目名称,则返回 0。注意:在使用 submitTimesheet 方法时,请确保提供小写的项目名称,以避免在检索总时间表小时数时出现大小写敏感问题。 |
|---|
我们得到的结果与我们的原始代码完全不同的格式,原始代码试图详细说明我们的代码库做了什么。审查输出,它确实感觉相当技术性,这是有道理的,因为我们要求对一小段代码提供详细的说明。然而,即使这些发布说明也可能有用,因为我们可以根据早期提示的风险分析来调整提示。我们使用分隔符来指示发布说明的位置:
|

| 您是一位软件测试专家。分析由三重哈希分隔的发布说明,并识别可能影响代码的风险。如果没有提供代码,请回答“没有找到风险。” |
|---|
我们随后提供有关质量特性和输出结构的说明:
|

| * 风险必须与质量特性相关:性能、安全性和互操作性 * 每个质量特性都可以识别出多个风险 * 以“风险 - 质量特性 - 说明”的格式输出已识别的风险 |
|---|
少样本提示允许我们给出输出结构的示例
|

| 示例:溢出 - 安全性 - 将大整数作为参数添加可能会导致方法触发缓冲区溢出。 |
|---|
发布说明位于指定的分隔符内:
|

### 发布说明: * 代码定义了一个名为 Timesheet 的 Java 类,用于管理考勤数据。 * 它使用 ConcurrentHashMap 存储考勤条目,其中项目名称(小写)与其相应的持续时间(小时)相关联。 * submitTimesheet 方法允许用户提交考勤条目。它需要项目名称和正数持续时间作为参数。如果满足这些条件,它将在映射中存储与项目名称关联的持续时间。如果不满足,则返回 false。 * getTimesheets 方法返回所有记录的考勤持续时间集合。 * getTotalTimesheetHours 方法允许用户检索特定项目名称的总持续时间(不区分大小写)。如果找不到项目名称,则返回 0。注意:在使用 submitTimesheet 方法时,请确保提供小写项目名称,以避免在检索总考勤小时数时出现大小写敏感问题。### |
|---|
使用此提示在 ChatGPT 返回以下建议风险:
|

并发修改 - 性能 - 使用 ConcurrentHashMap 可能不适合高并发访问,如果多个用户同时频繁修改考勤条目,可能会导致性能瓶颈。输入验证不足 - 安全 - submitTimesheet 方法缺少适当的输入验证。它只检查正数持续时间,但不验证项目名称,使其容易受到注入攻击或其他恶意输入的攻击。大小写敏感 - 互操作性 - 需要使用小写字母提供项目名称的要求,在集成期望不区分大小写输入或具有不同命名约定的系统时可能导致互操作性问题。无身份验证/授权 - 安全 - 发布说明中没有提及任何身份验证或授权机制,如果未经授权的用户可以提交或访问考勤数据,可能会带来安全风险。数据暴露 - 安全 - getTimesheets 方法提供了对所有记录的考勤持续时间的访问,如果不适当限制或验证,可能会暴露敏感信息。 |
|---|
当然,如果我们可以直接要求大型语言模型(LLM)根据代码直接提出风险,这可能是一个不必要的步骤,但它确实展示了 LLM 将数据从一种格式转换为另一种格式的强大能力。
4.6 与代码助手保持平衡
对于像 Copilot 和 ChatGPT 这样的工具所提供的价值的看法取决于个人。对于一些人来说,这意味着开发和许多职位的自动化,而对于另一些人来说,它只是一个随机选择建议代码的高级工具。这一章所展示的是,它们的价值和用途介于这两个极端之间。
这些模型在 Stack Overflow 和 GitHub 存储库的大量数据上训练,在建议生产代码和测试代码方面非常复杂。但它们仍然需要我们人类的指导——这种指导是由我们与利益相关者沟通、分析需求和设计实现的能力所引导的。我们能在多大程度上在开发中使用 AI 工具,取决于我们磨练互补技能的能力,这可以通过图 4.2 所示的影响区域模型来总结。
平衡的方法可以帮助我们更快地交付功能,但仍然确保我们构建的质量。因此,我们的目标是保持这种平衡,在可能需要依赖我们自身能力或工具特性的情况下。有时,代码助手可能无法建议正确的实现,我们需要承担责任。这给了我们更多的控制权,但牺牲了速度。在其他时候,我们可以依赖代码助手工具通过单元检查或对话来参考大量数据,以提出新的设计想法。然而,我们希望确保我们保持 TDD 循环专注于设计,而不是测试覆盖率。单元检查过多,我们就会失去对设计的关注,最终陷入检查框子的活动中。

图 4.2:影响区域模型,更新以展示人类技能和代码助手工具的能力
摘要
-
目前市场上大多数生成式 AI 工具都依赖于使用从互联网上抓取的大量数据训练的 LLMs。
-
LLMs 是复杂的算法,它们通过对我们的请求进行统计分析,以确定它们应该响应什么输出。
-
Copilot 是一个使用 OpenAI GPT-4 的编码助手工具,它是在 GitHub 上存储的代码上训练的。
-
Copilot 在 IDE 中工作,并将你的代码作为提示来建议接下来应该添加到测试代码和生产代码中的内容。
-
如 Copilot 之类的工具可以很好地与 TDD 的红色/绿色/重构循环一起工作,帮助我们快速创建单元检查和生产代码。
-
为了帮助 Copilot 返回有价值的代码,我们需要通过提示来引导它。
-
AI 代码助手的成功取决于我们对自身能力和代码助手工具特性的理解。
-
我们主导与工具主导的设计之间存在推拉关系。
-
当平衡从人类转向工具主导时,我们必须意识到其中的权衡。
第五章:使用 AI 支持的测试规划
本章涵盖
-
模型的价值如何与 LLMs 的使用相关联
-
在测试规划中使用 LLMs 和模型
-
评估 LLMs 生成的建议的适用性
现在我们已经看到大型语言模型(LLMs)如何帮助支持开发中的质量,是时候解决 LLMs 能否生成测试用例的问题了。表面上,答案很简单:是的,它们可以。但更深层次、更重要的问题是,你为什么想要它们生成测试用例?通过无思考或无方向地生成大量的测试用例,我们希望实现什么?仅仅因为我们能够创建测试用例,并不意味着在特定情况下这样做是正确的。
提出这个问题的动机来自于使用 LLMs 来指导对一个功能、史诗或项目的所需测试的愿望。尽管 LLMs 在建议我们应该进行哪些测试方面可能很有价值,但我们仍然担心我们应该有多大的信任度以及我们应该依赖 LLMs 的程度。就像我们探索和将要探索的其他活动一样,我们需要找到一个平衡点。一方面,当我们使用 LLMs 来辅助测试时,我们必须保持健康的怀疑态度,但我们也不应该完全否定它们的潜在价值(只要我们保持对它们可能误导我们的批判性眼光)。因此,本章将探讨两个核心问题,即 LLMs 如何指导测试的方向,重点关注测试规划是如何进行的:
-
LLMs 能否支持我们的测试规划?
-
如何有效地使用 LLMs 来支持此类规划?
具体来说,我们将探讨在确定一个功能、史诗或项目所需的测试类型时我们需要执行的活动。为此,我们将考察现代软件开发团队中的测试规划是什么样的。但在那之前,我们将确定 LLMs 如何在规划阶段为我们提供最佳支持。
5.1 定义现代测试中的测试规划
对于我们大多数人来说,测试计划意味着详细文档,试图细致地定义我们将如何执行我们的测试。但如果你是现代敏捷软件开发团队的一员,问问自己:你上次为即将到来的工作记录测试计划是什么时候?如果是的话,它看起来像什么?
现在的测试规划形式多种多样。有些人可能说他们很久没有写测试计划了,有些人可能在一页测试计划中捕捉关键细节,或者依赖验收标准来确定进行哪些测试。其他人可能仍然按照严格的测试计划模板创建正式的测试计划。无论我们的规划是正式的还是非正式的,推动测试计划的是我们的产品和项目风险,这种关系在图 5.1 中得到了说明。

图 5.1 显示风险和测试规划之间关系的图表
因此,当我们规划要进行的测试时,我们的核心目标是定义和减轻具体、可衡量的风险。我们定义的风险告诉我们将进行哪些测试,并且根据预期的测试类型,我们可以考虑不同的测试想法。
5.1.1 测试计划、LLM 和影响范围
考虑到风险是我们测试的核心,我们需要与 LLM 建立一种关系,这有助于提升基于风险规划的方法,同时确保我们不会过度依赖 LLM 的输出。关于如何使用 LLM 来指导我们的测试,有很多讨论,即通过让他们为我们生成测试用例。 (为了讨论的需要,我们将包括自动和非自动测试用例。) 但仅仅因为 LLM 可以生成测试用例,并不意味着它们对于减轻我们关心的风险是必要的或相关的。结果可能是,一些建议的测试用例是有价值的,但使用 LLM 大大增加了进行浪费性或误导性测试的风险。
我们希望测试过程既具有指导性又高效,因此,专注于使用 LLM 来支持我们识别风险是关键。首先,它开辟了我们的测试方式。要求 LLM 生成测试用例将我们绑定到一种测试活动类型,尽管使用测试用例作为不同测试技术整体策略的一部分是有道理的,但我们不应仅依赖于测试用例的单一文化。相反,我们应该关注风险,探索我们可能有的不同关注点,而不受选择测试技术的偏见。一旦我们了解了我们关心的风险,我们就可以选择合适的测试活动来减轻这些风险。
其次,最重要的是,关注风险改变了我们使用 LLM 的方式。在图 5.2 中,影响范围模型展示了我们如何告诉 LLM 我们关注的特性和我们已经识别出的风险。

图 5.2 影响范围模型展示了人类和 LLM 作为助手的核心角色
如图中所示,LLM(大型语言模型)充当助手,扩展我们关于风险的想法,并可能帮助我们揭示尚未考虑的新风险,帮助我们扩大风险覆盖范围,同时确保我们控制测试的方向。
5.2 使用模型进行专注提示
如果我们想使用 LLM 帮助我们识别风险,我们需要意识到在确定关注点时过度依赖它们的危害。为了更好地理解这一点,让我们看看一个常见的例子,即 LLM 据说如何帮助进行测试,为什么它们可能存在问题,以及我们如何更好地指导 LLM 的使用,以增加其价值并降低误导测试的风险。
5.2.1 弱提示意味着弱建议
在本书的第一部分,我们看到了如何处理不当的提示,例如
|

| 为文件上传功能创建测试 |
|---|
返回一个非基于上下文的通用操作结果列表,这意味着它们需要大量的修改,或者可能会影响我们测试的质量(为了可读性,已缩减大小):
|

| 实现文件上传功能可能会引入一些风险,包括: 1. 安全风险: − 恶意文件:用户可能会上传包含恶意软件或病毒的文件,这可能会感染系统并危害安全。 − 注入攻击:如果系统未能正确验证文件类型和内容,它可能会容易受到跨站脚本(XSS)或 SQL 注入等注入攻击的攻击。 − 拒绝服务(DoS)攻击:攻击者可能会上传大量或多个文件来耗尽系统的存储或处理能力,导致服务拒绝。 2. 隐私风险: − 敏感数据泄露:用户可能会无意中上传包含敏感信息的文件,如个人身份信息、财务数据或机密文件。 − 数据泄露:不当处理上传的文件可能会导致数据意外泄露,尤其是如果文件对未经授权的用户可访问。 3. 合规风险: − 法规合规:未能遵守 GDPR 或 HIPAA 等法规,关于处理个人或敏感数据,可能会导致法律后果。 − 侵权:用户可能未经许可上传受版权保护的材料,使平台面临潜在的版权侵权法律诉讼。 4. 性能风险: − 带宽和存储:大文件上传可能会消耗大量的带宽和存储资源,可能会影响系统性能和可扩展性。 − 并发:多个用户同时上传大文件可能会对服务器资源造成压力,并降低系统响应速度。 |
|---|
如果我们输入垃圾,我们就会得到垃圾输出,如果我们不对我们与 LLM 的交互方式持批判态度,那么麻烦就会随之而来。我们已经知道,要从 LLM 中成功获得价值,我们需要创建针对我们想要解决的问题的具体提示。在生成合适的风险时,挑战在于能够为复杂系统创建这些特定的提示。达到这一点的技能不在于我们编写提示的能力,而在于将系统分解成更易于管理的块。通过这样做,我们就可以创建更具有针对性和专注性的提示。因此,问题转变为如何将系统分解以创建更好的提示。
5.2.2 模型是什么以及为什么它们能有所帮助
在我们进入使用模型帮助我们进行提示的过程之前,让我们明确一下“模型”的含义。当我们使用“模型”这个词来指测试和软件开发时,我们是指某些信息的抽象表示。它可以是可视化的(例如,应用程序的数据流图),或者它可以是存储在我们心中的东西。我们说它是抽象的,因为尽管它基于现实中的信息,但模型通常简化、强调或忽略其基于的信息的某些方面。它提供了一个部分的真实图景。这就是为什么在讨论模型时使用乔治·博克斯的引言“所有模型都是错误的,但有些是有用的”这个格言的原因。模型倾向于用于关注更广泛背景下的特定属性或概念。这可能会被视为批评,但当它被良好地利用时,它可以帮助我们解决问题。例如,考虑图 5.3 中所示的应用程序模型。

图 5.3 系统模型的基本可视化
这个应用程序的视觉模型被设计用来帮助读者确定 API 之间的依赖关系。注意它并没有包含平台的所有细节。API 被抽象为盒子,总结了每个 API 内部代码的细节,而系统的前端则简单地总结为名为“用户界面”的盒子。这个模型是有缺陷的,因为它没有给我们提供一个关于平台每个方面的准确图景,但它仍然是有用的,因为它强调了读者关心的细节——平台上的 API 之间的关系。如果读者想了解 API 的依赖关系,那么这个模型是有价值的。然而,如果读者想了解前端的具体实现或每个 API 内函数的具体行为,这个模型就毫无价值了。
因此,在创建模型时,我们倾向于只通过突出我们想要的信息并丢弃其他细节来获得一个部分图景。这种系统建模的方法可以帮助我们使提示生成更具情境性和价值的建议风险。
5.3 结合模型和 LLM 以协助测试规划
既然你更好地理解了创建不专注于系统特定部分的提示的危险,让我们看看我们如何通过使用模型来减轻这种风险。为了展示我们的基于模型的策略,让我们假设我们正在使用一个 LLM 来帮助我们进行测试规划过程。
5.3.1 创建一个用于识别提示的模型
毫不奇怪,我们的第一步是创建一个应用程序的模型。我们将在稍后探讨我们可以使用的不同格式来创建模型,但在此期间,让我们假设我们已经创建了一个类似于图 5.4 所示的模型。

图 5.4 应用程序的可视模型
模型使用数据流图来概述在从 API 请求一系列预订的过程中数据是如何处理的。模型本身不是一个完整的系统,而是存在于更广泛的 B&B/酒店预订管理系统中的一个特定功能部分。为了分解模型所指定的内容,当管理员想要查看存储在数据库中的预订列表时,以下步骤会发生:
-
发送一个请求以查看预订列表,并将令牌附加到请求上。
-
然后,令牌从初始请求中提取出来,并送去做验证,以对比有效会话列表。
-
如果令牌被确认为一个有效的会话,那么一个积极的响应将被返回到管理视图预订请求的部分系统。
-
预订列表随后被汇总并发送回管理员。
考虑到这个流程,让我们首先看看如果我们构建一个尝试一次性包含模型中所有内容的提示,我们可能会从 LLM(大型语言模型)得到什么样的响应。首先,我们会创建一个提示:
|

| 你是一位软件测试专家。为使用基于会话的令牌来授权访问查看预订列表的应用程序生成一份风险列表。 |
|---|
当提示被发送到一个 LLM,例如 ChatGPT 时,我们会收到如下类似的响应:
|

返回的列表乍一看很长,但阅读后,似乎存在一些问题。首先,查看建议的风险列表,它们主要集中在安全风险上。虽然安全风险是需要考虑的重要因素,但关于认证和预订组件之间的交互或预订列表的编制的风险却很少。LLM 过于关注提示中的标记部分。因此,让我们考虑一个通过关注我们创建的模型的一个特定部分来创建的提示:
|

| 你是一位软件测试专家。尽可能多地生成由三个井号定界的行为的风险。###一旦授权,将存储在数据库中的预订列表发送给用户进行审查的预订 API### |
|---|
使用这个提示,我们试图描述系统中被我们模型捕获的特定部分。我们不是要求一次性涵盖所有功能部分的建议,而是选择系统的一个部分来查看我们是否能得到更好的建议。这意味着如果我们把这个发送给像 ChatGPT 这样的 LLM,我们可能会看到以下建议列表(为了可读性再次进行了缩减):
|

| 1. 数据安全风险:由于认证或授权机制不足,可能导致对敏感预订数据的未授权访问。如果 API 没有加密服务器与用户之间的数据传输,数据可能容易受到拦截,存在数据泄露风险。如果 API 没有正确清理输入,存在 SQL 注入攻击风险,可能导致对数据库的未授权访问或篡改。 |
|---|
让我们比较一下我们从 ChatGPT 那里得到的这个响应和之前的响应,看看是否有改进。首先,有一个问题就是初始提示过于关注功能的具体方面,而忽略了其他部分。鉴于我们已经根据我们的模型明确选择了一个不同的系统部分,那么新提示响应中大量关注测试预订列表的方式是有道理的。我们就是要求它这样做。但引人注目的是建议的多样性。在初始提示中,大部分建议都是基于标记可能处于的不同状态;这个提示不仅提供了更多的建议,还混合了关于预订正确性、边界、无效数据和缓存的多种想法。接下来,有一些建议过于模糊,无法采取行动。审查每个标题下的建议,我们可以看到它们更加明确——例如,负测试建议提到了需要考虑的具体状态码类型。
结果得到了改善,因为当在提示中添加更多上下文时,大型语言模型(LLMs)的响应更好,通过使用模型,我们可以分割行为,专注于特定的组件,并在我们的提示中捕捉和传达上下文。这确实意味着我们正在忽略模型中的其他组件,以便专注于如何传达系统的一部分工作。但如果我们迭代这个过程,我们可以改变我们想要为哪个模型部分创建提示。例如,我们可以运行一个专注于系统标记验证部分的第二个提示,创建如下提示:
|

| 你是一位专家软件测试员。尽可能多地生成由三个哈希符号分隔的行为的风险。###一个授权 API,它将从不同的 API 获取令牌,并在响应积极或消极之前检查令牌是否有效且在有效期内。### |
|---|
当发送到像 ChatGPT 这样的 LLM 时,它可能会返回如下内容:
|

| 1. 令牌验证失败:授权 API 可能无法正确验证从不同 API 接收到的令牌,导致未经授权的访问。2. 令牌过期:授权 API 可能无法准确检测过期的令牌,允许权限不再存在的用户访问。3. 假阳性/假阴性响应:API 可能提供不正确的响应,要么授予未经授权的用户访问权限,要么拒绝授权用户的访问。4. 性能下降:如果令牌验证过程资源密集,可能会导致在高流量期间性能下降或甚至停机。5. 依赖性故障:如果提供令牌的不同 API 出现停机或问题,授权 API 可能无法正确执行其功能。6. 安全漏洞:授权 API 可能容易受到安全漏洞的影响,如注入攻击或不正确的访问控制机制。7. 缺乏日志记录/监控:没有适当的日志记录和监控,可能难以识别未经授权的访问尝试或诊断令牌验证问题。8. 错误处理不足:较差的错误处理可能导致模糊或误导性的错误消息,使开发者难以调试集成问题。9. 令牌欺骗:恶意行为者可能尝试欺骗令牌以获得未经授权的访问,利用令牌验证过程中的弱点。10. 令牌重放攻击:攻击者可能截获有效的令牌并重放它们以获得未经授权的访问,突出了实施防止令牌重放攻击措施的重要性。11. 令牌过期竞争条件:如果令牌在验证和授权之间过期,可能会发生竞争条件,可能允许未经授权的访问。12. 令牌验证逻辑不一致:如果授权 API 和不同 API 之间的令牌验证逻辑不同,可能会导致不一致和意外的行为。13. 法规遵从问题:未能正确保护和验证令牌可能导致不符合行业法规或数据保护法。14. 可扩展性担忧:随着用户和令牌数量的增加,授权 API 的可扩展性可能成为问题,可能导致性能问题或服务中断。15. 集成挑战:将授权 API 与其他 API 和其他系统集成可能带来技术挑战,例如兼容性问题或数据格式不匹配。 |
|---|
我们将从这个列表中选择建议,并将其添加到之前提示响应中选出的建议中,以构建一个我们可能在测试计划中考虑的风险列表。
这表明,为了提高 LLM 风险建议的质量,我们需要理解正在测试的系统。仅仅选择一个需求或特性,并要求 LLM 生成风险,是不会奏效的。提供给 LLM 的上下文不足意味着它将依赖于在类似特性需求中训练出的常见行为模式,这会导致模糊的回答和幻觉。要从 LLM 中获得有价值的东西,我们需要具体,以便为它提供丰富的上下文信息,我们可以通过模型来获取。通过创建系统的模型,我们不仅提高了对我们正在测试内容的理解,还将系统的部分划分为特定的区域,我们可以利用这些区域来指导我们编写的提示类型。然后,我们可以迭代系统的每个部分,创建提示以返回建议,构建一个更相关、更针对特定组件及其可能行为的风险拼贴画。
所有这些都可以再次使用影响区域模型来总结,如图 5.5 所示。

图 5.5 影响区域模型,展示了从 LLM 获得有价值输出的所需技能
影响区域模型展示了我们的建模和分析技能是如何驱动 LLM(大型语言模型)提供价值的能力的。如果我们对系统的理解薄弱,那么我们的提示也会薄弱,这意味着我们得到的结果也会薄弱。
活动 5.1
选择你正在测试的系统的一部分,并创建一个表示其工作原理的模型。你可以创建一个数据流图,或者选择不同的建模技术。一旦你创建了模型,选择模型的一个特定部分,并使用它来创建一个有针对性的提示,以生成建议的风险。
5.3.2 尝试不同的模型类型
在我们刚刚探讨的例子中,我们使用数据流图方法来模拟我们的系统是如何工作的。然而,如前所述,模型是有缺陷的,这意味着我们之前使用的模型帮助我们从数据在应用程序中处理的角度识别出潜在的提示,但它忽略了过程中的其他视角。因此,尝试不同的模型类型对我们考虑从不同视角了解我们的应用程序是有用的,这使我们能够提示 LLM 提出广泛的风险建议。因此,让我们看看几种不同的建模技术,以帮助我们扩展我们的工作。
正式建模技术
当使用数据流图格式(DTD)时,我们采用该格式所属的显式符号和规则,以便我们的模型可以尽可能清晰地直观解释正在发生的事情。然而,DTD 只是许多不同类型的正式模型中的一种,我们可以借鉴和窃取来创建适合我们的模型。例如,UML(统一建模语言)包含许多不同的建模方法,可以从不同的角度看待应用程序。结构化的 UML 图,如组件图,可以用来分解系统的架构,并帮助我们使用 LLM(大型语言模型)生成针对系统特定部分的关注风险。例如,图 5.6 展示了我们如何将预订列表功能解释为组件图。

图 5.6 预订列表功能组件图
这将导致一个类似于以下提示的信息:
|

你是一位软件测试专家。尽可能多地生成由三个井号定界的行为的潜在风险。###一个BookingRequest类向 Auth API 发送请求以确认BookingService能否完成其请求### |
|---|
我们还可以使用行为图,如序列图和用例图,来帮助我们捕捉系统中的用户流程,并创建生成以用户为中心的提示的提示(例如,如图 5.7 所示的模型)。

图 5.7 预订列表功能用户流程图
这可能有助于我们创建如下所示的提示:
|

| 你是一位软件测试专家。尽可能多地生成由三个井号定界的行为的潜在风险。###用户在登录应用程序后想要查看预订列表。他们已使用正确的凭证登录,并在授权过期之前请求预订列表### |
|---|
基于正式模型的选项和图表非常丰富,因此建议进行实验。探索不同模型的工作方式和它们能触发哪些类型的提示,可以帮助我们确定更适合我们的模型。我们还可以利用从正式建模技术中学到的知识来构建适合我们的自定义模型。
心智模型
尽管不同的正式建模技术可以帮助我们从不同的角度分析我们的系统,但进行这项活动是代价高昂的。花时间以不同的方式对系统进行建模需要时间,并要求我们进行广泛的研究,以积累足够的知识来以不同的方式构建我们的应用程序。然而,我们可以从另一个角度来处理模型解释,即我们通过启发式方法来改变对单个模型的认识。
如果我们回到图 5.7,该模型旨在引导我们了解条件动作如何决定接下来发生什么。因此,我们创建专注于这些条件的提示,因为这是模型设计用来强调的。但如果我们通过采用不同的心智模型来改变我们的看法呢?一个例子是使用 SFDIPOT 记忆法,有时也称为旧金山车站。James Bach 作为启发式测试策略模型的一部分创建了这个记忆法(mng.bz/o0lN),SFDIPOT 旨在帮助我们从不同的角度考虑产品;记忆法中的每个字母代表不同的视角:
-
结构—产品的构成
-
功能—产品做什么
-
数据—产品处理的内容
-
接口—产品可以交互的方式
-
平台—产品所依赖的
-
操作—产品将如何被使用
-
时间—时间如何影响产品
虽然这个记忆法可以在战略层面上使用,我们也可以将其应用于系统的较小部分,如图 5.7 所示。我们可以使用这些不同类型的视角通过不同的角度重新评估我们的产品。例如,我们可能会通过询问时间可能会如何影响我们的功能来审查图 5.7。这可能会激发以下提示,该提示规定了生成风险和使用分隔符策略的初始指令:
|

| 你是一位软件测试专家。尽可能多地生成由三个井号定界的行为的风险。关注用户使用系统速度(快或慢)以及某一时刻使用系统的用户数量方面的风险。 |
|---|
我们可以利用这段时间来思考如何避免离题的建议:
|

| 在输出之前检查每个建议是否符合时间条件。 |
|---|
然后我们将提供工作的背景:
|

| ###用户在登录应用程序后想要查看预订列表。他们已使用正确的凭证登录,并在授权过期之前请求预订列表。### |
|---|
使用这样的提示可能会得到以下回应:
|

| 1. 单用户 - 正常速度:一个用户登录后,立即访问预订列表,确保在可接受的响应时间内显示。2. 单用户 - 慢速速度:一个用户,拥有较慢的互联网连接,登录并请求预订列表,检查系统如何处理延迟响应。3. 多用户 - 正常速度:多个用户在短时间内同时登录并请求预订列表,以观察系统是否会变慢或由于并发请求而对响应时间产生影响。4. 多用户 - 慢速速度:模拟多个用户使用较慢的连接访问系统,尝试同时查看预订列表,以评估系统在较慢网络条件和并发用户请求下的性能。5. 用户授权时效:用户在授权即将到期之前尝试访问预订列表,以验证系统是否允许访问或在此关键时间窗口内适当地处理请求。6. 访问期间授权过期:模拟用户在查看预订列表时授权过期的情况,以确保系统优雅地处理此过期,不会在授权过期后崩溃或显示敏感信息。7. 高负载场景:通过显著增加同时用户的数量来生成高负载,评估系统如何在不牺牲响应时间或因负载而崩溃的情况下提供预订列表。8. 并发访问和授权更新:多个用户同时访问系统,其中一些用户的授权正在过期并得到更新。此测试验证系统在处理并发访问和授权更新过程时的行为。9. 间歇性用户访问:用户在不同时间间隔登录,一些用户在登录后立即请求预订列表,而另一些用户先执行各种活动。此测试确保系统在用户行为各异的情况下保持一致的响应时间。10. 随机访问时间:随机时间用户请求预订列表,以模拟现实世界场景,测试系统如何应对不可预测和不规则的访问模式。 |
|---|
此响应的结果更多地关注用户或用户如何与系统交互。它们也比早期一些高级负载测试的建议更为详细。提示中包含关于系统预期如何工作的明确信息,但我们已经根据对图 5.7 中功能随时间工作方式的转变视角,为提示添加了重点。
使用 SFDIPOT 等启发式方法意味着我们可以快速迭代模型的部分,但也可以改变我们的视角,以不同的方式阅读模型,帮助我们识别可以添加到提示中的新指令。
活动 5.2
使用与早期活动中建模的相同特征的不同建模技术创建一个新的模型,或者使用心智建模技术重新评估现有的模型。使用它们来提出新的提示,这些建议可以为之前创建的提示提供独特想法。
5.4 LLMs 和测试用例
我们以询问是否可以使用 LLM 生成测试用例开始本章。相反,我们通过以风险为指南进行了一次旅行,以确定我们可能进行的测试类型。目的是表明我们在控制我们希望测试采取的方向。根据我们识别的风险和 LLM 提出的风险,我们可能会确定我们进行的测试根本不需要依赖测试用例。
然而,如果我们的规划确定确实需要测试用例,无论是手动运行还是通过自动化,我们已经通过建模识别了更小、更集中的功能段,这些段可以在我们的提示中使用。例如,我们可能会创建一个提示,结合我们之前所做的建模工作、我们已识别的风险,甚至是我们已经捕获的示例测试用例。我们使用分隔策略来概述两个数据部分:
|

| 你是一位专家软件测试员。基于由三个井号分隔的行为和由三个反引号分隔的测试用例,生成建议的测试用例。关注用户使用系统的速度(快或慢)以及在任何时间点使用系统的用户数量。 |
|---|
我们可以花时间思考以停止重复并专注于风险:
|

| 在输出之前检查每个建议是否与提出的风险相匹配,并且它们不是在由三个反引号分隔的区域内找到的测试用例的重复。 |
|---|
我们随后在分隔区域内提供一个捕获区域:
|

| ### 用户在登录应用程序后想要查看预订列表。他们已使用正确的凭证登录,并在授权过期之前请求预订列表。### |
|---|
最后,我们在分隔区域内提供测试用例:
|

pyTest Case 1: Single User - Normal Speed: • Test accessing the booking list immediately after logging in with valid credentials, within a typical speed range. • Verify that the booking list is displayed correctly without any delay or errors.Test Case 2: Single User - Slow Speed: • Simulate a slow network connection or system performance. • Attempt to access the booking list immediately after logging in with valid credentials, but at a slower pace. • Ensure that the system responds appropriately, perhaps with a loading indicator or progress bar, and eventually displays the booking list without timing out or crashing.Test Case 3: Multiple Concurrent Users: • Have one user login and request the booking list while another user performs a different action (e.g., making a booking). • Verify that the system can handle concurrent requests without performance degradation or conflicts. |
|---|
当将内容发送到 ChatGPT 时,它为我返回了以下建议的测试用例:
|

| 测试案例 4:单个用户 - 快速速度: • 使用有效凭据登录后立即以非常快的速度访问预订列表。 • 验证系统是否能够处理快速请求而不会导致错误或意外行为。测试案例 5:多个并发用户 - 正常速度: • 让多个用户同时登录并请求预订列表。 • 确保每个用户都能及时接收到正确的预订列表,而不会受到其他用户请求的延迟或干扰。测试案例 6:授权过期: • 使用有效凭据登录并等待授权过期。 • 再次尝试访问预订列表。 • 验证系统拒绝访问或提示用户重新认证,而不是显示预订列表。 |
|---|
再次强调,这里的目的是不是盲目地生成一个详尽的测试案例列表,而是批判性地评估所提出的建议,看看它是否产生了有用、新颖的想法,或者激发我们识别自己新的测试案例。
5.4.1 对生成的风险和测试案例保持健康的怀疑态度
将如何测试应用程序的明确步骤编纂成法典,无法与人类复杂、直观的测试相媲美。测试案例可以在一定程度上帮助,尤其是在自动化领域,但我们必须避免测试案例的单调文化,这可能会影响我们产品的质量而不是帮助提高它。因此,我们必须意识到,LLM(大型语言模型)作为我们测试计划活动的助手提供价值,而不是测试权威。另一种选择是过度依赖 LLM 来完成我们的工作,这会让用户天真地认为它们可以完全信赖来复制我们的测试,这反过来又可能使我们迅速生成数千个测试案例,而缺乏思考和/或价值。然而,危险并不仅仅来自 LLM。相反,我们必须仔细、系统地思考我们要求 LLM 为我们创建的内容,并对返回的内容持怀疑态度。
摘要
-
测试计划可以从非正式笔记到关于如何进行测试的正式文档。
-
然而,所有的测试计划都根植于我们在测试中关心的风险。
-
泛泛而谈的提示可能会损害我们的计划,而不是帮助它。相反,我们需要开发更准确和具体的提示来生成有用的建议。
-
使用视觉和心智模型可以帮助我们分解系统以创建更好的提示。
-
所有模型都有缺陷,但我们可以利用这一点来创建强调我们关心的细节的模型,以帮助我们解决问题。
-
创建模型允许我们将功能或系统的一部分分解,以更好地理解其各部分是如何工作的。
-
基于模型特定部分构建提示可以帮助我们产生有价值、准确和可操作的建议。
-
我们可以使用不同的建模技术来获得对系统行为的不同视角。
-
尝试不同的形式化建模方法可以帮助我们生成不同类型的提示。
-
用不同的心理模型来审查单个模型可以帮助我们改变自己关于编写哪些类型提示的内部偏见和观点。
-
我们应该关注测试什么,并使用大型语言模型(LLMs)来提出想法,而不是反过来。
第六章:使用 AI 快速创建数据
本章涵盖
-
使用 LLMs 生成基本测试数据
-
改变测试数据的格式
-
使用复杂数据集来提示 LLMs 创建新的数据集
-
将 LLMs 集成作为自动化检查的测试数据管理器
管理测试数据是测试和软件开发中最具挑战性的方面之一。通常,数据需求随着系统的复杂性而增长。为了自动化检查和人工驱动的测试,我们需要合成与我们的上下文相关的数据,并在需要时进行大规模匿名化,这可能会对测试时间和资源造成巨大消耗,而这些时间和资源本可以用于其他测试活动。
然而,我们需要测试数据。如果没有必要的数据来触发动作和观察行为,就无法进行大多数测试活动。这就是为什么本章展示了我们可以如何使用大型语言模型(LLMs)来生成测试数据,提供不同的提示来创建简单和复杂的数据结构,并通过第三方 API 将 LLMs 集成到我们的自动化框架中。
6.1 使用 LLMs 生成和转换数据
由于 LLMs 是强大的、概率性的文本生成器,因此,通过适当的提示,它们可以轻松地生成和转换测试数据,这是合理的。但这取决于编写清晰的提示,明确传达我们的数据需求,以便我们得到我们想要的数据,格式正确,并且没有由于幻觉引起的任何错误。我们可以以多种方式来处理这个问题,但让我们首先看看我们可以随意使用的一些基本提示,以创建各种测试活动的测试数据。
6.1.1 通过提示 LLMs 生成简单数据集
首先,让我们探索如何创建基本数据集示例,如下所示:
{
"room_name": "Cozy Suite",
"type": "single",
"beds": 1,
"accessible": true,
"image": "https://example.com/room1.jpg",
"description": "Charming room",
"features": ["Wifi", "TV"],
"roomPrice": 150
}
如我们所见,JSON 数据集使用相当简单的结构混合了多种数据类型。我们将在本章后面部分看到如何处理更复杂的结构,但现在,让我们回到第二章中我们用来创建一些样本测试数据的一个提示。首先,我们设定提示的主要说明,并使用思考时间原则来提高输出 JSON 的质量:
|

| 你是一个 JSON 数据生成器。生成一个包含 5 个 JSON 对象的数组,并在输出结果之前检查是否已创建了 5 个 JSON 对象。 |
|---|
我们使用不同的分隔符来设定我们的数据需求规则:
|

* 每个参数都用一个%符号标识。 * 每个列按照键、值数据类型和选项的顺序描述,使用|符号。 * 如果列数据选项说明为随机,则根据建议的格式和列名随机化数据。 |
|---|
然后我们提供我们想要根据我们的分隔规则集创建的数据:
|

| 这里是说明:
% room_name | string | random
% type | string | 'single' or 'double'
% beds | integer | 1 to 6
% accessible | boolean | true or false
% image | string | random url
% description | string | random max 20 characters
% features | array[string] | 'Wifi', 'TV' or 'Safe'
% roomPrice | integer | 100 to 200
|
这个提示依赖于一系列提示工程策略来明确说明我们想要生成的内容。我们使用格式策略来声明我们希望测试数据以何种格式返回。分隔符策略用于设置我们结构化数据以及格式值的规则。此外,我们还指示 LLM 在输出之前先找出解决方案,以降低幻觉的风险。
将此提示发送到 ChatGPT 返回以下数据集:
|

|
[
{
"room_name": "Cozy Suite",
"type": "single",
"beds": 1,
"accessible": true,
"image": "https://example.com/room1.jpg",
"description": "Charming room",
"features": ["Wifi", "TV"],
"roomPrice": 150
},
{
"room_name": "Deluxe Room",
"type": "double",
"beds": 2,
"accessible": false,
"image": "https://example.com/room2.jpg",
"description": "Luxury abode",
"features": ["Wifi", "Safe"],
"roomPrice": 180
},
{
"room_name": "Economy Stay",
"type": "single",
"beds": 1,
"accessible": true,
«image»: «https://example.com/room3.jpg»,
"description": "Budget-friendly",
"features": ["TV"],
"roomPrice": 120
},
{
"room_name": "Family Suite",
"type": "double",
"beds": 3,
"accessible": true,
"image": "https://example.com/room4.jpg",
"description": "Spacious and comfy",
"features": ["Wifi", "TV", "Safe"],
"roomPrice": 200
},
{
"room_name": "Executive Loft",
"type": "single",
"beds": 1,
"accessible": false,
«image»: «https://example.com/room5.jpg»,
"description": "Modern design",
"features": ["Wifi", "Safe"],
"roomPrice": 170
}
]
|
从生成的数据中可以得出一些观察结果。我们可以看到,在提示明确数据期望的地方,我们得到的数据接近我们的期望——例如,accessible和roomPrice特征。然而,也有一些较弱的地方,我们的提示让 LLM 决定输出什么。两个值得注意的领域是选择了example.com的图像 URL 以及beds和type之间的关系。图像 URL 虽然有效,但并不指向实际的图像。根据我们的需求,我们可能需要进一步调整我们的提示,使 URL 更加明确。另一个关系也很有趣,因为其中一条记录表明房间类型是双人,但它提供了三张床。尽管如此,这仍然是一个良好的开端,有时这种类型的生成数据足以支持我们的测试。
但如果我们想以不同的数据结构来获取这些数据呢?这可以通过快速重写提示来处理。例如,这个提示请求相同的数据,但这次是以 XML 格式。我们首先将初始说明重写为以 XML 格式创建数据:
|

| 你是一个 XML 数据生成器。在一个数组中生成 3 个 XML 条目,并在输出结果之前检查是否已创建了 3 个 XML 对象。 |
|---|
我们添加一个初始说明来处理根 XML 节点:
|

* 根节点元素名称用#符号标识 * 每个元素用%符号标识 * 每个列按元素名称、数据类型和选项的顺序描述,使用|符号 * 如果列数据选项为随机,则根据建议的格式和列名称随机化数据 |
|---|
然后我们提供与之前相同的数据步骤:
|

| 这里是说明:
# rooms
% room_name | string | random
% type | string | 'single' or 'double'
% beds | integer | 1 to 6
% accessible | boolean | true or false
% image | string | random url
% description | string | random max 20 characters
% features | array[string] | 'Wifi', 'TV' or 'Safe'
% roomPrice | integer | 100 to 200
|
虽然在一个应用程序中处理不同的数据格式并不一定常见,但这个提示展示了与 LLMs 合作生成数据的一个优点。使用结构化输出策略,我们能够保持大多数提示与 JSON 提示示例相同,只需稍作修改即可生成 XML;当我将其发送到 ChatGPT 时,得到了以下结果:
|

|
<rooms>
<room>
<room_name>A1</room_name>
<type>single</type>
<beds>3</beds>
<accessible>true</accessible>
<image>https://example.com/room_a1.jpg</image>
<description>Cozy room</description>
<features>
<feature>Wifi</feature>
<feature>TV</feature>
</features>
<roomPrice>150</roomPrice>
</room>
<room>
<room_name>B2</room_name>
<type>double</type>
<beds>2</beds>
<accessible>false</accessible>
<image>https://example.com/room_b2.jpg</image>
<description>Spacious suite</description>
<features>
<feature>TV</feature>
<feature>Safe</feature>
</features>
<roomPrice>180</roomPrice>
</room>
<room>
<room_name>C3</room_name>
<type>single</type>
<beds>1</beds>
<accessible>true</accessible>
<image>https://example.com/room_c3.jpg</image>
<description>Modern design</description>
<features>
<feature>Wifi</feature>
<feature>Safe</feature>
</features>
<roomPrice>130</roomPrice>
</room>
</rooms>
|
再次,输出具有与先前示例相似的属性。XML 的结构是有效的,确定使用哪些数据的规则已被遵循。但是,与 JSON 示例类似,我们做出了一些不寻常的选择。使用带有三个床位的单人房间,奇特的房间名称和虚拟 URL。在两个提示中,我们可以添加更多信息来减轻这些问题,但我们会面临在提示中创建大量规则以管理数据点之间关系的风险。然而,我们还可以在我们的提示中做出其他选择来处理更复杂的规则集,但首先,让我们探索 LLMs 如何通过其转换数据的能力来帮助我们。
活动第 6.1 节
使用本节中共享的提示,更改数据结构以创建新的 XML 或 JSON 测试数据。
6.1.2 将测试数据转换为不同的格式
LLMs 的核心优势之一,已被技术倡导者强调,是其将文本从一种语言翻译成另一种语言的能力——例如,从法语翻译成英语再翻译回来。我们还可以使用这种方法将数据或代码从一种结构或语言转换成另一种。例如,看看以下将一些 JSON 转换为 SQL 语句的提示。我们使用分隔符策略和指令来启动提示:
|

| 你是一个 JSON 到 SQL 转换器。将用三个井号分隔的 JSON 对象转换为 SQL 语句,该语句将: 1. 创建一个 SQL 表以插入转换后的记录 2. 创建插入语句将每条记录添加到数据库中 |
|---|
接下来,我们指示模型检查解决方案以改进输出:
|

| 在输出结果之前检查每个 SQL 语句是否涵盖了 JSON 的所有方面 |
|---|
然后我们提供我们想要转换的数据:
|

|
###
[{
"firstname": "Sally",
"lastname": "Wilson",
"totalprice": 229,
"depositpaid": false,
"bookingdates": {
"checkin": "2017-02-20",
"checkout": "2019-07-05"
},
"additionalneeds": "Breakfast"
},{
"firstname": "Jim",
"lastname": "Jones",
"totalprice": 871,
"depositpaid": false,
"bookingdates": {
"checkin": "2017-08-11",
"checkout": "2021-06-30"
},
"additionalneeds": "Breakfast"
},{
"firstname": "Mary",
"lastname": "Ericsson",
"totalprice": 558,
"depositpaid": true,
"bookingdates": {
"checkin": "2018-07-04",
"checkout": "2022-09-25"
}
}]
###
|
在这个提示中,我们要求一个 LLM 不仅将一个 JSON 对象转换为我们可以运行的 SQL INSERT 语句,而且还创建必要的 CREATE 语句,以便我们首先能够插入数据。将此发送到 ChatGPT 返回了以下 SQL 语句:
|

|
-- Create the table
CREATE TABLE bookings (
id SERIAL PRIMARY KEY,
firstname VARCHAR(255),
lastname VARCHAR(255),
totalprice DECIMAL(10, 2),
depositpaid BOOLEAN,
checkin DATE,
checkout DATE,
additionalneeds VARCHAR(255)
);
-- Insert the records
INSERT INTO bookings (firstname, lastname, totalprice, depositpaid,
checkin, checkout, additionalneeds)
VALUES
('Sally', 'Wilson', 229.00, false, '2017-02-20', '2019-07-05',
'Breakfast'),
('Jim', 'Jones', 871.00, false, '2017-08-11', '2021-06-30',
'Breakfast'),
('Mary', 'Ericsson', 558.00, true, '2018-07-04', '2022-09-25',
NULL);
|
在这个回答中引人注目的不仅仅是生成了正确的 SQL 语句来创建和将我们的数据插入数据库,而且它还尊重了原始 JSON 对象中的值。这证明了这些策略的重要性,这些策略被纳入提示中:
|

| 在输出结果之前检查每个 SQL 语句是否涵盖了 JSON 的所有方面 |
|---|
该提示有助于确保在转换过程中数据参数本身不会被修改。
这些快速提示演示了 LLM 可以用来快速生成和转换数据,这些提示可以通过替换每个提示分隔部分内的数据对象来多次重复使用。这可能在需要快速数据以帮助我们进行测试活动(如探索性测试和调试)时很有用。但正如所展示的,随着我们要求的复杂化,我们可能会迅速遇到不一致或无效的数据。
活动六.2
构建一个提示,尝试将一个 XML 片段转换为 SQL 或 JSON 数据结构。确保 XML 中的测试数据在转换过程中没有问题。
6.2 使用 LLM 处理复杂测试数据
在原始的生成数据提示中,我们用普通语言设定了规则和期望。这意味着我们要求在明确说明提示中的学习规则之前,解码我们数据结构和其关系——这是一个可能迅速变得相当复杂的工作。因此,我们不如看看如何发送不同的数据规范格式或现有数据,以提示 LLM 创建更复杂的数据。
6.2.1 在提示中使用格式标准
让我们从探讨如何使用 OpenAPI v3 和 XSD 等数据规范格式开始,这些格式概述了我们的数据必须遵循的结构和规则。这类规范有几个原因可能是有用的:
-
现成解决方案—规范框架的创建者已经处理了在不同格式中传达数据结构的繁重工作。考虑我们之前创建的带有分隔规则提示,这些规则概述了数据名称和类型。所有这些已经在规范框架中考虑并制定出来。因此,使用它们而不是自己构建是有意义的。
-
普遍性—我们将使用的框架是标准化的,并且已经被许多团队和组织采用。这增加了 LLM 在规范框架上训练的可能性,这将最大化我们在发送提示时获得期望输出的机会。
-
免费使用—如果我们是在使用 OpenAPI 和 XSD 等工具来指定数据结构或 API 的团队中工作,那么这些规范已经为我们准备好了。在设计阶段就已经完成了功能或应用程序的设计工作。
-
内在可测试性—使用常用的结构意味着 LLM 在其训练中可能比我们使用专有结构时更多地接触到了它。这意味着 LLM 可能增加产生更高价值输出的概率,并进一步协助我们的测试。
既然这种方法有几个好处,让我们看看它们如何被添加到提示中,以生成我们所需的数据。
带有 OpenAPI 的 JSON
我们将首先创建一个提示,使用 OpenAPI 3.0 格式创建 JSON 数据,结果如下提示,使用分隔符、格式和工作解决方案策略来设置提示说明
|

| 你是一个 JSON 数据生成器。根据 OpenAPI 模式,生成一个包含 3 个随机 JSON 对象的 JSON 数组,以三个井号作为分隔符。在输出结果之前,确认所有 3 个 JSON 对象都符合 OpenAPI 模式规则。 |
|---|
然后我们提供 OpenAPI 规范以进行处理:
|

|
###
openapi: 3.0.0
components:
schemas:
Room:
title: Room
type: object
properties:
accessible:
type: boolean
description:
type: string
type:
type: string
pattern: Single|Double|Twin|Family|Suite
features:
type: array
items:
type: string
pattern: WiFi|TV|Radio|Refreshments|Safe|
image:
type: string
roomNumber:
type: string
roomPrice:
type: integer
format: int32
minimum: 0
maximum: 999
exclusiveMinimum: true
exclusiveMaximum: false
roomid:
type: integer
format: int32
required:
- accessible
- description
- features
- image
- roomNumber
- roomPrice
###
|
注意提示开头如何表明我们打算使用 OpenAPI 格式来概述我们的数据需求。我们使用这一点来设定我们对结果数据的结构预期,这在我们向 ChatGPT 提交提示并收到以下输出时可以看到:
|

|
[
{
"accessible": true,
"description": "Cozy single room with a view",
"type": "Single",
"features": ["WiFi", "TV"],
"image": "single_room.jpg",
"roomNumber": "101",
"roomPrice": 75,
"roomid": 1
},
{
"accessible": false,
"description": "Spacious double room with amenities",
"type": "Double",
"features": ["WiFi", "TV", "Refreshments"],
"image": "double_room.jpg",
"roomNumber": "202",
"roomPrice": 120,
"roomid": 2
},
{
"accessible": true,
"description": "Family suite with a view and entertainment",
"type": "Family",
"features": ["WiFi", "TV", "Radio", "Refreshments", "Safe"],
"image": "family_suite.jpg",
"roomNumber": "305",
"roomPrice": 180,
"roomid": 3
}
]
|
与我们之前生成的前数据相比,这些数据感觉更准确。描述更详细,并引用了在别处找到的房间其他属性。例如,家庭套房描述引用了与房间类型和功能相关的数据。
图像属性值得怀疑,因为它们仅仅提供了一个图像名称,但如果图像存储在我们的应用程序中,这可能就足够了。然而,我们使用 OpenAPI 语言的使用允许我们设置更详细的规则,这意味着我们可以更新规范中的图像部分,从
|

|
image:
type: string
|
到以下内容,其中模式指向我们可能之前生成的测试图像:
|

|
image:
type: string
items:
type: string
pattern:
https://realdomain.com/image_1.png|https://realdomain.com/image_2.png
|
如果我们将此模式添加到我们的提示中并发送给 LLM,它将返回如下对象:
|

|
{
"accessible": true,
"description": "Cozy single room with a city view",
"type": "Single",
"features": ["WiFi", "TV"],
«image»: «https://realdomain.com/image_1.png»,
"roomNumber": "101",
"roomPrice": 89,
"roomid": 12345
}
|
通过利用 OpenAPI 中使用的不同规范规则,我们可以更成功地控制我们的输出。
XML 和 XSD
同样的过程可以应用于其他格式。考虑以下提示,它使用与上一个提示相同的方法,但使用 XML 模式定义(XSD)代替。我们使用格式、分隔符和工作解决方案策略来概述提示以采用 XSD 格式:
|

| 你是一个 XML 数据生成器。根据 XSD 模式,生成 3 个随机 XML 对象,以三个井号作为分隔符。将所有 XML 对象添加到 rooms 的父元素中。在输出结果之前,确认所有 3 个 XML 子对象都符合 XSD 模式规则。 |
|---|
接下来,我们提供 XSD 格式以设置我们想要的数据输出格式:
|

|
###
<?xml version="1.0" encoding="UTF-8"?>
<xs:schema >
<xs:element name="room">
<xs:complexType>
<xs:sequence>
<xs:element name="accessible" type="xs:boolean"/>
<xs:element name="description" type="xs:string"/>
<xs:element name="type">
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:enumeration value="Single"/>
<xs:enumeration value="Double"/>
<xs:enumeration value="Twin"/>
<xs:enumeration value="Family"/>
<xs:enumeration value="Suite"/>
</xs:restriction>
</xs:simpleType>
</xs:element>
<xs:element name="features">
<xs:complexType>
<xs:sequence>
<xs:element name="feature"
➥type="FeatureType" minOccurs="0"
maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:element name="image" type="xs:string"/>
<xs:element name="roomNumber" type="xs:string"/>
<xs:element name="roomPrice" type="xs:decimal"/>
<xs:element name="roomid" type="xs:integer"/>
</xs:sequence>
</xs:complexType>
</xs:element>
<xs:simpleType name="FeatureType">
<xs:restriction base="xs:string">
<xs:enumeration value="WiFi"/>
<xs:enumeration value="TV"/>
<xs:enumeration value="Radio"/>
<xs:enumeration value="Refreshments"/>
<xs:enumeration value="Safe"/>
<xs:enumeration value="Views"/>
</xs:restriction>
</xs:simpleType>
</xs:schema>
###
|
提示的工作方式与之前的类似。这个提示增加了一个额外步骤,以确保我们的测试数据通过存储在单个根节点下进行分组。但提示的其余部分工作方式相同,结果生成与之前生成 JSON 数据的提示类似的 XML 输出,正如我在提示 ChatGPT 生成 XML 测试数据时得到的响应所示:
|

|
<?xml version="1.0" encoding="UTF-8"?>
<rooms
xsi:noNamespaceSchemaLocation="schema.xsd">
<room>
<accessible>true</accessible>
<description>Cozy single room with a view</description>
<type>Single</type>
<features>
<feature>WiFi</feature>
<feature>TV</feature>
<feature>Views</feature>
</features>
<image>single_room.jpg</image>
<roomNumber>101</roomNumber>
<roomPrice>89.99</roomPrice>
<roomid>1001</roomid>
</room>
<room>
<accessible>false</accessible>
<description>Luxurious suite with all amenities</description>
<type>Suite</type>
<features>
<feature>WiFi</feature>
<feature>TV</feature>
<feature>Safe</feature>
<feature>Refreshments</feature>
<feature>Views</feature>
</features>
<image>suite_room.jpg</image>
<roomNumber>201</roomNumber>
<roomPrice>349.99</roomPrice>
<roomid>1002</roomid>
</room>
<room>
<accessible>true</accessible>
<description>Spacious family room for a comfortable
stay</description>
<type>Family</type>
<features>
<feature>WiFi</feature>
<feature>TV</feature>
<feature>Radio</feature>
<feature>Safe</feature>
</features>
<image>family_room.jpg</image>
<roomNumber>301</roomNumber>
<roomPrice>179.99</roomPrice>
<roomid>1003</roomid>
</room>
</rooms>
|
这类提示在我们需要为 NoSQL 数据库创建测试数据时非常有用。我记得有一次在一个包含超过 1,000 个元素的项目中工作。实际上不可能生成我们所需的全部测试数据,所以我们不得不妥协。但是,使用 LLM 和与我们刚才看到的类似的提示,创建 XML 文档的过程只需几分钟。
活动第 6.3 节
使用 OpenAPI 或 XSD 规范创建新的测试数据。如果您有权访问规范,请尝试使用它们。或者,找到示例规范并在提示中尝试使用它们来生成测试数据。
6.2.2 SQL 导出作为提示指南
我们之前探索的提示已经创建了作为单一实体的数据实体,但与数据分散在不同位置的应用程序一起工作也同样常见。例如,我们如何提示 LLM 为包含数据分布在多个表中的基于 SQL 的数据库创建数据?
一种方法是采用少样本策略(向提示提供示例)并提供数据库的结构,以及示例,以展示创建的数据以及数据所在的位置。以以下请求生成跨两个不同表的 SQL 数据的提示为例。首先,我们使用分隔符和结构化格式策略为提示设置初始指令:
|

| 你是一个 SQL 生成器。使用由三个井号分隔的 sql 语句创建一个 SQL 语句,该语句生成 5 个新的记录,这些记录遵循提供的语句格式。 |
|---|
然后我们让 LLM 在分享输出之前先找出解决方案:
|

| 在输出新生成数据之前检查每个新条目是否与提供的 SQL 语句不匹配,并且在输出之前确保 SQL 可以成功执行。 |
|---|
最后,我们为每个表提供 SQL 语句供 LLM 处理
|

|
###
CREATE TABLE rooms (roomid int NOT NULL AUTO_INCREMENT, room_name
➥varchar(255), type varchar(255), beds int, accessible boolean,
➥image varchar(2000), description varchar(2000),
➥features varchar(100) ARRAY, roomPrice int, primary key (roomid));
INSERT INTO rooms (room_name, type, beds, accessible, image,
➥description, features, roomPrice) VALUES ('101', 'single', 1,
➥true, 'https://www.mwtestconsultancy.co.uk/img/testim/room2.jpg',
➥'Aenean porttitor mauris sit amet lacinia molestie. In posuere
➥accumsan aliquet. Maecenas sit amet nisl massa. Interdum et
➥malesuada fames ac ante.', ARRAY['TV', 'WiFi', 'Safe'], 100);
CREATE TABLE bookings (bookingid int NOT NULL AUTO_INCREMENT, roomid
➥int, firstname varchar(255), lastname varchar(255),
➥depositpaid boolean, checkin date, checkout date,
➥primary key (bookingid), FOREIGN KEY (roomid) REFERENCES
➥ROOMS(roomid));
INSERT INTO bookings (roomid, firstname, lastname, depositpaid,
➥checkin, checkout) VALUES (1, 'James', 'Dean', true, '2022-02-01',
➥'2022-02-05');
###
|
在这个提示中,我们提供了通过roomid连接的两个不同表的 SQL 语句。第一个是rooms表,它具有以下属性(以更易读的格式列出):
|

|
roomid int NOT NULL AUTO_INCREMENT,
room_name varchar(255),
type varchar(255),
beds int,
accessible boolean,
image varchar(2000),
description varchar(2000),
features varchar(100) ARRAY,
roomPrice int,
primary key (roomid)
|
第二个是bookings表:
|

|
bookingid int NOT NULL AUTO_INCREMENT,
roomid int,
firstname varchar(255),
lastname varchar(255),
depositpaid boolean,
checkin date,
checkout date,
primary key (bookingid),
FOREIGN KEY (roomid) REFERENCES ROOMS(roomid)
|
向 LLM 提供CREATE和INSERT语句有助于最大化期望的输出,确保不仅正确类型的测试数据被创建,而且数据集之间的关系也是正确的。如果我们只提供插入语句,我们将提供较少的上下文,并增加外键被填充到不存在记录的关系中的风险。
将此提示发送到 ChatGPT 返回以下响应:
|

|
-- Generate new rooms records
INSERT INTO rooms (room_name, type, beds, accessible, image,
description, features, roomPrice) VALUES
('102', 'double', 2, true, 'https://www.example.com/image1.jpg',
'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
ARRAY['TV', 'WiFi'], 150),
('103', 'single', 1, false, 'https://www.example.com/image2.jpg',
'Nulla facilisi. Sed at lorem nec elit rhoncus tristique.',
ARRAY['WiFi', 'Safe'], 120),
('104', 'suite', 3, true, 'https://www.example.com/image3.jpg',
'Pellentesque fermentum tortor a dui interdum, ut efficitur felis
pharetra.', ARRAY['TV', 'Safe', 'Minibar'], 220),
('105', 'double', 2, false, 'https://www.example.com/image4.jpg',
'Vestibulum non tortor sit amet lacus mattis efficitur.',
ARRAY['WiFi'], 160),
('106', 'single', 1, true, 'https://www.example.com/image5.jpg',
'Fusce ut ligula nec ligula auctor tincidunt vel sit amet dui.',
ARRAY['TV', 'WiFi'], 130);
-- Generate new bookings records
INSERT INTO bookings (roomid, firstname, lastname, depositpaid,
checkin, checkout) VALUES
(2, 'Emma', 'Watson', true, '2023-08-15', '2023-08-20'),
(3, 'John', 'Smith', false, '2023-09-01', '2023-09-05'),
(4, 'Sophia', 'Johnson', true, '2023-08-25', '2023-08-30'),
(5, 'Michael', 'Williams', false, '2023-09-10', '2023-09-15'),
(6, 'Olivia', 'Brown', true, '2023-09-20', '2023-09-25');
|
插入的测试数据与来自其他提示的响应相似,但现在增加了使用roomid值的roomid,这些值将预订与现有房间连接起来,这些房间也在响应中生成。
这些提示所展示的是,如果我们有包含复杂关系或许多不同参数的数据,我们可以使用现有文档来协助测试数据生成。这不仅是一个节省时间的好方法,而且也是一个确保我们的测试数据生成可以与任何给定时间的数据结构保持同步的方法,从而在测试数据维护上节省我们更多的时间。
不要忘记数据隐私
在本章提供的示例中,我们使用了虚拟数据结构和规范,但当我们为我们的应用程序创建测试数据时,我们可能会依赖于我们组织的知识产权或用户数据。如果我们打算使用这些项目来创建我们的测试数据,我们需要确保我们没有违反内部关于知识产权共享的政策或用户数据隐私的法律。根据我们可以和不能分享的内容,将决定我们如何构建我们的提示。
活动六.4
使用 SQL 提示创建您自己的测试数据。尝试从您正在工作的应用程序中定位 SQL 或使用示例 SQL 来查看会发生什么。
6.3 设置 LLMs 作为测试数据管理员
我们已经探讨了如何通过发送提示通过工具如 ChatGPT 来创建数据。但我们如何将这些类型的提示集成到我们的自动化检查中?让我们通过增强这个简单的 UI 自动化检查,并使用 LLM 模型的数据生成来查看访问 LLMs 通过 API 平台的潜力:
@Test
public void exampleContactUsFormTest() throws InterruptedException {
我们使用 Selenium 打开网页:
driver.get("https://automationintesting.online/#/");
接下来,我们在网页上完成“联系我们”表单:
ContactFormPage contactFormPage = new ContactFormPage(driver);
contactFormPage.enterName("John Smith");
contactFormPage.enterEmail("john@example.com");
contactFormPage.enterPhone("01234567890");
contactFormPage.enterSubject("Test Subject");
contactFormPage.enterDescription("This is a test message");
contactFormPage.clickSubmitButton();
然后,我们断言联系表单页面已被提交:
assert contactFormPage.getSuccessMessage().contains("Thanks for getting
➥in touch");
通过这个自动检查,我们将用硬编码的字符串替换完成联系表单所用的字符串,并连接到 OpenAI API 平台,提示其 LLM 模型创建测试数据,然后我们可以解析并用于我们的检查。初始和完成的 OpenAI 集成检查的示例可以在支持存储库mng.bz/n0dv中找到。
6.3.1 设置 OpenAI 账户
在我们开始使用 OpenAI API 平台发送提示之前,我们需要设置一个账户。这可以通过在platform.openai.com/注册来完成。
OpenAI 平台费用
OpenAI 根据您发送到和从 LLM(大型语言模型)接收的 token 数量来收费。一个token本质上是一个单词或一组较小的单词。例如,“Hello ChatGPT”算作两个 token。您使用的 token 越多,意味着您收到的提示和内容越大,费用也就越高。如果您此时正在注册 OpenAI 的新账户,他们将会给您 5 美元的免费信用额度,您可以在前三个月内使用。这已经足够我们完成练习所需了。然而,由于免费信用额度在三个月后到期,如果您没有剩余的免费信用额度,您需要在发送和接收提示之前提供账单详情:mng.bz/vJRx。此外,强烈建议您设置一个最适合您的使用限制,以免最终收到令人惊讶的账单:platform.openai.com/account/billing/limits。
注册后,我们需要生成一个 API 密钥,我们将将其提供在我们的请求中以进行身份验证。这可以通过platform.openai.com/account/api-keys完成,点击“创建新密钥”按钮,它要求我们为 API 密钥命名。在输入名称并点击“创建密钥”后,我们将获得一个 API 密钥,如图 6.1 所示。

图 6.1 Open API 平台的新创建的 API 密钥
根据说明,我们需要将此 API 密钥记录在其他地方以备将来使用,因为我们无法再次查看它。因此,我们记录下密钥,然后点击“完成”以确保我们的密钥已被保存,如图 6.2 所示。

图 6.2 OpenAI API 平台 API 密钥管理器的屏幕截图
在创建并记录了密钥之后,我们就可以开始将 OpenAI 集成到我们的项目中了。
6.3.2 连接到 OpenAI
我们的第一步是构建必要的代码,向 OpenAI 发送 HTTP 请求并确认我们能够收到响应。因此,我们首先将以下库添加到我们的pom.xml文件中,我们将使用它来发送我们的请求:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>0.31.0</version>
</dependency>
LangChain4j 是 LangChain 工具集的 Java 实现,该工具集是用 Python 编写的。它提供了一系列工具,可用于与不同的 LLM 集成。在我们的示例测试中,我们将依赖 OpenAI 的 GPT 模型来生成我们的测试数据。因此,我们将使用 OpenAI 特定的 LangChain 版本来获取发送提示的基本访问权限。然而,如果我们想要更多的控制或选项,我们可以使用 LangChain 的完整 AI 服务版本。
gpt-3.5-turbo 和其他模型
OpenAI API 平台提供的一项功能是能够向不同的 LLM 模型发送提示。gpt-3.5-turbo 是在撰写本文时用于驱动 ChatGPT 免费版本的模型。我们将了解到,我们可以将其替换为调用其他模型,如 gpt-4o。不同的模型在不同的价格点提供不同的功能。例如,gpt-4o 相比于 gpt-3.5-turbo 是一个更有效的 LLM。然而,使用 gpt-4o 的价格点要高得多。有关其他模型的更多详细信息,请参阅 OpenAI 平台文档platform.openai.com/docs/models/overview。
在安装了必要的库之后,我们的下一步是构建一个请求 LLM 生成所需测试数据的提示。初始指令使用结构化输出和分隔符策略:
|

| 你是一个数据生成器。根据由三个井号分隔的标准创建随机数据,并以 JSON 格式提供。额外的数据要求在反引号之间共享。 |
|---|
添加了额外说明要处理的数据:
|

###nameemailphone UK formatsubject Over 20 characters in lengthdescription Over 50 characters in length### |
|---|
我们可以通过添加此提示和必要的代码将其发送到新的自动化检查来测试此提示:
@Test
public void exampleContactUsFormTestWithGPT() {
我们创建一个新的 OpenAIChat 模型并提供 API 密钥:
OpenAiChatModel model = OpenAiChatModel.withApiKey("Enter API key");
然后,我们将我们的提示添加到字符串中:
String prompt = """
You are a data generator. Create me random data in a
JSON format based on the criteria delimited by three hashes.
Additional data requirements are shared between back ticks.
###
name
email
phone `UK format`
subject `Over 20 characters in length`
description `Over 50 characters in length`
###
""";
最后,我们将提示发送到 GPT 模型并将响应存储在字符串中:
String testData = model.generate(prompt);
System.out.println(testData);
}
再次运行检查,我们将看到 LLM 返回类似的内容
|

|
{
"name": "John Doe",
"email": "johndoe@example.com",
"phone": "+44 1234 567890",
"subject": "Lorem ipsum dolor sit amet consectetur",
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing
➥elit. Suspendisse aliquet, tortor eu aliquet tincidunt, erat mi.»
}
|
接下来,我们需要将此解析为 Java 对象,因此我们创建一个新的类 ContactFormDetails,它可以把 JSON 转换为对象:
public class ContactFormDetails {
private String name;
private String email;
private String phone;
private String subject;
private String description;
public ContactFormDetails(String name, String email,
➥String phone, String subject, String description) {
this.name = name;
this.email = email;
this.phone = phone;
this.subject = subject;
this.description = description;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public String getPhone() {
return phone;
}
public String getSubject() {
return subject;
}
public String getDescription() {
return description;
}
}
在创建了 ContactFormDetails 类之后,我们现在可以将当前为字符串的提示响应转换为 POJO 以供进一步使用:
OpenAiChatModel model = OpenAiChatModel.*withApiKey*("Enter API key");
String prompt = """
You are a data generator. Create me random data in a
JSON format based on the criteria delimited by three hashes.
Additional data requirements are shared between back ticks.
###
name
email
phone `UK format`
subject `Over 20 characters in length`
description `Over 50 characters in length`
###
""";
String testData = model.generate(prompt);
ContactFormDetails contactFormDetails =
➥new Gson().fromJson(testData, ContactFormDetails.class);
现在我们已经有了在自动化检查中使用所需的测试数据:
@Test
public void exampleContactUsFormTestWithGPT() {
以下代码块将提示发送到 OpenAI 以生成测试数据:
OpenAiChatModel model = OpenAiChatModel.*withApiKey*("Enter API key");
String prompt = """
You are a data generator. Create me random data in a
JSON format based on the criteria delimited by three hashes.
Additional data requirements are shared between back ticks.
###
name
email
phone `UK format`
subject `Over 20 characters in length`
description `Over 50 characters in length`
###
""";
String testData = model.generate(prompt);
接下来,我们从响应中提取测试数据并将其转换为对象:
ContactFormDetails contactFormDetails =
➥new Gson().fromJson(testData, ContactFormDetails.class);
*driver*.get("https://automationintesting.online/#/");
然后,我们使用测试数据来完成“联系我们”表单并断言成功:
ContactFormPage contactFormPage = new ContactFormPage(*driver*);
contactFormPage.enterName(contactFormDetails.getName());
contactFormPage.enterEmail(contactFormDetails.getEmail());
contactFormPage.enterPhone(contactFormDetails.getPhone());
contactFormPage.enterSubject(contactFormDetails.getSubject());
contactFormPage.enterDescription(contactFormDetails.getDescription());
contactFormPage.clickSubmitButton();
assert contactFormPage.getSuccessMessage()
➥.contains("Thanks for getting in touch");
这完成了 OpenAI API 平台集成到我们的自动化检查中。在执行检查时,我们应该看到它通过,并且测试数据已成功用于创建联系信息,如图 6.3 所示。
我们可以通过将提示存储在外部文件中并在需要时将它们导入到我们的检查中进一步改进代码。这可能在使用提示多次时是有益的。这也意味着,当需要更改测试数据时,我们只需以新的详细信息更新我们的提示即可,这样无论谁,无论其与测试数据工作的经验如何,都可以做到。

图 6.3 使用 LLM 测试数据创建的消息
活动 6.5
创建一个新的需要输入测试数据的自动化检查。使用提示方法,创建一个新的提示来生成测试数据,然后将其传递到您的自动化检查中。
6.4 从生成的测试数据中获益
本章展示了 LLM 在生成测试数据方面非常擅长。它可以帮助我们快速创建用于各种测试活动的数据,从自动化测试到探索性测试,支持管理复杂的数据集,并简化使用自然语言提示管理测试数据的过程。然而,为此,我们需要创建提供关于我们想要的数据格式和从中抽取的示例的明确说明的提示,确保我们发送给 LLM 的内容不会影响个人和组织隐私。回到我们的影响范围模型,我们可以看到图 6.4 中描述的人类和 AI 在测试数据生成中的角色。

图 6.4 描述人类和 AI 在测试数据生成中角色的影响范围模型
通过以创造性的方式使用我们学到的提示工程策略,我们可以为各种情况创建测试数据,从简单到复杂,以帮助我们节省测试数据管理的时间。
摘要
-
可以构建提示来快速生成任何常见格式的数据(例如 JSON、XML 或 SQL)。
-
如果在提示中未明确设置,数据之间的关系有时可能是不正确的。
-
可以构建提示来将数据从一种格式转换为另一种格式,同时确保从原始格式复制的原始数据。
-
我们可以在提示中使用数据规范格式,如 OpenAPI 和 XSD,来设置我们对数据结构的期望。
-
使用创建数据库初始结构的 SQL 语句可以用来在处理分布式数据结构时提示 LLM。
-
OpenAI 提供了一个 API 平台,可以用来与不同的 AI 模型交互,包括 gpt-3.5 和 gpt-4o。
-
我们可以编程构建 HTTP 请求,将提示发送到 OpenAI LLM 以生成测试数据。
-
为测试数据提示 LLM 需要构建具有明确期望和有用示例的清晰提示。
第七章:使用 AI 加速和改进 UI 自动化
本章涵盖
-
如何使用 GitHub Copilot 和 ChatGPT 快速创建 UI 自动化
-
如何使用 GitHub Copilot 和 ChatGPT 快速提高 UI 自动化
如果在软件测试行业中有一个领域在 AI 工具的使用方面获得了最多的关注,那就是 UI 自动化。随着大型语言模型(LLMs)开始吸引人们的注意,测试领域的人们迅速关注了如何通过 Copilot 和 ChatGPT 等工具轻松生成 UI 自动化检查。围绕使用 AI 工具进行 UI 自动化的可能性和想法正在迅速扩展。尽管对这些工具的热情很高,工具供应商和自动化人员都急于使用 AI,但我们必须注意 AI 在哪里以及如何有用。
在使用 AI 创建自动化方面取得成功,不仅在于知道何时使用 AI 工具,还在于知道如何使用它。因此,本章重点介绍如何分解创建和维护自动化检查的过程,并发现 AI 工具如何加快我们的流程,同时确保我们仍然提供有价值的自动化。
7.1 快速创建 UI 自动化
前往 ChatGPT 并要求它生成一个 Java Selenium 测试,用于自动化登录页面,它会欣然从命。这看起来可能是一条有效的途径,但让我们简要看看这个过程的一个例子,看看会发生什么。对于提示,我们可以使用
|

| 假设测试自动化者的角色,使用 Selenium 和 Java 创建一个自动化测试,用于自动化和验证应用程序的登录过程 |
|---|
它将返回一个代码示例,例如
|

|
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
public class LoginTest {
public static void main(String[] args) {
// Set the path to the chromedriver executable
System.setProperty("webdriver.chrome.driver", "path/to/
➥ chromedriver");
// Create an instance of ChromeDriver
WebDriver driver = new ChromeDriver();
// Open the application login page
driver.get("https://example.com/login");
// Find the username and password input fields
WebElement usernameField =
➥ driver.findElement(By.id("username"));
WebElement passwordField =
➥ driver.findElement(By.id("password"));
// Enter the username and password
usernameField.sendKeys("your-username");
passwordField.sendKeys("your-password");
// Submit the login form
driver.findElement(By.id("login-button")).click();
// Wait for the page to load
// You can use an explicit wait here to ensure the page is
➥ fully loaded
// Verify the login success by checking for a specific
➥ element on the next page
WebElement welcomeMessage = driver.findElement(By.xpath("//
➥ h1[contains(text(),'Welcome')]"));
if (welcomeMessage.isDisplayed()) {
System.out.println("Login successful!");
} else {
System.out.println("Login failed!");
}
// Close the browser
driver.quit();
}
}
|
代码看起来是正确的,并且很可能会编译。但如果我们要把这段代码带入我们的自动化框架,我们应该问自己,“我们需要修改多少才能让它与我们的测试产品一起工作?”可能性很大,我们需要
-
移除
Driver实例化以使用我们自己的驱动器工厂。 -
通过更新
driver.get将检查指向正确的应用程序。 -
将
findElements方法移动到相关的页面对象中。 -
更新选择器,以便它们使用与我们的产品一致的选择器。
-
更新断言以符合我们的假设。
我们还没意识到,我们几乎已经用 ChatGPT 建议的所有代码替换了,这并不感觉像是我们时间的高效利用。这是因为尽管 ChatGPT 和 Copilot 等工具可以快速按需生成代码,但它们缺乏我们系统的上下文。也就是说,如果我们让这些工具在提供很少输入的情况下创建我们的自动化检查,结果将是需要大量重写的代码。相反,我们希望采取一种更共生的方法,有针对性地使用 AI 工具来帮助我们完成创建自动化 UI 检查的特定任务。
考虑图 7.1 中的可视化,它分解了包含在常见的基于 UI 层的自动化检查中的各种组件。
如我们所见,有许多动态部分——从处理自动化检查的依赖关系和报告的框架本身,到自动化检查执行的各种活动,以创建状态、与 UI 交互以及断言期望。这些部分都可以通过 AI 进行指导,因此,我们不是试图依赖 AI 一次性创建所有内容,而是在构建和维护自动化检查的过程中专注于具体任务,并使用 LLMs 来加速这个过程。
与记录和回放工具相比,AI
一个值得提出的问题是,AI 的使用与记录和回放工具及其记录我们的动作并将它们转换为代码的能力有何不同。如果我们使用 LLMs 来生成自动化检查,那么这种差异不会很大。事实上,记录和回放工具可能会更好,因为它们在与系统交互时,在记录过程中隐式地学习产品的上下文和规则。
然而,记录和回放的一个局限性在于,当它们遇到更复杂的框架时,这些框架将使用诸如页面对象和剧本模式等方法进行安排,以使其更易于维护。记录和回放工具往往会将生成的代码作为独立运行的脚本输出,与其他代码分离。当这个脚本需要集成到框架中时,我们可能需要大幅重写和重组我们的初始脚本,这又把我们带回了最初的问题——自动化 UI 检查的创建进度缓慢。
本章提出的是,我们在非常具体的情况下使用 LLMs,针对特定的动作。如果我们想快速创建页面对象,LLM 可以帮助我们完成这项任务——它的输出可以快速集成到一个更广泛的框架中,最小化重写。

图 7.1 自动化 UI 检查组件部分的视觉表示
为了演示这个过程,让我们看看我们如何可以在自动化工作流程中使用诸如 Copilot 和 ChatGPT 等工具,通过构建针对网站automationintesting.online的自动化检查来选择 AI 工具可以协助的具体动作,这是一个模拟的民宿预订网站,可以用来练习各种测试和自动化活动。
在这个例子中,我们检查消息是否显示在网站的管理员部分。为此,我们需要将以下步骤编码化:
-
启动浏览器。
-
在主页面上完成联系我们表单。
-
前往网站的管理员部分并登录。
-
加载消息部分并确认创建的消息出现。
这个过程在图 7.2 中进行了总结。

图 7.2 自动化 UI 检查的视觉表示
尽管这个例子本身并不引人注目,但我们将了解到,我们可以通过使用 AI 工具来加速我们的工作,每完成一步——让我们开始吧。为了参考,你可以查看 GitHub 上为这个例子生成的代码(mng.bz/4pXB)。
设置 Copilot
本章假设你已经在你 IDE 中安装并配置了 Copilot 插件。如果你还没有完成设置过程,你可以在附录 B 中找到安装说明。
活动 7.1
按照本章的步骤进行,看看你是否能生成类似的自动检查。像往常一样,请记住,来自 ChatGPT 和 Copilot 的输出可能与以下示例中捕获的输出不同。
7.1.1 设置项目
我们将在本节中使用 Java 进行示例。这是一种演示使用 AI 工具价值的有用语言,因为 Java 以其依赖模板代码(LLMs 可以帮助我们构建的好地方)而闻名。在我们的第一步,我们需要创建一个新的 Maven 项目,一旦项目创建完成,就将以下依赖项添加到我们的pom.xml文件中:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.9.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.9.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>5.5.3</version>
<scope>test</scope>
</dependency>
</dependencies>
现在我们已经拥有了开始开发我们的自动检查所需的一切。
在我们的依赖项就绪后,我们可以创建必要的包——com.example和我们的Test类,我们将它命名为MessageTest。从这里,我们可以开始使用 Copilot 构建我们的自动检查,但我们需要向 Copilot 提供一些信息以开始这个过程。所以首先我们在MessageTest类中添加一个WebDriver变量
private static WebDriver driver;
紧接着是代码注释:
// Use WebDriverManager to download the driver binaries
// and start the browser server for us.
代码和注释的组合足以作为提示来触发 Copilot 的以下响应:
public static void setupClass() {
WebDriverManager.chromedriver().setup();
}
这是一个好的开始,但它缺少了驱动程序实例化和BeforeAll钩子,我们可以在WebDriverManager调用下面的下一行添加:
driver = new ChromeDriver();
这给我们以下Before钩子:
@BeforeAll
public static void setupClass() {
WebDriverManager.chromedriver().setup();
driver = new ChromeDriver();
}
是否调整提示:做出高效的选择
关于使用提示生成所需输出的另一个观察是,可能会诱使人们多次调整提示以产生正确的输出,这可能会很耗时。在先前的例子中,代码注释可能对 Copilot 来说不够清晰,无法生成我们所需的完整代码片段。那么,可以选择改进提示或添加所需的代码部分。在这种情况下,添加所需的代码是有意义的。我知道我需要什么,花费时间调整提示将是浪费的。然而,如果我对想要的东西的了解较浅,我可能会选择进一步调整提示。效率是通过意识到在特定时刻做出正确选择而产生的。
我们已经设置了BeforeAll钩子,所以接下来我们想要创建一个拆卸钩子,我们可以通过添加以下注释来实现:
@AfterAll
提示 Copilot 返回
public static void teardown() {
driver.quit();
}
我们的第二个提示词可能比第一个更准确,因为我们开始具体化 Copilot 可以提示的上下文。我们添加到我们的代码库越多,Copilot 就越有可能准确地添加我们想要的。最后,为了验证一切是否正常工作,让我们添加一个裸骨的@Test来确保一切都在运行:
@Test
public void testMessageIsCreated() {
driver.get("https://automationintesting.online");
}
到目前为止,一切顺利。我们在 Copilot 的支持下设置了我们的项目以及初始测试。我们还观察到,最初,Copilot 缺乏细节来帮助推荐正确的代码行。但随着我们的发展,我们将开始观察到其准确性的提高。这是一个很好的开始——现在让我们看看工具如 ChatGPT 如何帮助我们更快地完成工作。
7.1.2 在 ChatGPT 的支持下创建我们的初始检查
在框架就绪后,我们可以将注意力转向完成主页上的“联系我们”表单。为了帮助具体化我们将要处理的内容,请参阅图 7.3。

图 7.3 测试网站上的“联系我们”表单
图表显示了需要完成的多个表单字段和一个提交按钮,所有这些我们都需要在我们的自动化检查中进行编码。为此,我们需要创建一个页面对象来捕获每个元素,我们将使用它来填充和提交表单。这个过程是费时的(而且我个人觉得这个过程既耗时又无聊,这是第一章中探讨的情感触发类型)。那么,我们如何加快创建页面对象的过程呢?我们可以使用 Copilot 来帮助我们编写类,但识别每个元素的每个 CSS 选择器的过程可能会占用大量时间。相反,让我们看看我们如何使用 ChatGPT 中的提示来快速创建我们的页面对象。
首先,让我们看看一个可以触发 ChatGPT 生成我们的页面对象的提示词(你可以将提示词复制并粘贴到 ChatGPT 中:mng.bz/QVpm)。我们使用分隔策略设置了指令:
|

你是一位 Java 开发专家。使用PageFactory库和@FindBy注解,将三个井号分隔的 HTML 转换为 Java Selenium 页面对象。 |
|---|
我们在分隔部分提供 HTML:
|

|
###
<Enter HTML here>
###
|
分析提示词,我们可以观察到它采取以下形式:
-
开头的清晰指令,告知 ChatGPT 我们想要实现的目标
-
一系列约束,明确指出我们对 ChatGPT 的期望,说明在创建我们的页面对象时我们希望明确使用哪些库和方法
要创建页面对象,我们将刚刚探索的提示词添加到我们的联系表单 HTML 的底部,然后将其粘贴到 ChatGPT 中。再次提醒,你可以使用 GitHub 上已经创建的提示词,链接为mng.bz/QVpm:
|

|
<form>
<div class="input-group mb-3">
<div class="input-group-prepend">
<span class="input-group-text" id="basic-addon1">
<span class="fa fa-id-card"></span>
</span>
</div>
<input type="text" data-testid="ContactName"
➥class="form-control" placeholder="Name" aria-label="Name"
➥id="name" aria-describedby="basic-addon1">
</div>
<div class="input-group mb-3">
<div class="input-group-prepend">
<span class="input-group-text" id="basic-addon1">
<span class="fa fa-envelope"></span>
</span>
</div>
<input type="text" data-testid="ContactEmail"
➥class="form-control" placeholder="Email" aria-label="Email"
➥id="email" aria-describedby="basic-addon1">
</div>
<div class="input-group mb-3">
<div class="input-group-prepend">
<span class="input-group-text" id="basic-addon1">
<span class="fa fa-phone"></span>
</span>
</div>
<input type="text" data-testid="ContactPhone"
➥class="form-control" placeholder="Phone" aria-label="Phone"
➥id="phone" aria-describedby="basic-addon1">
</div>
<div class="input-group mb-3">
<div class="input-group-prepend">
<span class="input-group-text" id="basic-addon1">
<span class="fa fa-envelope"></span>
</span>
</div>
<input type="text" data-testid="ContactSubject"
➥class="form-control" placeholder="Subject" aria-label="Subject"
➥id="subject" aria-describedby="basic-addon1">
</div>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">Message</span>
</div>
<textarea data-testid="ContactDescription" class="form-control"
➥aria-label="Description" id="description" rows="5"></textarea>
</div>
<br>
<button type="button" class="btn btn-outline-primary float-right"
➥id="submitContact">Submit</button>
</form>
|
将此提示输入 ChatGPT,得到以下用于我们的页面对象的代码:
|

|
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
public class ContactFormPage {
// WebDriver instance
private WebDriver driver;
// Constructor to initialize the PageFactory
public ContactFormPage(WebDriver driver) {
this.driver = driver;
PageFactory.initElements(driver, this);
}
// WebElements for the form fields
@FindBy(id = "name")
private WebElement nameInput;
@FindBy(id = "email")
private WebElement emailInput;
@FindBy(id = "phone")
private WebElement phoneInput;
@FindBy(id = "subject")
private WebElement subjectInput;
@FindBy(id = "description")
private WebElement descriptionTextarea;
@FindBy(id = "submitContact")
private WebElement submitButton;
// Methods to interact with the form
public void enterName(String name) {
nameInput.sendKeys(name);
}
public void enterEmail(String email) {
emailInput.sendKeys(email);
}
public void enterPhone(String phone) {
phoneInput.sendKeys(phone);
}
public void enterSubject(String subject) {
subjectInput.sendKeys(subject);
}
public void enterDescription(String description) {
descriptionTextarea.sendKeys(description);
}
public void clickSubmitButton() {
submitButton.click();
}
}
|
ChatGPT 不仅成功创建了一个包含完成和提交联系表单所需所有必要方法的类,而且这是在单个提示后迅速完成的。在这个例子中,类相当小,但常规页面对象可能相当大。编写和维护页面对象所需的时间会随着我们所需数量的增加而增加。因此,通过使用 ChatGPT 快速生成我们的代码,我们可以更快地开发我们的自动化检查。
测试性和 AI 工具
即使有 AI 工具,产品的可测试性也会影响工具的有效性。创建“联系我们”表单的 HTML 方式可以说具有很高的可测试性。HTML 是语义正确的,并且在input和textarea元素中包含清晰的、稳定的 HTML 属性,ChatGPT 可以预测并将其放入创建的类中。然而,如果我们正在处理的 HTML 需要我们识别更复杂的选择器,可能是因为自动生成的 ID 或 HTML 元素的缺乏,那么我们的提示的性能可能不会那么有效,需要我们更新和调整我们的页面对象以更好地满足我们的需求。
在创建好页面对象后,我们可以回到MessageTest并按 Tab 键浏览 Copilot 的建议来创建我们需要的“联系我们”表单填写代码:
ContactFormPage contactFormPage = new ContactFormPage(driver);
contactFormPage.enterName("John Smith");
contactFormPage.enterEmail("test@email.com");
contactFormPage.enterPhone("0123456789");
contactFormPage.enterSubject("Testing");
contactFormPage.enterDescription("This is a test message");
contactFormPage.clickSubmitButton();
Copilot 读取了我们的对象并帮助我们绘制了填写表单的流程图。尽管它可以读取ContactFormPage中存在的函数并预测下一步,但它缺乏对每个表单字段验证规则的上下文,这影响了它创建的测试数据。与上一章不同,其中测试数据是基于预期准确设置的,返回的数据是通用的,并可能导致问题。例如,为电话号码字段设置的验证规则要求输入 11 位或更多的数字,因此我们的预测测试数据会导致我们的自动化测试失败。因此,让我们更新enterPhone参数,使用将通过电话号码验证的数据:
contactFormPage.enterPhone("07123456789");
我们创建好消息后,现在想要登录,可以通过遵循与我们刚刚遵循的类似模式来实现。首先,我们创建必要的代码以跳转到管理页面:
driver.get("https://automationintesting.online/#/admin/");
然后,回到我们与 ChatGPT 的当前聊天中,我们可以重用我们之前的提示来为管理登录页面创建一个页面对象,这次只需简单地引用原始提示并提供新的 HTML 来生成我们的类:
|

| 再次遵循之前的提示,但这次使用以下 HTML:
<div class="col-sm-4">
<div class="form-group">
<input data-testid="username" type="text"
➥style="border: 1px solid grey;" class="form-control"
➥id="username"
➥placeholder="Username">
</div>
<div class="form-group">
<input data-testid="password" type="password"
➥style="border: 1px solid grey;" class="form-control"
➥id="password"
➥placeholder="Password">
</div>
<button type="submit" data-testid="submit" id="doLogin"
➥class="btn btn-primary float-right">Login</button>
</div>
|
这为我们项目添加了以下LoginPage代码:
|

|
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
public class LoginPage {
// WebDriver instance
private WebDriver driver;
// Constructor to initialize the PageFactory
public LoginPage(WebDriver driver) {
this.driver = driver;
PageFactory.initElements(driver, this);
}
// WebElements for the form fields
@FindBy(id = "username")
private WebElement usernameInput;
@FindBy(id = "password")
private WebElement passwordInput;
@FindBy(id = "doLogin")
private WebElement loginButton;
// Methods to interact with the form
public void enterUsername(String username) {
usernameInput.sendKeys(username);
}
public void enterPassword(String password) {
passwordInput.sendKeys(password);
}
public void clickLoginButton() {
loginButton.click();
}
}
|
接下来,我们在代码中创建 LoginPage 类,修复我们的缺失导入,然后回到 MessageTest 并使用 Copilot 通过按提示创建以下登录步骤:
LoginPage loginPage = new LoginPage(driver);
loginPage.enterUsername("admin");
loginPage.enterPassword("password");
loginPage.clickLoginButton();
最后,我们可以再执行一次使用 ChatGPT 和 Copilot 完成自动化测试的循环,首先添加必要的代码以导航到消息页面:
driver.get("https://automationintesting.online/#/admin/messages");
然后我们创建我们的最终页面对象,这次调整我们的提示以让 ChatGPT 创建一个返回消息计数的函数:
|

| 再次遵循提示,这次我需要一个返回消息计数的函数:
<div class="messages">
<div class="row">
<div class="col-sm-2 rowHeader">
<p>Name</p>
</div>
<div class="col-sm-9 rowHeader">
<p>Subject</p>
</div>
<div class="col-sm-1"></div>
</div>
<div class="row detail read-false" id="message0">
<div class="col-sm-2" data-testid="message0">
<p>James Dean</p>
</div>
<div class="col-sm-9" data-testid="messageDescription0">
<p>Booking enquiry</p>
</div>
<div class="col-sm-1">
<span data-testid="DeleteMessage0" class="fa fa-remove roomDelete"></span>
</div>
</div>
</div>
|
它返回以下 MessagePage 类:
|

|
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import org.openqa.selenium.support.PageFactory;
import java.util.List;
public class MessagePage {
@FindBy(className = "detail")
private List<WebElement> messageList;
public MessagePage(WebDriver driver) {
PageFactory.initElements(driver, this);
}
public int getMessageCount() {
return messageList.size();
}
}
|
与之前一样,我们在代码库中创建一个 MessagePage 类,修复我们的导入,然后回到 MessageTest 让 Copilot 预测以下代码以完成我们的自动化检查:
MessagePage messagePage = new MessagePage(driver);
assert messagePage.getMessageCount() == 1;
7.1.3 从生成的代码中填补空白
这样就完成了我们的自动化检查,但如果运行这段代码,很可能会导致自动化检查失败。失败的原因是我们必须等待消息计数加载,而我们的自动化检查中缺少这一步骤。尽管这种失败与我们之前遇到的 Copilot 添加的错误测试数据不同,但我们当前问题的原因相似。ChatGPT 根据提示内容和提供的 HTML 预测我们需要的代码。它缺乏理解我们系统哪些部分可能需要等待以防止自动化检查失败的能力。然而,我们可以通过回到 ChatGPT 并提交以下提示来解决这个问题:
|

你能改进 MessagePage 类,使其等待消息列表加载吗 |
|---|
ChatGPT 响应了一个更新版本的 MessagePage:
|

|
private WebDriver driver;
private WebDriverWait wait;
@FindBy(className = "detail")
private List<WebElement> messageList;
public MessagePage(WebDriver driver) {
this.driver = driver;
// Adjust the timeout as needed
this.wait = new WebDriverWait(driver, 10);
PageFactory.initElements(driver, this);
}
public int getMessageCount() {
wait.until(ExpectedConditions.visibilityOfElementLocated(
By.className("detail")));
return messageList.size();
}
|
这是在提高我们的自动化检查方面的一个进步,但也再次展示了 ChatGPT 是如何基于较旧的库和 API 版本进行训练的。如果我们把这段代码复制到我们的检查中,我们会看到 WebDriverWait 出现错误,因为它不再接受整数——这是在 ChatGPT 接受 Selenium 材料训练之后发生的改变。因此,我们需要将 WebDriverWait 更新为接受 Duration 参数:
this.wait = new WebDriverWait(driver, Duration.ofSeconds(10));
最后,为了提高我们从自动化测试中获得的反馈,我们将 Copilot 提出的断言更新为一个更具有信息量的版本:
assertEquals(1, messagePage.getMessageCount());
在所有这些准备就绪的情况下,假设平台上没有额外的消息,我们可以运行检查并看到它通过。关键的行为是观察我们如何使用 Copilot 和 ChatGPT 快速构建我们的自动化检查。而不是向 ChatGPT 发送如下提示
|

| 创建一个用于联系我们的表单的自动化测试,并检查消息是否已创建 |
|---|
这种方法会产生相当通用的输出,需要大量修改。我们通过 Copilot 和 ChatGPT 的每个步骤来处理我们的自动化检查,快速创建检查的部分,在工具之间切换以帮助我们解决特定问题。如果我们回到我们的影响范围模型,这种方法在图 7.4 中得到了总结。
模型显示,如果我们能够识别自动化检查中发生的特定操作——比如确定检查需要什么状态或需要做出什么断言——那么我们可以有效地使用 LLM(大型语言模型)进行这些操作。正如示例中所示,ChatGPT 和 Copilot(以及其他 LLM 工具)在预测和生成自动化代码方面非常快速。然而,它们无法访问我们正在自动化的产品的上下文。诸如错误的测试数据和缺少等待等问题需要我们引导自动化创建,而 AI 工具则在我们最需要的地方提供支持。

图 7.4 展示个人和工具在自动化检查开发中带来的影响范围模型
活动 7.2
使用 Copilot 和 ChatGPT 创建您自己的自动化检查,请访问automationintesting.online。这次,创建一个执行以下操作的自动化检查:
-
登录到网站的管理员部分
-
创建一个新的房间
-
断言房间出现在主页上
使用示例中共享的提示来生成您自己的页面对象,或者构建可能更有效的提示。
7.2 改进现有的 UI 自动化
我们最初的示例演示了如何与 LLM 工具合作快速创建新的自动化检查,但对于现有的检查又如何呢?与自动化工作意味着处理由于测试产品中的快速变化或不可靠性导致的失败的自动化检查。LLM 工具如何帮助我们快速改进自动化,同时确保它们仍然提供价值?让我们回到我们刚刚创建的自动化检查,看看 LLM 的使用模式如何帮助我们创建更健壮的自动化检查。
7.2.1 更新状态管理以使用适当的层
如果我们评估我们的自动化检查关注的内容,我们可以看到目标是检查消息是否可以在管理员面板中看到。这意味着对于我们的状态管理来说,我们不需要通过 UI 创建消息。这既慢又可能脆弱。因此,让我们看看我们如何通过 API 调用创建消息,这样我们可以改进我们的自动化检查并学习如何使用 LLM 构建 API 调用。
我们的目标是捕获通过“联系我们”页面创建消息时发送的 HTTP 请求,并将其编码到我们的自动化测试中。因此,我们的第一步是按照以下步骤捕获 HTTP 请求作为一个curl命令:
-
在我们的浏览器中打开开发者工具。
-
选择网络标签。
-
通过 UI 中的“联系我们”表单手动发送消息。
-
在网络标签页上找到 HTTP 请求,然后将其复制到
curl命令中(在开发者工具中右键单击请求)。
我们确定了命令位置后,就可以再次构建一个提示,将我们的 curl 请求转换为所需的 Java 代码,使用以下提示。我们首先为提示设置指令并使用分隔符策略:
|

将以下由三个井号分隔的 curl 请求转换为 Java,使用以下规则: 1. 请求封装在方法中 2. 方法将使用 Spring 框架发送 HTTP 请求 3. HTTP 响应不需要解析 4. 方法将接受一个表示 HTTP 负载的 POJO 作为参数 |
|---|
我们在分隔的段落中提供了 curl 请求:
|

|
###
curl 'https://automationintesting.online/message/' -X POST -H
➥'Accept: application/json' -H 'Content-Type: application/json'
➥--data-raw '{"name":"Mark","email":"test@test.com",
➥ "phone":"071234567890","subject":"This is a test message",
➥ "description":"This allows me to create a message rapidly"}'
###
|
与我们用来创建页面对象的提示类似,这个提示在添加具体指令以帮助 ChatGPT 预测我们所需的正确代码之前,在第一句话中说明了我们想要 ChatGPT 做的事情。例如,指出该方法希望体负载参数以 POJO(Plain Old Java Object)的格式传入,确保 ChatGPT 生成的解决方案使用 Java 类来构建我们的负载,而不是存储在大型字符串中的 JSON 对象,这将难以解析和维护。将带有 curl 请求的提示添加到底部并将提示传递进去,将得到以下代码:
|

|
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.web.client.RestTemplate;
public class HttpRequestSender {
public void sendRequest(MessagePayload payload) {
// Create a RestTemplate instance
RestTemplate restTemplate = new RestTemplate();
// Set the request URL
String url = "https://automationintesting.online/message/";
// Set the request headers
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.ACCEPT,
➥MediaType.APPLICATION_JSON_VALUE);
headers.set(HttpHeaders.CONTENT_TYPE,
➥MediaType.APPLICATION_JSON_VALUE);
// Create the request entity with the payload and headers
RequestEntity<MessagePayload> requestEntity = new
➥RequestEntity<>(
payload,
headers,
HttpMethod.POST,
URI.create(url)
);
// Send the HTTP request
restTemplate.exchange(requestEntity, Void.class);
}
}
|
这个预测的代码需要向我们的项目中添加新的依赖项,我们可以使用以下提示从 ChatGPT 请求:
|

| 该方法需要哪些依赖项? |
|---|
这导致 ChatGPT 返回
|

|
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.5.0</version>
</dependency>
|
然后,我们可以将依赖项添加到我们的 pom.xml 文件中,确保我们使用的是最新版本,接着将 HTTP 请求代码复制到我们的框架中一个新的名为 requests 的包中。我选择将类名从 HttpRequestSender 改为 MessageRequest。
这为我们提供了发送请求所需的代码,但由于我们通知 ChatGPT sendRequest 将需要一个表示请求体的 POJO 参数,我们接下来需要 ChatGPT 创建构建我们的 POJO 的代码。为此,我们回到与 ChatGPT 的现有对话中,并添加以下提示:
|

为 MessagePayload 创建一个包含构造函数和 getter 以及 setter 方法的 POJO |
|---|
提示清楚地表明我们需要构造函数和 getter/setter 方法,因为这些对于创建和发送我们的 HTTP 请求是必要的。将提示发送到 ChatGPT 得到以下代码:
|

|
public class MessagePayload {
private String name;
private String email;
private String phone;
private String subject;
private String description;
public MessagePayload() {
}
public MessagePayload(String name, String email, String phone,
String subject, String description) {
this.name = name;
this.email = email;
this.phone = phone;
this.subject = subject;
this.description = description;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getSubject() {
return subject;
}
public void setSubject(String subject) {
this.subject = subject;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
}
|
因为我们提供了初始提示中的curl请求,ChatGPT 可以预测我们的 POJO 创建通过 HTTP 发送消息的代码应该是什么样子,然后可以复制到我们项目requests包中的MessagePayload类。
在我们放置好用于创建消息负载并发送请求到POST /message/端点的类之后,我们可以回到MessageTest并使用 Copilot 来改进我们的检查。首先,我们移除以下检查部分:
driver.get("https://automationintesting.online");
ContactFormPage contactFormPage = new ContactFormPage(driver);
contactFormPage.enterName("John Smith");
contactFormPage.enterEmail("test@email.com");
contactFormPage.enterPhone("07123456789");
contactFormPage.enterSubject("Testing");
contactFormPage.enterDescription("This is a test message");
contactFormPage.clickSubmitButton();
并开始输入MessagePayload以触发 Copilot 添加以下代码作为替换:
MessagePayload messagePayload = new MessagePayload();
messagePayload.setName("Test User");
messagePayload.setEmail("test@email.com");
messagePayload.setPhone("0123456789");
messagePayload.setSubject("Test Subject");
messagePayload.setDescription("Test Description");
MessageRequest messageRequest = new MessageRequest();
messageRequest.sendRequest(messagePayload);
类似于我们使用 Copilot 在 UI 中生成创建消息的代码时,第一次运行自动检查会导致失败。检查将运行并收到 400 状态码,因为预测的测试数据再次不符合验证规则。因此,为了确保我们的消息符合必要的验证规则,我们需要更新以下方法以正确的测试数据:
messagePayload.setPhone("074123456789");
messagePayload.setDescription("Test Description that is larger");
一旦这些测试数据参数被更新,我们现在应该看到自动检查再次通过。
这个例子表明我们可以使用 LLM 工具帮助我们更新自动检查的特定方面,使其更加健壮,但这要求我们具备良好的分析眼光,了解哪些检查区域需要改进。这是因为我们既了解我们的自动检查,也了解我们正在测试的系统,这是我们迄今为止使用的工具所缺乏的。例如,如果我们给 ChatGPT 以下提示
|

| 建议如何改进这个自动测试,使其不那么不可靠 |
|---|
然后将我们的自动检查代码添加到提示中,这些将是返回的建议(总结):
|

| • 添加显式等待 • 使用稳定的定位器 • 处理异步操作 • 隔离测试 • 重试失败的操作 • 检查错误条件 • 审查和更新测试环境 |
|---|
这些都是合法的考虑因素,但它们是通用问题,并不一定给我们提供足够的信息来解决具体问题。因此,我们构建改进过程,寻求工具帮助我们快速生成必要的代码。
活动七.3
使用 ChatGPT 和 Copilot,尝试将登录过程转换为 API 调用。为此练习,你需要编写代码
-
将凭证发送到
POST /auth/login -
从 HTTP 响应中提取
token值 -
在前往消息页面之前,将
token值存储为浏览器中的 cookie
7.2.2 使用 AI 工具进入状态
本章展示了无论我们是在构建 UI 自动化、API 自动化还是其他完全不同的东西,使用 AI 的成功模式始终相同。我们对自动化检查的设计和结构的深入了解让我们知道何时何地使用 AI 工具来帮助我们完成特定任务以创建和维护有价值的自动化。围绕 AI 自动化的营销可能会让我们相信,当涉及 AI 时,我们在创建自动化检查中的作用是有限的。但如果我们想要帮助我们创建高质量产品的自动化,那么我们最好的行动方案是与 AI 工具建立关系,将我们的技能集置于工作的核心。
摘要
-
仅使用 ChatGPT 等工具尝试生成整个自动化 UI 测试可能需要大量的重做。相反,我们希望在 UI 自动化过程的特定点上选择性地使用 AI 工具。
-
使用 Copilot 等工具开始一个新项目可能会产生不同的结果。
-
我们在项目中添加的细节越多,Copilot 就会越准确。
-
通过提供 HTML 和转换指令,我们可以使用正确的提示类型在 ChatGPT 中快速生成页面对象。
-
我们可以通过结合 ChatGPT 和 Copilot(或类似工具)快速生成自动化检查。
-
AI 工具的输出并非 100%准确,因为它缺乏上下文——例如,使用测试数据或使用来自库的最新方法。
-
在创建自动化检查时使用 AI 工具的成功来自于在创建过程中使用 AI 工具完成特定任务。
-
我们领导创建过程,确定何时 AI 工具可以帮助我们加快速度。
-
如果我们能够识别自动化检查特定元素的可改进之处,我们可以使用 AI 工具更快地完成这些改进。
-
如果我们要求 LLMs 评估我们的检查并提出改进意见,我们会得到一些通用的答案作为回报。
-
我们可以使用在自动化检查中特定任务上使用 AI 工具的相同过程来维护它们。
第八章:使用人工智能辅助探索性测试
本章涵盖
-
使用 LLMs 增强探索性测试章程的创建
-
识别在探索性测试会话中使用 LLMs 的机会
-
使用 LLMs 在探索性测试会话期间支持各种活动
-
使用大型语言模型(LLMs)总结探索性测试会话报告
到目前为止,我们已经探讨了大型语言模型(LLMs)如何帮助我们处理一系列算法性的测试活动和工件。例如代码和数据生成具有独特的语法和格式规则,并且具有一定的可重复性,与 LLMs 配合良好。但是,对于更基于启发式的测试活动,如探索性测试呢?当我们亲自执行测试时,LLMs 如何支持我们?重要的是要重申,LLMs 不能取代测试或测试人员,但通过仔细观察我们在探索性测试期间所做的一切以及了解提示工程,我们可以选择性地增强我们的探索,而不会损害探索性测试的核心价值。为此,我们将检查以下三个方面的探索性测试以及 LLMs 如何帮助:使用章程组织探索性测试、执行探索性测试以及报告我们所发现的内容。
基于算法和启发式活动
当我们提到一个活动是启发式的基础上,我们指定该活动没有明确的步骤来执行或难以明确定义,而算法活动在本质上更程序化,可以用明确术语定义。在测试的背景下,测试用例和脚本可以被视为算法性的,而探索性测试则更启发式,因为它依赖于人类观察和分析情况以确定下一步行动的能力。
8.1 使用 LLMs 组织探索性测试
让我们首先关注 LLMs 如何帮助我们确定探索性测试的章程。通常,在执行探索性测试会话时,它们由以下测试章程指导:
|

| 探索使用不同提供商预订航班,以确定是否所有提供商都显示在结果中 |
|---|
在本章程示例中,我们遵循 Elisabeth Hendrickson 在其书籍《探索》(来自 Pragmatic Bookshelf)中提出的章程模板:
|

|
Explore <target>
With <resource>
To discover <information>
|
我们可以从这些章程中构建我们想要专注于探索的特性和产品,以及要忽略的内容。想法是拥有许多不同的章程,从不同的角度或更具体地说,不同的风险来探索特性。在生成章程时,我们理想情况下希望从风险中推导出它们,这样当我们根据章程进行探索性测试时,我们就知道以下内容:
-
我们应该将我们的章程放在什么优先级上(风险越高,优先级越高)
-
探索了哪些风险以及哪些尚未探索
-
我们从每次探索性测试会话中获得的价值
为了将其转化为视觉模型,图 8.1 展示了风险、任务书和探索性测试会话之间的关系。
该模型显示我们首先识别风险,然后将其编码为任务书,我们可以在这些任务书上运行多个探索性测试会话,以了解我们可以学到什么。

图 8.1 显示风险、任务书和探索性测试之间关系的视觉模型
识别风险和任务书主要是一种基于启发式的方法,因为没有明确的模式或程序可以遵循来识别风险。它基于测试人员和他们的团队的关键和横向思维技能。然而,我们将风险格式化为任务书的结构化性质意味着有时 LLMs 可以帮助增强我们的现有技能并提高我们的覆盖率。
8.1.1 使用 LLMs 增强已识别的风险
由于识别风险是一种基于启发式的方法,因此它容易受到偏见的影响。这意味着有时我们可能会错过需要我们注意的潜在风险(例如,功能固定性认知偏差,其中我们过于专注于观察一个事件,以至于完全错过了另一个)。那么,我们如何防止这些差距出现呢?测试人员努力提高他们的技能,以接受和处理这些偏见,并利用测试启发式方法来帮助我们改变在识别风险时的视角。但我们也可以使用 LLMs 作为额外的工具,帮助我们考虑不同的途径,并可能突出我们没有考虑到的潜在风险。
为了帮助展示它们如何帮助,让我们探索一个由团队创建的样本用户故事:
| • 为了作为管理员管理我的预订,我希望能够查看所有预订的报告 • 接受标准: − 假设我已登录为管理员,并且我有多个预订,当我加载报告页面时,我可以看到我的预订以月历视图呈现,并且选中的月份是当前月份 − 假设我在报告页面上点击导航控件,那么我能够移动到一年中的不同月份 − 假设我在报告页面上点击并拖动多个日期,那么我会看到一个包含以下字段的新预订表单:姓名 | 姓氏 | 房间 | 已支付押金 | − 假设我在完成预订表单的其余部分时加载了管理员预订弹出窗口,那么报告将被重新加载,并且新的预订将出现 |
|---|
使用示例,用户故事详细说明了从用户角度我们期望功能如何工作。这种类型的用户故事我们通常会在规划会议(可能更多或更少细节)中遇到,它将被用作开始讨论如何构建的起点,同样重要的是,讨论可能影响我们功能质量的风险。通常,这是测试员或注重质量的人会花时间提问并记录潜在风险以供进一步调查的地方。例如,一个基本的风险列表可能包含以下项目:
-
预订在报告视图中未显示。
-
报告视图难以解析。
-
导航控件工作不正确。
-
管理员预订无法提交。
这是一个很好的开始,但感觉还不够充分。这个列表模拟了我们可能发现自己难以进一步识别风险的情况,因此在此阶段,我们可以选择使用以下提示来帮助我们扩展分析。我们在指令中使用分隔符策略来区分发送的不同数据集:
|

| 你是一位专业的软件测试员。你将获得一个由三个井号分隔的用户故事和一个由三个反引号分隔的已识别风险列表。审查用户故事并扩展风险列表。 |
|---|
接下来,我们指导模型在输出之前先找出解决方案:
|

| 检查所有生成的风险是否不是原始风险的重复,并且它们与以下质量特性相关:可用性和准确性。 |
|---|
最后,我们在分隔的段落中提供数据集:
|

###<用户故事添加于此>###py − Bookings are not shown in the report view − Report view is hard to parse − Navigation controls don’t work correctly − Admin bookings can’t be submitted |
|---|
在这个提示中,我们提供了三组不同的数据集,以帮助指导 LLM:
-
质量特性—质量特性帮助我们定义对我们最终用户来说质量意味着什么。例如,交易平台的质量特性可能与个人助理应用的特征大不相同。将这些添加到我们的提示中可以帮助我们明确我们感兴趣的风险领域,并防止 LLM 产生无关或不感兴趣的风险。
-
用户故事—我们提供用户故事以给出提示上下文。再次强调,我们提供的关于一个特性预期要做什么的描述越多,我们就能越最大化 LLM 输出的风险的相关性。
-
当前风险—最后,提供现有风险可以防止 LLM 简单地输出我们已有的信息。通过要求 LLM 在输出风险之前先找出解决方案,我们确保输出的风险是新的,值得考虑的。这并不意味着它的输出更有用,而是它有助于界定我们一直在思考的内容。
本提示的目标是让一个大型语言模型提供可以添加到我们现有列表中的额外风险。例如,将提示发送到 ChatGPT 返回以下输出:
|

| 可用性风险:用户困惑:用户可能难以理解如何访问报告页面,这会导致挫败感和不良的用户体验。复杂的日历视图:如果月度日历视图没有设计得直观,可能会让管理员感到压倒性或困惑,影响可用性。导航控件:如果用于在月份之间移动的导航控件不够用户友好或响应,管理员可能会在导航时遇到困难,影响可用性。预订表单可用性:通过点击和拖动日期生成的新预订表单应该是用户友好和直观的。如果不是,管理员在填写时可能会出错。缺乏反馈:如果提交新预订后没有反馈或确认消息,管理员可能会不确定预订是否成功,影响可用性。错误处理:错误处理不当,例如未处理边缘情况或预订表单中的无效输入,可能会导致管理员困惑和挫败。准确性风险:遗漏的预订:在报告视图中未显示的预订可能会导致报告不完整或不准确,影响数据准确性。数据完整性:如果系统未能正确验证和保存管理员输入的预订数据,可能会导致报告中出现不准确的数据。错误的月份选择:如果报告中显示的默认月份不是指定的当前月份,这将是一个准确性问题。错误的导航:如果导航控件不符合预期,管理员可能无法准确导航到所需的月份。报告刷新中遗漏的预订:如果在完成预订表单后新预订未出现在报告中,这将影响显示数据的准确性。预订提交失败:如果管理员无法正确提交预订,这会导致系统中出现不完整或缺失的数据,影响数据准确性。 |
|---|
首先要注意的是,ChatGPT 已经有效地根据质量特性对提出的风险进行了分类。第二个观察点是每个风险都附带一个简短的句子来进一步解释它。这可以帮助我们理解它输出的风险或激发进一步风险的想法。 |
观察到提出的风险列表,其中一些风险被认为是有用且与我们用户故事相关的,例如:
-
用户困惑
-
错误处理
-
报告刷新中遗漏的预订
-
数据完整性
进一步的分析显示,一些显示的风险几乎彼此重复:例如,可用性类别中的导航控件和导航错误在准确性类别中(注意,这些不是我们原始风险的重复)。尽管措辞不同,但重点似乎相同:导航控件工作不正常。新风险和重复的组合表明,LLM 可以作为一个有用的工具来扩展我们的风险评估,但它们不能替代我们识别风险的能力。因此,在阅读 ChatGPT 的风险列表后,我们可能会选择扩大我们的风险列表,包括
-
预订在报告视图中未显示。
-
报告视图难以解析。
-
导航控件工作不正确。
-
行政预订无法提交。
-
对如何使用报告控制和行政预订存在困惑。
-
错误处理不正确。
在我们的最终列表中,我们选择采用并重构了一些从 ChatGPT 收到的风险,并忽略了其他风险。如果我们不满意,我们可以要求 ChatGPT 返回更多风险以供审查。然而,存在一个权衡,如果我们请求更多风险,我们面临 ChatGPT 响应中重复增加的风险。一旦我们对我们的列表感到满意,我们就可以开始将这些转换为章程,并返回到 LLM 以获取进一步的想法。
活动 8.1
从你正在工作的项目的待办事项中挑选一个用户故事或一系列需求。确定一些初始风险,然后编写一个提示,以建议考虑的其他风险。如果你不确定在提示中使用哪些质量特性,可以从以下来自《测试之眼》的综合性特性列表中选择一个或两个,该列表可在mng.bz/XV9Y找到。
8.1.2 使用 LLM 增强章程列表
一旦我们确定了风险,现在我们可以将它们转换为测试章程来指导我们的探索性测试。例如,以下风险:
|

| 预订在报告视图中未显示 |
|---|
可以翻译为:
|

| 探索预订在报告视图中的渲染方式,使用一组预订,以发现是否所有预订都正确显示 |
|---|
这个章程给我们提供了一些关于我们想要关注的内容、我们将使用的工具以及我们希望发现的内容的更多细节。如前所述,章程的形成遵循一个清晰的结构,就像风险一样,我们可以将其输入 LLM 以扩展我们未来测试可能考虑的章程列表。
因此,我们再次创建一个与之前风险导向提示类似的提示。首先,我们提供初始指令和分隔策略,以明确章程将在何处找到:
|

| 你是一位专业的软件测试员。审查由三个井号分隔的探索性测试章程,并提出额外的章程建议。 |
|---|
然后我们指示模型在输出解决方案之前先找出解决方案:
|

| 在输出额外内容之前,检查所有生成的额外内容是否不是原始内容的重复,并且它们与以下质量特征相关:可用性和准确性。 |
|---|
以下提示提供了分隔的多个数据集:
|

| ### 探索报告视图中预订的渲染方式,通过一组预订来发现是否所有预订都正确显示;探索报告视图中预订的渲染方式,通过一个非常大的预订集合来发现大量预订是否易于阅读;探索报告视图的导航,通过一组预订来发现导航控件是否按预期工作;探索提交预订,通过管理员预订表单来发现管理员是否可以提交预订;探索报告视图的核心功能,通过不同的角色来发现报告功能是否易于理解;探索管理员表单中的不同错误输出,使用损坏的数据和代理工具来发现错误是否被正确处理。### |
|---|
提示的结构与我们提供质量特征和当前已识别的额外内容列表作为提示内容的结构相似。每个额外内容都遵循相同的模板,这意味着当我将提示发送到 ChatGPT 时,我会得到以下建议的额外内容:
|

| 探索报告视图的响应性,通过不同的屏幕尺寸和设备来发现报告视图是否适应不同的屏幕尺寸;探索报告视图中的搜索功能,通过多种搜索条件来发现用户是否能够准确找到特定的预订;探索报告视图中日期过滤的准确性,通过不同的日期范围和时区来确保在指定日期范围内的预订正确显示;探索管理员预订表单中的用户权限,通过不同的用户角色(管理员、普通用户)来确保只有管理员可以访问和提交管理员表单中的预订;探索预订数据存储的准确性,通过提交具有各种数据格式和输入的预订来验证系统是否准确存储所有数据。 |
|---|
就像其他 LLM 输出一样,返回的内容质量存在差异。一些内容因其适合作为额外内容的候选而突出,例如:
-
探索报告视图的响应性
-
通过不同的屏幕尺寸和设备
-
为了发现报告视图是否适应不同的屏幕尺寸
其他一些内容,例如以下内容,超出了将预订添加到报告范围,可以丢弃:
-
探索报告视图中的搜索功能
-
通过多种搜索条件
-
为了发现用户是否能够准确找到特定的预订
再次,这表明 LLMs 可以帮助我们扩展我们的选项并识别新的探索途径。但它们绝不是完美的。虽然调整提示以使其更可靠当然可以帮助,但我们始终应该有选择性地从 LLM 中获取什么,忽略什么,这再次可以用图 8.2 所示的影响区域模型来总结。
我们运用创造性思维和使用横向和批判性思维技能来识别和组织我们的探索性测试的能力是至关重要的。在这个例子中,LLMs 为我们提供了一个在我们没有想法时改变事情的方法。
活动八.2
从上一个活动中提取风险,并使用“探索、使用、利用”模板将其中一些转换为章程。将这些添加到我们刚刚探索的提示中,以查看返回的建议提示。

图 8.2 影响区域模型,展示 LLMs 如何扩展我们的分析
8.2 在探索性测试中使用 LLMs
我们已经看到如何使用 LLMs 来组织探索性测试。现在让我们看看如何在执行探索性测试时使用 LLMs。
探索性测试中使用 LLMs 之所以有趣,是因为在探索性测试过程中可能会发生多种活动。在测试过程中,我们需要依赖工具以特定方式操作系统,这涉及到技术元素,同时还有基于启发式的人类驱动元素,其中我们使用心理启发式和或然性来理解我们所学的知识,并将其用于启发进一步的测试。这两个方面都可以通过使用 LLMs 来支持,为了更好地理解我们如何获得最大价值,让我们探索一个探索性测试会话的使用案例,其中使用了以下章程:
|

| 探索报告视图中预订的呈现方式,使用大量预订以发现大量预订是否易于阅读 |
|---|
为了进一步确定会话的上下文,会话的目标是测试报告页面的呈现,如图 8.3 所示。

图 8.3 测试中的报告页面
报告页面中的日历渲染了应用程序中每个房间存在的所有预订,我们的目标是了解它在有大量预订时的功能,特别是了解它可能如何影响日历的渲染和可用性。这意味着在理解数据如何发送到日历、如何快速创建它以及与我们的章程相符的测试类型等方面存在挑战。
8.2.1 建立理解
第一个挑战是理解报告数据如何到达日历,这意味着分析Report API 代码以更好地理解它。为此,我创建了以下提示。我们首先设定了添加代码注释的指令,并给系统留出思考时间:
|

| 你是一位 Java 开发者。为以下由三个井号分隔的代码添加代码注释。检查代码是否已被修改,并确保注释与提供的代码的每个部分相匹配,然后再输出创建的代码。 |
|---|
接下来,我们提供使用分隔符策略注释的代码:
|

|
###
public Report getAllRoomsReport(String token) {
List<Room> rooms = roomRequests.searchForRooms().getRooms();
List<Entry> parsedRooms = new ArrayList<>();
for(Room r : rooms){
Bookings roomBookings = bookingRequests.getBookings
➥(r.getRoomid(), token);
for(Booking b : roomBookings.getBookings()){
Entry entry = new Entry(
➥b.getBookingDates().getCheckin(),
➥b.getBookingDates().getCheckout(),
➥b.getFirstname() + " " + b.getLastname() + " - Room: " +
➥r.getRoomName());
parsedRooms.add(entry);
}
}
return new Report(parsedRooms);
}
###
|
提示被设计为提取存在于ReportAPI 中的服务层代码,并为我添加代码注释。通过使用代码注释,我可以更多地了解其工作原理以及它是如何构建发送到日历的必要报告数据的(这是通过使用工具监控发送到日历的 HTTP 流量所发现的)。这种类型的提示在我们想要快速了解系统某个部分的工作原理时很有帮助,并且对于那些可能不太自信的程序员来说可能非常有用。
因此,我构建了提示并将其发送给 ChatGPT,它返回了以下带有代码注释的代码:
|

|
/**
* Method: getAllRoomsReport
* Description: Retrieves a report containing booking details for all
* rooms.
*
* @param token The authentication token for accessing room and
* booking information.
* @return Report object containing parsed room booking entries.
*/
public Report getAllRoomsReport(String token) {
// Retrieve a list of rooms
List<Room> rooms = roomRequests.searchForRooms().getRooms();
// Initialize a list to store parsed room entries
List<Entry> parsedRooms = new ArrayList<>();
// Iterate through each room
for(Room r : rooms){
// Retrieve bookings for the current room
Bookings roomBookings =
➥bookingRequests.getBookings(r.getRoomid(), token);
// Iterate through each booking in the room
for(Booking b : roomBookings.getBookings()){
// Create an entry for each booking with relevant details
Entry entry = new Entry(
➥b.getBookingDates().getCheckin(),
➥b.getBookingDates().getCheckout(),
➥b.getFirstname() + " " + b.getLastname() + " - Room: " +
➥r.getRoomName());
// Add the entry to the list of parsed rooms
parsedRooms.add(entry);
}
}
// Return a new report containing parsed room entries
return new Report(parsedRooms);
}
|
由于初始提示中的指令“检查代码是否已被修改”,提供的原始代码保持不变(尽管检查一下以防幻觉导致代码被修改是很重要的)。除了代码,我们现在还有代码注释,这让我对代码的工作原理有了更多背景和细节的了解。这反过来又允许我将数据检索过程建模成与图 8.4 类似的可视化。
该模型基于提示中的信息来确定数据是如何创建的,并帮助阐明我需要如何创建所需的数据。这是一种快速建立理解的好方法,但正如往常一样,重要的是要核实这种理解并减轻 LLM 幻觉的风险。这就是为什么我在之后对过程进行了可视化建模,以便可视化可以轻松与他人分享,以确认我的假设是否正确或需要更新。

图 8.4 表示报告数据创建过程的模型
8.2.2 为会话创建数据需求
深入理解报告数据的构建方式后,我就可以继续探索测试会话中的下一个挑战——创建测试数据。我需要大量的预订(实际上成千上万),这意味着
-
创建测试房间以添加预订
-
为每个测试房间创建至少 1000 个测试预订
为了做到这一点,我再次使用了一个 LLM 来帮助我生成可以添加到seed.sql文件中的 SQL 数据,这些文件是room和bookingAPI 项目的一部分。如果我能将这些种子文件中的 SQL 数据设置正确,那么我就可以启动产品,并带有进行测试所需的所有数据。
我们已经在第六章中探讨了数据创建,因此对于RoomAPI 的第一个提示将感觉熟悉。为了创建必要的房间数据,我构建了以下提示。首先,我们使用初始指令和分隔符策略来提供 SQL 数据:
|

| 你是一位 MySQL 生成器。创建一个 MySQL 脚本,插入 10 条新记录,这些记录遵循由三个井号分隔的 MySQL 语句结构。 |
|---|
然后,我们提供额外的细节来处理数组数据的创建:
|

| * 如果使用了 ARRAY 关键字,则使用 MySQL ARRAY 函数创建相关数据类型 |
|---|
指令以思考时间策略结束:
|

| 在输出之前,检查每个新条目是否匹配正确的数据类型,并使用有效的 MySQL。 |
|---|
最后,我们提供将扩展为更多测试数据的 SQL 数据:
|

|
###
INSERT INTO ROOMS (room_name, type, beds, accessible, image,
➥description, features, roomPrice) VALUES ('101', 'single', 1,
➥true, 'https://www.mwtestconsultancy.co.uk/img/testim/room2.jpg',
➥'Aenean porttitor mauris sit amet lacinia molestie. In posuere
➥accumsan aliquet. Maecenas sit amet nisl massa. Interdum et
➥malesuada fames ac ante.', ARRAY['TV', 'WiFi', 'Safe'], 100);
###
|
提示旨在将现有的INSERT语句扩展为多个INSERT语句,这些语句可以添加到我的seed.sql文件中。所需的一个特定指令是处理INSERT语句中的字符串数组。在添加之前,ARRAY数据类型没有正确创建,这导致为功能创建了奇怪的数据。然而,通过在提示中添加以下规则,
|

|
If the keyword ARRAY is used, use the MySQL ARRAY function to create related data type
|
LLM 能够创建必要的数据。这是一个很好的例子,说明在某个时刻,现有的提示(如第六章中的原始数据创建提示)需要调整以适应特定要求。
创建了提示后,我将它发送到 ChatGPT,它返回了以下数据(我已经缩短了它,但原始响应给了我所需的十条记录):
|

|
-- Record 1
INSERT INTO ROOMS (room_name, type, beds, accessible, image,
description, features, roomPrice) VALUES ('101', 'single', 1, true,
'https://www.mwtestconsultancy.co.uk/img/testim/room2.jpg', 'Aenean
porttitor mauris sit amet lacinia molestie. In posuere accumsan
aliquet. Maecenas sit amet nisl massa. Interdum et malesuada fames
ac ante.', ARRAY['TV', 'WiFi', 'Safe'], 100);
-- Record 2
INSERT INTO ROOMS (room_name, type, beds, accessible, image,
description, features, roomPrice) VALUES ('102', 'double', 2, false,
'https://www.example.com/image2.jpg', 'Lorem ipsum dolor sit amet,
consectetur adipiscing elit. Nullam eget odio nec urna consequat
placerat.', ARRAY['WiFi', 'Mini Fridge'], 150);
-- Record 3
INSERT INTO ROOMS (room_name, type, beds, accessible, image,
description, features, roomPrice)VALUES ('103', 'suite', 3, true,
'https://www.example.com/image3.jpg', 'Sed et fermentum lectus.
Proin feugiat tortor sed elit feugiat, vel laoreet ante fermentum.',
ARRAY['TV', 'WiFi', 'Kitchen', 'Balcony'], 250);
|
生成的数据并不最具启发性,但鉴于我的重点是简单地将一些房间添加到 API 中,这已经足够了。将此信息添加到seed.sql文件中,并移除注释,我能够重建包含必要数据的RoomAPI。
接下来,我将注意力转向创建 1,000 个预订,以便添加到预订 API 的seed.sql文件中。然而,这带来了一项新的挑战。要求一个 LLM(大型语言模型)响应 1,000 个INSERT语句是一项对任何 LLM 都具有挑战性的活动。一些 LLM 受到响应大小的限制,而有些 LLM 则无法以我所需的大小返回 token 计数。因此,我不得不以不同的方式来解决这个问题。不是提示 LLM 创建必要的数据,而是要求它创建一个脚本,该脚本会为我生成INSERT语句。最初使用分隔符策略的指令如下:
|

你是一位 NodeJS 开发者。创建一个脚本,将 1,000 条新记录插入名为seed.sql的 SQL 文件中,该文件遵循由三个井号分隔的 MySQL 语句结构。 |
|---|
以下是一些确保正确生成数据的额外说明:
|

| * 房间 ID 必须在 1 到 10 之间 * 退房和入住日期必须在 2023-11-01 和 2023-11-30 之间 |
|---|
以下代码提供了一个脚本最终将生成的 SQL 语句:
|

|
###
INSERT INTO BOOKINGS (roomid, firstname, lastname, depositpaid,
checkin, checkout) VALUES (1, 'James', 'Dean', true, '2022-02-01',
'2022-02-05');
###
|
在这个提示中,我要求一个 LLM 将 SQL 语句转换成一个 NodeJS 脚本,该脚本将输出包含所有必要数据的seed.sql文件。这样,我就可以运行一次脚本,然后将新创建的seed.sql文件移动到我的预订 API 中。语言的选择完全是个人偏好,但使用格式策略,我们可以快速将其更新为让 LLM 生成不同语言的脚本。
将此提示发送到 ChatGPT 触发了 LLM 返回以下脚本以供使用:
|

|
const fs = require('fs');
// Function to generate a random date between two dates
const randomDate = (startDate, endDate) => {
return new Date(startDate.getTime() + Math.random() *
(endDate.getTime() - startDate.getTime()));
}
// Function to generate a random boolean value
const randomBoolean = () => {
return Math.random() < 0.5;
}
// Function to generate a random integer between min and max
// (inclusive)
const randomInt = (min, max) => {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
const generateSeedData = () => {
const startDate = new Date('2023-11-01');
const endDate = new Date('2023-11-30');
const bookings = [];
for (let i = 0; i < 1000; i++) {
const roomId = randomInt(1, 10);
const firstName = `First${i}`;
const lastName = `Last${i}`;
const depositPaid = randomBoolean();
const checkinDate = randomDate(startDate,
endDate).toISOString().slice(0, 10);
const checkoutDate = randomDate(new Date(checkinDate),
endDate).toISOString().slice(0, 10);
const booking = `INSERT INTO BOOKINGS (roomid, firstname, lastname,
depositpaid, checkin, checkout) VALUES (${roomId}, '${firstName}',
'${lastName}', ${depositPaid}, '${checkinDate}',
'${checkoutDate}');\n`;
bookings.push(booking);
}
return bookings.join('\n');
}
const seedData = generateSeedData();
fs.appendFile('seed.sql', seedData, (err) => {
if (err) {
console.error('Error appending data to seed.sql:', err);
} else {
console.log('Data appended to seed.sql successfully!');
}
});
|
将此脚本复制到文件中并运行,我得到了所需的seed.sql文件。这种方法意味着我仍然能够获取所需的测试数据,但提示和响应的大小比直接请求 SQL 时得到的响应要小得多。
再次,现在我的预订seed.sql文件中已经有了必要的数据,我能够重建booking API,用所有我需要的数据启动应用程序,并开始探索它如何处理如此庞大的数据集。
8.2.3 探索和调查错误
在测试数据就绪的情况下,我能够尝试一系列受本次会议章程启发的测试想法,包括以下内容:
-
当我加载页面时会发生什么? 我发现可以在日历视图中看到预订,并且需要通过点击特定日期的“查看更多”文本来查看额外的预订。我还了解到,当有大量预订时,页面加载数据非常缓慢。
-
当我使用导航控件时会发生什么? 与之前的观察相似,在月份之间导航时,日历加载较慢。但尽管页面加载缓慢,我仍然能够通过日历进行导航。
-
如果我想要查看更多的预订呢? 我发现了一个问题,即显示额外日预订的弹出窗口会从页面顶部溢出,因此某些预订无法阅读。对于预订量较大的日子,弹出窗口加载也较慢。
-
我能否使用键盘访问日历? 我发现我能够成功地在日历的主要视图中使用 Tab 键导航,并且按下 Enter 键在“查看更多”链接上会打开包含额外预订的弹出窗口。我还发现,在弹出窗口中 Tab 键导航时,我无法聚焦到超出页面边界的溢出预订。
这些想法的共同之处在于,它们都是通过我在多年的探索性测试中发展出的心理启发式方法识别出来的。这种使用无意识启发式方法的概念,即通过经验内化的启发式方法,在 Richard Bradshaw 和 Sarah Deery 的文章“Mind the Gap”中得到了探讨:mng.bz/yoRJ。它讨论了我们如何使用有意识和无意识的启发式方法来指导我们的探索性测试。在我的探索性测试中,我依赖无意识的启发式方法。然而,当我到了想法枯竭的点时,我转向了更明确的意识启发式方法来产生更多的测试想法——例如,使用测试寓意 PAOLO。
PAOLO 是一个由 Maik Nog 创建的寓意法,它作为触发关于屏幕方向和渲染的测试想法的工具。每个字母代表考虑方向和渲染的不同方面,具体如下:
-
竖屏——在竖屏视图中看起来如何?
-
音频——需要考虑音频伪影吗?
-
对象——还有哪些其他对象在视图中需要考虑?
-
横幅——在横幅视图中看起来如何?
-
叠加——需要考虑叠加吗?
寓意法的价值在于它们能帮助不同的人触发不同的想法——这既是祝福也是诅咒,因为我们仍然在一定程度上依赖于我们的个人经验和偏见来产生新想法。因此,为了打破我的思维定式,我创建了一个新的提示,使用 PAOLO 寓意法来查看我可能从 LLM 那里得到哪些想法。在第一个提示中,我们设定了指令,使用了分隔策略,并提供了上下文:
|

| 你是一位探索性测试员。使用由三个井号分隔的准则列表,为每个列表项提出不同的测试想法,以尝试一个有多个预订的报告日历。通过先提及列表项,然后提出建议来格式化它们。 |
|---|
然后我使用少量射击策略来设定我希望响应的格式:
|

| 例如:导航 - 是否可以使用键盘在报告日历中导航。 |
|---|
接下来,我在分隔的章节中提供了寓意法:
|

| ### * 竖屏 * 音频 * 对象 * 横幅 * 叠加### |
|---|
在这个提示中,我需要提供一些关于我希望生成的测试想法的上下文。省略这些细节可能会产生相当通用的响应,或者可能导致幻觉。因此,我提供了细节以提高我可能收到的想法。然后我提供了写出的寓意法来构建响应,如果我想的话,我可以更改寓意法来查看 LLM 可能返回的其他想法。将这个提示发送到 ChatGPT 返回了大量想法,例如以下这些值得注意的想法:
|

| 竖屏 − UI 响应性:验证报告日历在不同屏幕尺寸和分辨率下的竖屏模式显示。 − 交互测试:在竖屏模式下测试添加/编辑/删除预订的功能。 − 滚动行为:检查在竖屏模式下滚动日历是否平滑,不会导致预订之间的任何故障或重叠。 − 竖屏特定功能:探索是否存在任何特定于竖屏模式的独特功能或选项,并验证其功能。 − 文本可读性:确保当设备切换到竖屏模式时,日历中的文本和标签仍然清晰易读。横屏 − 方向切换:测试在竖屏和横屏模式之间的转换,并确保日历平滑调整,不会丢失数据或导致显示问题。 − 预订布局:验证横屏模式下预订的布局和排列,确保它们保持组织良好且易于阅读。 − 多日预订:检查设备在横屏模式下多日预订的显示和行为。 − UI 一致性:确保在竖屏模式下可用的所有功能和功能在横屏模式下也可访问且功能正常。 − 旋转速度:测试日历是否能够快速响应设备方向的变化,而不会出现延迟或延迟。 |
|---|
从这个列表中,我挑选了一些对我有启发性的想法,特别是:
|

| 报告日历在不同屏幕尺寸和分辨率下的竖屏模式显示。 |
|---|
这引发了一个想法,即放大和缩小日历来观察会发生什么,结果发现当缩小放大时,性能问题被发现,当放大时,日历隐藏了“显示更多按钮”链接。下一个提示想法是:
|

| 测试日历是否能够快速响应设备方向的变化,而不会出现延迟或延迟。 |
|---|
这引发了一个问题:如果我尝试不同的屏幕尺寸会怎样?尝试用不同尺寸的移动屏幕查看日历时,发现页面在尝试为新视图组织自身时出现了更多的性能问题。然而,日历最终还是很好地处理了不同的视图。最后,我选择了以下提示想法:
|

| 确保当设备切换到纵向模式时,日历中的文本和标签仍然清晰易读。 |
|---|
我更明确地遵循了这个建议,并发现,在移动屏幕尺寸上,文本非常小且难以阅读。
我还使用了更多想法,并且还有更多可以探索的可能性,但鉴于这是一个展示 LLMs 在探索性测试中价值的环节,我选择结束会议并整理笔记以供未来报告。
8.2.4 使用 LLMs 辅助探索性测试
这个用例再次展示了如何利用 LLM(大型语言模型)快速满足特定的技术需求,例如数据生成,并在我们需要新想法时充当建议引擎。在整个探索性测试过程中,LLM 从未负责过。它被用来帮助我在进展过程中——所有这些都可以再次使用图 8.5 所示的影响区域模型来定义。
关键技能是能够识别出 LLM 在不会打断探索性测试流程的情况下对我们有用的时刻。这正是创建提示库变得有用的地方。通过构建提示库,我们开始识别 LLM 可以帮助我们巩固何时使用它们的痛点。然后,一旦我们进入会议,我们可以快速复制我们选择的提示,生成所需响应,并迅速继续。

图 8.5 探索性测试中的角色影响区域模型
关键技能是能够识别出 LLM 在不会打断探索性测试流程的情况下对我们有用的时刻。这正是创建提示库变得有用的地方。通过构建提示库,我们开始识别 LLM 可以帮助我们巩固何时使用它们的痛点。然后,一旦我们进入会议,我们可以快速复制我们选择的提示,生成所需响应,并迅速继续。
活动 8.3
考虑你过去进行过的探索性测试会议,或者你必须调试一个复杂问题的情景。写下不同的方式,LLM 可能在你进行探索性测试或问题解决中有所裨益。
8.3 使用 LLM 总结测试笔记
一旦探索性测试会议结束,需要将结果报告给相关个人,这可能会很具挑战性,与报告其他测试活动不同。鉴于我们的目标是分享我们所学的,将会议的所有细节提炼成一个简洁的报告可能会很困难,报告中需要分享的细节包括
-
我们如何进行探索性测试会议
-
需要什么样的设置
-
我们进行了哪些测试
-
我们从测试中学到了什么
-
发现了哪些错误
理想情况下,我们在探索过程中创建笔记以捕捉上述细节。但为了确保我们的笔记不会让我们感到不知所措或分散我们的探索注意力,我们通常不会添加太多细节。如果我们会在会议后进行总结,这会很有用。我们可以通过使用笔记作为触发器来讨论测试的某些方面来分享我们所学的。问题是,如果我们被要求分享关于过去进行的会议的细节,我们的笔记只能起到提示我们记忆的作用。
因此,作为一种替代方案,我们可以转向大型语言模型(LLMs),让它们创建一个测试故事,这是一种报告,讲述了某些测试是如何进行的以及在此过程中学到了什么。为此,我们可以创建以下提示:
|

| 你是一位探索性测试员。将用三个井号分隔的探索性测试笔记转换为测试故事,详细描述探索性测试会话中发生的事情。 * 确保所有识别出的错误都列在故事的末尾,以便快速参考。###<输入原始测试笔记>### |
|---|
在这个提示中,我们要求 LLM 将我们的测试笔记转换为测试故事。在我的探索性测试会话中,笔记是用 Markdown 编写的(完整的笔记见附录 C),并将被复制到提示中找到的分隔部分(再次,完整的笔记见附录 C)。提示还包含额外的指令,将发现的错误作为单独的列表列出,这在我们与团队讨论时可能很有用。将附录 C 中的测试笔记复制到分隔部分并发送给 ChatGPT 返回了以下响应。
|

| 测试故事:探索报告视图中的预订渲染在最近的一次探索性测试会话中,我深入研究了报告视图界面中预订的渲染情况,重点关注大量预订的显示。主要目标是评估处理大量预订数据时的可读性和功能性。会话概述我通过分析关联房间和预订的 API 中的代码结构开始会话,以理解报告页面的数据生成。以下是我探索的分解:数据设置: * 探索生成报告页面的测试数据,结果创建了房间和预订 API 的测试数据。 * 由于测试数据集成问题,在重建预订 API 时遇到了挑战,需要通过 IDE 进行直接操作。调查: * 页面加载性能: * 当存在大量预订时,观察到页面加载速度显著变慢。 * 导航控件: * 注意到在日历视图中在繁忙月份和空闲月份之间导航时速度变慢,尽管导航仍然可行。 * 查看预订: * 发现弹出显示预订时存在问题,导致溢出并使某些预订难以阅读。 * 在具有许多预订的日子里,加载弹出窗口时遇到延迟。 * 创建新预订: * 在取消创建新预订时发现延迟问题,影响了日历组件的渲染时间。 * 键盘可访问性: * 成功通过日历进行标签导航,但在弹出窗口中标签导航和导航时面临挑战。进一步探索: * 测试了日历在不同场景下的行为,例如调整大小、缩放和设备方向变化,揭示了在不同屏幕尺寸和方向上的多个渲染和显示问题。已识别的错误:1. 当预订量高时,页面加载速度显著变慢。 2. 在繁忙月份和空闲月份之间切换时,日历导航体验速度变慢。 3. 查看预订的弹出窗口溢出,阻碍了某些预订的可读性。 4. 取消创建新预订时的延迟问题影响了日历渲染。 5. 弹出窗口中的键盘导航存在可访问性问题。 6. 调整大小和缩放日历时渲染和内容消失速度慢。 7. 由于字体大小小,移动设备上存在文本可读性问题。 8. 在移动设备上无法执行点击和拖动操作以进行管理员预订。这些发现表明,需要关注几个方面,以增强报告视图中预订系统的性能、可用性和显示效果。 |
|---|
大型语言模型(LLM)成功地把我探索性测试的笔记转换成了一个内容更丰富的报告。它从对整个探索性测试的总结以及进入会话前的意图开始,然后详细说明了会话的细节。
检查这份报告展示了在使用大型语言模型(LLMs)时可以做出的选择。报告显示了大量使用项目符号和编号列表,这是 ChatGPT 中相当常见的模式。然而,我们可以通过应用额外的指令或特定的模板结构来解决这个问题。例如,如果我们更新我们的提示以使用康奈尔笔记法(mng.bz/M1M2):
|

| 你是一位探索性测试员。使用康奈尔笔记法将用三个井号分隔的探索性测试笔记转换成一份报告。### |
|---|
我们得到了一种不同类型的报告,它具有标题、提示、笔记和总结结构,这些结构可以通过康奈尔方法找到:
|

| 探索性测试报告:报告视图中的预订渲染总结:探索性测试旨在评估报告视图中预订的渲染效果,特别是关注处理大量预订的情况。检查内容包括调查页面加载时间、导航控件、创建新预订、键盘可访问性、对屏幕尺寸变化的响应性、设备方向以及横竖屏模式下的文本可读性。提示列:任务:使用大量预订探索报告视图中的预订渲染,以确定可读性。会话笔记: • API 数据创建: − 分析了(房间和预订)API 中的代码以理解数据生成。 − 设计了使用 SQL 和脚本生成房间和预订 API 测试数据的策略。 • 调查: − 加载页面:注意到包含大量预订的页面加载速度较慢。 − 导航控件:从繁忙月份过渡到安静月份时观察到页面反应迟缓。 − 查看更多预订:检测到弹出窗口问题 - 内容溢出和加载缓慢。 − 创建新预订:取消预订操作后出现延迟。 − 键盘可访问性:成功进行标签导航,弹出窗口中的标签导航存在问题。 • PAOLO 寓意法: − 横屏模式:评估了不同屏幕尺寸和分辨率下的日历响应性。 − 缩放:识别出缓慢调整大小和内容隐藏的问题。 − 设备方向:检查了屏幕调整大小和方向变化的处理。 − 文本可读性:在移动屏幕上注意到小文本的问题。笔记列: • 问题/错误: − 包含大量预订时的页面加载缓慢。 − 在繁忙月份和安静月份之间导航时的反应迟缓。 − 弹出窗口显示问题 - 内容溢出和加载缓慢。 − 取消预订操作后的延迟。 − 弹出窗口中的标签导航问题,尤其是在内容溢出时。 − 缩放时的缓慢调整大小和内容隐藏。 − 屏幕调整大小时的混乱初始布局。 − 移动屏幕上的小文本影响了可读性。 − 在移动视图中无法点击和拖动日历进行管理员预订。总结列: • 关键发现: − 页面加载和导航控件的性能问题。 − 影响用户交互的弹出窗口显示问题。 − 移动设备上的调整大小和文本可读性问题。 − 移动视图中管理员预订的功能有限。评估列: • 影响: − 需要性能优化以实现更平滑的页面加载。 − 需要改进弹出窗口显示以获得更好的用户体验。 − 需要调整响应式设计以适应不同的屏幕尺寸和方向。 − 需要调整文本大小以在小屏幕上提高可读性。行动列: • 行动项: − 优化页面加载时间以获得更好的性能。 − 优先修复弹出窗口显示问题以增强可用性。 − 实施针对不同屏幕尺寸的响应式设计改进。 − 调整文本大小以在移动设备上提高可读性。反思:探索性测试揭示了与性能、可用性和响应性相关的关键问题,这些问题与报告视图中预订的渲染有关。解决这些发现将显著提高系统的整体用户体验和功能。 |
|---|
在这里,实验是关键,但这一点表明 LLMs 有潜力以其他更传统工具无法实现的方式支持我们。在测试中,报告有时会受到忽视,因为快速分享大量知识可能很困难,这促使团队依赖更多基于定量和测试脚本的报告,这是我们通常看到的。然而,使用 LLMs,我们现在可以构建和展示报告,这些报告可以更全面地展示我们在探索性测试中学到的东西,分享隐藏在测试脚本或会话数量背后的丰富信息。
活动 8.4
基于你过去捕获的笔记创建一个测试报告。将笔记复制到本章本部分我们探索过的其中一个提示中,看看会产生什么样的报告。反思这个报告对你是否有用,或者提示是否需要调整以改进报告。
摘要
-
LLMs 可以用于识别探索性测试的风险和任务。
-
在识别风险时,如功能固定性等偏见可能会使我们错过探索潜在风险。
-
我们可以使用 LLMs 分析我们已经发现的风险,并提出进一步考虑的额外风险。
-
LLMs 生成的风险将包含一些可能有用或无用的建议。
-
我们可以执行一个类似的过程,请求 LLMs 提出额外的测试任务。
-
在探索性测试和 LLMs 的背景下,目标是利用 LLMs 来颠覆我们的思维并提供新的视角,而不是盲目地接受它们作为额外的风险和任务。
-
LLMs 可以用于帮助支持探索性测试会话中的活动。
-
我们可以使用 LLMs 转换代码并添加代码注释,这有助于提高我们的理解。
-
我们还可以通过请求 LLMs 为我们输出测试数据或创建我们可以用来生成数据的脚本来使用 LLMs 创建所需的测试数据。
-
将提示与测试启发式方法相结合可以生成建议的测试想法。
-
我们可以确定哪些建议的测试想法是有用的,并丢弃其余的。
-
LLMs 可以用于将测试笔记转换为更丰富的测试报告,例如测试故事。
第九章:个 AI 代理作为测试助手
本章涵盖
-
理解如何使用 LLMs 构建 AI 代理
-
创建一个基本的 AI 代理来展示其价值
在前面的几章中,我们看到了大型语言模型(LLMs)如何帮助我们进行测试。我们还学习了如何使用各种提示技术充分利用 LLMs,并编制出在需要时可以使用的不同提示。这是一个很好的位置,但如果我们能将我们的新理解再向前迈进一步,创建定制的 AI 测试助手会怎样呢?
随着 LLMs 的进步,创建 AI 代理、可以自主与其他系统交互、收集数据、分析信息并适应适当响应以实现目标的应用程序的机会也在增加。在 AI 领域,代理可以以许多方式实现,但目标往往相同——创建我们可以分配任务以解决的东西。设计和构建 AI 代理的范围很广,但在这章中,我们将更多地了解它们的潜力和在生成式 AI 背景下的工作方式。我们还将创建我们自己的基本测试数据 AI 代理,以展示这项技术的力量和潜力。
9.1 理解 AI 代理和 LLMs
当我们讨论 AI 代理时,我们需要明确我们所说的意思。AI 代理可以根据我们工作的 AI 领域以不同的方式实现。但这并不意味着无论其工作方式如何,代理都没有预期的行为。因此,在我们开始实现我们的代理之前,让我们首先定义这些预期行为可能的样子,然后讨论在生成式 AI 的背景下如何构建代理。
9.1.1 什么是 AI 代理?
要理解什么是代理,我们必须更多地关注其中注入的特性,而不是它的实现。没有明确的特性列表表明代理应该满足,但以下属性通常是存在的:
-
以目标为导向——代理必须能够接收一个它可以最终实现的目标。目标本身可以是具体的,例如“为我预订 X 酒店的房间”,或者更抽象的,例如“根据当前趋势确定下个月最成功的股票”。无论目标范围如何,都需要某种方向来帮助我们评估代理采取的行动是否使其更接近完成任务。
-
具有感知能力——代理还必须能够与更广泛的世界互动。这可能意味着提取信息,或与系统交互以产生结果。例如,可能包括向 Web API 发出 HTTP 请求以获取用于处理的数据,或运行 Web 自动化代码以完成预订表单。无论我们希望我们的代理实现什么目标,我们都必须赋予它与世界外部互动的能力,以便它能为我们解决问题。
-
具有自主性——也许最重要的是,代理必须能够自主决定如何解决问题。它不必遵循我们设定的清晰、算法化的路径,而是可以选择和决定要执行的任务及其顺序。这就是代理的核心要素通常所在的地方。通过评估它被赋予的目标,它可以与世界互动,执行任务,并评估这些任务是否与其设定的目标一致。
-
具有适应性——最后,代理还必须能够从行动中学习。例如,能够完成视频游戏的代理是通过从它们犯的错误中学习来做到这一点的。但这也可以仅仅意味着对在特定时间点检索到的特定信息做出反应。与自主性类似,设定的目标在确定代理在执行特定任务时是否成功时发挥作用。如果没有,我们希望我们的代理能够对这种失败做出反应,而不是重复它或绕过它来实现其设定的目标。
这并不是一个详尽的特性列表。根据代理被要求解决的问题将决定哪些特性更重要。然而,提供的例子让我们对 AI 代理可能的样子及其行为有了概念。因此,代理是一块可以执行相对复杂任务并为我们解决问题的自主软件。
9.1.2 代理如何与 LLM 协同工作
那么,在使用 LLM(大型语言模型)驱动代理时,这些特征是如何产生的呢?答案是,“通过使用一个名为函数调用的 LLM 功能,”这使我们能够将代码封装成函数,当 LLM 被提示完成一个任务时,它可以为我们触发这些函数。为了帮助我们更好地理解这个过程,图 9.1 概述了函数调用的一般工作流程。

图 9.1 使用函数调用的 LLM 代理概述
如图中所述,函数调用通过向 LLM 提供提示和封装的代码来实现。然后 LLM 可以确定运行哪个函数以达到我们在提示中设定的目标。在每一个函数内部,都会有我们构建的代码,这些代码将以某种形式处理信息或与外界进行交互。例如,一个函数可能从 Web API 中提取数据,抓取网页,或从传感器收集信息。一个函数也可能从 LLM 接收参数,在将数据发送回 LLM 用于未来使用之前对其进行进一步处理。
使我们的代理具有自主特性的,是 LLM 确定何时以及使用什么数据调用哪些函数的能力。我们创建的每个函数都提供了一些额外的指令,以帮助 LLM 确定函数内部我们的代码做了什么。当被分配一个任务去完成时,LLM 可以处理初始提示,选择首先调用的正确函数,并将函数返回的任何信息存储到提示本身中。
这种自主性是区分代理与执行一系列函数的工具的关键,这些函数与不同的 API 或服务交互。代理可能被赋予大量各种函数,这些函数以不同的方式处理信息和与世界交互,仅使用必要的函数来解决问题。
9.2 创建 AI 测试助手
既然我们已经了解了代理是什么以及它们在生成式 AI 环境中的工作方式,让我们自己构建一个。在这个例子中,我们将构建一个 AI 代理,当给出指令时,它会为我们读取和创建数据。这可能看起来是一个相对简单的代理,但要深入了解代理的高级主题,可能需要另一整本书。然而,通过这个例子,我们将更好地理解如何使用 LLMs 构建代理,以便我们可以将我们的提示工程和代理构建提升到下一个层次。我们将逐步创建我们的 AI 代理,但您可以在以下链接查看我们的完成版本以供参考:mng.bz/aVlJ。
9.2.1 设置我们的虚拟 AI 代理
我们首先创建一个 Maven 项目,并在我们的pom.xml文件中添加以下依赖项:
<dependencies>
<dependency> ❶
<groupId>dev.langchain4j</groupId> ❶
<artifactId>langchain4j-open-ai</artifactId> ❶
<version>0.31.0</version> ❶
</dependency> ❶
<dependency> ❷
<groupId>dev.langchain4j</groupId> ❷
<artifactId>langchain4j</artifactId> ❷
<version>0.31.0</version> ❷
</dependency> ❷
<dependency> ❸
<groupId>com.h2database</groupId> ❸
<artifactId>h2</artifactId> ❸
<version>2.2.224</version> ❸
</dependency> ❸
</dependencies> ❸
❶ 快速连接到 OpenAI 平台的函数
❷ 创建我们的 AI 代理的 AI 服务
❸ 数据库用于与我们的代理交互
我们将使用 LangChain4J(docs.langchain4j.dev)来管理与我们 LLM 的通信以及我们希望代理执行的必要函数。当实现这些函数时,这会变得更加清晰,但首先,让我们通过创建一个名为DataAssistant的新类并添加一个main函数来建立我们与 LLM 的连接:
public class DataAssistant {
public static void main(String[] args) {
}
}
现在我们可以更新类,添加必要的代码来向 gpt-3.5-turbo 模型发送一个基本提示:
public class DataAssistant {
static interface DataAssistantService { ❶
String sendPrompt(String userPrompt); ❶
} ❶
public static void main(String[] args) { ❶
OpenAiChatModel model = OpenAiChatModel ❷
.builder() ❷
.apiKey("API-KEY-HERE") ❷
.modelName(OpenAiChatModelName.GPT_3_5_TURBO) ❷
.build(); ❷
DataAssistantService dataAssistantChat = ❸
➥ AiServices.builder(DataAssistantService.class) ❸
.chatLanguageModel(model) ❸
.build(); ❸
String response = dataAssistantChat.sendPrompt("Hi, can you ❹
➥introduce yourself?"); ❹
System.out.printf(response); ❹
} ❹
} ❹
❶ 创建一个接口,将我们的 AI 服务添加进去
❷ 设置访问我们的 OpenAI 模型和模型偏好
❸ 使用 AiServices.builder 将我们的模型添加到 DataAssistantService 中
❹ 向 OpenAI 发送基本提示,存储响应并输出
代码中的OpenAiChatModel部分确定了我们想要连接的模型以及我们的授权方法。在.apiKey方法中,我们提供了一个来自 OpenAI 的 API 密钥,该密钥可以通过他们位于platform.openai.com/api-keys的 API 密钥页面生成。然后,在设置DataAssistantService时,我们使用AiServices库将模型作为参数提供。这使得我们可以将模型选择和 AI 服务分开,从而可以轻松地更改我们想要使用的模型。DataAssistantService接口有助于配置我们想要用来发送提示的方法,以及如果我们希望的话,添加其他高级功能,例如系统提示,这些提示将上下文化我们想要发送的用户提示,一旦我们的服务建立。我们很快就会看到AiServices是如何发挥其作用的,但到目前为止,我们可以通过运行它并获取以下类似的响应来测试我们实现的代码:
|

| 你好,我是一个由 OpenAI 创建的语言模型 AI 助手。我在这里帮助你回答问题,并协助你获取可能需要的任何信息。今天我能帮您什么忙? |
|---|
现在我们已经连接到我们的 LLM,我们可以开始构建我们想要我们的代理触发的任务,以给我们的助手一些自主权。为此,我们创建了一个名为DataAssistantTools的新类,并添加了以下代码:
public class DataAssistantTools {
@Tool("Create room records")
public void createRooms(@P("Amount of room records to create")
➥int count) {
System.out.println("You want me to create " + count + "
➥rooms.");
}
@Tool("Create booking records")
public void createBookings(@P("Amount of booking records to
➥create") int count) {
System.out.println("You want me to create " + count + "
➥bookings.");
}
@Tool("Show results of database")
public void displayDatabase() {
System.out.println("I'll then share current database
➥details");
}
}
我们现在已经创建了三个函数,我们的 LLM 可以在提示时选择触发。但是 LLM 是如何确定在给定时间触发哪个函数的呢?这是通过使用LangChain4J提供的@Tool注解来实现的。@Tool注解不仅标识了要触发的函数,而且还向 LLM 用自然语言说明了代码的作用,这样 LLM 就可以确定它是否是一个值得调用的函数。例如,我们的第一个工具具有@Tool("Create room records")的注解。如果我们向我们的 LLM 发送一个提示,包括我们的工具,要求它创建一些房间,那么 LLM 将确定我们的工具应该被执行。如果我们发送一个完全不同的消息的提示,那么工具可能不会被使用。我们很快就会看到这一点,但首先,让我们更新我们的AiServices构建器,使其包含我们新创建的DataAssistantTools:
DataAssistantService dataAssistantChat =
➥AiServices.builder(DataAssistantService.class)
.chatLanguageModel(model)
.tools(new DataAssistantTools()) ❶
.build(); ❶
while(true){ ❷
Scanner scanner = new Scanner(System.in); ❷
System.out.println("What do you need?"); ❷
String query = scanner.nextLine(); ❷
String response = dataAssistantChat.sendPrompt(query); ❷
System.out.println(response); ❷
} ❷
❶ 通过构建器中的tools()方法添加我们的工具
❷ 设置一个扫描器以保持应用运行并接收提示
如我们所见,AiServices构建器开始展示其价值,它允许我们设置我们想要使用的模型和我们想要代理使用的工具。我们还更新了输入提示的方式,以便应用程序可以持续运行,我们可以用不同的指令测试我们的新代理。因此,当我们运行代理并被询问
|

| 您需要什么? |
|---|
我们可以提交我们的提示:
|

| 你能为我创建 4 个房间和 2 个预订,并告诉我数据库里有什么吗 |
|---|
将返回以下响应:
|

| 你希望我创建 4 个房间。你希望我创建 2 个预订。然后我会分享当前数据库的详细信息 |
|---|
让我们分析一下这个输出是如何生成的。首先,我们将我们的提示和工具发送到 gpt-3.5-turbo 进行处理。然后,模型评估提示的细节,并查看我们使用@Tool注解标记的工具列表,以找到与我们的指令相关的工具。在提示的开始,我们请求“你能为我创建 4 个房间”,LLM 确定我们的createRooms工具应该运行,因为它与注解@Tool("Create room records")相关。接下来,我们可以看到输出正确地说明了我们想要创建四个房间。这是因为我们通过 LangChain 的@P注解以@P("Amount of room records to create") int count的形式将一个参数传递给我们的createRooms方法。注意我们如何再次以类似@Tool注解的方式为@P注解提供自然语言上下文。这允许 LLM 进行类似的相关性匹配,从我们的提示中提取它认为必要的数据,并将其作为参数输出。
我们现在也可以通过给出不同的指令来测试我们的智能体自主确定使用哪个工具的能力。这次,当被要求给出一个提示时,我们发送了
|

| 你能告诉我数据库当前有什么吗 |
|---|
运行此操作将返回以下响应:
|

| 然后,我会分享当前数据库的详细信息 |
|---|
在这种情况下,LLM 只触发了我们的一个工具,即被标注为@Tool("Show results of database")的displayDatabase。由于我们在提示中未提及创建房间或预订,相关的工具被认为与我们的指令不相关,因此被忽略。这展示了智能体的强大能力。想象一下,如果我们不仅仅有 3 个工具,而是有 10 个、20 个或更多,那么我们给智能体提供反应我们指令和解决我们请求的问题的方式就更多了。
9.2.2 给我们的 AI 智能体执行函数
我们已经设置了决策过程,现在让我们完成智能体,并给它执行一些数据库查询的能力。为此,我们将使用h2创建一个模拟数据库,其中包含一些基本表,以展示我们如何让我们的 LLM 为我们执行操作。为此,我们将首先创建一个新的类QueryTools并添加以下代码:
public class QueryTools {
private final Connection connection;
public QueryTools() throws SQLException { ❶
connection = DriverManager.getConnection("jdbc:h2:mem:testdb"); ❶
Statement st = connection.createStatement(); ❶
st.executeUpdate(""" ❶
CREATE TABLE BOOKINGS ( ❶
bookingid int NOT NULL AUTO_INCREMENT, ❶
roomid int, ❶
firstname varchar(255), ❶
lastname varchar(255), ❶
depositpaid boolean, ❶
checkin date, ❶
checkout date, ❶
primary key (bookingid) ❶
); ❶
CREATE TABLE ROOMS ( ❶
roomid int NOT NULL AUTO_INCREMENT, ❶
room_name varchar(255), ❶
type varchar(255), ❶
beds int, ❶
accessible boolean, ❶
image varchar(2000), ❶
description varchar(2000), ❶
features varchar(100) ARRAY, ❶
roomPrice int, ❶
primary key (roomid) ❶
); ❶
"""); ❶
} ❶
public void createRoom() throws SQLException { ❷
Statement st = connection.createStatement(); ❷
st.executeUpdate(""" ❷
INSERT INTO ROOMS (room_name, type, beds, accessible, ❷
➥image, description, features, roomPrice) ❷
VALUES ( ❷
'101', ❷
'single', ❷
1, ❷
true, ❷
'/images/room2.jpg', ❷
'A generated description', ❷
ARRAY['TV', 'WiFi', 'Safe'], ❷
100); ❷
"""); ❷
} ❷
public void createBooking() throws SQLException { ❸
Statement st = connection.createStatement(); ❸
st.executeUpdate(""" ❸
INSERT INTO BOOKINGS (roomid, firstname, lastname, ❸
➥depositpaid, checkin, checkout) ❸
VALUES ( ❸
1, ❸
'James', ❸
'Dean', ❸
true, ❸
'2022-02-01', ❸
'2022-02-05' ❸
); ❸
"""); ❸
} ❸
public void outputTables(String query) throws SQLException { ❹
Statement st = connection.createStatement(); ❹
ResultSet rs = st.executeQuery(query); ❹
ResultSetMetaData rsmd = rs.getMetaData(); ❹
int columnsNumber = rsmd.getColumnCount(); ❹
while (rs.next()) { ❹
for(int i = 1 ; i <= columnsNumber; i++){ ❹
System.out.print(rs.getString(i) + " "); ❹
} ❹
System.out.println(); ❹
} ❹
} ❹
} ❹
❶ 启动时创建包含必要表的数据库
❷ 创建房间的基本方法
❸ 创建预订的基本方法
❹ 输出每个表内容的基本方法
在创建了QueryTools类之后,我们就可以通过更新DataAssistantTools来扩展我们的工具,以便以我们喜欢的方式与数据库交互。
public class DataAssistantTools [
QueryTools queryTools = new QueryTools(); ❶
public DataAssistantTools() throws SQLException { ❶
} ❶
@Tool("Create room records") ❶
public void createRooms(@P("Amount of room records to create") ❶
➥int count) throws SQLException { ❶
for(int i = 1; i <= count; i++){ ❷
queryTools.createRoom(); ❷
} ❷
} ❷
@Tool("Create booking records") ❷
public void createBookings(@P("Amount of booking records to ❷
➥create") int count) throws SQLException { ❷
for(int i = 1; i <= count; i++){ ❸
queryTools.createBooking();q ❸
} ❸
} ❸
@Tool("Show results of database") ❸
public void displayDatabase() throws SQLException { ❸
System.out.println("Current ROOM database state:"); ❹
queryTools.outputTables("SELECT * FROM ROOMS"); ❹
System.out.println("Current BOOKING database state:"); ❹
queryTools.outputTables("SELECT * FROM BOOKINGS"); ❹
} ❹
} ❹
❶ 在启动时创建数据库的新实例
❷ 通过循环请求的房间数量创建房间
❸ 通过循环请求的预订数量创建预订
❹ 输出每个表的内容
最后,我们更新DataAssistant类中的main方法以处理SQLException,结果如下完成代码:
public static void main(String[] args) throws SQLException
OpenAiChatModel model = OpenAiChatModel
.builder()
.apiKey("API-KEY-HERE")
.modelName(OpenAiChatModelName.GPT_3_5_TURBO)
.build();
DataAssistantService dataAssistantChat =
➥AiServices.builder(DataAssistantService.class)
.chatLanguageModel(model)
.tools(new DataAssistantTools())
.build();
while(true){
Scanner scanner = new Scanner(System.in);
System.out.println("What do you need?");
String query = scanner.nextLine();
String response = dataAssistantChat.sendPrompt(query);
System.out.println(response);
}
}
一切就绪后,我们现在可以通过首先请求它创建我们所需的数据来测试代理:
|

| 创建我 2 个房间和 3 个预订 |
|---|
然后,它会产生如下响应:
|

| 成功创建了两个房间和三个预订。 |
|---|
我们可以通过向代理发送额外的指令来确认我们的数据已被创建
|

| 列出数据库内容 |
|---|
结果输出看起来类似于
|

| 当前房间数据库状态:101 单间 1 真实 /images/room2.jpg 生成描述 [电视, WiFi, 安全] 100101 单间 1 真实 /images/room2.jpg 生成描述 [电视, WiFi, 安全] 100 当前预订数据库状态:1 詹姆斯·迪恩 真实 2022-02-01 2022-02-051 詹姆斯·迪恩 真实 2022-02-01 2022-02-051 詹姆斯·迪恩 真实 2022-02-01 2022-02-05 |
|---|
现在,我们的数据库查询已经连接到我们的工具中,我们可以与我们的代理交互,并让他们执行我们的任务。然而,让我们更进一步,让我们的代理能够运行多个工具链,利用一个工具中创建的数据在另一个工具中使用。
9.2.3 将工具链接在一起
目前,我们的工具是相互独立的。房间工具创建房间,并为每一行生成唯一的roomid键,但我们创建新的预订时并没有使用它们。我们只是简单地硬编码我们的值。因此,为了使我们的代理更加动态,并给它一个更复杂的问题,让我们看看我们如何将最近创建的房间的roomid传递给预订工具。
首先,我们需要在QueryTools类中创建一个额外的方法,该方法将返回最近创建的房间的roomid,如果没有房间在数据库中,则返回id为0:
public int getRoomId() throws SQLException
Statement st = connection.createStatement();
ResultSet rs = st.executeQuery("SELECT roomid FROM ROOMS
➥ORDER BY roomid DESC");
if(rs.next()){
return rs.getInt("roomid");
} else {
return 0;
}
}
在我们的新方法到位后,我们接下来使用DataAssistantTools创建一个新的工具:
@Tool("Get most recent roomid from database after
➥rooms have been created")
public int getRoomId() throws SQLException {
return queryTools.getRoomId();
}
注意我们在@Tool注解中设置,我们期望在创建房间之后运行此提示。虽然这并不保证我们的 LLM 按特定顺序运行工具,但如果我们要按特定顺序创建房间和预订,它充当了一个安全网。此外,我们将roomid整数返回给 LLM。然后,我们将它在原始提示的上下文中存储起来以供将来使用,我们将通过更新我们的createBookings方法来实现这一点:
@Tool("Create booking records")
public void createBookings(@P("Amount of booking records to create")
➥int count, @P("Most recent roomid") int roomid) throws SQLException {
System.out.println("I will create the bookings for room: " +
➥roomid);
for(int i = 1; i <= count; i++){
queryTools.createBooking(roomid);
}
}
在此更新方法中,我们添加了一个新的参数,形式为@P("Most recent roomid") int roomid。为了看到roomid提取的实际效果,我们在将其传递给createBooking用于我们的INSERT语句之前,使用System.out打印出roomid。为了使用roomid,我们返回到QueryTools并更新createBooking方法,使其接受roomid作为参数并将其添加到我们的 SQL 语句中:
public void createBooking(int roomid) throws SQLException
Statement st = connection.createStatement();
st.executeUpdate("""
INSERT INTO BOOKINGS (roomid, firstname, lastname,
➥depositpaid, checkin, checkout)
VALUES (
?,
'James',
'Dean',
true,
'2022-02-01',
'2022-02-05'
);
""".replace("?", Integer.toString(roomid)));
}
现在我们已经为我们的代理编写了代码,使其能够使用一个工具从我们的数据库中提取roomid,然后使用第二个工具中的该值,我们可以在以下示例聊天中看到这一功能是如何工作的:
|

| 你需要什么? |
|---|
|

| 创建三个房间 |
|---|
|

| 已成功创建三个房间。 |
|---|
|

| 你需要什么? |
|---|
|

| 列表数据库 |
|---|
|

| 当前房间数据库状态:101 单人间 1 可用 /images/room2.jpg 生成描述 [电视,WiFi,保险箱] 100101 单人间 1 可用 /images/room2.jpg 生成描述 [电视,WiFi,保险箱] 100101 单人间 1 可用 /images/room2.jpg 生成描述 [电视,WiFi,保险箱] 100 当前预订数据库状态:数据库已成功显示。 |
|---|
|

| 你需要什么? |
|---|
|

| 创建三个预订 |
|---|
|

| 我将为房间:3 创建预订。当前房间数据库状态:
101 single 1 TRUE /images/room2.jpg A generated description [TV, WiFi, Safe] 100
101 single 1 TRUE /images/room2.jpg A generated description [TV, WiFi, Safe] 100
101 single 1 TRUE /images/room2.jpg A generated description [TV, WiFi, Safe] 100
当前预订数据库状态:
3 James Dean TRUE 2022-02-01 2022-02-05
3 James Dean TRUE 2022-02-01 2022-02-05
3 James Dean TRUE 2022-02-01 2022-02-05
已成功创建三个预订。 |
分析输出,我们可以看到创建了三个房间,我们通过询问代理列出当前房间来确认这一点。然后,代理被要求创建三个预订,它成功地完成了这一任务。注意在Current BOOKING database state:之下的每一行的第二个整数。看看它们现在是如何使用roomid: 3的。代理已成功利用这两个工具查询最新的roomid,在将检索到的id传递给第二个工具(为我们创建预订的工具)之前。我们可以通过创建更多的房间来确认这不是巧合,以确认在创建的额外预订中添加了更新、更近的roomid(在这个例子中,roomid增加到6)。
|

| 你需要什么? |
|---|
|

| 创建另外三个房间 |
|---|
|

| 已成功创建三个房间。以下是更新后的数据库: |
|---|
|

| 你需要什么? |
|---|
|

| 创建另外三个预订 |
|---|
|

| 我将为房间:6 创建预订。三个预订已成功创建。 |
|---|
|

| 你需要什么? |
|---|
|

| 列表数据库 |
|---|
|

| 当前 ROOM 数据库状态:101 单间 1 真实 /images/room2.jpg 生成描述 [电视,WiFi,保险箱] 100101 单间 1 真实 /images/room2.jpg 生成描述 [电视,WiFi,保险箱] 100101 单间 1 真实 /images/room2.jpg 生成描述 [电视,WiFi,保险箱] 100101 单间 1 真实 /images/room2.jpg 生成描述 [电视,WiFi,保险箱] 100101 单间 1 真实 /images/room2.jpg 生成描述 [电视,WiFi,保险箱] 100Current BOOKING 数据库状态:3 詹姆斯·迪恩 真实 2022-02-01 2022-02-053 詹姆斯·迪恩 真实 2022-02-01 2022-02-053 詹姆斯·迪恩 真实 2022-02-01 2022-02-056 詹姆斯·迪恩 真实 2022-02-01 2022-02-056 詹姆斯·迪恩 真实 2022-02-01 2022-02-056 詹姆斯·迪恩 真实 2022-02-01 2022-02-05 |
|---|
这就完成了我们的基本数据创建代理。我们已经探讨了如何为代理创建多个工具,以便它能够使用这些工具来解决我们的特定请求。这些工具使我们能够赋予代理与周围世界互动的能力。例如,我们可以创建代码从数据源中提取相关数据,连接到传感器或物联网(IoT)设备,或与外部网站进行交互。我们还看到了这些与第三方交互的结果如何被反馈到我们的 LLM 中,以便它确定下一步要采取的行动,以及利用提取的信息进行进一步使用。
活动 9.1
考虑不同的方法来扩展这个代理以执行更多的数据助手任务。也许它可以删除或更新数据,或者对示例数据库中的现有内容进行进一步分析。或者,考虑构建执行其他任务的代理。
9.3 使用 AI 测试助手向前迈进
我们的测试数据助手展示了 AI 代理作为工具支持测试活动的潜力。我们可以将 AI 代理视为在提示变得过于复杂或我们希望将其扩展到与第三方系统接口时使用的方法。然而,重要的是要清楚地认识到开发 AI 代理的机会和挑战。因此,让我们回顾一下我们使用 LLM 支持测试的各个领域,AI 代理如何被设计来进一步扩展我们的提示,以及我们可能面临的问题。
9.3.1 AI 测试助手的例子
我们已经看到了 AI 代理如何帮助扩展 LLM 在测试数据空间中的应用。但为了将所有这些内容串联起来,这里有一些其他类型的 AI 代理的例子,它们将进一步提升我们的提示和 LLM 工作。
分析 AI 代理
我们已经了解到 LLMs(大型语言模型)如何帮助我们拓展思维,提出我们可能未曾考虑过的想法和风险。由于 AI 代理可以连接到各种数据源,因此可以构建一个增强我们建议提示的 AI 代理,该代理基于业务领域内收集的信息提供建议。例如,我们可能有一个如图 9.2 所示的代理。

图 9.2 连接到多个数据源的 AI 代理
这样的 AI 代理能够根据给出的指令确定访问哪些数据源。它可能能够从知识库和项目管理工具中提取相关文档,或从监控和分析工具中提取原始数据。所有这些收集到的数据都可以用来改进当被问及问题时 LLM 的响应,我们将在接下来的章节中更深入地探讨这个话题。
自动化助手 AI 代理
我们还探讨了如果我们在构建自动化时创建专注于特定任务的提示,LLMs 在测试自动化空间中最为有效。尽管如此,AI 代理交互、解析和共享信息的能力意味着我们可以创建如图 9.3 所示的代理。

图 9.3 以不同方式处理信息的 AI 代理
这样的 AI 代理能够构建自动化测试的部分。构建的部分可以传递给其他工具,以各种方式使用。这并不意味着这些类型的代理仍然可以一次性完全创建有价值的自动化。上下文,再次强调,是一个重要的因素,需要输入这些类型的 AI 代理,以使它们能够将规则和期望嵌入到我们的自动化中,与我们的产品工作方式保持一致。
探索性测试 AI 代理
最后,在这个例子中,我们并不是建议 AI 代理能为我们做探索性测试,但如图 9.4 所示,AI 代理可能作为测试人员的助手非常有用。

图 9.4 从相关信息中获取信息并使用附加提示构建建议的 AI 代理
注意在 AI 代理中,我们创建了一个助手,它接受一个初始提示,然后向 LLM 发送进一步的提示,以帮助生成有价值的建议。由于 AI 代理可以通过其工具与任何系统交互,因此没有理由 AI 代理不能与 LLM 交互。在这个例子中,代理帮助解析初始指令,然后使用这些指令来确定哪些进一步的提示可以被利用,从而触发一系列不同的提示以生成一些有趣的输出。
当然,这些都是假设的 AI 代理,但每个例子都表明,它们的成功根植于为代理接收的指令和分配给每个工具的提示类型。我们的数据代理示例只有基本的提示,但正是在这些空间中,我们可以为每个工具提供期望、参数和上下文,以帮助 AI 代理以对我们有价值的方式做出反应。
9.3.2 处理与代理一起工作的挑战
然而,这并不意味着构建、使用和维护代理没有挑战。以下是需要注意的几个挑战。
检查 LLM 的决策过程
毫不奇怪,开发代理的最大挑战之一是其不确定性。有很大可能性,当你在这个章节中处理示例代理时,你可能会遇到代理没有按预期执行工具,或者创建比所需更多或更少的数据的情况。代理中决定运行什么和工具之间共享什么数据的部分对我们来说是透明的。在我们的示例代理中,我们使用的是第三方 LLM,这意味着我们没有洞察其决策过程。我们也没有任何能力监控其行为或控制 LLM 的调整和操作方式。这种缺乏控制和可观察性可能会成为开发代理的重大风险。随着它们的复杂性增加,它们不仅可能变得更加脆弱,而且我们对出错的原因也几乎没有洞察力。
可以采取一些措施来减轻这种风险。我们可以在我们的平台上托管模型并提高可观察性。但是,尽管这可能让我们对 LLM 做出的决策和何时做出决策有更多的了解,但这并不意味着我们可以保证代理响应的结果。
导航防护措施和安全问题
这引出了使用代理时遇到的第二个问题:确保我们有必要的提示和代码来处理代理可能遇到边缘情况的情况,以及防止恶意行为者利用我们的代理对我们的业务或他人造成负面影响。在概述每个工具的目的时利用良好的提示技术,并添加检查和平衡来帮助我们的代理拒绝无效或不希望的要求是必须的,但这意味着要识别那些潜在的情景并为它们实施防护措施。结果是,代理需要广泛的测试和评估,这可能会带来成本,而这些成本可能会超过最初使用它们的价值。
管理出错时的情况
话虽如此,尽管我们尽了最大努力,但有时代理会出错。可能是工具运行顺序不正确,数据在工具之间传递不成功,或者我们工具中的代码有错误。在我们的示例代理的上下文中,这些潜在的错误将被 OpenAI 平台吞没。当我最初开发示例代理时,由于 JDBC 库失败,它抛出了异常,这些异常被代理消耗,引发了各种异常行为。在一个例子中,JDBC 代码以导致代理继续尝试创建新记录的方式失败,反复触发已损坏的功能,直到整个代理由于达到函数调用数量的限制而崩溃。问题是这个异常没有与我共享,这使得调试问题变得困难。
再次强调,可观察性和监控是必不可少的,以及确保我们的代码在潜在异常或错误方面进行了防御性编写。如果我们不捕获并报告它们,那么它们就会隐藏起来,导致我们浪费时间去调试最初出了什么问题。
最终,代理承诺通过支持我们完成任务,使我们个人更加高效。鉴于代理的自主能力,很容易相信他们的潜力。但就像我们寻求利用的任何软件一样,其创建、使用和维护都有成本。同样,像所有软件一样,它不是解决我们所有问题的万能药。要成功使用代理,我们需要花时间考虑我们试图解决的问题。有时,代理可能对我们有用,但其他选项,如精心设计的提示,可能同样有效,或者我们可能使用其他非基于 AI 的工具取得更好的成功。最后,代理只是我们在适当的时候可以使用的工具包中的另一个补充。
摘要
-
人工智能代理存在于人工智能的许多不同领域。
-
人工智能代理预计是目标驱动的,能够感知更广阔的世界,具有自主性和适应性。
-
在 LLM 的上下文中,代理是通过函数调用创建的。
-
函数调用是通过提供提示和代码块,这些代码块被分组为 LLM 可以调用的函数,以在提示内实现目标。
-
函数调用可以用来与其他网站和服务交互,将信息反馈给 LLM 进行进一步处理。
-
LangChain4J 是一个有用的库,可以轻松连接到 LLM 平台并管理 AI 服务,如工具。
-
我们可以使用
@Tool注解创建工具,这有助于 LLM 匹配我们的指令与要运行的方法以及何时运行。 -
我们通过
@P注解将提示中的提取值作为参数传递给方法,这与@Tool注解的工作方式类似。 -
代理还可以通过从方法中返回数据并使用
@P注解将数据作为参数拉入,在工具之间发送数据。 -
当智能体无法执行任务或发生错误时,很难检测出哪里出了问题。
-
当涉及到决策时,大型语言模型(LLMs)是透明的,这也可能使得调试问题变得具有挑战性。
-
将智能体暴露给更广泛的用户基础意味着它们需要设置防护措施,以防止智能体无法完成任务或容易受到恶意行为者的攻击。
第三部分. 背景:为测试环境定制 LLMs
在前面的章节中,我们已经看到,缺乏对我们产品工作方式或现有规则和期望的提示的泛化提示会返回更少的提示价值。虽然将我们的任务削减到合理的规模是关键,但提供那些必要的信息来明确 LLM 输出的边界,可能会使响应成功或失败。这就是为什么我们将以探索将上下文嵌入到我们的工作中来结束本书的最后一部分。
在接下来的章节中,我们将稍微偏离我们迄今为止学到的技术,探索不同方式来检索和添加上下文到 LLMs 和提示中。这意味着我们将涉足更高级的主题,如检索增强生成和微调,不是为了使我们成为这些领域的专家,而是为了欣赏它们是如何工作的,以及它们如何被利用来从 LLMs 中获得最大价值。因此,让我们深入探讨,看看有哪些令人兴奋的选项等待我们将 LLMs 提升到下一个层次,作为测试助手。
第十章:介绍定制化 LLM
本章涵盖
-
缺乏上下文如何影响大型语言模型(LLM)的性能
-
RAG 的工作原理及其价值
-
LLM 微调的工作原理及其价值
-
比较 RAG 和微调方法
在过去的几章中,我们看到了如何磨练我们的技能,以识别大型语言模型(LLMs)可以支持的不同、专注的任务。结合一系列提示工程技巧,我们已经成功地让 LLMs 返回对我们测试活动有价值的响应。然而,尽管我们学到了教训,我们收到的响应可能仍然不完全符合我们的需求和上下文。尽管认为我们可以完全使 LLM 与我们的上下文一致是愚蠢的,但我们可以利用更多高级选项,与提示工程结合使用,以进一步最大化 LLM 的输出,以支持我们的测试。因此,在本部分最后,我们将探讨如何增强 LLMs,使它们能够更多地嵌入我们的上下文,特别是关注检索增强生成(RAG)和微调。但在深入探讨这些方法的具体细节和行动之前,我们将首先检查为什么更常用的 LLMs,如 ChatGPT、Claude 和 Gemini,可能难以调整到我们的上下文,然后逐渐熟悉 RAG 和微调等更高级的主题,比较它们以确定在特定情况下哪一个更适合。
10.1 LLMs 和上下文的挑战
我们了解到,与 LLM 一起工作的关键挑战是让他们以输出响应,
-
与我们的上下文相关
-
遵循我们的提示规则
-
不包含幻觉
我们所使用的提示工程技巧帮助我们实现了这些目标。例如,如果我们回到第五章的示例提示,我们可以看到一个弱的提示,例如
|

| 创建文件上传功能的测试 |
|---|
这将返回一个弱响应,具有相当通用的结果:
|

| 1. 测试文件上传功能: • 测试用例 1.1:上传有效的文件 − 输入:选择一个有效格式的文件(例如,.jpg, .pdf)。 − 预期结果:文件成功上传,并显示确认消息。 • 测试用例 1.2:尝试上传无效的文件 − 输入:选择一个无效格式的文件(例如,.exe, .xhtml)。 − 预期结果:显示错误消息,指出文件格式不受支持。 2. 测试文件大小限制: • 测试用例 2.1:上传小于最大允许大小的文件。 − 输入:选择一个小于最大允许大小的文件。 − 预期结果:文件成功上传,并显示确认消息。 • 测试用例 2.2:尝试上传超过大小限制的文件。 − 输入:选择一个大于最大允许大小的文件。 − 预期结果:显示错误消息,指出文件超过最大大小限制。 |
|---|
然而,一个更详细且使用正确提示工程策略的提示可以导致更优化的响应。例如,一个提示
|

| 你是一位软件测试专家。为以下由三个井号分隔的行为生成尽可能多的测试用例。###一个授权 API,它将从不同的 API 获取令牌并检查该令牌是否有效且在有效期内,然后根据结果积极或消极地响应。### |
|---|
返回更具体的结果,如
|

| 1. 有效的令牌检查: • 使用在有效期内有效的令牌进行测试。预期结果:API 响应积极。 2. 过期令牌检查: • 使用已过期的有效令牌进行测试。预期结果:API 响应消极。 3. 无效令牌检查: • 使用无效的令牌格式进行测试(例如,字符错误,长度不正确)。预期结果:API 响应消极。 4. 空令牌检查: • 使用空令牌输入进行测试。预期结果:API 响应消极或返回适当的错误信息。 5. 空值令牌检查: • 使用空值令牌输入进行测试。预期结果:API 响应消极或返回适当的错误信息。 |
|---|
我们的第二个提示更成功,因为我们不仅分享了更多的上下文,还使用了特定的提示工程策略。因此,如果我们想最大限度地利用 LLM(大型语言模型),我们不仅需要依赖良好的提示工程技巧,还需要提供尽可能相关的上下文。原因有两点:
-
很可能 LLM(大型语言模型)没有在我们的上下文中接受过训练。因此,LLM 对我们上下文没有增加的权重或偏见。
-
LLM(大型语言模型)接受过如此大量的通用数据训练,当被问及一个通用问题时,它将依赖于从训练过程中识别出的更强、更通用的模式。
因此,如果我们想从 LLM 中获得最佳效果,表面上,答案似乎很简单:尽可能提供更多的上下文细节(我们已经在某种程度上这样做)。然而,如果我们这样做,我们很快就会遇到在提示中可以发送的上下文量的一些限制。
10.1.1 令牌、上下文窗口和限制
在我们讨论这个提示限制之前,还有一些关于 LLM 的概念我们需要了解——即令牌和上下文窗口。理解这两个方面的 LLM 将帮助我们理解为什么当前的 LLM 在可以提供的上下文量上有上限,以及这如何影响我们的使用策略。
令牌
假设我们正在向一个 LLM 发送以下提示:
|

| 列出世界上人口最多的五个城市。 |
|---|
一个只能使用机器码解释信息的 LLM 如何解析这个提示并返回响应?这是通过一个称为 分词 的过程完成的,在这个过程中,自然语言文本被转换成 LLM 可以读取的匹配整数。为了理解这是如何工作的,让我们考虑我们的城市人口提示。如果我们将其通过分词过程,这个句子将被切割成更小的、离散的部分。例如,我们的提示可以被分成 12 个部分:
List
me
five
of
the
most
populated
cities
in
the
world
.
如我们所见,句子中的每个单词以及句尾的句号都被切分成更小的部分,这些部分被称为 令牌。注意它们还包括每个单词左侧的空白。一般来说,句子中的每个单词都会被分割成其对应的令牌;然而,一些分词器(用于进行这种转换的工具)有时会将较大的单词分割成单独的令牌,或者将较小的单词组合在一起。
一旦句子被切割成令牌,每个令牌都会被转换成一个整数,使用一个唯一的数字作为每个单词的标识符。例如,完成我们的提示的分词将产生一个数字列表(逗号和空白已被添加以帮助可读性):
|

|
861, 757, 4330, 315, 279, 1455, 35459, 9919, 304, 279, 1917, 13
|
列表中的每个数字都与从我们的提示中提取的特定切片相关联。例如,令牌 the 的 ID 为 279,我们可以看到它在列表中出现了两次,每次 the 在句子中使用时都会出现。一旦提示完成分词过程,我们使用的模型就能够处理整数列表并开始确定如何回应。然后,分词过程再次被用来创建我们从模型收到的响应文本,因此我们的模型可能会以一系列整数的形式回应:
|

|
53954, 16417, 11, 6457, 198, 16939, 6151, 11, 6890, 198, 2059, 31170, 11, 5734, 198, 50, 3496, 38026, 11, 16327, 198, 44, 30955, 11, 6890
|
当转换回文本时,它会产生以下响应(行中断也包含在整数列表中,每次提到迭代 11 都是一个行中断):
|

| 东京,日本 | 德里,印度 | 上海,中国 | 圣保罗,巴西 | 孟买,印度 |
|---|
尝试不同的标记化器
为了更好地理解标记化过程是如何工作的,以及单词、数字和符号是如何被分割的,我们可以尝试使用标记化预览工具,例如gpt-tokenizer.dev/。
因此,标记是 LLM(大型语言模型)的一个重要方面,因为它们不仅告诉我们模型如何解析提示并形成响应,还告诉我们在我们遇到问题之前可以向该模型发送多长的提示。这引出了上下文窗口和我们的上下文挑战的核心。
上下文窗口
由于标记化过程将自然语言转换为 LLM 可以处理的数字序列,提示越长,需要处理的标记就越多。具有大量标记的提示的问题在于它会影响 LLM 处理我们的提示的有效性以及它消耗的资源。提示越大,生成响应就越复杂,这意味着更多的硬件使用。所有这些都会产生成本,无论是使用私有 LLM 的托管费用还是发送提示的 API 费用(例如,OpenAI 根据发送和接收的标记数量收费)。
再加上更大的上下文窗口并不一定意味着 LLM 性能更好,我们开始看到 LLM 提供商需要在某些方面做出权衡。因此,LLM 可能会在模型中内置某种类型的限制,限制它在特定时间内可以接收的标记数量。这被称为模型的上下文 窗口。不同的模型包含不同大小的上下文窗口,有时也被称为上下文长度。所有这些都取决于所训练的模型类型、运行它的硬件以及它与其他支持应用程序的部署方式。例如,OpenAI 的 ChatGPT 4 据估计具有 128k 标记的上下文窗口,而 Meta 的 Llama-2 为 4k(在修改之前)。因此,在确定在特定情况下使用哪个 LLM 时,我们必须意识到上下文长度。选择一个为了节省成本而限制大小的模型可能会限制我们可以添加到提示中的上下文。
并非所有上下文窗口都相同
讨论上下文窗口时需要注意的一点是,即使模型能够接收例如 128k 标记的请求,这并不意味着响应会有相同的限制。事实上,可能响应有一个更小的窗口来帮助降低成本。这不一定会影响我们接下来章节的学习,但这是一个在期望模型返回大型响应时值得记住的细节。
10.1.2 将上下文作为解决方案
现在我们了解到 LLMs 通过分词来解释我们的请求,并且我们可以发送给 LLM 的 token 大小是有限的,我们就可以开始看到当我们向提示中添加更多上下文时所面临的问题。尽管 LLMs 正在快速发展,变得更加高效,并提供了更大的上下文窗口,但例如添加一个应用程序的完整代码库作为提示的指令,这并不划算。我们可能会达到模型能力的上限,或者快速烧光我们的预算。相反,为了最大化准确性,我们需要考虑如何以智能的方式将上下文嵌入到我们的提示和 LLMs 中。幸运的是,AI 社区已经做了很多工作,我们可以利用这些工作来进一步嵌入上下文,从而提高准确性,同时不会让我们的预算(或模型)受损。
10.2 将上下文进一步嵌入到提示和 LLMs 中
为了提高 LLM 接触我们上下文的能力,我们可以利用两种技术之一。第一种是 RAG,第二种是微调。在接下来的章节中,我们将探讨这两种方法是如何工作的,它们之间有何不同,以及我们如何确定哪种方法更适合提高 LLM 的响应。尽管这两种方法在应用上不同,但它们的最终目标相似:通过允许我们向 LLM 的工作流程添加更多上下文,帮助我们提高模型的表现。检索增强生成试图通过关注我们如何增强提示的方式来解决问题,而微调则试图将我们的上下文直接嵌入到模型本身中。让我们简要地看看这两种方法,以便我们更加熟悉它们,并确定在特定情况下哪种方法更合适。
10.2.1 RAG
如我们之前所学的,如果一个 LLM 由于上下文窗口的限制而在接收提示的大小上存在限制,那么试图将所有上下文都放入一个提示中,希望它能改善 LLM 的响应,是不明智的。然而,这并不意味着我们不能在选择我们提供的提示中的上下文类型时有所选择。这意味着,在 LLM 中提高准确性,不是通过将我们的上下文强行施加到 LLM 上,而是通过精心设计我们的提示,使它们包含关于我们上下文的全部相关信息,以支持我们的指令。更具体地说,这意味着如果我们想让 LLM 为自动化检查生成模板页面对象,提供页面的特定 HTML 和任何相关的代码会比将整个代码库添加到提示中更好。
表面上看,这似乎是一个有效且简单的方法:编写我们的提示,找到相关的支持信息,将两者合并成最终的提示,然后发送给 LLM。然而,问题在于这可能是一项劳动密集型活动,需要研究和确定要添加什么信息以及要忽略什么信息。幸运的是,这正是 RAG 能帮助我们的地方。如图 10.1 所示,RAG 通过使用提示的初始指令来自动化将相关信息嵌入到我们的提示中的过程,以确定要添加到我们的提示中的信息。

图 10.1 RAG 的高级工作流程图
为了帮助我们更好地理解这个过程,让我们回到我们的 Page-object-generation 提示示例。使用 RAG 框架,方法将是这样进行的:
-
信息语料库将被创建。在我们的例子中,它可能包含我们应用程序每一页的标记 HTML 文档。
-
创建了一个提示,要求 LLM 为我们预订列表页面生成一个页面对象。
-
我们的 RAG 框架会分析我们的提示,并在我们的 HTML 文档语料库中程序化地找到最相关的文档。如果 RAG 框架工作正常,它将确定包含预订列表的 HTML 文档是最相关的。
-
将最相关的 HTML 文档添加到我们最初创建的提示中,然后将提示发送给 LLM 以返回响应。
RAG 通过分析我们所询问的内容,然后自动识别正确的上下文类型,帮助我们进一步地语境化我们的提示。这使得它非常有用,因为它帮助我们创建一个包含对 LLM 来说最有用的信息的提示,从而生成比没有这些信息更准确的响应。这同样是一种有用的方法,因为 RAG 允许我们嵌入任何易于解析和搜索相关性的数据类型,无论是代码、文档、数据库条目还是原始指标。我们还可以控制相关性的确定方式,这意味着我们仍然对可能添加到提示中的信息类型有控制权。
由于这些原因以及其相对容易的设置,RAG 已成为增强我们与 LLMs 互动的一种流行方法。一旦我们开始欣赏 RAG 的工作原理,我们就可以开始看到它在测试环境中的潜在用途。我们已经探讨了使用 RAG 提取代码库部分以支持旨在创建自动化的提示的想法,但它也可以用于支持关于风险评估、理解我们的产品如何工作以及生成测试想法的查询。还有在 RAG 框架中使用测试工件的可能性,例如探索性测试笔记、测试脚本或自动化代码,以加强我们在前几章中探讨的提示想法。从本质上讲,如果我们想要使用的数据可以以一致的形式存储并轻松查询,那么它有潜力被用于 RAG 框架中。
10.2.2 微调 LLMs
虽然 RAG 侧重于通过添加有针对性的上下文材料来增强提示,但微调侧重于增强我们使用的模型本身。微调利用一系列工具和技术,用于进一步训练一个已经初步训练过的模型,希望它能进一步使模型偏向于它已经被微调的数据,如图 10.2 所示。

图 10.2 展示了训练和微调之间的差异的可视化
如图中所示,微调是在已经在大规模数据集上训练过的模型上进行的。例如,ChatGPT 的免费版本是 OpenAI 的 gpt-3.5-turbo 模型的微调版本。gpt-3.5 经历了一系列的训练过程以获得其核心能力,但与人类互动时,它缺乏聊天机器人的语气和特征。例如,在向 gpt-3.5(在微调之前)发送以下提示后:
|

| 你是一位专业的旅行代理人。为我列出伦敦前三个必游之地。 |
|---|
我收到了以下回应:
|

| 1. 大英博物馆 - 这座标志性的博物馆收藏了来自全球各地的艺术和文物,包括罗塞塔石碑和有争议的伊林大理石。它是历史和文化爱好者的必游之地。 2. 伦敦塔 - 这座位于泰晤士河岸的历史堡垒曾作为皇家宫殿、监狱和军械库。游客可以探索王冠珠宝,看到著名的乌鸦,并了解伦敦塔迷人的历史。 3. 西区 - 伦敦著名的剧院区是世界上最顶级制作和音乐剧的家园。在皇家歌剧院或国家剧院等众多历史场馆之一观看演出,体验伦敦充满活力的表演艺术场景。 |
|---|
相反,当我将提示发送给 ChatGPT(gpt-3.5 的微调版本)时,我收到了以下回应:
|

| 绝对没错!伦敦提供了众多景点,但以下这三个地方是必游之地: 1. 大英博物馆:它是人类历史和文化的宝库,收藏着跨越数个世纪和大陆的数百万件文物。从罗塞塔石碑到埃及木乃伊和伊辛大理石,大英博物馆是一段穿越文明的迷人之旅。 2. 伦敦塔:历史悠久,充满传说,伦敦塔是一座标志性的堡垒,在几个世纪中曾作为皇家宫殿、监狱和金库使用。游客可以探索其古老的城墙,惊叹于王冠珠宝,并聆听著名囚犯和处决的故事。 3. 威斯敏斯特教堂:哥特式建筑的杰作,威斯敏斯特教堂不仅是礼拜场所,也是皇家加冕、婚礼和葬礼的地点。游客可以欣赏其令人惊叹的彩色玻璃窗、精致的石雕,以及艾萨克·牛顿、查尔斯·达尔文和伊丽莎白一世等著名人物的陵墓。这些景点为伦敦的历史、文化和遗产提供了丰富的视角。 |
|---|
实际上,它们是相似的。但观察第二个回答,我们可以看到它多了些风味。它有更随和、友好的语气,这一切都归功于它经过精心调整的过程,使其更具对话感。
类似于 ChatGPT 更随和的语气这样的品质,是通过使用精心挑选的数据集来训练模型,希望将其内部参数偏向于期望的语气、风格或特定上下文来实现的。总结一下,一个模型由数百万或数十亿个参数组成,这些参数决定了哪个词应该跟在另一个词或一组词之后。当开始微调会话时,这些参数已经存在,微调过程旨在进一步影响这些参数,使其更接近最终目标。我们将在第十一章中更详细地讨论这个问题,但我们可以从图 10.3 中看到这种方法是如何工作的。
微调是从数据集中提取信息,并使用这些信息形成发送给正在微调的模型的提示。然后,将响应与预期响应进行比较。如果响应不一致,模型将进行调整,以便在下一次提高产生一致响应的机会。这个过程可能需要成千上万次,甚至数百万次,以逐渐调整模型,使其更接近我们希望微调模型达到的目标,这意味着需要一个非常大的数据集才能成功训练模型。
将微调应用于模型可以带来一系列好处。我们已经在将 GPT 微调到 ChatGPT 中看到了它们,但我们还利用了微调后的 GPT 模型,以 GitHub Copilot 的形式出现。这些例子展示了微调在测试环境中可以提供的各种用途。因此,我们可以根据可能来自文档或测试工件的自然语言文本来调整模型。这可以用来将领域语言嵌入其响应中,并促进更符合我们上下文的响应。它们也可以在我们的代码库上进行调整,以帮助我们进行额外的风险评估、理解我们的代码在做什么,或者作为更一致的代码助手。
当讨论微调时,我们需要小心不要陷入这样的陷阱:认为我们在教模型关于我们的上下文。大型语言模型(LLMs)并不像人类那样思考。但是,作为一个类比,教模型关于我们的上下文是非常接近的。挑战在于这并不是一个精确的过程,这意味着可能需要多次迭代才能得到我们想要的结果,并且随着我们的上下文变化,可能还需要进一步的调整会话。

图 10.3 微调工作原理的视觉模型
10.2.3 比较两种方法
采取哪种方法很大程度上取决于我们想要实现的目标,以及施加在我们身上的限制。这两种方法并不完全相同。但如果我们处于需要决定采取哪种方法的位置,使用一些一般属性来帮助我们确定每种方法的优缺点是有益的。
学习曲线
尽管学习新技能总是与个人的能力和技能集相关,但对于那些一直跟随本书学习的人来说,熟悉 RAG 框架的学习跳跃比微调要小。可以认为,使用 RAG 框架是一种高级提示工程,并且有许多现成的工具可以用来以最小的努力开始使用 RAG。
然而,微调的学习曲线更陡峭,因为它是一系列不同的动作、工具和考虑因素,其范围比使用 RAG 要大得多。在微调章节中,你将学习在准备数据以进行微调、执行微调会话和评估成功时采取的不同步骤。每个部分都需要了解工具、框架和方法来完成每个步骤。幸运的是,微调周围的生态系统正在使微调过程更加易于访问和开始。
成本
我们需要考虑成本的两个方面:工具和人才。正如我们所学的,RAG 的学习曲线可能比微调更容易。这意味着围绕实施 RAG 的培训或雇佣人才的关联成本可能更低。至于工具,RAG 框架工具的初始设置成本可能相当低。然而,RAG 框架工具和第三方 LLMs 的使用可能会使成本激增,尤其是如果我们通过 LLM API 平台按发送和接收的令牌数量收费的话。
与流行的 RAG 框架不同,许多微调工具是开源的,这可以使工具的初始投资更便宜。出现了一些平台,它们使微调过程更容易,但它们确实有代价。对于工具而言,成本更多地体现在支持微调所需的硬件上。运行微调会话需要大量的 CPU、GPU 和 RAM 资源,如果我们想要大规模微调,那么就需要更多的投资。此外,一旦微调模型准备好使用,还需要考虑托管成本。最后,由于微调是由一系列活动组合而成的,因此培训或雇佣的成本可能非常高,这取决于我们想要在每个微调过程部分中达到多少细节。
生产速度
考虑到支持 RAG 框架的现有工具,RAG 的设置和运行可以相当快。当与 RAG 迭代时,重点将放在以下两个领域:我们想要发送给我们的 LLM 的提示(包括附加数据)以及我们想要存储并从所需数据中提取相关信息的数据。尽管有很多改进的空间,但将 RAG 框架的这些方面设置到初始令人满意的状态并不需要太多时间。
然而,微调可能相对较慢,因为涉及的活动更多。例如,为微调准备数据集本身可能是一项复杂的活动。根据硬件的不同,微调也可能需要时间来完成,即使是小的调整也可能需要数小时。再加上我们可能需要运行多个微调,因为我们调整微调、模型设置以及我们使用的数据集,所以我们可能需要一段时间才能达到一个令人满意的微调模型。
控制
尽管到目前为止的大多数比较都倾向于 RAG 框架,但这些好处确实有代价。当我们提到控制作为使用 LLMs 的质量特征时,这暗示了我们有多大程度的影响力来改进过程,我们对模型性能有多深的洞察,以及我们对 LLM 行为的控制程度。此外,还需要考虑隐私控制的问题。
大多数可购买的 RAG 工具都托管在可能相当不透明的平台上。这可能意味着对数据存储和检索方式或相关性算法的控制较少。例如,RAG 中使用的一种技术是向量数据库。数据在向量数据库中的存储和关系维护可能超出我们的控制,但这对返回的相关数据有很大影响。再加上许多这些工具倾向于鼓励我们使用像 OpenAI 的 API 这样的平台,那么我们对想要使用的模型以及 LLM 如何响应的控制就更少了。
微调非常依赖于实验,这意味着我们必须完全控制微调的所有方面。因为微调包含许多步骤,所以我们可以在过程的每个部分都有很多控制权。我们可以控制我们想要使用的数据以及它的格式,我们可以控制我们想要调整的模型类型及其调整方式。此外,因为结果是可以在其他地方部署的调整后的模型,所以我们有更多控制权来决定模型部署的位置以及谁可以访问它,这使得它更适合企业级应用。
这些比较帮助我们了解两种方法的比较,这在图 10.4 中已总结。
当然,这些比较高度依赖于上下文,但它们确实表明 RAG 可以是一个更快、更经济的初步方法。然而,如果我们想要对 LLM 的响应有更多的控制,那么如果我们愿意投入,转向微调可以给我们带来更多的回报。
10.2.4 结合 RAG 和微调
我们已经探讨了这两种方法的不同之处,但在我们结束本章之前,值得指出的是,这两种技术并不是相互排斥的。鉴于 RAG 侧重于提示,而微调侧重于对模型的调整,两者可以结合起来进一步提高响应质量。代价是引入了更多的复杂性,在构建、训练和调试过程中。将调整后的模型引入 RAG 框架进行生产要昂贵得多,而且如果它没有按预期(或希望)工作,我们如何确定需要我们关注的问题?这是与不确定性系统一起工作的挑战,无论我们选择专注于 RAG、微调还是两者的结合。评估 LLM 作为测试助手的使用方法需要持续的、健康的怀疑态度。

图 10.4 RAG 和微调的快速比较
摘要
-
使用 LLM 的一个关键挑战是让它们返回上下文相关的、有价值的结果。
-
要获得一个良好对齐的响应,一个大型语言模型(LLM)需要尽可能多地提供相关上下文。
-
LLM 通过将文本转换为称为标记的数字来解释自然语言文本,这一过程称为标记化。
-
根据 LLM 模型复杂性和运行它的硬件,LLM 在给定时间内只能接受一定数量的标记。
-
LLM 在给定时间内可以接受的标记数量称为上下文窗口。
-
由于 LLM 的上下文窗口有限,我们必须想出不同的策略,以便在不产生巨大成本的情况下嵌入上下文。
-
可以用来提高上下文感知度的两种方法是检索增强生成(RAG)和微调。
-
RAG 是一个过程,其中将额外的相关信息添加到提示中,以改善 LLM 的响应。
-
RAG 通过连接到一个数据语料库,并根据提供的提示查询找到最相关的材料。然后,所有这些材料都被组合成一个用于 LLM 的单个提示。
-
微调利用训练技术使用额外的数据调整已经训练好的模型。
-
微调允许我们修改 LLM 响应的语气、细节或方式。
-
微调可以帮助我们在 LLM 的参数中推广我们的上下文,使其对我们的需求更加敏感。
-
学习如何利用 RAG 框架通常比微调更快、更容易。
-
微调需要了解不同的流程和工具来执行完整的微调过程。
-
RAG 的工具和人才成本相对低于微调。
-
现有的 RAG 平台使得设置和运行 RAG 变得容易。
-
微调需要更多的时间投资来使模型准备好生产。
-
与我们最终使用的模型或框架相比,微调比 RAG 提供了更多的控制。
-
RAG 和微调可以一起使用。
第十一章:使用检索增强生成来上下文化提示
本章涵盖
-
RAG 是如何工作的
-
使用工具创建基本的 RAG 设置
-
将向量数据库集成到 RAG 设置中
如我们在上一章所学,与大型语言模型(LLMs)一起工作的一个挑战是它们缺乏对我们上下文的可见性。在这本书的第二部分,我们看到了不同的方法,我们可以安排我们的提示来帮助我们提供对我们上下文的小洞察。然而,这类提示只有在缺乏额外上下文导致响应价值降低之前才有用。因此,为了提高 LLM 响应的价值,我们需要在我们的提示中放入更多的上下文细节。在本章中,我们将探讨如何通过检索增强生成(RAG)来实现这一点。我们将学习 RAG 是如何工作的,为什么它是有益的,以及它从提示工程到构建我们自己的 RAG 框架示例的跳跃并不大,以建立我们对它们如何在测试上下文中帮助我们理解。
11.1 使用 RAG 扩展提示
回顾一下,RAG 是一种通过结合现有数据集和提示来提高 LLM 响应质量的方法。尽管这广泛地解释了 RAG 是如何工作的,但我们还需要深入挖掘,以更好地理解这种数据组合是如何实现的。RAG 系统的过程相对简单,可以总结如图 11.1 所示。

图 11.1 基本 RAG 系统工作原理的可视化
我们从用户输入开始,这可能是某种查询。例如,我们可能会向我们的 RAG 系统发送一个查询,比如“我想测试删除预订的想法。”这个查询随后会被发送到一个库或工具,它会检查我们的数据集中与查询相关的项目。在我们的例子中,这可能是一系列定义系统中每个特性的用户故事集合。库或工具将确定哪些用户故事最相关,然后将它们返回以添加到提示中:
|

你是一个为测试想法提供建议的机器人。你根据提供的用户故事回答建议的风险测试。这是用户故事:{relevant_document}用户输入是:{user_input}根据用户故事和用户输入,编制一个建议的风险测试列表。 |
|---|
然后,LLM 将消费用户查询和相关的文档,以返回一个比如果我们直接将查询“我想测试删除预订的想法”发送给 LLM 更准确的响应。
通过在提示中的{relevant_document}点提供与初始查询在{user_input}点相关的数据,我们得到一个准确性和价值都提高的响应。但这确实提出了一个问题:为什么一开始就要寻找相关数据呢?我们难道不能只发送每个提示中已有的数据,从而消除进行相关性检查的需要吗?选择添加到提示中的文档很重要,原因有几个。首先,考虑我们可以创建的提示大小。我们可以发送给 LLM 的提示大小取决于其最大序列长度或上下文窗口。上下文窗口定义了一个 LLM 可以处理多少个单词或 token。如果我们添加的 token 超过了上下文窗口允许的数量,那么 LLM 将要么在提示末尾截断多余的 token(导致提示部分完成),要么返回错误。用实际的话来说,Meta 的开源 LLM Llama-2 默认的上下文窗口为 4096 个 token,这大约相当于一本书的 10 页的平均等效长度。这最初可能感觉很多,但我们的测试和开发工件(例如,用户故事、测试脚本、代码)通常要大得多。
Token 和企业 AI 成本
在我们使用按发送的 token 数量收费的模型时,我们在提示中发送了多少 token 是一个重要的考虑因素。例如,在撰写本文时,具有 128k 上下文窗口的 gpt-4 turbo 模型,每 100 万个 token 收费 10 美元。因此,如果我们试图为每个提示最大化上下文窗口,我们将大约为每个提示支付 1.28 美元,这会迅速耗尽我们的预算。因此,高效的 RAG 提示不仅关乎从 LLM 获得最准确的响应,还关乎降低账单。
新的 LLM 出现了,它们具有更大的上下文窗口,这可能会解决提示大小的问题。然而,这让我们想到了使用相关性搜索的下一个原因——准确性。如果我们使用更大的上下文窗口,例如 gpt-4 的 128k 上下文窗口,我们可能会倾向于添加更多的上下文数据。但这会降低 LLM 响应的质量。我们提供的数据越多,我们向 LLM 的提示中添加的潜在噪声就越多,这可能导致更普遍或不受欢迎的响应。这也会使提示和响应的调试变得更加困难。正如我们在前几章多次探讨的那样,我们希望创建正确的提示类型,以最大化获得期望响应的机会。因此,在提示中提供特定的信息可以提高这种机会,这意味着在不过度稀释响应和遗漏重要细节之间取得平衡。
最后,通过将数据语料库与我们的提示生成和 LLM 分开存储,我们更好地控制了这些数据,这使我们能够根据需要更新存储的数据。尽管向量数据库(我们将在本章后面详细探讨)已成为与 RAG 平台同义的工具,但我们可以使用我们喜欢的任何数据源。只要我们能找到要添加到我们的提示中的相关数据,RAG 在访问数据以获取额外上下文方面提供了很多自由度。
11.2 构建 RAG 设置
现在我们已经了解了 RAG 框架的工作原理,为了更好地理解各个部分,让我们看看如何构建一个基本的 RAG 设置。我们将通过创建一个将执行以下步骤的框架来实现这一点:
-
摄入包含用户故事的文本文档集合。
-
查询用户故事集合,并根据用户查询找到最相关的文档。
-
将相关文档和用户查询添加到提示中,并通过 OpenAI 平台将其发送到 gpt-3.5-turbo。
-
解析响应并输出 LLM 返回的详细信息。
活动 11.1
在本章的这一部分,我们将介绍构建 RAG 系统基本示例所需的步骤。如果您想跟随示例并构建自己的系统,请从mng.bz/gAlR下载此框架所需的初始代码。所有必要的支持代码都可以在存储库中找到,以及存储在CompletedRAGDemo中的 RAG 框架的完整版本供参考。
11.2.1 构建我们的 RAG 框架
我们将从 GitHub 上示例框架代码中可以找到的未完成项目开始。该项目包含以下信息以帮助我们开始:
-
存储在
resources/data中的数据语料库 -
在
pom.xml中构建和运行我们的 RAG 框架所需的必要依赖项 -
ActivityRAGDemo,其中包含一个空的main方法,我们将在此处添加我们的框架
在我们开始构建我们的 RAG 框架之前,让我们回顾一下我们将在其中使用的pom.xml文件中存储的依赖项。这些库将帮助我们解析我们的文档并将我们的提示发送到 OpenAI 平台:
<dependencies>
<dependency> ❶
<groupId>commons-io</groupId> ❶
<artifactId>commons-io</artifactId> ❶
<version>2.16.1</version> ❶
</dependency>
<dependency> ❷
<groupId>org.apache.commons</groupId> ❷
<artifactId>commons-text</artifactId> ❷
<version>1.12.0</version> ❷
</dependency> ❷
<dependency> ❸
<groupId>dev.langchain4j</groupId> ❸
<artifactId>langchain4j-open-ai</artifactId> ❸
<version>0.31.0</version> ❸
</dependency> ❸
❶ 将所有用户故事文本文件添加到字符串集合中
❷ 提供在字符串集合上进行相似性检查的功能
❸ 将我们的提示发送到 OpenAI 平台
在我们的依赖项就绪后,我们现在需要导入存储在每个文本文件中的用户故事集合。每个用户故事都关注沙盒应用程序 restful-booker-platform(mng.bz/5Oy1)的特定 API 端点。以下是一个示例:
-
作为客人,为了取消我的预订,我想要能够发送带有预订 ID 的 DELETE 请求。
-
接受标准:
-
端点应接受预订 ID 作为路径中的参数。
-
如果提供了有效的预订 ID,服务器应取消预订并响应状态为“OK”(200)。
-
如果预订 ID 无效或缺失,服务器应响应一个“Bad Request”错误(400)。
-
可选地,可以在 cookie 中提供一个令牌进行身份验证。
-
这些用户故事是为了这个项目的目的而合成的,但我们可以想象这些数据可能已经被从项目管理平台、测试管理工具或任何我们认为相关的结构化数据中提取出来,从监控指标到维基条目。
为了引入我们的用户故事,我们首先需要将以下方法添加到我们的 ActivityRAGDemo 类中:
public static List<String> loadFilesFromResources(String folderPath) ❶
➥throws IOException { ❶
List<String> fileContents = new ArrayList<>(); ❶
ClassLoader classLoader = CompletedRAGDemo.class.getClassLoader(); ❷
File folder = new File(classLoader.getResource(folderPath).getFile()); ❷
for (File file : folder.listFiles()) { ❸
if (file.isFile()) { ❸
String fileContent = FileUtils.readFileToString(file, "UTF-8"); ❸
fileContents.add(fileContent); ❸
} ❸
} ❸
return fileContents; ❹
}
❶ 以文件夹位置作为参数
❷ 在资源中定位文件夹
❸ 遍历文件夹中的每个文件及其内容到列表中
❹ 返回文件内容列表以供进一步使用
方法 loadFilesFromResources 给我们提供了将所有用户故事文件加载到字符串列表中的能力,我们可以稍后查询这些文件。为了测试这是否有效,我们创建了一个 main 方法,我们可以执行它来运行我们的 RAG 设置:
public static void main(String[] args) throws Exception {
List<String> corpus = loadFilesFromResources("data"); ❶
System.out.println(corpus.get(0)); ❷
}
❶ 从资源文件夹内的数据文件夹加载文件
❷ 打印出集合中的第一个文件
在我们的 IDE 中运行此代码后,我们将看到以下结果输出以确认我们的用户故事确实被添加到列表中以便将来查询:
-
作为访客,为了更新品牌信息,我希望能够发送一个包含必要参数的 PUT 请求到 /branding/。
-
接受标准
-
我应该能够发送一个包含请求体中的必要参数(包括品牌信息)和可选令牌的 cookie 的 PUT 请求到 /branding/。
-
如果请求成功,响应状态应为 200 OK。
-
如果请求由于参数错误或数据缺失而失败,响应状态应为 400 错误请求。
-
请求体应包含符合 Swagger JSON 中定义的模式的有效 JSON 数据。
-
接下来,我们想要考虑我们将发送给 gpt3.5-turbo 的提示。我们将在以下提示中利用一些我们现在应该感到熟悉的策略:
|

你是一位专家软件测试员,负责提出测试想法和风险建议。你根据提供的三哈希分隔的用户故事和三反引号分隔的用户输入回答,提出建议的风险进行测试。编译基于用户故事和用户输入的建议风险列表。###{relevant_document}###py{user_input} |
|---|
注意我们如何参数化了相关的文档和用户输入部分。最终,我们的代码将用相关的文档和我们的初始查询来替换这两个部分。我们很快就会做到这一点,但首先,我们需要将提示添加到我们的代码库中:
public static void main(String[] args) throws Exception {
List<String> corpus = loadFilesFromResources("data"); ❶
String prompt = """ ❷
You are an expert software tester that makes recommendations for ❷
testing ideas and risks. You answer with suggested risks to test ❷
for based on the provided user story delimited by three hashes and ❷
user input that is delimited by three backticks. ❷
Compile a list of suggested risks to test for, based on the user ❷
story and the user input. ❷
### ❷
{relevant_document} ❷
### ❷
``` ❷
{user_input} ❷
```py ❷
"""; ❷
}
❶ 从资源文件夹加载文件
❷ 定义发送给 OpenAI 的提示
我们的下一步是确定我们的用户故事中哪一个与我们最终要输入的查询最相关。为此,我们将使用 Apache commons-text 库,它提供了一系列不同的相关性工具,例如 Levenshtein 距离、Jaccard 相似度和我们将要使用的——余弦距离。这些不同的相似性工具是如何工作的超出了本书的范围,但值得注意的是,RAG 的这个领域可以影响返回的数据。各种相似性算法以不同的方式工作,并且在生产就绪的 RAG 系统中可能会变得相当复杂。尽管如此,尝试基本方法以了解 RAG 系统的这一部分是如何工作的还是值得的,因此我们将创建我们的相似性匹配方法并将其添加到我们的类中:
public static String findClosestMatch(List<String> list, String query) { ❶
String closestMatch = null; ❶
double minDistance = Double.MAX_VALUE; ❶
CosineDistance cosineDistance = new CosineDistance(); ❶
for (String item : list) { ❶
double distance = cosineDistance.apply(item, query); ❷
if (distance < minDistance) { ❸
minDistance = distance; ❸
closestMatch = item; ❸
} ❸
}
return closestMatch; ❹
}
❶ 将用户故事列表和用户查询作为参数
❷ 使用余弦距离生成相似度分数
❸ 检查当前分数是否低于当前最相关的分数
❹ 返回列表中最接近的匹配项
该方法遍历每个文档,并使用 cosineDistance 计算相似度分数。分数越低,文档与查询的相似度越高。最终得分最低的文档是我们返回用于提示的文档。
与不同类型的相关性算法一起工作
cosineDistance 只是我们可以用来确定相关性的许多不同工具之一,每个都有自己的优缺点。我们将在本章后面进一步探讨其他工具以改进相关性搜索,但就目前而言,cosineDistance 将帮助我们构建一个可以迭代的可工作原型。
现在我们可以创建必要的代码来完成我们的提示生成。为此,我们扩展了 main 方法,首先允许用户输入他们的查询,然后在进行相似性检查之前将其全部添加到提示中:
public static void main(String[] args) throws Exception {
System.out.println("What would you like help with?"); ❶
Scanner in = new Scanner(System.in); ❶
String userInput = in.nextLine(); ❶
List<String> corpus = loadFilesFromResources("data"); ❷
String prompt = """ ❷
You are an expert software tester that makes recommendations ❷
for testing ideas and risks. You answer with suggested risks to ❷
test for, based on the provided user story delimited by three ❷
hashes and user input that is delimited by three backticks. ❷
Compile a list of suggested risks based on the user story ❷
provided to test for, based on the user story and the user input. ❷
Cite which part of the user story the risk is based on. Check ❷
that the risk matches the part of the user story before ❷
outputting. ❷
### ❷
{relevant_document} ❷
### ❷
``` ❷
{user_input} ❷
```py ❷
"""; ❷
String closestMatch = findClosestMatch(corpus, userInput); ❸
prompt = prompt.replace("{relevant_document}", closestMatch) ❹
.replace("{user_input}", userInput); ❹
System.out.println(prompt); ❹
}
❶ 等待用户通过命令行输入他们的查询
❷ 从资源文件夹加载文件
❸ 在加载的文件中找到与用户输入最接近的匹配项
❹ 替换提示中的占位符参数
我们现在可以运行这个方法,当被要求添加查询时,我们可以通过提交一个查询来测试我们的提示生成,例如:
|

| 我想要测试 GET 房间端点的测试想法 |
|---|
发送此查询将构建以下提示:
|

| 你是一位专家软件测试员,负责提出测试想法和风险建议。你将根据提供的三哈希分隔的用户故事和三反引号分隔的用户输入回答。根据用户故事和用户输入,编制一个建议的风险测试列表。###作为访客,为了浏览可用的房间,我希望能够检索所有可用房间的列表验收标准: * 我应该收到包含可用房间列表的响应 * 如果没有可用房间,我应该收到一个空列表 * 如果在检索房间列表时发生错误,我应该收到一个 400 Bad Request 错误 HTTP 有效载荷合约
{
"rooms": [
{
"roomid": integer,
"roomName": "string",
"type": "Single",
"accessible": true,
"image": "string",
"description": "string",
"features": [
"string"
],
"roomPrice": integer
}
]
}
###
pyI want test ideas for the GET room endpoint |
如我们所见,用户查询已被添加到提示的底部,而注入的用户故事是我们 findClosestMatch 方法认为最相关的那个。正是在这一点上,我们将开始看到我们实现的局限性。尝试不同的查询可能会选择一个不那么相关的用户故事。例如,使用以下查询:
|

| 我想要一个用于测试删除预订端点的风险列表 |
|---|
导致以下用户故事被选中:
|

| 作为访客,为了检索预订信息,我希望能够使用预订 ID 发送 GET 请求 |
|---|
这是因为 cosineDistance 方法在确定相关性方面存在局限性。我们将在本章后面探讨如何处理这个问题,但它确实突显了与 RAG 框架一起工作的局限性或风险。
尽管如此,让我们完成我们的 RAG 框架,以便它可以向 OpenAI 的 GPT 模型发送提示以获取响应。为此,我们将再次使用 LangChain 将我们的提示发送到 OpenAI 并输出响应:
public static void main(String[] args) throws Exception {
System.out.println("What would you like help with?"); ❶
Scanner in = new Scanner(System.in); ❶
String userInput = in.nextLine(); ❶
List<String> corpus = loadFilesFromResources("data"); ❷
String prompt = """ ❸
You are an expert software tester that makes recommendations ❸
for testing ideas and risks. You answer with suggested risks to ❸
test for, based on the provided user story delimited by three hashes ❸
and user input that is delimited by three backticks. ❸
Compile a list of suggested risks based on the user story provided ❸
to test for, based on the user story and the user input. ❸
Cite which part of the user story the risk is based on. ❸
Check that the risk matches the part of the user story before ❸
outputting. ❸
### ❸
{relevant_document} ❸
### ❸
``` ❸
{user_input} ❸
```py ❸
"""; ❸
String closestMatch = findClosestMatch(corpus, userInput); ❹
prompt = prompt.replace("{relevant_document}", closestMatch) ❺
.replace("{user_input}", userInput); ❺
System.out.println("Created prompt"); ❺
System.out.println(prompt); ❺
OpenAiChatModel model = OpenAiChatModel.withApiKey("enter-api-key"); ❻
String response = model.generate(prompt); ❼
System.out.println("Response received:"); ❼
System.out.println(response); ❼
}
❶ 接收用户对 RAG 的查询
❷ 从资源文件夹中加载文件
❸ 定义要发送给 OpenAI 的提示
❹ 在加载的文件中找到与用户查询最接近的匹配项
❺ 将提示中的占位符替换为用户查询和文件
❻ 使用 Open AI 密钥实例化一个新的 GPT 客户端
❼ 将提示发送到 gpt3.5-turbo 并打印响应
提供一个 OPEN_AI_KEY
要向 OpenAI 发送请求,必须提供项目 API 密钥,该密钥可以在platform.openai.com/api-keys生成。您需要创建一个新账户或根据您的信用是否已过期,向账户添加信用,这可以通过mng.bz/6YMD完成。一旦设置好,您需要直接在代码中添加您的项目 API 密钥,替换System.getenv("OPEN_AI_KEY"),或者将密钥作为标题为OPEN_AI_KEY的环境变量存储。
在我们的 GPT 实现到位后,我们现在应该有一个运行类,其外观类似于以下示例:
public class CompletedRAGDemo {
public static List<String> loadFilesFromResources(
➥String folderPath) throws IOException {
List<String> fileContents = new ArrayList<>();
ClassLoader classLoader = CompletedRAGDemo.class.getClassLoader();
File folder = new
➥File(classLoader.getResource(folderPath).getFile());
for (File file : folder.listFiles()) {
if (file.isFile()) {
String fileContent = FileUtils.readFileToString(file, "UTF-8");
fileContents.add(fileContent);
}
}
return fileContents;
}
public static String findClosestMatch(List<String> list, String query) {
String closestMatch = null;
double minDistance = Double.MAX_VALUE;
CosineDistance cosineDistance = new CosineDistance();
for (String item : list) {
double distance = cosineDistance.apply(item, query);
if (distance < minDistance) {
minDistance = distance;
closestMatch = item;
}
}
return closestMatch;
}
public static void main(String[] args) throws Exception {
System.out.println("What would you like help with?");
Scanner in = new Scanner(System.in);
String userInput = in.nextLine();
List<String> corpus = loadFilesFromResources("data");
String prompt = """
You are an expert software tester that makes
recommendations for testing ideas and risks. You answer with
suggested risks to test for, based on the provided user story
delimited by three hashes and user input that is delimited
by three backticks.
Compile a list of suggested risks based on the user story
provided to test for, based on the user story and the user
input. Cite which part of the user story the risk is based on.
Check that the risk matches the part of the user story before
outputting.
###
{relevant_document}
###
```
{user_input}
```py
""";
String closestMatch = findClosestMatch(corpus, userInput);
prompt = prompt.replace("{relevant_document}", closestMatch)
.replace("{user_input}", userInput);
System.out.println("Created prompt");
System.out.println(prompt);
OpenAiChatModel model = OpenAiChatModel.withApiKey("enter-api-key");
String response = model.generate(prompt);
System.out.println("Response received:");
System.out.println(response);
}
}
总结一下,我们已经创建了必要的代码来:
-
加载文档集合(在这个例子中,是用户故事)
-
通过命令行接收用户查询
-
使用查询找到最相关的文档
-
将查询和文档添加到我们的提示中
-
将提示发送到 OpenAI 并返回响应
一切准备就绪,我们现在可以启动我们的 RAG 框架并进行测试。
11.2.2 测试我们的 RAG 框架
一旦我们的 RAG 框架启动并运行,我们应该会被询问我们想要输入什么查询。为了测试我们的 RAG 框架,我们可以尝试输入以下查询:
|

| 你需要帮助什么? |
|---|
|

| 我想要测试 PUT 品牌端点负载部分的测试想法 |
|---|
我们的查询从我们的文档集合中检索以下用户故事:
|

| 作为访客,为了更新品牌信息,我希望能够向/branding/发送带有必要参数的 PUT 请求。验收标准 * 我应该能够向/branding/发送带有请求体中的品牌信息和可选令牌的 cookie 的 PUT 请求。 * 如果请求成功,响应状态应该是 200 OK。 * 如果请求因参数错误或数据缺失而失败,响应状态应该是 400 Bad Request。 * 请求体应包含符合 Swagger JSON 定义的模式的有效 JSON 数据.HTTP 有效负载合同
{
"name": "string",
"map": {
"latitude": 0,
"longitude": 0
},
"logoUrl": "string",
"description": "string",
"contact": {
"name": "string",
"address": "strings",
"phone": "string",
"email": "string"
}
}
|
gpt3.5-turbo 生成的响应将类似于我收到的响应:
|

| 基于用户输入,以下是一些建议测试的风险: 1. 请求体中缺少必要的参数,例如名称、地图(纬度和经度)、logoUrl、描述、联系方式(姓名、地址、电话、电子邮件) - 基于用户故事中提到的请求体中必要的参数。 2. 在请求体中发送无效的数据类型,例如将名称字段发送为数字而不是字符串 - 基于用户故事中定义的 HTTP 有效载荷合同。 3. 发送不符合 Swagger JSON 中定义的模式的无效 JSON 数据 - 基于接受标准中指定符合模式的有效 JSON 数据。 4. 在发送 PUT 请求时,未在 cookie 中包含可选的令牌 - 基于接受标准中提到的 cookie 中可选令牌。 5. 向错误的端点发送 PUT 请求或格式不正确 - 基于用户故事要求将 PUT 请求发送到/branding/并带有必要的参数。 |
|---|
如预期的那样,响应包含了我们请求的风险列表,但值得注意的是它返回了特定上下文的详细信息。例如,对于专注于缺少必要参数的第一个风险,我们可以看到它指出了用户故事中 HTTP 有效载荷示例中存在的特定参数。我们还可以看到其他风险引用了接受标准中编写的内容,例如在 cookie 中存储的可选令牌的使用(如风险 4 中所述)。
响应中提供的特定上下文信息是因为我们在提示中提供的额外细节,这些细节以我们的用户故事的形式存在。为了展示这些额外信息对于 LLM 响应质量的重要性,以下是一个使用相同提示但未添加用户故事的响应示例:
|

| 1. 数据验证:测试负载只接受 API 文档中指定的允许的数据类型和格式。这有助于确保无效数据不会导致系统出现意外行为。(基于测试 PUT 品牌端点的负载部分的需求) 2. 边界测试:测试当负载包含允许值的上下限数据时系统的行为。这有助于揭示系统可能无法正确处理极端值的任何边缘情况。(基于测试 PUT 品牌端点的负载部分的需求) 3. 性能测试:测试系统处理大量负载时的响应时间和资源使用情况。这可能揭示任何性能瓶颈或可扩展性问题。(基于测试 PUT 品牌端点的负载部分的需求) 4. 安全测试:测试与负载输入相关的任何漏洞,如 SQL 注入或跨站脚本攻击。这有助于确保系统对恶意输入的安全性。(基于测试 PUT 品牌端点的负载部分的需求) |
|---|
如我们所见,返回的项目更加通用和浅显,因为我们的提示缺少额外的上下文信息,从而证明了 RAG 系统在提示 LLM 中的价值。
然而,RAG 框架确实存在局限性,我们的基本框架也不例外。例如,如果我们发送以下查询:
|

| 我应该考虑哪些风险因素用于 Auth API? |
|---|
我们可能会收到如下类似的回复,其中返回的信息对我们来说没有用处:
|

| 1. 风险:无法通过提供有效的房间 ID 成功删除房间 • 此风险基于能够通过提供有效的房间 ID 成功删除房间的验收标准。 2. 风险:对无效房间 ID 的错误处理不正确 • 此风险基于在提供无效房间 ID 时收到 400 Bad Request 错误的验收标准。 3. 风险:成功删除后未显示确认消息 • 此风险基于在成功删除后收到确认消息的验收标准。 |
|---|
虽然这可能会感觉我们的 LLM 在产生幻觉,但问题在于我们的相关性检查。我们的查询集中在 Auth API 上,包括登录、登出和验证端点,但响应中提到的房间 ID暗示了一个基于房间的用户故事被认为是最相关的。再次,我们看到在从多个数据源同时提取相关数据时,在 RAG 框架中找到最相关数据的挑战。因此,为了进步,我们需要考虑更高级的工具来帮助我们改进相关性搜索的性能,以优化 LLM 的响应。
活动第 11.2 节
尝试基于项目中发现的用户故事的不同类型的查询。看看哪些查询返回了期望的结果,哪些没有。考虑我们可以对错误的查询进行哪些调整以改进它们。
11.3 增强 RAG 的数据存储
现在我们对 RAG 的工作原理有了更深入的了解,我们可以开始探索市场上允许我们快速使用我们的数据实现框架的工具类型。寻找合适的类型的数据来增强我们的提示的过程可能很棘手,但市场上有一些工具和平台通过使用 SaaS 平台和向量数据库,使设置 RAG 框架变得更加容易。因此,让我们通过简要讨论向量数据库是什么,它们如何帮助,以及我们如何根据我们的需求使用它们来结束我们对 RAG 的探索。
11.3.1 与向量数据库一起工作
与存储在表格行内的不同数据类型中的 SQL 数据库不同,向量数据库以数学表示的形式存储数据。具体来说,它们以向量的形式存储,即表示一个实体在多个维度上的位置的数字集合。
为了举例说明向量是如何工作的以及为什么它们是有用的,让我们考虑另一个使用向量的软件开发领域——游戏开发。假设我们在一个 2D 世界中有一个角色和两个其他实体,我们想知道哪个实体离我们的角色最近。我们会使用包含 X 和 Y 位置的向量来确定两者的位置。例如,如果我们的角色在地图上的中心位置,他们的向量将是 (0,0)。现在假设我们的实体在 X/Y 位置(我们的向量维度)为 (5,5) 和 (10,10),如图 11.2 所示。
我们可以看到,位置为 (10,10) 的后续实体距离更远。但我们可以通过比较它们来数学地计算向量的距离。因此 (0,0) 到 (5,5) 生成距离分数为 7.071068,而 (0,0) 到 (10,10) 的距离分数为 14.14214(使用mng.bz/o0lr计算)。这当然是一个基本示例,但使用向量数据库,一个实体可能包含包含许多不同维度的向量,这使得距离计算变得更加复杂。

图 11.2 显示角色和实体的向量图
这些向量以及我们文档的相关维度是如何计算的,这超出了本书的范围,但重要的是要认识到,使用向量数据库的目的是让我们能够编程地计算出我们感兴趣的数据项与我们的查询有多接近。换句话说,我们使用向量数据库来计算相关性,就像我们在基本的 RAG 框架中所做的那样。然而,我们不是在一维上这样做,而是可以同时与许多不同的维度进行比较——这意味着在我们迄今为止的工作中,这可以增加我们认为与我们的查询相关的用户故事准确性。因为它还支持多个相关性,所以如果我们感兴趣的数据项在相关性范围内,我们可以提取多个实体或文档添加到我们的提示中。
11.3.2 设置基于向量数据库的 RAG
基于向量数据库的 RAG 市场已经经历了巨大的增长,例如 LlamaIndex (www.llamaindex.ai/) 和 Weviate (weaviate.io/)。然而,为了快速设置并尽可能减少设置和编码,我们将关注一个名为 Canopy 的工具,该工具由 Pinecone 公司 (www.pinecone.io/) 构建。Pinecone 提供在云中创建向量数据库的能力,在其平台上称为索引。他们还创建了 Canopy,这是一个与他们的云设置集成的 RAG 框架。Canopy 是一个很好的试验 RAG 框架选择,因为它与我们的早期 RAG 框架不同,框架处理了大部分工作。这意味着我们可以比我们自己构建时更快地开始使用基于向量数据库的 RAG 框架。当然,这牺牲了控制以换取便利,但它将为我们尝试基于向量数据库的 RAG 提供所需的一切。您可以在他们的 README (github.com/pinecone-io/canopy) 中了解更多关于 Canopy 的不同部分。
Canopy 先决条件
要运行 Canopy,您需要在您的机器上安装 Python 3.11。这只是为了安装 Canopy。一旦安装,我们将独家使用 Canopy SLI 来设置我们的框架。
为了让我们开始,我们首先需要在我们的机器上安装 Canopy,我们可以通过运行pip3 install canopy-sdk命令来实现。
一旦安装完成,我们还需要一些 API 密钥来设置我们的环境。首先,我们需要我们的 OpenAI 密钥,可以在 platform.openai.com/api-keys 找到。接下来,我们需要在 Pinecone 上设置一个账户并从中提取 API 密钥,以便 Canopy 使用它来创建我们的向量数据库。为此,我们需要在 Pinecone 上注册,这可以通过以下链接完成:app.pinecone.io/?sessionType=login。在设置过程中,您将被要求提供一张卡片以提供账单详细信息,以便将免费账户升级为标准账户。我们需要升级到标准账户,以便 Canopy 能够创建必要的向量数据库。如果我们不这样做,当开始为我们的 RAG 框架构建索引时,Canopy 将会出错。撰写本文时,标准账户是免费的,但不幸的是,我们需要提供我们的账户详细信息才能访问我们所需的功能。
一旦我们创建了 Pinecone 账户并将其升级为标准账户,我们就可以开始使用 Canopy 创建我们的 RAG 框架。为此,我们需要设置一些环境变量:
export PINECONE_API_KEY="<PINECONE_API_KEY>"
export OPENAI_API_KEY="<OPENAI_API_KEY>"
export INDEX_NAME="<INDEX_NAME>"
或者,如果你使用的是 Windows 系统:
setx PINECONE_API_KEY "<PINECONE_API_KEY>"
setx OPENAI_API_KEY "<OPENAI_API_KEY>"
setx INDEX_NAME "<INDEX_NAME>"
Pinecone 和 OpenAI 的 API 密钥非常简单,可以在每个平台的相应管理部分找到。然而,第三个变量将设置在 Pinecone 平台上创建的索引的名称,因此我们需要为我们的索引选择一个名称,例如 test-index。一旦我们设置了这些变量,我们就可以通过运行 canopy new 命令来启动 Canopy。
假设我们的 API 密钥都是正确的,并且我们的 Pinecone 账户已经正确升级,Canopy 将在 Pinecone 中设置一个新的索引(见图 11.3),我们可以用它来上传我们的文档,当我们准备好时。

图 11.3 Canopy 运行后的 Pinecone 索引
当我们的索引准备就绪后,我们可以开始上传我们的用户故事文档(这些文档可以在支持库的 mng.bz/n0dg 找到)。我们通过运行 Canopy 的 upsert 命令并指定我们的用户故事根目录来完成此操作:
canopy upsert src/main/resources/data
这将启动一个过程,我们的用户故事将被上传到索引中,一旦上传完成,我们就可以回到 Pinecone 并确认它们存在于我们的索引中,如图 11.4 所示。
现在我们已经将所有内容加载到我们的索引中,并准备好通过运行 canopy start 命令启动我们的 RAG 框架。
这将启动 Canopy 服务器,我们现在可以访问它来发送我们的查询。要访问它,我们打开一个新的终端/命令提示符并运行 canopy chat --no-rag 命令。(如果启动了新的终端窗口,请务必再次导出您的 OPENAI_API_KEY。)
注意我们提供了--no-rag标志。这使 Canopy 能够显示,用于比较,使用 RAG 和不使用 RAG 的输出。在启动聊天功能后,我们会收到以下消息以确认我们已经准备好开始测试我们的 RAG 框架:
|

| 让我们聊天!用户消息:(按[Esc]后跟[Enter]接受输入) |
|---|

图 11.4 使用用户故事上传的索引
11.3.3 测试基于向量数据库的 RAG 框架
现在,Canopy 已经准备好接收我们的查询,我们可以开始通过查询测试它,并比较我们的 RAG 框架的性能。例如,发送以下查询:
|

| 列出 PUT 品牌端点中每个 JSON 参数的不同类型测试 |
|---|
返回了以下响应(已编辑以方便阅读):
|

| 在 PUT 品牌端点的 PUT 请求中,每个 JSON 参数的不同类型测试包括: 1. 名称: • 测试为名称参数提供有效的字符串值。 2. 地图(纬度和经度): • 测试为纬度和经度参数提供有效的整数值。 3. LogoUrl: • 测试为 logoUrl 参数提供有效的字符串 URL。 4. 描述: • 测试为描述参数提供有效的字符串值。 5. 联系(姓名、地址、电话、电子邮件): • 测试在联系对象中为姓名、地址、电话和电子邮件参数提供有效的字符串值。这些测试确保在 PUT 请求的品牌端点的请求体中提供的 JSON 数据符合在 Swagger JSON 中定义的指定模式。(来源:src/main/resources/data/put-branding.txt) |
|---|
响应有几个值得注意的点。首先,响应是围绕用户故事中定义的不同 JSON 参数来构建的。其次,我们可以看到 Canopy 在输出底部提供了一个参考(来源:src/main/resources/data/put-branding.txt),以表明哪些来源被认为与向量数据库中最相关。这意味着,与我们在前面构建的基本 RAG 框架类似,这个 RAG 框架正在按预期工作,与我们的查询在没有 RAG 时的表现相比,这一点更加引人注目:
|

| 1. 名称: − 测试名称参数的存在 − 测试无效值(例如空字符串,特殊字符) 2. 徽标: − 测试徽标参数的存在 − 测试有效的文件格式(例如 PNG,JPEG) − 测试大小限制(例如文件大小小于 5MB) 3. 颜色: − 测试颜色参数的存在 − 测试有效的颜色代码格式(例如十六进制代码) − 测试无效的颜色值(例如用‘purple’代替‘#123456’) 4. 字体: − 测试字体参数的存在 − 测试有效的字体名称或系列 − 测试无效的字体值(例如空字符串,特殊字符) 5. 描述: − 测试描述参数的存在 − 测试有效的长度限制(例如小于 100 个字符) − 测试无效值(例如特殊字符,长字符串) 6. 网站: − 测试网站参数的存在 − 测试有效的 URL 格式 − 测试无效值(例如无效域名,特殊字符) |
|---|
在这个响应中,我们可以看到它与我们的品牌 JSON 负载的上下文几乎没有关联。再次,这证明了我们在使用 LLMs 并确保它们能够访问我们的上下文时所面临的挑战。但关于我们之前遇到的基本 RAG 框架及其无法将多个用户故事带入查询的问题怎么办?有了 Canopy 和向量数据库的支持,我们可以测试它在一个查询中的表现,这个查询有可能在一个单独的提示中调用多个用户故事:
|

| 列出针对每个预订端点的不同测试类型 |
|---|
将此发送到 Canopy 返回了以下响应(同样,为了可读性进行了编辑):
|

| 1. 获取预订端点: • 功能测试: − 验证端点返回所有预订的列表。 − 通过提供房间 ID 作为查询参数来测试按房间 ID 过滤预订的功能。 2. POST 预订端点: • 功能测试: − 确保端点接受 JSON 格式的预订详情并成功创建新的预订。 − 测试当预订请求无效时的响应和状态码。 3. 获取预订 ID 端点: • 功能测试: − 确认端点检索提供的预订 ID 的正确详情。 − 测试当提供无效或缺失的预订 ID 时的响应。 4. PUT 预订 ID 端点: • 功能测试: − 确保端点正确更新给定预订 ID 的预订信息。 − 测试当预订 ID 缺失或无效时的响应和状态码。 5. 获取预订摘要端点: • 功能测试: − 验证服务器对指定房间 ID 的预订摘要做出响应。 − 测试当房间 ID 缺失或无效时的响应。(来源:src/main/resources/data) |
|---|
通过审查这个响应,我们可以看到提示底部的来源正在引用我们的数据根目录(来源:src/main/resources/data),这表明已经将多个文件拉入我们的查询中。这一点也通过响应中提到的每个端点得到了证实。我们可以将响应中的每个条目与我们数据集中存储的基于预订的用户故事相关联。
活动 11.3
使用 Canopy 和 Pinecone,准备并上传您自己的自定义数据集到一个索引。尝试使用您的自定义数据执行不同的查询,以查看 RAG 框架在您的上下文中如何表现。
11.3.4 使用 RAG 框架向前推进
在尝试现有的 RAG 平台之前,我们构建了自己的 RAG 框架,现在我们对 RAG 的工作原理及其如何带来益处有了更深入的理解。然而,我们所学的知识仅仅是对 LLM 应用领域的一个介绍,这个领域有很多实际应用。我们可以在 RAG 框架中存储哪些类型的数据,是否使用向量数据库,这些都提供了很大的空间。结合我们关于编写有效提示的知识,RAG 框架可以用来提供实时分析、生产数据、存储的用户内容等等,帮助我们创建更符合我们上下文的提示,这最终将有助于增加我们在测试和其他地方对 LLM 的使用。
摘要
-
检索增强生成(RAG)是一种将上下文数据和用户查询结合到 LLM 提示中的方法。
-
上下文数据的选取基于其与已提供的初始用户查询的相关性。
-
提供选定的数据可以提高准确性,并确保避免上下文窗口周围的错误。
-
将上下文数据与 LLM 分离,使得选择和更新数据源的过程更加容易。
-
为了使 RAG 系统能够工作,我们需要上传和存储数据的能力。
-
RAG 系统使用相似性算法和工具来确定哪些数据与查询最相关。
-
缺乏上下文数据的提示会导致更通用和肤浅的响应。
-
如果相似性算法返回不准确的数据,LLM 可能会返回错误的响应。
-
当查询范围较广或需要一次性将多个数据源添加到提示中时,查找相关数据会变得复杂。
-
向量数据库根据多个维度存储向量,这些维度用于确定查询的相关性。
-
一些框架和工具提供了使用向量数据库快速设置 RAG 框架的能力。
-
利用向量数据库和相关工具和平台,使我们查询它们变得更加容易。
-
向量数据库允许我们一次性将多个相关文件拉入查询中。
-
RAG 框架可以提供多种类型的数据,这些数据在测试和软件开发的整体过程中具有多种应用。
第十二章:使用商业领域知识微调 LLMs
本章涵盖
-
LLM 的微调过程
-
准备用于微调的数据集
-
使用微调工具更好地理解过程
尽管大型语言模型(LLMs)在各个行业中的影响在主流媒体中得到了广泛的报道,但 LLMs 的普遍性和受欢迎程度已经为 AI 开源社区带来了一场静悄悄的革命。通过开放协作的精神和大型科技公司的支持,微调 AI 模型的能力越来越容易为 AI 爱好者所获得。这个机会产生了一个充满活力的社区,该社区正在尝试和分享各种过程和工具,这些过程和工具可以更好地理解微调是如何工作的,以及我们如何自己或团队合作来调整模型。
微调的主题非常广泛,深入探讨每个重要细节可能需要一本整本书。然而,通过利用开源社区创建和为开源社区创建的模型、数据集、平台和工具,我们可以对微调过程有一个欣赏。这些开源资源可以为我们准备一个未来,在那个未来中,我们可能会在我们的组织中微调我们的模型,以帮助构建基于上下文的 LLMs。然而,微调不仅关乎我们采取的方法,也关乎我们使用的工具。因此,在本章中,我们将详细了解微调过程的每个重要部分,并学习如何微调我们的模型。
12.1 探索微调过程
在我们学习更多关于微调工具之前,我们首先应该讨论微调包含的内容,并反思我们希望实现的目标。正如我们将看到的,微调涉及一系列步骤,这些步骤在更广泛的过程中发挥着重要作用。了解我们希望实现的目标不仅有助于我们评估微调后的模型,而且还能指导我们的微调方法。微调过程的每一步都包括不同的活动和挑战。尽管我们可能无法涵盖所有细节,但我们将学习到足够多的知识,以了解我们在微调模型时会发生什么,以及我们可能面临的挑战。
12.1.1 微调过程的地图
正如我们在第十章中发现的,微调是将已经经过某种训练的现有模型(称为基础模型)进一步使用额外的数据集进行训练的过程
-
使模型更能适应上下文信息
-
改变模型的声音
-
帮助它响应特定的查询或指令
就像基础模型需要经过一系列步骤进行训练一样,在微调会话期间也有一系列步骤需要遵循。图 12.1 总结了这些步骤以及它们是如何相互关联的。

图 12.1 精调过程中所采取的不同步骤的视觉表示
图 12.1 不一定涵盖微调的每一个细微之处,但它捕捉了我们通常期望成功创建一个微调模型的核心步骤。在本章中,我们将更详细地检查每个步骤,但首先让我们反思一下这个过程可能最重要的部分,即确定我们希望通过微调实现的目标。
12.1.2 目标设定
在微调 LLM 时,我们可能犯的最大错误之一就是没有明确的想法,不知道我们希望微调后的 LLM 做什么。未能为微调模型应该帮助解决的问题设定明确的目标可能会影响微调过程的各个方面,从数据准备到测试。鉴于 LLM 的不确定性,这并不意味着围绕我们期望从 LLM 中获得的具体信息来设定目标。但我们必须问自己我们想要什么类型的行为,以及它如何融入更广泛的背景。
为了说明这一点,让我们考虑两个不同的目标。一个是创建一个代码补全工具,该工具已经在我们的代码库上微调,以使用 LLM 的生成能力,同时不向第三方泄露知识产权。另一个是一个基于问答/聊天的 LLM,它为用户提供支持,该 LLM 已经在支持文档和客户数据上微调,以帮助回答问题。根据我们想要追求的目标,我们需要考虑以下细节:
-
使用什么数据集? 对于我们的代码补全场景,我们可能希望创建一个由逻辑部分组成的代码数据集,以微调我们的模型。我们也可能对使用包含开源代码的其他数据集感兴趣。这与问答聊天场景不同,在问答聊天场景中,我们会创建一个包括帮助指南和文档的数据集。
-
使用什么模型? 在撰写本文时,Hugging Face,一个 AI 开源社区网站,目前托管了 500,000+个不同的模型,这些模型都旨在服务于不同的目的,如生成、分类和翻译。在考虑我们的微调目标时,我们需要选择适合我们需求的模型。在我们的代码场景中,我们可能会选择一个已经在大量代码数据集上训练过的模型,这使得它更容易与我们的代码库进一步微调。在我们的问答/聊天 LLM 场景中,我们可能会想要一个已经训练成能够作为有能力的基于聊天的 LLM 的模型。
-
使用多大的模型? 我们需要问自己的另一个问题是我们需要多大型的模型?根据我们想要的模型大小,通常由其参数大小定义,也将决定我们的硬件需求。这里有一个必须考虑的权衡。如果我们希望我们的模型在响应时准确,那么就需要大量的硬件来托管和运行更大的模型。如果我们预算有限,或者我们部署模型的位置性能不佳,那么我们可能需要考虑一个可能不那么准确或对我们的请求响应不那么快的较小模型。
什么是 Hugging Face?
Hugging Face 是一个 AI 开源社区的平台,允许成员托管、部署和协作他们的 AI 工作。值得注意的是,Hugging Face 提供了一个托管数据集和模型的地方,以及通过其空间功能让其他人部署和交互 AI 应用的机会。还有其他付费功能,如自动训练,旨在使微调更容易,以及增加硬件空间以部署更复杂和资源密集型模型。类似于 GitHub 除了提供托管代码的能力外,还允许团队协作,Hugging Face 提供了一个分享和从 AI 社区成员那里学习的地方,并共同参与未来的 AI 项目。
这绝对不是考虑因素的详尽列表,随着我们进一步探索微调,我们将了解到我们需要决定的不同选项。幸运的是(如果你有预算),已经创建并实施了许多工具,使微调模型的实验更容易、更快。因此,尽管在开始微调之旅时有一个目标是很不错的,但我们仍然可以自由地更换模型、数据集等,以了解如何为特定情境创建最优化微调模型。
12.2 执行微调会话
考虑到微调模型目标的重要性,在本章中,我们将尝试使用项目的代码库来微调一个模型,以创建一个可以支持未来分析的支持模型。在实际情况中,这意味着拥有一个模型,当被问及我们的代码库时,可以给出与我们的项目相关的上下文敏感的回答。为此,我们将使用开源项目 restful-booker-platform 的代码(mng.bz/vJRJ)。
12.2.1 准备训练数据
尽管微调不需要像预训练模型那样大量的数据,但微调的成功在很大程度上依赖于我们使用的数据的大小和质量,这引出了我们需要什么类型的数据的问题。为了帮助我们理解数据集的大小、范围和格式,我们可以从像 Hugging Face 这样的网站中学到很多,那里存储着开源数据集(huggingface.co/datasets)。在撰写本文时,一些值得注意的数据集包括:
-
堆栈—从 GitHub 等开源项目上抓取的 5.46 亿行代码示例(
huggingface.co/datasets/bigcode/the-stack) -
羊驼—使用现有的大型语言模型(LLM)生成的 52,000 行合成数据(
huggingface.co/datasets/tatsu-lab/alpaca) -
OpenOrca—290 万行问题和回答数据(
huggingface.co/datasets/Open-Orca/OpenOrca)
查看这些数据集的每一个,我们可以看到它们包含不同类型的信息,从代码示例到问题和回答,以不同的方式创建。例如,The Stack 这样的数据集是基于从互联网上抓取的真实信息,而 Alpaca 是由人工智能合成的。我们还可以看到,与列表中的其他数据集相比,Alpaca 是一个规模较小的数据集,但这并不意味着它没有用。
什么是合成数据?
在人工智能训练和微调的背景下,合成数据是指人工生成看起来像真实用户数据,但实际上并非基于真实生活数据的过程。使用合成数据是训练和微调人工智能的有用技术,因为它可以提供进行训练或微调所需的数据。尽管如此,使用合成数据也存在副作用。首先,生成测试数据会有成本。例如,gretel.ai、mostly.ai 和 tonic.ai 等工具提供数据生成工具,但它们是有价格的。其次,也许更重要的是,研究表明,在纯合成数据上训练模型可能会影响模型响应的质量。这很有道理,因为真实数据将具有难以在人工智能生成数据中模拟的变异性随机性。
因此,在制定我们的要求时,我们需要考虑我们想要什么,目前有什么可用,以及我们可能需要自己构建什么。让我们回到我们的代码助手和问答模型示例。对于我们的代码助手 LLM,我们可能需要一个主要以代码为基础的数据集,而我们的问答模型则需要以自然语言编写的数据,并包含以键值格式(问题作为键,答案作为值)编写的问题和答案。正如我们所看到的,我们的目标指导我们关于所需数据类型的决策,但随之而来的是更多的问题,例如数据从何而来以及我们将如何格式化它。
我们已经看到,来自 Hugging Face(Kaggle 的网址www.kaggle.com/datasets也是一个优秀的数据集来源)等网站上有许多公开的数据集。但如果我们试图微调一个模型,使其更符合我们的上下文,那么我们很可能会希望使用属于我们组织的数据来调整它。因此,在决定如何将其转换为适合训练的格式之前,我们需要进行一项关于可用数据、其质量以及我们如何获取它的练习。考虑 Alpaca 数据集的结构格式(huggingface.co/datasets/tatsu-lab/alpaca)。该数据集包括以下四个列:指令、输入、输出和文本。正如我们将在下一步学习的那样,根据我们微调模型的方式,我们将需要数据集的不同方面。例如,如果我们想微调一个问答模型,我们至少需要指令和输出列,以帮助调整它,使其能够回答预期的问题类型和回答类型。
挑战在于将原始数据转换为像我们在 Alpaca 数据集中看到的那种结构化格式。例如,如果我们考虑我们的问答模型场景,我们可能希望用我们的文档和支持文档来训练它。其中一些原始数据可能以问答格式存在,例如常见问题解答(FAQs),但我们的大部分数据可能不会这么直接。因此,我们需要找出一种方法来解析我们的数据,使其适合我们的数据集结构。更复杂的是,我们还需要自动完成这项工作,以生成足够的数据量,使其有用。手动完成也是一项选择,但可能会很昂贵。
数据准备案例研究
我们将要用于微调会话的数据集展示了我们在构建小型数据集时可能遇到的挑战。在我们的微调会话中,我们将使用一个之前创建的数据集,该数据集可以在 Hugging Face 上找到,网址为mng.bz/4pXa。该数据集是一个 JSONL 格式的文档,包含 restful-booker-platform (RBP)的 Java 部分的解析部分,以及生成的指令,其中一部分如下所示(为了便于阅读,已添加换行符):
{"instruction": "What is the class declaration for
➥BrandingServiceIT","output": "@ExtendWith
➥@SpringBootTest
➥@ActiveProfiles
public class BrandingServiceIT { }"}
{"instruction": "How does the method setup Restito work for
➥BrandingServiceIT?","output": "
➥@BeforeEach
➥public void setupRestito(){
➥whenHttp(server).match(post("/auth/validate"))
➥.then(status(HttpStatus.OK_200));
➥}"}
这段代码片段将为我们提供调整所需的所有数据,但在我们开始之前,让我们探索一下它是如何制作的。考虑以下类,然后问问自己你会如何以逻辑的方式将这段代码拆分以进行微调?
package com.automationintesting.api;
import com.automationintesting.db.BrandingDB;
import com.automationintesting.model.db.Branding;
import com.automationintesting.model.service.BrandingResult;
import com.automationintesting.requests.AuthRequests;
import com.automationintesting.service.BrandingService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import java.sql.SQLException;
@RestController
public class BrandingController {
@Autowired
private BrandingService brandingService;
@RequestMapping(value = "/", method = RequestMethod.GET)
public ResponseEntity<Branding> getBranding() throws SQLException {
Branding branding = brandingService.getBrandingDetails();
return ResponseEntity.ok(branding);
}
@RequestMapping(value = "/", method = RequestMethod.PUT)
public ResponseEntity<?> updateBranding(@Valid @RequestBody
➥Branding branding, @CookieValue(value ="token", required = false)
➥String token) throws SQLException {
BrandingResult brandingResult =
➥brandingService.updateBrandingDetails(branding, token);
return ResponseEntity.status(brandingResult.getHttpStatus())
➥.body(brandingResult.getBranding());
}
}
你会选择按文件逐个微调、按行逐个微调,还是其他方式?在尝试测试基于 RBP 代码库的模型微调的第一尝试中,我选择了逐行的方法。它创建了一个脚本,该脚本将遍历项目中的每个文件,并将文件的每一行添加到其行中,从而生成一个类似于以下示例表的数据表:
id, content
1, @RequestMapping(value = "/", method = RequestMethod.GET)
2, public ResponseEntity<Branding> getBranding() throws
3, SQLException {
4, Branding branding = brandingService.getBrandingDetails();
5, return ResponseEntity.ok(branding);
6, }
这种方法的缺点是,尽管解析和存储数据很容易,但我最终得到的条目缺乏上下文,这意味着我调整的是像}或@Autowired这样的条目。这些条目并没有提供很多关于 RBP 项目的上下文或细节,并且引发了一个新的问题。可以与这些类型的条目配对的指令类型是什么?如果你还记得,在基于指令的微调过程中,我们会发送一个指令(有时附带额外的输入数据),然后比较响应与我们的预期输出。我们添加到像}这样的条目中的指令类型将不包含任何关于我们上下文的提示,并可能对微调模型产生响应异常和不希望的方式。这正是我在尝试基于逐行材料调整模型时发生的情况。
相反,我选择的方法(并在数据集中可以找到)是将代码分解成逻辑部分。这意味着,而不是逐行切割事物,一个文件将基于 Java 类中的不同属性进行切割。例如,从之前分享的类中选择的切片在示例表中看起来如下:
id, content
1, @RestController
public class BrandingController { }
2, @Autowired
private BrandingService brandingService;
3, @RequestMapping(value = "/", method = RequestMethod.GET)
public ResponseEntity<Branding> getBranding() throws SQLException {
Branding branding = brandingService.getBrandingDetails();
return ResponseEntity.ok(branding);
}
与数据集中的每个条目都是一行代码不同,每个条目可能包含如何声明类、在类中声明的变量、类中的每个方法及其包含的代码的详细信息。目标是保持足够详细,以便微调的结果是一个对代码库有更高意识的模型,但又不至于过于细化而完全失去上下文。结果是更成功的调整,但解析过程变得更加复杂。这需要创建额外的代码,该代码将遍历每个文件,使用 JavaParser (javaparser.org/) 读取代码,构建语义树,然后查询该树以提取数据集所需的信息(代码可以在mng.bz/QVpw找到)。
当谈到为微调(或训练)准备数据集时,这个例子是一个非常基础的例子。然而,经过反思,我们可以清楚地看到,从头开始组织和准备即使是简单数据集也有其复杂性和挑战。这个数据集的原始数据可以用合适的工具轻松解析,但我们如何管理结构多样或一开始就没有明显结构的数据呢?对数据集的这种探索突出了识别和创建数据集是一个复杂的过程。我们如何组织数据以及我们放入其中的内容对于微调模型的成功至关重要,这也是微调和实验 LLMs 的大部分工作所在。因此,确保我们有必要的流程和工具非常重要,这样我们就可以快速实验不同的数据集,看看它们如何影响微调模型的结果。
12.2.2 预处理和设置
在我们的数据集就绪后,我们接下来需要在微调之前预处理我们的数据,并准备好我们的微调工具。我们将很快介绍工具设置,但首先,为了了解微调的预处理活动,我们需要稍微跳一下,谈谈微调会话期间发生的事情。考虑到数据集的大小,微调会话由一个特定的循环组成,如图 12.2 所示,这个循环在微调过程中会多次运行。
在可视化过程中,我们首先从我们的数据集开始。假设它以与我们之前查看的 _Alpaca_ 数据集类似的方式组织(它包含一个指令、输入和输出的列),指令和输入列中存储的数据将被添加到一个提示中。然后,这个提示被发送到我们正在微调的模型,模型会返回一个响应。我们随后将模型的响应与数据集中存储的输出进行比较,以确定情感。情感表示响应与我们的预期输出之间的匹配程度。然后,情感分数被用来告知需要调整模型参数以微调模型以实现我们希望模型如何响应。如果情感分数表明响应是可取的,那么变化将很小。另一方面,如果情感分数表明响应不可取,那么将进行更大的调整。

图 12.2 微调过程中的可视化
整个过程都是通过不同的工具以编程方式完成的,并在数据集中的每个条目上多次运行。数据集本身通常也会多次迭代,这被称为一个epoch。通过在微调过程中迭代多个 epoch,模型被调整到我们希望它如何响应的方式。这种调整方法被称为基于指令的微调,在我们执行微调之前理解它的工作方式非常重要,因为我们需要在调整开始之前采取一些步骤。首先,我们需要设计我们想要发送给模型的提示类型。其次,我们需要确定我们如何将我们的提示编码化,以便我们的模型可以读取它。类似于我们选择数据集,我们在这两个步骤中做出的选择也可能对我们的微调结果产生重大影响。
提示设计
一旦我们知道了数据集的格式,我们需要创建一个指导提示,使其能够与数据集中的数据以及我们想要添加的任何附加指令一起工作。例如,考虑 deeplearning.ai 课程《微调大型语言模型》中的这两个指导提示(mng.bz/XV9G)。第一个提示接受一个指令和一个输入:
|

| 下面是一个描述任务的指令,配有一个提供更多上下文的输入。请编写一个响应,以适当地完成请求。### Instruction:{instruction}### Input:{input}### Response: |
|---|
第二个提示只包含一个指令:
|

| 下面是一个描述任务的指令。请编写一个响应,以适当地完成请求。### Instruction:{instruction}### Response: |
|---|
注意到每个提示都包含静态的、指导性的文本,它为发送的信息提供了上下文,然后是像{instruction}这样的标签来注入数据集中的数据。根据我们想要使用的 RBP 数据集,我们可以这样配置我们的提示以进行微调:
|

| 下面是一个由三个井号分隔的指令,它询问关于 restful booker 平台代码库的问题。请提供必要的代码来回答问题。在输出之前检查代码是否正确编译。###{instruction}### |
|---|
这个提示遵循我们在前面章节中探讨的一些提示策略。我们可以使用这些策略来帮助我们清楚地指导模型在提示中期待什么,以及我们希望它做出什么响应。为了更好地理解微调循环,让我们想象我们在数据集中有以下条目:
{
"instruction" : "How does the method initialiseMocks work for
➥BrandingServiceTest?"
"output" : "@BeforeEach public void initialiseMocks() {
➥MockitoAnnotations.openMocks(this); }"
}
在微调过程中,这个数据集的指令部分将被注入到提示中,以创建以下提示
|

下面是一个由三个井号分隔的指令,询问关于 restful booker 平台代码库的问题。请提供必要的代码来回答问题。在输出之前检查代码是否正确编译。###initialiseMocks方法对BrandingServiceTest是如何工作的?### |
|---|
这可能会导致模型以如下方式响应一个代码示例:
|

|
use Tests\TestCase;
use App\Services\BrandingService;
class BrandingServiceTest extends TestCase {
protected function setUp(): void { // Initialize mocks
$this->brandingService = new BrandingService();
}
|
然而,我们数据集中的输出如下:
|

|
@BeforeEach public void initialiseMocks() {
MockitoAnnotations.openMocks(this);
}
|
这意味着两组数据之间的情感得分是中等水平,因为尽管响应是代码,并且它与我们的预期输出在解决方案上有些相似,但代码并不完全相同。这个情感得分将随后被考虑进需要调整模型参数以使当我们的数据集中的特定行再次出现时,结果更接近预期。我们使用的提示模板会影响微调模型的结果,我们添加的指令也会产生影响。然而,我们需要意识到我们添加到提示模板中的内容不仅会影响微调的结果,还会影响最初发送给模型的内容。这带我们来到了如何将基于文本的提示转换为模型理解的语言。
Tokenization
一个 token 是一个单词、短语或字符的数值表示。我们在第十章中讨论了 token 的概念。那么,为什么我们需要在微调过程中意识到 tokenization 的重要性呢?首先,在数据预处理阶段,有许多不同的 tokenizer 可以使用,它们将以不同的方式对文本进行 tokenization。我们使用的模型类型将影响 tokenizer 的类型。选择一个与我们正在微调的模型不匹配的 tokenizer 会导致我们的提示被转换成与我们在微调的模型内部参数不匹配的 token 标识符。简单来说,这就像是一个用不同或完全虚构的语言授课的老师在教你课程。
第二个原因与我们的微调提示和数据集相关,是上下文长度。上下文长度是指模型一次可以处理的 token 总数。这很重要,因为如果我们创建了一个包含大量 token 的提示,或者尝试使用每个条目中包含大量 token 的数据进行微调,那么我们的提示可能会超出上下文长度限制,这意味着我们的提示将被截断。超过上下文长度限制的每个 token 将简单地被丢弃或忽略,结果将是基于部分完成的提示进行微调的模型,这可能会产生意外或不希望出现的副作用。
因此,在整理我们的数据集和设计微调提示时,我们需要考虑我们的上下文长度。这可能意味着从数据集中移除任何可能超出我们上下文长度的条目,编写具有明确指令但不会超出令牌计数的提示,或者寻找包含更大上下文长度的新的模型。
工具和硬件
由于处理数据和执行微调过程需要许多步骤,因此必须具备必要的工具来执行每个阶段。幸运的是,微调工具最近取得了很大的进步。最初,这可能需要使用 Python 等工具以及 PyTorch、Tensorflow 或 Keras 等库的丰富经验。尽管这些工具旨在尽可能易于使用,但学习曲线可能相当陡峭,需要我们从零开始构建微调框架。如果我们对这种类型的方法感到舒适,或者与有这些类型工具经验的人一起工作,那么使用它们是值得的。然而,随着微调兴趣的增长,基于上述工具的新工具已经开始出现,使微调更加容易访问。例如 Axolotl 这样的框架和 Hugging Face 这样的平台允许我们快速设置微调,而无需进行大量的工具开发。权衡的是,这些框架要么具有偏见,例如选择我们应该使用的标记化器,要么是有成本的。
不仅围绕 AI 微调的工具看到了增长,支持它的基础设施也是如此。训练模型是一项硬件密集型的工作,需要访问图形处理单元(GPU)。这意味着要么购买具有大量 CPU、RAM 和 GPU 的硬件用于微调,要么从云服务提供商那里获取计算资源。对于许多团队来说,后者是更受欢迎的选择,并且是一个快速增长的领域,因为它降低了硬件需求成本,确保了访问更新的 GPU。不出所料,像谷歌、微软和亚马逊这样的大型云计算公司都提供了专门的服务,这些服务是为微调和 LLM 托管而设计的。但一些替代方案已经开始出现,例如 RunPod、Latitude.sh 和 Lambda Labs,它们是专业的 GPU 云服务提供商。这些是可以与我们所选择的微调工具结合使用的选项,但一些服务同时提供微调框架和运行微调的计算资源。
我们可以用于微调的市场环境和可以运行微调的地方是一个快速发展的领域。但这也突显了需要进行研究,以确定哪些工具和基础设施最适合我们团队的经验和可用于微调的预算类型。
设置我们的微调工具
有许多微调工具可供选择,它们提供了不同级别的微调过程控制,PyTorch 是一个流行的选择。但如前所述,设置和使用这些工具存在一个学习曲线。如果我们在一个希望对提示、分词器和微调工具有完全控制的上下文中工作,我们可能会选择这些更细粒度的工具。但对于像我们这样的新手,我们乐于使用那些为了易用性而牺牲意见的工具,我们再次可以转向 AI 开源社区。因此,对于我们的微调会话,我们将使用 Axolotl,这是一个专门设计用于简化各种 AI 模型微调的工具,提供对多种配置和架构的支持。
我们可以将 Axolotl 视为一个微调框架,其中包含执行微调所需的所有工具和流程。这意味着对于我们的微调会话,提示方法和分词器已经为我们处理好了,使我们能够快速进入微调,而不需要巨大的学习曲线。
使用 Axolotl 的硬件要求
在我们开始之前,重要的是要注意,为了进行微调会话,你需要一个可以访问 GPU 的系统。然而,如果你没有访问 GPU 的权限,有一些成本效益高的云平台被设计用来支持 AI 微调。Axolotl 的 ReadMe 文件包含了两个提供商的链接:RunPod 和 Latitude.sh。
作为没有访问 GPU 的人,我发现 RunPod 很容易设置,并且价格合理,每次训练会话不到 10 美元。如果你想采取这种方法,以下是一些设置步骤:
-
创建一个账户并通过
www.runpod.io/console/user/billing为其添加信用。我发现最低交易额 10 美元就足够了。 -
前往 GPU 云
www.runpod.io/console/gpu-cloud,点击页面顶部的“选择模板”,找到winglian/axolotl-runpod:main-latestDocker 镜像,并选择它。 -
接下来,选择一个要部署的 Pod。根据一天中的时间和需求,你会看到哪些可以部署,哪些不能。在撰写本文时,1x RTX 4090 足以满足我们的微调练习。然而,如果我们希望微调更快,我们可以选择更多的 GPU 或更大的盒子。
-
点击“部署”并完成设置向导以启动 Pod 的创建。前往
www.runpod.io/console/pods并等待 Pod 部署。 -
一旦部署了 Pod,点击“连接”以显示详情,然后通过 SSH 进入你的 Pod(这需要你在连接之前添加一个 SSH 公钥,这可以在以下链接完成:
www.runpod.io/console/user/settings。)
一旦你登录到 Pod,你会发现 Axolotl 已经安装并准备好使用。
我们将首先在我们的选择机器上设置 Axolotl(如果您选择了 RunPod 选项,则可以跳过这一步)。Axolotl 的文档和代码可在mng.bz/yoRG找到,其中包含关于如何安装应用程序的全面说明,提供直接在我们的机器上或通过 Docker 安装的选项。
12.2.3 使用微调工具
一旦 Axolotl 设置完成,我们就可以开始配置我们的会话。如前所述,我们将使用 RBP 数据集,该数据集可在 Hugging Face 上找到(mng.bz/M1M7)。对于我们的模型,我们还将使用 Meta 的 Llama-2 模型版本,该模型包含 70 亿个参数和 4k 的上下文窗口。与提示和分词器类似,模型设置已在 Axolotl 项目examples/llama-2/lora.yml中的示例文件中处理完毕。然而,为了在我们的数据集上训练模型,我们需要在 YAML 文件中更新dataset.path:
datasets:
- path: 2bittester/rbp-data-set
type: alpaca
使用path指定在 Hugging Face 上找到数据集的位置(即从哪里下载)以及type设置我们想要使用的模板提示。查看 YAML 文件顶部,我们还可以看到将要使用的模型和分词器的引用。同样,如果我们想尝试其他方法,这些也可以进行修改:
base_model: NousResearch/Llama-2-7b-hf
---
tokenizer_type: LlamaTokenizer
文件中还有其他一些设置,但超出了本章的范围,但有两个我们想要强调:sample_packing和num_epochs。
sample_packing: false
---
num_epochs: 4
对于sample_packing,我们将其设置为false,因为数据集不够大,不足以将其分为训练集和测试集(关于这一点稍后会有更多说明)。num_epochs决定了我们想要遍历数据集的次数。默认值为4,这意味着微调过程将在完成之前将整个数据集循环四次。在 YAML 文件中做出这些更改后,我们可以保存、退出并开始我们的微调。
12.2.4 启动微调运行
在我们的配置就绪后,我们可以开始微调。为此,我们将遵循 Axolotl 的 ReadMe 中找到的步骤。首先,我们将触发一个预处理步骤:
CUDA_VISIBLE_DEVICES="" python -m axolotl.cli.preprocess
➥ examples/llama-2/lora.yml
预处理步骤会下载数据集并通过分词器进行处理,将数据从文本转换为标记,以便进行微调。
LORA 是什么?
你可能已经注意到,我们一直在配置并现在用于启动微调会话的 YAML 文件名为 lora.yml。LORA 是一种微调方法,它不是直接尝试调整模型内的参数,而是创建一个较小的参数子集来近似模型的参数,并进行微调,从而创建一个 LORA 适配器。这意味着当我们部署微调后的模型时,模型内部将加载 LORA 适配器,以提供我们期望的微调行为。它之所以变得流行,是因为它加快了微调过程,并允许社区和团队共享他们的适配器。
预处理完成后,我们就准备好开始微调过程了。请注意,根据所使用的硬件,这个过程可能需要从 30 分钟到 4 小时或更长时间,所以请选择一个可以在你处理其他任务时让微调运行的时间。要触发微调,我们运行以下命令:
accelerate launch -m axolotl.cli.train examples/llama-2/lora.yml
这将导入我们编辑过的 YAML 文件并启动微调过程。随着微调的开始,我们将开始看到关于微调进度的详细信息,如下例所示:
{'loss': 1.0936, 'learning_rate': 0.00019999154711147226, 'epoch': 0.02}
{'loss': 1.3172, 'learning_rate': 0.00019999135609452385, 'epoch': 0.02}
{'loss': 1.0351, 'learning_rate': 0.0001999911629434316, 'epoch': 0.02}
控制台中的每个条目都详细说明了以下内容:
-
损失——这个分数表示我们数据集中预期输出与模型输出的对齐程度。分数越低,预期和实际响应的对齐程度越好。在这个例子中,损失分数相对较高,因为它是微调开始时取的。随着微调的进行,我们希望看到损失分数降低。
-
学习率——这是对模型参数所做的步长变化的数值表示。较小的步长意味着微调中的变化更渐进。步长的大小由情感分数以及学习率超参数决定。在人工智能训练的背景下,超参数是在微调开始之前可以设置的配置选项,它会影响训练或微调的结果。因此,在学习率的情况下,我们可以增加步长范围,这可能导致模型有更显著的变化。这又可能或可能不会导致更优的微调。
-
纪元——之前,我们学习了如何在微调期间多次迭代数据集,并且每次迭代都被称为一个纪元。控制台输出中显示的纪元值只是简单地告诉我们我们处于给定纪元的哪个阶段。
这些指标有助于我们了解微调会话的质量和进度。根据数据集的大小,支持微调的模型和硬件将决定微调可能需要多长时间。然而,由于微调所需的工作量很大,微调可能需要多个小时并不罕见。这就是为什么经验丰富的模型微调员会设置流程和工具,以便在微调完成后可以同时微调多个模型以进行比较。
12.2.5 测试微调的结果
一旦我们的微调完成,我们将想要检查我们所做的更改是否与我们在微调过程开始时设定的目标一致。如果你还记得,在微调过程中,一条指令被发送给模型,然后它返回一个输出。情感分析随后确定模型的输出和我们的预期输出之间的匹配程度,这有助于确定对模型参数的调整。因此,模型内部的参数现在应该更多地偏向我们的上下文。因此,为了测试模型是否已被成功微调,我们想要检查当我们要求模型提供与微调时不同的新指令时会发生什么。我们可以通过以下两种方式之一来完成这项工作:推理和/或人工验证。
推理
由于模型内部有众多参数和指令发送选项以及接收输出的选项,推理采用了一种自动化的方法来测试模型的输出。推理的工作方式与微调非常相似。我们要么从更大的数据集中取出一部分,要么使用一个新的数据集,该数据集的结构与用于微调的数据集相同,包含与原始微调数据不同的指令和输出。然后我们将这些指令集发送给模型,捕获响应,然后使用情感分析来比较我们期望模型做出的响应与它实际做出的响应。(调整和推理之间的关键区别在于情感分析之后。调整会改变模型,而在推理的情况下,模型保持不变)。如果返回的情感分数很高,我们可以假设模型已被调整以满足我们的目标。如果它没有,那么我们可以开始考虑在未来的微调会话中下一步应该做什么。
人工验证
尽管情感分析很有用,但它基于数学模型来确定一致性。因此,手动探索模型输出,通过人工验证也是合理的。这可能通过使用推理数据集中保存的提示并评估响应来完成,或者通过生成新的提示来测试不同的响应,以查看模型如何反应。为了获得与推理测试相同的范围和覆盖范围,对于团队来说可能过于昂贵,但它可以提供更符合人类视角的视角,可以识别推理无法识别的差异和/或幻觉。
| 根据我们从两方面学到的知识,推理的自动化流程和我们的实验将指导我们的下一步决策。也许微调的结果产生了一个可接受的模型,我们可以将其发布以供更广泛的使用。然而,我们通常会得出结论,微调后的模型不适合我们。这将触发进一步的分析,以确定我们的下一步行动。也许我们的数据集需要改变,或者我们想要进一步修改我们的提示,或者也许可以将分词器替换掉。无论如何——再次强调,这是一个超出本章范围的决策过程——这表明微调项目需要多次实验以发现最佳结果。这就是为什么那些在微调(或一般训练)方面有成熟流程的团队会同时运行多个微调实验,同时调整多个模型,使用略有不同的参数来比较结果。|
测试我们的微调会话
返回到我们的微调模型,尽管我们没有足够的数据来运行推理测试会话,但我们可以手动测试我们的模型。为此,我们运行以下命令:
accelerate launch -m axolotl.cli.inference examples/llama-2/lora.yml \
--lora_model_dir="./lora-out" –gradio
使用此命令,我们正在加载 Llama-2 模型,并加载我们作为微调的一部分创建的 LORA 适配器。命令 --gradio 允许我们使用 gradio 库([www.gradio.app/](https://www.gradio.app/))托管我们的模型用户界面,这样我们就可以通过网页浏览器开始测试我们的模型。
当我们的模型运行时,我们可以开始检查微调会话的结果。首先,我们可以通过从我们的数据集中选择一个指令和输出集来检查微调,例如:
|

| 说明:initialiseMocks 方法在 BrandingServiceTest 中是如何工作的?输出:|
@BeforeEach public void initialiseMocks() { MockitoAnnotations.openMocks(this); }
|
使用微调会话中的提示,我们可以向我们的微调模型发送以下请求:
|

以下是一个描述任务的说明。写一个适当的响应来完成请求。### 说明:initialiseMocks 方法在 BrandingServiceTest 中是如何工作的?### 响应: |
|---|
发送此请求后,我们会收到如下响应:
|

| 以下是一个描述任务的说明。写一个适当的响应来完成请求。### 说明:initialiseMocks 方法在 BrandingServiceTest 中是如何工作的?### 响应:|
@BeforeEach public void initialiseMocks()
{
MockitoAnnotations.openMocks(this);
}
|
如我们所见,模型已经微调到根据我们发送的说明返回一个强烈对齐的响应。现在我们可以关注模型如何处理新的未经测试的提示,例如这个基本的示例提示:
|

BrandingResult java 类中发现了哪些注释? |
|---|
将此发送到示例微调模型会返回如下响应:
|

| 在BrandingResult java 类中发现了哪些注释?
public class BrandingResult {
private HttpStatus brandingHttpStatus;
private Branding branding;
public HttpStatus getBrandingHttpStatus()
{
return brandingHttpStatus;
}
public void setBrandingHttpStatus(HttpStatus brandingHttpStatus)
{
this.brandingHttpStatus = brandingHttpStatus;
}
public Branding getBranding()
{
return branding;
}
public void setBranding(Branding branding)
{
this.branding = branding;
}
}
|
将此响应与微调模型所基于的原始代码进行比较(可以在mng.bz/aVlz找到),我们可以看到模型在列出BrandingResult类的某些方面(如使用的变量和 getter/setter 方法)方面取得了一些成功。然而,它也遗漏了诸如类构造函数等细节,并且错误地得到了变量的名称(尽管它在代码中至少是一致的)。也可以认为提示没有正确回答,因为我们要求的是关于注释的细节,而不是整个类的信息。
因此,总结来说,我们在这次调整会话中取得了一些成功,但还需要更多的工作。调整过程以某种方式重新平衡了模型内部的参数,使得我们的上下文在其中变得更加突出。然而,缺失的项目和错误细节意味着需要进一步调整微调过程。也许我们可以考虑提高数据集中指令的质量,或者重新考虑我们用于调整的提示。同样,我们可以关注调整的更多技术方面,例如选择具有更多参数的模型,或者调整超参数,如我们用于训练的 epoch 数量或学习率。
12.2.6 通过微调学到的经验
本章让我们领略了微调过程的工作方式。起初,这可能看起来是一项令人望而生畏的活动。但尽管存在特定的工具和术语需要理解,通过逐步进行微调过程,我们可以逐个应对出现的挑战。最终,微调在很大程度上是关于实验。我们使用的数据、调整的模型、使用的工具以及设置的超参数,它们都会影响结果。
在撰写本文时,实验的成本并不是可以忽视的事情。想要进行微调会话的团队需要大量的财务支持以获取资源和经验。但随着私营公司和开源社区的不断发展,微调将变得更加容易获得,硬件的价格可能会降低,这使得微调在组织内成为一个不断增长的空间,并为团队提供高质量模型带来挑战,这些模型可以帮助我们和我们的组织。
摘要
-
微调是进一步训练现有模型的过程,有时也称为基础模型。
-
微调过程涉及多个步骤,如目标设定、数据准备、处理、调整和测试。
-
明确设定我们希望微调的 LLM 要完成的任务的目标,这指导我们如何进行微调过程。
-
微调模型需要指定和准备数据。
-
数据集极大地影响了微调模型的结果。这意味着找到与我们目标相关的数据,并以有助于最大化微调后模型输出的方式对其进行格式化。
-
微调依赖于反复发送嵌入训练数据的提示,以获得我们希望偏向于与预期输出对齐的响应。
-
模型需要提示才能转换为机器可读的语言。这是通过分词过程实现的。
-
分词是将数据切割成更小令牌的过程。
-
模型有一个上下文长度,即它一次可以处理的令牌的最大数量。一次性发送太多令牌,其中一些将被丢弃,影响微调过程。
-
在微调时,我们既可以构建自己的框架,这需要经验,也可以利用现有的框架,这些框架可能具有主观性,或者使用它们需要付费。
-
鳄鱼是针对微调的出色框架,对于经验有限且希望开始的人来说易于访问。
-
微调模型的测试可以通过自动化的方式完成,使用推理或手动进行。
-
微调正变得越来越容易为团队使用,用于人工智能助手工具。
附录 A. 设置和使用 ChatGPT
使用 ChatGPT 的过程相对简单。首先,我们在 OpenAI 上创建一个账户,这可以通过登录和注册页面完成,该页面位于chat.openai.com/auth/login。从那里,我们创建我们的账户,目前主要关注注册一个免费账户。
了解是否购买 ChatGPT Plus
很可能在注册过程中会推销 ChatGPT Plus。在撰写本文时,ChatGPT Plus 每月收费 20 美元,并为您提供访问 ChatGPT 最新版本(与使用 gpt-3.5-turbo 的免费版本相比)以及一系列插件和其他新功能,这些功能扩展了 ChatGPT 的核心提示功能。我们在这本书中不使用 Plus,但活动和示例都可以在 Plus 和免费账户上使用。然而,使用 Plus 时,ChatGPT 的响应可能会不同。
一旦我们注册,我们可以通过chat.openai.com/登录,进入 ChatGPT 主页,在那里我们可以开始发送提示。
要使用 ChatGPT,我们需要提供指令或提示,这些指令我们将输入到页面底部的“发送消息”表单中,如图 A.1 所示。

图 A.1 ChatGPT 消息表单
一旦提交了提示,它将出现在页面顶部,一个新的聊天将被添加到左侧的历史栏中,ChatGPT 将像图 A.2 中所示的那样进行响应。

图 A.2 一个提示和一个响应
我们可以添加额外的提示,之前的提示和响应将被考虑。例如,在图 A.3 中,我们可以看到 ChatGPT 在响应关于城市规模的第二个提示时,考虑了关于英国首都的第一个提示。

图 A.3 额外的提示和响应
我们还可以在发送提示后编辑提示,以从 ChatGPT 生成新的响应。要做到这一点,我们悬停在提示上,并点击左侧的编辑图标,如图 A.4 所示。

图 A.4 带有编辑图标的提示
我们还可以点击“重新生成”按钮,触发 ChatGPT 返回一个替代响应,如图 A.5 所示。

图 A.5 重新生成按钮
对于免费和 Plus 许可证,一个更高级的功能是使用自定义指令。这些指令可以通过将我们的指令添加到我们发送的每个提示中来定制 ChatGPT 对提示的响应方式。要访问指令,我们点击右上角的头像,并选择自定义 ChatGPT,如图 A.6 所示。
为了展示指令是如何工作的,我们可以添加一个指令来确保当 ChatGPT 被要求以代码示例作为回应时,它应该使用 Java 而不是默认的 Python。为此,我们在“你希望 ChatGPT 如何回应?”这一节添加以下指令:
|

| 所有代码示例应以 Java 格式返回 |
|---|
并保存指令。然后我们可以通过创建一个新的聊天并发送提示来测试指令:
|

| 创建一个函数,用于计算一个数的平方根 |
|---|
ChatGPT 应该以 Java 编写的代码示例作为回应。我们还可以尝试移除指令再次测试,很可能会收到一个用 Python 编写的新的回应。

图 A.6 自定义指令弹出窗口
附录 B. 设置和使用 GitHub Copilot
B.1 设置 Copilot
为了更好地了解设置 Copilot 的过程,让我们简要讨论一下该工具的工作原理。由于 Copilot 依赖于需要快速分析我们的工作并提出代码片段的 GPT 模型,因此它需要大量的资源来快速响应。因此,当使用 Copilot 时,我们不会在我们的机器上安装 AI 系统;相反,我们安装插件,使我们能够将我们的工作片段发送到 Copilot 进行处理,并接收推荐的代码,如图 B.1 所示。
如图表所示,我们在 IDE 中安装一个插件,并授予其访问权限,以便插件可以监控我们输入的内容,将其发送到 Copilot,然后处理并显示 Copilot 的推荐。由于这种结构,Copilot 可以与各种 IDE 一起工作,包括 JetBrains 产品、Visual Studio、VS Code 等。在我们的例子中,我们将使用 IntelliJ Community Edition,但无论选择的 IDE 如何,设置 IDE 以用于 Copilot 的过程在很大程度上是相同的。
寻找 GitHub 支持
如果您需要更多具体细节来在您的机器上设置 Copilot,更多信息可在 mng.bz/gAll 获取。

图 B.1 展示了 IDE、插件和 Copilot 之间连接的流程图
B.1.1 设置 Copilot 账户
第一步是设置一个 Copilot 账户。在撰写本章时,Copilot 提供了两种计划:
-
个人用户每月 10 美元
-
商业用户每月 19 美元
虽然 Copilot 账户是付费的,但 GitHub 提供了 30 天的免费试用。需要提供付款详情,但可以通过账单页面 (github.com/settings/billing) 取消账户。
Copilot 也要求我们拥有一个 GitHub 账户,因此在设置 Copilot 之前,我们需要确保我们已经创建了一个免费的 GitHub 账户。一旦完成,或者如果您已经有了账户,请前往 github.com/features/copilot 以完成 Copilot 测试版的设置过程。
B.1.2 安装 Copilot 插件
一旦我们将 Copilot 添加到我们的 GitHub 账户中,我们就可以设置我们的 IDE 以访问它。对于 IntelliJ,我们通过其插件服务来完成此操作,无论是通过“欢迎使用 IntelliJ IDEA”窗口选择“插件”还是转到“首选项”>“插件”。一旦打开插件窗口,搜索 Copilot 并安装插件。如果因为任何原因返回了更多 Copilot 插件的结果,请查找由 GitHub 编写的插件。最后,重启 IntelliJ 以完成插件安装。
B.1.3 授予对您的 Copilot 账户的访问权限
安装插件后,重新打开 IntelliJ,我们会看到一个位于右下角的小弹出窗口,其外观如图 B.2 所示。

图 B.2 该弹出窗口会要求您登录 GitHub。
或者,如果没有弹出窗口出现(或者不小心关闭了),我们可以通过带有贯穿其面部粗线的 Copilot 小图标访问登录过程,如图 B.3 所示。

图 B.3 IntelliJ 中 Copilot 插件的定位
点击弹出窗口后,我们会看到一个窗口,其中描述了登录 GitHub 并授予 Copilot 访问我们 IDE 的过程,如图 B.4 所示。

图 B.4 这个对话框详细说明了如何通过我们的 IDE 登录 GitHub。
要完成注册过程,我们取出设备代码并前往网页github.com/login/device。要从 IDE 中输入设备代码,我们可以在表单中键入或粘贴,然后提交。GitHub 随后会要求我们授予 Copilot 访问权限,一旦确认,就会完成登录过程。当我们返回到 IDE 并看到一个新弹出窗口通知我们已连接时,我们可以确认我们已经登录,如图 B.5 所示。

图 B.5 这个弹出窗口确认我们已连接到 Copilot。
当我们开始编辑代码并看到一个弹出窗口分享关于它在 IDE 中如何工作的详细信息时,我们也会看到 Copilot 插件已安装的确认,如图 B.6 所示。

图 B.6 这个弹出窗口描述了 Copilot 在 IDE 中的工作方式,并指定了要使用的快捷键。
现在我们已经安装并设置了插件,我们可以开始使用 Copilot 作为我们的助手进行开发。
B.2 与 Copilot 一起工作
一旦设置好 Copilot,它就会在我们开始在 IDE 中编写代码时开始反应并提供建议。例如,我们可以创建一个名为TaxCalculator的空类,输入关键字public,几秒钟后就会收到如图 B.7 所示的类似建议。

图 B.7 来自 GitHub Copilot 的建议代码
注意建议的代码被灰色显示。为了确认我们想要将代码添加到我们的类中,我们通过按 Tab 键来完成。这样就会插入代码并将光标移动到已添加代码的末尾。
B.2.1 探索建议
如果建议的代码不满意,我们可以查看 Copilot 的其他建议以确定它们是否更适合。为此,我们需要打开位于 IDE 右侧的 GitHub Copilot 窗口,如图 B.8 所示。

图 B.8 在 IDE 右侧找到的 GitHub Copilot 菜单项
选择该菜单项将打开一个新窗口,其中显示可以添加到我们的类中的其他建议代码列表。面板可能会要求您点击刷新以从 Copilot 请求建议代码,但一旦完成,您将看到类似于图 B.9 的内容。

图 B.9 来自 Github Copilot 的替代建议
要完成此过程,我们点击我们想要看到插入到我们班级中的代码片段上方的“接受解决方案”按钮,然后关闭建议框。
附录 C. 探索性测试笔记
这些是第八章中探索的使用案例示例的原始笔记:
章程
探索如何在报告视图中渲染预订。
在一个非常大的预订集合中
为了发现大量预订是否易于阅读
会话笔记
会话设置:
我如何为报告页面创建测试数据?
通过提示分析 API 和 JS 中的代码,似乎报告数据是通过从房间 API 拉取数据然后遍历每个房间从预订 API 检索预订来构建的。
我如何为房间和预订 API 创建 SQL 数据
-
创建一个基于
seed.sql文件生成房间 API 测试数据的提示。 -
将数据添加到
seed.sql中,并使用种子数据重新构建房间 API。 -
创建一个脚本来使用 ChatGPT 生成预订 API 的测试数据。
-
由于它破坏了测试,因此无法使用包含数据的预订 API 重新构建,所以加载了 IDE 中的预订 API。
调查
当我加载页面时会发生什么?
-
Bug:当有大量预订时,页面加载数据非常缓慢。
-
页面加载后,我可以在日历视图中看到两个预订,然后有查看特定日“查看更多”的选项。
当我使用导航控件工作时会发生什么?
-
Bug:在从繁忙的月份导航到安静的月份时,日历也加载缓慢。
-
尽管页面加载缓慢,但我仍然能够导航。
如果我想要查看更多预订会发生什么?
-
Bug:显示当天预订的弹出窗口溢出到页面顶部,意味着某些预订无法阅读。
-
Bug:当有大量预订时,弹出窗口加载缓慢。
如果我想要创建一个新的预订会发生什么?
-
仍然可以点击和拖动天来打开管理员预订弹出窗口。
-
Bug:点击取消意味着等待日历组件再次渲染的时间很长。
键盘可访问性?
-
能够成功在日历中按 Tab 键。
-
在“查看更多”上按 Enter 键会打开包含更多预订的弹出窗口。
-
Bug:在包含更多预订的弹出窗口中按 Tab 键不友好。我必须先点击,然后才能按 Tab 键,或者在我被带到弹出窗口中的第一个预订之前,必须遍历所有日历元素。
-
Bug:在弹出窗口中按 Tab 键不会将焦点带到溢出到页面顶部的预订上。
使用 PAOLO 助记符进行调查
提示想法:报告日历在不同屏幕尺寸和分辨率下以纵向模式显示的方式。
如果我放大和缩小日历会发生什么?
-
Bug:调整大小加载缓慢。
-
Bug:放大查看会隐藏“查看更多”按钮,最终隐藏每个日历的整个内容。
-
当缩小查看时,日历渲染正常。
提示想法:测试日历是否能够快速响应用户设备方向的变化,而不会出现延迟。
如果我尝试不同的屏幕尺寸会发生什么?
-
Bug:屏幕调整大小很慢,所以在最终自行纠正之前,初始布局很混乱。
-
最终确实正确渲染。
-
很好地处理了方向变化。
提示想法:确保在设备切换到纵向模式时,日历中的文本和标签仍然清晰易读。
-
Bug:在移动屏幕尺寸下,文字非常小且难以阅读。
-
在分辨率更高的移动设备上,如平板电脑,文本更容易阅读。
提示想法:确保在纵向模式下可用的所有功能和功能在横向模式下也可访问且功能正常。
-
Bug:在移动设备视图中无法点击和拖动日历来调用管理员预订。
-
仍然可以在纵向和横向模式下使用导航控件。
-
制表符功能仍然有效。


浙公网安备 33010602011771号