Higress AI统计插件中gjson表达式实战与踩坑指南 最近在给网关接AI应用监控需要统计每个请求的模型名、Token消耗、响应延迟这些指标。Higress的AI统计插件刚好能干这件事但我在配置时被里面的gjson表达式卡住了。一开始看到response.choices.0.message.usage这种路径还以为是普通JSONPath结果照搬老翻车。后来认真研究了gjson的路径语法才发现里面门道不少和传统JSONPath的区别也很大。这篇文章就把我用Higress AI统计插件时学到的gjson表达式经验整理出来给同样在这上面踩坑的朋友做个参考。1. 为什么AI统计插件会用到gjson表达式1.1 Higress AI统计插件到底是干什么的Higress是一个云原生API网关在AI应用场景下它扮演的是流量入口的角色。所谓AI统计插件本质上就是把经过网关的AI请求和响应数据按照我们自定义的规则提取出关键指标然后上报到监控系统或日志平台。常见的场景包括统计每个请求使用的是哪个模型、消耗了多少输入Token和输出Token、响应耗时是多少、命中了哪个服务等等。没有这个插件的时候想拿到这些数据只能自己写中间件解析请求体费时费力。有了插件之后只需要在配置里写清楚“从JSON的哪个位置取哪个字段”网关就会自动完成提取和上报。这里的“从JSON的哪个位置取哪个字段”就是用gjson表达式来声明的。所以理解gjson基本上就是理解了AI统计插件的核心用法。1.2 为什么不用标准JSONPath而用gjson我用的时候也产生过这个疑问。JSONPath在业界很流行为什么Higress偏偏要选gjson后来翻了代码才明白gjson是Go语言生态里一个非常轻量的JSON解析库性能很高而且路径语法简单直接非常适合在网关这种高并发热路径上做字段提取。gjson的路径语法有几个特点它用点号分隔层级用#表示数组或遍历用|做管道操作整体表达式写出来短小精悍。比如要取响应里第一个choice的text内容gjson写response.choices.0.text而标准JSONPath要写成$.response.choices[0].text。在配置文件中gjson这种写法明显更简洁也更容易嵌入到YAML里。另外gjson在处理异常数据时比较宽容某个字段不存在不会导致整体崩溃而是返回空值或零值这对网关侧的容错处理很友好。1.3 一个最简配置长什么样先别急着学语法看一下AI统计插件里gjson表达式的使用位置。我当时在Higress的插件配置里加了一个类似这样的段落ai-statistics: metrics: - name: model expr: request.model type: string - name: prompt_tokens expr: response.usage.prompt_tokens type: integer这个配置的意思是从网关收到的请求体里取model字段记为指标model从响应体里取usage.prompt_tokens字段记为指标prompt_tokens。expr字段里写的就是gjson表达式。从这里能看出来表达式本身不复杂复杂的是当JSON结构变复杂的时候比如多轮对话、数组嵌套、条件筛选这时候就需要更高级的gjson语法了。2. gjson表达式核心语法速成2.1 基础键路径从request.model这类写法说起gjson最基础的用法就是按点号逐层取字段和访问对象属性一样。比如JSON是{request: {model: gpt-4}}那么request.model就能取出gpt-4。这里有个细节需要注意gjson的根路径默认是你要解析的整个JSON对象本身不需要写$符号。这和标准JSONPath很不一样我刚用的时候总是习惯性写$.request.model结果取不到值后来才反应过来不要加$。另外gjson还支持嵌套对象和普通数组的下标访问。比如response.choices.0.message.content这个表达式可以同时完成“进入choices这个数组取下标为0的元素再取它的message.content字段”的操作。这里下标从0开始如果数组为空表达式不会报错而是返回空字符串。在AI统计插件里基础键路径已经能覆盖八成场景。顺带说一个容易踩的坑gjson的键名如果包含点号或特殊字符普通的点路径会失效。比如有字段叫d.id直接写request.d.id会被拆成request-d-id而不是一个叫d.id的键。这时候需要用转义语法request.d\\.id。不过在实际AI请求里字段名一般都比较规范很少遇到这种情况但知道有转义这回事总不会错。2.2 数组与通配#的多种用法#是gjson里功能最丰富的符号也是初学者最容易搞混的地方。它主要有三种常见用法。第一种是取数组长度。比如响应体里usage.completion_tokens_details是一个数组想知道数组有多少个元素可以写usage.completion_tokens_details.#。这个表达式返回的是数组长度是个数字。在AI统计插件里我常常用这个来统计多轮对话的轮数或者某个辅助数组的长度。第二种是遍历所有元素通常配合管道或修饰符使用。例如items.#.name会返回一个数组里面是所有items元素的name字段拼接成的JSON数组。这在AI场景里不太常用因为统计插件一般只需要聚合值而不是提取一个数组再二次处理。第三种是按下标取元素比如items.0和items.#0在某些场景下是等价的但#后面跟数字需要特别注意写法。我自己用得最多的是choices.0.message而不是choices.#0.message因为点号下标写法更直观。但如果你看到别人写的choices.#0也要懂那是表示“第0个元素”。还有#(key)和#(key#)这种带括号的写法专门用来做过滤和计数。比如统计响应中包含type: function_call的choices数量可以写choices.#(typefunction_call)#。这个语法我们在下一节具体展开。2.3 过滤器与条件#(key:value)如何筛选如果AI返回的choices数组里有多个候选我们可能只关心某个特定类型的元素。gjson支持用#(key:value)这样的过滤器来选中符合条件的元素。比如response里有这样一个JSON{ choices: [ {role: assistant, content: 你好}, {role: tool, content: 调用结果} ] }如果想取第一个role为tool的元素可以写choices.#(roletool).content。这里的#(roletool)相当于筛选出所有role为tool的元素然后取第一个匹配项再取出它的content字段。如果之后还想做更复杂的条件判断gjson也支持和||例如#(roletool content!)。还有一种写法是#(key:value)#末尾多一个#号表示返回匹配元素的个数而不是元素本身。我统计异常响应时经常这么干统计某个字段等于error的次数。表达式写response.choices.#(finish_reasonerror)#得到的就是一个数字可以直接上报给监控看错误率。这个语法最关键的误区是忘了最后的#导致返回了第一个匹配的整个对象字段类型对不上插件上报时就会出问题。2.4 管道与修饰符this、reverse这些怎么玩gjson的管道符是|它的作用是把前一步取到的值作为后一步的输入有点类似Linux里的管道。管道后面跟的通常是一个修饰符gjson内置了一些修饰符常用的有this、reverse、ugly、pretty等。在AI统计插件里this是最值得一提的因为它能把当前值当作完整JSON返回。比如我们要从响应体里取出整个usage对象而不是只取其中一个字段。直接写response.usage当然可以但如果想把它原样放到指标里可能还需要在配置里配合其他处理。在gjson表达式内如果想对数组做倒序可以写response.messages.#|reverse返回反转后的数组。如果想取第一个匹配项的完整对象可以写response.choices.#(finishedtrue)#|this。这里有一个很实用的组合管道可以让表达式变得非常紧凑。举个例子我统计一个请求里messages数组中所有role为user的元素个数可以写request.messages.#(roleuser)#。但如果我要把匹配到的所有user消息的文本内容拼成一个数组就可以写request.messages.#(roleuser).content。注意这里不是管道但效果类似。真正用到管道的是配合this做整体提取比如response.choices.#(index0)#|this这个表达式的意思是在choices里筛出index为0的元素然后返回整个匹配对象。对于AI统计插件有时候我们需要把完整的响应片段保存到日志里方便排错这类表达式就非常实用。3. 在AI统计插件中落地实战3.1 统计请求模型和Token消耗现在回到Higress AI统计插件的实际配置里。最常见的需求就是拿到模型名和Token用量。OpenAI格式的请求和响应体一般长这样请求体{ model: gpt-4, messages: [ {role: user, content: 你好} ] }响应体{ model: gpt-4, choices: [ {message: {content: 你好}, finish_reason: stop} ], usage: { prompt_tokens: 12, completion_tokens: 8, total_tokens: 20 } }如果只关注“模型名”两个地方都有那我们建议优先从请求体取因为响应体里可能只有部分网关配置会透传。我的配置如下ai-statistics: metrics: - name: model expr: request.model type: string - name: prompt_tokens expr: response.usage.prompt_tokens type: integer - name: completion_tokens expr: response.usage.completion_tokens type: integer - name: total_tokens expr: response.usage.total_tokens type: integer这里有个细节type: integer很重要。如果gjson表达式返回的是字符串12而插件期望的是数字后面做量化和告警时会出问题。所以表达式本身要保证能取到数字类型配置里的类型声明也要匹配。3.2 统计上下文长度与多轮对话现实中的AI应用很少有真正的单轮对话多轮请求里messages数组会不断累积。网关如果想把“当前请求携带了多少轮上下文”作为指标上报就可以用gjson的数组长度特性。假设请求体的messages长这样{ model: gpt-4, messages: [ {role: system, content: 你是助手}, {role: user, content: 今天天气}, {role: assistant, content: 请问你在哪个城市}, {role: user, content: 北京} ] }要统计消息总数表达式就是request.messages.#。如果想只统计用户消息数量可以写request.messages.#(roleuser)#这里再强调一下最后这个#是gjson特有的“计数”语法。如果你不写最后的#比如写成request.messages.#(roleuser)返回的会是第一个满足条件的消息对象而不是数量。我在一开始就栽在这个地方统计出来的用户消息数莫名其妙等于一个JSON字符串导致监控面板里全是乱码。后来对照gjson文档才发现条件后面多一个#才是取数量少一个#是取匹配对象完全两码事。如果还需要统计所有用户消息的字符数总和那就不能只靠一个gjson表达式了更多的是靠插件后端聚合。gjson在这里主要解决的是“提取”和“计数”计算逻辑是由统计插件负责的。3.3 用管道表达式处理嵌套的choice数组有的AI服务返回结构比较复杂比如每个choice里除了正文还有工具调用信息而我们需要把工具调用次数单独统计出来。假设响应体如下{ choices: [ { message: { content: 我来查一下天气, tool_calls: [ {function: {name: get_weather, arguments: {\city\:\北京\}}} ] }, finish_reason: tool_calls } ] }这时候要统计响应中有多少个工具调用用gjson可以这样写response.choices.#.message.tool_calls.#this这个表达式不太好直接作为统计指标因为它返回的是一个数组的数组。更常见的做法是统计工具调用发生的次数可以写成response.choices.#.message.tool_calls#注意这里没有点路径拼到最后而是在tool_calls#里结束。tool_calls#表示取tool_calls数组的长度而前面的#.message表示遍历所有choices。所以整体意思是按顺序遍历choices对每个element都取message.tool_calls数组的长度最后返回一个元素间是组合关系的数组。嘿其实这个表达式的结果还是一个数组不是单一数字。在实际插件里我们更希望拿到的是一个总数。如果插件不支持对返回数组做sum通常需要改成用#(finish_reasontool_calls)#这样的写法统计finish_reason等于tool_calls的choices数量也能间接反映工具调用次数。我的建议是不要硬造复杂表达式能用条件过滤就用条件过滤逻辑更清晰。3.4 配置到Higress插件的完整示例与验证过程把上面的例子组合起来一个较完整的AI统计插件配置可以是ai-statistics: metrics: - name: model expr: request.model type: string - name: message_count expr: request.messages.# type: integer - name: user_message_count expr: request.messages.#(role\user\)# type: integer - name: prompt_tokens expr: response.usage.prompt_tokens type: integer - name: completion_tokens expr: response.usage.completion_tokens type: integer - name: tool_call_choices expr: response.choices.#(finish_reason\tool_calls\)# type: integer配置好之后我怎么验证表达式有没有写对呢最简单的方式是找一个真实的请求和响应样例先保存成JSON文件然后用gjson的命令行工具或者在线调试页面跑一遍。比如在本地用Go写个几行的小程序或者直接用一个现成的gjson命令行验证工具。这里我分享一个我常用的快速验证方法用Go语言的gjson包写一小段代码输入JSON和表达式直接打印结果。代码非常简单我贴一下当时用的验证脚本package main import ( fmt github.com/tidwall/gjson ) func main() { json : { choices: [ {finish_reason: tool_calls}, {finish_reason: stop} ] } expr : choices.#(finish_reasontool_calls)# result : gjson.Get(json, expr) fmt.Println(result.Raw) }这段代码输出的是1说明表达式正确。如果表达式写错了输出会是空字符串或者[]。这样验证完再填到Higress配置里基本不会出问题。收尾时删掉临时代码或者本地跑完做个记录效率很高。这种本地验证的方式比我直接在网关配置里改了之后再去打流量测要稳妥得多。4. 踩坑记录与排查方法4.1 gjson表达式报错错在哪、怎么看gjson的一个特点是即使表达式无效它也不会像其他解析器那样抛出一个带行号的异常很多时候只是安静地返回空值。这在网关高并发环境下是好事但对开发者来说很难发现问题。当时我遇到的情况是插件上报的指标一直是空的但请求和响应都正常返回。排查了很久最后发现是表达式的层级写错了。所以如果你发现AI统计插件里某个指标一直为空第一条要查的就是表达式本身。最直接的办法是把你观察到的原始JSON复制出来放到gjson在线工具里输入你的表达式看能不能返回预期值。如果返回结果为空大概率是层级不对。比如有些服务的响应体最外层不是response而是result但我们的表达式写的是response.usage自然取不到值。别着急先确认JSON结构再对着结构写表达式。4.2 数组边界与缺失字段用#计数时的坑用#取数组长度时有一个非常隐蔽的坑如果这个数组字段根本不存在数组.#返回的会是0还是空gjson里数组.#对缺失字段的返回值实际上是空字符串而不是数字0。插件如果把它当成整数来处理就会产生类型转换错误或者指标值变成0但你又看不出来是哪一步的问题。我举个例子request.messages.#如果请求体里没有messages字段gjson会返回一个空字符串。在插件里声明type: integer时有些版本会默认把空字符串视为0有些版本则会直接丢弃这个指标。最好的办法是在上游服务里保证字段一定存在或者在表达式层面做一个兜底。不过gjson本身没有类似||的默认值语法所以更可靠的是让网关在前置环节做数据校验确保关键字段缺失时有所处理。另一个坑是数组过滤条件里的#(key:value)#写法对嵌套层级很敏感。比如我想统计choices里message.role等于tool的数量不能直接写成choices.#(message.roletool)#。咋一看好像很合理但gjson的过滤器默认只会检查当前层级的直接字段message.role这种带点号的路径不一定能被正确解析。我在实测中遇到过有的版本支持有的版本不支持。为了稳妥建议先通过管道把嵌套结构拍平或者在条件里拆解路径。比如先取choices.#.message再基于结果进行二次过滤。不过这会让表达式变得复杂个人建议如果遇到这种情况优先考虑用两三个简单表达式组合而不是硬写一个复杂的。4.3 类型转换与空值为什么统计数字总是0如果你配置的表达式本身没问题但指标值总是0那就得考虑类型转换的问题。最常见的场景是响应体里的Token数字是浮点数或者字符串。比如有些网关会把prompt_tokens写成12带引号gjson取出来是字符串插件如果要求整数转换失败就会变成0。还有一种情况是Token字段本身没有返回比如流式响应里部分数据块的usage字段是空的。此时表达式返回空字符串统计自然就会显示为0。要解决这个问题首先要看网关透传的原始响应体。如果服务端确实返回了字符串数字可以考虑在插件配置里把type设成float或string然后在上游做二次转换。但更推荐的做法是让AI服务规范化尽量输出数字类型。毕竟网关侧做字符串到数字的转换不仅增加开销还容易出现精度问题。我在实际项目里就碰到过这种怪事不同模型提供的响应格式不太一样。OpenAI官方返回的usage是数字但某些代理网关会在转发时把数字变成字符串。同一个统计表达式A模型的指标正常B模型一直是0。排查之后发现根源不在gjson而是在于上游数据结构不统一。所以如果你的网关代理了多个模型供应商一定要仔细看各自返回的JSON结构不要指望一个表达式通吃所有格式。4.4 调试技巧用go小工具本地验证表达式除了上面提到的Go小程序还有一些更轻量的调试方法。如果你电脑上装了Go环境最简单的是先go get github.com/tidwall/gjson然后写一个类似我上面贴过的脚本。其实gjson还提供了一个标准库里的json命令行工具吗并没有但我们可以借助常见的通用脚本完成。比如用Python的json库做对照不过不要在线上环境依赖Python这里只是本地验证。另一个思路是用curl或jq。把实际请求和响应保存成文件比如response.json然后像这样验证jq -r .choices | map(select(.finish_reasontool_calls)) | length response.json这段jq表达式的作用和choices.#(finish_reasontool_calls)#类似。虽然jq和gjson语法并不相同但逻辑上可以做对照。如果jq能算出你期望的结果那就可以反推gjson表达式的逻辑设计没有错剩下的一般只是语法层面的调整。这个技巧对不太熟悉gjson的人来说特别友好相当于用熟悉的工具校验思路。还有一个小技巧在Higress的AI统计插件配置里把表达式写在YAML的注释中然后保留一份说明文档。这样下次改配置时不用再从头猜这个表达式是干嘛的。我的习惯是在配置模板里写清楚每个指标对应的业务含义以及表达式的逻辑思路避免过了一个月回来连自己都看不懂。最后的经验小分享说实话我最初学习gjson是为了应付Higress插件配置结果越用越觉得这套表达式在JSON提取场景里有自己的优势。它足够简洁也足够高效而且不太容易把配置写成冗长的一堆嵌套调用。真正用顺了以后网关上的各种指标提取都变得很简单。另外如果你和我一样是半路出家看到#(keyvalue)#这种语法时别慌把它当成一个整体来记括号里是筛选条件最后那个#是计数。多用几次就会形成肌肉记忆。建议在本地准备一份样例JSON把各种表达式都试一遍看看输入输出到底是什么样的。踩坑不可怕可怕的是不知道去哪里排查。今天分享的这些排查方法都是我实实在在用过的希望能让你的AI统计插件配置之路少走一些弯路。