Skip to content

Commit 1f784f1

Browse files
committed
feat(ai-proxy-multi): add semantic load-balancing algorithm
Add a `semantic` balancer algorithm to `ai-proxy-multi` that picks an LLM instance by the semantic similarity between the request prompt and each instance's configured `examples`, instead of by weight or hash. Each non-catchall instance declares `examples` describing the prompts it should serve. On the request path the plugin embeds those examples and the incoming prompt via a configurable OpenAI-compatible embedding endpoint, computes cosine similarity, aggregates per-instance scores, and routes to the highest-scoring instance that clears its threshold. When no instance clears its threshold — or embedding fails for any reason — the request fails open to the `catchall` instance (else the first instance), so a request always has a target and never 500s on the routing path. Reference vectors are embedded once per config version and cached; only the prompt is embedded per request. No vector database is required. New config: `balancer.algorithm=semantic`, `balancer.threshold`, `balancer.aggregation`, `balancer.expose_scores`, per-instance `examples` / `threshold` / `catchall`, and an `embeddings` block.
1 parent c7a76b2 commit 1f784f1

12 files changed

Lines changed: 1636 additions & 10 deletions

File tree

apisix/plugins/ai-providers/azure-openai.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ return require("apisix.plugins.ai-providers.base").new(
3232
path = "/completions",
3333
rewrite_request_body = rewrite_chat_request_body,
3434
},
35+
["openai-embeddings"] = { path = "/embeddings" },
3536
},
3637
}
3738
)

apisix/plugins/ai-providers/base.lua

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,8 @@ function _M.build_request(self, ctx, conf, request_body, opts)
188188
.. "includes a path", 400
189189
end
190190

191-
local headers = transport_http.construct_forward_headers(auth.header or {}, ctx)
191+
local headers = transport_http.construct_forward_headers(auth.header or {}, ctx,
192+
opts.skip_client_headers)
192193
if opts.host_header then
193194
headers["Host"] = opts.host_header
194195
end

apisix/plugins/ai-proxy-multi.lua

Lines changed: 210 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@ local ngx_now = ngx.now
3333

3434
local require = require
3535
local pcall = pcall
36+
local error = error
37+
local tostring = tostring
3638
local ipairs = ipairs
3739
local type = type
3840
local string = string
3941
local url = require("socket.url")
4042

4143
local priority_balancer = require("apisix.balancer.priority")
44+
local semantic = require("apisix.plugins.ai-proxy.semantic")
45+
local embedding = require("apisix.plugins.ai-proxy.embedding")
4246
local endpoint_regex = "^(https?)://([^:/]+):?(%d*)/?.*$"
4347

4448
local pickers = {}
@@ -48,6 +52,11 @@ local lrucache_server_picker = core.lrucache.new({
4852
local lrucache_health_status = core.lrucache.new({
4953
ttl = 300, count = 256
5054
})
55+
-- Keyed by route + conf version, so config changes invalidate immediately;
56+
-- the long ttl just avoids re-embedding references on unchanged config.
57+
local lrucache_semantic_vectors = core.lrucache.new({
58+
ttl = 3600, count = 256
59+
})
5160

5261
local plugin_name = "ai-proxy-multi"
5362
local _M = {
@@ -156,7 +165,41 @@ function _M.check_schema(conf)
156165
end
157166
end
158167

159-
return ok
168+
if algo == "semantic" then
169+
if not conf.embeddings then
170+
return false, "must configure `embeddings` when balancer algorithm is semantic"
171+
end
172+
if conf.embeddings.provider == "azure-openai" and not conf.embeddings.endpoint then
173+
return false, "must configure `embeddings.endpoint` when embeddings " ..
174+
"provider is azure-openai"
175+
end
176+
local catchall_count = 0
177+
for _, instance in ipairs(conf.instances) do
178+
if instance.catchall then
179+
catchall_count = catchall_count + 1
180+
else
181+
local has_example = false
182+
if instance.examples then
183+
for _, ex in ipairs(instance.examples) do
184+
if type(ex) == "string" and ex ~= "" then
185+
has_example = true
186+
break
187+
end
188+
end
189+
end
190+
if not has_example then
191+
return false, "instance '" .. (instance.name or "?") ..
192+
"': must configure non-empty `examples` for the semantic " ..
193+
"algorithm unless `catchall` is set"
194+
end
195+
end
196+
end
197+
if catchall_count > 1 then
198+
return false, "at most one instance may be marked `catchall`"
199+
end
200+
end
201+
202+
return true
160203
end
161204

162205

@@ -598,9 +641,174 @@ local function pick_target(ctx, conf, ups_tab)
598641
end
599642

600643

644+
local function extract_last_user_message()
645+
local body = core.request.get_json_request_body_table()
646+
if not body or type(body.messages) ~= "table" then
647+
return nil
648+
end
649+
for i = #body.messages, 1, -1 do
650+
local m = body.messages[i]
651+
if type(m) == "table" and m.role == "user" then
652+
local content = m.content
653+
if type(content) == "string" then
654+
return content
655+
elseif type(content) == "table" then
656+
-- multimodal content: concatenate the text parts so routing
657+
-- still works for {type=text|image_url,...} arrays.
658+
local parts = {}
659+
for _, p in ipairs(content) do
660+
if type(p) == "table" and p.type == "text"
661+
and type(p.text) == "string" then
662+
parts[#parts + 1] = p.text
663+
end
664+
end
665+
if #parts > 0 then
666+
return table_concat(parts, " ")
667+
end
668+
end
669+
end
670+
end
671+
return nil
672+
end
673+
674+
675+
-- Embed every instance's examples in one batch and group the normalized
676+
-- reference vectors by instance name. Raises on embedding failure so the
677+
-- lrucache below does not cache a bad result.
678+
local function build_instance_vectors(conf)
679+
local texts = {}
680+
local owners = {}
681+
for _, inst in ipairs(conf.instances) do
682+
if inst.examples then
683+
for _, ex in ipairs(inst.examples) do
684+
texts[#texts + 1] = ex
685+
owners[#texts] = inst.name
686+
end
687+
end
688+
end
689+
690+
local vecs, err = embedding.fetch(conf.embeddings, texts)
691+
if not vecs then
692+
error("failed to fetch reference embeddings: " .. tostring(err))
693+
end
694+
695+
local by_instance = {}
696+
for i, v in ipairs(vecs) do
697+
local name = owners[i]
698+
if name then
699+
by_instance[name] = by_instance[name] or {}
700+
core.table.insert(by_instance[name], semantic.normalize(v))
701+
end
702+
end
703+
return by_instance
704+
end
705+
706+
707+
-- Guaranteed fallback: catchall instance if configured, else the first
708+
-- instance. Never fails, so a request always has a target.
709+
local function semantic_fallback(conf)
710+
for _, inst in ipairs(conf.instances) do
711+
if inst.catchall then
712+
return inst.name, inst
713+
end
714+
end
715+
local inst = conf.instances[1]
716+
return inst.name, inst
717+
end
718+
719+
720+
local function pick_semantic_instance(ctx, conf)
721+
local version = plugin.conf_version(conf)
722+
local ok, by_instance = pcall(lrucache_semantic_vectors,
723+
ctx.matched_route.key .. "#semantic", version,
724+
build_instance_vectors, conf)
725+
if not ok or not by_instance then
726+
core.log.warn("semantic routing: ", by_instance, ", falling back")
727+
return semantic_fallback(conf)
728+
end
729+
730+
local prompt = extract_last_user_message()
731+
if not prompt then
732+
core.log.warn("semantic routing: no user message found, falling back")
733+
return semantic_fallback(conf)
734+
end
735+
736+
local qvecs, err = embedding.fetch(conf.embeddings, { prompt })
737+
if not qvecs or not qvecs[1] then
738+
core.log.warn("semantic routing: query embedding failed: ", err, ", falling back")
739+
return semantic_fallback(conf)
740+
end
741+
local qvec = semantic.normalize(qvecs[1])
742+
local qdim = #qvec
743+
744+
local aggregation = conf.balancer.aggregation or "avg"
745+
local ranked = {}
746+
for _, inst in ipairs(conf.instances) do
747+
local refs = by_instance[inst.name]
748+
if refs then
749+
local scores = {}
750+
for _, rv in ipairs(refs) do
751+
-- guard against dimension drift (e.g. embedding model changed):
752+
-- mismatched vectors would make dot() error, so fail open instead.
753+
if #rv ~= qdim then
754+
core.log.warn("semantic routing: embedding dimension mismatch ",
755+
"(query ", qdim, " vs reference ", #rv, "), falling back")
756+
return semantic_fallback(conf)
757+
end
758+
scores[#scores + 1] = semantic.dot(qvec, rv)
759+
end
760+
core.table.insert(ranked, {
761+
name = inst.name,
762+
score = semantic.aggregate(scores, aggregation),
763+
})
764+
end
765+
end
766+
core.table.sort(ranked, function(a, b) return a.score > b.score end)
767+
768+
local expose_scores = conf.balancer.expose_scores
769+
if expose_scores then
770+
local parts = {}
771+
for _, c in ipairs(ranked) do
772+
parts[#parts + 1] = c.name .. ":" .. string.format("%.4f", c.score)
773+
end
774+
core.response.set_header("X-AI-Semantic-Scores", table_concat(parts, ","))
775+
end
776+
777+
-- Highest score first; pick the first instance that clears its own threshold
778+
-- (per-instance override, else the global balancer.threshold).
779+
for _, cand in ipairs(ranked) do
780+
local inst = get_instance_conf(conf.instances, cand.name)
781+
local thr = inst.threshold or conf.balancer.threshold or 0
782+
if cand.score >= thr then
783+
if expose_scores then
784+
core.response.set_header("X-AI-Semantic-Route", cand.name)
785+
end
786+
core.log.info("semantic routing picked instance: ", cand.name,
787+
", score: ", cand.score)
788+
return cand.name, inst
789+
end
790+
end
791+
792+
if expose_scores then
793+
core.response.set_header("X-AI-Semantic-Route", "fallback")
794+
end
795+
-- Only on the fallback path: surface why nothing matched, without requiring
796+
-- expose_scores. Cheap, because this runs once per unmatched request.
797+
local unmatched = {}
798+
for _, c in ipairs(ranked) do
799+
unmatched[#unmatched + 1] = c.name .. ":" .. string.format("%.4f", c.score)
800+
end
801+
core.log.warn("semantic routing: no instance cleared threshold (scores: ",
802+
table_concat(unmatched, ","), "), falling back")
803+
return semantic_fallback(conf)
804+
end
805+
806+
601807
local function pick_ai_instance(ctx, conf, ups_tab)
602808
local instance_name, instance_conf, err
603-
if #conf.instances == 1 then
809+
if conf.balancer and conf.balancer.algorithm == "semantic" then
810+
instance_name, instance_conf = pick_semantic_instance(ctx, conf)
811+
elseif #conf.instances == 1 then
604812
instance_name = conf.instances[1].name
605813
instance_conf = conf.instances[1]
606814
else

0 commit comments

Comments
 (0)