Browse Source

调整多模态支持

liuchengsen 1 tháng trước cách đây
mục cha
commit
f9c9d035f7

+ 6 - 1
backend-java/pom.xml

@@ -99,7 +99,12 @@
             <groupId>com.fasterxml.jackson.core</groupId>
             <artifactId>jackson-databind</artifactId>
         </dependency>
-
+        <!-- Dashscope SDK -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>dashscope-sdk-java</artifactId>
+            <version>the-latest-version</version>
+        </dependency>
         <!-- Test -->
         <dependency>
             <groupId>org.springframework.boot</groupId>

+ 2 - 1
backend-java/src/main/java/com/pharmacopoeia/config/QwenProperties.java

@@ -16,6 +16,7 @@ public class QwenProperties {
     private String embeddingModel = "text-embedding-v3";
     private String embeddingUrl = "https://dashscope.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding";
     private int embeddingDim = 1024;
-    private String vlModel = "qwen3.6-flash";       // 视觉模型(图片分析+OCR)
+    private String vlModel = "qwen3.7-plus";       // 多模态模型(图片/视频分析,原生 API)
+    private String multimodalUrl = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation";
     private boolean enableWebSearch = true;        // 是否启用联网搜索
 }

+ 86 - 41
backend-java/src/main/java/com/pharmacopoeia/service/LLMService.java

@@ -21,6 +21,7 @@ public class LLMService {
 
     private final WebClient chatClient;
     private final WebClient embedClient;
+    private final WebClient visionClient;
     private final QwenProperties props;
     private final ObjectMapper mapper = new ObjectMapper();
 
@@ -33,6 +34,11 @@ public class LLMService {
         this.embedClient = WebClient.builder()
                 .defaultHeader("Authorization", "Bearer " + props.getApiKey())
                 .build();
+        // 原生多模态 API(非兼容模式),endpoint 与 chat/embed 不同
+        this.visionClient = WebClient.builder()
+                .baseUrl("https://dashscope.aliyuncs.com/api/v1")
+                .defaultHeader("Authorization", "Bearer " + props.getApiKey())
+                .build();
     }
 
     // ============================================================
@@ -96,11 +102,11 @@ public class LLMService {
     }
 
     // ============================================================
-    // 图片分析 + OCR(Qwen VL 视觉模型
+    // 图片分析 + OCR(原生多模态 API
     // ============================================================
 
     /**
-     * 调用 Qwen VL 模型分析图片,提取文字(OCR)
+     * 调用 Qwen 多模态模型分析图片,提取文字(OCR)
      */
     public String analyzeImage(String imageBase64, String mimeType, String prompt) {
         if (prompt == null || prompt.isBlank()) {
@@ -115,7 +121,7 @@ public class LLMService {
                 """;
         }
 
-        // 构建 vision 消息(DashScope 原生多模态格式)
+        // 原生 DashScope 多模态格式
         List<Map<String, Object>> contentParts = List.of(
                 Map.of("image", "data:" + mimeType + ";base64," + imageBase64),
                 Map.of("text", prompt)
@@ -123,29 +129,36 @@ public class LLMService {
 
         Map<String, Object> userMessage = Map.of("role", "user", "content", contentParts);
 
-        String response = chatClient.post()
-                .uri("/chat/completions")
+        Map<String, Object> body = new java.util.HashMap<>();
+        body.put("model", props.getVlModel());
+        body.put("input", Map.of("messages", List.of(userMessage)));
+        body.put("parameters", Map.of("max_tokens", 2048));
+
+        String response = visionClient.post()
+                .uri(props.getMultimodalUrl(), uri -> uri.build())
                 .contentType(MediaType.APPLICATION_JSON)
-                .bodyValue(Map.of(
-                        "model", props.getVlModel(),
-                        "messages", List.of(userMessage),
-                        "max_tokens", 2048
-                ))
+                .bodyValue(body)
                 .retrieve()
                 .bodyToMono(String.class)
                 .block();
 
         try {
             JsonNode node = mapper.readTree(response);
-            return node.get("choices").get(0).get("message").get("content").asText();
+            // 原生 API 响应:output.choices[0].message.content[0].text
+            JsonNode content = node.get("output").get("choices").get(0)
+                    .get("message").get("content");
+            if (content.isArray() && content.size() > 0) {
+                return content.get(0).get("text").asText();
+            }
+            return content.asText();
         } catch (Exception e) {
-            log.error("analyzeImage() 解析 LLM 响应失败,response={}", response, e);
+            log.error("analyzeImage() 解析响应失败,response={}", response, e);
             return "";
         }
     }
 
     /**
-     * 流式版本:Qwen VL 分析图片
+     * 流式版本:Qwen 多模态分析图片
      */
     public Flux<String> analyzeImageStream(String imageBase64, String mimeType, String prompt) {
         if (prompt == null || prompt.isBlank()) {
@@ -159,23 +172,23 @@ public class LLMService {
 
         Map<String, Object> userMessage = Map.of("role", "user", "content", contentParts);
 
-        return chatClient.post()
-                .uri("/chat/completions")
+        Map<String, Object> body = new java.util.HashMap<>();
+        body.put("model", props.getVlModel());
+        body.put("input", Map.of("messages", List.of(userMessage)));
+        body.put("parameters", Map.of("max_tokens", 2048, "stream", true, "incremental_output", true));
+
+        return visionClient.post()
+                .uri(props.getMultimodalUrl(), uri -> uri.build())
                 .contentType(MediaType.APPLICATION_JSON)
-                .bodyValue(Map.of(
-                        "model", props.getVlModel(),
-                        "messages", List.of(userMessage),
-                        "max_tokens", 2048,
-                        "stream", true
-                ))
+                .bodyValue(body)
                 .retrieve()
                 .bodyToFlux(String.class)
                 .filter(data -> !"[DONE]".equals(data.trim()))
-                .map(this::extractDeltaContent);
+                .map(this::extractVisionDelta);
     }
 
     // ============================================================
-    // 视频分析 + OCR(Qwen VL 模型支持视频帧提取分析
+    // 视频分析 + OCR(原生多模态 API
     // ============================================================
 
     public String analyzeVideo(String videoBase64, String mimeType, String prompt) {
@@ -196,23 +209,29 @@ public class LLMService {
 
         Map<String, Object> userMessage = Map.of("role", "user", "content", contentParts);
 
-        String response = chatClient.post()
-                .uri("/chat/completions")
+        Map<String, Object> body = new java.util.HashMap<>();
+        body.put("model", props.getVlModel());
+        body.put("input", Map.of("messages", List.of(userMessage)));
+        body.put("parameters", Map.of("max_tokens", 2048));
+
+        String response = visionClient.post()
+                .uri(props.getMultimodalUrl(), uri -> uri.build())
                 .contentType(MediaType.APPLICATION_JSON)
-                .bodyValue(Map.of(
-                        "model", props.getVlModel(),
-                        "messages", List.of(userMessage),
-                        "max_tokens", 2048
-                ))
+                .bodyValue(body)
                 .retrieve()
                 .bodyToMono(String.class)
                 .block();
 
         try {
             JsonNode node = mapper.readTree(response);
-            return node.get("choices").get(0).get("message").get("content").asText();
+            JsonNode content = node.get("output").get("choices").get(0)
+                    .get("message").get("content");
+            if (content.isArray() && content.size() > 0) {
+                return content.get(0).get("text").asText();
+            }
+            return content.asText();
         } catch (Exception e) {
-            log.error("analyzeVideo() 解析 LLM 响应失败,response={}", response, e);
+            log.error("analyzeVideo() 解析响应失败,response={}", response, e);
             return "";
         }
     }
@@ -229,19 +248,19 @@ public class LLMService {
 
         Map<String, Object> userMessage = Map.of("role", "user", "content", contentParts);
 
-        return chatClient.post()
-                .uri("/chat/completions")
+        Map<String, Object> body = new java.util.HashMap<>();
+        body.put("model", props.getVlModel());
+        body.put("input", Map.of("messages", List.of(userMessage)));
+        body.put("parameters", Map.of("max_tokens", 2048, "stream", true, "incremental_output", true));
+
+        return visionClient.post()
+                .uri(props.getMultimodalUrl(), uri -> uri.build())
                 .contentType(MediaType.APPLICATION_JSON)
-                .bodyValue(Map.of(
-                        "model", props.getVlModel(),
-                        "messages", List.of(userMessage),
-                        "max_tokens", 2048,
-                        "stream", true
-                ))
+                .bodyValue(body)
                 .retrieve()
                 .bodyToFlux(String.class)
                 .filter(data -> !"[DONE]".equals(data.trim()))
-                .map(this::extractDeltaContent);
+                .map(this::extractVisionDelta);
     }
 
     // ============================================================
@@ -334,4 +353,30 @@ public class LLMService {
         } catch (Exception e) { /* ignore parse errors on partial chunks */ }
         return "";
     }
+
+    /** 原生多模态 API 流式响应解析:output.choices[0].message.content[0].text */
+    private String extractVisionDelta(String chunk) {
+        try {
+            String json = chunk.trim();
+            if (json.startsWith("data:")) {
+                json = json.substring(5).trim();
+            }
+            if (json.isEmpty() || "[DONE]".equals(json)) {
+                return "";
+            }
+            JsonNode node = mapper.readTree(json);
+            JsonNode output = node.get("output");
+            if (output != null) {
+                var choices = output.get("choices");
+                if (choices != null && choices.size() > 0) {
+                    var msgContent = choices.get(0).get("message").get("content");
+                    if (msgContent != null && msgContent.isArray() && msgContent.size() > 0) {
+                        var text = msgContent.get(0).get("text");
+                        return text != null ? text.asText() : "";
+                    }
+                }
+            }
+        } catch (Exception e) { /* ignore parse errors on partial chunks */ }
+        return "";
+    }
 }

+ 3 - 1
backend-java/src/main/resources/application.yml

@@ -44,7 +44,9 @@ qwen:
   max-tokens: 4096
   temperature: 0.0
   # 视觉模型(图片分析+OCR)
-  vl-model: qwen3.6-flash
+  # 多模态模型(图片/视频分析,原生 API)
+  vl-model: qwen3.7-plus
+  multimodal-url: https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation
   enable-web-search: true
   # 嵌入模型
   embedding-model: text-embedding-v3