大多数 CI 流水线都假定以相同输入调用函数两次会返回相同输出。这条假设一旦 LLM 调用进入你的测试套件就会失效。向 GPT-4 或 Claude 提出相同问题两次,可能会得到两个不同的(但都正确)答案。交付 LLM 驱动功能的团队对此通常的做法是:几乎不为涉及 LLM 的代码路径编写任何测试,或者断言精确的字符串输出,然后在第一次测试失败时禁用它。一旦功能上线,提示词变更或模型升级都可能悄无声息地破坏行为,这两种做法都不可持续。

解决办法不是编写巧妙的断言库,而是把你实际要测试的内容拆成三层,每层对应不同的确定性保证,并把每一层映射到不同的 CI 作业。

第一层:契约测试(确定性,每次 PR 运行)

契约测试永远不会调用真实模型。它断言的是流水线输出的结构,而不是内容。如果你的编排器期望 LLM 返回 {title, tags, price_usd, full_content},契约测试会把一个预置响应送入解析/验证层,检查它是否抛异常、必填字段是否存在,以及类型是否匹配。

# test_contract.py
import json
import jsonschema
import pytest

PRODUCT_SCHEMA = {
    "type": "object",
    "required": ["title", "tags", "price_usd", "full_content"],
    "properties": {
        "title": {"type": "string", "minLength": 5},
        "tags": {"type": "array", "minItems": 1},
        "price_usd": {"type": "number", "minimum": 1},
        "full_content": {"type": "string", "minLength": 200},
    },
}

@pytest.mark.parametrize("fixture_file", [
    "fixtures/well_formed.json",
    "fixtures/missing_price.json",
    "fixtures/empty_content.json",
])
def test_pipeline_handles_llm_output(fixture_file):
    payload = json.load(open(fixture_file))
    result = parse_and_validate(payload)  # your own code
    if fixture_file == "fixtures/well_formed.json":
        jsonschema.validate(result, PRODUCT_SCHEMA)
    else:
        assert result.rejected_reason is not None

Enter fullscreen mode Exit fullscreen mode

这些测试夹具是静态 JSON 文件,已提交到仓库。它们在毫秒级运行,无需 API Key,能够捕获真正导致故障的错误类别:解析器在模型遗漏字段、空值出现在预期字符串处、提示词编辑后模式漂移等。此层在每次提交时运行,并应阻止合并。

第二层:回放测试(确定性,每次 PR 运行)

介于“虚假 JSON 夹具”与“调用真实 API”之间的是基于磁带(cassette)的回放:先记录一次真实模型响应,保存到磁盘,随后在每次 CI 运行时回放,而不是访问网络。这与用于 HTTP 测试的 VCR 思路相同,只是应用到了 LLM 调用。

# conftest.py
import json, os, hashlib

CASSETTE_DIR = "tests/cassettes"

def llm_call_with_cassette(prompt, real_call_fn):
    key = hashlib.sha256(prompt.encode()).hexdigest()[:16]
    path = f"{CASSETTE_DIR}/{key}.json"
    if os.path.exists(path):
        return json.load(open(path))
    if os.environ.get("CI") and not os.environ.get("RECORD_CASSETTES"):
        raise RuntimeError(f"Missing cassette for prompt hash {key}; run with RECORD_CASSETTES=1 locally")
    response = real_call_fn(prompt)
    json.dump(response, open(path, "w"))
    return response

Enter fullscreen mode Exit fullscreen mode

磁带文件像其他测试夹具一样提交到仓库。当你有意修改提示词时,在本地使用 RECORD_CASSETTES=1 重新录制,把磁带文件的 diff 作为 PR 的一部分进行审查(审阅者经常在此发现回归),然后提交新的磁带。CI 永远不会为这些测试访问网络,因此既快又免费,但它们仍使用真实模型输出(而非手写夹具)来检验你的真实解析和业务逻辑。

第三层:实时冒烟测试(非确定性,每晚运行,不在 PR 上运行)

这一层使用真实 API Key 和真实费用调用实际模型。它的任务是回答一个问题:自上次检查以来,模型的行为是否发生了漂移,而这种漂移是回放磁带无法揭示的。由于输出会变化,你不能断言相等,而是断言属性:响应是否为有效 JSON、是否满足模式、与参考答案的语义相似度分数是否高于阈值(使用嵌入而非字符串匹配)、token 用量是否在成本预算内。

def test_live_semantic_similarity(embed_fn, cosine_sim):
    reference = load_reference_embedding("golden_answer.json")
    live_output = call_real_llm(PROMPT)
    similarity = cosine_sim(embed_fn(live_output["full_content"]), reference)
    assert similarity > 0.80  # loose bound; catches total drift, not phrasing

Enter fullscreen mode Exit fullscreen mode

这里正是 CircleCI 工作流模型的用武之地。在 .circleci/config.yml 中定义两个工作流:一个在每次 PR 时运行,仅执行第一、二层;另一个按计划每晚(或通过审批作业按需)执行第三层,这样一次不稳定的实时调用就不会阻塞合并。

version: 2.1

jobs:
  fast-tests:
    docker: [{ image: cimg/python:3.12 }]
    steps:
      - checkout
      - run: pip install -r requirements.txt
      - run: pytest tests/test_contract.py tests/test_replay.py -v

  live-smoke-tests:
    docker: [{ image: cimg/python:3.12 }]
    steps:
      - checkout
      - run: pip install -r requirements.txt
      - run:
          name: Enforce per-run cost ceiling
          command: python scripts/check_budget.py --max-usd 2.00
      - run: pytest tests/test_live_smoke.py -v --reruns 1

workflows:
  on-pr:
    jobs:
      - fast-tests
  nightly-live-check:
    triggers:
      - schedule:
          cron: "0 6 * * *"
          filters:
            branches: { only: main }
    jobs:
      - live-smoke-tests

Enter fullscreen mode Exit fullscreen mode

这里有两点细节很重要。首先,实时作业上的 --reruns 1 承认单次 API 超时不应触发告警;连续两次失败才是真正的信号。其次,check_budget.py 步骤在 pytest 调用之前运行,根据固定测试提示词的 token 数量估算成本,如果提示词变更会导致费用超过美元上限,则直接让作业失败——这能够在费用超支前(而非收到账单后)捕获有人扩大提示词上下文窗口并将夜间账单提高 10 倍的情景。

为什么拆分,而不是“更多重试”

跳过这种拆分的团队通常会遇到两种失败模式之一:PR 因等待对受限 API 的不稳定网络调用而被阻塞数分钟;或者测试断言过于松散,即使流水线返回垃圾数据也能通过。将契约正确性(第一层)、针对已知良好真实输出的回归检测(第二层)以及实时模型漂移检测(第三层)分开,让每一层都使用与其确定性保证相匹配的断言风格,并让 CircleCI 以适合其成本和速度配置的频率调度每一层。阻塞 PR 的路径保持快速且免费;昂贵、不稳定、需要真金白银的路径每天运行一次,人工可以在不延误发布的情况下进行分流。