Higress AI统计插件配置指南:gjson表达式从入门到实战 做AI网关的同学应该都有过这种经历模型路由、限流、计量计费这些事只要业务一上量光靠翻日志根本盯不过来。我当时落地higress作为AI网关方案时第一件想搞清楚的事情就是每个模型的调用量、token消耗、错误率到底是多少。higress自带的AI统计插件恰好就是干这个的但配置过程中有个绕不过去的门槛——插件规则里要用gjson表达式来定位JSON里面的字段。不熟悉这套语法的人第一眼就会被“到底怎么写”卡住。这篇文章就是我在配置higress AI统计插件过程中对gjson表达式从一头雾水到能熟练编写的一份完整梳理。我会说清楚AI统计插件为什么需要gjson、gjson的核心语法有哪些、在插件实际配置里怎么用、以及我踩过的那些坑。不管你是刚开始接触网关配置还是已经用过但总在表达式上报错都能从里面找到能直接抄走的解法。1. higress AI统计插件为什么需要gjson表达式1.1 AI统计插件到底在统计什么higress是云原生网关AI场景下最常用的能力就是把请求转发给上游的大模型服务比如OpenAI兼容格式的接口。这种接口的请求体和响应体里会携带很多有价值的信息模型名称、usage里的token统计、生成内容的finish_reason、响应状态码、错误信息等等。AI统计插件做的事情就是把这些藏在JSON body里的字段提取出来整理成监控指标。举个例子一次正常的模型响应体长这样{ model: gpt-4, choices: [ { message: {content: 你好}, finish_reason: stop } ], usage: { prompt_tokens: 120, completion_tokens: 80, total_tokens: 200 } }如果我想知道这次请求总共消耗了多少token就得从响应体里把usage.total_tokens这个字段取出来。放在AI统计插件里这个“取出来”的动作靠的就是gjson表达式。我最初接触这个插件时心里想的是不就是取个字段吗用正则写不就行了但真正配置起来才发现插件设计得比我想象的更讲究。它让你写的是一个高层的路径描述而不是对整段文本做匹配。这个设计思路的不同直接决定了为什么我们要学gjson。1.2 配置核心从请求或响应体中提取字段AI统计插件的规则配置核心逻辑可以拆成三部分数据从哪里来、要取哪个字段、提取后怎么用。数据来源一般在source里指定常见的是response_body和request_body。要取哪个字段就是在expr或类似的字段里写gjson表达式。比如我想统计total_tokens最自然的写法就是usage.total_tokens这个表达式很好理解点号左边是JSON里的键名点号右边是更深一层的键名。gjson会从根节点开始一级一级往下找。如果你要统计的是第一个生成结果的内容那表达式要稍微绕一下choices.0.message.content这里可以看到gjson表达式的两个特点数组用数字下标访问不需要中括号嵌套路径用点号连接清晰且短。这样的表达方式在配置一些监控规则时非常直观比写正则要省心太多。1.3 为什么是gjson而不是正则、JSONPath这个问题我一开始也想不通。后来仔细对比了一下发现gjson在几个核心维度上确实更贴合网关这种场景。先对比正则。正则匹配JSON的痛点是结构不固定、转义多。一个usage:{total_tokens:200}可能有各种换行和空格你要写准了很费劲。而且就算你匹配到了数字还得再处理一下才能变成指标。gjson是基于JSON结构来定位的换行空格对它来说没有影响字段路径写对了就一定找得到。再对比JSONPath。JSONPath功能更强比如支持$.store.book[0].title这种完整写法但代价就是语法相对复杂。功能强归强在网关插件这种配置型场景里绝大多数用户只想知道“怎么简洁地取到某个字段”gjson这种精简路径语法反而更友好。还有很重要的一点higress本身是Go语言实现的而gjson就是Go生态里一个成熟的高性能JSON解析库。一个Go写的网关网关内部插件用同样Go生态的表达式库这本身就是最合理的技术选型。用过gjson库的应该都知道它在解析大JSON时性能表现相当好这点对于网关这种高并发场景尤其重要。如果用一个生活化的类比JSON就是一棵按目录存放文件的树gjson表达式就是“文件夹/子文件夹/文件名”这种路径写法。知道了所有文件的存放位置你要取哪个东西都只是写路径的问题。2. gjson表达式基础语法拆解2.1 点路径与数组索引先看最基本的点路径。对于JSON结构{ name: { first: Tom, last: Jerry }, age: 30, tags: [devops, gateway], address: { city: Hangzhou, code: 310000 } }name.first返回Tomage返回30address.city返回Hangzhou这些路径本质上就是一层层向下访问键名。需要注意的是gjson对JSON的键名严格区分大小写。写错了大小写表达式返回的就是空结果并且不会报错。这一点后面我会专门讲因为太容易踩了。数组的访问用数字下标。比如tags.0返回devopstags.1返回gateway。这里有个和很多语言习惯不一致的地方gjson不需要也不支持tags[0]这样的中括号写法。我第一次用的时候习惯性地写tags[0]结果什么都没有查了一会儿才意识到要写tags.0。如果你想要数组的长度可以用#号。tags.#返回2。这在统计一批结果的数量时很有用。还有一种特殊场景键名本身包含点号。比如JSON里有个键叫a.b那写a.b会被解析成先找a再找b自然就找不到了。我的建议是尽量避免设计这种键名如果上游接口固定返回这种结构那可以考虑用通配符或者调整提取思路。gjson在这块处理起来确实别扭不要硬刚。2.2 通配符与条件查询通配符*可以匹配任意字段名。比如{ data: { item1: {value: 10}, item2: {value: 20}, item3: {value: 30} } }data.*返回item1、item2、item3三个对象的数组data.*.value返回[10, 20, 30]这在AI网关场景里很实用。比如模型响应里可能有多个choices你想把每个choice的finish_reason都取出来就可以写choices.*.finish_reason返回的就是类似[stop, length]这样的数组。条件查询是gjson里比较有特色的能力语法是#(...)。比如{ items: [ {name: a, age: 25}, {name: b, age: 35}, {name: c, age: 30} ] }items.#(age30)#返回年龄大于30的元素个数结果是1items.#(age30).name返回年龄大于30的元素的name字段结果是[b]items.#(nameb)#返回name等于b的元素个数结果是1注意字符串比较要用双引号把值包起来数字比较直接写数字布尔值写true或者false。这个细节在调试插件时经常遇到少写了双引号表达式就不会生效。#(...)放在末尾后面再跟一个#是gjson里的一个固定写法表示“统计符合条件的数量”。第一次看到items.#(age30)#这个表达式里面出现两个#很容易懵。简单理解就是第一个#进入条件查询模式最后的#表示取数量结果。2.3 修饰符与多路径提取gjson的修饰符是通过符号调用的函数式能力。常用的几个this返回当前匹配到的值本身reverse把数组倒序join把字符串数组用分隔符拼起来flatten把多维数组扁平化slice对数组做切片举个例子items.*.name返回所有name字段后如果想倒序排列可以写items.*.name|reverse这里|是管道符号表示把前一个表达式的结果传给修饰符处理。join也很有用如果想把多个name拼成一个带逗号的字符串可以写items.*.name|join:,在插件配置里修饰符的使用频率其实不算特别高但遇到“需要将多个值合并成一个标签”的场景时会很方便。多路径提取的语法是用花括号{}包住多个查询路径。比如{name.first, name.last}返回的结果是一个JSON对象{ name.first: Tom, name.last: Jerry }这在插件配置里可以用于同时提取多个字段一次查询拿到多个值减少对body的重复解析。2.4 聚合统计让表达式不止于取值gjson表达式不仅能取值还能做简单的聚合。除了前面讲到的#统计数量还有一个常见的场景是统计数组长度。考虑这样一个响应体表示一次流式输出过程中的多段结果{ stream_choices: [ {index: 0, text: 你好}, {index: 1, text: 世界}, {index: 2, text: !} ] }stream_choices.#返回3也就是输出段数。结合AI统计插件你可以把这个值作为一次会话的“流式返回块数”来监控。再想想一个更常见的用法我需要知道一次请求返回的choices里有多少个是被截断的finish_reason为length这直接反映了生成内容是否超长被截。表达式写出来是choices.#(finish_reasonlength)#这在调试大模型输出的“戛然而止”问题时给了我很大的帮助。之前我只能靠日志一条条翻有了这种条件聚合表达式直接就能让监控面板告诉我截断发生的次数和频率。聚合统计这部分让我对gjson的理解从“取字段”上升到“表达意图”。表达式写得好一个路径就能顶一段日志分析的工作量。3. 在AI统计插件里写gjson的实战3.1 一个完整的配置示例这里给出一份基于我实际使用结构的配置示例。不同的higress版本插件的字段名可能略有差异但核心思路是一样的。metrics: - name: llm_total_tokens source: response_body expr: usage.total_tokens - name: llm_prompt_tokens source: response_body expr: usage.prompt_tokens - name: llm_completion_tokens source: response_body expr: usage.completion_tokens - name: llm_finish_reason_stop source: response_body expr: choices.#(finish_reasonstop)#这个配置的意图很明确从每个模型的响应体里提取token数据和finish_reason情况全部作为指标输出。配置完并接入监控系统后我在Grafana里添加一个面板直接对llm_total_tokens做sum聚合就能看到一段时间内所有模型调用加起来消耗的总token数。这里有一个重要的实操心得AI统计插件只负责“提取和暴露指标”如果要做累计、平均、分位数这些计算建议放到Prometheus查询语句里做不要在插件里做复杂处理。插件侧尽量保持简单、明确每个指标只做一件事。3.2 统计token消耗从单次到全局token消耗是AI网关最关心的指标之一因为它直接和成本挂钩。很多模型服务的计费方式非常直接你的账单就是按prompt_tokens和completion_tokens分别计费的。假设你接的模型返回结构是{ model: qwen-max, usage: { prompt_tokens: 112, completion_tokens: 233, total_tokens: 345 } }在AI统计插件里提取total_tokens的表达式只有一行usage.total_tokens但如果你给两个不同模型做对比测试希望分别看到它们的token消耗传统方法是每部署一个插件实例就配一个固定的表达式。但更好的做法是把model字段提取出来作为一个标签在监控系统里按模型维度分组。AI统计插件通常会支持自定义标签字段这样你可以把响应体里的model提取出来指标就自动带上了模型名称这个维度。我实际配置时是这么干的metrics: - name: llm_requests_total source: response_body expr: model labels: - model这样在监控面板上每一种模型的调用量、token消耗量就可以清清楚楚分开看。想象一下线上接入三家模型供应商每个模型的成本都不一样没有这种按模型维度的指标月底算账就是一场灾难。还有一个小技巧因为model字段在请求体和响应体里都有如果你想统计的是“用户请求了哪个模型”这个从请求体取更准确metrics: - name: llm_calls_requests source: request_body expr: model如果是从响应体取那你统计的是“实际被调用的模型”能发现网关是否做了模型路由兜底。我后来就靠这个区分了“用户请求的模型”和“实际处理的模型”在排查路由配置问题时非常有用。3.3 按模型维度聚合与错误统计前面讲了怎么按model字段分组。其实错误统计的思路完全一样。当上游模型返回错误时标准的响应体结构通常是{ error: { message: Rate limit reached, type: rate_limit, code: 429 } }要统计错误类型表达式就是error.type要拿到具体的错误信息写error.message这里有个容易忽略的点gjson的message本身是一种查询语法吗其实不是它就是一个普通字段名不会跟gjson内部的功能冲突。所以放心用。我建议在配置里单独建一组错误指标把错误类型作为标签提取出来metrics: - name: llm_error_total source: response_body expr: error.type labels: - type配置完成后我在监控面板上就能直接看到“哪些错误类型占了大多数”比如是不是rate_limit暴增是不是context_length_exceeded频繁出现。这种可视化的排查方式比我之前靠日志grep错误码的效率高出几个量级。还有一种情况是上游网关返回的错误没有结构化只是纯文本。这种情况下gjson表达式提取不到字段指标会为空。我的建议是设置一个默认的兜底指标比如请求总数再结合非空的错误指标做比率计算这样就算拿不到错误细分至少还能知道错误率的整体变化趋势。3.4 更多场景响应延迟、内容长度、缓存命中除了token和错误AI网关还有很多值得统计的指标。响应延迟虽然可以直接在网关层拿到耗时数据但有些模型服务会在响应体里返回更细粒度的时间信息比如总共花了多少秒。假设响应体里有这样的字段{ timings: { total_ms: 890, prompt_ms: 120, completion_ms: 770 } }你可以在插件里提取timings.total_ms再换算成秒或者直接作为耗时指标。这个比网关层的连接耗时更贴近“模型真实处理时间”因为网关耗时还包括了网络传输和排队。还有一个有意思的指标是生成内容长度。比如你关心一次请求的输出有多少字但响应体里没有直接给出长度字段。这时候常见的做法是统计choices里content的长度但这需要更复杂的计算能力。gjson本身不支持函数计算字符串长度所以我一般用请求和响应的字节数作为近似。有些模型供应商会在usage里额外返回completion_tokens这个数字其实就约等于输出长度所以直接用usage.completion_tokens就够了。对于大模型的流式响应还有一种情况是每一段chunk里都有usage信息。这时候可以用通配符把每一段的token数都取出来虽然不能直接加总但可以在监控系统里对指标做sum。比如chunks.*.usage.total_tokens这个表达式返回的是所有chunk的total_tokens数组在Prometheus里对每个实例做sum就可以得到一次完整流式响应累计消耗的token总量。我在配置这类指标时踩过坑因为没有意识到返回的是一个数组导致监控面板上显示的数据很奇怪。后来才明白这种场景下gjson的返回值类型是数组下游监控系统能不能正确处理数组需要在配置时特别确认。4. 调试方法与常见坑4.1 快速验证gjson表达式的三种方式写gjson表达式最忌讳的就是直接改配置、上生产然后盯着监控看有没有数据。正确流程应该是先在本地验证表达式是否符合预期。第一种方式在线调试。gjson官网提供了一个在线调试页面网址是gjson.dev。你可以在左边粘贴一段JSON样例右边写表达式立刻就能看到解析结果。这个页面我几乎每次配置插件都要打开非常稳。第二种方式本地写一段小代码验证。如果你本地有Go环境可以用gjson库写一个十来行的测试脚本。这里给个简单示例package main import ( fmt github.com/tidwall/gjson ) func main() { json : {usage:{total_tokens:200},choices:[{finish_reason:stop}]} result : gjson.Get(json, choices.#(finish_reason\stop\)#) fmt.Println(result.String()) }第三种方式利用higress本地部署的调试能力。higress支持本地运行可以在本地搭建好网关配置后用真实的模型响应体去触发插件看日志里打印的指标。这个方法最接近线上环境适合调通了表达式但还需要验证整体链路的情况。我的经验是先用在线调试把表达式的语法验证明白再放到本地higress里跑一遍真实请求最后才上生产。三步下来踩坑率会低很多。4.2 常见错误写法速查我把实际配置中最容易踩的几个坑整理成了表格方便对照排查错误场景错误写法示例正确写法错误原因数组访问用了中括号choices[0]choices.0gjson不使用中括号语法字符串比较少了双引号#(nameb)##(nameb)#字符串必须用双引号包裹大小写写错Usage.total_tokensusage.total_tokensJSON键名严格区分大小写条件查询末尾少了#items.#(age30)items.#(age30)#统计数量要用双#结尾点路径把数字当键名data.0.name需要确认根结构如果data不是数组会取不到忽略多层嵌套choices.finish_reasonchoices.0.finish_reason数组必须先指定下标或用通配符这里重点说下第一个坑。choices[0]这个错误非常常见因为大多数语言的JSON解析都是用中括号访问数组元素。但gjson走的是点路径风格所以choices.0.message.content才是对的。虽然这个表达习惯刚开始会让人不适但用熟了会发现它写起来更简洁尤其适合在配置类文件里使用。还有一个坑是条件查询里布尔值的写法。比如JSON里的字段值是true表达式要写成#(statustrue)#不要试图用true这种字符串形式。数字比较和字符串比较的规则我在前面已经说过这里再强调一下类型不匹配会导致条件永远为假。4.3 空值、大小写与结构差异处理gjson的一个特性是当路径不存在时它不会抛异常而是返回一个空结果。这对插件来说既是优点也是隐患。优点是即使上游模型返回的JSON结构不完整插件也能正常运行不会因为解析失败导致网关崩溃。隐患是如果你没注意到返回为空监控面板上就会莫名其妙出现“某指标一直为零”的情况。我排查过这类问题。有一次配置了一个统计请求延迟的指标配置完以后发现一直都是0。在线调试工具里手写了一段模拟JSON路径是正确的能正常提取到数据。后来拉了一个真实线上日志才发现那家模型服务在响应体里根本没有timings字段它是放在响应头里返回的。这种情况表达式再怎么写都是白搭因为数据源就不在body里。还有一个小细节有些模型供应商在不同接口版本里返回的JSON结构会略有不同。比如老版本的finish_reason字段可能在choices数组的元素里新版本却变成了choices元素下面的message对象里。如果你的网关同时接了多个版本的接口表达式就必须考虑到这种结构差异。我的应对办法是准备一个兜底表达式在无法提取到值时返回一个默认值让指标至少能被监控到不至于完全空白。gjson本身不提供复杂的分支判断语法所以在插件层面解决结构差异并不容易。我的建议是要么在上游做数据标准化让所有模型服务返回统一结构要么在网关前面的流程里做一次转换要么就分开配置不同的指标分别对应不同版本。4.4 性能与维护建议网关是流量入口每个请求都会经过插件处理。虽然gjson性能在Go库里面算是很优秀的但你也不能随便乱用在高并发场景下还是要注意几个点。首先尽量减小解析范围。如果你的JSON body有几十KB而你只需要提取其中两个字段那表达式路径越短越好。gjson的查询是基于逐层解析的路径越深需要遍历的节点越多。其次避免在一个表达式里做太多聚合运算。比如choices.*.message.content返回全体内容的数组这个数组可能非常大如果只是想知道有多少个choices这个表达式就是浪费。用choices.#就够了。第三如果多个指标要提取同一个祖先路径下的不同子字段尽量合并成多路径提取减少对body的重复解析。比如{usage.prompt_tokens, usage.completion_tokens}这种写法在一次查询里拿到两个值比分别写两条表达式调用两次解析要高效。最后维护角度的一点建议表达式不要写得花里胡哨复杂的条件嵌套、多级修饰符串联容易让后来的人看不懂。我的习惯是每个表达式旁边写一个简短注释说明它在统计什么对应哪个业务指标。这比把表达式写得很炫技但没人敢碰要好得多。一点个人体会gjson这个表达式库单独看语法其实非常小半天就能掌握。难的不是语法本身而是你能否在真实场景里理解“结构的形状”。我在配置higress AI统计插件时最深的感受是每一个gjson表达式背后都是在跟一组真实存在的JSON数据结构对话。你越了解你的AI网关后端返回什么样的body你的表达式就写得越顺手。如果你正准备上手higress的AI统计插件我的建议很简单先找一条你真实环境下的模型响应体把JSON慢慢展开一行一行看清楚然后从最简单的model、usage.total_tokens开始写。写完以后用在线调试工具过一遍确认提取结果符合预期再放进插件配置里。过程中遇到的任何报错先看是不是斜杠、双引号、大小写的问题。等你把第一组指标跑出来看到监控面板上出现那些漂亮的曲线时你就会觉得前面花的这些功夫完全值得。