refactor: ai功能改造

This commit is contained in:
qiufeng
2026-07-01 23:23:30 +08:00
parent f78b70be1f
commit 35252b56af
13 changed files with 618 additions and 342 deletions
+26 -2
View File
@@ -18,21 +18,45 @@
</properties>
<dependencies>
<!-- Mysql Connector -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>
<!-- 核心工具(StringUtils、ServiceConstants、Jackson等) -->
<dependency>
<groupId>cn.com.mfish</groupId>
<artifactId>mf-common-web</artifactId>
<artifactId>mf-common-core</artifactId>
</dependency>
<!-- AI公共模块(AiConfig、ChatResponseVo、AiRouteService等) -->
<dependency>
<groupId>cn.com.mfish</groupId>
<artifactId>mf-common-ai</artifactId>
</dependency>
<!-- 数据源(MyBatis-Plus等,AI模型配置表需要) -->
<dependency>
<groupId>cn.com.mfish</groupId>
<artifactId>mf-common-ds</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
<!-- WebFlux替代ServletLLM长连接不占用线程 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- WebFlux版Swagger -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
<version>${swagger.doc.version}</version>
</dependency>
<!-- Jackson序列化(WebFlux不自动引入,需显式声明) -->
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
</project>
</project>
@@ -0,0 +1,242 @@
package cn.com.mfish.ai.controller;
import cn.com.mfish.ai.dto.ChatCompletionDto;
import cn.com.mfish.ai.service.LlmModelRouter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.json.JsonMapper;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* LLM统一代理控制器(WebFlux版)
* 对外提供OpenAI兼容的 /v1/chat/completions 接口
* <p>
* 改进:
* 1. 多模型路由 + fallback链(通过LlmModelRouter
* 2. 使用Spring AI Message列表保持对话结构
* 3. 使用Jackson ObjectMapper序列化,替代手工拼接JSON
* 4. 支持流式(SSE)和非流式(JSON)双模式
* 5. WebFlux非Servlet架构,LLM长连接不占用Servlet线程
*
* @author: mfish
* @date: 2026/07/01
*/
@RestController
@RequestMapping("/v1")
@Slf4j
public class LlmProxyController {
private final LlmModelRouter modelRouter;
/** 使用snake_case命名策略的ObjectMapper,对齐OpenAI兼容格式的JSON键名 */
private final ObjectMapper snakeCaseMapper;
public LlmProxyController(LlmModelRouter modelRouter) {
this.modelRouter = modelRouter;
this.snakeCaseMapper = JsonMapper.builder()
.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.build();
}
/**
* OpenAI兼容的聊天补全接口
* 根据 stream 参数自动切换流式/非流式响应
*/
@PostMapping("/chat/completions")
public Mono<Void> chatCompletions(@RequestBody ChatCompletionDto.Request request,
ServerHttpResponse response) {
String model = request.getModel();
List<Message> messages = toSpringAiMessages(request.getMessages());
Prompt prompt = buildPrompt(messages, request);
if (Boolean.TRUE.equals(request.getStream())) {
return handleStream(response, prompt, model);
}
return handleNonStream(response, prompt, model);
}
// ======================== 流式处理 ========================
private Mono<Void> handleStream(ServerHttpResponse response, Prompt prompt, String model) {
String completionId = generateCompletionId();
response.getHeaders().setContentType(MediaType.TEXT_EVENT_STREAM);
return response.writeAndFlushWith(
modelRouter.streamWithFallback(prompt, model)
.mapNotNull(chatResponse -> {
String content = extractContent(chatResponse);
if (content == null || content.isEmpty()) {
return null;
}
ChatCompletionDto.Chunk chunk = buildChunk(completionId, model, content);
return "data:" + writeJson(chunk) + "\n\n";
})
.concatWith(Mono.just("data:[DONE]\n\n"))
.onErrorResume(ex -> {
log.error("[LLM代理] 流式调用失败", ex);
ChatCompletionDto.Chunk errorChunk = buildChunk(completionId, model,
"AI服务暂时不可用,请稍后再试。");
return Flux.just(
"data:" + writeJson(errorChunk) + "\n\n",
"data:[DONE]\n\n"
);
})
.map(s -> Mono.just(response.bufferFactory()
.wrap(s.getBytes(StandardCharsets.UTF_8))))
);
}
// ======================== 非流式处理 ========================
private Mono<Void> handleNonStream(ServerHttpResponse response, Prompt prompt, String model) {
String completionId = generateCompletionId();
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
return modelRouter.callWithFallback(prompt, model)
.map(chatResponse -> buildResponse(completionId, model, chatResponse))
.onErrorResume(ex -> {
log.error("[LLM代理] 同步调用失败", ex);
return Mono.just(buildErrorResponse(completionId, model,
"AI服务暂时不可用,请稍后再试。"));
})
.map(resp -> response.bufferFactory()
.wrap(writeJson(resp).getBytes(StandardCharsets.UTF_8)))
.flatMap(buffer -> response.writeWith(Mono.just(buffer)));
}
// ======================== Message转换 ========================
/**
* 将请求中的messages转换为Spring AI的Message列表,保持对话结构
* 替代原有的字符串拼接方式,保留system/user/assistant角色信息
*/
private List<Message> toSpringAiMessages(List<ChatCompletionDto.RequestMessage> messages) {
if (messages == null || messages.isEmpty()) {
return List.of(new UserMessage("你好"));
}
List<Message> result = new ArrayList<>(messages.size());
for (ChatCompletionDto.RequestMessage msg : messages) {
String role = msg.getRole() != null ? msg.getRole().toLowerCase() : "user";
switch (role) {
case "system" -> result.add(new SystemMessage(msg.getContent()));
case "assistant" -> result.add(new AssistantMessage(msg.getContent()));
default -> result.add(new UserMessage(msg.getContent()));
}
}
return result;
}
private Prompt buildPrompt(List<Message> messages, ChatCompletionDto.Request request) {
OpenAiChatOptions options = OpenAiChatOptions.builder()
.model(request.getModel() != null ? request.getModel() : "default")
.maxTokens(request.getMaxTokens() != null ? request.getMaxTokens() : 1000)
.temperature(request.getTemperature() != null ? request.getTemperature() : 0.7)
.build();
return new Prompt(messages, options);
}
// ======================== 响应构建 ========================
private ChatCompletionDto.Chunk buildChunk(String id, String model, String content) {
ChatCompletionDto.Chunk chunk = new ChatCompletionDto.Chunk();
chunk.setId(id);
chunk.setCreated(Instant.now().getEpochSecond());
chunk.setModel(model != null ? model : "default");
ChatCompletionDto.ChunkChoice choice = new ChatCompletionDto.ChunkChoice();
choice.setIndex(0);
ChatCompletionDto.Delta delta = new ChatCompletionDto.Delta();
delta.setContent(content);
choice.setDelta(delta);
chunk.setChoices(List.of(choice));
return chunk;
}
private ChatCompletionDto.Response buildResponse(String id, String model, ChatResponse chatResponse) {
ChatCompletionDto.Response response = new ChatCompletionDto.Response();
response.setId(id);
response.setCreated(Instant.now().getEpochSecond());
response.setModel(model != null ? model : "default");
ChatCompletionDto.ResponseChoice choice = new ChatCompletionDto.ResponseChoice();
choice.setIndex(0);
ChatCompletionDto.ResponseMessage message = new ChatCompletionDto.ResponseMessage();
message.setContent(extractContent(chatResponse));
choice.setMessage(message);
choice.setFinishReason("stop");
response.setChoices(List.of(choice));
// 尝试提取usage信息
try {
if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
var usageData = chatResponse.getMetadata().getUsage();
ChatCompletionDto.Usage usage = new ChatCompletionDto.Usage();
usage.setPromptTokens(usageData.getPromptTokens().intValue());
usage.setCompletionTokens(usageData.getCompletionTokens().intValue());
usage.setTotalTokens(usageData.getTotalTokens().intValue());
response.setUsage(usage);
}
} catch (Exception e) {
log.debug("[LLM代理] 获取usage信息失败,跳过", e);
}
return response;
}
private ChatCompletionDto.Response buildErrorResponse(String id, String model, String errorMessage) {
ChatCompletionDto.Response response = new ChatCompletionDto.Response();
response.setId(id);
response.setCreated(Instant.now().getEpochSecond());
response.setModel(model != null ? model : "default");
ChatCompletionDto.ResponseChoice choice = new ChatCompletionDto.ResponseChoice();
choice.setIndex(0);
ChatCompletionDto.ResponseMessage message = new ChatCompletionDto.ResponseMessage();
message.setContent(errorMessage);
choice.setMessage(message);
choice.setFinishReason("stop");
response.setChoices(List.of(choice));
return response;
}
// ======================== 工具方法 ========================
private String extractContent(ChatResponse chatResponse) {
if (chatResponse == null || chatResponse.getResult() == null) {
return null;
}
return chatResponse.getResult().getOutput().getText();
}
private String generateCompletionId() {
return "chatcmpl-" + UUID.randomUUID().toString().replace("-", "").substring(0, 24);
}
private String writeJson(Object value) {
try {
return snakeCaseMapper.writeValueAsString(value);
} catch (Exception e) {
log.error("[LLM代理] JSON序列化失败", e);
return "{}";
}
}
}
@@ -1,79 +0,0 @@
package cn.com.mfish.ai.controller;
import cn.com.mfish.common.ai.entity.ChatResponseVo;
import cn.com.mfish.common.core.utils.StringUtils;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
/**
* @description: ollama_chat_model
* @author: mfish
* @date: 2025-08-13
* @version: V2.4.0
*/
@Tag(name = "AI聊天模型-ollama")
@RestController
@RequestMapping("/ollama")
public class OllamaChatModelController {
/** 默认提示词 */
private static final String DEFAULT_PROMPT = "你好,介绍下你自己吧。请用中文回答。";
/** Ollama聊天模型 */
private final ChatModel ollamaChatModel;
/**
* 构造函数,注入Ollama聊天模型
*
* @param ollamaChatModel Ollama聊天模型实例
*/
public OllamaChatModelController(ChatModel ollamaChatModel) {
this.ollamaChatModel = ollamaChatModel;
}
/**
* 最简单的使用方式,没有任何 LLMs 参数注入。
*
* @return String types.
*/
@GetMapping("/chat/simple")
@Parameters({
@Parameter(name = "prompt", description = "提示词")
})
public String simpleChat(String prompt) {
if (StringUtils.isEmpty(prompt)) {
prompt = DEFAULT_PROMPT;
}
return ollamaChatModel.call(new Prompt(prompt)).getResult().getOutput().getText();
}
/**
* 流式聊天接口,以SSE方式返回AI响应内容
*
* @param id 会话ID,用于标识一次对话
* @param prompt 用户输入的提示词,为空时使用默认提示词
* @return 流式响应内容
*/
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@Parameters({
@Parameter(name = "id", description = "会话id"),
@Parameter(name = "prompt", description = "提示词")
})
public Flux<ChatResponseVo> streamChat(String id, String prompt) {
if (StringUtils.isEmpty(prompt)) {
prompt = DEFAULT_PROMPT;
}
return ollamaChatModel.stream(new Prompt(prompt))
.mapNotNull(resp -> new ChatResponseVo().setId(id)
.setContent(resp.getResult().getOutput().getText()));
}
}
@@ -1,77 +0,0 @@
package cn.com.mfish.ai.controller;
import cn.com.mfish.common.ai.entity.ChatResponseVo;
import cn.com.mfish.common.core.utils.StringUtils;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
/**
* @description: openai_chat_model
* @author: mfish
* @date: 2025-08-13
* @version: V2.4.0
*/
@Tag(name = "AI聊天模型-openai")
@RestController
@RequestMapping("/openai")
@RequiredArgsConstructor
public class OpenAiChatModelController {
/** 默认提示词 */
private static final String DEFAULT_PROMPT = "你好,简单介绍下摸鱼低代码";
/** OpenAI聊天模型 */
private final ChatModel openAiChatModel;
/**
* 最简单的使用方式,没有任何 LLMs 参数注入。
*
* @return String types.
*/
@GetMapping("/chat/simple")
@Parameters({
@Parameter(name = "prompt", description = "提示词")
})
public String simpleChat(String prompt) {
if (StringUtils.isEmpty(prompt)) {
prompt = DEFAULT_PROMPT;
}
return openAiChatModel.call(new Prompt(prompt)).getResult().getOutput().getText();
}
/**
* 使用编程方式自定义 LLMs ChatOptions 参数, {@link org.springframework.ai.openai.OpenAiChatOptions}
* 优先级高于在 application.yml 中配置的 LLMs 参数!
*
* @param id 会话ID,用于标识一次对话
* @param prompt 用户输入的提示词,为空时使用默认提示词
* @return 流式聊天响应内容
*/
@GetMapping("/chat/custom")
@Parameters({
@Parameter(name = "id", description = "会话id"),
@Parameter(name = "prompt", description = "提示词")
})
public Flux<ChatResponseVo> customChat(String id, String prompt) {
if (StringUtils.isEmpty(prompt)) {
prompt = DEFAULT_PROMPT;
}
OpenAiChatOptions customOptions = OpenAiChatOptions.builder()
.model("meta-llama/llama-3.3-70b-instruct:free")
.maxTokens(1000)
.temperature(0.8)
.build();
return openAiChatModel.stream(new Prompt(prompt, customOptions))
.mapNotNull(resp -> new ChatResponseVo().setId(id)
.setContent(resp.getResult().getOutput().getText()));
}
}
@@ -0,0 +1,95 @@
package cn.com.mfish.ai.dto;
import lombok.Data;
import java.util.List;
/**
* OpenAI兼容的Chat Completion请求/响应DTO
* 字段名直接对齐JSON键名,无需额外注解
*
* @author: mfish
* @date: 2026/07/01
*/
public class ChatCompletionDto {
// ======================== 请求 ========================
@Data
public static class Request {
private String model;
private List<RequestMessage> messages;
private Double temperature;
/** max_tokens */
private Integer maxTokens;
private Boolean stream = true;
}
@Data
public static class RequestMessage {
private String role;
private String content;
}
// ======================== 流式响应 ========================
@Data
public static class Chunk {
private String id = "chatcmpl-" + java.util.UUID.randomUUID().toString()
.replace("-", "").substring(0, 24);
private String object = "chat.completion.chunk";
private Long created;
private String model;
private List<ChunkChoice> choices;
}
@Data
public static class ChunkChoice {
private Integer index = 0;
private Delta delta;
/** finish_reason */
private String finishReason;
}
@Data
public static class Delta {
private String role;
private String content;
}
// ======================== 非流式响应 ========================
@Data
public static class Response {
private String id;
private String object = "chat.completion";
private Long created;
private String model;
private List<ResponseChoice> choices;
private Usage usage;
}
@Data
public static class ResponseChoice {
private Integer index = 0;
private ResponseMessage message;
/** finish_reason */
private String finishReason;
}
@Data
public static class ResponseMessage {
private String role = "assistant";
private String content;
}
@Data
public static class Usage {
/** prompt_tokens */
private Integer promptTokens;
/** completion_tokens */
private Integer completionTokens;
/** total_tokens */
private Integer totalTokens;
}
}
@@ -0,0 +1,222 @@
package cn.com.mfish.ai.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* LLM多模型路由服务
* <p>
* 通过ApplicationContext自动发现所有ChatModel Bean,Bean名称自动转提供者名称:
* - "openAiChatModel" → "openai"
* - "ollamaChatModel" → "ollama"
* - "zhiPuAiChatModel" → "zhipuai"
* <p>
* 模型名→提供者的映射支持两种来源:
* 1. 内置前缀规则(默认兜底)
* 2. 数据库配置(动态刷新,优先级更高)
* <p>
* fallback顺序:优先使用模型名对应的提供者,失败后按注册顺序依次降级
*
* @author: mfish
* @date: 2026/07/01
*/
@Service
@Slf4j
public class LlmModelRouter {
/** Bean名称后缀 → 提供者名称的转换规则 */
private static final String BEAN_SUFFIX = "ChatModel";
/** 提供者名称 → ChatModel实例 */
private final Map<String, ChatModel> providers = new LinkedHashMap<>();
/** fallback顺序(按Bean注册顺序) */
private final List<String> fallbackOrder;
/** 模型名前缀 → 提供者名称的映射(内置默认规则) */
private final Map<String, String> defaultPrefixMap = new LinkedHashMap<>();
/** 模型名前缀 → 提供者名称的映射(数据库动态配置,优先级高于默认) */
private final Map<String, String> dynamicPrefixMap = new ConcurrentHashMap<>();
public LlmModelRouter(ApplicationContext applicationContext) {
// 自动发现所有ChatModel Bean
Map<String, ChatModel> beans = applicationContext.getBeansOfType(ChatModel.class);
for (Map.Entry<String, ChatModel> entry : beans.entrySet()) {
String providerName = beanNameToProvider(entry.getKey());
this.providers.put(providerName, entry.getValue());
log.info("[LLM路由] 注册模型提供者: {} ({})", providerName, entry.getKey());
}
this.fallbackOrder = List.copyOf(this.providers.keySet());
// 内置默认前缀规则
initDefaultPrefixMap();
log.info("[LLM路由] 已注册提供者: {}, fallback顺序: {}", providers.keySet(), fallbackOrder);
}
/**
* Bean名称转提供者名称
* "openAiChatModel" → "openai"
* "ollamaChatModel" → "ollama"
* "zhiPuAiChatModel" → "zhipuai"
*/
private String beanNameToProvider(String beanName) {
if (beanName.endsWith(BEAN_SUFFIX)) {
beanName = beanName.substring(0, beanName.length() - BEAN_SUFFIX.length());
}
// 驼峰转小写,首字母小写
if (beanName.isEmpty()) {
return "default";
}
// 将驼峰转为下划线再连成小写:openAi → open_ai → openai
StringBuilder sb = new StringBuilder();
for (int i = 0; i < beanName.length(); i++) {
char c = beanName.charAt(i);
if (Character.isUpperCase(c)) {
if (i > 0) {
sb.append('_');
}
sb.append(Character.toLowerCase(c));
} else {
sb.append(c);
}
}
return sb.toString().replace("_", "");
}
/**
* 初始化内置默认前缀规则
* 这些规则在数据库没有配置时作为兜底使用
*/
private void initDefaultPrefixMap() {
// Ollama常见模型
defaultPrefixMap.put("llama", "ollama");
defaultPrefixMap.put("mistral", "ollama");
defaultPrefixMap.put("phi", "ollama");
defaultPrefixMap.put("gemma", "ollama");
// OpenAI兼容API常见模型
defaultPrefixMap.put("gpt", "openai");
defaultPrefixMap.put("qwen", "openai");
defaultPrefixMap.put("deepseek", "openai");
defaultPrefixMap.put("o1", "openai");
defaultPrefixMap.put("o3", "openai");
defaultPrefixMap.put("chatglm", "zhipuai");
}
/**
* 刷新数据库动态映射配置
* 由外部定时任务或配置变更事件调用
*
* @param prefixMap 模型名前缀 → 提供者名称的映射
*/
public void refreshDynamicPrefixMap(Map<String, String> prefixMap) {
dynamicPrefixMap.clear();
if (prefixMap != null) {
dynamicPrefixMap.putAll(prefixMap);
}
log.info("[LLM路由] 刷新动态映射配置: {}", dynamicPrefixMap);
}
/**
* 根据模型名解析提供者名称
* 优先级:动态配置 > 内置默认规则 > 首个注册的提供者
*/
public String resolveProvider(String model) {
if (model == null || model.isEmpty()) {
return fallbackOrder.getFirst();
}
String lower = model.toLowerCase();
// 1. 优先查动态配置(数据库)
for (Map.Entry<String, String> entry : dynamicPrefixMap.entrySet()) {
if (lower.startsWith(entry.getKey()) && providers.containsKey(entry.getValue())) {
return entry.getValue();
}
}
// 2. 兜底查内置默认规则
for (Map.Entry<String, String> entry : defaultPrefixMap.entrySet()) {
if (lower.startsWith(entry.getKey()) && providers.containsKey(entry.getValue())) {
return entry.getValue();
}
}
// 3. 直接匹配提供者名称
if (providers.containsKey(lower)) {
return lower;
}
// 4. 默认返回首个提供者
return fallbackOrder.getFirst();
}
/**
* 获取所有已注册的提供者名称
*/
public Set<String> getProviderNames() {
return Collections.unmodifiableSet(providers.keySet());
}
/**
* 流式调用,支持fallback链
*/
public Flux<ChatResponse> streamWithFallback(Prompt prompt, String model) {
String primaryProvider = resolveProvider(model);
List<String> chain = buildFallbackChain(primaryProvider);
return tryStreamChain(prompt, chain, 0, model);
}
/**
* 同步调用,支持fallback链
*/
public Mono<ChatResponse> callWithFallback(Prompt prompt, String model) {
String primaryProvider = resolveProvider(model);
List<String> chain = buildFallbackChain(primaryProvider);
return tryCallChain(prompt, chain, 0);
}
private List<String> buildFallbackChain(String primaryProvider) {
List<String> chain = new ArrayList<>();
chain.add(primaryProvider);
for (String provider : fallbackOrder) {
if (!chain.contains(provider)) {
chain.add(provider);
}
}
return chain;
}
private Flux<ChatResponse> tryStreamChain(Prompt prompt, List<String> chain, int index, String model) {
if (index >= chain.size()) {
return Flux.error(new RuntimeException("所有模型均不可用"));
}
String provider = chain.get(index);
log.info("[LLM路由] 流式调用, provider={}, model={}", provider, model);
return providers.get(provider).stream(prompt)
.onErrorResume(ex -> {
log.warn("[LLM路由] 提供者{}流式调用失败,尝试fallback", provider, ex);
return tryStreamChain(prompt, chain, index + 1, model);
});
}
private Mono<ChatResponse> tryCallChain(Prompt prompt, List<String> chain, int index) {
if (index >= chain.size()) {
return Mono.error(new RuntimeException("所有模型均不可用"));
}
String provider = chain.get(index);
log.info("[LLM路由] 同步调用, provider={}", provider);
return Mono.fromCallable(() -> providers.get(provider).call(prompt))
.subscribeOn(Schedulers.boundedElastic())
.onErrorResume(ex -> {
log.warn("[LLM路由] 提供者{}同步调用失败,尝试fallback", provider, ex);
return tryCallChain(prompt, chain, index + 1);
});
}
}
@@ -20,6 +20,7 @@ public class ServiceConstants {
public static final String WORKFLOW_SERVICE = "mf-workflow";
public static final String NOCODE_SERVICE = "mf-nocode";
public static final String DEMO_SERVICE = "mf-demo";
public static final String AI_SERVICE = "mf-ai";
/**
* 判断是否为单体服务类型
@@ -42,7 +43,8 @@ public class ServiceConstants {
STORAGE(STORAGE_SERVICE, "/storage"),
WORKFLOW(WORKFLOW_SERVICE, "/workflow"),
NOCODE(NOCODE_SERVICE, "/nocode"),
DEMO(DEMO_SERVICE, "/demo");
DEMO(DEMO_SERVICE, "/demo"),
AI(AI_SERVICE, "/ai");
private final String value;
private final String gatewayPrefix;
-5
View File
@@ -85,11 +85,6 @@
<groupId>cn.com.mfish</groupId>
<artifactId>mf-common-ai</artifactId>
</dependency>
<!-- AI网关:MCP Server 支持 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
@@ -37,6 +37,17 @@ public class RouteFunctionConfig {
.build();
}
/**
* LLM代理路由,将/v1开头的请求路由到mf-ai微服务
* LLM慢速长连接由mf-ai处理,避免占用网关资源
*/
@Bean
public RouteLocator llmProxyRoute(RouteLocatorBuilder builder) {
return builder.routes()
.route("llm-proxy", r -> r.path("/v1/**").uri("lb://mf-ai"))
.build();
}
/**
* 验证码生成路由,通过GET方式访问/captcha获取验证码
*
@@ -1,114 +0,0 @@
package cn.com.mfish.gateway.controller;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
import java.util.Objects;
/**
* LLM统一代理控制器
* 对外提供OpenAI兼容的 /v1/chat/completions 接口,
* 统一代理后端的多个LLMOpenAI/Ollama/DeepSeek等),
* 实现模型负载均衡和fallback
*
* @author: mfish
* @date: 2026/06/26
*/
@RestController
@RequestMapping("/v1")
@Slf4j
public class LlmProxyController {
private final ChatModel primaryChatModel;
public LlmProxyController(@Qualifier("openAiChatModel") ChatModel primaryChatModel) {
this.primaryChatModel = primaryChatModel;
}
/**
* OpenAI兼容的聊天补全接口(SSE流式)
*/
@PostMapping(value = "/chat/completions", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatCompletions(@RequestBody ChatCompletionRequest request,
ServerHttpResponse response) {
response.getHeaders().setContentType(MediaType.TEXT_EVENT_STREAM);
log.info("[LLM代理] 收到请求, model={}, stream={}", request.getModel(), request.getStream());
OpenAiChatOptions options = OpenAiChatOptions.builder()
.model(request.getModel() != null ? request.getModel() : "default")
.maxTokens(request.getMaxTokens() != null ? request.getMaxTokens() : 1000)
.temperature(request.getTemperature() != null ? request.getTemperature() : 0.7)
.build();
String promptText = request.extractPromptText();
return primaryChatModel.stream(new Prompt(promptText, options))
.mapNotNull(resp -> {
String content = Objects.requireNonNull(resp.getResult()).getOutput().getText();
if (content == null || content.isEmpty()) {
return null;
}
return "data: " + buildChunk(request.getModel(), content) + "\n\n";
})
.concatWith(Mono.just("data: [DONE]\n\n"))
.onErrorResume(ex -> {
log.error("[LLM代理] 调用失败, 尝试fallback", ex);
return Flux.just("data: " + buildChunk(request.getModel(),
"抱歉,AI服务暂时不可用,请稍后再试。") + "\n\n")
.concatWith(Mono.just("data: [DONE]\n\n"));
});
}
private String buildChunk(String model, String content) {
String escapedContent = content
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n");
return "{\"id\":\"chatcmpl-mfish\",\"object\":\"chat.completion.chunk\",\"model\":\""
+ (model != null ? model : "default")
+ "\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\""
+ escapedContent
+ "\"},\"finish_reason\":null}]}";
}
/**
* OpenAI兼容的聊天补全请求体
*/
@Data
public static class ChatCompletionRequest {
private String model;
private List<Message> messages;
private Double temperature;
private Integer maxTokens;
private Boolean stream = true;
public String extractPromptText() {
if (messages == null || messages.isEmpty()) {
return "你好";
}
StringBuilder sb = new StringBuilder();
for (Message msg : messages) {
sb.append(msg.role).append(": ").append(msg.content).append("\n");
}
return sb.toString();
}
@Data
public static class Message {
private String role;
private String content;
}
}
}
@@ -1,61 +0,0 @@
package cn.com.mfish.gateway.mcp;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.discovery.ReactiveDiscoveryClient;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 网关MCP工具集
* 将网关背后的微服务能力暴露为MCP工具,让外部AI Agent可以通过MCP协议调用
*
* @author: mfish
* @date: 2026/06/26
*/
@Component
public class GatewayMcpTools {
private final ReactiveDiscoveryClient discoveryClient;
public GatewayMcpTools(ReactiveDiscoveryClient discoveryClient) {
this.discoveryClient = discoveryClient;
}
/**
* 查询所有已注册的微服务列表
* AI Agent可通过此工具了解当前系统中有哪些可用服务
*/
@McpTool(description = "查询当前网关下所有已注册的微服务列表,返回服务ID列表")
public String listServices() {
List<String> services = discoveryClient.getServices().collectList().block();
if (services == null || services.isEmpty()) {
return "当前没有已注册的微服务";
}
return String.join(", ", services);
}
/**
* 查询指定微服务的实例详情
* AI Agent可通过此工具了解某个服务的运行实例、端口等信息
*/
@McpTool(description = "查询指定微服务的实例详情,包括主机、端口、元数据等")
public String getServiceInstances(
@McpToolParam(description = "微服务ID,如mf-oauth、mf-sys等") String serviceId) {
List<ServiceInstance> instances = discoveryClient.getInstances(serviceId).collectList().block();
if (instances == null || instances.isEmpty()) {
return "未找到服务: " + serviceId;
}
StringBuilder sb = new StringBuilder();
for (ServiceInstance inst : instances) {
sb.append("实例: ").append(inst.getInstanceId())
.append(", 地址: ").append(inst.getHost())
.append(":").append(inst.getPort())
.append(", 元数据: ").append(inst.getMetadata())
.append("\n");
}
return sb.toString();
}
}
+12
View File
@@ -26,6 +26,18 @@
<groupId>cn.com.mfish</groupId>
<artifactId>mf-common-cloud</artifactId>
<version>${mfish.version}</version>
<exclusions>
<!-- mf-ai使用WebFlux,排除Servlet容器 -->
<exclusion>
<groupId>cn.com.mfish</groupId>
<artifactId>mf-common-web</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- Actuator监控(原本在mf-common-app中,排除mf-common-web后丢失) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<build>
@@ -1,18 +1,22 @@
package cn.com.mfish.ai;
import cn.com.mfish.common.cloud.annotation.AutoCloud;
import cn.com.mfish.common.core.utils.Utils;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.ConfigurableApplicationContext;
/**
* @author: mfish
* @description: AI服务
* @description: AI服务WebFlux版)
* @date: 2025/08/15
*/
@Slf4j
@AutoCloud
@SpringBootApplication
@EnableFeignClients(basePackages = "cn.com.mfish")
@MapperScan({"cn.com.mfish.**.mapper"})
public class MfAiApplication {
public static void main(String[] args) {
ConfigurableApplicationContext application = SpringApplication.run(MfAiApplication.class, args);