Browse Source

模型优化和问答埋点调整

liuchengsen 3 tuần trước cách đây
mục cha
commit
8c7983666c

+ 44 - 2
backend-java/src/main/java/com/pharmacopoeia/controller/ChatController.java

@@ -16,6 +16,7 @@ import org.springframework.http.codec.ServerSentEvent;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 import reactor.core.publisher.Flux;
 import reactor.core.publisher.Flux;
+import reactor.core.publisher.SignalType;
 
 
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.web.multipart.MultipartFile;
 import org.springframework.web.multipart.MultipartFile;
@@ -40,13 +41,15 @@ public class ChatController {
     private final JdbcTemplate jdbc;
     private final JdbcTemplate jdbc;
     private final QwenProperties props;
     private final QwenProperties props;
     private final HttpServletRequest request;
     private final HttpServletRequest request;
+    private final AnalyticsService analyticsService;
 
 
     public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
     public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
                           ChatPersistenceService cps, RerankerService rrs,
                           ChatPersistenceService cps, RerankerService rrs,
                           QACacheService qaCache,
                           QACacheService qaCache,
                           BrandRecommendService brandRecommendService,
                           BrandRecommendService brandRecommendService,
                           JdbcTemplate jdbc, QwenProperties props,
                           JdbcTemplate jdbc, QwenProperties props,
-                          HttpServletRequest request) {
+                          HttpServletRequest request,
+                          AnalyticsService analyticsService) {
         this.retrieverService = rs;
         this.retrieverService = rs;
         this.llmService = ls;
         this.llmService = ls;
         this.promptService = ps;
         this.promptService = ps;
@@ -57,6 +60,7 @@ public class ChatController {
         this.jdbc = jdbc;
         this.jdbc = jdbc;
         this.props = props;
         this.props = props;
         this.request = request;
         this.request = request;
+        this.analyticsService = analyticsService;
     }
     }
 
 
     @PostMapping("/ask")
     @PostMapping("/ask")
@@ -167,6 +171,10 @@ public class ChatController {
 
 
         final String intent = retrieverService.classifyIntent(query);
         final String intent = retrieverService.classifyIntent(query);
         final long t0 = System.currentTimeMillis();
         final long t0 = System.currentTimeMillis();
+        final String chatRequestId = request.getHeader("X-Request-Id");
+        final String chatIp = IpUtils.getClientIp(request);
+        final String chatUa = request.getHeader("User-Agent");
+        final java.util.Map<String, Long> timings = new java.util.concurrent.ConcurrentHashMap<>();
         log.info("[chatStream] intent={}, query={}", intent, query.substring(0, Math.min(50, query.length())));
         log.info("[chatStream] intent={}, query={}", intent, query.substring(0, Math.min(50, query.length())));
 
 
         // 先发射 intent/status 事件,再用 flatMapMany 接回管道保持取消链完整。
         // 先发射 intent/status 事件,再用 flatMapMany 接回管道保持取消链完整。
@@ -179,11 +187,13 @@ public class ChatController {
                 .flatMapMany(docs -> {
                 .flatMapMany(docs -> {
                     long t2 = System.currentTimeMillis();
                     long t2 = System.currentTimeMillis();
                     log.info("[chatStream] search+rerank done, docs={}, elapsed={}ms", docs.size(), t2 - t0);
                     log.info("[chatStream] search+rerank done, docs={}, elapsed={}ms", docs.size(), t2 - t0);
+                    timings.put("search_rerank_ms", t2 - t0);
 
 
                     final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
                     final List<Map<String, String>> messages = promptService.buildPrompt(query, docs, intent);
                     final long t3 = System.currentTimeMillis();
                     final long t3 = System.currentTimeMillis();
                     log.info("[chatStream] prompt built, elapsed={}ms, chars={}",
                     log.info("[chatStream] prompt built, elapsed={}ms, chars={}",
                             t3 - t0, messages.stream().mapToInt(m -> m.get("content").length()).sum());
                             t3 - t0, messages.stream().mapToInt(m -> m.get("content").length()).sum());
+                    timings.put("prompt_ms", t3 - t0);
 
 
                     // 用 StringBuilder 攒完整答案(Flux 内部同步操作,无线程安全问题)
                     // 用 StringBuilder 攒完整答案(Flux 内部同步操作,无线程安全问题)
                     var fullAnswerBuf = new StringBuilder();
                     var fullAnswerBuf = new StringBuilder();
@@ -212,6 +222,8 @@ public class ChatController {
                         }
                         }
                         long t4 = System.currentTimeMillis();
                         long t4 = System.currentTimeMillis();
                         log.info("[chatStream] chatStream done, llmElapsed={}ms, total={}ms", t4 - t3, t4 - t0);
                         log.info("[chatStream] chatStream done, llmElapsed={}ms, total={}ms", t4 - t3, t4 - t0);
+                        timings.put("llm_ms", t4 - t3);
+                        timings.put("total_internal_ms", t4 - t0);
                         persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
                         persistenceService.saveMessage(userKey, cid, "user", query, intent, null);
                         persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources, brandRecs);
                         persistenceService.saveMessage(userKey, cid, "assistant", finalAnswer, intent, sources, brandRecs);
                         qaCache.put(normalized, finalAnswer, intent, sources);
                         qaCache.put(normalized, finalAnswer, intent, sources);
@@ -233,7 +245,8 @@ public class ChatController {
                                 log.error("[chatStream] error, totalElapsed={}ms, error={}", t4 - t0, e.getMessage());
                                 log.error("[chatStream] error, totalElapsed={}ms, error={}", t4 - t0, e.getMessage());
                                 qaCache.removePending(normalized);
                                 qaCache.removePending(normalized);
                             });
                             });
-                }));
+                }))
+                .doFinally(sig -> recordChatTiming(userKey, chatRequestId, chatIp, chatUa, "stream", t0, sig, timings));
     }
     }
 
 
     /** 将缓存命中结果以流式 SSE 形式返回 */
     /** 将缓存命中结果以流式 SSE 形式返回 */
@@ -873,4 +886,33 @@ public class ChatController {
                 .replaceAll("\\n{3,}", "\n\n")
                 .replaceAll("\\n{3,}", "\n\n")
                 .trim();
                 .trim();
     }
     }
+
+    /** 流式问答结束时上报一次"入参→出参"耗时(含分段),用 request_id 与前端 chat_complete 关联 */
+    private void recordChatTiming(String userKey, String requestId, String ip, String ua,
+                                  String endpoint, long t0, SignalType sig,
+                                  java.util.Map<String, Long> timings) {
+        try {
+            long total = System.currentTimeMillis() - t0;
+            String status;
+            if (sig == null) {
+                status = "unknown";
+            } else {
+                status = switch (sig) {
+                    case ON_COMPLETE -> "ok";
+                    case ON_ERROR -> "error";
+                    case CANCEL -> "cancelled";
+                    default -> sig.name().toLowerCase();
+                };
+            }
+            java.util.Map<String, Object> ed = new java.util.LinkedHashMap<>();
+            ed.put("endpoint", endpoint);
+            ed.put("request_id", requestId != null ? requestId : "");
+            ed.put("total_ms", total);
+            ed.put("status", status);
+            if (timings != null && !timings.isEmpty()) ed.putAll(timings);
+            analyticsService.saveEvent(userKey, "chat_stream_server", ed, "", "", ip, ua, "AI药典");
+        } catch (Exception ignored) {
+            // 埋点失败不影响主流程
+        }
+    }
 }
 }

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

@@ -75,7 +75,7 @@ server:
 qwen:
 qwen:
   api-key: ${QWEN_API_KEY:}
   api-key: ${QWEN_API_KEY:}
   base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
   base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
-  model: qwen3.7-max
+  model: qwen3.7-flash
   max-tokens: 4096
   max-tokens: 4096
   temperature: 0.0
   temperature: 0.0
   # 视觉模型(图片分析+OCR)
   # 视觉模型(图片分析+OCR)

+ 26 - 12
static/index.html

@@ -443,7 +443,7 @@
     chat.appendChild(div);chat.scrollTop=chat.scrollHeight;return div;
     chat.appendChild(div);chat.scrollTop=chat.scrollHeight;return div;
   }
   }
 
 
-  async function streamAsk(query,media,aiDiv,contentDiv){
+  async function streamAsk(query,media,aiDiv,contentDiv,metrics){
     var endpoint,body;
     var endpoint,body;
     if(media&&media.type==='image'){
     if(media&&media.type==='image'){
       endpoint=API_BASE+'/api/v1/chat/stream-image';
       endpoint=API_BASE+'/api/v1/chat/stream-image';
@@ -454,14 +454,15 @@
     }
     }
     try{
     try{
       var ctrl=streamAbort?streamAbort:new AbortController();
       var ctrl=streamAbort?streamAbort:new AbortController();
-      var r=await fetch(endpoint,{method:'POST',headers:authHeaders(),body:body,signal:ctrl.signal});
-      if(r.status===401){console.warn('需要登录');aiDiv.classList.remove('streaming');STREAMING=false;resetSendBtn();contentDiv.innerHTML=md2html('请先登录后再提问');return}
+      var hdrs=authHeaders();if(metrics&&metrics.reqId)hdrs['X-Request-Id']=metrics.reqId;
+      var r=await fetch(endpoint,{method:'POST',headers:hdrs,body:body,signal:ctrl.signal});
+      if(r.status===401){console.warn('需要登录');aiDiv.classList.remove('streaming');STREAMING=false;resetSendBtn();contentDiv.innerHTML=md2html('请先登录后再提问');trackComplete(metrics,'error',{http_status:401});return}
       if(r.status===429){
       if(r.status===429){
         var detail='请求过于频繁,请稍后再试';
         var detail='请求过于频繁,请稍后再试';
         try{var b=await r.json();detail=b.detail||b.message||b.error||detail}catch(e){}
         try{var b=await r.json();detail=b.detail||b.message||b.error||detail}catch(e){}
-        contentDiv.innerHTML=md2html(detail);aiDiv.classList.remove('streaming');STREAMING=false;resetSendBtn();return
+        contentDiv.innerHTML=md2html(detail);aiDiv.classList.remove('streaming');STREAMING=false;resetSendBtn();trackComplete(metrics,'error',{http_status:429,detail:detail});return
       }
       }
-      if(!r.ok){contentDiv.innerHTML=md2html('请求失败: HTTP '+r.status);aiDiv.classList.remove('streaming');STREAMING=false;resetSendBtn();return}
+      if(!r.ok){contentDiv.innerHTML=md2html('请求失败: HTTP '+r.status);aiDiv.classList.remove('streaming');STREAMING=false;resetSendBtn();trackComplete(metrics,'error',{http_status:r.status});return}
       sourcesData=[];
       sourcesData=[];
       brandRecsData=[];
       brandRecsData=[];
       var reader=r.body.getReader(),decoder=new TextDecoder(),buffer='',fullText='',eventType='';
       var reader=r.body.getReader(),decoder=new TextDecoder(),buffer='',fullText='',eventType='';
@@ -484,7 +485,7 @@
             }
             }
             if(eventType==='intent'){currentIntent=data;var tagEl=aiDiv.querySelector('.tag');if(tagEl){tagEl.textContent=intentLabel(data);tagEl.className='tag '+tagClass(data)}continue}
             if(eventType==='intent'){currentIntent=data;var tagEl=aiDiv.querySelector('.tag');if(tagEl){tagEl.textContent=intentLabel(data);tagEl.className='tag '+tagClass(data)}continue}
             if(eventType==='meta'){
             if(eventType==='meta'){
-              try{var meta=JSON.parse(data);if(meta.sources&&meta.sources.length>0)sourcesData=meta.sources}catch(e){}
+              try{var meta=JSON.parse(data);if(meta.sources&&meta.sources.length>0)sourcesData=meta.sources;if(meta.conversation_id&&metrics)metrics.conversationId=meta.conversation_id;}catch(e){}
               continue;
               continue;
             }
             }
             if(eventType==='brand_recommend'){
             if(eventType==='brand_recommend'){
@@ -493,6 +494,7 @@
             }
             }
             fullText+=data;
             fullText+=data;
             if(fullText.trim()){
             if(fullText.trim()){
+              if(metrics&&!metrics.tFirst)metrics.tFirst=Date.now();
               contentDiv.innerHTML=md2html(fullText)+'<span class="cursor">|</span>';
               contentDiv.innerHTML=md2html(fullText)+'<span class="cursor">|</span>';
               document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;
               document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;
             }
             }
@@ -501,9 +503,10 @@
         }
         }
       }
       }
       contentDiv.innerHTML=md2html(fullText)+buildBrandRecs()+buildSourceTags();var c=contentDiv.querySelector('.cursor');if(c)c.remove();
       contentDiv.innerHTML=md2html(fullText)+buildBrandRecs()+buildSourceTags();var c=contentDiv.querySelector('.cursor');if(c)c.remove();
+      trackComplete(metrics,'ok',{chars:fullText.length});
     }catch(e){
     }catch(e){
-      if(e.name==='AbortError'){contentDiv.innerHTML=md2html(fullText+' <span style="color:var(--rd)">已停止</span>')+buildBrandRecs()+buildSourceTags();}
-      else{contentDiv.innerHTML=md2html('连接失败: '+esc(e.message));}
+      if(e.name==='AbortError'){contentDiv.innerHTML=md2html(fullText+' <span style="color:var(--rd)">已停止</span>')+buildBrandRecs()+buildSourceTags();trackComplete(metrics,'aborted',{chars:fullText.length});}
+      else{contentDiv.innerHTML=md2html('连接失败: '+esc(e.message));trackComplete(metrics,'error',{error:esc(e.message)});}
     }
     }
     aiDiv.classList.remove('streaming');STREAMING=false;streamAbort=null;resetSendBtn();
     aiDiv.classList.remove('streaming');STREAMING=false;streamAbort=null;resetSendBtn();
     document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;
     document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;
@@ -563,10 +566,12 @@
     var input=document.getElementById('userInput'),text=input.value.trim();
     var input=document.getElementById('userInput'),text=input.value.trim();
     var hasMedia=!!pendingMedia;
     var hasMedia=!!pendingMedia;
     if(!text&&!hasMedia)return;input.value='';if(STREAMING)return;
     if(!text&&!hasMedia)return;input.value='';if(STREAMING)return;
-    track('chat', {query: text, has_media: hasMedia});
-    doSend(text,hasMedia?pendingMedia:null);
+    var reqId=(window.crypto&&window.crypto.randomUUID)?window.crypto.randomUUID()
+      :('r'+Date.now().toString(36)+Math.random().toString(36).slice(2,8));
+    track('chat', {query: text, has_media: hasMedia, request_id: reqId, msg_len: text.length});
+    doSend(text,hasMedia?pendingMedia:null,reqId);
   };
   };
-  function doSend(text,media){
+  function doSend(text,media,reqId){
     STREAMING=true;streamAbort=new AbortController();
     STREAMING=true;streamAbort=new AbortController();
     document.getElementById('sendBtn').disabled=true;document.getElementById('stopBtn').style.display='inline-block';
     document.getElementById('sendBtn').disabled=true;document.getElementById('stopBtn').style.display='inline-block';
     var w=document.getElementById('chat').querySelector('.welcome');if(w)w.remove();
     var w=document.getElementById('chat').querySelector('.welcome');if(w)w.remove();
@@ -578,9 +583,18 @@
     if(!query){query='请介绍一下自己'}
     if(!query){query='请介绍一下自己'}
     if(media)pendingMedia=null;if(media)clearMedia();
     if(media)pendingMedia=null;if(media)clearMedia();
     currentIntent=classifyIntent(query);
     currentIntent=classifyIntent(query);
+    var metrics={reqId:reqId,t0:Date.now(),tFirst:0,hasMedia:!!media,msgLen:query.length,intent:currentIntent,conversationId:''};
     var aiDiv=addMsg('ai','',true,currentIntent),contentDiv=aiDiv.querySelector('.content');
     var aiDiv=addMsg('ai','',true,currentIntent),contentDiv=aiDiv.querySelector('.content');
     contentDiv.innerHTML='<div class="thinking"><span class="think-dots"><span class="think-dot"></span><span class="think-dot"></span><span class="think-dot"></span></span><span class="think-text">思考中</span><span class="cursor">|</span></div>';
     contentDiv.innerHTML='<div class="thinking"><span class="think-dots"><span class="think-dot"></span><span class="think-dot"></span><span class="think-dot"></span></span><span class="think-text">思考中</span><span class="cursor">|</span></div>';
-    streamAsk(query,media,aiDiv,contentDiv);
+    streamAsk(query,media,aiDiv,contentDiv,metrics);
+  }
+  /** 统一上报一次问答的完成结果(含耗时) */
+  function trackComplete(m,status,extra){
+    if(!m)return;
+    var d={request_id:m.reqId,total_ms:Date.now()-m.t0,ttft_ms:m.tFirst?(m.tFirst-m.t0):0,status:status,intent:m.intent||'',has_media:m.hasMedia,msg_len:m.msgLen};
+    if(m.conversationId)d.conversation_id=m.conversationId;
+    if(extra)for(var k in extra){if(Object.prototype.hasOwnProperty.call(extra,k))d[k]=extra[k]}
+    track('chat_complete',d);
   }
   }
 
 
   function buildBrandRecs(){
   function buildBrandRecs(){