@@ -12,6 +12,7 @@ import { DataAssembler } from "./data-assembler";
1212import { PromptBuilder } from "./prompt-builder" ;
1313import { LLMRouter } from "./llm-router" ;
1414import type { MCPClientManager , MCPToolInfo } from "../mcp/mcp-manager" ;
15+ import { compactChatMessages , DEFAULT_CHAT_CONTEXT_CHARS } from "./context-budget" ;
1516
1617/** 请求数低于此值时跳过 Phase 1 预过滤 */
1718const PRE_FILTER_THRESHOLD = 20 ;
@@ -21,12 +22,8 @@ const PRE_FILTER_MIN_SELECTED = 3;
2122const PHASE1_MAX_TOKENS = 1024 ;
2223/** 需要全量请求的分析目的(不跳过任何请求) */
2324const SKIP_FILTER_PURPOSES = [ "performance" ] ;
24- /** 对话历史最大字符数(约 25K tokens),超过时压缩旧消息 */
25- const MAX_CHAT_CONTEXT_CHARS = 100_000 ;
26- /** 每条工具结果保留的最大字符数 */
27- const TOOL_RESULT_MAX_CHARS = 2000 ;
28- /** 保留最近 N 条 assistant 消息的 tool_context(更早的会被剥离) */
29- const KEEP_TOOL_CONTEXT_RECENT = 2 ;
25+ /** 预过滤每次发送的请求摘要上限,避免单次上下文膨胀 */
26+ const FILTER_BATCH_SIZE = 100 ;
3027
3128/** 内置 tool:查看请求详情 */
3229const BUILTIN_TOOLS : MCPToolInfo [ ] = [
@@ -152,32 +149,38 @@ export class AiAnalyzer {
152149 onProgress ?.( `> 正在过滤:分析 ${ fullData . requests . length } 条请求的相关性...\n\n` ) ;
153150
154151 allSummaries = assembler . extractSummaries ( fullData ) ;
155- const promptBuilder = new PromptBuilder ( ) ;
156- const filterPrompt = promptBuilder . buildFilterPrompt (
157- allSummaries ,
158- fullData . sceneHints ,
159- purpose ,
160- template ,
161- ) ;
162-
163152 const phase1Config : LLMProviderConfig = { ...config , maxTokens : PHASE1_MAX_TOKENS } ;
164153 const phase1Router = new LLMRouter ( phase1Config , this . createLogCallback ( sessionId , null , 'filter' , phase1Config ) ) ;
165- const phase1Messages : Array < { role : "system" | "user" | "assistant" ; content : string } > = [
166- { role : "system" , content : filterPrompt . system } ,
167- { role : "user" , content : filterPrompt . user } ,
168- ] ;
169-
170- // 非流式调用
171- signal ?. throwIfAborted ( ) ;
172- const phase1Result = await phase1Router . complete ( phase1Messages , undefined , signal ) ;
173- filterPromptTokens = phase1Result . promptTokens ;
174- filterCompletionTokens = phase1Result . completionTokens ;
175- this . aiRequestLogRepo . updateLatestTokens ( sessionId , 'filter' , phase1Result . promptTokens , phase1Result . completionTokens ) ;
176-
177154 const validSeqs = new Set ( fullData . requests . map ( r => r . seq ) ) ;
178- const filteredSeqs = this . parseFilterResponse ( phase1Result . content , validSeqs ) ;
155+ const selectedSeqs = new Set < number > ( ) ;
156+
157+ for ( let batchStart = 0 ; batchStart < allSummaries . length ; batchStart += FILTER_BATCH_SIZE ) {
158+ const batchSummaries = allSummaries . slice ( batchStart , batchStart + FILTER_BATCH_SIZE ) ;
159+ const filterPrompt = new PromptBuilder ( ) . buildFilterPrompt (
160+ batchSummaries ,
161+ fullData . sceneHints ,
162+ purpose ,
163+ template ,
164+ ) ;
165+ const phase1Messages : Array < { role : "system" | "user" | "assistant" ; content : string } > = [
166+ { role : "system" , content : filterPrompt . system } ,
167+ { role : "user" , content : filterPrompt . user } ,
168+ ] ;
169+
170+ const batchNumber = Math . floor ( batchStart / FILTER_BATCH_SIZE ) + 1 ;
171+ const batchCount = Math . ceil ( allSummaries . length / FILTER_BATCH_SIZE ) ;
172+ onProgress ?.( `> 正在过滤第 ${ batchNumber } /${ batchCount } 批请求(${ batchSummaries . length } 条)...\n\n` ) ;
173+ signal ?. throwIfAborted ( ) ;
174+ const phase1Result = await phase1Router . complete ( phase1Messages , undefined , signal ) ;
175+ filterPromptTokens = ( filterPromptTokens ?? 0 ) + phase1Result . promptTokens ;
176+ filterCompletionTokens = ( filterCompletionTokens ?? 0 ) + phase1Result . completionTokens ;
177+ this . aiRequestLogRepo . updateLatestTokens ( sessionId , 'filter' , phase1Result . promptTokens , phase1Result . completionTokens ) ;
178+ this . parseFilterResponse ( phase1Result . content , validSeqs ) ?. forEach ( ( seq ) => selectedSeqs . add ( seq ) ) ;
179+ }
179180
180- if ( filteredSeqs && filteredSeqs . length >= PRE_FILTER_MIN_SELECTED ) {
181+ const filteredSeqs = [ ...selectedSeqs ] ;
182+
183+ if ( filteredSeqs . length >= PRE_FILTER_MIN_SELECTED ) {
181184 analysisData = assembler . filterBySeqs ( fullData , filteredSeqs ) ;
182185 onProgress ?.( `> 过滤完成:从 ${ fullData . requests . length } 条中选出 ${ filteredSeqs . length } 条相关请求进行深度分析。\n\n` ) ;
183186 } else {
@@ -220,7 +223,7 @@ export class AiAnalyzer {
220223 const mcpMgr = this . mcpManager ;
221224 const callTool = async ( name : string , args : Record < string , unknown > ) : Promise < string > => {
222225 if ( name === 'get_request_detail' ) {
223- const seq = args . seq as number ;
226+ const seq = typeof args . seq === ' number' ? args . seq : Number ( args . seq ) ;
224227 const req = requestMap . get ( seq ) ;
225228 if ( ! req ) return `Error: 未找到序号为 ${ seq } 的请求` ;
226229 return this . formatRequestDetail ( req ) ;
@@ -245,7 +248,7 @@ export class AiAnalyzer {
245248 allTools ,
246249 callTool ,
247250 onProgress ,
248- 10 ,
251+ undefined ,
249252 signal ,
250253 ) ;
251254 } else {
@@ -350,9 +353,9 @@ export class AiAnalyzer {
350353 { role : 'user' as const , content : userMessage } ,
351354 ]
352355
353- // Trim old messages if total context exceeds limit
354- // Keep: messages[0] (system) + messages[1] (report) + most recent turns
355- await this . compressMessages ( messages , MAX_CHAT_CONTEXT_CHARS , config , sessionId , reportId ?? null ) ;
356+ // Deterministic compaction avoids an extra summarization request and keeps
357+ // follow-up requests on the same provider path as analysis.
358+ const compactedMessages = compactChatMessages ( messages , DEFAULT_CHAT_CONTEXT_CHARS ) ;
356359
357360 const router = new LLMRouter ( config , this . createLogCallback ( sessionId , reportId ?? null , 'chat' , config ) )
358361
@@ -379,7 +382,7 @@ export class AiAnalyzer {
379382 const callTool = async ( name : string , args : Record < string , unknown > ) : Promise < string > => {
380383 let result : string ;
381384 if ( name === 'get_request_detail' ) {
382- const seq = args . seq as number ;
385+ const seq = typeof args . seq === ' number' ? args . seq : Number ( args . seq ) ;
383386 const req = requestMap . get ( seq ) ;
384387 if ( ! req ) {
385388 result = `Error: 未找到序号为 ${ seq } 的请求` ;
@@ -394,22 +397,24 @@ export class AiAnalyzer {
394397
395398 // Record tool interaction for context preservation across turns
396399 const argsStr = JSON . stringify ( args ) ;
397- const truncatedResult = result . length > TOOL_RESULT_MAX_CHARS
398- ? result . slice ( 0 , TOOL_RESULT_MAX_CHARS ) + `\n...(truncated, ${ result . length } chars total)`
399- : result ;
400- toolInteractions . push ( `[${ name } ](${ argsStr } )\n${ truncatedResult } ` ) ;
400+ toolInteractions . push ( `[${ name } ](${ argsStr } )\n${ result } ` ) ;
401401
402402 return result ;
403403 } ;
404404
405405 let replyContent : string ;
406406
407407 if ( allTools . length > 0 ) {
408- const result = await router . completeWithTools ( messages , allTools , callTool , onProgress , 5 ) ;
409- this . aiRequestLogRepo . updateLatestTokens ( sessionId , 'chat' , result . promptTokens , result . completionTokens ) ;
410- replyContent = result . content ;
408+ try {
409+ const result = await router . completeWithTools ( compactedMessages , allTools , callTool , onProgress ) ;
410+ this . aiRequestLogRepo . updateLatestTokens ( sessionId , 'chat' , result . promptTokens , result . completionTokens ) ;
411+ replyContent = result . content ;
412+ } catch ( error ) {
413+ const message = error instanceof Error ? error . message : String ( error ) ;
414+ throw new Error ( `追问工具调用失败:${ message } ` ) ;
415+ }
411416 } else {
412- const result = await router . complete ( messages , onProgress ) ;
417+ const result = await router . complete ( compactedMessages , onProgress ) ;
413418 this . aiRequestLogRepo . updateLatestTokens ( sessionId , 'chat' , result . promptTokens , result . completionTokens ) ;
414419 replyContent = result . content ;
415420 }
@@ -423,95 +428,4 @@ export class AiAnalyzer {
423428 return replyContent ;
424429 }
425430
426- /**
427- * 两级上下文压缩,保留所有对话信息。
428- *
429- * 第一级(无 LLM 调用):剥离旧 assistant 消息中的 <tool_context>,
430- * 仅保留最近 KEEP_TOOL_CONTEXT_RECENT 条 assistant 消息的工具上下文。
431- *
432- * 第二级(需要 LLM 调用):如果仍然超限,将最旧的对话轮次
433- * (messages[2] 到中间某个位置)用 LLM 压缩为一条摘要消息替换。
434- *
435- * 始终保留 messages[0](system)和 messages[1](report)。
436- */
437- private async compressMessages (
438- messages : Array < { role : string ; content : string } > ,
439- maxChars : number ,
440- config : LLMProviderConfig ,
441- sessionId : string ,
442- reportId : string | null ,
443- ) : Promise < void > {
444- const totalChars = ( ) => messages . reduce ( ( sum , m ) => sum + m . content . length , 0 ) ;
445-
446- if ( totalChars ( ) <= maxChars ) return ;
447-
448- // ---- Tier 1: Strip <tool_context> from older assistant messages ----
449- // Find assistant messages (skip messages[1] which is the initial report)
450- let assistantCount = 0 ;
451- for ( let i = messages . length - 1 ; i >= 2 ; i -- ) {
452- if ( messages [ i ] . role === 'assistant' ) {
453- assistantCount ++ ;
454- if ( assistantCount > KEEP_TOOL_CONTEXT_RECENT ) {
455- // Strip tool_context from this older assistant message
456- messages [ i ] = {
457- ...messages [ i ] ,
458- content : messages [ i ] . content . replace ( / \n * < t o o l _ c o n t e x t > [ \s \S ] * ?< \/ t o o l _ c o n t e x t > \s * $ / g, '' ) ,
459- } ;
460- }
461- }
462- }
463-
464- if ( totalChars ( ) <= maxChars ) return ;
465-
466- // ---- Tier 2: LLM-based summarization of oldest conversation turns ----
467- // Keep at least the most recent 4 messages (2 turns) + system + report
468- const minKeepFromEnd = 4 ;
469- const conversationMessages = messages . slice ( 2 ) ; // exclude system + report
470- if ( conversationMessages . length <= minKeepFromEnd ) return ; // too few to compress
471-
472- // Split: older turns to compress, recent turns to keep
473- const splitIdx = conversationMessages . length - minKeepFromEnd ;
474- const toCompress = conversationMessages . slice ( 0 , splitIdx ) ;
475- // Build summary prompt
476- const conversationText = toCompress
477- . map ( ( m ) => `[${ m . role } ]: ${ m . content . slice ( 0 , 3000 ) } ` )
478- . join ( '\n\n' ) ;
479-
480- const summaryPrompt = [
481- {
482- role : 'system' as const ,
483- content : '你是一个对话摘要助手。将以下多轮对话压缩为一段简洁的摘要,保留所有关键信息:讨论过的请求序号、API端点、发现的问题、用户关注的重点。用中文输出。' ,
484- } ,
485- {
486- role : 'user' as const ,
487- content : `请将以下对话压缩为摘要(保留关键技术细节,尤其是请求序号和具体发现):\n\n${ conversationText } ` ,
488- } ,
489- ] ;
490-
491- try {
492- const router = new LLMRouter ( config , this . createLogCallback ( sessionId , reportId , 'compress' , config ) ) ;
493- const result = await router . complete ( summaryPrompt ) ;
494- const summary = result . content ;
495-
496- if ( summary ) {
497- // Replace compressed messages with a single summary
498- // Remove old conversation turns (index 2 to 2+splitIdx), insert summary
499- messages . splice ( 2 , splitIdx , {
500- role : 'assistant' ,
501- content : `<conversation_summary>\n${ summary } \n</conversation_summary>` ,
502- } ) ;
503- }
504- } catch ( err ) {
505- // Summarization failed — fall back to stripping old messages' content to fit
506- console . warn ( 'Chat history compression failed, falling back to content truncation:' , err ) ;
507- for ( let i = 2 ; i < messages . length - minKeepFromEnd && totalChars ( ) > maxChars ; i ++ ) {
508- if ( messages [ i ] . content . length > 500 ) {
509- messages [ i ] = {
510- ...messages [ i ] ,
511- content : messages [ i ] . content . slice ( 0 , 500 ) + '\n...(compressed)' ,
512- } ;
513- }
514- }
515- }
516- }
517431}
0 commit comments