Spring AI + GraphQL:AI查询接口的GraphQL封装实战
2026/4/30大约 7 分钟
Spring AI + GraphQL:AI查询接口的GraphQL封装实战
适读人群:熟悉Spring Boot、想用GraphQL封装AI能力的Java工程师 阅读时长:约16分钟
为什么要把AI接口GraphQL化
之前做一个AI分析平台,前端需要同时查询:用户画像分析结果、产品推荐列表、用户最近的对话记录,以及一段AI生成的个性化文案。
用REST API的话,前端要发四个请求,等四个响应,再自己拼接展示。
换成GraphQL之后,一个查询搞定,前端还可以精确控制要哪些字段,不多取不少取。
但AI接口做GraphQL封装有几个特殊挑战:
- 流式响应:AI生成是流式的,GraphQL原生支持Subscription,但实现起来有坑
- Prompt在哪管理:AI查询有Prompt,GraphQL Query里带Prompt字段太丑
- 响应时间长:AI生成可能10秒以上,GraphQL默认超时怎么处理
这篇文章就是我们在实际项目里摸索出来的完整方案。
整体架构
第一步:引入依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
</dependencies>第二步:定义GraphQL Schema
# src/main/resources/graphql/ai-schema.graphqls
type Query {
# AI问答查询
aiAsk(input: AskInput!): AskResult!
# RAG知识库查询
knowledgeSearch(query: String!, topK: Int = 5): [KnowledgeChunk!]!
# AI摘要生成
summarize(docId: String!, style: SummaryStyle = CONCISE): Summary!
# 用户对话历史
conversationHistory(sessionId: String!, limit: Int = 20): [Message!]!
}
type Mutation {
# 创建新对话会话
createSession(userId: String!): Session!
# 发送消息(同步,适合短回答)
sendMessage(input: SendMessageInput!): Message!
# 上传文档到知识库
uploadDocument(input: UploadDocInput!): Document!
}
type Subscription {
# 流式AI回答(适合长回答)
streamAnswer(input: AskInput!): StreamToken!
# 文档处理进度
documentProcessProgress(docId: String!): ProcessProgress!
}
input AskInput {
question: String!
sessionId: String
contextDocIds: [String!]
}
input SendMessageInput {
sessionId: String!
content: String!
}
input UploadDocInput {
fileName: String!
fileBase64: String!
department: String
visibility: DocVisibility = DEPARTMENT
}
type AskResult {
answer: String!
sourceDocs: [SourceDoc!]!
confidence: Float!
sessionId: String!
}
type KnowledgeChunk {
id: String!
content: String!
docTitle: String!
pageNum: Int
similarity: Float!
}
type Summary {
text: String!
keyPoints: [String!]!
generatedAt: String!
}
type Message {
id: String!
role: MessageRole!
content: String!
createdAt: String!
sourceDocs: [SourceDoc]
}
type StreamToken {
token: String!
isLast: Boolean!
sourceDocs: [SourceDoc]
}
type SourceDoc {
docId: String!
title: String!
pageNum: Int
excerpt: String!
}
type Session {
id: String!
userId: String!
createdAt: String!
}
type Document {
id: String!
title: String!
status: DocStatus!
}
type ProcessProgress {
docId: String!
stage: String!
progress: Int!
message: String
}
enum MessageRole { USER ASSISTANT SYSTEM }
enum SummaryStyle { CONCISE DETAILED BULLET_POINTS }
enum DocVisibility { PUBLIC DEPARTMENT PRIVATE }
enum DocStatus { DRAFT PROCESSING PUBLISHED FAILED }第三步:Query解析器实现
@Controller
@Slf4j
public class AiQueryController {
private final ChatClient chatClient;
private final HybridRetrievalService retrievalService;
private final ConversationService conversationService;
@QueryMapping
public AskResult aiAsk(@Argument AskInput input, DataFetchingEnvironment env) {
String userId = extractUserId(env);
log.info("GraphQL aiAsk: userId={}, question={}", userId, input.getQuestion());
// 检索相关文档
List<Document> sourceDocs = retrievalService.retrieve(
RetrievalRequest.builder()
.query(input.getQuestion())
.userId(userId)
.build()).getDocs();
// 组装上下文
String context = sourceDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n"));
// AI生成回答
String answer = chatClient.prompt()
.system("""
你是专业的知识库助手,基于提供的参考文档回答问题。
如果文档不足以回答,说明情况,不要编造。
""")
.user(String.format("[参考文档]\n%s\n\n[问题]\n%s", context, input.getQuestion()))
.call()
.content();
// 计算置信度(基于最高相似度分数)
double confidence = sourceDocs.stream()
.mapToDouble(d -> (double) d.getMetadata().getOrDefault("score", 0.0))
.max()
.orElse(0.5);
// 保存到对话历史
String sessionId = input.getSessionId() != null ?
input.getSessionId() : UUID.randomUUID().toString();
conversationService.saveMessage(sessionId, userId,
MessageRole.USER, input.getQuestion(), null);
conversationService.saveMessage(sessionId, userId,
MessageRole.ASSISTANT, answer,
sourceDocs.stream().map(Document::getId).collect(Collectors.toList()));
return AskResult.builder()
.answer(answer)
.sourceDocs(toSourceDocs(sourceDocs))
.confidence(confidence)
.sessionId(sessionId)
.build();
}
@QueryMapping
public List<KnowledgeChunk> knowledgeSearch(
@Argument String query,
@Argument Integer topK,
DataFetchingEnvironment env) {
String userId = extractUserId(env);
List<Document> docs = retrievalService.retrieve(
RetrievalRequest.builder()
.query(query)
.userId(userId)
.topK(topK != null ? topK : 5)
.build()).getDocs();
return docs.stream()
.map(doc -> KnowledgeChunk.builder()
.id(doc.getId())
.content(doc.getContent())
.docTitle((String) doc.getMetadata().getOrDefault("title", "未知"))
.pageNum((Integer) doc.getMetadata().get("pageNum"))
.similarity((Double) doc.getMetadata().getOrDefault("score", 0.0))
.build())
.collect(Collectors.toList());
}
@QueryMapping
public Summary summarize(@Argument String docId, @Argument SummaryStyle style) {
Document doc = documentService.findById(docId);
String content = doc.getContent();
String styleInstruction = switch (style) {
case CONCISE -> "用3-5句话总结核心内容";
case DETAILED -> "详细总结,保留重要细节和数据";
case BULLET_POINTS -> "用要点列表格式总结,每个要点一行,以'-'开头";
};
String summaryText = chatClient.prompt()
.user(String.format("请%s:\n\n%s", styleInstruction, content))
.call()
.content();
// 提取关键点(只在BULLET_POINTS模式下做解析)
List<String> keyPoints = style == SummaryStyle.BULLET_POINTS ?
extractBulletPoints(summaryText) : List.of();
return Summary.builder()
.text(summaryText)
.keyPoints(keyPoints)
.generatedAt(LocalDateTime.now().toString())
.build();
}
private String extractUserId(DataFetchingEnvironment env) {
// 从GraphQL Context中取用户信息(在拦截器里设置)
GraphQLContext context = env.getGraphQlContext();
return context.get("userId");
}
}第四步:Subscription流式实现
这是整个方案里最有技术含量的部分——用GraphQL Subscription实现AI流式输出:
@Controller
@Slf4j
public class AiSubscriptionController {
private final ChatClient chatClient;
private final HybridRetrievalService retrievalService;
/**
* GraphQL Subscription - 流式AI回答
* 返回Flux,Spring for GraphQL会自动转成WebSocket推送
*/
@SubscriptionMapping
public Flux<StreamToken> streamAnswer(
@Argument AskInput input,
DataFetchingEnvironment env) {
String userId = extractUserId(env);
log.info("GraphQL streamAnswer: userId={}, question={}", userId, input.getQuestion());
// 先同步检索文档(检索很快,不需要流式)
List<Document> sourceDocs = retrievalService.retrieve(
RetrievalRequest.builder()
.query(input.getQuestion())
.userId(userId)
.build()).getDocs();
String context = sourceDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n"));
List<SourceDoc> sourceDocDtos = toSourceDocs(sourceDocs);
// 流式生成,转换成StreamToken
Flux<StreamToken> tokenStream = chatClient.prompt()
.system("你是专业助手,基于参考文档回答,不确定的说明。")
.user(String.format("[参考文档]\n%s\n\n[问题]\n%s", context, input.getQuestion()))
.stream()
.content()
.map(token -> StreamToken.builder()
.token(token)
.isLast(false)
.build());
// 在流末尾追加一个带source的结束标记
Flux<StreamToken> endSignal = Flux.just(StreamToken.builder()
.token("")
.isLast(true)
.sourceDocs(sourceDocDtos)
.build());
return tokenStream
.concatWith(endSignal)
.doOnError(e -> log.error("流式生成出错: {}", e.getMessage()))
.onErrorReturn(StreamToken.builder()
.token("抱歉,生成过程出现问题,请重试。")
.isLast(true)
.build());
}
@SubscriptionMapping
public Flux<ProcessProgress> documentProcessProgress(@Argument String docId) {
// 返回文档处理进度的实时推送
return documentProcessingService.getProgressFlux(docId)
.map(progress -> ProcessProgress.builder()
.docId(docId)
.stage(progress.getStage())
.progress(progress.getPercent())
.message(progress.getMessage())
.build())
.timeout(Duration.ofMinutes(30));
}
}第五步:GraphQL配置
# application.yml
spring:
graphql:
websocket:
path: /graphql-ws
graphiql:
enabled: true # 开发环境开启GraphiQL调试界面
schema:
locations: classpath:graphql/
file-extensions: .graphqls
cors:
allowed-origins: "http://localhost:3000"// GraphQL安全拦截器
@Component
public class GraphQLAuthInterceptor implements WebGraphQlInterceptor {
private final JwtTokenService jwtService;
@Override
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
String token = request.getHeaders().getFirst("Authorization");
if (token == null || !token.startsWith("Bearer ")) {
// 对非认证请求,可以选择拦截或给匿名身份
return chain.next(request);
}
try {
String userId = jwtService.extractUserId(token.substring(7));
// 把userId注入GraphQL Context,resolver里可以取到
request.configureExecutionInput((input, builder) ->
builder.graphQLContext(ctx -> ctx.put("userId", userId)));
} catch (Exception e) {
log.warn("Token解析失败: {}", e.getMessage());
}
return chain.next(request);
}
}前端GraphQL调用示例
这部分展示GraphQL的优势,前端可以精确控制要哪些字段:
# 普通查询 - 只要answer和来源文档标题
query AskQuestion {
aiAsk(input: { question: "年假政策是什么?", sessionId: "sess-123" }) {
answer
confidence
sourceDocs {
title
pageNum
}
}
}
# 流式订阅
subscription StreamAsk {
streamAnswer(input: { question: "详细介绍一下我们的绩效考核制度" }) {
token
isLast
sourceDocs {
title
excerpt
}
}
}
# 批量查询 - 一次请求获取多种数据
query BatchAiData {
summary: summarize(docId: "doc-001", style: BULLET_POINTS) {
keyPoints
}
relatedDocs: knowledgeSearch(query: "绩效考核") {
docTitle
similarity
}
history: conversationHistory(sessionId: "sess-123", limit: 5) {
role
content
createdAt
}
}性能优化:DataLoader解决N+1问题
GraphQL场景下,如果sourceDocs字段涉及批量查询,要用DataLoader:
@Configuration
public class DataLoaderConfig {
@Bean
public RuntimeWiringConfigurer runtimeWiringConfigurer(
DocumentService documentService) {
return wiringBuilder -> wiringBuilder
.type("SourceDoc", typeWiring -> typeWiring
.dataFetcher("document", env -> {
String docId = env.getSource();
DataLoader<String, Document> loader =
env.getDataLoader("documentLoader");
return loader.load(docId);
}));
}
@Bean
public DataLoaderRegistrar documentDataLoaderRegistrar(
DocumentService documentService) {
return registry -> registry.register("documentLoader",
DataLoaderFactory.newDataLoader(docIds -> {
// 批量查询,避免N+1
List<Document> docs = documentService.findAllById(docIds);
Map<String, Document> docMap = docs.stream()
.collect(Collectors.toMap(Document::getId, d -> d));
return CompletableFuture.completedFuture(
docIds.stream().map(docMap::get).collect(Collectors.toList()));
}));
}
}GraphQL vs REST 对比
| 维度 | REST | GraphQL |
|---|---|---|
| 数据获取 | 固定字段,可能过多或过少 | 精确控制需要的字段 |
| 多资源查询 | 多次HTTP请求 | 单次查询 |
| 流式响应 | SSE/WebSocket自定义 | Subscription标准化 |
| 版本管理 | 需要v1/v2版本 | 通过字段废弃(@deprecated)演进 |
| 类型系统 | 需要额外Swagger | 内置强类型Schema |
| AI场景适配 | 可以,但不如GraphQL优雅 | Subscription天然适合流式AI输出 |
小结
GraphQL封装AI接口的几个核心价值:
- Subscription解决流式输出:比SSE更标准,前端开发体验更好
- Schema即文档:AI接口类型化,前后端协作更清晰
- 批量查询减少请求数:一次查询拿多个AI分析结果
主要注意点:流式Subscription需要WebSocket支持;长时间AI操作要设超时;DataLoader解决批量查询的N+1问题。
