资讯动态

Higress AI统计插件gjson语法:从JSON路径到token计量实践

发布时间:2026/10/9 7:00:12 来源:尧图企业网站定制
Higress的AI统计插件我第一次打开它的配置界面时盯着dimensions里那一串usage.prompt_tokens、choices.#.message.content看了很久。作为一个平时写Go、对从JSON里取字段这件事还停留在json.Unmarshal加结构体阶段的开发者这些带井号和通配符的路径表达式让我既眼熟又陌生。眼熟是因为它长得像JSONPath陌生是因为它不完全等于JSONPath。为了把一个按模型、按用户统计token消耗的计量需求做好我花了一个晚上把gjson语法系统学了一遍又在Higress的AI统计插件里反复用真实流量验证这篇文章就是我这次学习过程的完整整理。如果你也是第一次接触Higress或者第一次在AI统计插件配置里看到gjson表达式跟着这篇文章走一遍应该能自己读懂、手写、调试这类表达式甚至迁移到请求改写、日志精简、字段脱敏这些相邻场景。内容不涉及源码级解析优先保证你学完就能用。1. Higress AI统计插件为什么值得拿来当gjson的入门教材1.1 这个插件到底在解决什么问题先说背景。AI应用接到网关之后最常见的两个运营需求一是按模型维度看调用量和token消耗二是按用户维度做计量和配额。可是一个网关前面可能不止一个模型厂商OpenAI兼容接口的响应体里token消耗在usage.prompt_tokens通义千问类接口的字段结构又不一样Anthropic的completion字段藏在更深的嵌套层级里。如果每个厂商都写一套硬编码解析逻辑插件就得跟着模型数量一起膨胀维护成本直线上升。Higress的ai-statistics插件解决这个问题的思路很直接把从请求/响应里取哪个字段这件事彻底配置化。插件本身只负责在请求阶段和响应阶段各取一次数据取出什么取决于你给的表达式。我在实测环境里配置过的简化版长这样rules: - match: - path: /v1/chat/completions dimensions: - key: model_name type: BODY value: model - key: user_id type: HEADER value: x-user-id - key: input_tokens type: BODY value: usage.prompt_tokens - key: output_tokens type: BODY value: usage.completion_tokens这里type决定数据从哪里取value填的就是gjson路径表达式key是最终统计维度上显示的标签名。dimensions整体表达的是给这条AI调用打上model_name、user_id这些标签再抽取input_tokens、output_tokens这两个指标形成一条计量记录。做过监控系统的人一眼就能认出来这是标准的标签加指标结构只是标签的值不是写死的而是从运行时的报文里动态提取的。所以你看gjson并不是Higress故意引入来做复杂计算的语言它就是这个插件里从JSON报文中提取字段的唯一手段。把它当入门教材是因为它有真实的使用场景、有真实的字段结构而不是单纯背语法。1.2 统计配置里藏着一套迷你表达式语言我刚接触的时候有个困惑为什么插件不直接提供一个字段选择器下拉框非要让用户写一串像路径又不是路径的东西跑通几个案例之后才理解AI请求的响应结构虽然大体相似但嵌套深度、数组数量、字段命名习惯完全不同。下拉框没法覆盖千变万化的JSON结构只有提供一种足够灵活的表达式才能让同一个插件适配所有模型。这串表达式就是gjson。单独看它官方定位是Go语言的JSON解析库但站在使用者的角度它更像一门只有几类语法元素的微型DSL。你写的usage.prompt_tokens是一行表达式choices.#.message.content也是一行表达式它们在配置里的角色是运行时对JSON文档求值。你可以类比cron表达式cron是一套时间维度的DSL由分、时、日、月、周几几个字段加运算符组成gjson就是JSON维度的DSL由路径、数组标记、修饰符和管道组成。学它的时候如果只背语法容易忘我是从表达式求值的角度去理解的你写出的每一行路径最终都会被解释成一步一步在JSON树上的查找动作。理解了这个模型语法是可以推理出来的而不是靠记忆硬撑。1.3 把gjson和JSONPath、jq放一起看才明白它为什么被选中在看Higress文档之前我脑子里的JSON提取工具主要是两个JSONPath和jq。放到网关场景里对比之后才发现它们都不如gjson适合。工具语法体量性能定位JSONPath分支多、标准不一不同实现互相不兼容一般Web前后端通用查询jq语法丰富能做管道、变换、重组较重适合离线处理命令行文本处理gjson路径短、语法少、专注只读提取轻量适合热路径高频调用Go程序内嵌字段提取gjson在Go生态里几乎是从JSON里取字段的事实标准几百KB级别的库、无第三方依赖路径表达式短到可以直接塞进YAML配置不违和。Higress本身是Go写的插件热路径上对性能有要求选gjson是顺理成章的事。理解了这个取舍逻辑你就不会纠结为什么不用jq做这件事——网关里的表达式只需要读不需要写和变换gjson刚好把读做得很极致。2. 从表达式求值的视角拆解gjson的语法体系2.1 路径本身就是一种中缀表达式点号、下标、通配符把一份JSON文档想象成一棵树根节点是{}下一层是各个字段。gjson路径的本质就是在这棵树上从根走到目标节点的导航坐标。usage.prompt_tokens的意思很直白先取usage节点再取usage下面的prompt_tokens子节点。这个读写方式和你平时浏览文件系统没有本质区别。但我想换一个说法帮大家理解内部机制这种用点号连接字段名的写法本质上是一种中缀表达式——操作数字段名和连接符点号交错排列。gjson的解析器拿到字符串之后不会真的一边读一边猜意思而是先把整条路径解析成一组有序的求值步骤。你可以把它理解成先构建一张表达式树再把树上每个节点翻译成一步一步的查找动作。用表达式树和中缀、后缀的视角去理解有一个实际好处你能解释为什么路径是严格从左往右、逐层向下的。usage.prompt_tokens不会先取prompt_tokens再回头找父节点因为表达式树里usage是prompt_tokens的祖先节点求值顺序天然被树结构锁死。相比之下如果不理解这套求值模型遇到看起来没错但取不到的表达式就只能靠猜。基础语法其实就四类点号字段访问model、usage.prompt_tokens数组下标choices.0.message取数组第1个元素数组计数choices.#返回数组长度通配符choices.*.message.content取数组每一个元素里的message.content这里最容易忽略的是数组的写法。如果choices是数组直接写choices.message.content是取不到值的必须带上下标或通配符。数组就是树上的一个不可穿透的中间层想往下走必须先问清楚要第几个还是要全部。2.2 修饰符是内置函数管道是函数组合gjson最值钱、也最容易被初学者跳过的一部分是修饰符。修饰符以开头作用在路径获取到的结果上相当于给表达式语言加了一批内置函数。我用在统计场景里的有几个修饰符作用我的用法this返回当前节点本身choices.#.message.content|this逐条取当前值root从根节点重新导航在深层上下文里回跳根路径join把数组元素拼接成字符串把多个content拼成一段文本做分析reverse数组倒序取最近的N条记录values/keys取对象所有值或键遍历未知结构的字段修饰符和路径之间用管道符|连接形式上是先取字段值再对这个值做处理。这个管道其实就是函数组合前一步的结果是后一步的输入。学过任何支持lambda或函数式风格的语言应该能立刻get到——choices.#.message.content|join的意思就是先取出所有content值再合并成一个字符串。把修饰符理解成内置函数、管道理解成组合再遇到没见过的修饰符也能猜出大半语法。比如看到|pretty八成就是把取到的值格式化输出不会跑偏。2.3 表达式上下文this、root和控制流思维gjson里有几个上下文概念刚开始很容易绕进去。拿this和root举例root永远指向整份JSON文档的根this指向当前位置的节点。极端的说这就像lambda表达式里的参数作用域this是我正在处理的这个值root是闭包捕获的外部环境。如果你在数组某个元素内部想回到根节点按另一个路径取数据用root如果只是想把当前取到的值原样返回或传给下一个修饰符用this。这种上下文意识在处理数组过滤时尤其重要。gjson支持带条件的数组查询比如items.#(price10).id意思是遍历items数组找出所有price大于10的元素再取它们的id字段。括号里的price10就是对每个数组元素依次求值的布尔表达式表达式运算规则在这里和在普通编程语言里没有区别。我做统计、监控时经常靠这个套路过滤出token消耗超过阈值的调用记录比先全量取回来再在应用层过滤省事得多。3. 用AI统计插件实际配置把最常用的gjson表达式过一遍3.1 提取模型名称和token最朴素的点路径一个OpenAI兼容接口的响应体典型结构长这样{ id: chatcmpl-123, object: chat.completion, model: gpt-4o-mini, usage: { prompt_tokens: 320, completion_tokens: 128, total_tokens: 448 }, choices: [ {index: 0, message: {role: assistant, content: 你好}} ] }统计模型维度表达式就是model用点路径取usage里的token数就是usage.prompt_tokens和usage.completion_tokens。把这三个表达式放进dimensions插件会在响应阶段各自求值生成类似下面的指标维度表达式求值结果model_namemodelgpt-4o-miniinput_tokensusage.prompt_tokens320output_tokensusage.completion_tokens128最朴素的点路径没有玄机但它验证了一件事gjson表达式默认从JSON根节点开始导航照着响应体字段名一级一级往下写大概率就能打通。如果这一步都取不到值优先检查是不是type配错了阶段——请求体字段要在请求阶段取响应体字段要在响应阶段取这一点特别容易被忽略。3.2 从数组里捞数据choices、tools与通配符只取第一条结论用choices.0.message.content想统计所有候选内容用通配符choices.#.message.content。前者是精确下标访问后者是数组里的所有元素都给我过一遍。我在实际配置里用通配符最多的场景是统计多轮工具调用。有的模型响应会带多个choices或者一个choice里带多个tool call想分析其中某个工具被调用的次数可以写choices.#.tool_calls.#.function.name。这条表达式里的两个#分别作用于两层数组先遍历choices再遍历每个choice里的tool_calls最后取function.name。写出来之后配合join还能把结果拼成字符串便于日志输出。反过来如果只是想数一数生成了几个choicechoices.#直接返回数组长度放在监控维度里很直观。需要提醒的是通配符取到的是数组直接用于统计维度时要注意结果类型。比如choices.#.message.content取到的是一个数组如果统计插件期望的是单个字符串维度就要配合修饰符把数组规约成字符串。这也是为什么我建议把表达式求值结果和维度字段期望类型一起核对而不是只看取没取到值。3.3 按用户聚合从请求头取ID把上下文串起来token统计只有落在具体用户身上才有计量意义。用户ID通常不在响应体里而在请求头或者请求体的某个字段里。这时候type: HEADER就派上用场- key: user_id type: HEADER value: x-user-id注意这里的value里写的不是gjson路径而是一个HTTP头名字gjson表达式主要用于type: BODY。以解析OpenAI请求体中的user字段为例{ model: gpt-4o-mini, messages: [{role: user, content: 你好}], user: user_10086 }在dimensions里写user就能在请求阶段把用户ID标记到这条调用上。等响应阶段再把usage字段里的token数提取出来一前一后两个阶段的数据最终拼接成一条完整的计量记录哪个用户、用了哪个模型、消耗了多少输入输出token。整个过程里gjson表达式负责的始终是从JSON里把值捞出来时间点由type控制。把这两件事分开理解配置就不会乱。4. 我在调试表达式时踩过的坑以及一套可复用的排查链路4.1 字段名带点和特殊字符转义不是可选项第一条让我差点摔键盘的坑是字段名里带点号。有些模型厂商返回的JSON字段名为了分组长这样{model.info: qwen-max, usage: {...}}。如果按惯性写model.infogjson会把它解释成先取model字段再取model下的info子字段当然取不到。gjson里带点的字段名需要转义写成model\.info。这个细节通常不在插件文档里一旦响应体里有这类字段统计维度就会莫名为空。除了点号字段名里如果出现*、?、#也都需要考虑转义或换一种取法。我自己的经验是先打印原始响应体逐字段看清楚有没有特殊字符再写表达式能省一半调试时间。4.2 通配符边界与数组嵌套的翻车现场第二类坑集中在数组套数组的结构上。我遇到过一种响应体tokens统计字段在两层数组里面{ data: [ { messages: [ {content: a, tokens: 10}, {content: b, tokens: 20} ] }, { messages: [ {content: c, tokens: 30} ] } ] }一开始我写的是data.messages.tokens心想这不就是逐层往下走嘛结果返回空。问题在于data是数组、messages也是数组中间隔着两层数组路径上必须显式处理。正确的简化写法是data.#.messages.#.tokens先遍历data再遍历每个元素里的messages最后取tokens。这个坑的根本原因是数组不可穿透数组就是一个中间层不写下标、不写#、不写*gjson不会默认帮你遍历。排查的时候如果发现路径明明看起来对但取不到第一反应应该是检查每一层是不是数组、有没有漏掉通配标记。4.3 我用的本地验证三板斧最小复现、逐级展开、线上对拍线上配置直接改一来不好回滚二来日志噪音大。我总结了一套本地验证的排查链路照着走能快速定位问题。第一步最小复现。把线上真实响应体里的大字段删掉只留需要提取的字段结构存成sample.json。第二步写一个二十行不到的Go程序用gjson库跑表达式打印结果和类型package main import ( fmt os github.com/tidwall/gjson ) func main() { data, _ : os.ReadFile(sample.json) expr : os.Args[1] result : gjson.Get(string(data), expr) fmt.Printf(expr: %s\nvalue: %v\ntype: %s\n, expr, result.String(), result.Type) }这个程序的核心价值是能看到表达式求值的真实结果和类型。第三步逐级展开整条路径取不到就把路径拆成前半段、前半段加一层、再加一层逐步定位断在哪一级。实际案例是我希望从响应体取choices.0.message.tool_calls.0.function.name先试choices.0有值试choices.0.message有值试choices.0.message.tool_calls发现返回空——问题定位到tool_calls层级再仔细看原始报文才发现这个字段在最新版本里改成了复数结构路径按旧文档写自然失效。最后一步是对拍把本地验证通过的表达式填进Higress配置看插件实际输出的dimensions键值有没有进入统计结果并和本地求值结果一一比对。重要提示gjson在路径取不到值时不会报错它返回的是一个Null类型的空Result。所以统计数据里出现空维度不一定是插件问题先确认是不是表达式本身取不到字段不要被无异常日志误导。5. gjson表达式的边界与进阶离开AI统计插件后还能怎么用5.1 请求改写、日志精简、字段脱敏里的同款语法学会gjson之后你会发现它在Higress里远不止AI统计插件一个用武之地。请求改写插件里经常需要从旧格式请求体里抽取字段、再拼装成新格式比如把某个私有模型请求体里的prompt字段映射到OpenAI兼容格式的messages结构日志插件里可以用gjson从request body中挑出关键字段输出而不必整包落日志既省存储又方便检索有些场景需要把响应体里的手机号、身份证号做脱敏同样是靠gjson定位字段后再做替换。这些场景里的表达式语法和AI统计插件里完全一致。所以花一个晚上把gjson学扎实是很划算的。甚至离开Higress在任何Go项目里需要从JSON字符串里取字段都可以直接用这个库不用再跳进json.Unmarshal定义结构体的流程里去翻字段。5.2 性能和安全红线通配符有成本表达式也有权限最后说两个容易被忽略的红线。第一性能。通配符#和*意味着遍历如果响应体很大又用了多层通配网关热路径上会有额外开销。我一般建议在网关层面给请求体和响应体设置合理的大小上限同时对使用通配符的表达式数量做控制避免一台网关被几个表达式拖住。第二安全。gjson表达式本质是运行时求值如果允许外部用户传入表达式等于给了一个读取JSON任意字段的接口用户ID、内部字段都可能被捞走。生产环境里表达式应该来自固定配置走评审而不是开放给调用方自由传递。我在实际使用中的体会是gjson这套语法面很窄窄到一晚上就能覆盖八成用法但它解决的是高频且通用的问题——从JSON里取字段。学的时候别把它当一门编程语言去啃而是当一把精准的镊子去练手。Higress AI统计插件给了一个特别好的练习场因为你能立刻看到每一个表达式对真实流量的求值结果。等把usage.prompt_tokens、choices.#.message.content这几类写法练熟再回到任何其他需要JSON提取的场景都会觉得顺手很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑