Higress AI 网关实战:如何实现基于用户的 Token 用量统计与展示
本文详细介绍了一套基于 Higress AI 网关的完整方案,通过 key-auth 认证插件、自定义 Lua EnvoyFilter、ai-statistics 统计插件的组合,配合 Promtail + Loki + Grafana 可观测性链路,实现从用户 API Key 认证到 Token 用量按用户多维度可视化展示的完整闭环。
一、背景与目标
在企业内部共享 AI 大模型服务时,我们面临以下需求:
- 用户认证:每个团队成员拥有独立的 API Key,通过网关统一接入
- 权限隔离:不同用户可访问不同的模型路由(如某些用户只能用阿里云通义千问,某些用户只能用 DeepSeek)
- 用量统计:精确统计每个用户的 Token 消耗(input_token / output_token / total_token)
- 可视化展示:在 Grafana 面板中按用户、模型、路由多维度展示 Token 用量
Higress 作为阿里云开源的云原生 API 网关,内置了丰富的 AI 网关能力。本文将展示如何在不修改 Higress 核心代码的前提下,通过插件组合实现上述完整链路。
二、整体架构
┌─────────────┐
│ 用户请求 │ Authorization: Bearer <UUID>
│ (Bearer Key) │
└──────┬──────┘
│
▼
┌──────────────────────────────────────────────┐
│ Higress AI Gateway │
│ │
│ ┌─────────────┐ ┌─────────────────────┐ │
│ │ key-auth │───▶│ consumer-header- │ │
│ │ WasmPlugin │ │ injector (Lua) │ │
│ │ (AUTHN阶段) │ │ UUID → 用户名映射 │ │
│ └─────────────┘ └──────────┬──────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ ai-statistics WasmPlugin │ │
│ │ 读取 x-mse-consumer header │ │
│ │ 采集 input/output/total token │ │
│ │ 写入 audit log │ │
│ └────────────────────┬────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ Envoy Access Log (JSON) │ │
│ │ consumer / api_key / ai_log / route │ │
│ └────────────────────┬────────────────────┘ │
└───────────────────────┼──────────────────────┘
│
▼
┌──────────────┐
│ Promtail │ 采集日志, 两阶段 JSON 解析
└──────┬───────┘
│
▼
┌──────────────┐
│ Loki │ 结构化日志存储, labels 索引
└──────┬───────┘
│
▼
┌──────────────┐
│ Grafana │ 按用户/模型/路由多维展示
└──────────────┘
整条链路分为 网关侧 (认证 → 身份注入 → 用量采集 → 日志输出)和 可观测侧(日志采集 → 存储索引 → 可视化展示)两大部分。下面逐层展开。
三、网关侧实现
3.1 用户认证 --- key-auth WasmPlugin
Higress 内置了 key-auth 插件,运行在 AUTHN 阶段(优先级 310),用于基于 API Key 的认证。
3.1.1 消费者注册
每个用户(consumer)被分配一个唯一的 UUID 作为 Bearer Token,在 Higress Console 中创建消费者后自动生成。配置如下:
yaml
apiVersion: extensions.higress.io/v1alpha1
kind: WasmPlugin
metadata:
name: key-auth.internal
namespace: higress-system
spec:
phase: AUTHN
priority: 310
defaultConfig:
consumers:
- credentials:
- Bearer a1b2c3d4-1111-2222-3333-000000000001
in_header: true
in_query: false
keys:
- Authorization
name: user-a
- credentials:
- Bearer a1b2c3d4-1111-2222-3333-000000000002
in_header: true
keys:
- Authorization
- x-api-key
name: user-b
# ... 更多用户
global_auth: false
关键点说明:
credentials:用户的认证凭证,格式为Bearer <UUID>in_header: true:支持从请求头中提取凭证in_query: false:是否支持从 URL 查询参数中提取keys:指定从哪些 header 中查找凭证(如Authorization、x-api-key)global_auth: false:不开启全局认证,仅在匹配到的路由上生效
3.1.2 路由级权限控制
通过 matchRules 为不同 AI 路由配置允许访问的消费者白名单:
yaml
matchRules:
# 阿里云通用路由:允许多人访问
- config:
allow:
- user-c
- user-d
- user-b
- user-a
- user-e
ingress:
- ai-route-aliyun.internal
# user-a 专属路由:仅允许 user-a 访问
- config:
allow:
- user-a
ingress:
- ai-route-team-a-aliyun.internal
# DeepSeek 路由:允许除 user-a 外的所有人
- config:
allow:
- user-b
- user-d
- user-e
# ...
ingress:
- ai-route-deepseek.internal
这样,当用户携带自己的 Bearer Token 请求时,key-auth 插件会:
- 从
Authorization头中提取 UUID - 匹配到对应的 consumer 名称
- 检查该 consumer 是否在目标路由的
allow列表中 - 认证通过后,将 consumer 信息传递给后续插件
3.2 身份注入 --- consumer-header-injector (Lua EnvoyFilter)
3.2.1 为什么需要这一层?
key-auth 插件认证通过后,会将 consumer 信息写入 Envoy 的 FILTER_STATE,但后续的 ai-statistics 插件需要通过 请求 header 来读取消费者名称。因此我们需要一个中间层,将 UUID 映射为用户名并注入到请求 header 中。
这里我们使用 EnvoyFilter(Lua 脚本)来实现:
yaml
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: consumer-header-injector
namespace: higress-system
spec:
configPatches:
- applyTo: HTTP_FILTER
match:
context: GATEWAY
listener:
filterChain:
filter:
name: envoy.filters.network.http_connection_manager
patch:
operation: INSERT_BEFORE
value:
name: envoy.filters.http.lua
typed_config:
'@type': type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua
inlineCode: |
function envoy_on_request(request_handle)
local auth = request_handle:headers():get("authorization")
if auth then
local key = string.match(auth, "Bearer%s+(.+)")
if key then
-- UUID 到用户名的映射表
local consumers = {
["a1b2c3d4-1111-2222-3333-000000000001"] = "user-a",
["a1b2c3d4-1111-2222-3333-000000000002"] = "user-b",
["a1b2c3d4-1111-2222-3333-000000000003"] = "user-c",
["a1b2c3d4-1111-2222-3333-000000000004"] = "user-d",
["a1b2c3d4-1111-2222-3333-000000000005"] = "user-e",
}
local consumer = consumers[key]
if consumer then
-- 注入两个 header
request_handle:headers():add("x-mse-consumer", consumer)
request_handle:headers():add("x-consumer-name", consumer)
end
end
end
end
工作原理:
- 从
Authorization头中提取 Bearer Token(UUID) - 通过硬编码的 Lua 表将 UUID 映射为消费者名称
- 注入两个 header:
x-mse-consumer:供 ai-statistics 插件读取,写入审计日志x-consumer-name:供 Envoy access log 直接记录
3.2.2 自动同步脚本
手动维护 Lua 映射表容易出错。当在 Higress Console 中添加或删除用户时,需要同步更新 EnvoyFilter 中的映射关系。下面是一个自动化同步脚本:
bash
#!/bin/bash
# sync-consumer-map.sh
# 从 key-auth 配置中自动提取用户信息,同步到 Lua 映射表
set -e
KEY_AUTH_CFG="/path/to/key-auth.internal.yaml"
LUA_CFG="/path/to/consumer-header-injector.yaml"
echo "读取 key-auth 配置..."
python3 - "$KEY_AUTH_CFG" "$LUA_CFG" << 'PYTHON_SCRIPT'
import re, sys
key_auth_path = sys.argv[1]
lua_path = sys.argv[2]
consumers = {}
current_uuid = None
current_name = None
in_consumers = False
with open(key_auth_path, 'r') as f:
for line in f:
stripped = line.strip()
if stripped == 'consumers:':
in_consumers = True
continue
if not in_consumers:
continue
# 提取 UUID
if stripped.startswith('- Bearer'):
current_uuid = stripped.split('Bearer', 1)[1].strip()
# 提取 Name
elif stripped.startswith('name:'):
current_name = stripped.split(':', 1)[1].strip()
if current_uuid and current_name:
consumers[current_uuid] = current_name
current_uuid = None
current_name = None
# 生成 Lua 表
lua_table = " local consumers = {\n"
for uuid, name in consumers.items():
lua_table += f' ["{uuid}"] = "{name}",\n'
lua_table += " }"
# 替换原有映射表
with open(lua_path, 'r') as f:
content = f.read()
pattern = r'(\s+local consumers = \{[\s\S]*? \})'
new_content = re.sub(pattern, "\n" + lua_table, content)
with open(lua_path, 'w') as f:
f.write(new_content)
print(f"同步完成,共 {len(consumers)} 个用户")
PYTHON_SCRIPT
# 重启网关使配置生效
docker restart higress-ai-gateway
echo "全部完成!"
这个脚本的核心逻辑是:
- 用 Python 解析
key-auth.internal.yaml中所有credentials+name对 - 自动生成 Lua 映射表
- 用正则替换
consumer-header-injector.yaml中的旧映射表 - 重启 gateway 容器使配置生效
3.3 Token 用量采集 --- ai-statistics WasmPlugin
ai-statistics 是 Higress 内置的 AI 统计插件(优先级 900),能够自动解析 LLM 响应中的 Token 统计信息。我们需要配置它从请求 header 中读取消费者名称:
yaml
apiVersion: extensions.higress.io/v1alpha1
kind: WasmPlugin
metadata:
name: ai-statistics-2.0.1
namespace: higress-system
spec:
priority: 900
matchRules:
- config:
attributes:
- key: consumer # 自定义属性名
value_source: request_header # 从请求 header 中读取
value: x-mse-consumer # header 名称(由 Lua 脚本注入)
apply_to_log: true # 写入审计日志
as_separate_log_field: true # 作为独立字段(而非嵌套在 ai_log 中)
configDisable: false
ingress:
- ai-route-anthropic.internal
defaultConfigDisable: true
配置要点:
| 字段 | 含义 |
|---|---|
value_source: request_header |
从请求 header 中获取值 |
value: x-mse-consumer |
读取 Lua 脚本注入的 header |
apply_to_log: true |
将该属性写入审计日志 |
as_separate_log_field: true |
作为独立字段,方便后续 LogQL 查询 |
该插件会自动从 LLM 的响应中提取以下信息并写入 ai_log 字段:
model:使用的模型名称(如qwen-max、deepseek-chat)input_token:输入 Token 数output_token:输出 Token 数total_token:总 Token 数cached_tokens:缓存命中的 Token 数(如有)reasoning_tokens:推理 Token 数(如有)
3.4 结构化访问日志 --- Envoy Access Log
在 Higress 的 mesh 配置中,定义 JSON 格式的 access log,将上述所有信息汇聚到一条日志中:
yaml
# higress-config.yaml (mesh 配置段)
accessLogFormat: |
{
"consumer": "%FILTER_STATE(wasm.consumer:PLAIN)%",
"api_key": "%REQ(X-CONSUMER-NAME)%",
"ai_log": "%FILTER_STATE(wasm.ai_log:PLAIN)%",
"route_name": "%ROUTE_NAME%",
"start_time": "%START_TIME%",
"method": "%REQ(:METHOD)%",
"path": "%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%",
"response_code": "%RESPONSE_CODE%",
"duration": "%DURATION%",
...
}
一条典型的日志输出如下:
json
{
"consumer": "user-a",
"api_key": "user-a",
"ai_log": "{\"model\":\"qwen-max\",\"input_token\":156,\"output_token\":892,\"total_token\":1048}",
"route_name": "ai-route-aliyun.internal",
"start_time": "2026-09-07T10:30:00.123Z",
"response_code": "200",
"duration": "2345"
}
字段来源总结:
| 字段 | 来源 | 说明 |
|---|---|---|
consumer |
FILTER_STATE(wasm.consumer) |
ai-statistics 插件写入的消费者名称 |
api_key |
REQ(X-CONSUMER-NAME) |
Lua 脚本注入的用户名 header |
ai_log |
FILTER_STATE(wasm.ai_log) |
ai-statistics 插件采集的 Token 统计 JSON |
route_name |
ROUTE_NAME |
Envoy 路由名称 |
四、可观测侧实现
4.1 日志采集 --- Promtail
Promtail 负责从网关容器采集 access log,进行结构化解析后推送到 Loki。
yaml
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: higress-audit-logs
static_configs:
- targets:
- localhost
labels:
job: higress-audit-logs
__path__: /var/log/higress/proxy/access.log*
pipeline_stages:
# 第 1 次解析:外层 JSON
- json:
expressions:
start_time: start_time
ai_log: ai_log
route: route_name
consumer: consumer
api_key: api_key
# 用请求的 start_time 替换采集时间作为日志时间戳
- timestamp:
source: start_time
format: RFC3339Nano
# 第 2 次解析:内层 ai_log JSON 字符串
- json:
source: ai_log
expressions:
model: model
# 提取为 Loki labels(走索引,查询时高效定位)
- labels:
model:
route:
consumer:
api_key:
两阶段解析流程图:
原始日志 (JSON 字符串)
│
▼ 第 1 次 json 解析
┌──────────────────────────────────┐
│ start_time → 替换日志时间戳 │
│ ai_log → 待二次解析的 JSON 串 │
│ route → Loki label │
│ consumer → Loki label │
│ api_key → Loki label │
└──────────────┬───────────────────┘
│
▼ 第 2 次 json 解析 (source: ai_log)
┌──────────────────────────────────┐
│ model → Loki label │
│ (input_token/output_token 等 │
│ 在查询时按需解析) │
└──────────────────────────────────┘
设计要点:
start_time替换时间戳:确保 Grafana 中展示的时间是请求实际发生的时间,而非日志采集时间labels提取:将model、route、consumer、api_key提取为 Loki labels,查询时可以直接通过标签过滤,无需全文扫描- Token 数值不在采集阶段解析:
input_token、output_token等数值字段在 Grafana 查询时通过 LogQL 动态解析,避免 labels 基数过高
4.2 可视化展示 --- Grafana Dashboard
在 Grafana 中创建 Dashboard,数据源选择 Loki,通过 LogQL 实现多维度的 Token 用量展示。
4.2.1 按模型统计 Token 用量 (Model Token Usage)
展示每个 AI 模型的调用次数和 Token 消耗:
logql
# 调用次数
sum by(model) (
count_over_time({job="higress-audit-logs", model!=""}
|= "ai_log" | json | ai_log!="-" [$__range])
)
# Input Token
sum by(model) (
sum_over_time({job="higress-audit-logs", model!=""}
|= "ai_log" | json | ai_log!="-"
| line_format "{{.ai_log}}" | json input_token="input_token"
| unwrap input_token [$__range])
)
# Output Token
sum by(model) (
sum_over_time({job="higress-audit-logs", model!=""}
|= "ai_log" | json | ai_log!="-"
| line_format "{{.ai_log}}" | json output_token="output_token"
| unwrap output_token [$__range])
)
# Total Token
sum by(model) (
sum_over_time({job="higress-audit-logs", model!=""}
|= "ai_log" | json | ai_log!="-"
| line_format "{{.ai_log}}" | json total_token="total_token"
| unwrap total_token [$__range])
)
4.2.2 按消费者统计 Token 用量 (Consumer Token Usage)
展示每个消费者(路由维度)的 Token 消耗:
logql
# 按路由聚合的调用次数
sum by(route) (
count_over_time({job="higress-audit-logs", model!=""}
|= "ai_log" | json | ai_log!="-" [$__range])
)
# 按路由聚合的 Total Token
sum by(route) (
sum_over_time({job="higress-audit-logs", model!=""}
|= "ai_log" | json | ai_log!="-"
| line_format "{{.ai_log}}" | json total_token="total_token"
| unwrap total_token [$__range])
)
4.2.3 按路由 + 用户 + 模型统计(最细粒度)
这是核心面板 ,按 route、api_key(消费者名)、model 三个维度聚合,精确展示每个用户在每个路由上使用每个模型的 Token 用量:
logql
# 调用次数
sum by(route, api_key, model) (
count_over_time({job="higress-audit-logs", model!=""}
|= "ai_log" | json | ai_log!="-" [$__range])
)
# Input Token
sum by(route, api_key, model) (
sum_over_time({job="higress-audit-logs", model!=""}
|= "ai_log" | json | ai_log!="-"
| line_format "{{.ai_log}}" | json input_token="input_token"
| unwrap input_token [$__range])
)
# Output Token
sum by(route, api_key, model) (
sum_over_time({job="higress-audit-logs", model!=""}
|= "ai_log" | json | ai_log!="-"
| line_format "{{.ai_log}}" | json output_token="output_token"
| unwrap output_token [$__range])
)
# Total Token
sum by(route, api_key, model) (
sum_over_time({job="higress-audit-logs", model!=""}
|= "ai_log" | json | ai_log!="-"
| line_format "{{.ai_log}}" | json total_token="total_token"
| unwrap total_token [$__range])
)
4.2.4 Token 明细日志
除了聚合统计,还可以展示每次请求的 Token 消耗明细:
logql
{job="higress-audit-logs"}
|= "ai_log"
| json
| label_format route="{{.route_name}}", consumer="{{.ai_consumer}}"
| line_format "{{.ai_log}}"
| json input_token="input_token", output_token="output_token",
total_token="total_token", model="model"
| line_format "\n{{.route}} | {{.consumer}} | {{.model}} | In:{{.input_token}} | Out:{{.output_token}} | Total:{{.total_token}}\n"
4.2.5 Grafana 数据后处理
Grafana 面板通过 transformations 对查询结果做进一步处理:
| 转换步骤 | 作用 |
|---|---|
| Merge | 合并多个查询(调用次数、Input Token、Output Token、Total Token)的结果 |
| Group By | 按 api_key(消费者名)、model、route 分组聚合 |
| Organize | 重命名列(如 route_name → Consumer (Route)),通过正则 ^ai-route-(.+?)\.internal$ 提取可读的路由名 |
最终展示效果类似:
| Consumer (Route) | Consumer | Model | 调用次数 | Input Token | Output Token | Total Token |
|---|---|---|---|---|---|---|
| aliyun | user-a | qwen-max | 156 | 245,800 | 189,200 | 435,000 |
| aliyun | user-b | qwen-plus | 89 | 120,500 | 98,300 | 218,800 |
| deepseek | user-c | deepseek-chat | 234 | 567,000 | 423,000 | 990,000 |
五、插件执行顺序
理解各插件的执行顺序对于排查问题至关重要:
请求进入
│
▼ AUTHN 阶段 (priority: 310)
┌──────────────────────────────────────┐
│ key-auth │
│ 1. 提取 Authorization header │
│ 2. 匹配 consumer │
│ 3. 检查路由 allow 列表 │
│ 4. 写入 FILTER_STATE(consumer) │
└──────────────┬───────────────────────┘
│
▼ Lua Filter (INSERT_BEFORE)
┌──────────────────────────────────────┐
│ consumer-header-injector │
│ 1. 从 Authorization 提取 UUID │
│ 2. Lua 表映射 UUID → 用户名 │
│ 3. 注入 x-mse-consumer header │
│ 4. 注入 x-consumer-name header │
└──────────────┬───────────────────────┘
│
▼ 路由匹配 → 转发到上游 LLM
│
▼ LLM 响应返回
│
▼ priority: 900
┌──────────────────────────────────────┐
│ ai-statistics │
│ 1. 解析 LLM 响应中的 usage 字段 │
│ 2. 提取 input/output/total token │
│ 3. 从 x-mse-consumer 读取消费者名 │
│ 4. 写入 FILTER_STATE(ai_log) │
│ 5. 写入 FILTER_STATE(consumer) │
└──────────────┬───────────────────────┘
│
▼ 日志输出
┌──────────────────────────────────────┐
│ Envoy Access Log │
│ JSON 格式,包含所有字段 │
└──────────────────────────────────────┘
六、关键设计决策与踩坑记录
6.1 为什么需要 Lua 中间层?
问题 :key-auth 插件认证后,consumer 信息存储在 Envoy 的 FILTER_STATE 中。ai-statistics 插件支持通过 value_source: request_header 从请求 header 中读取自定义属性,但无法直接读取 FILTER_STATE。
方案:通过 Lua EnvoyFilter 将 UUID 映射为用户名,注入到请求 header 中,作为两个插件之间的桥梁。
替代方案考虑:
- 直接在 ai-statistics 中使用
FILTER_STATE?→ ai-statistics 的value_source仅支持request_header和response_header - 使用 ext-auth 外部认证服务?→ 架构过于复杂,引入额外网络开销
6.2 为什么 Token 数值不在 Promtail 阶段提取为 labels?
Loki 最佳实践 :labels 的基数(cardinality)应该尽可能低。input_token、output_token 等数值每次请求都不同,如果提取为 labels 会导致:
- Loki 索引膨胀
- 查询性能下降
- 内存占用增加
正确做法 :只在 Promtail 阶段提取低基数的分类字段(model、route、consumer、api_key)作为 labels,数值字段在 Grafana 查询时通过 LogQL 动态解析。
6.3 为什么用 start_time 替换日志时间戳?
Promtail 默认使用日志采集时间作为时间戳,但这会引入误差:
- 日志从容器到 Promtail 有网络延迟
- 批量采集可能导致时间偏移
使用请求的 start_time(Envoy 记录的请求开始时间)作为时间戳,确保 Grafana 时间轴与实际请求时间一致。
6.4 双 header 设计的原因
Lua 脚本注入了两个 header,看似冗余,实则各有用途:
| Header | 消费者 | 用途 |
|---|---|---|
x-mse-consumer |
ai-statistics 插件 | 通过 value_source: request_header 读取,写入 FILTER_STATE(wasm.consumer) |
x-consumer-name |
Envoy access log | 通过 %REQ(X-CONSUMER-NAME)% 直接记录到日志 JSON 的 api_key 字段 |
七、部署清单
以下是完整的部署组件清单,供参考:
| 组件 | 说明 |
|---|---|
| key-auth WasmPlugin 配置 | 消费者注册 + 路由权限控制 |
| Lua EnvoyFilter 配置 | UUID → 用户名映射 |
| ai-statistics WasmPlugin 配置 | Token 统计 + consumer 属性采集 |
| Higress mesh 配置 | JSON 格式 access log 定义 |
| Promtail 配置 | 两阶段解析 + labels 提取 |
| 同步脚本 (Shell + Python) | key-auth → Lua 映射表自动同步 |
| Grafana Dashboard JSON | 多维度 Token 展示面板 |
最终效果

八、总结
本文展示了一套完整的 Higress + 可观测性 方案,核心思路是:
- key-auth 负责认证和消费者识别
- Lua EnvoyFilter 作为桥梁,将 UUID 映射为用户名注入 header
- ai-statistics 采集 LLM 响应中的 Token 统计,关联消费者信息写入审计日志
- Envoy access log 以 JSON 格式输出结构化日志
- Promtail + Loki 完成日志采集、解析和索引
- Grafana 通过 LogQL 实现按用户/模型/路由的多维度 Token 用量展示
整套方案无需修改 Higress 核心代码,完全通过插件组合 + 可观测性工具链实现,具有良好的可维护性和扩展性。当需要新增用户时,只需在 Higress Console 中创建消费者并配置路由权限,然后运行同步脚本即可自动更新所有配置。
如果你也在使用 Higress 搭建 AI 网关,希望这篇文章能给你一些参考。欢迎在评论区交流讨论!