balancer_body — 带请求体替换的网关降级链

背景

在 LLM API 网关中,当主模型服务不可用时,需要自动降级到备用模型。简单的重试逻辑只需切换 upstream 地址,但模型能力不同时往往需要改写请求体(例如替换 model 字段)。这个项目就是解决「降级 + 请求体重写」的问题。

架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
客户端请求

access.lua: 解析 X-Target / X-Target-Bak 头
↓ (有降级配置)
内部跳转 @nexthop

access_nexthop.lua: 序列化路由信息到 $route_info 变量

proxy_pass → 主目标
↓ (5xx)
error_page → @retry

retry_handler.lua: 遍历降级目标,逐个改写 body 并重试

返回第一个成功的响应

核心 Location 设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
location / {
access_by_lua_file lua/access.lua;
proxy_pass $target;
error_page 500 502 503 504 = @retry;
}

location @nexthop {
access_by_lua_file lua/access_nexthop.lua;
proxy_pass $nexthop_target;
error_page 500 502 503 504 = @retry;
}

location @retry {
content_by_lua_file lua/retry_handler.lua;
}

三个 location 各自独立:/ 处理直接代理,@nexthop 处理带降级配置的请求,@retry 处理降级重试。使用命名 location @retry 而非普通路径,从设计上杜绝了外部直接访问的可能。

关键实现

请求体改写 (body_builder.lua)

1
2
3
4
5
6
7
8
local cjson = require "cjson"

local function build_body(original_body, backup)
local body = cjson.decode(original_body)
body.model = backup.model_id or body.model
body.model_service_id = backup.model_service_id
return cjson.encode(body)
end

每个降级目标可以有不同的 model 映射,在重试时动态替换。

流式响应支持

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 根据 Content-Length 判断是否使用流式传输
local content_length = res.headers["Content-Length"]
if not content_length or tonumber(content_length) > 65536 then
-- 流式转发 chunked 响应
local reader = res.body_reader
while true do
local chunk, err = reader(8192)
if not chunk then break end
ngx.print(chunk)
ngx.flush(true)
end
else
ngx.print(res.body)
end

大于 64KB 或无 Content-Length 时自动切换流式传输,保证大响应不占满内存。

随机起点轮询

降级目标按随机起点轮询,避免所有请求同时打向同一个降级服务器:

1
2
3
4
5
6
local start = math.random(#backups)
for i = 1, #backups do
local idx = (start + i - 2) % #backups + 1
local backup = backups[idx]
-- 尝试...
end

技术要点

  • ngx.ctx 丢失问题error_page 内部跳转会清空 ngx.ctx,改为用 ngx.var 传递数据
  • proxy_intercept_errors:该指令不支持变量,通过拆分 location /location @nexthop 实现直连透传 vs 降级拦截的区别
  • resty.http 库:本地 vendor 了完整 resty.http(1185 行),用于 @retry 阶段的 cosocket 请求

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 启动
openresty -p . -c conf/nginx.conf

# 直接代理(无降级)
curl localhost:8000/ -H 'X-Target: {"host":"127.0.0.1","port":9001}'

# 带降级(主目标失败时切换)
curl localhost:8000/ \
-H 'X-Target: {"host":"127.0.0.1","port":9001}' \
-H 'X-Target-Bak: [{"host":"127.0.0.1","port":9002}]' \
-H 'X-Target-Model-Bak: [{"model_id":"gpt-4o"}]'

# 控制 mock 后端行为
curl 'localhost:8000/test/config?primary=fail'
🤖 AI 博客助手
你好!我是博客 AI 助手,可以回答关于博客文章内容的问题。试试问我吧~