Explorar o código

模型优化和问答埋点调整

liuchengsen hai 3 semanas
pai
achega
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.web.bind.annotation.*;
 import reactor.core.publisher.Flux;
+import reactor.core.publisher.SignalType;
 
 import org.springframework.jdbc.core.JdbcTemplate;
 import org.springframework.web.multipart.MultipartFile;
@@ -40,13 +41,15 @@ public class ChatController {
     private final JdbcTemplate jdbc;
     private final QwenProperties props;
     private final HttpServletRequest request;
+    private final AnalyticsService analyticsService;
 
     public ChatController(RetrieverService rs, LLMService ls, PromptService ps,
                           ChatPersistenceService cps, RerankerService rrs,
                           QACacheService qaCache,
                           BrandRecommendService brandRecommendService,
                           JdbcTemplate jdbc, QwenProperties props,
-                          HttpServletRequest request) {
+                          HttpServletRequest request,
+                          AnalyticsService analyticsService) {
         this.retrieverService = rs;
         this.llmService = ls;
         this.promptService = ps;
@@ -57,6 +60,7 @@ public class ChatController {
         this.jdbc = jdbc;
         this.props = props;
         this.request = request;
+        this.analyticsService = analyticsService;
     }
 
     @PostMapping("/ask")
@@ -167,6 +171,10 @@ public class ChatController {
 
         final String intent = retrieverService.classifyIntent(query);
         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())));
 
         // 先发射 intent/status 事件,再用 flatMapMany 接回管道保持取消链完整。
@@ -179,11 +187,13 @@ public class ChatController {
                 .flatMapMany(docs -> {
                     long t2 = System.currentTimeMillis();
                     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 long t3 = System.currentTimeMillis();
                     log.info("[chatStream] prompt built, elapsed={}ms, chars={}",
                             t3 - t0, messages.stream().mapToInt(m -> m.get("content").length()).sum());
+                    timings.put("prompt_ms", t3 - t0);
 
                     // 用 StringBuilder 攒完整答案(Flux 内部同步操作,无线程安全问题)
                     var fullAnswerBuf = new StringBuilder();
@@ -212,6 +222,8 @@ public class ChatController {
                         }
                         long t4 = System.currentTimeMillis();
                         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, "assistant", finalAnswer, intent, sources, brandRecs);
                         qaCache.put(normalized, finalAnswer, intent, sources);
@@ -233,7 +245,8 @@ public class ChatController {
                                 log.error("[chatStream] error, totalElapsed={}ms, error={}", t4 - t0, e.getMessage());
                                 qaCache.removePending(normalized);
                             });
-                }));
+                }))
+                .doFinally(sig -> recordChatTiming(userKey, chatRequestId, chatIp, chatUa, "stream", t0, sig, timings));
     }
 
     /** 将缓存命中结果以流式 SSE 形式返回 */
@@ -873,4 +886,33 @@ public class ChatController {
                 .replaceAll("\\n{3,}", "\n\n")
                 .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:
   api-key: ${QWEN_API_KEY:}
   base-url: https://dashscope.aliyuncs.com/compatible-mode/v1
-  model: qwen3.7-max
+  model: qwen3.7-flash
   max-tokens: 4096
   temperature: 0.0
   # 视觉模型(图片分析+OCR)

+ 26 - 12
static/index.html

@@ -443,7 +443,7 @@
     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;
     if(media&&media.type==='image'){
       endpoint=API_BASE+'/api/v1/chat/stream-image';
@@ -454,14 +454,15 @@
     }
     try{
       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){
         var detail='请求过于频繁,请稍后再试';
         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=[];
       brandRecsData=[];
       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==='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;
             }
             if(eventType==='brand_recommend'){
@@ -493,6 +494,7 @@
             }
             fullText+=data;
             if(fullText.trim()){
+              if(metrics&&!metrics.tFirst)metrics.tFirst=Date.now();
               contentDiv.innerHTML=md2html(fullText)+'<span class="cursor">|</span>';
               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();
+      trackComplete(metrics,'ok',{chars:fullText.length});
     }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();
     document.getElementById('chat').scrollTop=document.getElementById('chat').scrollHeight;
@@ -563,10 +566,12 @@
     var input=document.getElementById('userInput'),text=input.value.trim();
     var hasMedia=!!pendingMedia;
     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();
     document.getElementById('sendBtn').disabled=true;document.getElementById('stopBtn').style.display='inline-block';
     var w=document.getElementById('chat').querySelector('.welcome');if(w)w.remove();
@@ -578,9 +583,18 @@
     if(!query){query='请介绍一下自己'}
     if(media)pendingMedia=null;if(media)clearMedia();
     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');
     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(){