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


所有评论(0)