feat(后端):AiServiceClient 改为 submit+poll 异步任务模式

- 内部实现重构:每个公有方法内部 submitAndWait() → submitTask() + pollResult()
- submitTask(): POST /ai/tasks,返回 taskId(30s 超时)
- pollResult(): 轮询 GET /ai/tasks/:taskId,500ms 首次延迟,
  2s × 10 次后退避到 5s,总超时 600s(10 min)
- 公有方法签名完全不变:aggregateReport()、generateMindMap()、compareRecall()
- 异常失败时返回 Optional.empty(),调用方降级逻辑不变
- application.yml timeout-seconds: 180 → 600
This commit is contained in:
2026-07-04 16:41:21 +08:00
parent 4ec900577e
commit 55b7c1c83c
2 changed files with 126 additions and 58 deletions
@@ -13,6 +13,7 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -20,6 +21,10 @@ import java.util.Optional;
/**
* lpt-ai 独立 AI 服务客户端。
* <p>通过 {@code lpt.ai-service.url} 配置服务地址;未配置或调用失败时返回 empty,由调用方降级。</p>
* <p>
* 内部使用异步任务模式:提交任务到 lpt-ai 后轮询等待结果,
* 避免同步等待 LLM 响应时阻塞 HTTP 连接。
* </p>
*/
@Slf4j
@Component
@@ -29,8 +34,8 @@ public class AiServiceClient {
/** lpt-ai 服务地址,如 http://localhost:5199 */
private String url;
/** 超时秒数 */
private int timeoutSeconds = 90;
/** 超时秒数(等待 AI 任务完成的最长时间) */
private int timeoutSeconds = 600;
private HttpClient httpClient;
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -41,9 +46,9 @@ public class AiServiceClient {
.connectTimeout(Duration.ofSeconds(10))
.build();
if (isConfigured()) {
log.info("AiServiceClient 已配置: {}", url);
log.info("AiServiceClient 已配置: {} (超时 {}s, 异步任务模式)", url, timeoutSeconds);
} else {
log.info("AiServiceClient 未配置,AI 聚合功能不可用(将降级为拼接");
log.info("AiServiceClient 未配置,AI 功能不可用(将降级为内置逻辑");
}
}
@@ -51,10 +56,10 @@ public class AiServiceClient {
return url != null && !url.isBlank();
}
// ============ 公开 API(签名不变,内部改为 submit + poll============
/**
* 调用 lpt-ai 将残片聚合为学习报告草稿。
*
* @return 聚合后的报告文本;服务未配置或调用失败时返回 empty
*/
public Optional<String> aggregateReport(String taskName, List<String> fragments, String expectation) {
if (!isConfigured() || fragments == null || fragments.isEmpty()) {
@@ -64,36 +69,20 @@ public class AiServiceClient {
List<Map<String, String>> fragmentBodies = fragments.stream()
.map(content -> Map.of("content", content))
.toList();
Map<String, Object> body = expectation == null || expectation.isBlank()
Map<String, Object> params = expectation == null || expectation.isBlank()
? Map.of("taskName", taskName, "fragments", fragmentBodies)
: Map.of("taskName", taskName, "fragments", fragmentBodies, "expectation", expectation);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url + "/ai/aggregate-report"))
.timeout(Duration.ofSeconds(timeoutSeconds))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.warn("lpt-ai 聚合报告失败: status={}, body={}", response.statusCode(),
response.body() != null ? response.body().substring(0, Math.min(200, response.body().length())) : "");
return Optional.empty();
}
JsonNode json = objectMapper.readTree(response.body());
String report = json.path("report").asText(null);
return Optional.ofNullable(report).filter(s -> !s.isBlank());
Optional<JsonNode> result = submitAndWait("aggregate-report", params);
return result.map(r -> r.path("report").asText(null)).filter(s -> s != null && !s.isBlank());
} catch (Exception e) {
log.warn("lpt-ai 调用异常,将降级: {}", e.getMessage());
log.warn("lpt-ai 聚合报告异常,将降级: {}", e.getMessage());
return Optional.empty();
}
}
/**
* 调用 lpt-ai 从学习数据生成思维导图大纲(缩进文本格式)
*
* @return 缩进大纲文本;服务未配置或调用失败时返回 empty
* 调用 lpt-ai 从学习数据生成思维导图大纲。
*/
public Optional<String> generateMindMap(String taskName, String taskDescription,
List<String> reports, List<String> fragments) {
@@ -101,69 +90,148 @@ public class AiServiceClient {
return Optional.empty();
}
try {
Map<String, Object> body = Map.of(
Map<String, Object> params = Map.of(
"taskName", taskName,
"taskDescription", taskDescription != null ? taskDescription : "",
"reports", reports != null ? reports : List.of(),
"fragments", fragments != null ? fragments : List.of()
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url + "/ai/generate-mind-map"))
.timeout(Duration.ofSeconds(timeoutSeconds))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.warn("lpt-ai 生成思维导图失败: status={}, body={}", response.statusCode(),
response.body() != null ? response.body().substring(0, Math.min(200, response.body().length())) : "");
return Optional.empty();
}
JsonNode json = objectMapper.readTree(response.body());
String outline = json.path("outline").asText(null);
return Optional.ofNullable(outline).filter(s -> !s.isBlank());
Optional<JsonNode> result = submitAndWait("generate-mind-map", params);
return result.map(r -> r.path("outline").asText(null)).filter(s -> s != null && !s.isBlank());
} catch (Exception e) {
log.warn("lpt-ai 思维导图调用异常,将降级: {}", e.getMessage());
log.warn("lpt-ai 思维导图异常,将降级: {}", e.getMessage());
return Optional.empty();
}
}
/**
* 调用 lpt-ai 进行语义化回忆对比。
* 注意:AI 返回平铺的匹配列表,不返回完整树结构。
* 树标注(MATCHED/MISSED)由 StandardMindMapServiceImpl 基于本方法返回的 JSON 完成。
*
* @return AI 返回的原始 JSON;服务未配置或调用失败时返回 empty
*/
public Optional<JsonNode> compareRecall(String taskName, String standardOutline, String recallOutline) {
if (!isConfigured()) {
return Optional.empty();
}
try {
Map<String, String> body = Map.of(
Map<String, Object> params = Map.of(
"taskName", taskName,
"standardOutline", standardOutline,
"recallOutline", recallOutline
);
return submitAndWait("compare-recall", params);
} catch (Exception e) {
log.warn("lpt-ai 回忆对比异常,将降级为内置算法: {}", e.getMessage());
return Optional.empty();
}
}
// ============ 内部异步 submit + poll ============
/** 提交任务并同步等待结果(内部轮询,调用方无感知) */
private Optional<JsonNode> submitAndWait(String type, Map<String, Object> params) {
String taskId = submitTask(type, params);
if (taskId == null) {
return Optional.empty();
}
return pollResult(taskId);
}
/** 提交任务到 lpt-ai /ai/tasks */
private String submitTask(String type, Map<String, Object> params) {
try {
Map<String, Object> body = Map.of("type", type, "params", params);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url + "/ai/compare-recall"))
.timeout(Duration.ofSeconds(timeoutSeconds))
.uri(URI.create(url + "/ai/tasks"))
.timeout(Duration.ofSeconds(30)) // 提交本身不应超时
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.warn("lpt-ai 回忆对比失败: status={}, body={}", response.statusCode(),
if (response.statusCode() != 201) {
log.warn("lpt-ai 提交任务失败: type={}, status={}, body={}",
type, response.statusCode(),
response.body() != null ? response.body().substring(0, Math.min(200, response.body().length())) : "");
return Optional.empty();
return null;
}
return Optional.of(objectMapper.readTree(response.body()));
JsonNode json = objectMapper.readTree(response.body());
String taskId = json.path("taskId").asText(null);
if (taskId == null || taskId.isBlank()) {
log.warn("lpt-ai 提交任务成功但未返回 taskId");
return null;
}
log.info("lpt-ai 任务已提交: type={}, taskId={}", type, taskId);
return taskId;
} catch (Exception e) {
log.warn("lpt-ai 回忆对比调用异常,将降级为内置算法: {}", e.getMessage());
log.warn("lpt-ai 提交任务异常: {}", e.getMessage());
return null;
}
}
/** 轮询等待任务完成 */
private Optional<JsonNode> pollResult(String taskId) {
Instant deadline = Instant.now().plusSeconds(timeoutSeconds);
int pollCount = 0;
try {
// 给 worker 一点启动时间
Thread.sleep(500);
while (Instant.now().isBefore(deadline)) {
pollCount++;
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url + "/ai/tasks/" + taskId))
.timeout(Duration.ofSeconds(10))
.GET()
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 404) {
log.warn("lpt-ai 任务 {} 不存在(可能已过期或被清理)", taskId);
return Optional.empty();
}
if (response.statusCode() != 200) {
log.warn("lpt-ai 查询任务失败: taskId={}, status={}", taskId, response.statusCode());
// 临时故障,继续轮询
Thread.sleep(2000);
continue;
}
JsonNode json = objectMapper.readTree(response.body());
String status = json.path("status").asText("");
switch (status) {
case "done":
log.info("lpt-ai 任务完成: taskId={}, 轮询次数={}", taskId, pollCount);
return Optional.ofNullable(json.path("result"));
case "failed":
String err = json.path("error").asText("未知错误");
log.warn("lpt-ai 任务失败: taskId={}, error={}", taskId, err);
return Optional.empty();
case "pending":
case "running":
// 还在处理,继续轮询
break;
default:
log.warn("lpt-ai 未知任务状态: taskId={}, status={}", taskId, status);
return Optional.empty();
}
} catch (Exception e) {
log.warn("lpt-ai 轮询异常 (第{}次): {}", pollCount, e.getMessage());
}
// 退避:前 10 次 2s,之后 5s
long delay = pollCount <= 10 ? 2000 : 5000;
Thread.sleep(delay);
}
log.warn("lpt-ai 任务超时: taskId={}, timeout={}s", taskId, timeoutSeconds);
return Optional.empty();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("lpt-ai 轮询被中断: taskId={}", taskId);
return Optional.empty();
}
}
+1 -1
View File
@@ -56,4 +56,4 @@ management:
lpt:
ai-service:
url: http://localhost:5199
timeout-seconds: 180
timeout-seconds: 600