LLM 本地开发循环:在不耗尽 API 预算的情况下实现快速迭代
大多数构建 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 调用的子进程或后台线程也会被拦截,而无需感知你的测试基础设施。
这里的陷阱是过度依赖。确定 性 Fixtures 无法捕获真实模型在处理边界情况输入时的实际表现——例如过度自信的错误回答、意外的拒绝,或者破坏 JSON 解析器的格式怪癖。如果你的整个测试套件都针对 Fixtures 运行,那么你测试的是你的逻辑,而不是你的系统。当生产环境出现问题时,这种区别至关重要。
适配 LLM 应用的测试金字塔
传统的测试金字塔——大量的单元测试、较少的集成测试、极少的端到端测试——同样适用于 LLM 应用,但每一层的边界有所不同。
第一层 — 使用确定性固定数据 (Fixtures) 的单元测试。 这些测试你的应用代码:重试逻辑、结构化输出解析、上下文窗口截断、工具调度、错误处理。每个测试都在毫秒级运行,使用的是预设的响应。由于逻辑是确定的(即使 LLM 不是),这里的覆盖率应该很高。
第二层 — 使用缓存磁带 (Cassettes) 的集成测试。 这些运行完整的调用路径——真实的提示词、真实的输出模式、真实的工具调用——但针对的是录制的响应。它们验证你的提示词工程是否产生了预期的输出结构,你的智能体 (Agent) 循环是否正确处理了多轮对话,以及你的检索流水线是否按照你预期的方式组装了上下文。在初始录制之后,这些测试在 CI 中以接近单元测试的速度运行。
第三层 — 针对真实模型的实时冒烟测试。 少量真正调用 API 的测试,按计划运行(每天或发布前),绝不放在主 CI 路径中。它们的存在是为了捕捉模型漂移——即模型更新 改变了行为,而由于你的磁带是针对旧版本录制的,磁带无法检测到这些变化。
在实践中效果良好的比例:70-80% 第一层,15-25% 第二层,5% 或更少的第三层。节省资金和时间的秘诀在于保持第三层的小巧且有目的性,而不是让它侵占你的开发循环。
识别虚假信心陷阱
过度模拟 (Over-mocking) 是团队采用这种方法后常遇到的主要失败模式。这种失败很微妙:你的测试套件全是绿色,CI 在 10 秒内通过,一切看起来都在掌控之中——但测试的是一个不存在的模型,一个从不产生幻觉、从不格式错误、总是完全返回你指定内容的完美固定响应器。
固定数据可能误导你的具体场景:
置信度校准:真实的 LLM 校准很差——它们以与正确答案同样自信的语气给出错误答案。固定数据总是返回“正确答案”。如果你的应用依赖于置信度分数,或者使用模型自身的不确定性作为路由信号,仅靠固定数据的测试无法告诉你这种路由是否真的有效。
提示词敏感度:对系统提示词进行 10 个词的修改可能对你的固定数据测试产生零影响(固定数据匹配的是用户消息,而不是系统提示词),但对真实模型的行为产生重大影响。第二层的磁带在这里有所帮助——它们编码了实际的系统提示词——但前提是你需要在提示词更改时重新生成它们。
跨模型版本的格式漂移:当你从一个模型版本升级到下一个版本时,你的磁带反映的是旧模型的输出格式。一个在 gpt-4o 上解析干净的结构化输出模式,在 gpt-4.1 上可能会有细微差别。第三层的冒烟测试能捕捉到这一点,而固定数据和磁带不能。
缓解措施不是放弃底层测试,而是诚实地面对它们测试的内容。为逻辑正确性编写固定数据层测试,为提示词正确性编写磁带层测试。运行真实模型的冒烟测试,以验证这两层都无法覆盖的行为假设。
开发内环中的实用成本约束
除了测试基础设施,一些习惯可以显著减少日常开发中的浪费:
为每个 API 调用打标签。 为每个请求附加 user_id、feature_name 和 environment 等元数据。大多数供应商通过请求元数据支持这一点。如果没有标签,你的账单仪表盘只显示总支出,却无法归因。有了标签,你可以看到开发预算的 60% 来自某个工程师的实验性功能分支,并能直接解决问题。
开发时使用更便宜的模型。 出于质量原因,你的生产系统可能需要 Claude Opus 或 GPT-4。但你的开发循环几乎从不需要。除了最后的“这个提示词产生的输出够好吗?”验证之外,其他所有环节都降级到更小的模型。成本差异通常达 100 倍。即使输出质量略低,你的固定数据层和磁带层无论如何都能捕捉到逻辑错误。
为每个环境设置硬性支出限制。 大多数供应商允许设置针对每个 Key 的预算上限。为开发、CI 和生产环境创建独立的 API Key,并对前两者设置适当的限制。这迫使人们保持目的性——如果开发环境烧光了月度预算,必须有人主动决定补充,这 会引发关于支出是否合理的讨论。
将 CI Token 消耗视为一项指标。 将 Token 计数和预估成本作为非阻塞注释添加到你的 CI 输出中。能够看到“此 PR 运行了 47 次 API 调用,耗资 $0.83”的团队,与没有任何可见性的团队相比,会做出不同的决策。这个数字不需要阻断构建——可见性本身就能改变行为。
搭建基础设施
对于新项目,实际的步骤顺序是:
- 建立本地模型访问(使用 Ollama 进行原型设计,或使用小型托管模型),用于不需要特定模型行为的自由探索。
- 从第一天起就添加
pytest-recording或同等工具。在编写测试时录制磁带,而不是以后再补。 - 为开发、CI 和生产设置独立的 API Key,并设定明确的预算上限。
- 立即为所有解析和逻辑代码编写第一层的固定数据测试。
- 推迟第三层的冒烟测试,直到你有值得针对真实模型进行验证的内容——通常是在功能接近完成时。
对于改造现有项目的团队,从 CI 开始。将每个 live_api_call_in_test 替换为磁带或跳过注释。这可以立即让 CI 变得确定且廉价,而不需要任何逻辑更改。然后,逐步为你发现自己最常调试的逻辑添加固定数据层覆盖。
总结
LLM 开发循环中存在一条明显的慢速路径和一条不太直观的快速路径。慢速路径——始终调用实时 API——由于 无需搭建基础设施,初看非常简单,但随着项目规模的扩大,它会演变成对团队交付速度和预算的沉重负担。快速路径则需要对录制-回放(record-replay)基础设施、测试固件(fixture)设计和 CI 规范进行前期投入,但一旦你的测试套件运行时间从分钟级缩短到秒级,这些投入就会迅速获得回报。
其原则与工程师处理其他外部依赖时所遵循的准则一致:掌控你关注的行为,尽可能进行缓存,并使昂贵的路径变得明确且有意识。LLM 的不同之处在于其非确定性,但这种差异还不足以让这些习惯失效。尽早意识到这一点的团队会将 API 预算花在生产环境的用户身上,而不是浪费在 CI 运行上。
- https://engineering.block.xyz/blog/testing-pyramid-for-ai-agents
- https://langfuse.com/blog/2025-10-21-testing-llm-applications
- https://github.com/kiwicom/pytest-recording
- https://github.com/amosjyng/vcr-langchain
- https://github.com/CopilotKit/llmock
- https://www.promptfoo.dev/
- https://dev.to/buildwithabid/how-i-found-1240month-in-wasted-llm-api-costs-and-built-a-tool-to-find-yours-3041
- https://dev.to/akarshc/how-to-test-llm-integrations-in-ci-without-burning-tokens-1ibh
- https://eugeneyan.com/writing/llm-patterns/
- https://martinfowler.com/articles/llm-learning-loop.html
- https://www.evidentlyai.com/blog/llm-unit-testing-ci-cd-github-actions
