AI应用的契约测试:确保AI服务接口的稳定性
AI应用的契约测试:确保AI服务接口的稳定性
一个周五下午的噩梦
2025年11月的一个周五下午4点,某大厂AI平台团队的李明正准备提前下班。
他在公司做了3年Java后端,半年前转型加入了AI应用团队。那天下午,他们刚完成了一个季度级别的需求——把公司的智能客服从GPT-3.5全面升级到了自研大模型。
测试环境跑得很顺,自动化测试全部通过,就等着5点发布上线了。
4点15分,发布完成。
4点17分,监控告警开始疯狂闪烁。
问题出在一个看起来毫不起眼的地方:AI服务团队在更新模型的同时,顺手把响应字段 answer 改成了 response,把 confidence 改成了 score。只是变量名的修改,他们以为没人会在意。
但李明的团队在意了。他们的消费者代码写死了字段名,整个客服系统的AI回答功能全部返回 null。
周五下午5点,本来该喝啤酒的时间,李明和三个同事开始了紧急回滚。等到修复上线,已经是晚上11点。整整一天的工程师时间就这么没了,还有那段时间里几千个用户的糟糕体验。
"如果当时有契约测试就好了。"
他后来跟我说这句话的时候,眼神里有一种经历过的人才有的疲惫。
这篇文章,就是写给所有可能遇到这个问题的Java工程师的。
为什么 AI 服务接口特别脆弱
传统微服务的接口变更,通常有严格的版本管理,有 API 文档,团队间有明确的沟通流程。
但 AI 服务有它自己的特殊性:
1. 迭代频率极高
AI 团队经常在"优化模型"的名义下悄悄修改接口。一次 prompt 调优,一次模型切换,可能顺带改了响应结构。这些变更在 AI 团队看来是"内部实现细节",但对消费者来说是破坏性变更。
2. 响应结构复杂且不稳定
传统 REST 接口通常有严格的 Schema。但 AI 服务的响应可能包含嵌套的 JSON、可选字段、根据不同请求类型变化的结构。任何一个层级的修改都可能影响消费者。
3. 非确定性响应
AI 服务的输出内容是不确定的,你无法通过传统的"期望值比较"来做契约测试。这是 AI 接口契约测试最核心的挑战。
4. 提供者与消费者往往属于不同团队
AI 平台团队专注于模型能力,业务团队专注于用户体验,双方沟通成本高,接口变更很容易产生信息不对称。
契约测试是什么
契约测试(Contract Testing)是一种验证服务间接口兼容性的测试方法。
它的核心思想是:消费者定义它对提供者的期望(契约),提供者证明自己满足了这些期望。
传统的集成测试需要同时启动消费者和提供者,而契约测试将验证分成两个独立阶段:
- Consumer 阶段:消费者写测试,描述"我期望 AI 服务返回什么结构",生成契约文件
- Provider 阶段:提供者拿着契约文件,验证"我的实现满足这些期望"
两个阶段可以完全独立运行,这是契约测试的核心价值:快速发现接口不兼容,不需要跑完整的集成环境。
Pact 框架介绍
Pact 是目前最成熟的消费者驱动契约测试框架,支持 Java、JavaScript、Go、Python 等主流语言。
在 Java 生态中,我们使用 pact-jvm 配合 JUnit 5。
Maven 依赖
<dependencies>
<!-- Spring AI -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
<!-- Pact Consumer -->
<dependency>
<groupId>au.com.dius.pact.consumer</groupId>
<artifactId>junit5</artifactId>
<version>4.6.14</version>
<scope>test</scope>
</dependency>
<!-- Pact Provider -->
<dependency>
<groupId>au.com.dius.pact.provider</groupId>
<artifactId>junit5spring</artifactId>
<version>4.6.14</version>
<scope>test</scope>
</dependency>
<!-- Pact Broker Client -->
<dependency>
<groupId>au.com.dius.pact.provider</groupId>
<artifactId>maven</artifactId>
<version>4.6.14</version>
<scope>test</scope>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- AssertJ -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>AI 接口特殊性:为非确定性响应设计契约
这是所有人第一次接触 AI 契约测试时都会遇到的困惑:AI 返回的内容每次都不一样,怎么写契约?
答案是:契约测试不验证内容,只验证结构。
传统 API 契约:
{
"status": "success",
"data": "北京今天天气晴"
}可以断言值是 "北京今天天气晴"。
AI API 契约:
{
"response": "根据您的问题,北京今天天气晴朗,温度约25度...",
"usage": {
"promptTokens": 42,
"completionTokens": 156,
"totalTokens": 198
},
"model": "gpt-4o",
"finishReason": "stop"
}契约只断言:
response字段存在,且是 String 类型usage.promptTokens是 Integer 类型model字段存在,且是 String 类型
Pact 提供了丰富的类型匹配器(Type Matchers)来支持这种模式:
// 值匹配 - 精确匹配,适合枚举、状态码等固定值
equalTo("success")
// 类型匹配 - 只验证类型,不验证值,适合AI响应内容
like("任意字符串") // String类型即可
like(42) // Integer类型即可
// 正则匹配 - 适合有格式要求的字段
regex("\\d+", "123") // 必须是数字字符串
// 数组匹配 - 验证数组结构
eachLike(...) // 数组中每个元素都满足规则
// 最小长度匹配
minArrayLike(1, ...) // 数组至少有1个元素Consumer 端:定义 AI 服务契约
我们以一个 RAG(检索增强生成)服务为例。业务团队的消费者需要调用 AI 平台的问答接口。
消费者端的业务代码
首先是真实的业务调用代码:
// src/main/java/com/company/consumer/client/AiServiceClient.java
package com.company.consumer.client;
import com.company.consumer.dto.AiQueryRequest;
import com.company.consumer.dto.AiQueryResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
@Component
@RequiredArgsConstructor
public class AiServiceClient {
private final WebClient webClient;
@Value("${ai.service.base-url}")
private String baseUrl;
/**
* 调用RAG问答接口
*/
public AiQueryResponse query(AiQueryRequest request) {
return webClient.post()
.uri(baseUrl + "/api/v1/ai/query")
.bodyValue(request)
.retrieve()
.bodyToMono(AiQueryResponse.class)
.block();
}
/**
* 调用流式问答接口
*/
public Mono<AiQueryResponse> queryStream(AiQueryRequest request) {
return webClient.post()
.uri(baseUrl + "/api/v1/ai/query/stream")
.bodyValue(request)
.retrieve()
.bodyToMono(AiQueryResponse.class);
}
}// src/main/java/com/company/consumer/dto/AiQueryRequest.java
package com.company.consumer.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AiQueryRequest {
private String question;
private String sessionId;
private String userId;
private Integer topK; // RAG检索数量
private Boolean includeSource; // 是否返回引用来源
}// src/main/java/com/company/consumer/dto/AiQueryResponse.java
package com.company.consumer.dto;
import lombok.Data;
import java.util.List;
@Data
public class AiQueryResponse {
private String response; // AI回答内容
private List<SourceDocument> sources; // 引用来源
private UsageInfo usage; // Token使用情况
private String model; // 使用的模型
private String finishReason; // 完成原因
private Long processingTimeMs; // 处理时间
@Data
public static class SourceDocument {
private String documentId;
private String title;
private String snippet;
private Double relevanceScore;
}
@Data
public static class UsageInfo {
private Integer promptTokens;
private Integer completionTokens;
private Integer totalTokens;
}
}消费者端契约测试
// src/test/java/com/company/consumer/contract/AiServiceContractTest.java
package com.company.consumer.contract;
import au.com.dius.pact.consumer.MockServer;
import au.com.dius.pact.consumer.dsl.PactDslJsonBody;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.core.model.RequestResponsePact;
import au.com.dius.pact.core.model.annotations.Pact;
import com.company.consumer.client.AiServiceClient;
import com.company.consumer.dto.AiQueryRequest;
import com.company.consumer.dto.AiQueryResponse;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "ai-rag-service")
class AiServiceContractTest {
/**
* 定义基础问答契约
* 核心要点:只验证结构,不验证AI响应内容
*/
@Pact(consumer = "customer-service-app", provider = "ai-rag-service")
public RequestResponsePact queryContract(PactDslWithProvider builder) {
return builder
.given("AI服务正常运行")
.uponReceiving("标准问答请求")
.method("POST")
.path("/api/v1/ai/query")
.headers(Map.of(
"Content-Type", "application/json",
"Accept", "application/json"
))
.body(new PactDslJsonBody()
.stringType("question", "什么是向量数据库?")
.stringType("sessionId", "sess-001")
.stringType("userId", "user-001")
.numberType("topK", 3)
.booleanType("includeSource", true)
)
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
// 核心字段:类型匹配,不验证具体AI响应内容
.stringType("response", "向量数据库是一种专门存储向量数据的数据库...")
// 来源列表:至少有1个元素,每个元素满足结构要求
.minArrayLike("sources", 1, new PactDslJsonBody()
.stringType("documentId", "doc-001")
.stringType("title", "向量数据库简介")
.stringType("snippet", "向量数据库用于存储高维向量...")
.numberType("relevanceScore", 0.95)
)
// 嵌套对象
.object("usage")
.integerType("promptTokens", 42)
.integerType("completionTokens", 156)
.integerType("totalTokens", 198)
.closeObject()
.stringType("model", "gpt-4o")
// 枚举值:精确匹配
.stringMatcher("finishReason", "stop|length|content_filter", "stop")
.numberType("processingTimeMs", 1250L)
)
.toPact();
}
/**
* 定义错误场景契约:问题为空时的处理
*/
@Pact(consumer = "customer-service-app", provider = "ai-rag-service")
public RequestResponsePact emptyQuestionContract(PactDslWithProvider builder) {
return builder
.given("AI服务正常运行")
.uponReceiving("空问题请求")
.method("POST")
.path("/api/v1/ai/query")
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.stringValue("question", "")
.stringType("sessionId", "sess-002")
.stringType("userId", "user-001")
)
.willRespondWith()
.status(400)
.body(new PactDslJsonBody()
.stringType("error", "INVALID_REQUEST")
.stringType("message", "问题不能为空")
)
.toPact();
}
/**
* 定义AI服务不可用时的降级契约
*/
@Pact(consumer = "customer-service-app", provider = "ai-rag-service")
public RequestResponsePact fallbackContract(PactDslWithProvider builder) {
return builder
.given("AI服务降级模式")
.uponReceiving("降级场景下的问答请求")
.method("POST")
.path("/api/v1/ai/query")
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.stringType("question", "任意问题")
.stringType("sessionId", "sess-003")
.stringType("userId", "user-001")
)
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
// 降级时也应该返回相同的结构,只是内容是固定的fallback文案
.stringType("response", "当前服务繁忙,请稍后再试。")
.array("sources") // 降级时来源为空数组
.closeArray()
.object("usage")
.integerType("promptTokens", 0)
.integerType("completionTokens", 0)
.integerType("totalTokens", 0)
.closeObject()
.stringType("model", "fallback")
.stringValue("finishReason", "fallback")
.numberType("processingTimeMs", 10L)
)
.toPact();
}
/**
* 执行契约测试:验证消费者代码能正确处理Provider的响应
*/
@Test
@PactTestFor(pactMethod = "queryContract")
void testQueryContract(MockServer mockServer) {
// 使用MockServer(由Pact框架自动创建,模拟Provider行为)
AiServiceClient client = createClient(mockServer.getUrl());
AiQueryRequest request = AiQueryRequest.builder()
.question("什么是向量数据库?")
.sessionId("sess-001")
.userId("user-001")
.topK(3)
.includeSource(true)
.build();
AiQueryResponse response = client.query(request);
// 验证消费者代码能正确解析响应
assertThat(response).isNotNull();
assertThat(response.getResponse()).isNotBlank();
assertThat(response.getSources()).isNotEmpty();
assertThat(response.getUsage()).isNotNull();
assertThat(response.getUsage().getTotalTokens()).isPositive();
assertThat(response.getModel()).isNotBlank();
assertThat(response.getFinishReason()).isIn("stop", "length", "content_filter");
}
@Test
@PactTestFor(pactMethod = "emptyQuestionContract")
void testEmptyQuestionContract(MockServer mockServer) {
AiServiceClient client = createClient(mockServer.getUrl());
AiQueryRequest request = AiQueryRequest.builder()
.question("")
.sessionId("sess-002")
.userId("user-001")
.build();
// 验证消费者代码能正确处理400错误
try {
client.query(request);
} catch (Exception e) {
assertThat(e.getMessage()).contains("400");
}
}
private AiServiceClient createClient(String baseUrl) {
WebClient webClient = WebClient.builder().build();
AiServiceClient client = new AiServiceClient(webClient);
// 通过反射或测试工具设置baseUrl
return client;
}
}Provider 端:验证契约
AI 服务提供者需要证明自己满足消费者定义的契约。
Provider 端的 AI 控制器
// src/main/java/com/company/ai/controller/AiQueryController.java
package com.company.ai.controller;
import com.company.ai.dto.AiQueryRequest;
import com.company.ai.dto.AiQueryResponse;
import com.company.ai.service.RagService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/ai")
@RequiredArgsConstructor
@Slf4j
public class AiQueryController {
private final RagService ragService;
@PostMapping("/query")
public ResponseEntity<?> query(@RequestBody AiQueryRequest request) {
log.info("收到查询请求: question={}, userId={}", request.getQuestion(), request.getUserId());
// 参数校验
if (request.getQuestion() == null || request.getQuestion().isBlank()) {
return ResponseEntity.badRequest().body(
new ErrorResponse("INVALID_REQUEST", "问题不能为空")
);
}
AiQueryResponse response = ragService.query(request);
return ResponseEntity.ok(response);
}
record ErrorResponse(String error, String message) {}
}// src/main/java/com/company/ai/service/RagService.java
package com.company.ai.service;
import com.company.ai.dto.AiQueryRequest;
import com.company.ai.dto.AiQueryResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
@Slf4j
public class RagService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
public AiQueryResponse query(AiQueryRequest request) {
long startTime = System.currentTimeMillis();
// 1. 向量检索
List<Document> relevantDocs = vectorStore.similaritySearch(
request.getQuestion(),
request.getTopK() != null ? request.getTopK() : 3
);
// 2. 构建增强提示
String context = relevantDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n"));
String prompt = String.format("""
基于以下背景信息回答用户的问题。如果背景信息不足以回答,请如实说明。
背景信息:
%s
用户问题:%s
""", context, request.getQuestion());
// 3. 调用LLM
ChatResponse chatResponse = chatClient.prompt()
.user(prompt)
.call()
.chatResponse();
// 4. 构建响应
long processingTime = System.currentTimeMillis() - startTime;
List<AiQueryResponse.SourceDocument> sources = relevantDocs.stream()
.map(doc -> {
AiQueryResponse.SourceDocument source = new AiQueryResponse.SourceDocument();
source.setDocumentId(doc.getId());
source.setTitle((String) doc.getMetadata().getOrDefault("title", "未知来源"));
source.setSnippet(doc.getContent().substring(0, Math.min(200, doc.getContent().length())));
source.setRelevanceScore((Double) doc.getMetadata().getOrDefault("score", 0.0));
return source;
})
.collect(Collectors.toList());
AiQueryResponse.UsageInfo usage = new AiQueryResponse.UsageInfo();
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
usage.setPromptTokens((int) chatResponse.getMetadata().getUsage().getPromptTokens());
usage.setCompletionTokens((int) chatResponse.getMetadata().getUsage().getGenerationTokens());
usage.setTotalTokens((int) chatResponse.getMetadata().getUsage().getTotalTokens());
}
AiQueryResponse response = new AiQueryResponse();
response.setResponse(chatResponse.getResult().getOutput().getContent());
response.setSources(sources);
response.setUsage(usage);
response.setModel(chatResponse.getMetadata().getModel());
response.setFinishReason(chatResponse.getResult().getMetadata().getFinishReason());
response.setProcessingTimeMs(processingTime);
return response;
}
}Provider 端契约验证测试
// src/test/java/com/company/ai/contract/AiServiceProviderContractTest.java
package com.company.ai.contract;
import au.com.dius.pact.provider.junit5.HttpTestTarget;
import au.com.dius.pact.provider.junit5.PactVerificationContext;
import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider;
import au.com.dius.pact.provider.junitsupport.Provider;
import au.com.dius.pact.provider.junitsupport.State;
import au.com.dius.pact.provider.junitsupport.loader.PactBroker;
import au.com.dius.pact.provider.junitsupport.loader.PactBrokerAuth;
import com.company.ai.service.RagService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.TestTemplate;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Provider("ai-rag-service")
@PactBroker(
host = "${PACT_BROKER_HOST:localhost}",
port = "${PACT_BROKER_PORT:9292}",
scheme = "${PACT_BROKER_SCHEME:http}",
authentication = @PactBrokerAuth(
token = "${PACT_BROKER_TOKEN:}"
)
)
class AiServiceProviderContractTest {
@LocalServerPort
private int port;
@MockBean
private RagService ragService;
@BeforeEach
void setupTestTarget(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}
/**
* 对应Consumer契约中 given("AI服务正常运行") 的状态
* 在这个状态下,mock RagService 返回合法数据
*/
@State("AI服务正常运行")
void aiServiceNormalState() {
// 构建mock响应 - 结构必须与契约一致
AiQueryResponse mockResponse = buildMockResponse();
when(ragService.query(any())).thenReturn(mockResponse);
}
/**
* 对应Consumer契约中 given("AI服务降级模式") 的状态
*/
@State("AI服务降级模式")
void aiServiceFallbackState() {
AiQueryResponse fallbackResponse = buildFallbackResponse();
when(ragService.query(any())).thenReturn(fallbackResponse);
}
private AiQueryResponse buildMockResponse() {
AiQueryResponse response = new AiQueryResponse();
response.setResponse("向量数据库是一种专门用于存储和检索高维向量数据的数据库系统...");
AiQueryResponse.SourceDocument source = new AiQueryResponse.SourceDocument();
source.setDocumentId("doc-001");
source.setTitle("向量数据库简介");
source.setSnippet("向量数据库用于存储高维向量,支持相似度搜索...");
source.setRelevanceScore(0.95);
response.setSources(List.of(source));
AiQueryResponse.UsageInfo usage = new AiQueryResponse.UsageInfo();
usage.setPromptTokens(42);
usage.setCompletionTokens(156);
usage.setTotalTokens(198);
response.setUsage(usage);
response.setModel("gpt-4o");
response.setFinishReason("stop");
response.setProcessingTimeMs(1250L);
return response;
}
private AiQueryResponse buildFallbackResponse() {
AiQueryResponse response = new AiQueryResponse();
response.setResponse("当前服务繁忙,请稍后再试。");
response.setSources(List.of());
AiQueryResponse.UsageInfo usage = new AiQueryResponse.UsageInfo();
usage.setPromptTokens(0);
usage.setCompletionTokens(0);
usage.setTotalTokens(0);
response.setUsage(usage);
response.setModel("fallback");
response.setFinishReason("fallback");
response.setProcessingTimeMs(10L);
return response;
}
}Pact Broker:契约的集中管理
Pact Broker 是契约文件的中央仓库,负责存储、版本化和追踪所有契约。
Docker Compose 部署 Pact Broker
# docker-compose.pact.yml
version: '3.8'
services:
pact-broker:
image: pactfoundation/pact-broker:latest
ports:
- "9292:9292"
environment:
PACT_BROKER_DATABASE_URL: postgres://pact:pact@postgres/pact
PACT_BROKER_BASIC_AUTH_USERNAME: admin
PACT_BROKER_BASIC_AUTH_PASSWORD: admin
PACT_BROKER_ALLOW_PUBLIC_READ: "true"
depends_on:
- postgres
postgres:
image: postgres:15
environment:
POSTGRES_DB: pact
POSTGRES_USER: pact
POSTGRES_PASSWORD: pact
volumes:
- pact_data:/var/lib/postgresql/data
volumes:
pact_data:发布契约到 Pact Broker
<!-- pom.xml 中配置 Pact Maven Plugin -->
<plugin>
<groupId>au.com.dius.pact.provider</groupId>
<artifactId>maven</artifactId>
<version>4.6.14</version>
<configuration>
<pactBrokerUrl>http://localhost:9292</pactBrokerUrl>
<pactBrokerUsername>admin</pactBrokerUsername>
<pactBrokerPassword>admin</pactBrokerPassword>
<projectVersion>${project.version}</projectVersion>
<trimSnapshot>true</trimSnapshot>
</configuration>
</plugin># 发布消费者契约
mvn pact:publish
# 或者通过 Pact CLI
pact-broker publish ./target/pacts \
--broker-base-url http://localhost:9292 \
--consumer-app-version 1.2.3 \
--branch mainCI/CD 集成:契约变更自动阻止发布
契约测试真正的价值,在于集成到 CI/CD 流水线中,自动拦截破坏性变更。
GitHub Actions 配置
# .github/workflows/contract-test.yml
name: Contract Tests
on:
push:
branches: [ main, develop ]
pull_request:
jobs:
consumer-contract:
name: Consumer Contract Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Run Consumer Contract Tests
run: mvn test -pl consumer-app -Dtest="*ContractTest"
- name: Publish Pacts to Broker
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: |
mvn pact:publish \
-Dpact.broker.url=$PACT_BROKER_URL \
-Dpact.broker.token=$PACT_BROKER_TOKEN \
-Dpact.consumer.version=${{ github.sha }} \
-Dpact.tag=${{ github.ref_name }}
provider-contract:
name: Provider Contract Verification
runs-on: ubuntu-latest
needs: consumer-contract
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Run Provider Verification
env:
PACT_BROKER_HOST: ${{ secrets.PACT_BROKER_HOST }}
PACT_BROKER_PORT: 443
PACT_BROKER_SCHEME: https
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
PACT_PROVIDER_VERSION: ${{ github.sha }}
PACT_PROVIDER_BRANCH: ${{ github.ref_name }}
run: mvn test -pl ai-service -Dtest="*ProviderContractTest"
- name: Can I Deploy Check
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: |
# 检查当前版本是否可以安全部署
pact-broker can-i-deploy \
--pacticipant ai-rag-service \
--version ${{ github.sha }} \
--to-environment production \
--broker-base-url $PACT_BROKER_URL \
--broker-token $PACT_BROKER_TOKENcan-i-deploy 命令会查询 Pact Broker,确认所有相关消费者都已经验证过当前版本,只有全部通过才允许部署。
版本兼容性:AI 接口升级时的契约演进
AI 服务的接口升级是常态。当提供者需要添加新字段时,有两种方式:
向后兼容的变更(安全):
- 新增可选字段
- 新增新的 API 端点
- 为枚举类型新增值(但消费者契约中不能用精确匹配)
破坏性变更(危险):
- 删除字段
- 重命名字段
- 修改字段类型
- 改变数组结构
契约演进最佳实践
/**
* 版本演进策略:提供者添加新字段
* 消费者契约只定义自己关心的字段,新增字段不会影响已有契约
*/
@Pact(consumer = "customer-service-app", provider = "ai-rag-service")
public RequestResponsePact queryWithCitationsContract(PactDslWithProvider builder) {
return builder
.given("AI服务v2运行(支持引用标注)")
.uponReceiving("带引用标注的问答请求")
.method("POST")
.path("/api/v2/ai/query") // 新版本用新路径
.body(new PactDslJsonBody()
.stringType("question", "什么是向量数据库?")
.stringType("sessionId", "sess-001")
.stringType("userId", "user-001")
.booleanValue("includeCitations", true) // 新增参数
)
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringType("response", "向量数据库...")
.minArrayLike("citations", 0, new PactDslJsonBody()
.integerType("startIndex", 10)
.integerType("endIndex", 20)
.stringType("sourceId", "doc-001")
)
// 只添加新消费者关心的字段
// 不影响 v1 消费者的契约
)
.toPact();
}/**
* 消费者端处理字段重命名:
* 使用配置化的字段映射,不在契约中硬编码字段名
*/
@Configuration
public class AiServiceClientConfig {
/**
* 自定义 ObjectMapper,处理字段名兼容性
*/
@Bean
public ObjectMapper aiResponseMapper() {
ObjectMapper mapper = new ObjectMapper();
// 允许未知字段,防止 Provider 新增字段时崩溃
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
return mapper;
}
}实战:RAG 服务的完整契约测试套件
整合前面所有内容,给出一个完整的生产级 RAG 服务契约测试套件。
测试场景矩阵
| 场景 | Consumer 契约 | Provider 状态 | 验证重点 |
|---|---|---|---|
| 正常问答 | 标准响应结构 | 正常运行 | 字段类型和必填项 |
| 无相关文档 | 空 sources 数组 | 向量库无数据 | 空数组处理 |
| 问题太长 | 400 错误 | 输入验证 | 错误格式 |
| 服务降级 | fallback 响应 | 降级模式 | 降级结构一致性 |
| 流式输出 | SSE 事件结构 | 流式模式 | 流式字段格式 |
完整的消费者契约测试套件
// src/test/java/com/company/consumer/contract/RagServiceContractSuite.java
package com.company.consumer.contract;
import au.com.dius.pact.consumer.MockServer;
import au.com.dius.pact.consumer.dsl.PactDslJsonBody;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.core.model.RequestResponsePact;
import au.com.dius.pact.core.model.annotations.Pact;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "ai-rag-service")
class RagServiceContractSuite {
// ===== 场景1:无相关文档时的问答 =====
@Pact(consumer = "customer-service-app", provider = "ai-rag-service")
public RequestResponsePact noDocumentFoundContract(PactDslWithProvider builder) {
return builder
.given("向量库无相关文档")
.uponReceiving("知识库中不存在相关文档的问题")
.method("POST")
.path("/api/v1/ai/query")
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.stringType("question", "宇宙是什么形状的?")
.stringType("sessionId", "sess-010")
.stringType("userId", "user-001")
.booleanValue("includeSource", true)
)
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringType("response", "根据现有知识库,未找到相关信息。")
.array("sources") // 空数组
.closeArray()
.object("usage")
.integerType("promptTokens", 20)
.integerType("completionTokens", 30)
.integerType("totalTokens", 50)
.closeObject()
.stringType("model", "gpt-4o")
.stringType("finishReason", "stop")
.numberType("processingTimeMs", 800L)
)
.toPact();
}
// ===== 场景2:问题超长(Token超限)=====
@Pact(consumer = "customer-service-app", provider = "ai-rag-service")
public RequestResponsePact questionTooLongContract(PactDslWithProvider builder) {
return builder
.given("AI服务正常运行")
.uponReceiving("超过最大Token限制的问题")
.method("POST")
.path("/api/v1/ai/query")
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.stringMatcher("question", ".{4097,}", "a".repeat(5000)) // 超过4096字符
.stringType("sessionId", "sess-011")
.stringType("userId", "user-001")
)
.willRespondWith()
.status(422)
.body(new PactDslJsonBody()
.stringValue("error", "QUESTION_TOO_LONG")
.stringType("message", "问题长度超过最大限制(4096字符)")
.integerType("maxLength", 4096)
.integerType("actualLength", 5000)
)
.toPact();
}
// ===== 场景3:会话历史上下文 =====
@Pact(consumer = "customer-service-app", provider = "ai-rag-service")
public RequestResponsePact conversationContextContract(PactDslWithProvider builder) {
return builder
.given("存在会话历史记录")
.uponReceiving("带会话上下文的跟进问题")
.method("POST")
.path("/api/v1/ai/query")
.headers(Map.of("Content-Type", "application/json"))
.body(new PactDslJsonBody()
.stringType("question", "那它的优缺点是什么?") // 代词引用上文
.stringValue("sessionId", "sess-existing-001") // 已有历史的session
.stringType("userId", "user-001")
.booleanValue("includeSource", false)
)
.willRespondWith()
.status(200)
.body(new PactDslJsonBody()
.stringType("response", "基于我们之前的对话,向量数据库的优点包括...")
.array("sources").closeArray()
.object("usage")
.integerType("promptTokens", 200) // 包含历史上下文,token更多
.integerType("completionTokens", 300)
.integerType("totalTokens", 500)
.closeObject()
.stringType("model", "gpt-4o")
.stringType("finishReason", "stop")
.numberType("processingTimeMs", 2000L)
)
.toPact();
}
@Test
@PactTestFor(pactMethod = "noDocumentFoundContract")
void testNoDocumentFound(MockServer mockServer) {
AiServiceClient client = createClient(mockServer.getUrl());
AiQueryRequest request = AiQueryRequest.builder()
.question("宇宙是什么形状的?")
.sessionId("sess-010")
.userId("user-001")
.includeSource(true)
.build();
AiQueryResponse response = client.query(request);
assertThat(response).isNotNull();
assertThat(response.getResponse()).isNotBlank();
assertThat(response.getSources()).isEmpty(); // 必须能处理空数组
}
@Test
@PactTestFor(pactMethod = "questionTooLongContract")
void testQuestionTooLong(MockServer mockServer) {
AiServiceClient client = createClient(mockServer.getUrl());
AiQueryRequest request = AiQueryRequest.builder()
.question("a".repeat(5000))
.sessionId("sess-011")
.userId("user-001")
.build();
// 消费者必须能正确处理422错误
// 不能让这个错误无声地吞掉
try {
client.query(request);
} catch (AiServiceException e) {
assertThat(e.getErrorCode()).isEqualTo("QUESTION_TOO_LONG");
}
}
private AiServiceClient createClient(String baseUrl) {
// 创建测试用Client实例
return new AiServiceClient(WebClient.builder().build(), baseUrl);
}
}FAQ
Q1:契约测试能替代集成测试吗?
不能。契约测试验证的是接口契约的兼容性,集成测试验证的是完整业务流程。两者互补:契约测试快(秒级)、不需要完整环境,适合在 CI 早期阶段运行;集成测试慢(分钟级)、需要完整依赖,用于验证业务逻辑正确性。
Q2:AI 响应的内容完全随机,契约测试还有意义吗?
有,而且非常有意义。契约测试不验证内容,只验证结构。对于 AI 服务来说,"response 字段是 String 类型且不为 null"、"usage.totalTokens 是正整数"这样的结构契约,足以捕获 90% 的破坏性接口变更。
Q3:Provider 端的契约验证测试,mock 了 RagService,是否测试了真正的 AI 能力?
没有,也不需要。契约测试的目标是验证接口兼容性,不是功能正确性。AI 能力的测试应该通过 AI 评估框架(如 RAGAS、DeepEval)单独进行。
Q4:如果 AI 服务提供者是第三方(如 OpenAI),还能做契约测试吗?
对于第三方 Provider,可以使用"Consumer-Driven Contract without Provider Verification"模式:只做消费者端测试,验证你的消费者代码能正确处理 API 文档描述的响应结构。同时用 WireMock 录制真实响应,定期比对是否有变化。
Q5:Pact Broker 必须部署吗?本地能用吗?
本地开发阶段,契约文件可以通过文件系统共享(消费者生成到 ./pacts 目录,提供者从该目录读取)。Pact Broker 在团队协作和 CI/CD 阶段才是必需的,它提供了版本追踪、can-i-deploy 等企业级功能。
总结
| 测试层次 | 工具 | 运行时间 | 适用场景 |
|---|---|---|---|
| 契约测试 | Pact | 秒级 | 接口兼容性验证 |
| AI评估测试 | RAGAS/DeepEval | 分钟级 | AI响应质量验证 |
| 集成测试 | TestContainers | 5-15分钟 | 完整业务流程 |
| E2E测试 | Playwright | 30分钟+ | 用户旅程验证 |
契约测试是 AI 服务稳定性的第一道防线。它不能保证 AI 回答的质量,但可以保证 AI 服务的接口不会悄悄变化,在问题到达生产环境之前就被拦截。
李明的团队,在引入 Pact 契约测试的三个月后,再也没有因为接口不兼容导致的故障了。
