《Higress AI 网关全解析》是一个以 Higress 为例介绍 AI 网关功能的系列。第 9 篇不再调用现成插件,而是从零写一个:usage-billing。它只做一件事:把每次 AI 调用的 token 用量按消费者记下来,送进自己的计费系统。公司要把模型成本按月摊到业务线。网关是唯一同时看见三件事的一层:谁调的、调的哪个模型、花了多少 token。上游 provider 的账单只告诉你「这个 key 这个月用了多少」,不告诉你「哪个业务线、哪个消费者用的」。要把成本摊回去,网关必须在每个请求经过时记一笔。
Higress 内置的 ai-statistics 已经把用量写进了 access log、指标和 ai_log。如果只是想看报表,到这一步就够了。但账单要进自己的计费系统——那张表在 PostgreSQL 里,字段、唯一键、对账口径都由财务定——这一步没有内置插件,得自己写。
自定义插件常见的三类场景:
这一篇做的是第一种,但它会顺带把后两种的骨架也搭出来。
形态上,usage-billing 是一段编译成 Wasm 的 Go 代码,挂在 Envoy 的 HTTP filter 链上,跟着每个请求走,出网只能走宿主提供的 host call(HTTP / gRPC / Redis)。
计费插件要读两样东西:身份和用量。它们不在同一个时间点出现。
身份是 key-auth 写下的。认证通过后,它把消费者名写进请求头 X-Mse-Consumer。用量在响应体里:非流式响应在 JSON 顶层,流式响应在最后一个带 usage 的 SSE chunk 里。一个在请求头阶段,一个在响应体阶段。
所以先看链上的顺序。从 config_dump 里取 http_filters,只留和这条链相关的几项:
docker exec higress-gateway-1 curl -s localhost:15000/config_dump \ | jq -r '.. | objects | .http_filters? // empty | .[] | .name' \ | grep -E 'model-router|key-auth|usage-billing' | head -5extensions.istio.io/wasmplugin/higress-system.model-router.internalextensions.istio.io/wasmplugin/higress-system.key-auth.internalextensions.istio.io/wasmplugin/higress-system.usage-billing-0.1.0三个插件都在 AUTHN 阶段,priority 分别是 900 / 310 / 200,数值越大越早。model-router 先选模型,key-auth 再认人,usage-billing 最后读。计费插件必须落在 key-auth 后面,因为它读的是 key-auth 刚写下的头。
2.2 wasm-go 的编程模型:一个入口,几组回调wasm-go 的插件入口只有一句 SetCtx,把「插件名 + 配置解析 + 回调」一次注册进去:
func init() { wrapper.SetCtx( "usage-billing", wrapper.ParseOverrideConfig(parseConfig, parseRuleConfig), wrapper.ProcessRequestHeaders(onHttpRequestHeaders), wrapper.ProcessResponseHeaders(onHttpResponseHeaders), wrapper.ProcessStreamingResponseBody(onStreamingResponseBody), wrapper.ProcessResponseBody(onResponseBody), )}五个注册项对应五件事:请求头、响应头、流式响应体、缓冲响应体和配置解析。每个请求有一个独立的 HttpContext,回调之间靠 ctx.SetContext 传递状态:消费者、请求 ID、已经攒下的 SSE 半截事件,都放在这里。
出网用的是 cluster client:
client := wrapper.NewClusterClient(wrapper.FQDNCluster{ FQDN: "billing.dns", Port: 18090,})client.Post("/api/usage", headers, body, callback, 500)FQDNCluster 拼出来的集群名是 outbound|18090||billing.dns,和 McpBridge 里注册的 DNS 服务一一对应。Post 是异步的:调用发出后立即返回,主链路不等 ingest 的回包。计费是旁路,不能因为计费系统慢而让用户多等。
这是整篇最容易被低估的一段。
wasm-go 的 wrapper 对响应体有两种注册方式,行为完全不同:
•注册 ProcessStreamingResponseBody:所有响应体都逐块交给回调,SSE 和非流式 JSON 一样。你拿到的是 chunk,不是完整事件。•只注册 ProcessResponseBody:Envoy 会一直攒到 endOfStream 才调用,回调里拿到整段 body;代价是客户端在这段时间里收不到任何 body。注册了流式回调之后,缓冲回调并不会自动生效。想临时切到缓冲路径,要显式调用 ctx.BufferResponseBody()。
SSE 的边界问题来自这里:chunk 是传输层切的,事件是应用层切的,两者没有关系。 一个 data: {...}\n\n 可能被拆进两个 chunk;一个 chunk 里也可能有好几个事件。最终 usage 是一个独立的 chunk,形如:
data: {"id":"chatcmpl-p10-...","object":"chat.completion.chunk","created":...,"model":"mock-chat","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}choices 是空数组:它不是一次「回答」,只是一条用量回执。后面通常还有一条 data: [DONE]。
所以插件必须自己留 carry-over buffer:把本次 chunk 追加到上次没处理完的尾巴上,按 \n\n(或 \r\n\r\n)切出完整事件,只解析以 data: 开头的行,[DONE] 跳过。
func collect(ctx wrapper.HttpContext, chunk []byte) { carry, _ := ctx.GetContext(ctxCarry).([]byte) carry = append(carry, chunk...) for { end, sepLen := nextEventEnd(carry) if end < 0 { break } handleEvent(ctx, carry[:end]) carry = carry[end+sepLen:] } if len(carry) > 0 && carry[0] == '{' { var envelope usageEnvelope if err := json.Unmarshal(carry, &envelope); err == nil { applyEnvelope(ctx, envelope) carry = nil } } ctx.SetContext(ctxCarry, carry)}OpenAI 兼容的响应里,用量字段是固定的三个:
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18}非流式在顶层,流式在最后一个带 usage 的 chunk 里。流式请求要拿到这个 chunk,通常需要请求里带 stream_options.include_usage: true;ai-proxy 对这类请求会处理这个参数,但不同版本、不同 provider 的细节并不完全一致。接真上游之前,先用一条 stream: true 的请求确认最后一个 chunk 里到底有没有 usage,不要假设。
proxy-wasm 的 host call 提供 HTTP、gRPC、Redis、共享数据这类能力,没有 Postgres/MySQL 的 wire protocol,无法直接对接数据库。
标准形态是两段:
usage-billing (wasm) --HTTP POST--> ingest (普通服务) --SQL--> PostgreSQLingest 是一个单文件 Go 服务:net/http + database/sql + lib/pq,没有框架、没有队列、没有状态。它只做四件事:校验 request_id、INSERT ... ON CONFLICT (request_id) DO NOTHING、返回 202、打一行日志。
res, err := db.Exec(` insert into ai_usage (request_id, consumer, model, prompt_tokens, completion_tokens, total_tokens, stream, usage_reported) values ($1,$2,$3,$4,$5,$6,$7,$8) on conflict (request_id) do nothing`, u.RequestID, u.Consumer, u.Model, u.PromptTokens, u.CompletionTokens, u.TotalTokens, u.Stream, u.UsageReported)这条路径有三个好处:
1.数据库凭据不进网关。 网关只认识 billing.dns:18090,不知道数据库在哪、用什么口令。ingest 可以走 Unix socket 连本地 PostgreSQL,正文和截图里不会出现任何连接串。2.写入逻辑可以独立演进。 加字段、换库、补对账,不用重新编译 wasm、不用重启网关。3.失败面被隔离。 ingest 挂了,插件最多丢一笔账,客户端照常拿到完整回答。幂等键用 Envoy 的 x-request-id。它是每个请求唯一的,ingest 用 on conflict do nothing 兜住重复投递,重放同一条记录不会多出一行:
# 第一次{"inserted":true,"ok":true}# 原样重放{"inserted":false,"ok":true}整个过程如下:
Step 0 · 装置:库、ingest、mock、服务与路由先建库和表。不碰现有数据库,单独建 ai_billing:
create table ai_usage ( request_id text primary key, consumer text not null default '', model text not null default '', prompt_tokens integer not null default 0, completion_tokens integer not null default 0, total_tokens integer not null default 0, stream boolean not null default false, usage_reported boolean not null default false, created_at timestamptz not null default now());request_id 是主键,幂等靠它。usage_reported 区分「0 token」和「没上报」。stream 留着做口径分析:流式和非流式的延迟、成本、失败模式都不一样。
ingest 监听 0.0.0.0:18090,通过 Unix socket 连本地 PostgreSQL,默认 DSN 是 host=/tmp dbname=ai_billing sslmode=disable。起好之后先自检:
curl -s http://127.0.0.1:18090/healthz# ok建一个独立模块,依赖只有三个:proxy-wasm-go-sdk、wasm-go、gjson。
module p10-usage-billinggo 1.24.1require ( github.com/higress-group/proxy-wasm-go-sdk v0.0.0-20250822030947-8345453fddd0 github.com/higress-group/wasm-go v1.0.2-0.20250821081215-b573359becf8 github.com/tidwall/gjson v1.18.0)配置解析沿用 2.2 的 ParseOverrideConfig 注册方式,结构体不再展开。请求头回调只做三件事:读请求 ID、读消费者、按需切换缓冲模式。
func onHttpRequestHeaders(ctx wrapper.HttpContext, config BillingConfig) types.Action { requestID, _ := proxywasm.GetHttpRequestHeader("x-request-id") if requestID == "" { requestID = fmt.Sprintf("p10-%d", time.Now().UnixNano()) } ctx.SetContext(ctxRequestID, requestID) if config.ConsumerPhase == "request" { ctx.SetContext(ctxConsumer, readConsumer()) } if config.BufferMode { ctx.BufferResponseBody() } return types.ActionContinue}请求 ID 用 Envoy 生成的 x-request-id;只有拿不到时才自己造一个。消费者读不到就记 anonymous——不要记空字符串,空字符串在 SQL 里既不是「未知」也不是「匿名」,对账时最难查。
Step 2 · 编译与发布:wasm 的缓存比你想的顽固编译命令:
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .产物约 6 MB。发布就是把文件放进 plugin-server 的版本目录:
docker exec higress-plugin-server-1 mkdir -p /usr/share/nginx/html/plugins/usage-billing/0.1.0docker cp plugin.wasm higress-plugin-server-1:/usr/share/nginx/html/plugins/usage-billing/0.1.0/plugin.wasm校验两件事:URL 能取到、字节一致。
docker exec higress-gateway-1 curl -s -o /dev/null \ -w 'http=%{http_code} bytes=%{size_download}\n' \ http://plugin-server:8080/plugins/usage-billing/0.1.0/plugin.wasm# http=200 bytes=6294200shasum -a 256 plugin.wasm# 1addcc69444ac574b90e226148bbf3e6a6b22bed3b909238dda6b5c9d910417e同一件事有两种挂法:在控制台页面上添加,或者直接写 WasmPlugin CR。先走页面,适合第一次操作;CR 那份留给脚本化。
「插件配置」页左上角有「添加插件」:
七个字段怎么填、缺省是什么、写错会怎样:
| 字段 | 填什么 / 缺省 | 写错的后果 |
|---|
| usage-billing | 页面能建,但日志、指标里的插件名和页面上的名字对不上,排查时要来回换名字 |
| | |
| http://plugin-server:8080/plugins/usage-billing/0.1.0/plugin.wasm | 拉不到 wasm,插件起不来;FAIL_OPEN 下请求照常通过,但一笔账都不记 |
| 认证(AUTHN);页面提示的顺序是 认证>鉴权>统计>默认 | 选后面几个阶段,插件整体后移。身份仍然读得到,因为 key-auth 在认证阶段,但它不再「紧跟在 key-auth 后面」 |
| | 大于 310 会插到 key-auth 前面,请求头阶段读到的消费者是空的,账单里全是 anonymous——Step 5 会把这个现象复现一遍 |
| 本地插件服务器选 IfNotPresent;同名同版本覆盖发布时选 Always | 覆盖了 plugin.wasm 却仍用 IfNotPresent,网关继续跑旧版本,表现为「改了没生效」 |
| | |
保存后,插件出现在「插件配置 → 自定义」。点卡片上的「配置」,打开开关,在 YAML 视图里填上后面那六个键:
保存后,卡片状态变为「已启用」:
WasmPlugin CR 如下(省略了无关字段):
apiVersion: extensions.higress.io/v1alpha1kind: WasmPluginmetadata: name: usage-billing-0.1.0 namespace: higress-systemspec: url: http://plugin-server:8080/plugins/usage-billing/0.1.0/plugin.wasm phase: AUTHN priority: 200 failStrategy: FAIL_OPEN defaultConfig: ingest_cluster: billing.dns ingest_port: 18090 ingest_path: /api/usage timeout_ms: 500 buffer_mode: false consumer_phase: request matchRules: - config: {} ingress: - p10-chat手写 CR 时,控制台不会自动收录。它按 higress.io/resource-definer=higress 过滤 CR,显示名和分类再从这批标签、注解里取:
metadata: labels: higress.io/resource-definer: higress higress.io/wasm-plugin-name: usage-billing higress.io/wasm-plugin-version: 0.1.0 higress.io/wasm-plugin-category: custom higress.io/wasm-plugin-built-in: "false" annotations: higress.io/wasm-plugin-title: Usage Billing higress.io/wasm-plugin-description: Count AI token usage per consumer and post it to an ingest service for billing.只让数据面生效,这几行可以不写;想让页面里也看得见,就得补上。
| 键 | 作用 | 缺省 | 写错的后果 |
|---|
ingest_cluster | ingest 服务的集群名,和 McpBridge 里的 DNS 注册对应 | billing.dns | 写错就找不到集群,请求结束时 host call 失败,这笔账丢 |
ingest_port | | 18090 | 与 McpBridge 注册的端口不一致,连接被拒 |
ingest_path | | /api/usage | |
timeout_ms | | 500 | 调小,ingest 稍慢就丢;调大,调用虽然是异步的、不阻塞主链路,但连接会一直占着 |
buffer_mode | | false | 设成 true 会走 ctx.BufferResponseBody(),客户端在整段回答生成完之前收不到任何字节。除非确实要完整 body,否则别开 |
consumer_phase | | request | 写 response,计费时点推到响应阶段(Step 5 的 pos-400-response 就是这种读法);写其他值,配置解析直接报错,插件不加载 |
发一条普通请求,看 ingest 的日志:
curl -s -X POST http://127.0.0.1/p10/chat \ -H 'Authorization: Bearer sk-higress-local-user1' \ -H 'Content-Type: application/json' \ -d '{"model":"mock-chat","messages":[{"role":"user","content":"billing probe"}]}'rid=2f0821bf-e9fb-42fa-bfa6-33c1af3a6761 consumer="user1" model="mock-chat" prompt=15 completion=8 stream=false reported=true inserted=1数据库里多了一行。到这一步,「看见 → 送出 → 落库」这条链就通了。
发流式请求:
curl -sN -X POST http://127.0.0.1/p10/chat \ -H 'Authorization: Bearer sk-higress-local-user1' \ -H 'Content-Type: application/json' \ -d '{"model":"mock-chat","stream":true,"messages":[{"role":"user","content":"split usage"}]}'账本里新增一行:
select consumer, total_tokens, stream, usage_reportedfrom ai_usage order by created_at desc limit 1; consumer | total_tokens | stream | usage_reported----------+--------------+--------+---------------- user1 | 19 | t | t再发一条非流式请求,也能拿到 usage。
Step 5 · 位置对照:把同一个 wasm 挪到 key-auth 前面只改 CR 的 priority,从 200 改成 400,其余不动。请求带同一个 key,三次结果如下:
select model, consumer, total_tokens from ai_usage where model like 'pos-%' order by created_at; model | consumer | total_tokens------------------+-----------+-------------- pos-400-request | anonymous | 19 pos-400-response | user1 | 19 pos-200-request | user1 | 19第一次是 400 + 请求头读,匿名;第二次是 400 + 响应头读,补回身份;第三次是 200 + 请求头读,正常。三行都是 200 成功请求,差别只在「谁在读、什么时候读」。
这一步的结论不是「400 一定错」,而是:计费插件必须明确自己记录的是哪个时间点的身份。 记录「请求进来时是谁」,就把插件放在 key-auth 之后,在请求头阶段读;记录「请求结束时被谁认领」,可以推迟到响应阶段,但要接受计费时点后移、以及响应阶段拿不到头的风险。
不带 key 的 401 请求也在 AUTHN/200 位置测了:三次 401 前后,ai_usage 行数不变。鉴权失败的请求不该进账单。
另外提一嘴,mcp调用不在统计范围内。
自定义插件不是微服务,是链上的一格加一组回调。 位置决定你在请求头那一刻读得到谁,回调决定你为这条流付多少延迟。