LLM 应用开发有一个令人头疼的特点:你写的不是确定性逻辑,而是在和一个"每次回答都可能不同"的系统打交道。传统的软件测试套路——assert 某个输出等于某个期望值——在这里几乎不管用。
但这不意味着 LLM 应用就不能测试。恰恰相反,越是非确定性系统,越需要严格的测试体系。本文从工程角度出发,梳理 LLM 应用从单元测试到端到端测试的完整方案。—## 为什么 LLM 应用更难测试先诊断问题所在。传统软件测试的核心假设:给定相同输入,系统产生相同输出,因此可以断言。LLM 应用的现实:- 相同 prompt,不同 temperature 会给出不同回答- 模型版本升级,相同 prompt 的行为可能改变- 长对话中早期内容会影响后续回答- 工具调用链路非常长,中间任何一步都可能失败这意味着 LLM 测试不能直接断言输出,而要断言输出的属性:格式是否正确?关键信息是否存在?逻辑是否一致?有无明显错误?—## 测试分层架构和普通软件一样,LLM 应用的测试也分层:端到端测试(E2E) ↑ 少量,覆盖核心用户场景集成测试 ↑ 中等数量,测试模块间交互单元测试 ↑ 大量,测试独立函数和组件区别在于:LLM 的调用成本高、速度慢,所以测试策略要最小化真实 LLM 调用。—## 第一层:单元测试单元测试应尽量 Mock 掉 LLM 调用,只测试你自己写的逻辑。### 1.1 Prompt 构建函数的测试python# prompt_builder.pydef build_rag_prompt(question: str, contexts: list[str]) -> str: context_str = "\n\n".join(f"[{i+1}] {c}" for i, c in enumerate(contexts)) return f"""你是一个专业助手。请根据以下资料回答问题。## 参考资料{context_str}## 问题{question}## 回答要求- 只使用参考资料中的信息- 如果资料不足以回答,请明确说明- 回答要简洁准确"""# test_prompt_builder.pydef test_build_rag_prompt_contains_question(): question = "什么是向量数据库?" contexts = ["向量数据库是专门存储向量的数据库系统"] prompt = build_rag_prompt(question, contexts) assert question in prompt assert contexts[0] in prompt assert "[1]" in prompt # 上下文有序编号def test_build_rag_prompt_multiple_contexts(): question = "测试" contexts = ["上下文1", "上下文2", "上下文3"] prompt = build_rag_prompt(question, contexts) assert "[1]" in prompt assert "[2]" in prompt assert "[3]" in promptdef test_build_rag_prompt_empty_contexts(): question = "测试" prompt = build_rag_prompt(question, []) assert question in prompt # 应该能优雅处理空上下文,不报错### 1.2 输出解析器的测试这是最值得单测的部分——LLM 返回的文本总是各种格式,解析器必须足够健壮:python# output_parser.pyimport re, jsonfrom dataclasses import dataclassfrom typing import Optional@dataclassclass ExtractedAction: tool_name: str parameters: dict reasoning: Optional[str] = Nonedef parse_tool_call(llm_output: str) -> Optional[ExtractedAction]: """从 LLM 输出中解析工具调用""" # 尝试提取 JSON 代码块 json_match = re.search(r'json\s*({.?})\s', llm_output, re.DOTALL) if json_match: try: data = json.loads(json_match.group(1)) return ExtractedAction( tool_name=data['tool'], parameters=data.get('params', {}), reasoning=data.get('reasoning') ) except (json.JSONDecodeError, KeyError): pass # 降级:尝试直接解析 JSON try: data = json.loads(llm_output.strip()) return ExtractedAction( tool_name=data['tool'], parameters=data.get('params', {}) ) except: return None# test_output_parser.pydef test_parse_tool_call_from_code_block(): output = """我需要查询天气。json{“tool”: “get_weather”, “params”: {“city”: “北京”}, “reasoning”: “用户询问天气”}""" result = parse_tool_call(output) assert result is not None assert result.tool_name == "get_weather" assert result.parameters == {"city": "北京"} assert result.reasoning == "用户询问天气"def test_parse_tool_call_plain_json(): output = '{"tool": "search", "params": {"query": "AI新闻"}}' result = parse_tool_call(output) assert result is not None assert result.tool_name == "search"def test_parse_tool_call_malformed(): output = "我不确定应该用什么工具" result = parse_tool_call(output) assert result is None # 应该优雅失败def test_parse_tool_call_missing_tool_key(): output = 'json\n{“action”: “search”}\n' result = parse_tool_call(output) assert result is None### 1.3 Mock LLM 的正确姿势python# 使用 pytest-mock 或 unittest.mockfrom unittest.mock import AsyncMock, patchimport pytest@pytest.mark.asyncioasync def test_rag_pipeline_with_mock(): """测试 RAG 流水线,Mock 掉 LLM 和向量数据库""" mock_llm_response = "向量数据库是专门用于存储和检索高维向量的数据库系统。" with patch('your_app.llm_client.acomplete', new_callable=AsyncMock) as mock_llm: mock_llm.return_value = mock_llm_response with patch('your_app.vector_store.search') as mock_search: mock_search.return_value = [ {"content": "向量数据库存储向量", "score": 0.95}, {"content": "常见产品有Qdrant、Pinecone", "score": 0.88}, ] from your_app.rag import answer_question result = await answer_question("什么是向量数据库?") # 验证 LLM 被调用,且 prompt 包含检索到的上下文 mock_llm.assert_called_once() call_args = mock_llm.call_args[0][0] # 取 prompt assert "向量数据库存储向量" in call_args—## 第二层:集成测试集成测试允许真实调用 LLM,但要控制数量和成本。### 2.1 使用便宜的小模型测试逻辑正确性python# conftest.pyimport pytestimport os@pytest.fixture(scope="session")def cheap_llm(): """集成测试用便宜的小模型""" from openai import AsyncOpenAI client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY")) async def complete(prompt: str) -> str: resp = await client.chat.completions.create( model="gpt-4o-mini", # 用最便宜的模型 messages=[{"role": "user", "content": prompt}], temperature=0, # 固定 temperature=0 提高确定性 max_tokens=500, ) return resp.choices[0].message.content return complete# test_integration.py@pytest.mark.integration # 标记为集成测试,可选择性运行@pytest.mark.asyncioasync def test_structured_output_format(cheap_llm): """测试 LLM 能否稳定输出 JSON 格式""" prompt = """分析以下代码的问题,以 JSON 格式返回:{"issues": [{"type": "bug|style|performance", "description": "...", "line": 行号}]}代码:def divide(a, b): return a / b""" output = await cheap_llm(prompt) # 验证格式 import json, re json_match = re.search(r'\{.*\}', output, re.DOTALL) assert json_match, f"输出不包含 JSON: {output}" data = json.loads(json_match.group(0)) assert "issues" in data assert isinstance(data["issues"], list) assert len(data["issues"]) > 0 # 应该发现除零问题### 2.2 关键词断言法python@pytest.mark.integration@pytest.mark.asyncioasync def test_rag_answer_contains_key_info(cheap_llm, real_vector_store): """测试 RAG 回答包含关键信息""" # 预先在向量库中插入测试数据 await real_vector_store.upsert([ {"id": "test-1", "text": "Qdrant 是用 Rust 编写的高性能向量数据库"}, {"id": "test-2", "text": "Qdrant 支持过滤条件和负载字段存储"}, ]) from your_app.rag import answer_question answer = await answer_question("Qdrant 是什么语言写的?", llm=cheap_llm) # 不要 assert 完整答案,而是 assert 关键信息存在 assert "rust" in answer.lower() or "Rust" in answer### 2.3 工具调用链路测试python@pytest.mark.integration@pytest.mark.asyncioasync def test_agent_tool_selection(): """测试 Agent 在不同问题下选择正确工具""" from your_app.agent import ReactAgent agent = ReactAgent( tools=["search_web", "calculate", "read_file"], llm_model="gpt-4o-mini", max_steps=3, ) # 测试计算问题 → 应该调用 calculate result = await agent.run("123 * 456 等于多少?") assert "calculate" in result.tools_used assert "56088" in result.final_answer # 测试文件问题 → 应该调用 read_file result = await agent.run("读取 test.txt 文件的内容") assert "read_file" in result.tools_used—## 第三层:端到端测试E2E 测试模拟真实用户场景,成本最高,数量最少。### 3.1 Golden Dataset 测试维护一个"黄金数据集"——预先标注好的问答对,定期回归:python# golden_dataset.json# [# {"input": "什么是RAG?", "expected_keywords": ["检索", "增强", "生成"], "should_not_contain": ["我不知道"]},# {"input": "如何部署vLLM?", "expected_keywords": ["docker", "GPU", "启动"], "min_length": 200}# ]import jsonimport pytest@pytest.mark.e2e@pytest.mark.asyncioasync def test_golden_dataset(): with open("tests/golden_dataset.json") as f: cases = json.load(f) from your_app.chat import ChatBot bot = ChatBot() results = [] for case in cases: answer = await bot.chat(case["input"]) # 检查必须包含的关键词 kw_pass = all( kw.lower() in answer.lower() for kw in case.get("expected_keywords", []) ) # 检查不应包含的内容 forbidden_pass = not any( bad.lower() in answer.lower() for bad in case.get("should_not_contain", []) ) # 检查最短长度 length_pass = len(answer) >= case.get("min_length", 0) results.append({ "input": case["input"], "pass": kw_pass and forbidden_pass and length_pass, "answer_preview": answer[:100], }) pass_rate = sum(1 for r in results if r["pass"]) / len(results) print(f"\nGolden Dataset 通过率: {pass_rate:.1%}") # 容忍一定失败率(LLM 的非确定性) assert pass_rate >= 0.85, f"通过率低于 85%: {[r for r in results if not r['pass']]}"### 3.2 LLM-as-Judge 评估用另一个 LLM 来评判输出质量:pythonasync def llm_judge(question: str, answer: str, criteria: str) -> dict: """用 LLM 评判答案质量""" judge_prompt = f"""你是一个评估 AI 回答质量的专业评审。问题:{question}AI 回答:{answer}评估标准:{criteria}请以 JSON 格式返回评估结果:{{ "score": 1-5的整数, "pass": true或false, "reason": "评估理由(1-2句话)"}}""" from openai import AsyncOpenAI client = AsyncOpenAI() resp = await client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": judge_prompt}], temperature=0, response_format={"type": "json_object"}, ) return json.loads(resp.choices[0].message.content)@pytest.mark.e2e@pytest.mark.asyncioasync def test_answer_quality_with_llm_judge(): from your_app.chat import ChatBot bot = ChatBot() test_cases = [ { "question": "用Python实现一个简单的链表", "criteria": "答案应包含可运行的Python代码,有Node类定义,有基本操作(append, search)", }, { "question": "解释什么是注意力机制", "criteria": "解释应准确,提到Query/Key/Value,适合技术人员理解", }, ] for case in test_cases: answer = await bot.chat(case["question"]) judgment = await llm_judge(case["question"], answer, case["criteria"]) print(f"\n问题: {case['question']}") print(f"评分: {judgment['score']}/5 - {judgment['reason']}") assert judgment["pass"], f"回答质量不合格: {judgment['reason']}" assert judgment["score"] >= 3, f"评分过低: {judgment['score']}"—## CI/CD 集成在 GitHub Actions 中分层运行测试:yaml# .github/workflows/test.ymlname: LLM Application Testson: [push, pull_request]jobs: unit-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: {python-version: '3.11'} - run: pip install -r requirements.txt - run: pytest tests/ -m "not integration and not e2e" -v # 单元测试:不需要 API key,快速,便宜 integration-tests: runs-on: ubuntu-latest needs: unit-tests if: github.event_name == 'push' && github.ref == 'refs/heads/main' env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: {python-version: '3.11'} - run: pip install -r requirements.txt - run: pytest tests/ -m "integration" -v --timeout=60 # 集成测试:只在 main 分支运行,有成本预算 e2e-tests: runs-on: ubuntu-latest if: github.event_name == 'schedule' # 只在定时任务中运行 env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} steps: - uses: actions/checkout@v3 - run: pip install -r requirements.txt - run: pytest tests/ -m "e2e" -v --timeout=120—## 测试工具推荐| 工具 | 用途 | 特点 ||------|------|------|| pytest | 测试框架基础 | Python 标准,必备 || pytest-asyncio | 异步测试支持 | LLM 调用都是异步的 || pytest-mock | Mock 工具 | 单元测试隔离 LLM || deepeval | LLM 评估框架 | 专门为 LLM 应用设计,有多种内置评估指标 || Ragas | RAG 评估 | 评估检索质量、答案忠实度等 || langsmith | 调试与追踪 | 生产环境 LLM 调用的完整记录 || promptfoo | Prompt 测试 | YAML 定义测试用例,支持多模型对比 |—## 实用建议1. 先测解析器,再测 LLM最容易写也最有价值的单元测试是输入/输出解析器。LLM 输出的格式化处理往往是 Bug 的高发区。2. 用 temperature=0 提高测试稳定性在测试中固定 temperature=0,虽然仍有少量随机性,但大幅提升断言通过率。3. 维护 Snapshot 测试对于关键 Prompt,保存一份"快照"输出,版本升级时对比是否有明显偏差。4. 监控生产数据,回流测试集把生产环境中用户反馈"差评"的对话,加入黄金数据集,形成持续改进闭环。5. 为 LLM 测试设预算估算每次 CI 运行的 Token 消耗,在测试配置中设置 mock 优先、真实调用次数上限,避免成本失控。—## 总结LLM 应用测试不是不可能,而是需要换一套思路:- 单元测试:Mock LLM,专注测试自己的代码(解析器、构建器、工具函数)- 集成测试:用小模型+低 temperature,断言关键属性而非完整输出- E2E 测试:维护黄金数据集,用 LLM-as-Judge 评估语义质量- CI 集成:分层运行,控制成本,单测跑每次提交,E2E 只在定时任务中跑测试不能消灭 LLM 应用的不确定性,但能把它控制在可接受的范围内。这才是工程化的核心目标。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐