第1711篇:AI应用的契约测试——Pact框架保证Provider/Consumer接口兼容
第1711篇:AI应用的契约测试——Pact框架保证Provider/Consumer接口兼容
上周有个同事跑来找我,说他们的AI服务升级之后,前端调用直接500了。排查了两个小时,发现是AI服务的响应结构改了,多了一层嵌套,Consumer端解析逻辑全挂了。我当时就说:你们没做契约测试吧?他一脸茫然——什么是契约测试?
这篇文章就来好好聊聊这个话题。契约测试对于AI应用来说,不是锦上添花,是真正的保命符。
一、为什么AI应用特别需要契约测试
传统微服务里,接口变更相对可控,因为你的服务行为是确定性的。但AI应用不一样,它有三个特殊性:
第一,模型在迭代。你用的可能是第三方大模型API,人家悄悄升了一个版本,响应字段名变了,你不知道。
第二,Prompt在优化。你为了提升效果改了Prompt,但改完之后输出的JSON结构悄悄变了,Consumer端解析直接挂。
第三,多团队协作。AI能力通常是平台团队提供,业务团队消费。两边沟通成本很高,接口文档经常对不上实际行为。
传统的集成测试能发现这些问题,但代价是什么?启动整个环境,联调,等待,发现问题,修复。这个循环通常需要几十分钟甚至几个小时。
契约测试的核心思路是:把接口的"约定"固化成可执行的测试用例,让双方独立验证。Consumer说"我期望你给我这样的数据",Provider说"我能给你这样的数据",中间有个经纪人(Pact Broker)负责对接。
二、Pact框架核心概念
先把几个概念理清楚:
- Consumer:调用AI服务的一方,比如业务应用
- Provider:提供AI能力的服务,比如你封装的LLM服务
- Pact:Consumer生成的契约文件(JSON格式),描述它期望Provider提供什么
- Pact Broker:存储和管理契约文件的服务器,两边都可以推送/拉取
流程如下:
这个流程的妙处在于:Consumer和Provider完全解耦,各自独立运行测试,但最终在Broker上汇合,确保兼容性。
三、项目依赖配置
Maven依赖:
<dependencies>
<!-- Pact Consumer -->
<dependency>
<groupId>au.com.dius.pact.consumer</groupId>
<artifactId>junit5</artifactId>
<version>4.6.7</version>
<scope>test</scope>
</dependency>
<!-- Pact Provider -->
<dependency>
<groupId>au.com.dius.pact.provider</groupId>
<artifactId>junit5</artifactId>
<version>4.6.7</version>
<scope>test</scope>
</dependency>
<!-- Spring Boot Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- OkHttp for Consumer -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
</dependencies>四、实战:Consumer端写契约测试
假设我们有个业务服务,调用AI分析服务的/api/analyze接口,把用户文本发过去,拿回来情感分析结果。
先定义我们的数据模型:
// Consumer端的数据模型
@Data
public class AnalysisRequest {
private String text;
private String language;
private List<String> analysisTypes;
}
@Data
public class AnalysisResponse {
private String requestId;
private String sentiment; // positive/negative/neutral
private Double sentimentScore; // 0.0 - 1.0
private List<String> keywords;
private String summary;
private Long processTimeMs;
}Consumer端的Pact测试:
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "ai-analysis-service")
class AiAnalysisConsumerPactTest {
// 定义Provider在收到特定请求时应该返回什么
@Pact(consumer = "business-service")
public RequestResponsePact createSentimentAnalysisPact(PactDslWithProvider builder) {
// 构造请求体的匹配规则
DslPart requestBody = new PactDslJsonBody()
.stringType("text", "这个产品真的太好用了,强烈推荐!")
.stringValue("language", "zh")
.array("analysisTypes")
.stringValue("sentiment")
.stringValue("keywords")
.closeArray();
// 构造响应体的匹配规则——注意这里用的是类型匹配,不是值匹配
DslPart responseBody = new PactDslJsonBody()
.stringType("requestId") // 只要是string就行
.stringValue("sentiment", "positive") // 这个值必须是positive
.decimalType("sentimentScore") // 只要是decimal就行
.array("keywords")
.stringType() // 数组元素是string类型就行
.stringType()
.closeArray()
.stringType("summary")
.numberType("processTimeMs");
return builder
.given("AI服务正常运行") // Provider状态
.uponReceiving("情感分析请求") // 交互描述
.method("POST")
.path("/api/analyze")
.headers(Map.of(
"Content-Type", "application/json",
"Authorization", "Bearer test-token"
))
.body(requestBody)
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(responseBody)
.toPact();
}
// 测试Consumer是否能正确处理响应
@Test
@PactTestFor(pactMethod = "createSentimentAnalysisPact")
void testSentimentAnalysisConsumer(MockServer mockServer) {
// 用MockServer的URL创建Client
AiAnalysisClient client = new AiAnalysisClient(mockServer.getUrl(), "test-token");
AnalysisRequest request = new AnalysisRequest();
request.setText("这个产品真的太好用了,强烈推荐!");
request.setLanguage("zh");
request.setAnalysisTypes(List.of("sentiment", "keywords"));
AnalysisResponse response = client.analyze(request);
// 验证Consumer能正确解析响应
assertThat(response).isNotNull();
assertThat(response.getRequestId()).isNotBlank();
assertThat(response.getSentiment()).isEqualTo("positive");
assertThat(response.getSentimentScore()).isBetween(0.0, 1.0);
assertThat(response.getKeywords()).isNotEmpty();
}
// 测试Provider返回错误时的处理
@Pact(consumer = "business-service")
public RequestResponsePact createErrorPact(PactDslWithProvider builder) {
DslPart requestBody = new PactDslJsonBody()
.stringType("text", "") // 空文本,触发错误
.stringValue("language", "zh")
.array("analysisTypes")
.stringValue("sentiment")
.closeArray();
DslPart responseBody = new PactDslJsonBody()
.stringType("errorCode")
.stringType("message")
.booleanValue("success", false);
return builder
.given("AI服务正常运行")
.uponReceiving("空文本分析请求")
.method("POST")
.path("/api/analyze")
.body(requestBody)
.willRespondWith()
.status(400)
.body(responseBody)
.toPact();
}
@Test
@PactTestFor(pactMethod = "createErrorPact")
void testEmptyTextHandling(MockServer mockServer) {
AiAnalysisClient client = new AiAnalysisClient(mockServer.getUrl(), "test-token");
AnalysisRequest request = new AnalysisRequest();
request.setText("");
request.setLanguage("zh");
request.setAnalysisTypes(List.of("sentiment"));
// 验证Consumer正确处理了400错误
assertThatThrownBy(() -> client.analyze(request))
.isInstanceOf(AiAnalysisException.class)
.hasMessageContaining("分析失败");
}
}运行这个测试之后,Pact会在target/pacts目录生成一个JSON文件,这就是契约文件。
五、实战:Provider端验证契约
Provider端需要验证自己的实现是否满足Consumer的契约。
@Provider("ai-analysis-service")
@PactFolder("src/test/pacts") // 从本地加载,生产环境换成Broker
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AiAnalysisProviderPactTest {
@LocalServerPort
private int port;
@MockBean
private LlmService llmService; // Mock掉真实的LLM调用
@BeforeEach
void setup(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
// 处理Provider状态——Consumer定义的"given"条件
@State("AI服务正常运行")
void setupNormalState() {
// 配置Mock LLM服务返回正常结果
LlmAnalysisResult mockResult = LlmAnalysisResult.builder()
.sentiment("positive")
.sentimentScore(0.92)
.keywords(List.of("好用", "推荐", "产品"))
.summary("用户对该产品持非常积极的态度")
.build();
when(llmService.analyze(any())).thenReturn(mockResult);
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
// 添加认证Header(Consumer测试里有Authorization)
context.verifyInteraction();
}
// 如果需要在验证前后做额外操作
@BeforeEach
void addAuthInterceptor(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port, "/",
List.of(new DelegatingMockMvcTestResultInterceptor() {
@Override
public void requestFilters(HttpRequest request) {
// 可以在这里统一处理认证逻辑
}
})
));
}
}更完整的Provider端配置:
@Provider("ai-analysis-service")
@PactBroker(
host = "${PACT_BROKER_HOST:localhost}",
port = "${PACT_BROKER_PORT:9292}",
authentication = @PactBrokerAuth(
username = "${PACT_BROKER_USER:admin}",
password = "${PACT_BROKER_PASSWORD:admin}"
)
)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AiAnalysisProviderBrokerPactTest {
@LocalServerPort
private int port;
@MockBean
private LlmService llmService;
@MockBean
private TokenValidationService tokenValidationService;
@BeforeEach
void setup(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
// 模拟Token验证通过
when(tokenValidationService.validate(anyString())).thenReturn(true);
}
@State("AI服务正常运行")
void setupNormalRunningState() {
setupMockLlmService();
}
@State("AI服务降级模式")
void setupDegradedState() {
// 模拟LLM超时,触发降级逻辑
when(llmService.analyze(any()))
.thenThrow(new LlmTimeoutException("LLM响应超时"));
}
private void setupMockLlmService() {
when(llmService.analyze(argThat(req ->
req.getText() != null && !req.getText().isBlank())))
.thenAnswer(invocation -> {
LlmAnalysisRequest req = invocation.getArgument(0);
// 根据文本内容返回合理的Mock结果
String text = req.getText();
boolean isPositive = text.contains("好") || text.contains("推荐");
return LlmAnalysisResult.builder()
.sentiment(isPositive ? "positive" : "neutral")
.sentimentScore(isPositive ? 0.89 : 0.5)
.keywords(extractSimpleKeywords(text))
.summary("文本分析完成")
.build();
});
when(llmService.analyze(argThat(req ->
req.getText() == null || req.getText().isBlank())))
.thenThrow(new InvalidInputException("文本不能为空"));
}
private List<String> extractSimpleKeywords(String text) {
// 简单的关键词提取,测试用
return List.of("测试关键词1", "测试关键词2");
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPact(PactVerificationContext context) {
context.verifyInteraction();
}
}六、Pact Broker的搭建与配置
用Docker Compose快速搭建:
version: "3"
services:
pact-broker:
image: pactfoundation/pact-broker:latest
ports:
- "9292:9292"
environment:
PACT_BROKER_DATABASE_URL: "sqlite:////tmp/pact_broker.sqlite3"
PACT_BROKER_BASIC_AUTH_USERNAME: admin
PACT_BROKER_BASIC_AUTH_PASSWORD: admin
PACT_BROKER_BASE_URL: "http://localhost:9292"
volumes:
- pact-broker-data:/tmp
volumes:
pact-broker-data:在Consumer端配置发布契约到Broker:
<!-- Maven插件配置 -->
<plugin>
<groupId>au.com.dius.pact.provider</groupId>
<artifactId>maven</artifactId>
<version>4.6.7</version>
<configuration>
<pactBrokerUrl>http://localhost:9292</pactBrokerUrl>
<pactBrokerUsername>admin</pactBrokerUsername>
<pactBrokerPassword>admin</pactBrokerPassword>
<tags>
<tag>develop</tag>
<tag>${git.branch}</tag>
</tags>
</configuration>
</plugin># 发布Consumer契约到Broker
mvn pact:publish
# 验证Provider是否满足所有契约
mvn pact:verify
# 检查是否可以部署(Can I Deploy)
mvn pact:can-i-deploy \
-Dpacticipant=business-service \
-Dversion=${APP_VERSION} \
-Dto=production七、踩坑记录
讲几个我们实际踩过的坑:
坑1:匹配规则太严格
早期我们把所有字段都用stringValue精确匹配,结果Provider测试老是挂,原因是AI输出的summary字段每次都不一样。后来改成stringType,只验证类型不验证值,问题解决了。
坑2:Provider State没有对齐
Consumer定义了given("用户有足够余额"),但Provider端根本没有实现这个State的setup方法。测试一跑,数据库里没数据,直接报错。后来我们建了一个Provider State字典文档,Consumer必须从里面选已有的State,不能随便写。
坑3:大模型响应的数组长度问题
Consumer期望keywords数组至少有一个元素,但LLM有时候返回空数组(文本太短或者完全不相关的情况)。这个要在契约里显式声明:
// 用minArrayLike表示至少有1个元素
DslPart responseBody = new PactDslJsonBody()
.minArrayLike("keywords", 1) // 至少1个元素
.stringType("value")
.closeArray();坑4:时间戳格式
Provider返回的时间戳有时候是Unix毫秒,有时候是ISO 8601字符串,Consumer两种格式都能处理但Pact契约里只定义了一种。后来我们统一规范,全部用ISO 8601,并且在契约里明确指定:
.datetime("createdAt", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")八、契约测试的架构全景
整个流程跑通之后,每次有接口变更,CI流水线会自动验证,而不是等到联调的时候才发现问题。
九、与AI特性的结合
针对AI应用的一些特殊处理:
// 针对非确定性输出的契约定义
@Pact(consumer = "business-service")
public RequestResponsePact createNonDeterministicResponsePact(PactDslWithProvider builder) {
// 对AI输出只验证结构,不验证具体值
DslPart responseBody = new PactDslJsonBody()
.stringType("requestId")
.stringMatcher("sentiment", "positive|negative|neutral") // 枚举匹配
.decimalType("sentimentScore") // 类型匹配
.minArrayLike("keywords", 0) // 可以为空数组
.stringType()
.closeArray()
.stringType("summary") // 只验证是string
.numberType("processTimeMs") // 只验证是number
.booleanValue("cached", false); // 确定性字段精确匹配
return builder
.given("AI服务正常运行")
.uponReceiving("标准情感分析请求")
.method("POST")
.path("/api/analyze")
.body(new PactDslJsonBody()
.stringType("text")
.stringValue("language", "zh"))
.willRespondWith()
.status(200)
.body(responseBody)
.toPact();
}核心原则就一句话:AI输出的确定性部分用精确匹配,非确定性部分用类型或正则匹配。
十、CI/CD集成
GitHub Actions的配置:
name: Contract Tests
on:
push:
branches: [main, develop]
pull_request:
jobs:
consumer-contract-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Run Consumer Pact Tests
run: mvn test -pl consumer-service -Dtest=*PactTest
- name: Publish Pacts to Broker
run: mvn pact:publish -pl consumer-service
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_USERNAME: ${{ secrets.PACT_BROKER_USERNAME }}
PACT_BROKER_PASSWORD: ${{ secrets.PACT_BROKER_PASSWORD }}
provider-contract-verify:
runs-on: ubuntu-latest
needs: consumer-contract-test
steps:
- uses: actions/checkout@v4
- name: Run Provider Verification
run: mvn test -pl ai-analysis-service -Dtest=*ProviderPactTest
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
- name: Can I Deploy Check
run: |
mvn pact:can-i-deploy \
-Dpacticipant=ai-analysis-service \
-Dversion=${{ github.sha }} \
-Dto=production总结
契约测试在AI应用里真的很值得投入。它解决的核心问题是:在系统演化过程中,如何确保服务间的接口契约不被悄悄破坏。
对于AI服务来说,这个问题尤其突出,因为Prompt调优、模型升级、功能迭代都可能导致接口行为变化,而这些变化在代码层面往往看不出来,只有在运行时才爆。
实践建议:
- 从Consumer端开始写,先定义你想要什么,再看Provider能给什么
- 匹配规则要合理,AI输出尽量用类型匹配而非值匹配
- Provider State一定要仔细管理,建立状态字典
- 把Can I Deploy集成到发布流程里,变成强制门禁
契约测试不能替代端到端测试,但它能把很多问题提前暴露,大幅降低联调成本。
