CrewAI单元测试实战:从Mock到录制回放的工程指南

发布时间:2026/10/22 10:22:49
CrewAI单元测试实战:从Mock到录制回放的工程指南 做CrewAI开发半年后我最大的体会是智能体协作本身并不难难的是没人测试它。市面上讲怎么建Agent、写Task、跑Crew的教程不少但真正告诉你“写完这坨逻辑之后怎么用单元测试把它兜住”的内容少得可怜。这篇指南就是想补这个缺口用一条新手也能直接上手的路径把CrewAI单元测试从“怎么做”讲到“为什么这么做”既有原理分析也有能直接跑的代码。我默认的读者是两种人一种是刚接触CrewAI正在用多智能体做业务但每次改完Prompt都不敢上线另一种是已经踩过“改完一个任务描述整个流程全乱了”的坑想建立一套真正可持续的测试体系。只要你属于其中任何一种这篇指南都能给你一套可复用的方案。1. 为什么CrewAI项目必须引入单元测试1.1 LLM应用测试的三个“老毛病”先面对几个现实问题。第一是成本。CrewAI里的每个Agent最终都会调用模型服务测一条流程可能没多少钱但跑一次完整回归几十个用例下来账单肉眼可见地涨。我见过一个团队把测试挂在CI上跑一次全量要烧掉几十块后来干脆不跑了回归全靠手点等于没测。第二是不确定性。同一段Prompt模型这次输出A下次输出B甚至偶尔输出一段完全不符合格式的废话。你写断言的时候根本没法定死“这一轮对话之后结果里必须出现某个固定字段”因为下一次可能格式就变了。这会让传统单测思维直接失效。第三是难复盘。多Agent场景下输出是在多个步骤之间传递的。最终结果出错你通常只看到Crew结果对象里的一行文字根本不知道是哪个Task产生了脏数据也不知道是哪一步的Prompt出了问题。没有留痕复盘就只能靠日志和感觉。单元测试在这三个问题上都有明确解法用Mock锁死模型输出测试就变得确定且零成本把LLM调用替换成“场景化假模型”不同输入返回不同固定结果断言瞬间变得干净再加上每轮调用都会记录Prompt和返回值问题出在哪一步测试报告里一眼就能看到。1.2 单元测试在CrewAI工程里的分层价值很多人以为CrewAI测试就是把整个Crew跑一遍然后看最终结果对不对。这个思路是集成测试不是单元测试。真正稳定的做法是把测试分成四层工具函数、Agent行为、Task执行、Crew编排。工具函数是最值得测的因为它完全没有LLM参与输入输出稳定跑起来毫秒级。Agent层要测的是Prompt分支是否清晰、工具是否绑定成功、模型返回的格式是否被正确消费。Task层测的是模板变量填充、输出解析、异常降级。Crew层测的是Agent之间的调用顺序和中间产物传递。有了这四层你才能做到“改一个Prompt只跑改动的子集”。比如只改了审核Agent的角色描述那Agent层和该Agent所属Task层的用例跑一遍就够了根本不需要把整条Crew重新执行。这个分层价值在多人协作时尤其明显也是整个CrewAI测试体系里最容易被忽略的地方。2. CrewAI测试环境搭建与依赖选型2.1 目录结构与依赖清单CrewAI本身是Python项目测试江湖上最顺手的组合就是pytest加少量辅助库不要自己造轮子。你直接把测试放在tests目录按被测对象分文件名比什么都清晰your_crew_project/ ├── crew/ │ ├── __init__.py │ ├── agents.py │ ├── tasks.py │ ├── crews.py │ └── tools.py ├── tests/ │ ├── conftest.py │ ├── test_tools.py │ ├── test_agents.py │ ├── test_tasks.py │ └── test_crews.py └── requirements-dev.txt依赖清单里我建议至少装这几样pip install pytest pytest-asyncio pytest-cov vcrpypytest是主框架pytest-asyncio用来处理CrewAI里的异步方法pytest-cov看覆盖率vcrpy做录制回放。vcrpy不是必选项但我建议一开始就装上后面章节会讲它的具体用途。安装完之后先不急着写用例你在tests目录建一个空的conftest.py再跑一次pytest tests/ -v确认环境没问题再往下走。2.2 conftest全局fixture怎么设计conftest.py是整个测试体系的“总装配车间”。你要在里面规划好所有全局复用的fixture否则每个测试文件都会重复造轮子越写越痛苦。我习惯在conftest里放两个基础fixture一个是“假模型工厂”一个是“标准Crew对象”。假模型工厂负责返回可控的MockLLM实例标准Crew对象负责组装一套可直接触发的测试业务。这样业务代码和测试代码的耦合度会降到最低换项目时只需要换组装逻辑测试手法可以全套迁移。fixture的scope我建议用函数级也就是默认scopefunction。CrewAI的Agent和Task对象内部可能有缓存或可变状态用session级fixture容易让测试之间互相污染查起来非常头疼。保持每次用例拿到的都是全新对象虽然多花几毫秒但换来了隔离性。2.3 如何保证测试不真烧钱聊到测试环境很多新手第一反应是“直接用真实模型测不就行了”。我用切身教训告诉你这种方式撑不过两周。真实模型每一轮调用都要花钱每一次输出都不可能完全一致最致命的是一旦上游模型接口调整你的整个测试套件会瞬间失去参考基准。你根本分不清是业务Prompt变了导致测试挂还是模型版本变了导致测试挂。一个CTO朋友跟我讲过一句话有状态、不可控、有成本的东西都不应该出现在单元测试里。深以为然。所以我的原则是单元测试里一律不调真实模型全部走Mock。只有少量录制回放用例才会碰真实模型API而且必须由人工控制执行。为了让方案落地我通常在Agent初始化上做一个“测试开关”。检测到环境变量里没有配置模型服务密钥时自动把Agent的LLM替换成Mock实现。这样哪怕CI机器上没有密钥测试也能安全跑完绝对不会产生意外账单。3. 核心测试对象拆解Agent、Task、Crew、Tool3.1 Tool函数最纯粹的单测目标CrewAI里的Tool本质上就是普通Python函数只是被包装成了Agent可以调用的形式。这部分最容易被忽略但恰恰是性价比最高的测试对象。工具函数不依赖模型不依赖网络输入输出完全确定非常适合用传统单元测试的思路覆盖各种边界条件。我在项目里踩过一个大坑某个搜索工具在传入空字符串时会直接抛异常导致下游Agent根本拿不到结果整个Crew中途中断。后来给这个工具补了一条空字符串用例五分钟发现问题十分钟修完。如果只测Crew整体流程这种问题大概率要等到线上才能暴露代价高得多。所以凡是Agent会用的工具都要先做独立单测。重点覆盖正常输入、边界输入、异常输入三类场景把工具的脾气摸清楚再让它进入Agent的武器库。3.2 Agent行为Prompt分支与工具绑定Agent测试的核心不是验证“Agent有没有智能”而是验证“在给定固定模型输出时Agent的封装逻辑是否按预期工作”。这听起来有点绕但想明白这一点CrewAI测试就算入门了。我用一个“场景化MockLLM”来测Agent。比如给审核Agent准备三组固定输出一组是“通过”的JSON一组是“拦截”的JSON一组是乱格式文本。分别喂给Agent断言Agent的后续处理逻辑是否走了对应分支。如果“通过”和“拦截”在代码里没有区分处理那这两条用例就会立刻暴露问题。还需要测工具绑定。有些Agent被设计成“在需要时调用指定工具”但配置时可能把工具写漏了或者写到了另一个Agent身上。这种配置错误人工很难发现但测试很容易判断直接检查Agent对象上挂载的工具列表是否包含预期工具即可。这类断言看起来“很傻”但能拦住大量低级配置错误。3.3 Task执行输出解析与异常路径Task是最容易写坏测试的一层因为它夹在Agent和Crew之间既要处理模型输出又要保证输出格式符合Crew后续步骤的期望。测试时最需要盯住两个点模板变量填充输出解析容错。描述模板的变量如果填错比如脚本里写{content}但实际传的是text字段Agent会拿到一段带花括号的残缺描述输出质量肉眼可见地下降。这种错误在测试里非常好抓你只需要启动Task让它把模板渲染出来再断言最终Prompt里包含了预期的动态内容。输出解析是另一个高风险点。Agent返回的结果常常带着非法JSON、多行注释、前后缀噪声甚至纯文本回答。CrewAI自带的解析逻辑能不能兜住需要你用专门构造的“脏数据”去测。我给自己的项目定了一条规矩每一个Task都必须有一条“模型输出非法格式”的用例避免上线后被一次意外输出击穿。3.4 Crew编排调用顺序与中间产物到了Crew层测试重点不再是单个Agent的表现而是编排顺序是否正确、前一个Task的输出是否顺利变成后一个Task的输入。这里我用一个“调用追踪器”来断言顺序极其好用。具体做法是在MockLLM里维护一个调用列表每收到一次调用就往列表末尾追加当前时间戳和一个标识符。Crew跑完之后什么时间点哪个Agent被调用过一目了然。如果设计的是“先审核、后改写”的顺序但实际执行时反了测试断言就能立刻报警。这个追踪器还有额外好处可以打印出每轮Mock接收到的Prompt方便你复盘代码里到底把什么内容传给了模型。我几乎每次调Crew逻辑问题第一步都是看追踪器里记录的Prompt序列而不是去翻最终结果。4. 完整实例给一个内容审核Crew写一套测试4.1 业务建模与测试用例清单用一个最典型的多Agent场景来演示内容审核Crew。它由两个Agent组成第一个负责审核文本输出是否违规的结论第二个负责在审核通过后生成一段简短摘要。为了演示完整过程我把这个Crew建模成下面这个样子代码基于CrewAI当前主流接口# crew/agents.py from crewai import Agent def create_moderation_agent(llm): return Agent( role内容审核员, goal判断输入内容是否违规输出JSON格式结论, backstory你是一名严谨的内容安全运营专家只用JSON输出结论, llmllm, ) def create_summary_agent(llm): return Agent( role摘要生成员, goal对通过审核的内容生成一句话摘要, backstory你是擅长提炼要点的编辑, llmllm, )# crew/tasks.py from crewai import Task def create_moderation_task(agent): return Task( description审核以下内容是否违规{content}。只输出JSON: {\verdict\: \pass|flag\, \reason\: \原因\}, expected_outputJSON对象包含verdict和reason两个字段, agentagent, ) def create_summary_task(agent): return Task( description上一步审核结论为pass。为内容生成摘要{content}, expected_output一句话摘要, agentagent, )# crew/crews.py from crewai import Crew, Process def build_moderation_crew(moderation_agent, summary_agent, moderation_task, summary_task): return Crew( agents[moderation_agent, summary_agent], tasks[moderation_task, summary_task], processProcess.sequential, )测试用例清单可以先这么列测试对象测试目标关键断言审核Agent模型返回pass分支后续逻辑走“通过”分支审核Agent模型返回flag分支后续逻辑走“拦截”分支摘要Task模板变量填充正确最终Prompt包含真实内容审核Task非法JSON容错不抛异常给出友好提示Crew执行顺序正确审核Agent先于摘要Agent被调用4.2 第一轮测试MockLLM锁死回复这一轮的关键就是把不可控的模型输出变成可控的Mock。我写了一个非常朴素的MockLLM类核心逻辑就是按关键词匹配返回固定结果# tests/conftest.py class MockLLM: def __init__(self, scenarios: dict, default: str ): self.scenarios scenarios self.default default self.calls [] def call(self, prompt: str, **kwargs): self.calls.append(prompt) for key, resp in self.scenarios.items(): if key in prompt: return resp return self.default注意不同版本CrewAI对LLM对象的调用方法名可能不完全一样。我这里用call(prompt)做演示如果你的版本内部是通过其他方式调用你只需要把Mock的方法名对齐到实际调用入口即可思路完全不变。接着写审核Agent的测试# tests/test_agents.py import pytest def test_moderation_agent_flag_branch(moderation_agent): mock_llm MockLLM({ 违规: {verdict: flag, reason: 检测到禁词}, default: {verdict: pass, reason: 未知}, }) moderation_agent.llm mock_llm result moderation_agent._execute( task_description审核以下内容是否违规这是一个违规词测试, ) assert flag in result assert 禁词 in result这类用例的特点是无论模型真实水平如何测试结果都是确定的。Mock返回flag代码就必须走向flag分支Mock返回pass代码就必须走向pass分支。这就是“锁死”的含义。4.3 第二轮测试非法JSON与降级处理真实模型输出里非法JSON出现概率远比想象中高。我遇到过模型在JSON外面加了一堆解释性文字也遇到过它把双引号转义成不一样的格式还有直接返回null的。所以解析逻辑必须有一个独立的兜底函数并为它写专项测试。我通常会在工具模块里放一个解析函数# crew/tools.py import json import re def parse_json_output(text: str) - dict: if not isinstance(text, str) or not text.strip(): return {verdict: flag, reason: 模型输出为空默认拦截} try: return json.loads(text) except json.JSONDecodeError: match re.search(r\{.*\}, text, re.S) if match: try: return json.loads(match.group(0)) except json.JSONDecodeError: return {verdict: flag, reason: 模型输出非法JSON默认拦截} return {verdict: flag, reason: 未提取到结构化输出默认拦截}对应的测试就非常简单了# tests/test_tools.py from crew.tools import parse_json_output def test_parse_json_with_extra_text(): raw 根据分析结论如下{\verdict\: \pass\, \reason\: \内容正常\}。请知悉。 data parse_json_output(raw) assert data[verdict] pass def test_parse_json_with_invalid_content(): raw 完全无法解析的内容 data parse_json_output(raw) assert data[verdict] flag一个审核系统最怕的不是误伤正常文本而是放过了风险内容。所以我把解析兜底策略设计成“解析失败默认拦截”测试里也专门锁定了这个行为。这个决策不是技术问题是业务风险问题但通过单元测试把它固化下来后续任何人改解析逻辑都会先被测试提醒一遍。4.4 录制回放少量真实API验证Mock解决了确定性和成本问题但也带来一个隐患它验证的是“我们自己假设的输入输出关系”不是真实模型下Prompt与输出的协议兼容性。比如Agent实际发给模型的是messages格式但Mock里用的是prompt字符串等上真实环境才发现协议对不上那就晚了。所以我会在每个Sprint里跑一批录制回放用例。用vcrpy录制真实模型请求之后CI上重放请求既不需要密钥也不花钱还能验证协议兼容性。基础写法是# tests/integration/test_real_api_recording.py import vcr import pytest my_vcr vcr.VCR( cassette_library_dirtests/cassettes, record_modeonce, filter_headers[authorization], ) my_vcr.use_cassette(moderation_pass.yaml) def test_real_moderation_pass(): result build_moderation_crew(...).kickoff(inputs{content: 这是一段正常内容}) assert pass in result.raw.lower()这条用例第一次跑会真实调用模型并录制磁带之后就不需要模型服务了。要注意两点一是录制下来的磁带文件里可能包含请求体如果里面有敏感业务数据一定要用filter_post_data_parameters过滤二是不要傻乎乎把真实密钥写进断言里那不是测试是事故预告。录制回放只解决协议兼容性问题不该用来做高频回归否则回归的仍然是“特定时刻的模型行为”而不是业务逻辑。5. 测试不稳定与常见坑排查5.1 断言偶发失败的根因与修复新手最容易遇到的现象是测试用例跑五次三次过两次挂而且挂的用例每次报错信息都不一样。这种情况99%是因为某个依赖项没有被Mock干净。要么是Agent里残留了一个真实LLM对象要么是某工具函数偷偷访问了外部网络要么是共享了同一个全局缓存。排查方法是先看失败用例的调用追踪。我会在MockLLM里把每次调用内容写到临时文件报错之后直接对比几次运行的Prompt差异。如果Mock完全锁死了Prompt理论上应该是相同的如果出现细微差别比如带时间戳、随机数、session id之类的字段那就说明业务代码里有动态内容干扰了Mock匹配逻辑先把动态内容从Prompt里摘出去再重新跑测试。测试要稳定前提是输入可复现这个逻辑放在任何时候都成立。5.2 录制回放不生效vcrpy在CrewAI场景下有一个很常见的坑它默认录制的是库底层HTTP请求但CrewAI内部如果走的是某模型服务SDK的流式接口或重试机制两层调用之间可能会发生vcr拦截不到、或拦截到的地址是重试后的二级地址的情况。结果就是录制时一切正常回放时怎么都匹配不上。我踩过这个坑之后摸索出的做法是不要试图让vcrpy拦截所有层次的请求只对一个最小范围的Agent做录制回放测试。也就是设计一个专门用于协议验证的简化Task单独跑最小Prompts录制范围小匹配自然稳定。等这份“最小磁带”通过后再逐步扩大范围每次只扩一条Task。回归测试里录制回放用例数量控制在10条以内质量远比数量重要。5.3 异步报错与pytest配置CrewAI的新版本里不少接口是异步的。如果你直接用pytest跑很可能会看到类似“coroutine was never awaited”的告警或者断言里出现一个内存地址而不是真实结果。这不是代码写错了是pytest默认不知道怎么处理异步函数。在项目根目录的pytest.ini里加上配置[pytest] asyncio_mode auto这是pytest-asyncio推荐的做法。打开自动模式之后pytest能直接跑async def测试函数不用每个方法都手动加装饰器。另一个易错点是异步Mock。如果你的MockLLM的call方法不是异步的但业务代码await了它会直接报“object is not awaitable”。我建议在conftest里就写好一个异步版本的MockLLM一劳永逸避免每写一条测试就改一次Mock。5.4 工具函数污染全局状态工具函数如果内部有“缓存”或“计数器”之类的全局状态跑它自己的单测时没事跑完一轮Crew测试后第二个用例就会拿到被污染的状态。我见过一个真实案例某个工具函数用全局列表保存历史结果导致第二个Agent在做同样决策时看到的输入多了一倍历史数据测试结果飘忽不定。解决方法是在conftest里给所有涉及全局状态的工具函数写一个cleanup fixturepytest.fixture(autouseTrue) def clean_global_state(): yield search_tool.cache.clear() moderation_agent.memory []autouseTrue的意思是它不需要被显式引用每个测试结束后都会自动执行。看到这里你可能会问为什么不用mock把工具完全替换掉我的观点是工具函数本身值得真测让它保持真实逻辑只用autouse fixture清理状态这样才能最大程度保留回归价值。6. 把单元测试嵌入日常开发流程6.1 测试金字塔在CrewAI里的落地传统的测试金字塔是“底层单测多中间集成适中上层端到端少”。在CrewAI项目里我把这套结构微调成工具函数单测是底座数量最多Agent和Task的Mock测试是中间层数量适中Crew级编排测试和录制回放是塔尖数量最少。这样做的好处是大部分问题在底座和中间层就会被拦截。工具函数有问题跑工具单测就能发现Prompt分支有问题Agent测试立刻报警只有真正的跨Agent交互问题才需要动用Crew级测试。如果反过来把大部分测试都堆在Crew级那每次单测跑起来慢、贵、还不稳定最后整个测试体系会被团队自然放弃。我建议刚开始不要追求测试数量先把每一层各写两三条核心用例跑通后再迭代增加。再完美的测试体系如果开发嫌麻烦不跑就是废的。6.2 CI集成与覆盖率红线在CI上我建议把测试拆成两个任务日常任务只跑Mock测试固定时间或发布前才跑录制回放。我常用这样的流水线配置test: stage: test script: - pip install -r requirements-dev.txt - pytest tests/ -m not integration --covcrew --cov-reportterm-missing覆盖率红线我通常设成两个值全项目行覆盖率不低于80%核心模块不低于90%。如果某个文件是新写的新增代码行覆盖率必须达到100%才能合入。这个红线不是拿来卡人的是让新人知道哪些特殊逻辑该优先测试。比如解析兜底函数、异常处理分支都属于“必须覆盖”的死角没有这些用例等于没测。覆盖率只是底线不是目标。别为了提高行覆盖去写一堆无意义断言那只会让团队对测试产生免疫。测试的价值在于能否暴露问题而不是凑数字。6.3 团队协作约定最后分享几条我给自己团队定的测试约定。第一条任何对Prompt的修改必须附带修改对应测试用例。理由是Prompt的改动很可能改变输出格式和分支走向如果测试不跟着改失败信号就被污染了。第二条新增工具必须写单测且至少覆盖一个正常路径和一个异常路径单纯“能跑”不算完成。第三条任何人禁止在本地全量测试里使用真实模型API所有测试统一走Mock需要真实验证的场景单独走录制回放任务。这些约定听起来像行政要求但它们是CrewAI项目能持续迭代的基础。多智能体应用最怕的不是模型能力差而是“不知道哪个环节被谁改坏了”。有了测试约定每次改动都有据可查每个失败都能追踪到具体场景。我个人的体会是给CrewAI编写单元测试最难的不是技术而是思维转换。你不能再把“模型会产生正确答案”当成前提而是得把模型当成一个“行为不可控但可以被Mock的外部依赖”。一旦想通这一点整个测试体系就会非常自然地长出来Mock锁死模型回复工具函数独立验证Crew编排用追踪器记录调用顺序录制回放负责少量真实校验。这套组合拳打下来多智能体项目在改Prompt、换模型、调整Agent编排时才能真正做到心中有数。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询