跳到主要内容

LLM 本地开发循环:在不耗尽 API 预算的情况下实现快速迭代

· 阅读需 12 分钟
Tian Pan
Software Engineer

大多数构建 LLM 应用的团队在第三周左右都会发现同样的问题:每次有人运行测试套件时,它都会发起实时 API 调用,消耗真金白银,耗时 30 多秒,且每次运行返回的结果都不尽相同。在原型阶段感觉良好的“直接调用 API”方法,现在变成了迭代速度的沉重负担——而且是账单上的一项重要支出。一个工程团队审计了他们每月的 API 支出,发现 2,847 美元中有 1,240 美元(43%)是由于开发和测试流量不必要地访问实时端点而产生的纯粹浪费。

解决方案不是停止测试,而是从一开始就构建正确的开发循环——让快速路径既便宜又具有确定性,而将慢速路径(真实的 API 调用)留给真正需要的时刻。

为什么内部循环会崩溃

当你开始一个 LLM 项目时,你会通过运行应用程序并观察输出来进行测试。这行得通。但随着代码库的增长,团队成员的增加,以及有人配置了 CI 流水线,突然之间,每个 Pull Request 都会触发数十个真实的 API 调用。成本不断累积。不稳定的测试失败(同样的提示词,不同的输出)开始阻塞合并。工程师们添加了 skip_in_ci 标志来规避这个问题,这意味着测试根本不再运行。

根本原因在于团队将 LLM 调用视为数据库查询——即在开发过程中应始终实时访问的外部 I/O。但 LLM 调用具有一些特性,使得这种做法异常昂贵:

  • 高延迟:单个 GPT-4 调用可能耗时 10–30 秒。一个包含 20 个步骤的 Agent 测试需要几分钟。
  • 每次运行的成本可变:输出 Token 的成本是输入 Token 的 3–6 倍。生成冗长输出的测试会按比例消耗预算。
  • 非确定性:Temperature > 0 意味着同一个测试可能会根据模型当天的“心情”而通过或失败,导致 CI 不可靠。
  • CI 没有免费层级:不像你可以在 Docker 中启动本地数据库,每一次测试运行都会在你的 OpenAI 或 Anthropic 账户中产生账单。

解决方法是对待 LLM API 调用时,采用对待任何昂贵外部依赖项时相同的纪律:捕获一次你关注的行为,之后以廉价的方式进行回放。

录制-回放:一次捕获,永久测试

录制-回放(Record-replay)模式是 LLM 测试基础设施最接近通用的解决方案。这个概念借鉴自 HTTP Mock 库:测试第一次在“录制”模式下运行时,它会发起真实的 API 调用并将响应持久化到磁盘。在后续以“回放”模式运行时,它会从磁盘读取而不是调用网络。

对于 Python 项目,pytest-recording(基于 VCR.py 构建)是最符合人体工程学的实现。你使用 @pytest.mark.vcr 装饰测试,第一次运行会捕获交互录制文件(cassettes)到签入存储库的 YAML 文件中。CI 始终以回放模式运行——不需要 API 密钥,执行只需几毫秒,输出在字节级完全一致。

@pytest.mark.vcr
def test_classify_support_ticket():
result = classify_ticket("My order hasn't arrived in two weeks")
assert result.category == "shipping"
assert result.priority == "high"

对于基于 LangChain 的应用,vcr-langchain 将此扩展到了整个链的执行,而不仅仅是单个 HTTP 调用。对于使用 BAML 进行结构化输出的团队,BAML VCR 提供了具有类型感知存储的相同模式。

关键的实现细节:录制文件(cassettes)应与代码一起提交到存储库。当你特意更改提示词或模型时,删除受影响的录制文件,在录制模式下运行一次以重新生成,在 PR 差异中审查新的录制文件,然后提交。录制文件的差异变成了证明你的更改产生了预期行为转变的具体工件——而不仅仅是主观的“我运行的时候看起来是对的”。

用于单元测试的确定性 Fixtures

录制-回放适用于练习完整调用路径的集成级测试。但它仍然将你的单元测试与真实 API 响应的形式耦合在一起。为了测试封装 LLM 调用的逻辑——例如重试处理、输出解析、上下文窗口管理、错误升级——你需要更快速、更受控的方法。

确定性 Fixtures 是测试基础设施针对匹配输入返回的预设响应。与录制-回放不同,Fixtures 是手动编写且显式的。你精确定义“模型”针对给定提示词返回的内容,然后你的应用逻辑针对该内容运行。

llmock 这样的工具更进一步,它运行一个模拟 OpenAI 或 Anthropic API 的真实 HTTP 服务器。你的应用程序通过环境变量指向 http://localhost:8080 而不是 api.openai.com。应用程序代码无需任何改动——可以这么说,你只是重定向了 DNS。Fixture 响应在 JSON 中配置:

{
"matchers": [{"contains": "classify this ticket"}],
"response": {"category": "shipping", "priority": "high"}
}

这种方法比进程内补丁(in-process patching)更可靠,因为它在 HTTP 级别进行拦截。任何发起 API 调用的子进程或后台线程也会被拦截,而无需感知你的测试基础设施。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates