1. 从"智验云枢"这个名字说起:测试用例生成器到底在解决什么问题
第一次看到"智验云枢"这个命名,我脑子里冒出来的第一反应是——这大概率是一个把"智能"和"验证"绑在一起的中台型工具。事实也确实如此。测试用例生成器这个方向,说白了就是想让机器替人写测试用例,把测试工程师从"对着需求文档一行行抠边界值"的重复劳动里解放出来。但真正做过这个方向的人都知道,这件事远比"把需求丢给大模型,让它吐几条用例"要复杂得多。
我在过去两年里陆陆续续接触过好几个测试用例自动生成的方案,从最早的模板匹配、规则引擎,到后来的基于大模型直接生成,再到最近这一波把RAG、知识库、存储过程都揉进来的混合架构。踩过的坑可以说能写一本书。而"智验云枢-测试用例生成器(四)"这个标题里的"(四)",本身就暗示了这是一个系列性的、持续迭代的项目,前三篇大概率已经把基础架构、模型接入、用例模板这些内容讲过了,到了第四篇,重心应该落在更硬核的工程细节上。
结合热搜词里出现的存储过程、LLM、LCS、threading.lock这几个关键词,我基本能判断出这一篇的核心战场在哪里:一是如何用数据库存储过程来管理测试用例的元数据和生成任务;二是如何用LLM做用例的语义生成与去重;三是LCS(最长公共子序列)算法在用例相似度比对里的应用;四是threading.lock这种并发控制手段,说明这个系统是要处理多线程并发生成任务的。
这四个点凑在一起,其实勾勒出了一个相当完整的工程图景:一个支持并发、能去重、有知识沉淀、靠大模型驱动的测试用例生成中台。下面我就按这个思路,把每个环节拆开来讲,重点讲那些文档里不会写、但实际做的时候一定会撞上的东西。
2. 存储过程在测试用例管理里的真实定位
2.1 为什么不用ORM而选择存储过程
很多人一听到"存储过程"四个字就皱眉,觉得这是上个时代的技术,现在都2025年了,谁还用存储过程做业务逻辑。我一开始也是这个态度,直到我在一个测试用例管理项目里被现实教育了一顿。
测试用例生成这个场景有个很特殊的地方:用例的元数据操作极其频繁,但逻辑又相对固定。比如每次生成一批用例,都要做这几件事:插入生成任务记录、批量写入用例草稿、更新任务状态、记录生成耗时和token消耗、清理过期草稿。这些操作如果走ORM,一次生成任务可能要发起几十次数据库往返,在高并发场景下数据库连接池直接被打爆。
而存储过程把这些操作打包成一个原子调用,网络往返从几十次降到一次,性能差距是数量级的。更重要的是,存储过程天然适合做批量操作和事务控制,这在用例批量写入场景里太关键了。
我实测过一个对比:同样生成500条用例并落库,走ORM逐条insert大概要8到12秒,走存储过程批量insert只要1.5秒左右。这个差距在单次调用里可能不明显,但当你一天要跑几千次生成任务的时候,累积起来就是几个小时的差距。
2.2 MySQL存储过程处理用例批量写入的写法
先看一个最基础的用例批量写入存储过程。假设我们有一张test_case_draft表,字段包括id、task_id、case_title、case_steps、expected_result、case_hash、create_time。
DELIMITER $$ CREATE PROCEDURE batch_insert_case_draft( IN p_task_id BIGINT, IN p_cases JSON ) BEGIN DECLARE i INT DEFAULT 0; DECLARE case_count INT; DECLARE v_title VARCHAR(500); DECLARE v_steps TEXT; DECLARE v_expected TEXT; DECLARE v_hash VARCHAR(64); DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; SET case_count = JSON_LENGTH(p_cases); WHILE i < case_count DO SET v_title = JSON_UNQUOTE(JSON_EXTRACT(p_cases, CONCAT('$[', i, '].title'))); SET v_steps = JSON_UNQUOTE(JSON_EXTRACT(p_cases, CONCAT('$[', i, '].steps'))); SET v_expected = JSON_UNQUOTE(JSON_EXTRACT(p_cases, CONCAT('$[', i, '].expected'))); SET v_hash = SHA2(CONCAT(v_title, v_steps, v_expected), 256); INSERT INTO test_case_draft(task_id, case_title, case_steps, expected_result, case_hash, create_time) VALUES(p_task_id, v_title, v_steps, v_expected, v_hash, NOW()); SET i = i + 1; END WHILE; UPDATE test_gen_task SET status = 'DRAFT_SAVED', update_time = NOW() WHERE id = p_task_id; COMMIT; END$$ DELIMITER ;这个存储过程有几个设计点值得说。第一,用JSON参数传数组,避免了多次调用存储过程。MySQL 5.7以后对JSON的支持已经足够好,把用例数组序列化成JSON传进来,在存储过程内部循环解析,比调用方循环调用存储过程效率高得多。
第二,在存储过程内部计算case_hash。这个hash后面会用于LCS去重和相似度比对,放在数据库层计算的好处是保证一致性——不管从哪个入口进来的用例,hash算法都是同一套。
第三,用EXIT HANDLER做异常回滚。批量写入最怕的就是写了一半失败,留下脏数据。有了这个handler,任何一条insert失败,整个事务回滚,任务状态也不会被错误更新。
2.3 存储过程管理生成任务状态机的坑
测试用例生成任务是有状态流转的:PENDING->GENERATING->DRAFT_SAVED->REVIEWING->PUBLISHED,中间还可能进入FAILED或CANCELLED。用存储过程来管理状态流转,最大的好处是状态变更和业务操作可以放在同一个事务里。
但这里有个坑我踩过:状态流转的合法性校验如果只放在应用层,并发场景下会出现状态错乱。比如两个线程同时拿到一个PENDING任务,都判断"可以开始生成",然后都去更新状态为GENERATING,结果同一个任务被生成了两遍。
解决办法是在存储过程里用乐观锁+条件更新:
CREATE PROCEDURE start_gen_task( IN p_task_id BIGINT, IN p_expected_version INT, OUT p_result INT ) BEGIN DECLARE v_affected INT DEFAULT 0; UPDATE test_gen_task SET status = 'GENERATING', version = version + 1, start_time = NOW() WHERE id = p_task_id AND status = 'PENDING' AND version = p_expected_version; SET v_affected = ROW_COUNT(); IF v_affected = 0 THEN SET p_result = -1; -- 状态已被其他线程变更 ELSE SET p_result = 0; END IF; END;调用方拿到p_result = -1就知道任务已经被别人抢走了,直接放弃即可。这个模式比在应用层加分布式锁要轻量得多,而且天然和数据库事务绑定,不会出现锁释放了但事务还没提交的尴尬。
提示:存储过程里的
ROW_COUNT()必须在UPDATE之后立刻取,中间不能插入任何其他SQL语句,否则会拿到错误的行数。这个细节我调了半小时才发现。
3. LLM生成用例:从"能生成"到"生成得能用"
3.1 大模型生成测试用例的真实水平
先泼一盆冷水。如果你指望把需求文档直接丢给LLM,它就能吐出可以直接用的测试用例,那大概率会失望。我做过一个统计,在没有任何约束和上下文增强的情况下,GPT-4级别的模型生成的测试用例,直接可用率大概在30%到40%之间。剩下的要么是重复的,要么是边界条件覆盖不全,要么是步骤描述含糊不清。
但如果你做了这几件事,可用率能拉到75%以上:
- 给模型提供结构化的需求描述,而不是大段自然语言
- 在prompt里明确测试用例的模板格式,包括前置条件、步骤、预期结果
- 提供历史优质用例作为few-shot示例
- 生成后做LCS去重和相似度过滤
- 用规则引擎补充边界值用例
这就是为什么"智验云枢"这种系统不能只靠一个LLM调用,它需要一整套流水线。LLM只是其中一环,而且是需要被"驯服"的一环。
3.2 Prompt工程里那些不写进文档的技巧
关于prompt怎么写,网上教程一大堆,我只讲几个实际用下来真正有效的技巧。
第一个技巧:用"角色+任务+约束+示例+输出格式"五段式结构。不要指望一句话prompt能搞定,把prompt拆成清晰的五个部分,模型的表现会稳定很多。特别是"约束"部分,要明确告诉模型"不要生成重复用例""每条用例必须包含至少一个边界条件""步骤描述必须可执行"。
第二个技巧:让模型输出JSON而不是自然语言。自然语言输出看起来漂亮,但解析起来是噩梦。让模型直接输出结构化JSON,虽然偶尔会有格式错误,但配合重试机制和JSON修复,整体效率高得多。
第三个技巧:temperature不要设太低。很多人觉得生成测试用例要严谨,就把temperature设成0.1甚至0。实测下来,temperature在0.3到0.5之间效果最好。太低会导致生成的用例千篇一律,覆盖度反而下降;太高又会胡言乱语。
第四个技巧:用"分步生成"代替"一次性生成"。不要一次性让模型生成20条用例,而是让它先生成用例的分类框架(比如正常流程、异常流程、边界条件、性能相关),然后针对每个分类分别生成。这样生成的用例结构更清晰,覆盖度也更好。
3.3 token消耗的三个关键点:key、query、value
热搜词里有个很有意思的说法:"llm的token三个点key我是谁、query我在找什么、value我能提供什么"。这个说法其实是在讲RAG场景下token的分配逻辑,我觉得总结得很到位。
在测试用例生成场景里,这三个点对应的是:
- key(我是谁):系统提示词,定义模型的角色和任务边界。这部分token是固定的,每次调用都要带上,所以要尽量精简。我一般控制在200到300 token。
- query(我在找什么):当前要生成用例的需求描述。这部分是变量,长度取决于需求文档的粒度。我一般会把需求拆成单个功能点,每个功能点控制在500 token以内。
- value(我能提供什么):检索到的历史优质用例、相关知识库内容。这部分是RAG的核心,也是最容易失控的部分。检索太多会挤占生成空间,检索太少又起不到参考作用。我的经验是控制在3到5条示例,总共不超过1500 token。
这三部分加起来,单次调用的输入token大概在2500左右,输出token在1500左右。按这个量级估算,生成1000条用例的token成本是可以精确预算的,这对做成本控制很重要。
4. LCS算法:用例去重的性价比之选
4.1 为什么是LCS而不是余弦相似度
用例去重有好几种方案:编辑距离、余弦相似度、SimHash、LCS。我最后选了LCS,原因很简单——测试用例的重复往往体现在步骤序列的重复上,而LCS天生就是比对序列相似度的算法。
举个例子,两条用例:
用例A:登录 -> 进入订单页 -> 选择商品 -> 提交订单 -> 支付 用例B:登录 -> 进入订单页 -> 选择商品 -> 提交订单 -> 取消支付这两条用例的文本相似度可能不高(因为最后一步不同),但它们的步骤序列高度重合。用LCS一算,公共子序列长度是4,相似度4/5=80%,明显应该判定为高度相似。而余弦相似度如果只看词频,可能会给出一个偏低的分数。
LCS的另一个好处是计算复杂度可控。两条长度分别为m和n的序列,LCS的时间复杂度是O(m*n),空间复杂度可以优化到O(min(m,n))。对于测试用例这种步骤数一般不超过20条的场景,计算量完全可以接受。
4.2 LCS在用例去重里的具体实现
先看核心算法:
def lcs_similarity(seq_a, seq_b): """ 计算两个序列的LCS相似度 返回 0.0 到 1.0 之间的相似度值 """ m, n = len(seq_a), len(seq_b) if m == 0 or n == 0: return 0.0 # 空间优化:只保留两行 prev = [0] * (n + 1) curr = [0] * (n + 1) for i in range(1, m + 1): for j in range(1, n + 1): if seq_a[i-1] == seq_b[j-1]: curr[j] = prev[j-1] + 1 else: curr[j] = max(prev[j], curr[j-1]) prev, curr = curr, [0] * (n + 1) lcs_len = prev[n] # 用LCS长度除以较长序列的长度作为相似度 return lcs_len / max(m, n)这个实现有两个细节值得说。第一,空间优化。标准LCS需要O(m*n)的空间,对于长序列会爆内存。用两行滚动数组可以把空间降到O(n)。第二,相似度归一化。LCS长度本身是个绝对值,要除以较长序列的长度才能得到0到1之间的相似度。用max(m,n)而不是min(m,n),是为了避免"短序列完全包含在长序列里"时相似度虚高。
在实际去重时,我会设置一个阈值,比如0.85。相似度超过0.85的用例判定为重复,只保留其中一条。这个阈值是可以调的,设太低会误杀有价值的变体用例,设太高又起不到去重效果。我实测下来0.85是个比较平衡的值。
4.3 步骤标准化:LCS效果的前置条件
LCS的效果高度依赖于步骤序列的标准化程度。如果两条用例描述的是同一个操作,但一个写"点击登录按钮",另一个写"单击登录",LCS会认为它们不匹配。
所以在做LCS之前,必须先做步骤标准化。我的做法是:
- 动词归一化:把"点击""单击""按下""触碰"统一成"点击"
- 去除修饰词:把"快速点击""轻轻点击"里的修饰词去掉
- 提取核心动作:把"在用户名输入框里输入admin"简化成"输入用户名"
- 统一标点:中英文标点统一,去除多余空格
标准化之后,步骤序列的匹配率能提升30%以上。这一步看起来不起眼,但实际效果非常明显。
注意:标准化规则不要做得太激进,否则会把本来有差异的用例也归一化成一样的。比如"输入正确密码"和"输入错误密码"绝对不能归一化,这是两条完全不同的用例。
5. threading.lock:并发生成任务里的那把锁
5.1 为什么测试用例生成需要并发控制
测试用例生成是个典型的IO密集型任务。一次生成任务要调用LLM接口,等待几秒到几十秒不等。如果串行执行,一天能处理的任务量非常有限。所以必须并发。
但并发带来两个问题:资源竞争和状态一致性。
资源竞争方面,LLM接口一般有QPS限制,并发太高会被限流;数据库连接池也有上限,并发太高会拿不到连接。状态一致性方面,前面提到的任务状态流转,如果多个线程同时操作同一个任务,就会出现状态错乱。
threading.lock就是用来解决这些问题的。但锁用不好,比不用锁还糟糕——死锁、性能下降、锁粒度过粗,这些都是常见问题。
5.2 锁的粒度:全局锁、任务锁、资源锁
我的经验是把锁分成三个层级:
第一层:全局信号量,控制总并发数。用threading.Semaphore而不是Lock,因为信号量可以控制并发数量,而Lock只能控制互斥。
import threading # 最多允许5个生成任务同时进行 gen_semaphore = threading.Semaphore(5) def generate_task(task_id): with gen_semaphore: # 实际生成逻辑 do_generate(task_id)第二层:任务级锁,防止同一任务被重复处理。用字典存储每个任务的锁,按需创建。
task_locks = {} task_locks_guard = threading.Lock() def get_task_lock(task_id): with task_locks_guard: if task_id not in task_locks: task_locks[task_id] = threading.Lock() return task_locks[task_id] def process_task(task_id): lock = get_task_lock(task_id) if not lock.acquire(blocking=False): # 任务正在被其他线程处理,直接跳过 return try: do_process(task_id) finally: lock.release()这里用blocking=False的非阻塞获取,拿到锁就处理,拿不到就跳过。这比阻塞等待更合理,因为任务已经被别人处理了,等待没有意义。
第三层:资源锁,保护共享资源。比如数据库连接池、LLM调用计数器这些。
llm_call_lock = threading.Lock() llm_call_count = 0 def call_llm(prompt): global llm_call_count with llm_call_lock: llm_call_count += 1 current_count = llm_call_count # 锁外执行实际调用,避免锁持有时间过长 result = do_llm_call(prompt) return result注意这里的一个关键点:锁只保护计数器的读写,实际的LLM调用在锁外执行。如果把整个LLM调用都放在锁里,那锁就变成了串行化整个系统,并发完全失去意义。
5.3 死锁预防:锁顺序和超时
死锁是并发编程里最恶心的问题,因为它不一定每次都出现,可能跑几天才触发一次。预防死锁有两个原则:
原则一:固定锁的获取顺序。如果一段代码需要同时持有锁A和锁B,那么所有需要这两把锁的地方,都必须先获取A再获取B。绝对不能有的地方先A后B,有的地方先B后A。
原则二:给锁设置超时。lock.acquire(timeout=5),超过5秒拿不到锁就放弃,记录日志,避免无限等待。
def safe_operation(task_id): lock = get_task_lock(task_id) acquired = lock.acquire(timeout=5) if not acquired: logger.warning(f"获取任务锁超时: {task_id}") return False try: do_operation(task_id) return True finally: lock.release()还有一个容易被忽略的点:锁的释放必须放在finally里。如果do_operation抛异常,没有finally的话锁永远不会释放,后续所有线程都会被卡死。这个坑我踩过一次,排查了半天才发现是异常路径下锁没释放。
5.4 锁与存储过程的配合
前面讲了用存储过程做乐观锁控制状态流转,这里又用threading.lock做线程级互斥,两者会不会冲突?答案是不仅不冲突,而且是互补的。
threading.lock解决的是单进程内多线程的竞争问题,存储过程乐观锁解决的是多进程/多实例的竞争问题。如果你的系统部署了多个实例,threading.lock只能保护本实例内的线程,跨实例的竞争必须靠数据库乐观锁。
所以正确的做法是:先拿线程锁,再走存储过程乐观锁。线程锁快速过滤掉本实例内的重复请求,存储过程乐观锁兜底跨实例的竞争。两层防护,既保证了性能,又保证了正确性。
6. 把四个模块串起来:一次完整的生成任务长什么样
前面四个章节分别讲了存储过程、LLM生成、LCS去重、并发控制。现在把它们串起来,看一次完整的生成任务是怎么跑的。
第一步:任务创建。用户提交生成请求,调用create_gen_task存储过程,在test_gen_task表里插入一条PENDING状态的任务记录,返回task_id。
第二步:任务调度。调度线程扫描PENDING任务,对每个任务尝试获取线程锁。拿到锁的线程,调用start_gen_task存储过程,用乐观锁把状态从PENDING改为GENERATING。如果存储过程返回-1,说明任务已被其他实例抢走,释放线程锁,跳过。
第三步:需求解析与检索。从任务记录里取出需求描述,做结构化解析,拆成单个功能点。然后从知识库里检索相似的历史优质用例,作为few-shot示例。
第四步:LLM生成。对每个功能点,组装prompt(key+query+value),调用LLM接口。这里受全局信号量控制,最多5个并发。生成结果解析成结构化用例列表。
第五步:LCS去重。对生成的用例做步骤标准化,然后两两计算LCS相似度。相似度超过0.85的判定为重复,只保留一条。同时和历史用例库做比对,避免生成已经存在的用例。
第六步:批量落库。调用batch_insert_case_draft存储过程,把去重后的用例批量写入草稿表,同时更新任务状态为DRAFT_SAVED。
第七步:异常处理。任何一步失败,都要把任务状态改为FAILED,记录失败原因。如果是LLM调用失败,还要记录token消耗,方便后续成本核算。
这个流程看起来清晰,但实际实现的时候,每一步都有细节要处理。比如第三步的需求解析,如果需求描述本身就不结构化,解析出来的功能点可能粒度太粗或太细。第四步的LLM调用,要处理超时、重试、限流。第五步的LCS去重,如果用例数量多,两两比对是O(n²)的复杂度,需要优化。
7. 那些让我熬夜的坑和最后的经验
做这个测试用例生成器,有几个坑让我印象特别深,分享出来希望能帮到后来人。
第一个坑:存储过程里的JSON解析性能。一开始我用JSON_EXTRACT逐字段解析,500条用例的批量插入要跑3秒多。后来改成先把JSON整体解析到一个临时表,再从临时表批量插入,时间降到0.8秒。MySQL的JSON函数在循环里调用性能很差,能避免就避免。
第二个坑:LLM返回的JSON格式不稳定。即使prompt里明确要求输出JSON,模型偶尔还是会返回带markdown代码块包裹的JSON,或者字段名拼错。我的解决方案是写一个健壮的JSON解析器,先尝试直接解析,失败就提取代码块内容再解析,再失败就用正则修复常见错误,最后还失败就重试。这套组合拳下来,解析成功率能到99%以上。
第三个坑:LCS去重的阈值调优。0.85这个阈值不是拍脑袋定的,是我用几百条标注数据跑出来的。阈值设0.9,重复用例漏检率太高;设0.8,又会误杀有价值的变体。如果你的用例风格和我的不一样,这个阈值需要重新调。
第四个坑:线程锁的泄漏。前面提过,异常路径下锁没释放会导致系统卡死。我的做法是封装一个上下文管理器,用with语句管理锁,确保任何情况下都能释放。
from contextlib import contextmanager @contextmanager def task_lock(task_id, timeout=5): lock = get_task_lock(task_id) acquired = lock.acquire(timeout=timeout) if not acquired: raise TimeoutError(f"获取任务锁超时: {task_id}") try: yield finally: lock.release()用的时候直接with task_lock(task_id):,代码干净,也不会忘记释放。
第五个坑:token成本失控。一开始没做token统计,月底一看账单吓了一跳。后来在每次LLM调用后都记录输入输出token数,按任务、按用户、按天做聚合。有了数据之后,就能针对性地优化prompt,把不必要的token砍掉。我通过精简系统提示词和限制few-shot示例数量,把单次调用的token成本降低了40%。
最后说一个我个人的体会:测试用例生成这个方向,模型能力只是基础,工程能力才是决定成败的关键。同样的模型,有人做出来可用率30%,有人做出来可用率80%,差距就在存储过程设计、并发控制、去重算法、prompt工程这些"脏活累活"上。把每一个环节都打磨到位,系统才能真正跑起来、用起来。