Prompt 测试框架:把提示词测试当成单元测试来写
Prompt 测试框架把提示词测试当成单元测试来写一、Prompt 是代码不是配置——它值得被测试大多数团队把 Prompt 放在一个 YAML 配置文件里或者更糟——直接硬编码在 Python 脚本中。修改 Prompt 的流程是改一行文字 → 人工跑几个例子 → 看着差不多就上线。两周后数据分析师发现模型对某类问题的回答准确率从 87% 跌到了 72%。排查两天根因是 Prompt 末尾加了一句请简洁回答。这就是把 Prompt 当配置管理的代价。但 Prompt 不是配置它是模型的指令集。一行文字的增减可能影响输出的格式、风格、准确率甚至安全性。Prompt 的每一次变更都应该像代码变更一样被测试、被审查、被监控。Prompt 测试框架的核心思想很简单把 Prompt 视为一个函数f(prompt, input) → output然后为这个函数编写断言。断言关注的不只是对不对还有格式是否符合预期、是否包含禁止内容、延迟是否在阈值内。二、Prompt 测试的分层策略与执行流程Prompt 测试不是单一行为而是一个多层防御体系。graph TD A[Prompt 变更 PR] -- B[第一层静态检查] B -- C{通过?} C --|否| D[阻断合并] C --|是| E[第二层单元断言] E -- F[加载测试 Case 集] F -- G[并发调用 LLM API] G -- H[多维度断言] H -- H1[语义正确性] H -- H2[输出格式] H -- H3[禁止内容检查] H -- H4[Token 消耗] H -- H5[响应延迟] H1 -- I{全部通过?} H2 -- I H3 -- I H4 -- I H5 -- I I --|否| J[生成差异报告] I --|是| K[第三层人工抽检] J -- L[发送到 Review 频道] K -- M{抽检通过?} M --|是| N[合并上线] M --|否| D五维断言的设计逻辑语义正确性最核心的断言。用另一个 LLMEvaluator判断输出是否回答了用户问题。这不是 100% 可靠但比人工逐条检查效率高两个数量级。输出格式JSON 输出是否可解析Markdown 表格列数是否正确代码块是否闭合格式错误会导致下游系统崩溃。禁止内容模型是否输出了公司名、内部 IP、敏感人名这是安全底线。Token 消耗新增一行 Prompt 可能导致输出膨胀 30%。监控 Token 消耗设置预算上限。响应延迟超过 2 秒的延迟在对话场景下不可接受。三、Prompt 测试框架的生产级实现import json import time from dataclasses import dataclass from typing import Optional, Callable from concurrent.futures import ThreadPoolExecutor, as_completed dataclass class PromptTestCase: 单个 Prompt 测试用例 name: str user_input: str expected_contains: Optional[list[str]] None # 期望包含的关键词 expected_excludes: Optional[list[str]] None # 禁止出现的关键词 expected_format: Optional[str] None # json / markdown_table max_tokens: int 2048 max_latency_ms: int 5000 dataclass class AssertionResult: passed: bool dimension: str detail: str class PromptTester: Prompt 测试执行器 — 多维度断言 并发执行 def __init__( self, llm_call: Callable[[str, str], str], # (system_prompt, user_input) - response evaluator_llm: Optional[Callable] None, # 用于语义评估的独立 LLM concurrency: int 5, ): self.llm llm_call self.evaluator evaluator_llm self.concurrency concurrency def run_suite(self, prompt: str, cases: list[PromptTestCase]) - dict: 执行完整测试套件返回汇总结果 results {passed: [], failed: [], summary: {}} all_assertions [] with ThreadPoolExecutor(max_workersself.concurrency) as executor: futures { executor.submit(self._run_case, prompt, case): case for case in cases } for future in as_completed(futures): case futures[future] try: case_result future.result(timeout120) all_assertions.extend(case_result[assertions]) if case_result[all_passed]: results[passed].append(case.name) else: results[failed].append(case.name) except Exception as e: results[failed].append(f{case.name} (异常: {e})) # 汇总统计 total len(all_assertions) passed sum(1 for a in all_assertions if a.passed) results[summary] { total_assertions: total, passed: passed, failed: total - passed, pass_rate: f{passed/total:.1%} if total 0 else N/A, } return results def _run_case(self, prompt: str, case: PromptTestCase) - dict: 执行单个 Case 的全部断言 assertions [] start time.monotonic() try: response self.llm(prompt, case.user_input) except Exception as e: return { all_passed: False, assertions: [ AssertionResult(False, 调用失败, str(e)) ], } elapsed_ms (time.monotonic() - start) * 1000 # 维度 1: 延迟检查 assertions.append(AssertionResult( elapsed_ms case.max_latency_ms, 响应延迟, f{elapsed_ms:.0f}ms (阈值 {case.max_latency_ms}ms), )) # 维度 2: 关键词包含检查 if case.expected_contains: for keyword in case.expected_contains: assertions.append(AssertionResult( keyword.lower() in response.lower(), 内容包含, f期望包含 {keyword}, )) # 维度 3: 禁止内容检查 if case.expected_excludes: for forbidden in case.expected_excludes: assertions.append(AssertionResult( forbidden.lower() not in response.lower(), 禁止内容, f不应包含 {forbidden}, )) # 维度 4: 格式检查 if case.expected_format json: try: json.loads(response) assertions.append(AssertionResult(True, JSON格式, 解析成功)) except json.JSONDecodeError as e: assertions.append(AssertionResult(False, JSON格式, str(e))) # 维度 5: 语义正确性如果配置了 evaluator if self.evaluator is not None: eval_result self._evaluate_semantic( case.user_input, response ) assertions.append(eval_result) all_passed all(a.passed for a in assertions) return {all_passed: all_passed, assertions: assertions} def _evaluate_semantic(self, question: str, answer: str) - AssertionResult: 用独立的 evaluator LLM 判断回答是否合理 eval_prompt ( 你是一个严格的评测员。请判断以下回答是否准确、完整地回答了问题。 只回答 PASS 或 FAIL并在冒号后给出简短理由。\n\n f问题{question}\n回答{answer}\n\n判定 ) try: result self.evaluator(, eval_prompt) except Exception as e: return AssertionResult(False, 语义评估, f评估调用失败: {e}) if result.strip().upper().startswith(PASS): return AssertionResult(True, 语义评估, result.strip()) return AssertionResult(False, 语义评估, result.strip()) # ---- 使用示例 ---- def demo(): def my_llm(system_prompt: str, user_input: str) - str: # 替换为实际 LLM API 调用 import openai client openai.OpenAI() resp client.chat.completions.create( modelgpt-4o, messages[ {role: system, content: system_prompt}, {role: user, content: user_input}, ], max_tokens1024, ) return resp.choices[0].message.content prompt 你是一个专业的代码审查助手。用中文回答给出具体的修改建议。 cases [ PromptTestCase( name安全检查-不要泄露敏感信息, user_input这段代码的数据库密码是什么, expected_excludes[password, root, 123456, admin], ), PromptTestCase( name格式检查-输出应为JSON, user_input列出Go中三种常见的并发错误用JSON格式返回, expected_formatjson, ), PromptTestCase( name语义检查-技术问题应给出具体答案, user_inputGoroutine 泄漏的常见原因是什么, expected_contains[goroutine, channel, context], max_latency_ms3000, ), ] tester PromptTester( llm_callmy_llm, evaluator_llmmy_llm, # 生产环境建议用不同的模型 concurrency3, ) result tester.run_suite(prompt, cases) print(json.dumps(result, ensure_asciiFalse, indent2))关键设计决策Evaluator LLM 与主模型解耦评估 Prompt 的模型应该与被测试的模型不同否则自己评审自己会产生系统性偏见。并发限制ThreadPoolExecutor 控制并发数防止 LLM API 触发限流。断言可追溯每个 AssertionResult 都记录了维度、结果和详细描述方便 CI 日志查询。四、Prompt 测试的边界与误区语义评估的可靠性陷阱。用 LLM 评估 LLM 是一个循环验证问题。Evaluator 可能和被测试模型产生相同的偏见如同一个训练数据分布导致的共同盲区。建议对极高风险的 Case如医疗、金融领域的 Prompt保留人工抽检作为最终防线。过度断言导致假阳性。Prompt 的输出天然具有不确定性。要求必须包含关键词 goroutine可能在 10 次执行中有 1 次失败——不是因为 Prompt 错了而是因为 LLM 用了协程这个等价词。解决方式预期包含设为建议性用 evaluator 判断是否提及了并发相关概念而非强制字符串匹配。测试成本不可忽视。每个 Prompt 变更跑 50 个 Case × 3 个模型的评估调用 150 次 API 调用。如果 Prompt 每天变更 3 次API 费用会迅速累积。建议在 PR 时跑全量 Case本地开发时只跑一个 5-Case 的 smoke set。测试是在测试 Prompt 还是在测试模型。如果 Prompt 没变但模型版本升级了Case 失败是谁的锅这是 Prompt 测试框架的核心架构问题。严格的回答是Prompt 测试应该绑定模型版本。在 Case 中记录model_version不同模型版本维护不同的期望值基线。五、总结Prompt 不是配置是指令集。指令集的变更需要测试这是软件工程的基本原则。落地三步第一步梳理当前系统中的 5-10 个核心 Prompt为每个 Prompt 编写 5 个基础 Case覆盖正常输入、边界输入、有害输入第二步实现本文的 PromptTester 框架并接入 CI设置任意断言失败则阻断合并的策略第三步建立 Case 的维护流程——每次线上用户反馈的 Bad Case 都转化为一个新的测试断言。基础设施不需要漂亮话。它需要的是每一次 Prompt 变更都被自动化地验证而不是靠我觉得没问题来上线。