mirror of
https://github.com/mfish-qf/mfish-nocode.git
synced 2026-08-30 17:12:14 +08:00
refactor: ai功能改造
This commit is contained in:
@@ -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替代Servlet,LLM长连接不占用线程 -->
|
||||
<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 "{}";
|
||||
}
|
||||
}
|
||||
}
|
||||
-79
@@ -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()));
|
||||
}
|
||||
|
||||
}
|
||||
-77
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+3
-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;
|
||||
|
||||
@@ -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 接口,
|
||||
* 统一代理后端的多个LLM(OpenAI/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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user