@@ -37,8 +37,8 @@ local protocols = require("apisix.plugins.ai-protocols")
3737local deep_merge = require (" apisix.plugins.ai-proxy.merge" ).deep_merge
3838local ngx = ngx
3939local ngx_now = ngx .now
40- local tonumber = tonumber
4140local require = require
41+ local tonumber = tonumber
4242
4343local table = table
4444local pairs = pairs
@@ -420,7 +420,12 @@ function _M.parse_streaming_response(self, ctx, res, target_proto, converter, co
420420 local body_reader = res .body_reader
421421 local contents = {}
422422 local sse_state = { is_first = true }
423- local sse_buf = " "
423+ -- SSE framing buffer: accumulate chunks with table.insert to avoid
424+ -- allocating a new string on every append; reset to {remainder} after
425+ -- each split so the table never grows beyond two elements.
426+ -- Initialized with "" so the fast-path (sse_parts[1] == "") activates
427+ -- immediately on the first chunk, avoiding an unnecessary table.concat.
428+ local sse_parts = {" " }
424429 -- Track whether any output was sent to the client.
425430 -- When a converter is active but the upstream returns a different SSE format,
426431 -- all events may be skipped and no output produced, leaving the response
@@ -436,59 +441,146 @@ function _M.parse_streaming_response(self, ctx, res, target_proto, converter, co
436441 end
437442 local bytes_read = 0
438443
444+ -- streaming_flush_interval_ms controls both flush strategy and the thread:
445+ -- == 0 : no thread; lua_response_filter flushes synchronously
446+ -- per chunk via ngx.flush(true), guaranteeing immediate
447+ -- client delivery.
448+ -- > 0 (default: 10): background thread calls ngx.flush(false) every N ms;
449+ -- lua_response_filter skips per-chunk flush for maximum
450+ -- throughput. Useful when the upstream bursts multiple
451+ -- tokens at once.
452+ local flush_interval_ms = conf and conf .streaming_flush_interval_ms or 0
453+ -- async_flush: true when the interval thread is responsible for flushing
454+ local async_flush = flush_interval_ms > 0
455+ -- needs_flush is set to true immediately after dispatching a chunk so the
456+ -- thread always flushes exactly the data that has been written. Cleared
457+ -- before ngx.flush() so any new chunks written during the flush yield are
458+ -- picked up on the next interval rather than silently dropped.
459+ local needs_flush = false
460+ local flush_thread
461+ local flush_err
462+ if async_flush then
463+ local interval_s = flush_interval_ms / 1000
464+ local spawn_err
465+ flush_thread , spawn_err = ngx .thread .spawn (function ()
466+ while true do
467+ ngx .sleep (interval_s )
468+ if needs_flush then
469+ needs_flush = false
470+ local ok , err = ngx .flush (false )
471+ if not ok then
472+ flush_err = err
473+ return
474+ end
475+ core .log .debug (" ai-proxy: flush_thread periodic flush" )
476+ end
477+ end
478+ end )
479+ if not flush_thread then
480+ core .log .error (" failed to spawn flush thread: " , spawn_err )
481+ async_flush = false
482+ end
483+ end
484+
439485 local function abort_on_disconnect (flush_err )
440486 core .log .info (" client disconnected during AI streaming, " ,
441487 " aborting upstream read: " , flush_err )
488+ if flush_thread then
489+ ngx .thread .kill (flush_thread )
490+ flush_thread = nil
491+ end
442492 if res ._httpc then
443493 res ._httpc :close ()
444494 res ._httpc = nil
445495 end
446496 res ._upstream_bytes = bytes_read
497+ ctx .var .apisix_upstream_response_time = math.floor (
498+ (ngx_now () - ctx .llm_request_start_time ) * 1000 )
447499 ctx .var .llm_request_done = true
448500 end
449501
502+ -- Use a local flag instead of reading ctx.var on every chunk.
503+ local first_token_set = false
504+
450505 while true do
506+ if flush_err then
507+ abort_on_disconnect (flush_err )
508+ return
509+ end
510+
451511 local chunk , err = body_reader ()
452- ctx .var .apisix_upstream_response_time = math.floor ((ngx_now () -
453- ctx .llm_request_start_time ) * 1000 )
454512 if err then
513+ ctx .var .apisix_upstream_response_time = math.floor (
514+ (ngx_now () - ctx .llm_request_start_time ) * 1000 )
455515 core .log .warn (" failed to read response chunk: " , err )
456516 res ._upstream_bytes = bytes_read
517+ if flush_thread then
518+ ngx .thread .kill (flush_thread )
519+ flush_thread = nil
520+ end
457521 return transport_http .handle_error (err )
458522 end
459523 if not chunk then
460- if # sse_buf > 0 then
524+ local sse_rem = table.concat (sse_parts )
525+ if # sse_rem > 0 then
461526 core .log .warn (" dropping incomplete stream frame at EOF, size: " ,
462- # sse_buf )
527+ # sse_rem )
463528 end
464529
465530 res ._upstream_bytes = bytes_read
531+ ctx .var .apisix_upstream_response_time = math.floor (
532+ (ngx_now () - ctx .llm_request_start_time ) * 1000 )
466533 if converter and not output_sent then
534+ if flush_thread then
535+ ngx .thread .kill (flush_thread )
536+ end
467537 local msg = " streaming response completed without producing "
468538 .. " any output; the upstream likely returned a "
469539 .. " different stream format than the converter expects"
470540 core .log .error (msg )
471541 return 502 , msg
472542 end
543+ -- Final sync flush: ensure the last async-queued bytes reach the client.
544+ -- flush_err means client already disconnected; skip to avoid a noisy log.
545+ if flush_thread then
546+ ngx .thread .kill (flush_thread )
547+ flush_thread = nil
548+ end
549+ if not flush_err then
550+ ngx .flush (true )
551+ end
473552 return
474553 end
475554
476555 bytes_read = bytes_read + # chunk
477556
478- if ctx . var . llm_time_to_first_token == " 0 " then
557+ if not first_token_set then
479558 ctx .var .llm_time_to_first_token = math.floor (
480- (ngx_now () - ctx .llm_request_start_time ) * 1000 )
559+ (ngx_now () - ctx .llm_request_start_time ) * 1000 )
560+ first_token_set = true
561+ end
562+
563+ -- Skip table.concat when there is no carry-over remainder (common case).
564+ -- sse_parts is reset to {remainder} after each iteration; when the
565+ -- previous chunk ended on a boundary, sse_parts[1] == "" and we can
566+ -- hand the new chunk directly to decode_buf without allocating a concat.
567+ local candidate
568+ if sse_parts [1 ] == " " then
569+ candidate = chunk
570+ else
571+ sse_parts [# sse_parts + 1 ] = chunk
572+ candidate = table.concat (sse_parts )
481573 end
482574
483- sse_buf = sse_buf .. chunk
484- local complete , remainder = framing .split_buf (sse_buf )
575+ -- One-pass split + decode: finds all complete SSE events and the
576+ -- trailing remainder in a single forward scan (no PCRE, no double scan).
577+ local events , remainder = framing .decode_buf (candidate )
485578 local max_remainder = framing .max_remainder or 1024 * 1024
486579 if # remainder > max_remainder then
487580 core .log .warn (" stream remainder exceeded " , max_remainder , " bytes, resetting" )
488581 remainder = " "
489582 end
490- sse_buf = remainder
491- local events = complete ~= " " and framing .decode (complete ) or {}
583+ sse_parts = {remainder }
492584 ctx .llm_response_contents_in_chunk = {}
493585 local converted_chunks = {}
494586
@@ -532,24 +624,33 @@ function _M.parse_streaming_response(self, ctx, res, target_proto, converter, co
532624 :: CONTINUE::
533625 end
534626
535- -- Output: converter events or passthrough raw chunk.
536- -- Pass wait=true for synchronous flush so we can detect client disconnection.
627+ -- Dispatch chunk downstream. Plugins run per-chunk so body_filter
628+ -- hooks (e.g. content moderation) receive every SSE event individually.
629+ -- no_flush=true when the interval thread handles flushing; otherwise
630+ -- no_flush=nil + wait=true for synchronous per-chunk delivery guarantee.
631+ local no_flush = async_flush or nil
537632 if converter then
538633 for _ , c in ipairs (converted_chunks ) do
539- local ok , flush_err = plugin .lua_response_filter (ctx , res . headers , c , true )
540- output_sent = true
634+ local ok , flush_err = plugin .lua_response_filter (
635+ ctx , res . headers , c , no_flush , true )
541636 if not ok then
542637 abort_on_disconnect (flush_err )
543638 return
544639 end
640+ output_sent = true
545641 end
546642 else
547- local ok , flush_err = plugin .lua_response_filter (ctx , res . headers , chunk , true )
548- output_sent = true
643+ local ok , flush_err = plugin .lua_response_filter (
644+ ctx , res . headers , chunk , no_flush , true )
549645 if not ok then
550646 abort_on_disconnect (flush_err )
551647 return
552648 end
649+ output_sent = true
650+ end
651+ -- Let the interval flush thread know there is unflushed output.
652+ if async_flush then
653+ needs_flush = true
553654 end
554655
555656 -- Enforce runaway-upstream safeguards after processing the chunk.
@@ -561,6 +662,10 @@ function _M.parse_streaming_response(self, ctx, res, target_proto, converter, co
561662 limit_hit = " max_response_bytes"
562663 end
563664 if limit_hit then
665+ if flush_thread then
666+ ngx .thread .kill (flush_thread )
667+ flush_thread = nil
668+ end
564669 local duration_ms = math.floor ((ngx_now () -
565670 ctx .llm_request_start_time ) * 1000 )
566671 core .log .warn (" aborting AI stream: " , limit_hit , " exceeded;" ,
@@ -574,6 +679,7 @@ function _M.parse_streaming_response(self, ctx, res, target_proto, converter, co
574679 end
575680 -- Signal downstream filters (e.g. moderation plugins that defer
576681 -- work until request completion) that no more content is coming.
682+ ctx .var .apisix_upstream_response_time = duration_ms
577683 ctx .var .llm_request_done = true
578684 res ._upstream_bytes = bytes_read
579685 if output_sent then
@@ -602,6 +708,7 @@ function _M.parse_streaming_response(self, ctx, res, target_proto, converter, co
602708 -- backpressure, or time out stalled streams. See #13256 for a proper
603709 -- solution.
604710 ngx .sleep (0 )
711+
605712 end
606713end
607714
0 commit comments