用 DeepSeek、LangChain 和 pytest 搭建第一个可测试的 LLM 应用
本文是项目的第一阶段主要完成以下内容调用 DeepSeek 兼容接口跑通基础 LLM 链路将模型调用封装成可测试的LLMClient通过依赖注入隔离真实模型使用 Fake LLM 编写不访问网络的单元测试覆盖正常返回、模型超时和非法输入实践一次完整的 TDD 红灯—绿灯—重构过程。本文暂不涉及 Agent 工具调用后续文章再继续搭建真正的 Tool Agent。一、为什么“模型能回答”不等于“模型应用可测试”最初的模型调用通常类似下面这样fromlangchain_openaiimportChatOpenAI llmChatOpenAI(...)responsellm.invoke(付航出生在哪一年)print(response.content)这段代码能帮助我们快速确认 API Key、模型地址和网络是否正常但它有几个明显问题模型对象与业务逻辑紧密耦合测试时难以替换每次运行都会访问真实网络并消耗 Token导入模块时如果直接执行调用会将该模块的顶层代码一起执行产生预期之外的效果很难稳定复现超时等外部服务异常鉴权失败、限流和服务不可用等场景也需要在后续通过测试替身或故障注入进行验证。测试代码应该尽可能做到快速、稳定、可重复。为此需要先把“模型创建”和“模型使用”拆开。二、项目环境与目录结构本文使用的主要环境如下Python 3.11 pytest python-dotenv langchain-openai当前目录结构knowledge-agent-quality/ ├── app/ │ └── llm_client.py ├── tests/ │ └── test_llm.py ├── .env ├── .gitignore └── main.py安装基础依赖python-mpipinstalllangchain-openai python-dotenv pytest建议为项目创建独立的虚拟环境或 Conda 环境避免不同项目之间发生依赖污染。三、安全管理 API Key在项目根目录创建.envDEEPSEEK_API_KEY替换为自己的API_Key不要把真实密钥写进 Python 代码也不要提交到 Git 仓库。.gitignore至少应包含.env __pycache__/ *.py[cod] .pytest_cache/ .vscode/ .venv/ venv/ htmlcov/ .coverage reports/提交前可以执行gitstatus--shortgitdiff--cached重点确认.env和真实 API Key 没有进入暂存区。四、使用依赖注入设计可测试的 LLMClient4.1 什么是依赖注入如果LLMClient在内部直接创建ChatOpenAI它就只能依赖真实模型。依赖注入的思路是模型由外部创建再传给LLMClient。正式运行ChatOpenAI → LLMClient 单元测试FakeLLM → LLMClientLLMClient只要求传入对象具有invoke()方法不需要知道底层究竟是 DeepSeek、其他模型还是真实网络之外的测试替身。4.2 LLMClient 实现app/llm_client.pyclassLLMClient:def__init__(self,llm):self.llmllmdefchat(self,prompt:str)-str:ifnotpromptornotprompt.strip():raiseValueError(prompt不能为空)responseself.llm.invoke(prompt)returnresponse.content这个类只负责三件事接收外部模型依赖校验用户输入调用模型并提取content。这里没有加载.env也没有创建真实模型更没有在模块底部直接发起请求。因此导入该模块不会产生网络调用。4.3 为什么使用if not prompt or not prompt.strip()我们需要拦截以下无效输入None \t\nifnotpromptornotprompt.strip():当prompt是None或空字符串时左侧已经成立不会继续调用strip()当prompt有值时再使用strip()判断它是否只包含空白字符。这里的strip()只用于校验没有修改原始 prompt正常输入仍会原样传给模型。五、在程序入口中创建真实模型main.py负责配置和组装真实依赖importosfromdotenvimportload_dotenvfromlangchain_openaiimportChatOpenAIfromapp.llm_clientimportLLMClientdefmain():load_dotenv()api_keyos.getenv(DEEPSEEK_API_KEY)ifnotapi_key:raiseRuntimeError(DEEPSEEK_API_KEY未配置)chat_modelChatOpenAI(modeldeepseek-v4-flash,api_keyapi_key,base_urlhttps://api.deepseek.com,)clientLLMClient(chat_model)answerclient.chat(付航出生在哪一年)print(answer)if__name____main__:main()这里有两个重要设计。5.1 主动检查配置如果没有读取到 API Key程序主动抛出清晰异常raiseRuntimeError(DEEPSEEK_API_KEY未配置)与print()后直接退出相比抛出异常会让命令返回非零退出码CI 更容易识别失败。5.2 使用入口保护if__name____main__:main()只有直接运行python main.py时才会调用真实模型。其他模块导入main.py时不会自动发送请求。六、使用 Fake LLM 隔离真实模型单元测试的目标不是验证 DeepSeek 服务是否在线而是验证我们自己的代码逻辑。因此可以制作一个与真实模型拥有相同最小接口的 Fake LLMfromtypesimportSimpleNamespaceclassFakeLLM:def__init__(self):self.last_promptNonedefinvoke(self,prompt):self.last_promptpromptreturnSimpleNamespace(content2026年)真实模型与 Fake LLM 的共同接口是invoke(prompt) → 返回具有 content 属性的响应对象SimpleNamespace可以快速构造一个带content属性的假响应不需要引入真实模型响应类。七、第一条正常链路单元测试deftest_chat_passes_prompt_and_returns_content():fake_llmFakeLLM()clientLLMClient(fake_llm)resultclient.chat(测试问题)assertfake_llm.last_prompt测试问题assertresult2026年这条测试验证两个接口契约LLMClient将原始 prompt 正确传给底层模型LLMClient正确提取并返回响应中的content。测试完全不访问网络执行速度通常只有几毫秒也不会消耗 Token。八、模拟模型超时异常场景不应该依赖真实网络偶然失败。我们可以主动构造一个超时模型classTimeoutLLM:definvoke(self,prompt):raiseTimeoutError(模型调用超时)然后使用pytest.raises验证异常类型和消息deftest_chat_propagates_timeout_error():timeout_llmTimeoutLLM()clientLLMClient(timeout_llm)withpytest.raises(TimeoutError,match模型调用超时):client.chat(测试)这条测试固定了当前异常策略底层模型发生超时时LLMClient不吞掉异常而是向上传递给调用方。后续如果增加重试、统一异常封装或降级策略也可以基于这条测试继续演进。九、使用参数化覆盖空输入边界对于None、空字符串、空格、Tab 和换行符如果分别写五个测试函数会产生很多重复代码。pytest 参数化可以让一条测试使用多组数据运行pytest.mark.parametrize(invalid_prompt,[None,, ,\t,\n],)deftest_chat_rejects_empty_prompt(invalid_prompt):fake_llmFakeLLM()clientLLMClient(fake_llm)withpytest.raises(ValueError,matchprompt不能为空):client.chat(invalid_prompt)assertfake_llm.last_promptisNone最后一条断言非常重要assertfake_llm.last_promptisNone它不仅验证程序抛出了异常还验证无效输入在进入底层模型之前就被拦截避免无意义的网络请求和 Token 消耗。十、一次真实的 TDD 过程空输入校验采用了测试驱动开发的方式。10.1 Red先写失败测试最初的LLMClient没有输入校验。新增测试后pytest 报告Failed: DID NOT RAISE ValueError这个失败证明测试准确暴露了尚未实现的需求。10.2 Green增加最小实现首先加入ifnotprompt:raiseValueError(prompt不能为空)空字符串测试通过了但加入空格、Tab 和换行数据后测试再次失败。这说明if not prompt不能识别纯空白字符串。10.3 补充边界发现执行顺序问题一度尝试promptprompt.strip()加入None用例后出现AttributeError: NoneType object has no attribute strip最终实现调整为ifnotpromptornotprompt.strip():raiseValueError(prompt不能为空)最终所有输入边界测试通过。测试全部通过只能证明已经覆盖的场景通过并不代表没有遗漏场景。测试设计的价值不仅是验证代码还在于持续发现需求边界。十一、执行测试运行全部测试python-mpytest-v只运行 LLM 客户端测试python-mpytest tests/test_llm.py-v本阶段的 LLM 客户端测试包含1 条正常调用测试1 条超时异常测试5 组空输入参数化测试。共计 7 个测试用例。提交代码前还可以检查空白格式gitdiff--checkgitdiff--cached--check两条命令分别检查未暂存和已暂存改动中的行尾空格、多余空白行等常见问题。十二、测试分层哪些测试不应该混在一起当前实践中可以区分两类测试。单元测试使用 Fake LLM不访问网络验证自己的代码prompt 是否正确传递 content 是否正确返回 非法输入是否提前拦截 底层异常是否按约定传播特点是快速、稳定、无费用适合每次提交和 CI 回归。集成测试使用真实 API Key 和模型服务验证真实请求是否能够返回 环境变量是否正确 模型地址是否可用 鉴权是否成功集成测试依赖网络并产生费用不应该替代单元测试也不适合在每次本地修改后无条件执行。后续可以通过 pytest marker 将两类测试分开执行。十三、阶段总结这一阶段虽然还没有搭建完整 Agent但已经完成了一个可测试的 LLM 基础层真实模型调用 ↓ 模型创建与使用解耦 ↓ Fake LLM 替代真实网络 ↓ 正常、异常和边界测试 ↓ 形成可重复执行的测试基线对于 Agent 测试来说这一步的意义是建立底层可测性。否则未来加入 RAG、Tool 和 MCP 后一旦最终结果错误很难判断问题来自模型服务、Agent 决策、工具执行还是外围系统。下一阶段将开始构建最小 Tool Agent重点测试Agent 是否选择了正确工具工具参数是否正确工具调用次数是否符合预期无需工具的问题是否发生了错误调用工具异常时 Agent 如何提示或降级。