在LLM后端测试中,AI调用与业务逻辑耦合导致只能走真实API,拖慢测试且耗资。解决方案是将AI调用提取为独立依赖,通过接口注入实现可测试性。
一下午调试一个不稳定的接口,我把测试套件跑了四遍,等去看 OpenAI 的用量面板时,发现烧掉的 credits 比整周预算还多。其实什么都没坏——测试就这么跑着,每一个都在调用真实的模型。这时我才意识到:我做出来的东西能工作,但我根本没有做一个能低成本测试的东西。
如果你在 LLM 之上做后端密集的东西——FastAPI 服务、Flask 应用,或者别的什么——这个问题迟早会遇到。测试要么打真实的 API(慢、要花钱、模型响应不是 100% 确定性的所以偶尔会不稳定),要么干脆不测 AI 调用部分,上线后听天由命。两种都不好。
根本原因通常是结构性的,不是测试本身的问题。如果你的路由处理器做了类似"收请求 → 拼 prompt → 调用 OpenAI → 解析响应 → 返回"这样的事,那么要测试其中任何一步逻辑都必须真正调用 OpenAI,因为 AI 调用和其他所有东西缠绕在一起了。
解决办法和对付任何外部依赖一样:把它抽出来,单独设立边界。在我为自己的项目写的后端模板里,我把东西分成了三层——routers、services 和 schemas——目的就是让"调用模型"这部分只存在于一个地方,在测试时可以替换掉。
这个 service 层简化后大概长这样:
# services/ai_service.py
from openai import OpenAI
client = OpenAI()
def generate_summary(text: str) -> str:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Summarize the following text in one sentence."},
{"role": "user", "content": text},
],
)
return response.choices[0].message.content
而路由层只是调用它——它不知道也不关心底下正在发生一次 API 调用:
# routers/summary.py
from fastapi import APIRouter
from schemas.summary import SummaryRequest, SummaryResponse
from services.ai_service import generate_summary
router = APIRouter()
@router.post("/summarize", response_model=SummaryResponse)
def summarize(payload: SummaryRequest):
result = generate_summary(payload.text)
return SummaryResponse(summary=result)
不花一分钱测试
因为 generate_summary 是自己的可导入函数,我可以在测试里直接 mock 它,而不用走网络:
# test_summary.py
from unittest.mock import patch
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
@patch("routers.summary.generate_summary")
def test_summarize_endpoint(mock_generate):
mock_generate.return_value = "This is a mocked summary."
response = client.post("/summarize", json={"text": "Some long text here..."})
assert response.status_code == 200
assert response.json()["summary"] == "This is a mocked summary."
这个测试几毫秒跑完、不花一分钱、完全确定性——再也不用纠结"那个测试失败是因为有 bug 还是因为这次模型措辞不一样?"
这里有个容易踩的坑:注意 patch 的目标是 routers.summary.generate_summary,而不是 services.ai_service.generate_summary。因为路由里做的是 from services.ai_service import generate_summary,它持有自己的本地函数引用——patch 原始模块不会影响到它。用 unittest.mock 的经验法则:永远 patch 的是使用名称的地方,而不是定义名称的地方。
我还喜欢加上一两个测试,检查 AI 调用失败时会发生什么——超时、限速、畸形响应——因为这些才是线上真正会坏、却几乎没人测的东西:
@patch("routers.summary.generate_summary")
def test_summarize_handles_upstream_error(mock_generate):
mock_generate.side_effect = Exception("upstream timeout")
response = client.post("/summarize", json={"text": "..."})
assert response.status_code == 500
这些都不稀奇——就是对付任何第三方 API 会用的同一种 mock 模式。唯一的真正"技巧"是 disciplined 的分离:把模型调用放在自己的 service 函数里,把校验放在 Pydantic schema 里,让 router 保持轻薄。一旦这个边界存在,后续的测试(以及之后换 provider、加缓存、加重试)都会轻松很多。
这基本就是我每次开一个新 FastAPI + AI 项目时复用的骨架,所以我把它打包成了一个小启动模板——按上面那样分离的 routers/services/schemas、Pydantic 校验、从一开始就内置的一致错误处理。它故意不包含 auth 或数据库,因为那些通常是项目特定的,我宁可给你一个干净的范例,也不愿把我的想法强加给你。如果你觉得这能省点搭建时间,可以在这里获取。
我目前也接自由职业和全职的后端/全栈工作(Python、FastAPI、Flask、AI 集成)——如果你在招聘或认识这样的人,代码在 github.com/GerAle30。