把 LLM 评测写进 pytest:DeepEval 让每次改提示词都有据可查

做过提示词工程的人都懂这种痛:上周还好好的回复,今天改了一段系统提示,某个边角场景突然开始胡来,而你根本不知道。解法不是更小心,而是把评测变成自动化测试。本文用 DeepEval 把 LLM 评测写进 pytest,接进 CI。 一条 DeepEval 用例长什么样? DeepEval 把评测指标封装成断言:用一个样例,包含输入与期望输出,配上一个或多个指标,例如答案相关性、事实一致性、严格匹配。写起来和普通 pytest 几乎一样:定义样例列表,循环调用被测智能体,断言指标不低于阈值。逻辑上就是:对黄金集里的每条用例,跑一遍被测流程,再断言相关分数高于你设的红线。 黄金集该怎么一步步攒起来? 别一开始就建几百条。先从线上真实工单里挑三五十条「最容易出错」的典型问题,人工标注期望行为。每次线上翻车,就把这个案例补进黄金集——它就会变成一条永远不许再犯的回归用例。黄金集是长出来的,不是设计出来的。 把评测接进 CI 有多大成本? 评测要调模型,比普通单测慢且花钱。做法是:提交请求级别只跑子集做冒烟,合并到主干才跑全量。再给评测成本设个上限,避免半夜 CI 跑飞烧掉一大笔 API 费。 大模型评测分数该怎么正确看待? DeepEval 这类「用大模型评大模型」的指标不是绝对真理,它更像一个报警器:分数掉了说明有变化,值不值得修要人判断。别把它当成精确的质量分,要把它当成「改动有没有让事情变坏」的护栏。 指标一开始该选几个才合适? 一开始别把七八个指标全挂上,每加一个指标,评测成本和噪音都涨。先选两个最贴你业务的:答案相关性和事实一致性。跑稳了,再按需加。指标是手段不是目的,你要的是「改动没让事情变坏」这一条报警线,不是一张看着漂亮却没人看的仪表盘。 为什么提示词也需要回归测试? 提示词和智能体代码和软件一样需要回归测试。把几十条黄金用例用 DeepEval 写进 pytest,你就拥有了一张安全网——下次再改提示词,心里有底,而不是靠玄学。 去论坛讨论 这篇文章里的观点,欢迎到硅基AGI论坛讨论。按标题搜索:DeepEval回归

2026-10-09 · 1 min · 36 words · AI 实战派

提示词也能回归测试:用 promptfoo 把提示词改动纳入 CI

提示词工程有个隐性痛点:它一直被当作文档,而不是代码。改一句系统提示、换一个 few-shot 示例,往往是「我觉得更顺了」就上线,没人知道这次改动是不是把某个边缘问题改坏了。promptfoo 要补的就是这个洞——它把提示词连同一组测试用例,变成一个能在命令行重复跑、能接进 CI 的评测脚本。 它怎么工作 写一个配置文件,列三件事:要测哪些提示词变体、一组输入样例、以及判定标准(正则包含、JSON 字段校验、或让另一个模型打分)。然后跑一行命令:npx promptfoo eval,它把每个提示词在每个样例上都跑一遍,输出一张对照表。 再用 npx promptfoo view 打开本地界面,就能并排看:旧提示词和新提示词,在这 50 个用例上分别答成什么样。 接进 CI 的价值 把这一步挂进 GitHub Actions:每次有人改提示词文件,自动跑评测,失败率上升超过阈值就拦住合并。提示词从此有了「测试套」——改坏了能立刻发现,而不是靠用户反馈。 判定标准别贪多。一开始只测硬指标:输出能不能被你的下游 JSON schema 解析、关键事实有没有出现。软的「答得好不好」交给人工抽检,否则你会陷入给模型打分的模型打分。 判断 2026 年做 AI 应用,提示词已经是生产代码的一部分。既然是代码,就该享受代码的待遇:版本控制、评审、回归测试。promptfoo 这类工具把「凭感觉改提示词」变成「带着测试改提示词」,这是从玩具走向产品的必经一步。 补充一个实践点:测试用例要从真实失败里积累。每次用户反馈答得不好,就把那个问题和期望答案补进 promptfoo 的用例库。半年下来,你的测试套就是一份「我们踩过的坑清单」,提示词改动再也不会重蹈覆辙,新成员接手也能一眼看懂这条提示词经历过什么。 提示词是生产代码,就该享受版本控制和回归测试的待遇。 去论坛讨论 这篇文章里的观点,欢迎到硅基AGI论坛讨论。按标题搜索:提示词测试

2026-10-08 · 1 min · 40 words · AI 实战派
回归测试

LLM回归测试策略:确保更新不引入退化

引言 LLM应用的一个独特挑战是:即使代码没变,模型提供商更新模型版本也可能导致输出变化。同样,提示的微小修改可能在某些场景下引入退化。2026年,LLM回归测试已经成为AI应用的标配。本文将系统介绍回归测试策略。 什么是LLM回归测试 与传统回归测试的区别 维度 传统软件 LLM应用 变化来源 代码修改 代码+模型版本+提示修改 输出确定性 确定性 不确定(同输入可能不同输出) 测试方法 精确匹配 语义匹配/范围匹配 回归原因 代码bug 模型行为变化 LLM回归的场景 模型升级:从GPT-4升级到GPT-5 提示修改:优化提示时可能影响其他场景 配置变更:调整temperature、max_tokens等 依赖更新:嵌入模型、向量数据库等更新 模型版本:提供商静默更新模型 回归测试策略 策略一:黄金测试集 维护一个经过验证的"黄金"测试集: class GoldenTestSuite: def __init__(self): self.golden_cases = [ { "id": "gold_001", "input": "解释什么是递归", "expected_keywords": ["函数", "自身", "终止条件"], "expected_min_length": 100, "expected_max_length": 500, "must_not_contain": ["错误代码示例"], "category": "concept_explanation", "last_verified": "2026-06-15", "verified_by": "expert_001" }, # ... 更多黄金测试用例 ] def run(self, model_config): results = [] for case in self.golden_cases: response = call_llm(case["input"], **model_config) result = self.verify(response, case) results.append(result) return results def verify(self, response, case): checks = { "keywords_present": all(kw in response for kw in case["expected_keywords"]), "length_ok": case["expected_min_length"] <= len(response) <= case["expected_max_length"], "no_forbidden": not any(bad in response for bad in case.get("must_not_contain", [])) } checks["passed"] = all(checks.values()) return {"case_id": case["id"], "response": response, "checks": checks} 策略二:语义回归检测 不只检查精确匹配,还检查语义是否一致: def semantic_regression_check(old_response, new_response, threshold=0.85): """ 检查新旧响应的语义相似度 """ # 使用嵌入模型计算语义相似度 old_embedding = embed(old_response) new_embedding = embed(new_response) similarity = cosine_similarity(old_embedding, new_embedding) if similarity < threshold: return { "status": "potential_regression", "similarity": similarity, "old_response": old_response, "new_response": new_response } return {"status": "ok", "similarity": similarity} 策略三:多维回归检测 def multi_dimensional_regression(old_outputs, new_outputs): """ 多维度回归检测 """ dimensions = { "format": check_format_consistency, # 格式一致性 "length": check_length_distribution, # 长度分布 "sentiment": check_sentiment_shift, # 情感偏移 "quality": check_quality_degradation, # 质量退化 "safety": check_safety_regression, # 安全性退化 } results = {} for dim, checker in dimensions.items(): results[dim] = checker(old_outputs, new_outputs) return results 策略四:分布回归检测 检查输出分布是否发生变化: ...

2026-07-02 · 3 min · 618 words · 硅基 AGI 探索者
Agent回放测试:确定性验证与回归测试

Agent回放测试:确定性验证与回归测试

引言 LLM的本质是非确定性的——同样的输入可能产生不同的输出。这给Agent系统的测试带来了根本性挑战:如何验证Agent的"正确性"?如何检测"回归"?回放测试(Replay Testing)通过将真实请求重新执行并与历史结果对比,为Agent系统提供了一种实用且有效的验证手段。 2026年,回放测试已成为Agent系统质量保障的核心手段。本文系统介绍如何设计和实施Agent回放测试体系。 回放测试原理 ┌──────────────────────────────────────────────────────┐ │ 回放测试流程 │ │ │ │ 生产流量 ──▶ 录制 (Record) │ │ │ │ │ ▼ │ │ 测试数据集 (Test Set) │ │ │ │ │ ▼ │ │ 新版本 ──▶ 回放 (Replay) ──▶ 结果对比 ──▶ 报告 │ │ │ │ │ │ ▼ ▼ │ │ 差异分析 质量门禁 │ │ │ └──────────────────────────────────────────────────────┘ 录制生产流量 class ProductionRecorder: """生产流量录制器""" def __init__(self, storage_client): self.storage = storage_client self.sampling_rate = 0.01 # 1%采样 async def record_request( self, session_id: str, request: dict, response: dict, metadata: dict ): """录制请求和响应""" import random if random.random() > self.sampling_rate: return recording = { "session_id": session_id, "timestamp": datetime.now().isoformat(), "request": { "input": request["input"], "context": request.get("context", {}), "config": request.get("config", {}), }, "response": { "output": response["output"], "tool_calls": response.get("tool_calls", []), "tokens_used": response.get("usage", {}), "latency_ms": response.get("latency_ms"), }, "metadata": { "model": metadata.get("model"), "prompt_version": metadata.get("prompt_version"), "quality_score": metadata.get("quality_score"), "user_feedback": metadata.get("user_feedback"), }, "recording_version": "1.0" } # 存储到对象存储 key = f"recordings/{datetime.now().strftime('%Y%m%d')}/{session_id}.json" await self.storage.put(key, json.dumps(recording, ensure_ascii=False)) 回放执行 class ReplayExecutor: """回放执行器""" async def replay_test_set( self, test_set: list, config: dict ) -> dict: """回放测试集""" results = [] for i, test_case in enumerate(test_set): logger.info(f"Replaying test case {i+1}/{len(test_set)}") try: result = await self._replay_single(test_case, config) results.append(result) except Exception as e: logger.error(f"Replay failed for case {i+1}: {e}") results.append({ "test_case_id": test_case.get("id"), "status": "error", "error": str(e) }) # 汇总分析 return self._analyze_results(results) async def _replay_single( self, test_case: dict, config: dict ) -> dict: """回放单个测试用例""" # 使用录制时的配置(或覆盖) replay_config = {**test_case["request"]["config"], **config} # 执行请求 start = time.monotonic() response = await self.agent.process( test_case["request"]["input"], context=test_case["request"]["context"], config=replay_config ) latency_ms = (time.monotonic() - start) * 1000 # 对比结果 comparison = self._compare_responses( expected=test_case["response"], actual=response ) return { "test_case_id": test_case.get("id"), "status": "pass" if comparison["passed"] else "fail", "comparison": comparison, "actual_response": response, "latency_ms": latency_ms, } def _compare_responses(self, expected: dict, actual: dict) -> dict: """对比预期和实际响应""" comparison = { "passed": True, "checks": [] } # 检查1:工具调用是否相同 expected_tools = [t["name"] for t in expected.get("tool_calls", [])] actual_tools = [t["name"] for t in actual.get("tool_calls", [])] tools_match = set(expected_tools) == set(actual_tools) comparison["checks"].append({ "name": "tool_calls_match", "passed": tools_match, "expected": expected_tools, "actual": actual_tools }) if not tools_match: comparison["passed"] = False # 检查2:响应质量是否相似(语义相似度) similarity = self._compute_similarity( expected["output"], actual["output"] ) quality_ok = similarity > 0.85 # 85%相似度阈值 comparison["checks"].append({ "name": "response_quality", "passed": quality_ok, "similarity": similarity, "threshold": 0.85 }) if not quality_ok: comparison["passed"] = False # 检查3:Token消耗是否在合理范围 token_ratio = actual.get("usage", {}).get("total_tokens", 0) / \ max(expected.get("tokens_used", {}).get("total_tokens", 1), 1) token_ok = 0.5 < token_ratio < 2.0 # Token消耗在0.5x-2x之间 comparison["checks"].append({ "name": "token_consumption", "passed": token_ok, "ratio": token_ratio, "threshold": "0.5-2.0" }) return comparison 快照测试 class SnapshotTesting: """快照测试——保存首次运行结果为快照,后续运行对比快照""" def __init__(self, snapshot_dir: str): self.snapshot_dir = snapshot_dir os.makedirs(snapshot_dir, exist_ok=True) async def run_with_snapshot( self, test_name: str, test_fn: callable, update_snapshot: bool = False ) -> dict: """运行快照测试""" snapshot_file = os.path.join( self.snapshot_dir, f"{test_name}.snapshot.json" ) # 执行测试 actual = await test_fn() if update_snapshot: # 更新快照 with open(snapshot_file, "w") as f: json.dump(actual, f, indent=2, ensure_ascii=False) return {"status": "snapshot_updated", "result": actual} # 对比快照 if not os.path.exists(snapshot_file): raise FileNotFoundError( f"Snapshot not found: {snapshot_file}. " f"Run with update_snapshot=True to create." ) with open(snapshot_file) as f: expected = json.load(f) diff = self._deep_diff(expected, actual) if diff: return { "status": "fail", "diff": diff, "expected": expected, "actual": actual } else: return {"status": "pass", "result": actual} def _deep_diff(self, expected, actual, path: str = "") -> list: """深度对比,返回差异列表""" diffs = [] if isinstance(expected, dict) and isinstance(actual, dict): for key in set(list(expected.keys()) + list(actual.keys())): new_path = f"{path}.{key}" if path else key if key not in actual: diffs.append(f"Missing in actual: {new_path}") elif key not in expected: diffs.append(f"Extra in actual: {new_path}") else: diffs.extend( self._deep_diff(expected[key], actual[key], new_path) ) elif isinstance(expected, list) and isinstance(actual, list): if len(expected) != len(actual): diffs.append( f"List length mismatch at {path}: " f"expected {len(expected)}, actual {len(actual)}" ) else: for i, (e, a) in enumerate(zip(expected, actual)): diffs.extend(self._deep_diff(e, a, f"{path}[{i}]")) else: # 标量对比(对LLM输出用模糊匹配) if self._is_llm_output(path): similarity = self._compute_similarity(str(expected), str(actual)) if similarity < 0.9: diffs.append( f"Semantic difference at {path}: " f"similarity={similarity:.2f}" ) else: if expected != actual: diffs.append( f"Value mismatch at {path}: " f"expected={expected}, actual={actual}" ) return diffs 回归测试框架 class RegressionTestSuite: """回归测试套件""" def __init__(self): self.test_cases = [] self.quality_thresholds = { "response_similarity": 0.85, "tool_call_accuracy": 0.95, "latency_increase_max": 1.2, # 延迟最多增加20% "token_increase_max": 1.3, # Token最多增加30% } def add_test_case(self, test_case: dict): """添加测试用例""" self.test_cases.append(test_case) async def run_regression_test(self, version: str) -> dict: """运行回归测试""" results = { "version": version, "total_cases": len(self.test_cases), "passed": 0, "failed": 0, "regressions": [], "improvements": [], } for test_case in self.test_cases: # 获取基线结果 baseline = await self._get_baseline(test_case["id"]) # 执行测试 actual = await self._execute_test(test_case) # 对比 comparison = self._compare_with_baseline(baseline, actual) if comparison["is_regression"]: results["failed"] += 1 results["regressions"].append({ "test_case": test_case["id"], "regression_type": comparison["regression_type"], "details": comparison["details"] }) elif comparison["is_improvement"]: results["improvements"].append({ "test_case": test_case["id"], "improvement_type": comparison["improvement_type"], }) else: results["passed"] += 1 return results def _compare_with_baseline(self, baseline: dict, actual: dict) -> dict: """与基线对比""" issues = [] # 质量回归 if actual["quality_score"] < baseline["quality_score"] * 0.95: issues.append({ "type": "quality_regression", "baseline": baseline["quality_score"], "actual": actual["quality_score"], }) # 延迟回归 latency_ratio = actual["latency_ms"] / baseline["latency_ms"] if latency_ratio > self.quality_thresholds["latency_increase_max"]: issues.append({ "type": "latency_regression", "baseline": baseline["latency_ms"], "actual": actual["latency_ms"], "ratio": latency_ratio, }) # Token回归 token_ratio = actual["tokens_used"] / baseline["tokens_used"] if token_ratio > self.quality_thresholds["token_increase_max"]: issues.append({ "type": "token_regression", "baseline": baseline["tokens_used"], "actual": actual["tokens_used"], "ratio": token_ratio, }) return { "is_regression": len(issues) > 0, "is_improvement": actual["quality_score"] > baseline["quality_score"] * 1.05, "issues": issues, } CI/CD集成 # .github/workflows/regression-test.yml name: Agent Regression Test on: pull_request: branches: [main] jobs: regression-test: runs-on: self-hosted steps: - uses: actions/checkout@v3 - name: Setup Test Environment run: | docker-compose -f docker-compose-test.yml up -d sleep 30 # 等待服务就绪 - name: Run Regression Tests id: regression run: | python -m pytest tests/regression/ \ --baseline=baseline.json \ --output=regression-report.json \ --junitxml=junit.xml - name: Check Quality Gate run: | python scripts/check_quality_gate.py \ --report=regression-report.json \ --max-regressions=5 \ --min-quality-score=0.85 - name: Upload Test Results if: always() uses: actions/upload-artifact@v3 with: name: regression-test-results path: | regression-report.json junit.xml - name: Comment PR if: always() uses: actions/github-script@v6 with: script: | const report = require('./regression-report.json'); const comment = ` ## Regression Test Results - Total: ${report.total_cases} - Passed: ${report.passed} - Failed: ${report.failed} - Regressions: ${report.regressions.length} ${report.regressions.length > 0 ? '⚠️ Regressions detected!' : '✅ No regressions'} `; github.rest.issues.createComment({ issue_number: context.issue.number, body: comment }); 总结 回放测试为Agent系统提供了一种实用的验证手段——通过录制生产流量并在新版本上回放,可以有效检测功能回归和质量下降。快照测试通过保存首次运行结果作为基准,简化了测试用例的创建。回归测试套件则系统性地验证新版本在质量、延迟、Token消耗等维度上是否出现退化。 ...

2026-06-30 · 5 min · 970 words · 硅基 AGI 探索者
🔗 硅基 AGI 论坛 · silicon-agi.com | 📡 RSS 订阅
鲁ICP备2026018361号