feat(ai-proxy-multi): add semantic load-balancing algorithm#13676
feat(ai-proxy-multi): add semantic load-balancing algorithm#13676AlinsRan wants to merge 1 commit into
Conversation
f1618ea to
1f784f1
Compare
|
Is there a specific reason to log the full embedding response body here? If not, please avoid logging the entire body, since it may contain sensitive information. Logging only the status and a bounded, sanitized error summary would be safer. |
1f784f1 to
be76356
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces a new semantic load-balancing algorithm for ai-proxy-multi, allowing APISIX to select an LLM instance based on semantic similarity between the request prompt and per-instance example prompts, using an embeddings sidecar call and cached reference vectors.
Changes:
- Add semantic vector math + an embeddings batch client, and integrate semantic instance selection into
ai-proxy-multiwith fail-open fallback behavior. - Extend schema/docs to support
balancer.algorithm=semantic, per-instanceexamples/threshold/catchall, and anembeddingsconfiguration block (including encrypted auth fields). - Add test coverage for semantic math, schema validation, routing behavior, multimodal prompt extraction, and “do not forward client headers to embeddings”.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| t/plugin/ai-proxy-semantic.t | Unit tests for vector math helpers (normalize/dot/cosine/aggregate). |
| t/plugin/ai-proxy-semantic-schema.t | Schema validation tests for semantic config constraints. |
| t/plugin/ai-proxy-semantic-routing.t | End-to-end routing tests with mocked embeddings/LLM endpoints and fail-open cases. |
| docs/zh/latest/plugins/ai-proxy-multi.md | Chinese docs: add semantic algorithm config fields and usage examples. |
| docs/en/latest/plugins/ai-proxy-multi.md | English docs: add semantic algorithm config fields and usage examples. |
| apisix/plugins/ai-transport/http.lua | Add skip_client_headers support for sidecar calls (e.g., embeddings). |
| apisix/plugins/ai-proxy/semantic.lua | New pure-Lua vector math module used by semantic routing. |
| apisix/plugins/ai-proxy/schema.lua | Extend ai-proxy-multi schema with semantic/embeddings fields + encrypt_fields updates. |
| apisix/plugins/ai-proxy/embedding.lua | New embeddings batch client via ai-provider layer with bounded error handling. |
| apisix/plugins/ai-proxy-multi.lua | Implement semantic instance selection + reference-vector caching and prompt extraction. |
| apisix/plugins/ai-providers/base.lua | Wire skip_client_headers into header construction for provider requests. |
| apisix/plugins/ai-providers/azure-openai.lua | Add openai-embeddings capability path for Azure OpenAI provider. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
bbb4318 to
d3d9d4f
Compare
| }, | ||
| aggregation = { | ||
| type = "string", | ||
| enum = { "avg", "max", "min" }, |
There was a problem hiding this comment.
Would it make sense to drop avg/min here and just keep max (ideally as the fixed behaviour, not a knob)?
max is what the closest prior art does — LiteLLM's auto-routing hardcodes MAX aggregation for exactly this reason: "a strong match on one keyword in a tier is not diluted by that tier's other utterances." avg reintroduces that dilution: an instance with one dead-on example plus a couple of loosely-related ones scores lower than an instance whose examples are all mediocre-but-consistent, which is usually the opposite of what you want for intent routing.
And since avg is the default, the out-of-the-box behaviour is the diluted one — a route that works great with two tight examples can start mis-routing the moment someone adds a third, broader example.
If there's a concrete use case for avg/min I'm happy to be wrong, but otherwise a single max (no enum, no default to reason about) is simpler and matches what people expect from semantic routing. What do you think?
There was a problem hiding this comment.
You are right, and the numbers back it up. examples are alternative phrasings of one intent, so the question is "does any of them match" — that is max. I ran the aggregation on a concrete case:
| A: one dead-on example + two broader ones (1.0 / 0.5 / 0.4) | B: uniformly mediocre (0.7 / 0.7) | picked | |
|---|---|---|---|
avg |
0.633 | 0.700 | B — despite A having a perfect match |
max |
1.000 | 0.700 | A |
min |
0.400 | 0.700 | B |
And your "adding a third example breaks the route" point reproduces exactly: avg drops 0.750 → 0.633 when a broader example is appended, so a route that cleared a 0.7 threshold stops clearing it — for max nothing changes.
min is worse still: it demands that every phrasing match, which is nonsense for alternatives.
So there is no use case I can defend for either, and avg-as-default meant the out-of-the-box behaviour was the diluted one. Dropped the knob entirely in 9c58718 — no enum, no default: an instance is scored by its best-matching example. semantic.aggregate(scores, method) is now just semantic.max(scores).
| path = "/completions", | ||
| rewrite_request_body = rewrite_chat_request_body, | ||
| }, | ||
| ["openai-embeddings"] = { path = "/embeddings" }, |
There was a problem hiding this comment.
Heads up that this /embeddings path probably won't take effect for azure-openai. In ai-providers/base.lua, build_request resolves the path as parsed_url.path or opts.target_path — so once the endpoint URL carries a path (which it must for azure, e.g. .../openai/deployments/{deployment}), parsed_url.path wins and this capability path is dropped. Azure also needs the ?api-version= query param, which nothing on this path adds.
The doc example for embeddings.endpoint stops at /openai/deployments/{deployment} (no /embeddings, no api-version), so following the docs a semantic route with provider: azure-openai would POST to the deployment root and likely 404. All the routing tests use provider: openai, so this branch isn't exercised.
Could you either (a) require the azure embeddings.endpoint to include the full /embeddings?api-version=... and document that, or (b) construct the azure embeddings URL the same way the chat path does — and add one azure embeddings test so it doesn't silently regress? openai works today, but azure looks unverified.
There was a problem hiding this comment.
Confirmed — this was broken, thanks for catching it. build_request resolves parsed_url.path or opts.target_path, and an Azure endpoint always carries a path, so the capability /embeddings was dead code. Worse, my doc example stopped at the deployment root, so following it would have POSTed there and 404'd — and since every routing test used provider: openai, nothing exercised this.
Went with (a), matching the convention the azure chat path and ai-cache-semantic already use (full URL, api-version in the query):
azure-openainow declares the capability with no default path (["openai-embeddings"] = {}), so a path-less endpoint fails loudly inbuild_requestinstead of silently building a wrong URL.check_schemarejects an azureembeddings.endpointwithout a deployment path — otherwise the route would fail open to the catchall on every request, i.e. silently never work.- Docs (en + zh) and the schema description now show the full URL:
https://{resource}.openai.azure.com/openai/deployments/{deployment}/embeddings?api-version=2024-02-01. - Added an end-to-end test that drives a real azure-shaped URL and asserts the request lands on the deployment path with
api-version, plus a negative schema test.
8867bf8 to
7b4da59
Compare
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.
7b4da59 to
f7e8f18
Compare
Description
This PR adds a
semanticload-balancing algorithm toai-proxy-multi, so a route can pick an LLM instance by what the request is asking for rather than by weight (roundrobin) or by a hash key (chash).Problem
ai-proxy-multitoday can spread traffic across several LLM instances, but the choice is blind to the request. In practice the instances behind one route are rarely interchangeable — a frontier model, a small cheap model and a domain-tuned model differ by an order of magnitude in cost and latency. Today the only ways to send the right prompt to the right model are:model(leaks backend topology into every caller, and every model swap becomes a client-side change), orNone of these let a single endpoint accept
"model": "auto"and route on meaning.Proposal
Let each instance describe, in natural language, the kind of prompt it should serve. The gateway embeds those descriptions once, embeds the incoming prompt, and picks the closest instance by cosine similarity.
Key properties:
examplesare embedded in a single batch and cached in an lrucache keyed by route + plugin config version, so a config change invalidates immediately and a steady-state request only embeds the prompt.nullvector, non-numeric component, missing index, dimension mismatch (e.g. the embedding model was changed underneath), lrucache error — falls back to thecatchallinstance (or the first instance if none is marked). The routing path cannot return 500 because embeddings are unavailable.Use cases
1. Cost routing — send hard prompts to the expensive model, everything else to the cheap one.
The single most common motivation. Coding and reasoning go to a frontier model; translation and summarisation go to a small one; anything that matches neither still gets served.
{ "balancer": { "algorithm": "semantic", "threshold": 0.5 }, "embeddings": { "provider": "openai", "model": "text-embedding-3-small", "auth": { "header": { "Authorization": "Bearer $EMBEDDING_KEY" } } }, "instances": [ { "name": "reasoning", "provider": "openai", "weight": 1, "options": { "model": "gpt-4o" }, "auth": { "header": { "Authorization": "Bearer $KEY" } }, "examples": ["write a python function", "debug this stack trace", "explain this algorithm"] }, { "name": "cheap", "provider": "openai", "weight": 1, "options": { "model": "gpt-4o-mini" }, "auth": { "header": { "Authorization": "Bearer $KEY" } }, "examples": ["translate this sentence", "summarize this paragraph"] }, { "name": "default", "provider": "openai", "weight": 1, "options": { "model": "gpt-4o-mini" }, "auth": { "header": { "Authorization": "Bearer $KEY" } }, "catchall": true } ] }Clients always POST
"model": "auto". Swappinggpt-4ofor a newer model, or re-balancing what counts as "hard", is a gateway config change — no client ships a new build.2. Domain specialisation — route to fine-tuned or self-hosted models per subject.
A support assistant fronted by one endpoint, backed by a billing-tuned model, a technical-troubleshooting model, and a general model as
catchall. The client does not need to know the taxonomy, and adding a fourth domain is one more instance with a fewexamples.3. Data-residency / compliance routing.
Prompts that look like they carry regulated content (
"examples": ["my social security number is", "here is the patient's diagnosis"]) route to a self-hosted instance with a tightthreshold; everything else goes to the public API viacatchall. Note this is best-effort routing, not a DLP control — it complements, and does not replace, a content-inspection plugin.4. Progressive model migration.
Point a new model at a narrow set of
examplesand give it a high per-instancethreshold, so it only receives prompts it is confidently a match for. Widen the examples (or lower the threshold) as confidence grows, and drop the old instance when its share reaches zero.expose_scoresshows the score distribution while tuning.Configuration
balancer.algorithmroundrobinsemanticbalancer.thresholdbalancer.expose_scoresfalseX-AI-Semantic-Route/X-AI-Semantic-Scoresresponse headersinstances[].examplescatchallinstances[].thresholdbalancer.thresholdfor this instanceinstances[].catchallexamplesembeddingsalgorithm=semanticembeddings.provideropenai/azure-openaiembeddings.modeltext-embedding-3-smallembeddings.endpointopenai; forazure-openaithe full URL incl. deployment path +api-versionembeddings.authheader/query; both are inencrypt_fieldsembeddings.timeout10000embeddings.ssl_verifytrueThe embedding request is issued through the existing ai-provider layer (
openai-embeddingscapability), so it reuses the same endpoint resolution, authentication and HTTP transport as the chat path rather than a second HTTP client. It is a self-contained sidecar call: the client's own request headers are deliberately not forwarded to the embedding provider.Debugging the routing decision
balancer.expose_scores(defaultfalse) makes the plugin report how each instance scored and which one it picked:X-AI-Semantic-Scoreslists every instance that hasexamples, highest score first — not just the winner, so you can see how close the runners-up came. Thecatchallhas noexamples, hence no similarity score, and does not appear.When nothing clears the threshold, the pick becomes
fallbackwhile the scores still show how close each one got — which is exactly what you need to calibratethreshold:If the headers are absent while
expose_scoresis on, the embedding step itself failed and the request was served by thecatchallbefore any score was computed.Since these headers reveal instance names to the caller, they are meant for tuning, not production. The same score list is always logged at
warnon the fallback path, with no configuration and nothing exposed to clients:Alternatives considered
embeddingsblock already allows pointing at a self-hosted OpenAI-compatible embedding server.Known limitation
semanticselects an instance but does not participate in health checks orfallback_strategy/ retry: if the chosen instance's upstream then fails, that failure is returned to the client. It only falls back when routing is inconclusive (no instance clears its threshold) or embedding fails. Combining semantic selection with health-aware failover is a follow-up; the behaviour is stated in the schema description and the docs so it is not a surprise.Tests
t/plugin/ai-proxy-semantic.t(similarity math, incl. zero-vector and empty-aggregate edges),t/plugin/ai-proxy-semantic-schema.t(6 negative schema cases),t/plugin/ai-proxy-semantic-routing.t(14 cases): routing by intent, catchall fallback, and one case per fail-open guard — malformed (null) vector, non-table 200 body, non-200 response, dimension mismatch, reference-embedding failure — plus multimodalcontentarrays and a regression test asserting the client'sCookienever reaches the embedding endpoint.Which issue(s) this PR fixes:
N/A — new feature.
Checklist
docs/enanddocs/zh)roundrobin/chashroutes are unaffected; every new field is optional andsemanticmust be opted into)