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.#(price>10).id,意思是遍历items数组,找出所有price大于10的元素,再取它们的id字段。括号里的price>10就是对每个数组元素依次求值的布尔表达式,表达式运算规则在这里和在普通编程语言里没有区别。我做统计、监控时,经常靠这个套路过滤出"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_name | model | gpt-4o-mini |
| input_tokens | usage.prompt_tokens | 320 |
| output_tokens | usage.completion_tokens | 128 |
最朴素的点路径没有玄机,但它验证了一件事: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还能把结果拼成字符串,便于日志输出。反过来,如果只是想数一数生成了几个choice,choices.#直接返回数组长度,放在监控维度里很直观。
需要提醒的是,通配符取到的是数组,直接用于统计维度时要注意结果类型。比如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.info,gjson会把它解释成"先取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提取的场景,都会觉得顺手很多。