Skip to content

Commit c1fc58f

Browse files
authored
perf(ai-proxy): optimize SSE decoder - remove PCRE, add decode_buf, fix comment lines (#13391)
1 parent f434e1b commit c1fc58f

9 files changed

Lines changed: 786 additions & 103 deletions

File tree

apisix/plugin.lua

Lines changed: 29 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,52 +1471,45 @@ end
14711471
-- can detect client disconnection. Defaults to false (async flush).
14721472
-- @return boolean, string|nil Always returns (ok, err). On success returns true.
14731473
-- On flush failure or print failure returns false, err.
1474-
function _M.lua_response_filter(api_ctx, headers, body, wait)
1474+
function _M.lua_response_filter(api_ctx, headers, body, no_flush, wait)
14751475
local plugins = api_ctx.plugins
1476-
if not plugins or #plugins == 0 then
1477-
-- if there is no any plugin, just print the original body to downstream
1478-
local ok, err = ngx_print(body)
1479-
if not ok then
1480-
return false, err
1481-
end
1482-
ok, err = ngx_flush(wait == true)
1483-
if not ok then
1484-
return false, err
1485-
end
1486-
return true
1487-
end
1488-
for i = 1, #plugins, 2 do
1489-
local phase_func = plugins[i]["lua_body_filter"]
1490-
if phase_func then
1491-
local conf = plugins[i + 1]
1492-
if not meta_filter(api_ctx, plugins[i]["name"], conf)then
1493-
goto CONTINUE
1494-
end
1495-
1496-
run_meta_pre_function(conf, api_ctx, plugins[i]["name"])
1497-
local code, new_body = phase_func(conf, api_ctx, headers, body)
1498-
if code then
1499-
if code ~= ngx_ok then
1500-
ngx.status = code
1476+
if plugins and #plugins > 0 then
1477+
for i = 1, #plugins, 2 do
1478+
local phase_func = plugins[i]["lua_body_filter"]
1479+
if phase_func then
1480+
local conf = plugins[i + 1]
1481+
if not meta_filter(api_ctx, plugins[i]["name"], conf)then
1482+
goto CONTINUE
15011483
end
15021484

1503-
ngx_print(new_body)
1504-
ngx_exit(ngx_ok)
1505-
end
1506-
if new_body then
1507-
body = new_body
1485+
run_meta_pre_function(conf, api_ctx, plugins[i]["name"])
1486+
local code, new_body = phase_func(conf, api_ctx, headers, body)
1487+
if code then
1488+
if code ~= ngx_ok then
1489+
ngx.status = code
1490+
end
1491+
1492+
ngx_print(new_body)
1493+
ngx_exit(ngx_ok)
1494+
end
1495+
if new_body then
1496+
body = new_body
1497+
end
15081498
end
1509-
end
15101499

1511-
::CONTINUE::
1500+
::CONTINUE::
1501+
end
15121502
end
15131503
local ok, err = ngx_print(body)
15141504
if not ok then
15151505
return false, err
15161506
end
1517-
ok, err = ngx_flush(wait == true)
1518-
if not ok then
1519-
return false, err
1507+
if not no_flush then
1508+
core.log.debug("lua_response_filter: flushing chunk to client")
1509+
ok, err = ngx_flush(wait == true)
1510+
if not ok then
1511+
return false, err
1512+
end
15201513
end
15211514
return true
15221515
end

apisix/plugins/ai-providers/base.lua

Lines changed: 125 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ local protocols = require("apisix.plugins.ai-protocols")
3737
local deep_merge = require("apisix.plugins.ai-proxy.merge").deep_merge
3838
local ngx = ngx
3939
local ngx_now = ngx.now
40-
local tonumber = tonumber
4140
local require = require
41+
local tonumber = tonumber
4242

4343
local table = table
4444
local 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
606713
end
607714

apisix/plugins/ai-proxy/schema.lua

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,16 @@ _M.ai_proxy_schema = {
267267
description = "keepalive timeout in milliseconds",
268268
},
269269
keepalive_pool = {type = "integer", minimum = 1, default = 30},
270+
streaming_flush_interval_ms = {
271+
type = "integer",
272+
minimum = 0,
273+
default = 10,
274+
description = "A background thread flushes the output buffer every N "
275+
.. "milliseconds (async flush). Useful when the upstream bursts "
276+
.. "multiple tokens at once and you need to bound client latency. "
277+
.. "Set to 0 to disable the background thread and flush each "
278+
.. "chunk synchronously inline.",
279+
},
270280
ssl_verify = {type = "boolean", default = true },
271281
override = override_schema,
272282
},
@@ -353,6 +363,16 @@ _M.ai_proxy_multi_schema = {
353363
description = "keepalive timeout in milliseconds",
354364
},
355365
keepalive_pool = {type = "integer", minimum = 1, default = 30},
366+
streaming_flush_interval_ms = {
367+
type = "integer",
368+
minimum = 0,
369+
default = 10,
370+
description = "A background thread flushes the output buffer every N "
371+
.. "milliseconds (async flush). Useful when the upstream bursts "
372+
.. "multiple tokens at once and you need to bound client latency. "
373+
.. "Set to 0 to disable the background thread and flush each "
374+
.. "chunk synchronously inline.",
375+
},
356376
ssl_verify = {type = "boolean", default = true },
357377
},
358378
required = {"instances"},

apisix/plugins/ai-transport/aws-eventstream.lua

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,4 +289,15 @@ function _M.decode(buf)
289289
end
290290

291291

292+
--- Combined split + decode in one call, matching the interface expected by
293+
-- ai-providers/base.lua.
294+
-- @param buf string Accumulated bytes from the upstream socket.
295+
-- @return table Array of decoded event tables (same as decode()).
296+
-- @return string Trailing bytes that did not form a complete frame.
297+
function _M.decode_buf(buf)
298+
local complete, remainder = _M.split_buf(buf)
299+
return _M.decode(complete), remainder
300+
end
301+
302+
292303
return _M

0 commit comments

Comments
 (0)