夜雨聆风学习资料网

ARTICLE · 1245525

Higress AI 网关系列 9|自定义 AI 插件:在大模型调用链路插入自定义处理逻辑

Higress AI 网关系列 9|自定义 AI 插件:在大模型调用链路插入自定义处理逻辑
《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)。

二、它是怎么工作的
2.1 一条链上的两个时间点

计费插件要读两样东西:身份和用量。它们不在同一个时间点出现。

身份是 key-auth 写下的。认证通过后,它把消费者名写进请求头 X-Mse-Consumer。用量在响应体里:非流式响应在 JSON 顶层,流式响应在最后一个带 usage 的 SSE chunk 里。一个在请求头阶段,一个在响应体阶段。

所以先看链上的顺序。从 config_dump 里取 http_filters,只留和这条链相关的几项:

bash
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 -5
text
extensions.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,把「插件名 + 配置解析 + 回调」一次注册进去:

go
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:

go
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 的回包。计费是旁路,不能因为计费系统慢而让用户多等。

2.3 响应体是一条流,不是一块 Buffer

这是整篇最容易被低估的一段。

wasm-go 的 wrapper 对响应体有两种注册方式,行为完全不同:

•注册 ProcessStreamingResponseBody:所有响应体都逐块交给回调,SSE 和非流式 JSON 一样。你拿到的是 chunk,不是完整事件。
•只注册 ProcessResponseBody:Envoy 会一直攒到 endOfStream 才调用,回调里拿到整段 body;代价是客户端在这段时间里收不到任何 body。

注册了流式回调之后,缓冲回调并不会自动生效。想临时切到缓冲路径,要显式调用 ctx.BufferResponseBody()。

SSE 的边界问题来自这里:chunk 是传输层切的,事件是应用层切的,两者没有关系。 一个 data: {...}\n\n 可能被拆进两个 chunk;一个 chunk 里也可能有好几个事件。最终 usage 是一个独立的 chunk,形如:

text
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] 跳过。

go
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)}
2.4 用量从哪来

OpenAI 兼容的响应里,用量字段是固定的三个:

json
"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,不要假设。

2.5 从插件到数据库:为什么中间要有一个服务

proxy-wasm 的 host call 提供 HTTP、gRPC、Redis、共享数据这类能力,没有 Postgres/MySQL 的 wire protocol,无法直接对接数据库。

标准形态是两段:

text
usage-billing (wasm) --HTTP POST--> ingest (普通服务) --SQL--> PostgreSQL

ingest 是一个单文件 Go 服务:net/http + database/sql + lib/pq,没有框架、没有队列、没有状态。它只做四件事:校验 request_id、INSERT ... ON CONFLICT (request_id) DO NOTHING、返回 202、打一行日志。

go
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 兜住重复投递,重放同一条记录不会多出一行:

bash
# 第一次{"inserted":true,"ok":true}# 原样重放{"inserted":false,"ok":true}

整个过程如下:

三、分步骤实操
Step 0 · 装置:库、ingest、mock、服务与路由

先建库和表。不碰现有数据库,单独建 ai_billing:

sql
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。起好之后先自检:

bash
curl -s http://127.0.0.1:18090/healthz# ok
Step 1 · 插件骨架:配置、回调、状态

建一个独立模块,依赖只有三个:proxy-wasm-go-sdk、wasm-go、gjson。

go
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、读消费者、按需切换缓冲模式。

go
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 的缓存比你想的顽固

编译命令:

bash
GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm .

产物约 6 MB。发布就是把文件放进 plugin-server 的版本目录:

bash
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 能取到、字节一致。

bash
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
Step 3 · 挂上链:AUTHN/200

同一件事有两种挂法:在控制台页面上添加,或者直接写 WasmPlugin CR。先走页面,适合第一次操作;CR 那份留给脚本化。

3.1 在页面上添加

「插件配置」页左上角有「添加插件」:

七个字段怎么填、缺省是什么、写错会怎样:

字段填什么 / 缺省写错的后果
插件名称
usage-billing
,与代码里 SetCtx 的注册名一致
页面能建,但日志、指标里的插件名和页面上的名字对不上,排查时要来回换名字
插件描述
可空
只影响卡片显示
镜像地址
http://plugin-server:8080/plugins/usage-billing/0.1.0/plugin.wasm
,必须是网关能访问到的完整 URL
拉不到 wasm,插件起不来;FAIL_OPEN 下请求照常通过,但一笔账都不记
执行阶段
认证(AUTHN);页面提示的顺序是 认证>鉴权>统计>默认
选后面几个阶段,插件整体后移。身份仍然读得到,因为 key-auth 在认证阶段,但它不再「紧跟在 key-auth 后面」
执行优先级
200,范围 1~1000,越大越早
大于 310 会插到 key-auth 前面,请求头阶段读到的消费者是空的,账单里全是 anonymous——Step 5 会把这个现象复现一遍
插件拉取策略
本地插件服务器选 IfNotPresent;同名同版本覆盖发布时选 Always
覆盖了 plugin.wasm 却仍用 IfNotPresent,网关继续跑旧版本,表现为「改了没生效」
插件拉取密钥
公共镜像留空
私有仓库不填拉不到;公共地址乱填也会让拉取失败

保存后,插件出现在「插件配置 → 自定义」。点卡片上的「配置」,打开开关,在 YAML 视图里填上后面那六个键:

保存后,卡片状态变为「已启用」:

3.2 等价的 WasmPlugin CR

WasmPlugin CR 如下(省略了无关字段):

yaml
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,显示名和分类再从这批标签、注解里取:

yaml
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.

只让数据面生效,这几行可以不写;想让页面里也看得见,就得补上。

3.3 六个配置键
键作用缺省写错的后果
ingest_cluster
ingest 服务的集群名,和 McpBridge 里的 DNS 注册对应
billing.dns
写错就找不到集群,请求结束时 host call 失败,这笔账丢
ingest_port
集群端口
18090
与 McpBridge 注册的端口不一致,连接被拒
ingest_path
POST 路径
/api/usage
ingest 只收这个路径,404 之后账丢
timeout_ms
host call 超时,单位毫秒
500
调小,ingest 稍慢就丢;调大,调用虽然是异步的、不阻塞主链路,但连接会一直占着
buffer_mode
是否把响应体切到缓冲模式
false
设成 true 会走 ctx.BufferResponseBody(),客户端在整段回答生成完之前收不到任何字节。除非确实要完整 body,否则别开
consumer_phase
在请求头还是响应头阶段读消费者
request
写 response,计费时点推到响应阶段(Step 5 的 pos-400-response 就是这种读法);写其他值,配置解析直接报错,插件不加载

发一条普通请求,看 ingest 的日志:

bash
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"}]}'
text
rid=2f0821bf-e9fb-42fa-bfa6-33c1af3a6761 consumer="user1" model="mock-chat" prompt=15 completion=8 stream=false reported=true inserted=1

数据库里多了一行。到这一步,「看见 → 送出 → 落库」这条链就通了。

Step 4 · 解析流式用量,并把落库做幂等

发流式请求:

bash
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"}]}'

账本里新增一行:

sql
select consumer, total_tokens, stream, usage_reportedfrom ai_usage order by created_at desc limit 1;
text
 consumer | total_tokens | stream | usage_reported----------+--------------+--------+---------------- user1    |           19 | t      | t

再发一条非流式请求,也能拿到 usage。

Step 5 · 位置对照:把同一个 wasm 挪到 key-auth 前面

只改 CR 的 priority,从 200 改成 400,其余不动。请求带同一个 key,三次结果如下:

sql
select model, consumer, total_tokens from ai_usage where model like 'pos-%' order by created_at;
text
      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调用不在统计范围内。

四、小结

自定义插件不是微服务,是链上的一格加一组回调。 位置决定你在请求头那一刻读得到谁,回调决定你为这条流付多少延迟。

相关学习资料