三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

AI Agent开发中测试驱动开发(TDD)的实践指南:从交互契约到工程化落地

AI Agent开发中测试驱动开发(TDD)的实践指南:从交互契约到工程化落地

1. 从一次深夜的线上故障说起

凌晨两点,我被一阵急促的告警电话惊醒。一个由AI Agent驱动的核心业务流程突然中断,日志里只有一句模糊的“推理失败”。没有堆栈信息,没有明确的错误边界,整个团队花了三个小时,才定位到问题根源:一个看似无关的第三方API返回了与历史数据结构略有差异的JSON,而负责处理这个数据的Agent,其内部逻辑是基于过去几个月“完美”的响应样本训练出来的,它“自信”地按照旧有模式解析,最终导致下游服务崩溃。这次经历让我痛定思痛:在AI Agent的开发范式里,我们是否过于迷信模型的“智能”,而忽略了软件工程中最朴素、也最坚固的基石——测试,尤其是测试驱动开发(TDD)?

当“AI编程”、“Agent开发”成为热搜,各种框架和教程(如Hermes Agent、Spring AI)层出不穷时,我们往往沉迷于Agent的“超级能力”(Superpowers),却容易忽视一个根本性的转变:开发对象从“确定性逻辑”变成了“概率性行为”。传统的TDD,要求我们先写一个会失败的测试,再去实现功能使其通过,从而构建出高可靠性的代码。很多人认为,面对AI这种“黑盒”,TDD已经过时了。但我的结论恰恰相反:在AI时代,尤其是Agent开发中,TDD不是变得不重要,而是变得前所未有的重要,甚至可以说,Agent比人类程序员更需要“先写测试”。这不是为了测试AI模型本身的不可预测性,而是为了用确定性的“护栏”和“契约”,去框定和引导Agent的“超能力”,确保它能在复杂多变的环境中可靠、可控地工作。

2. 误解澄清:TDD测的不是“智能”,而是“交互契约”

很多人一听到“为AI写测试”,第一反应是:难道我要为模型每次不同的输出写断言吗?这显然不现实。这正是最大的误解所在。为Agent实施TDD,其焦点发生了根本性的转移。

2.1 从“实现逻辑”到“定义契约”

传统TDD针对的是我们亲手编写的、每一行都清晰可见的函数或方法。我们测试的是实现细节。例如,测试一个排序函数是否真的按升序排列。

而Agent时代的TDD,测试的是交互契约。Agent的核心价值在于它作为“代理”,与用户、工具、其他系统进行交互。因此,我们的测试重点应该是:

  1. 输入/输出规格:给定一个明确的用户请求(输入),Agent是否理解了核心意图?它生成的计划或下一步动作(输出)是否符合预期的格式和关键要素?
  2. 工具调用规范:当Agent决定调用一个工具(如搜索API、计算器、数据库查询)时,它生成的调用参数是否正确、完整、安全?
  3. 流程与状态管理:在多轮对话或复杂任务分解中,Agent是否能保持上下文,状态转换是否符合业务逻辑?

例如,我们不是测试一个“翻译Agent”的翻译结果是否信达雅(这是大模型能力的范畴),而是测试:当用户输入“翻译‘Hello World’成中文”时,Agent是否正确地调用了“翻译工具”,并且传入的参数是{“text”: “Hello World”, “target_lang”: “zh-CN”}。至于翻译工具内部是用GPT-4还是Claude,那是另一个层面的问题。

2.2 一个具体的TDD启动案例:天气查询Agent

假设我们要开发一个“天气查询Agent”。用TDD的思维,我们不会一上来就琢磨怎么用LangChain或Hermes Agent框架拼装代码。

第一步:写一个失败的验收测试(Acceptance Test)我们先定义一个最核心的用户场景测试。这个测试用自然语言或结构化的方式描述:

“作为用户,我想知道北京的天气,以便决定是否带伞。”预期行为

  1. Agent应理解查询意图为“天气查询”。
  2. Agent应提取关键实体:城市=“北京”。
  3. Agent应调用“天气查询工具”,并传入参数{“city”: “北京”}
  4. Agent应将工具返回的原始天气数据(如温度、湿度、天气状况),组织成一段友好的自然语言回复给用户。

在代码层面,这可能是一个模拟测试(Mock Test):

def test_weather_agent_happy_path(): # 1. 模拟用户输入 user_input = “北京今天天气怎么样?” # 2. 创建Agent实例(此时尚未实现) agent = WeatherAgent() # 3. 模拟天气工具,预设一个固定返回值 mock_weather_data = {“temp”: 22, “condition”: “晴”} agent.weather_tool = Mock(return_value=mock_weather_data) # 4. 执行Agent response = agent.process(user_input) # 5. 断言:工具是否被以正确参数调用了一次? agent.weather_tool.assert_called_once_with(city=“北京”) # 6. 断言:最终回复是否包含了关键信息? assert “22” in response assert “晴” in response assert “北京” in response

运行这个测试,它当然会失败,因为WeatherAgent类还不存在。但这恰恰是TDD的起点:我们已经清晰地定义了“成功”的标准。

第二步:实现最简单的Agent使其通过测试接下来,我们才去思考如何实现。我们会创建一个简单的WeatherAgent类,里面可能有一个粗糙的意图识别(比如简单的关键词匹配“天气”),然后硬编码调用天气工具。这个最初的实现可能很笨,但它的唯一目的就是让上面的测试变绿(通过)。

class WeatherAgent: def __init__(self): self.weather_tool = get_weather # 假设这是一个真实或模拟的工具 def process(self, user_input): if “天气” in user_input: # 简陋的实体提取(实际会用NLU模型) city = “北京” # 先写死 weather_data = self.weather_tool(city=city) return f“{city}的天气是{weather_data[‘condition’]},温度{weather_data[‘temp’]}度。” return “我不理解您的请求。”

现在运行测试,它应该通过了。我们拥有了一个虽然简陋但行为符合契约的Agent。

第三步:重构与增强,同时保持测试通过现在,我们可以安全地改进Agent,而测试就是我们的安全网。例如:

  • 引入真正的意图识别模型(如基于BERT微调的小模型)。
  • 用更鲁棒的实体抽取(如使用正则表达式或NER模型)替换硬编码的“北京”。
  • 优化回复的文本模板。 每做一次修改,就运行一次测试套件。只要测试依然通过,我们就确信核心的交互契约没有被破坏。这种开发节奏,对于构建复杂、可靠的Agent系统至关重要。

3. 为什么Agent比人类代码更需要TDD?四大核心原因

3.1 原因一:对抗“幻觉”与“沉默失败”的终极武器

AI模型,特别是大语言模型,存在“幻觉”——即自信地生成错误或虚构的信息。在Agent场景下,这可能导致灾难性后果,比如错误地调用删除数据的API。更危险的是“沉默失败”:Agent没有报错,但执行了完全错误的操作序列。

TDD通过前置的、明确的断言,为Agent的行为设立了“事实检查点”。例如,在测试中,我们可以断言:“当用户要求删除编号为‘TEST-123’的非存在文件时,Agent不得调用‘删除文件工具’,而应回复‘文件不存在’。” 这个测试会强制我们在实现Agent的逻辑时,必须加入“文件存在性校验”这一步骤。没有这个测试,开发者很可能依赖模型“自己学会”这个校验,而这是极不可靠的。

3.2 原因二:复杂工作流的可观测性与调试基线

一个高级Agent往往涉及多步骤规划、工具调用循环、状态维护。其执行过程像一个黑盒,一旦出错,调试极其困难(就像我开篇遇到的故障)。TDD产生的测试套件,实际上为这个黑盒安装了一系列“探针”和“检查站”。

每一个测试用例,都描述了Agent在某个特定场景下应该走的正确路径。当线上出现问题时,我们可以快速回归测试套件:

  • 如果所有测试都通过,说明问题可能出在训练数据漂移、外部API变化等“环境因素”。
  • 如果某个相关测试失败了,我们就立刻拥有了一个最小化的复现场景,极大地缩小了调试范围。这比在海量日志和模糊的提示词工程中摸索要高效得多。

3.3 原因三:驱动“提示工程”的精确化与模块化

很多Agent开发还处于“玄学”阶段,通过反复手动调整提示词(Prompt)来碰运气。TDD能将这个过程工程化。我们可以为Agent的每一个“能力”编写独立的测试。

例如,一个电商客服Agent需要有“处理退货”、“查询物流”、“推荐商品”等能力。我们可以为“处理退货”编写一组测试:

  • 测试1:用户提供有效订单号,应触发退货流程生成。
  • 测试2:用户未提供订单号,应主动询问。
  • 测试3:订单已超过退货期限,应礼貌拒绝并说明政策。

在实现时,我们可能会为“处理退货”设计一个专门的“子提示词模板”或“技能模块”。TDD迫使我们清晰地定义这个模块的输入输出,然后通过测试来迭代优化提示词,直到所有测试用例通过。这使提示词开发从“艺术”变成了有反馈、可衡量的“工程”。

3.4 原因四:实现Agent系统的持续安全演进

Agent系统不是一成不变的。模型会更新,业务规则会变化,集成的外部API也会迭代。没有测试覆盖的系统,任何改动都像是在雷区中行走。

TDD提供的完整测试套件,是进行持续集成(CI)和持续部署(CD)的基石。每次代码或提示词更新后,自动化测试流水线可以快速验证:

  • 核心功能是否依然完好?(回归测试)
  • 新加的功能是否按预期工作?(新功能测试)
  • 修改是否引入了意外的副作用?(集成测试)

这确保了Agent系统能够安全、快速地进行迭代,适应AI技术和业务需求的快速发展。

4. 为AI Agent设计测试的策略与实操框架

理解了“为什么”,接下来是关键性的“怎么做”。为Agent设计测试需要一套不同的策略和工具。

4.1 测试金字塔在Agent领域的应用

传统的测试金字塔(单元测试->集成测试->端到端测试)依然适用,但内涵发生了变化。

第一层:单元测试(最多)—— 测试“组件”与“工具”

  • 测试对象:不是LLM本身,而是Agent框架中的确定性组件。
    • 工具函数:你封装的每一个工具(如calculate_discount,search_database),都必须有完整的单元测试,确保其逻辑正确。
    • 输出解析器:负责将LLM的非结构化输出解析成结构化数据(如JSON)的代码,是测试重点。
    • 提示词模板:可以测试模板渲染是否正确,是否会在特定输入下产生注入风险。
  • 方法:使用标准的测试框架(如pytest),完全模拟(Mock)掉LLM调用。

第二层:集成测试(中等)—— 测试“交互链”

  • 测试对象:Agent核心的执行循环或链条。例如,测试一个ReAct(推理-行动)循环是否能正常完成一轮“思考->调用工具->观察结果”的过程。
  • 方法:使用模拟的LLM(如使用unittest.mock或专门的库如langchain-test)来提供确定性的响应,验证Agent在接收到特定序列的模拟LLM输出后,是否能按预期调用正确的工具并更新状态。

第三层:端到端测试(最少但最重要)—— 测试“用户场景”

  • 测试对象:完整的用户与Agent的交互流程。
  • 方法
    • 模拟真实LLM(低成本):使用较小的、开源的LLM(如Llama 3.1 8B)在测试环境中运行,验证端到端流程。虽然慢,但能发现集成测试无法覆盖的模型相关怪癖。
    • 基于评估框架:使用像RAGASDeepEvalLangSmith这样的评估框架,为关键用户旅程定义评估指标(如忠实度、答案相关性、毒性分数),并定期运行测试。这更像是一种“监控”而非瞬时测试,但对于衡量Agent质量至关重要。

4.2 实操工具链选型与示例

场景:构建一个“技术文档问答Agent”,它能理解用户关于某个API的问题,并从向量数据库中检索相关文档片段来生成答案。

步骤1:为工具和解析器编写单元测试

# test_retriever_tool.py import pytest from my_agent.retriever_tool import hybrid_retriever def test_hybrid_retriever_finds_relevant_doc(): # 准备模拟的向量数据库和关键词索引 mock_vector_db = Mock(...) mock_keyword_index = Mock(...) retriever = hybrid_retriever(vector_store=mock_vector_db, keyword_index=mock_keyword_index) # 模拟查询 query = “如何配置OAuth 2.0认证?” # 执行检索 results = retriever.retrieve(query, top_k=3) # 断言:返回3个结果 assert len(results) == 3 # 断言:每个结果都包含必要的元数据字段(如source, content) for r in results: assert hasattr(r, ‘source’) assert hasattr(r, ‘content’) assert len(r.content) > 10

步骤2:为Agent链条编写集成测试

# test_qa_agent_chain.py from langchain_core.messages import AIMessage, HumanMessage from unittest.mock import Mock, AsyncMock import my_agent.qa_chain as qa_chain @pytest.mark.asyncio async def test_qa_chain_happy_path(): # 1. 创建模拟的LLM,让它返回我们预设的、格式正确的“思考”和“回答” mock_llm = AsyncMock() # 模拟LLM先返回一个决定调用检索工具的思考 mock_llm.ainvoke.side_effect = [ AIMessage(content=“我需要检索关于OAuth 2.0配置的文档。”, additional_kwargs={“tool_calls”: [...]}), AIMessage(content=“根据检索到的文档,配置步骤如下:...”) ] # 2. 创建模拟的检索工具 mock_retrieve = AsyncMock(return_value=[“文档片段1...”, “文档片段2...”]) # 3. 装配测试用的Agent链 chain = qa_chain.create_chain(llm=mock_llm, retriever_tool=mock_retrieve) # 4. 执行测试 human_msg = HumanMessage(content=“怎么设置OAuth 2.0?”) response = await chain.ainvoke({“messages”: [human_msg]}) # 5. 断言 # - 检索工具被调用了一次 mock_retrieve.assert_awaited_once() # - 最终回复包含关键信息 assert “步骤” in response.messages[-1].content # - 对话历史被正确维护 assert len(response.messages) == 3 # Human, AI(思考), AI(回答)

步骤3:设计端到端评估测试这不是一个在每次CI中运行的快速测试,而是一个定期(如每日)运行的评估任务。

# evaluate_qa_agent.py from deepeval import evaluate from deepeval.metrics import AnswerRelevancy, Faithfulness from deepeval.test_case import LLMTestCase # 定义测试用例 test_cases = [ LLMTestCase( input=“我们产品的API速率限制是多少?”, # 这里需要Agent实际运行得到的输出 actual_output=run_agent_in_test_env(“我们产品的API速率限制是多少?”), # 这是基于已知文档的“标准答案”,用于评估 expected_output=“每个用户每分钟最多100次请求。”, retrieval_context=[“速率限制文档...”] # 可选的,提供检索上下文用于Faithfulness评估 ), # ... 更多测试用例 ] # 定义评估指标 metrics = [AnswerRelevancy(), Faithfulness()] # 运行评估 evaluation_results = evaluate(test_cases, metrics) print(evaluation_results)

这个评估脚本会输出各项指标的分数,帮助你量化Agent的质量是否下降。

5. 在Agent开发流程中嵌入TDD:一个完整的迭代周期

将TDD融入Agent开发,意味着工作流程的调整。下面是一个结合了热词中提到的“需求澄清”、“代码审查”等概念的敏捷迭代周期:

周期起点:需求澄清与测试用例共创在动手写一行提示词或代码之前,产品、测试和开发一起,基于用户故事(User Story)共创可执行的验收测试用例。这些用例用Given-When-Then格式或简单的表格描述。

故事:作为开发者,我想询问某个API的弃用时间,以便升级我的应用。测试用例

  • 输入:“/v1/users这个API什么时候弃用?”
  • 预期动作:Agent应识别出这是一个关于“API弃用”的查询,并提取实体“/v1/users”。
  • 预期工具调用:调用“API文档查询工具”,参数为{“api_endpoint”: “/v1/users”, “info_type”: “deprecation”}
  • 预期回复:包含确切的弃用日期(如“2024-12-31”)和替代方案建议。

第一步:红——编写失败的自动化测试开发者将上述自然语言描述的测试用例,转化为具体的自动化测试代码(如第4.2节中的示例)。运行测试套件,这个新测试显示为“失败”(红色)。

第二步:绿——实现最简单的Agent功能开发者以实现这个测试用例为目标,开始构建或修改Agent。这可能包括:

  • 修改或添加快捷词(Prompt),让LLM能识别“API弃用”意图。
  • 增强实体抽取逻辑,准确抓取API端点路径。
  • 配置或创建“API文档查询工具”。 实现的目标是让测试变绿,而不是实现一个完美的Agent。最初的实现可以非常简陋(比如用规则匹配意图)。

第三步:重构——优化设计,保持绿色在测试的保护下,安全地进行优化:

  • 提示词重构:将冗长的提示词拆解为模块化的、可复用的模板。
  • 代码重构:抽象出通用的工具调用逻辑、状态管理模块。
  • 架构重构:也许发现多个测试用例共享类似模式,可以考虑引入一个“对话策略”层。 每步重构后,立即运行所有测试,确保没有回归。

第四步:代码审查与UI设计(并行)

  • 代码审查:审查的重点不仅是代码风格,更是测试的质量。审查者会问:“测试是否覆盖了边界情况?”(例如,API名称不存在时怎么办?)“测试是否过于脆弱?”(例如,是否对LLM输出的具体措辞进行了过度断言?)。
  • UI设计:如果Agent有前端界面(如聊天窗口),设计师可以基于确定的Agent交互契约(由测试定义)来设计UI,确保用户体验与Agent能力对齐。例如,测试定义了Agent会询问“请提供您的订单号”,UI就可以提前设计好一个方便用户输入订单号的表单组件。

这个“红-绿-重构”的循环,快速、小步地推进Agent能力的演进,每一步都有自动化的安全网,极大地提升了开发信心和系统质量。

6. 避坑指南:Agent TDD实践中常见的“坑”与对策

在实践中,为Agent实施TDD会遇到一些特有的挑战。以下是我总结的几个关键“坑”及其应对策略。

坑一:测试过于脆弱——对LLM自由输出的过度断言

  • 问题:断言Agent回复必须包含“您好,亲爱的用户”,一旦LLM换种说法(如“你好!”),测试就失败。这种测试毫无价值,且维护成本极高。
  • 对策:进行语义断言而非字符串匹配。
    • 使用嵌入向量相似度:计算测试输出与预期输出在语义空间(如通过OpenAI的text-embedding-3-small模型)的余弦相似度,设定一个阈值(如>0.85)。
    • 使用LLM作为评判官:在测试中调用一个轻量、廉价的LLM(如GPT-3.5-Turbo),让它根据评分规则判断输出是否合格。虽然慢,但对于核心场景的验收测试是可行的。
    • 断言关键信息点:只断言回复中必须出现的核心实体、数字或状态。例如,对于天气回复,只断言包含温度数字和城市名。

坑二:测试执行缓慢且昂贵

  • 问题:如果每个测试都调用真实的GPT-4,测试套件将慢得无法接受,且成本高昂。
  • 对策:分层模拟(Mock)。
    • 单元测试层:100%使用模拟(Mock),完全隔离LLM和外部服务。
    • 集成测试层:使用确定性模拟LLM。许多框架(如LangChain)支持配置一个“FakeListLLM”,你可以预先定义好一系列响应,测试时LLM会按顺序返回这些响应。这完美适用于测试固定的交互序列。
    • 端到端测试/评估层:仅在夜间或发布前,针对核心场景,使用小型廉价模型或抽样调用真实模型进行评估。

坑三:难以测试“创造性”或“开放性”任务

  • 问题:对于需要创意写作、头脑风暴的Agent,似乎没有“正确”答案,如何测试?
  • 对策:测试过程约束质量底线,而非具体内容。
    • 过程测试:断言Agent在完成创意任务时,遵循了必要的步骤。例如,一个“营销文案Agent”的测试可以断言:它是否先调用了“目标用户分析工具”,再调用了“竞品分析工具”,最后才生成文案?
    • 质量底线测试:使用自动化指标设置最低标准。例如,生成文案的测试可以检查:是否超过最低字数?是否包含指定的关键词?是否通过基本的语法检查?是否不包含敏感词或负面情绪?(可使用简单的文本分析库)。

坑四:忽视工具与外部服务的集成测试

  • 问题:只测试了Agent“想”做什么,没测试它“做”得对不对。Agent调用的工具或API本身可能有bug,或接口发生了变化。
  • 对策:建立“契约测试”和“集成测试沙盒”。
    • 契约测试:为Agent调用的每一个外部服务(如天气API、数据库)编写契约测试(使用如Pact等工具),确保双方的接口约定(请求格式、响应格式、错误码)未被破坏。
    • 沙盒环境:在CI/CD流水线中,为集成测试准备一个包含真实工具(但连接测试数据库、模拟支付网关)的沙盒环境。定期运行集成测试,确保整个工具调用链路畅通。

7. 超越测试:TDD思维如何重塑Agent设计与团队协作

最终,TDD不仅仅是一种测试方法,更是一种设计和协作哲学。在AI Agent项目中,它带来了更深层的改变。

设计驱动:TDD迫使你在写代码之前先思考接口和行为。对于Agent,这意味着先定义清晰的“能力契约”——这个Agent究竟能做什么、不能做什么、输入输出是什么。这直接催生了更模块化、更清晰的Agent架构。你会自然地将一个庞大的、无所不能的“超级Agent”,拆分成多个职责单一的、可独立测试的“技能Agent”或“工具”。

文档即测试:你的测试套件成为了最准确、最不会过时的“活文档”。任何新加入团队的成员,通过阅读测试用例,就能迅速理解每个Agent的预期行为、边界条件和典型用法。这比阅读可能陈旧的Markdown文档要可靠得多。

团队协作的通用语言:产品经理、测试工程师、开发者可以围绕“测试用例”进行高效沟通。产品需求可以转化为测试用例,测试用例的通过与否成为功能完成的客观标准。这减少了AI项目常见的“我觉得模型理解对了”之类的主观争论。

质量文化的建立:当“红-绿-重构”成为团队节奏,质量就不再是最后阶段的“测试环节”,而是贯穿始终的“开发习惯”。团队对每一次改动都充满信心,因为知道有数百个自动化测试在守护着系统的核心行为。这种信心,是应对AI系统固有不确定性的最大底气。

回到开头那个故障,如果当时我们为那个处理第三方API数据的Agent编写了TDD风格的契约测试——明确断言“当API响应结构不符合Schema X时,应触发Y处理流程并记录告警”——那么,在API发生微小变化时,我们的集成测试就会立刻失败,从而在部署前阻止故障。我们损失的将只是几分钟的CI时间,而不是半夜三更的应急处理和用户的糟糕体验。

在AI赋予我们“超级力量”的同时,TDD这类经典的工程实践,就是我们驾驭这份力量、确保其造福而非添乱的最可靠缰绳。它让不可预测的AI,运行在可预测、可测试、可维护的软件工程轨道上。这或许才是AI时代,工程师最应该掌握和强化的“第一性原理”。

← 返回列表