AI 应用的 CI/CD 流水线——模型评估怎么集成进发布流程
AI 应用的 CI/CD 流水线——模型评估怎么集成进发布流程
去年有一次发布,我们改了一个推理服务的 System Prompt,想让回复更简洁,结果上线之后发现一个边缘场景的输出质量下降了,有用户反馈说某类问题的回答变差了。
从发现问题到回滚,花了四个小时。而且这四个小时里,我们一度不确定是 Prompt 改了导致的还是模型版本换了导致的,因为两件事情几乎同时上线。
这件事给我的教训是:AI 应用的发布流程,和普通 Java 应用有本质区别。普通应用只需要检查代码逻辑正确性,AI 应用还需要检查「模型的输出是否符合预期」。而这一点,现有的 CI/CD 工具链完全没有考虑。
AI 应用的发布比普通应用多了什么
先梳理清楚差异,再谈怎么解决。
普通 Java 应用的发布检查清单:
- 单元测试通过
- 集成测试通过
- 代码静态检查(SonarQube)
- Docker 镜像构建成功
- 部署到测试环境,冒烟测试通过
- 上线
AI 应用的发布检查清单(多出来的部分):
- Prompt 回归测试:改了 Prompt 之后,原来通过的测试用例还能通过吗?
- 模型版本兼容性检查:换了基础模型之后,API 格式是否兼容?行为是否有重大变化?
- 向量数据库 Schema 变更检查:如果调整了 Embedding 维度或索引结构,需要重建索引
- 输出质量基准测试:在标准测试集上的评分,是否低于设定的阈值?
- 成本回归测试:改了 Prompt 之后,平均 Token 消耗是否异常增加?
这五类检查,前两类最关键,但也最难做。"原来通过的测试用例还能通过吗" 这个问题,在 AI 场景里没有精确的布尔答案——模型的输出是概率性的,不是确定性的。
Prompt 回归测试的设计思路
我见过两种错误的做法:
第一种:不做 Prompt 回归测试,靠人工 review。 改个 Prompt,QA 人工测几个 case,觉得没问题就上线。问题是,AI 应用的边缘 case 太多了,人工测试覆盖不到,一改就可能踩到。
第二种:把 Prompt 测试当成精确匹配。 准备 100 个输入,记录期望输出,跑回归的时候检查模型输出是否和期望输出字符串匹配。这条路根本走不通——同样的输入,模型每次输出都可能有微小差异,字符串匹配的结果是每次都失败。
正确的做法是:基于评估函数的回归测试,而不是精确匹配。
对于不同类型的任务,评估函数不一样:
- 信息提取类:期望输出是结构化 JSON,检查 JSON 字段的完整性和值的合理性
- 分类任务:期望输出是有限集合中的一个值,精确匹配是可行的
- 生成任务:需要用 LLM-as-judge 或者计算 BLEU/ROUGE 分数,和基准分数比较
- 代码生成:运行生成的代码,看是否通过单元测试(这个最靠谱)
- 多轮对话:检查是否遵循了约束条件(比如"不能泄露系统 Prompt")
基于 GitHub Actions 的 AI 功能回归测试
下面是我们实际在用的 GitHub Actions workflow,讲解一下关键设计决策:
# .github/workflows/ai-regression.yml
name: AI Regression Tests
on:
push:
branches: [main, develop]
paths:
# 只有 Prompt 相关文件变更才触发 AI 回归测试
# 代码变更用普通 CI,不需要跑昂贵的 LLM 评估
- 'src/main/resources/prompts/**'
- 'src/main/resources/ai-config/**'
- 'src/main/java/com/example/ai/**'
pull_request:
paths:
- 'src/main/resources/prompts/**'
- 'src/main/java/com/example/ai/**'
env:
# 测试用的模型配置,比生产用更便宜的模型做快速验证
TEST_MODEL: "gpt-4o-mini"
EVAL_MODEL: "gpt-4o"
REGRESSION_THRESHOLD: "0.85"
jobs:
# 第一步:快速冒烟测试(2-3分钟),PR 阶段就运行
ai-smoke-test:
name: AI Smoke Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Java 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Run AI Smoke Tests
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
SPRING_PROFILES_ACTIVE: test
run: |
mvn test -pl ai-core \
-Dtest=AISmokeTest \
-Dspring.ai.openai.chat.options.model=${{ env.TEST_MODEL }} \
-Dmaven.test.failure.ignore=false
- name: Upload smoke test results
if: always()
uses: actions/upload-artifact@v4
with:
name: smoke-test-results
path: ai-core/target/surefire-reports/
# 第二步:完整回归测试(10-20分钟),合并到 main 分支时运行
ai-full-regression:
name: AI Full Regression
runs-on: ubuntu-latest
needs: ai-smoke-test
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
steps:
- uses: actions/checkout@v4
with:
# 获取上一个发布版本的代码,用于基准对比
fetch-depth: 0
- name: Set up Java 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Get baseline commit
id: baseline
run: |
# 获取上一个 tag 作为基准版本
BASELINE=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "HEAD~10")
echo "baseline=$BASELINE" >> $GITHUB_OUTPUT
echo "Baseline version: $BASELINE"
- name: Run full regression tests
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
SPRING_PROFILES_ACTIVE: test
run: |
mvn test -pl ai-core \
-Dtest=AIRegressionTest \
-Dspring.ai.openai.chat.options.model=${{ env.TEST_MODEL }} \
-Dai.eval.model=${{ env.EVAL_MODEL }} \
-Dai.regression.threshold=${{ env.REGRESSION_THRESHOLD }} \
-Dmaven.test.failure.ignore=false
- name: Generate regression report
if: always()
run: |
mvn exec:java -pl ai-core \
-Dexec.mainClass="com.example.ai.eval.RegressionReportGenerator" \
-Dexec.args="target/ai-test-results"
- name: Upload regression report
if: always()
uses: actions/upload-artifact@v4
with:
name: regression-report
path: ai-core/target/regression-report/
- name: Comment PR with results
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const report = fs.readFileSync('ai-core/target/regression-report/summary.md', 'utf8');
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: report
});
# 第三步:模型版本兼容性检查
model-compatibility-check:
name: Model Compatibility Check
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: pip install openai pydantic pytest
- name: Check model API compatibility
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python scripts/check_model_compatibility.py \
--config src/main/resources/ai-config/model-config.yaml \
--output target/compatibility-report.json
- name: Check Token budget regression
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
python scripts/check_token_budget.py \
--test-cases src/test/resources/ai-testcases/budget-test-cases.json \
--baseline target/token-baseline.json \
--threshold 1.2 # 允许 Token 消耗增加 20%,超过就报警下面是 Java 测试代码的核心部分:
// AIRegressionTest.java
@SpringBootTest
@ActiveProfiles("test")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
public class AIRegressionTest {
@Autowired
private ChatClient chatClient;
@Autowired
private AIEvaluator evaluator;
// 测试用例从外部 JSON 文件加载,便于非开发人员维护
@ParameterizedTest
@MethodSource("loadClassificationTestCases")
@Order(1)
void testIntentClassification(AITestCase testCase) {
// 分类任务:精确匹配
String result = chatClient.prompt()
.system(testCase.getSystemPrompt())
.user(testCase.getUserInput())
.call()
.content();
// 提取 JSON 中的分类字段
String actualCategory = JsonPath.read(result, "$.category");
assertThat(actualCategory)
.as("测试用例: %s", testCase.getId())
.isEqualTo(testCase.getExpectedOutput());
}
@ParameterizedTest
@MethodSource("loadGenerationTestCases")
@Order(2)
void testTextGeneration(AITestCase testCase) {
// 生成任务:用 LLM-as-judge 评分
String result = chatClient.prompt()
.system(testCase.getSystemPrompt())
.user(testCase.getUserInput())
.call()
.content();
EvaluationResult evalResult = evaluator.evaluate(
testCase.getUserInput(),
result,
testCase.getEvaluationCriteria()
);
// 评分低于阈值(0.85)则测试失败
assertThat(evalResult.getScore())
.as("测试用例 %s 的 LLM 评分: %.2f, 阈值: %.2f",
testCase.getId(), evalResult.getScore(), REGRESSION_THRESHOLD)
.isGreaterThanOrEqualTo(REGRESSION_THRESHOLD);
// 记录评分,用于生成回归报告
RegressionMetrics.record(testCase.getId(), evalResult.getScore());
}
@ParameterizedTest
@MethodSource("loadConstraintTestCases")
@Order(3)
void testOutputConstraints(AITestCase testCase) {
// 约束检查:验证输出是否遵守特定规则
String result = chatClient.prompt()
.system(testCase.getSystemPrompt())
.user(testCase.getUserInput())
.call()
.content();
for (ConstraintRule rule : testCase.getConstraintRules()) {
assertThat(rule.check(result))
.as("约束 '%s' 未满足,用例: %s,输出: %s",
rule.getName(), testCase.getId(), result)
.isTrue();
}
}
static Stream<AITestCase> loadClassificationTestCases() throws IOException {
return loadTestCases("classpath:ai-testcases/classification/*.json");
}
static Stream<AITestCase> loadGenerationTestCases() throws IOException {
return loadTestCases("classpath:ai-testcases/generation/*.json");
}
private static Stream<AITestCase> loadTestCases(String pattern) throws IOException {
ObjectMapper mapper = new ObjectMapper();
ResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
Resource[] resources = resolver.getResources(pattern);
return Arrays.stream(resources)
.flatMap(resource -> {
try {
List<AITestCase> cases = mapper.readValue(
resource.getInputStream(),
new TypeReference<List<AITestCase>>() {}
);
return cases.stream();
} catch (IOException e) {
throw new RuntimeException("Failed to load test cases from " + resource, e);
}
});
}
}// AIEvaluator.java - LLM-as-judge 评估器
@Component
public class AIEvaluator {
private final ChatClient evalClient;
private static final String EVAL_PROMPT_TEMPLATE = """
你是一个严格的 AI 输出质量评估器。请根据以下标准评估 AI 的回复质量。
用户问题:{question}
AI 回复:{response}
评估标准:{criteria}
请从以下维度评分(每项 1-5 分):
1. 准确性:回复是否准确回答了问题
2. 完整性:是否覆盖了所有必要信息
3. 格式合规:是否符合要求的输出格式
4. 无幻觉:是否没有编造不存在的信息
严格按照以下 JSON 格式输出,不要有其他内容:
{
"accuracy": <1-5>,
"completeness": <1-5>,
"format_compliance": <1-5>,
"no_hallucination": <1-5>,
"overall_score": <0.0-1.0>,
"reasoning": "<简短说明>"
}
""";
public EvaluationResult evaluate(String question, String response, String criteria) {
String evalPrompt = EVAL_PROMPT_TEMPLATE
.replace("{question}", question)
.replace("{response}", response)
.replace("{criteria}", criteria);
String evalOutput = evalClient.prompt()
.user(evalPrompt)
.call()
.content();
try {
ObjectMapper mapper = new ObjectMapper();
JsonNode node = mapper.readTree(evalOutput);
return EvaluationResult.builder()
.accuracy(node.get("accuracy").asInt())
.completeness(node.get("completeness").asInt())
.formatCompliance(node.get("format_compliance").asInt())
.noHallucination(node.get("no_hallucination").asInt())
.score(node.get("overall_score").asDouble())
.reasoning(node.get("reasoning").asText())
.build();
} catch (Exception e) {
log.error("Failed to parse evaluation result: {}", evalOutput, e);
// 解析失败给低分,不是直接抛出异常(避免评估器本身导致 CI 失败)
return EvaluationResult.failed("Failed to parse: " + e.getMessage());
}
}
}测试用例文件的格式:
// src/test/resources/ai-testcases/generation/customer-service-cases.json
[
{
"id": "CS-001",
"description": "处理用户退款请求",
"systemPrompt": "你是一个客服助手,处理用户的售后问题...",
"userInput": "我昨天买的商品质量有问题,想申请退款,订单号是 ORD-2024-001",
"evaluationCriteria": "回复应该包含退款流程说明,语气友好,不应该推脱责任,应该给出明确的处理时间承诺",
"constraintRules": [
{
"name": "no_negative_attitude",
"pattern": "不受理|无法退款|不支持",
"shouldMatch": false
},
{
"name": "contains_timeline",
"pattern": "工作日|小时|天",
"shouldMatch": true
}
]
}
]CI/CD 流水线的整体设计
模型版本兼容性检查
这个检查脚本是 Python 写的,主要检查两件事:API 格式是否兼容,以及模型行为有没有重大变化。
# scripts/check_model_compatibility.py
import json
import yaml
import argparse
from openai import OpenAI
def check_structured_output_compatibility(client, model_name):
"""检查结构化输出(JSON Mode)是否正常工作"""
test_cases = [
{
"name": "基础 JSON 输出",
"messages": [
{"role": "system", "content": "严格以 JSON 格式回复,格式:{\"result\": \"...\", \"confidence\": 0.0-1.0}"},
{"role": "user", "content": "这句话的情感是积极还是消极:'今天天气真好'"}
],
"required_fields": ["result", "confidence"]
}
]
results = []
for case in test_cases:
try:
response = client.chat.completions.create(
model=model_name,
messages=case["messages"],
response_format={"type": "json_object"},
temperature=0 # 测试用 temperature=0,减少随机性
)
content = json.loads(response.choices[0].message.content)
missing_fields = [f for f in case["required_fields"] if f not in content]
results.append({
"test": case["name"],
"passed": len(missing_fields) == 0,
"missing_fields": missing_fields
})
except Exception as e:
results.append({
"test": case["name"],
"passed": False,
"error": str(e)
})
return results
def check_token_usage_regression(client, model_name, baseline_file):
"""检查 Token 消耗是否有回归"""
with open(baseline_file) as f:
baseline = json.load(f)
regression_results = []
for case_id, baseline_tokens in baseline.items():
# 用固定的测试 Prompt 重新测量
test_prompt = load_test_prompt(case_id)
response = client.chat.completions.create(
model=model_name,
messages=test_prompt
)
current_tokens = response.usage.total_tokens
ratio = current_tokens / baseline_tokens
regression_results.append({
"case_id": case_id,
"baseline_tokens": baseline_tokens,
"current_tokens": current_tokens,
"ratio": ratio,
"regression": ratio > 1.2 # 超过 20% 增长视为回归
})
return regression_results
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--config", required=True)
parser.add_argument("--output", required=True)
args = parser.parse_args()
with open(args.config) as f:
config = yaml.safe_load(f)
client = OpenAI()
model_name = config["model"]["name"]
results = {
"model": model_name,
"structured_output": check_structured_output_compatibility(client, model_name),
"token_regression": check_token_usage_regression(
client, model_name, "target/token-baseline.json"
)
}
# 写输出
with open(args.output, "w") as f:
json.dump(results, f, indent=2, ensure_ascii=False)
# 有失败就退出码 1,GitHub Actions 会识别为失败
has_failure = any(not r["passed"] for r in results["structured_output"])
has_regression = any(r["regression"] for r in results["token_regression"])
if has_failure or has_regression:
print("兼容性检查失败!详见报告。")
exit(1)
print("兼容性检查通过。")几个真实的经验教训
经验一:测试用例要跨职能维护。 技术同学不懂业务边界,产品同学不懂技术细节,Prompt 测试用例最好由产品和技术共同维护。我们把测试用例存在 Git 里,产品同学直接提 PR 修改测试用例,技术同学 review 格式是否正确。
经验二:LLM-as-judge 本身的稳定性要注意。 用 GPT-4o 来评估输出,但 GPT-4o 自己也是概率性的,同一个输出在不同时刻评分可能差 10%。我们的做法是多次评估取均值(通常评 3 次),或者在评估 Prompt 里加 temperature=0,尽量减少评估器的随机性。
经验三:区分"功能性回归"和"风格性变化"。 换了模型版本,回复风格变了(比如从正式变得活泼),这不是 bug,是特性。纯粹的风格变化不应该阻断发布,但如果影响了结构化输出的格式或者关键信息的准确性,才是真正的回归。这个边界要在测试设计阶段就想清楚。
经验四:成本门控不是可选项。 我们有一次因为 Prompt 里加了很多 few-shot 示例,导致平均 Token 消耗增加了 3 倍,但回归测试没有发现(因为测试用例少,总 Token 不多)。后来专门加了"Token 预算检查",每次发布前用标准测试集估算平均 Token 消耗,和基线比较,超过 20% 就告警。
总结
AI 应用的 CI/CD 核心在于:把模型输出质量的验证自动化,而不是依赖人工测试。
实现这一点需要三个基础设施:一个维护良好的测试用例库(按任务类型分类,持续补充边缘案例),一个可靠的自动化评估器(分类任务精确匹配,生成任务 LLM-as-judge),一个成本监控的门控(防止 Prompt 膨胀导致费用突增)。
这套体系建立起来之后,Prompt 工程师改 Prompt 就不会担心破坏原有功能了,发布也不需要 QA 人工测试每个 case。这是 AI 工程走向成熟的必要条件。
