mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-05 07:02:29 +00:00
## 1. 去掉 JSON 往返(快路径)
实测单次 Fire 14.6µs,其中 json.Marshal 4.0 + json.Unmarshal 5.6 = 9.6µs,
**67% 花在把 map[string]interface{} 序列化再反序列化**,而紧接着的
pushGoValue 本来就能直接遍历这两种类型。改为按类型直接转换(fastvalue.go),
只对不认识���类型才回落 JSON —— 陌生字段仍然会被送到插件,而不是消失。
快路径与 JSON 路径逐字节等价由 TestFastPathMatchesJSONPath 锁住(8 组载荷,
覆盖 int/uint/float 各宽度、嵌套、slice、map[string]string、未知类型)。
还有一条专门防止「优化悄悄失效」:TestFastPathIsActuallyUsed 用真实的
request_end 载荷断言它确实走快路径。
同一份代码 A/B 实测:JSON 往返 56.4µs → 快路径 35.3µs(省 37%)。
## 2. 同 stage 跨插件并行
参照 /home/program/TrueAgent 的 StageHost.RunStage:
- **快照后释放锁**再并行 —— 它记录过一次自死锁(p.Stop → onExit → ReclaimOwner
要拿 registry 锁,持锁并行即死锁)。这里同理:钩子可能经 admin API 增删插件,
那条路径要拿 ps.mu 写锁,所以并行段内不持任何 ps 锁。
- 每个 goroutine recover。
- 单插件走直连路径,不付 goroutine 代价(生产就是这种配置)。
**与 TrueAgent 不同的一点**:它可以放心并行,因为 handler 只写 ctx.Response 并有
IsResponded() 仲裁;我们的钩子返回 table 会合并进 payload,而
docs/plugins.md 明确承诺「payload 原样传给下一个插件」。所以合并**按插件加载
顺序**执行,结果确定,不依赖调度;代价是钩子之间不再互相可见 —— 这是一处
**契约变化**,已在文档里写明,并说明随核心发布的 billing 从不返回任何值
(代码注释就写着 "nobody downstream would read a return value")。
实测收益(真实二进制,三实例对照,3000 请求):
无插件 1 插件 4 插件
稳态并发32 849 rps 768 (-9.5%) 741 (-12.7%)
突发并发64 1524-1893 1182-1676 1064-1443
4 插件只降 10-20%,而并行前实测 4 插件是 63.8µs vs 单插件 14.6µs(-300%)。
## ★ 我自己造成的两次性能事故
**① 持久化把热路径拖慢 26 倍。** 最初的快照在钩子路径上做:走 luaValueToGo +
json.Marshal + json.Unmarshal 三重转换,每请求 264µs,Fire 从 14.6µs 变成 385µs。
改成 saver 按自己节奏拉取(钩子只标记 dirty,flush 时才快照),385µs → 25.7µs。
**这里还踩了第二次 use-after-free**:让后台 goroutine 去读 Lua 表,vm.Stop() 后
那是已释放内存(SIGSEGV)。安全性现在由「Plugins.Close 等 saver 的最后一次
flush 完成后,调用方才停 VM」保证。
**② 基准被自己的后台写入污染。** 关掉 markDirty 反而测出 36µs、比开着还慢,
方向完全反了 —— 是 saver 每 2 秒写盘混进了计时。加了 DisableStatePersistence
后数据才可信。
## 判据(11 项,全部变异验证)
快路径等价/确实生效/不别名输入 + 并行与单插件路径合并一致 + 合并顺序确定 +
抛异常的钩子不拖累同伴 + 每插件恰好执行一次 + 并发 Fire 安全 + Fire 期间不持
注册表锁 + 真实 billing 在并行下正常 + 持久化 7 项。
变异:改坏合并顺序 → 红;去掉单插件路径的合并 → 红(3 个既有测试同时抓到)。
★ 「删掉 recover」这个变异**没有**让判据变红,查下去发现 golua 把 error()、
nil 索引、调用 nil、深递归全部转成 error RETURN,不产生 Go panic —— 那个测试
根本没测到 recover。已改名 TestThrowingHook 并在注释里写明 recover() 当前无法
被 Lua 触达,保留它是为了守 Go 侧。留一个「看起来有覆盖」的断言比没有更糟。
## 端到端(真实二进制 + 真实 billing)
20 万请求全 200,rps 1870,p99 96ms,RSS 37.9→42MB 有界;
负载停止后四个插件计数**完全一致**(231745),hook_errors 为空;
systemctl restart 后 billing 仍是 231745 —— 并行与持久化同时生效。
381 个测试全绿,含 -race。
508 lines
19 KiB
Markdown
508 lines
19 KiB
Markdown
# 插件系统(Plugin System)
|
||
|
||
> **English**: this document is the reference for writing ModelRouter plugins.
|
||
> The Chinese version is the primary one; section titles map 1:1.
|
||
|
||
ModelRouter 的插件是**单个 `.lua` 文件**,放在 `config.yaml` 的 `plugin_dir` 目录里。
|
||
插件能做两件事:
|
||
|
||
1. **挂钩子**:在请求流水线的若干 stage 上注册回调,看到每个请求的完整信息,
|
||
并可以把结果累加进自己的状态。
|
||
2. **贡献界面**:在启动时返回 HTML / CSS / JS,由内核注入 WebUI——可以是一整个
|
||
新页面,也可以是往现有页面里追加一个组件。
|
||
|
||
两者互相独立:只想统计请求数的插件不必碰界面;只想加个仪表盘的插件不必碰钩子。
|
||
|
||
---
|
||
|
||
## 1. 快速上手
|
||
|
||
一个最小的插件:
|
||
|
||
```lua
|
||
-- plugins/hello.lua
|
||
local plugin = {
|
||
name = "hello",
|
||
version = "1.0.0",
|
||
description = "示例插件",
|
||
author = "you",
|
||
}
|
||
|
||
-- 声明钩子
|
||
plugin.hooks = {
|
||
request_end = "on_request_end",
|
||
}
|
||
|
||
-- 钩子实现
|
||
function plugin.on_request_end(payload)
|
||
-- payload 是解码后的 table,不是 JSON 字符串
|
||
log("info", string.format("%s via %s: %d prompt tokens",
|
||
payload.model, payload.source, payload.prompt_tokens or 0))
|
||
return nil -- 最后一个 stage 没有下游,return 无意义
|
||
end
|
||
|
||
-- 贡献界面
|
||
plugin.ui = {
|
||
page = {
|
||
page_id = "hello", -- kebab-case
|
||
title = "Hello",
|
||
icon = "👋",
|
||
order = 90, -- 侧栏排序
|
||
mount = [[<div id="hello">hello</div>]],
|
||
},
|
||
}
|
||
|
||
return plugin -- 必须返回一个 table
|
||
```
|
||
|
||
放进 `plugin_dir` 后重启即生效。`GET /api/plugins` 确认它被加载了。
|
||
|
||
---
|
||
|
||
## 2. 加载与生命周期
|
||
|
||
```
|
||
core.New
|
||
└─ lua.NewVM(adapter_dir).Start() 适配器状态
|
||
└─ lua.NewPlugins(vm, plugin_dir)
|
||
├─ SeedBundled() 仅当目录不存在时写入内置插件(目前是 billing)
|
||
└─ LoadDir() 按文件名字典序逐个加载
|
||
```
|
||
|
||
**加载失败不影响网关启动。** 一个语法错误的插件会被记录在 `GET /api/plugins`
|
||
的 `error` 字段里,永远不会被调用。这与适配器一致,但理由更强:插件是可选的
|
||
第三方扩展,因为一个 `.lua` 打错字就让网关起不来是错误的取舍。
|
||
|
||
**目录一旦存在就是权威的。** 与适配器同规则:首启会 seed 内置插件,之后目录里
|
||
的文件说了算,删除或编辑内置插件都是真实生效的操作。
|
||
|
||
### 2.1 热更新
|
||
|
||
| 方式 | 效果 |
|
||
|---|---|
|
||
| `POST /api/plugins {name, code}` | 写文件 + 立即加载新版本(旧的 Lua 状态被关闭重建,**累计量清零**) |
|
||
| `DELETE /api/plugins/{name}` | 删文件 + 卸载 |
|
||
| 改文件后 `POST` 同名 | 同上 |
|
||
|
||
改文件但**不** POST,需要重启才生效。
|
||
|
||
---
|
||
|
||
## 3. 流水线 stage
|
||
|
||
一个请求依次经过三个 stage。插件可以为任意 stage 注册钩子;未注册的 stage
|
||
被忽略,所以插件不会因为网关将来新增 stage 而报错。
|
||
|
||
```
|
||
客户端请求
|
||
│
|
||
┌─────────▼──────────┐
|
||
│ request_start │ 已解析、已鉴权,尚未选源
|
||
│ · type │ "chat" | "stream" | "image"
|
||
│ · model │ 客户端请求的原始 model("AUTO" 也在这里)
|
||
│ · key / role │ 掩码后的网关 key id("***a1b2c3")与角色
|
||
│ · source │ 空(还没选源)
|
||
│ · stream │
|
||
│ · messages_count │
|
||
│ · tools_count │
|
||
│ · ts │ unix 秒
|
||
└─────────┬──────────┘
|
||
│ (调度:tier 遍历 → 槽位轮转 → 冷却/配额过滤)
|
||
┌─────────▼──────────┐
|
||
│ routed │ 已选定 (source, model),尚未发往上游
|
||
│ · source / model │ 实际选中的
|
||
│ · tier │ AUTO 链的档位;直连 = -1;AUTO = -2
|
||
│ · stream / key │
|
||
└─────────┬──────────┘
|
||
│ (HTTP 往返 / SSE 流)
|
||
┌─────────▼──────────┐
|
||
│ request_end │ 每个请求恰好一次,成功失败都触发
|
||
│ · ok / status │
|
||
│ · latency_ms │
|
||
│ · first_byte_ms │ 流式的首字节时间
|
||
│ · prompt_tokens │ 上游真实 usage,缺失时为字节估算
|
||
│ · completion_tokens
|
||
│ · cache_hit_tokens / cache_miss_tokens
|
||
│ · image_count │ 生图数量(图片不计 token)
|
||
│ · error │ 失败原因,成功时为 ""
|
||
│ · time │ unix **毫秒**
|
||
└─────────┬──────────┘
|
||
│
|
||
写审计 + 聚合统计
|
||
```
|
||
|
||
### 3.1 触发点在哪
|
||
|
||
| stage | 代码位置 | 说明 |
|
||
|---|---|---|
|
||
| `request_start` | `gateway/chat.go` `handleChat` / `fireImageStart` | 每个请求一次(chat 与生图各一条),在配额闸门**之前** |
|
||
| `chain_step` | `gateway/chat.go` `chainTraceSink` | **仅 AUTO 路径**,每步一次 |
|
||
| `routed` | `singleChat` / `streamChat` / `singleChatAuto` / `streamChatAuto` | 成功选定源之后,各一次 |
|
||
| `request_end` | `gateway/chat.go` `writeRec` | **所有出口的唯一汇合点**,每个请求一次 |
|
||
|
||
### 3.1b 为什么单独有 `chain_step`
|
||
|
||
`routed` 只在**遍历结束后**触发一次,只带最终胜出的槽位。所以
|
||
"tier 1 冷却所以降级到 tier 3"和"tier 1 正常接单"在它眼里**完全一样**——
|
||
而这恰恰是优先级链存在的全部理由。
|
||
|
||
`chain_step` 补上这条信息,四种 `kind`:
|
||
|
||
| kind | 含义 | 何时产生 |
|
||
|---|---|---|
|
||
| `tier_skip` | 整档被跳过 | 该档所有槽位冷却中/配额用尽 |
|
||
| `slot_fail` | 某个槽位硬失败 | 上游报错 / 适配器输出不可用 |
|
||
| `tier_busy` | 整档全忙且有界等待超时 | 2s 内没等到空位 |
|
||
| `selected` | 这个槽位接了单 | 每次成功遍历**恰好一次**,且是最后一步 |
|
||
|
||
顺序保证:所有 `chain_step` 都在 `routed` 之前,`selected` 是最后一步。
|
||
所以只订阅 `request_end` 的插件也能拿到轨迹摘要(见下)。
|
||
|
||
### 3.1c `request_end` 里的轨迹摘要
|
||
|
||
除了逐个 `chain_step`,`request_end` 还带三个便于做报表的字段:
|
||
|
||
| 字段 | 含义 |
|
||
|---|---|
|
||
| `chain_walk` | 整个遍历的步骤数组(上限 12 步,超出截断) |
|
||
| `degraded` | 布尔。`true` = 有过跳过/失败,即**发生了降级** |
|
||
| `tier_served` | 实际服务的那一档;直连或全失败时为 `-1` |
|
||
|
||
> **计费口径**:按**实际服务的模型**计费。降级到 tier 3 仍按 tier 3 的价算,
|
||
> `chain_step` / `degraded` / `tier_served` 只作**观测**,不参与计价。
|
||
> 理由见 §7.5。
|
||
|
||
`request_end` 放在 `writeRec` 是因为四条入口路径(直连/AUTO × 流式/非流式)都
|
||
经过它,既不会漏(流式的 token 数只有流结束才知道),也不会重复。
|
||
|
||
**hot path 注意事项**:没有插件注册某 stage 时,`Fire` 立刻返回(一次 `RLock`
|
||
加一次 map 查找)。装了插件之后,每个请求会在该 stage 上多一次 Lua 调用——
|
||
这是同步的,在关键路径上。计费插件那种"每请求一次"是正常的;把重活放进钩子是
|
||
反模式。
|
||
|
||
### 3.2 钩子的返回值
|
||
|
||
- 返回 `nil` 或不返回 = **没有意见**,payload 原样传给下一个插件
|
||
- 返回 table = 其中的键会**合并进 payload**,并作为 `Fire` 的返回值
|
||
|
||
**同一 stage 的多个插件并行执行**,但返回值按**插件加载顺序**合并,所以结果是
|
||
确定的(不依赖 goroutine 调度)。代价是一个插件看不到另一个插件刚加的字段:
|
||
每个钩子拿到的是**同一份 payload 快照**。
|
||
|
||
这与早期版本不同 —— 早期是顺序执行,后一个插件能看到前一个的返回值。它从未被
|
||
实际依赖(随核心发布的 billing 在每个 stage 都 `return nil`,注释里写着
|
||
"nobody downstream would read a return value"),但这是一处**契约变化**:如果你的
|
||
插件依赖「读到前一个插件写的字段」,并行的两个插件之间必须改用外部通信
|
||
(例如各自写 `plugin.state`,由 `/api/plugins/<name>/state` 读取)。
|
||
|
||
单个插件时不启 goroutine,直接调用。
|
||
|
||
前三个 stage 的返回值目前没有内部消费者(最后一个 stage 之后就是写审计),
|
||
所以计费插件改用 `plugin.state` + `/state` 端点来暴露数据。
|
||
|
||
---
|
||
|
||
## 4. 界面扩展
|
||
|
||
### 4.1 整页
|
||
|
||
```lua
|
||
plugin.ui = {
|
||
page = {
|
||
page_id = "billing", -- 必填,kebab-case。侧栏 data-tab 与 #tab-billing
|
||
title = "Billing", -- 必填,侧栏文字
|
||
icon = "💰", -- 可选
|
||
order = 40, -- 侧栏排序,默认 100
|
||
mount = [[...HTML...]],
|
||
},
|
||
}
|
||
```
|
||
|
||
### 4.2 往现有页面追加元素
|
||
|
||
```lua
|
||
plugin.ui = {
|
||
elements = {
|
||
{
|
||
target = "status", -- status | chat | keys | sort | sources | adapters
|
||
anchor = "top", -- "top" | "bottom" | "before:<sel>" | "after:<sel>"
|
||
order = 5,
|
||
mount = [[...HTML...]],
|
||
},
|
||
},
|
||
}
|
||
```
|
||
|
||
### 4.3 `mount` 里可以带 `<script>` 和 `<style>`
|
||
|
||
内核的注入顺序是:**先插 HTML,再执行 `<script>`**,所以脚本里访问
|
||
`document.getElementById` 一定能拿到已渲染的节点。
|
||
|
||
### 4.4 插件可用的浏览器端 API
|
||
|
||
| API | 作用 |
|
||
|---|---|
|
||
| `window.pluginAPI.fetchState(name)` | 等价于 `GET /api/plugins/<name>/state` |
|
||
| `window.pluginAPI.onTabShown(fn)` | 注册"页面切到可见时"的回调(轮询类组件用) |
|
||
| `window.pluginAPI.postState(name, obj)` | `PUT` 自己的 state(写操作,需 admin) |
|
||
|
||
### 4.5 一个完整例子
|
||
|
||
见 `internal/lua/plugins/billing.lua`——它同时用了两种 UI 形式、完整的价格配置、
|
||
以及全部三个 stage。
|
||
|
||
---
|
||
|
||
## 5. 插件状态与 HTTP API
|
||
|
||
### 5.1 端点
|
||
|
||
```
|
||
GET /api/plugins 已加载插件清单 + 钩子 + UI + 错误
|
||
POST /api/plugins 上传/替换(admin){name, code}
|
||
DELETE /api/plugins/{name} 删除(admin)
|
||
GET /api/plugins/{name}/state 读取插件自己发布的状态
|
||
PUT /api/plugins/{name}/state 替换状态(admin)
|
||
```
|
||
|
||
`GET .../state` 对**任意角色开放**:它是报表数据(开销、计数),用户自己的
|
||
计费组件要能渲染。而 `PUT` 需要 admin。
|
||
|
||
### 5.2 约定:`prices` 与 `state` 分离
|
||
|
||
`PUT .../state` 的载荷里如果有 `prices` 键,它会被写进插件的 **`plugin.prices`**
|
||
字段并从 `state` 里剔除。
|
||
|
||
**为什么**:`state` 是累计量(历史),`prices` 是配置。一次改价如果整体替换
|
||
`state`,累计量就没了——账目会在改价那一刻清零。分离之后:
|
||
|
||
- 只带 `prices` → 改配置,**不动** `state`(累计量保留)
|
||
- 带其它键 → 替换 `state`(这是显式的重置)
|
||
|
||
> 插件可以不遵守这个约定,把整个载荷放进 `state` 自行处理。约定只为内置的
|
||
> 计费插件存在。
|
||
|
||
### 5.3 `GET /api/plugins` 的响应
|
||
|
||
```json
|
||
{
|
||
"plugins": [{
|
||
"name": "billing", "version": "1.0.0",
|
||
"description": "...", "author": "...",
|
||
"hooks": ["request_end"],
|
||
"ui": { "page": "billing", "elements": 1 },
|
||
"loaded": true
|
||
}],
|
||
"hook_errors": { "request_end": { "count": 3, "last_error": "billing: ..." } },
|
||
"plugin_dir": "/etc/llmsproxy/plugins"
|
||
}
|
||
```
|
||
|
||
`hook_errors` 是**排错入口**:一个坏掉的插件表现为"功能缺失"而不是报错,
|
||
这个计数让它可见。
|
||
|
||
---
|
||
|
||
## 6. 运行时约束(重要)
|
||
|
||
### 6.1 一个插件 = 一个 Lua 状态
|
||
|
||
适配器可以有多个独立的 worker 状态(它们无状态,这是对的)。**插件不行**:
|
||
钩子通常往 `plugin.state` 里累加,多个状态就意味着总额被劈成几份,而写价格只
|
||
写进其中一个状态,钩子恰好跑到另一个时**所有请求按 0 计费**。
|
||
|
||
因此插件持有**唯一一个**状态,所有入口(`Fire` / `State` / `SetState`)用互斥锁
|
||
串行化。代价:**一个卡住的钩子会卡住所有插件的钩子**。所以——
|
||
|
||
> **钩子必须短、同步、不阻塞。** 不要在里面做 HTTP 请求、睡眠或重计算。
|
||
|
||
### 6.2 异常被隔离
|
||
|
||
钩子里 `error()` 不会影响转发:异常被捕获、记进 `hook_errors`、跳到下一个插件。
|
||
适配器变换出错会让源进入冷却,但插件出错**不产生任何惩罚**——插件是可选功能。
|
||
|
||
### 6.3 内置可用函数
|
||
|
||
与适配器相同:`json.encode` / `json.decode`、`log(level, msg)`、
|
||
`hmac_sha256_hex` / `sha256_hex` / `base64_encode` / `tohex`。
|
||
|
||
⚠️ `os.date` 在极简运行时里可能不可用(计费插件因此有降级路径)。
|
||
|
||
### 6.4 命名
|
||
|
||
- 全局 `__llmsproxy_plugin` 存放插件返回的 table,**不要占用**
|
||
- 插件间状态完全隔离,一个插件改不了另一个的全局
|
||
|
||
---
|
||
|
||
## 7. 内置计费插件
|
||
|
||
`plugins/billing.lua`,默认随首启 seed 进来,开箱可用。
|
||
|
||
### 7.1 计费维度
|
||
|
||
| 维度 | 用途 | 优先级 |
|
||
|---|---|---|
|
||
| `sources` | 源的固定价(如按请求计费的转发服务) | 中 |
|
||
| `models` | 模型的 per-token 价 | 高 |
|
||
| `keys` | 单个网关 key 的覆盖价 | **最高** |
|
||
|
||
**token 价**优先级:`keys` > `models` > `default`。
|
||
**`per_request` 固定价是叠加的**(不覆盖),所以一个生图模型可以既算 token 又收固定费。
|
||
**`cache_discount`** 见 §7.7。
|
||
|
||
### 7.2 价格单位
|
||
|
||
**USD / 单个 token**。这是各家 provider 的公布口径,所以典型数值长这样:
|
||
`1.25e-6`。插件内部乘以 token 数,全程不做单位换算。
|
||
|
||
```json
|
||
{
|
||
"prices": {
|
||
"currency": "USD",
|
||
"default": { "prompt": 0, "completion": 0, "per_request": 0 },
|
||
"sources": { "trae": { "per_request": 0.01 } },
|
||
"models": {
|
||
"gpt-5.4": { "prompt": 1.25e-6, "completion": 1e-5 },
|
||
"kolors": { "per_request": 0.04 }
|
||
},
|
||
"keys": { "***a1b2c3": { "prompt": 1.1e-6, "completion": 9e-6 } }
|
||
}
|
||
}
|
||
```
|
||
|
||
配置价目:
|
||
|
||
```bash
|
||
curl -X PUT http://127.0.0.1:8080/api/plugins/billing/state \
|
||
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
|
||
-d '{"prices":{"models":{"gpt-5.4":{"prompt":1.25e-6,"completion":1e-5}}}}'
|
||
```
|
||
|
||
读回账目:
|
||
|
||
```bash
|
||
curl -H "Authorization: Bearer $ADMIN_KEY" \
|
||
http://127.0.0.1:8080/api/plugins/billing/state
|
||
```
|
||
|
||
### 7.3 累计维度
|
||
|
||
`total` / `by_source` / `by_model` / `by_key` / `by_day`(`YYYY-MM-DD` UTC),
|
||
每项含 `cost`、`requests`、`prompt_tokens`、`completion_tokens`、`failures`。
|
||
|
||
另有两个**降级观测**维度(来自 `chain_step`):
|
||
|
||
| 字段 | 含义 |
|
||
|---|---|
|
||
| `degraded_reqs` | 发生过降级的请求数 |
|
||
| `by_tier_served` | 各档实际接单数(`{"1": 812, "2": 37}`) |
|
||
| `skip_reasons` | 跳过原因计数,等待时长已归一(`no free slot within <wait>`) |
|
||
|
||
这三项是"网关是不是在悄悄降级"的核心指标:一个持续降级的网关,账单结构和健康
|
||
网关看起来一模一样——**除非**单独统计降级次数。
|
||
|
||
### 7.4 计费策略:失败的请求怎么算
|
||
|
||
**保留 token 费用,丢弃固定费用。** 理由:上游在生成后才 500,token 确实被消耗
|
||
了;但那个从未真正发生的固定费不该收。
|
||
|
||
这是本插件里**最可争议的一条**。想改成"失败也收固定费":
|
||
|
||
```json
|
||
{ "prices": { ... }, "count_failures": true }
|
||
```
|
||
|
||
### 7.5 ⚠️ 计费插件只报表,不执法
|
||
|
||
**网关自己的配额会计(`internal/gateway/stats.go`,入口处强制)才是限额权威。**
|
||
本插件不参与任何路由或配额决策。
|
||
|
||
理由:两套独立的会计路径如果对不上,比一套功能略少的更糟。计费是**观察**,
|
||
配额是**控制**,二者分开。
|
||
|
||
### 7.6 未定价流量(重要)
|
||
|
||
**任何维度都没配价的请求,成本记 0。** 这是最危险的失败模式:账单照样能加总,
|
||
只是**悄悄少报**,而且没有任何报错。
|
||
|
||
所以插件单独统计它们:
|
||
|
||
| 字段 | 含义 |
|
||
|---|---|
|
||
| `unpriced_reqs` | 没有任何价目覆盖的请求数 |
|
||
| `unpriced_models` | 按模型点名(`{"MYSTERY-MODEL": 12}`)——直接告诉你价目表缺了哪一行 |
|
||
|
||
仪表盘上有 "Unpriced" 卡片。**这个数应该是 0**;不是 0 就去补价目。
|
||
|
||
注意"未定价"不等于"免费":这些请求的 `requests` / token 数**照常计入**
|
||
`total` 与各维度,只有金额是 0。
|
||
|
||
### 7.7 提示缓存计价
|
||
|
||
**缓存命中的 prompt token 不按全价算。** 绝大多数 provider 对缓存读给很深的折扣
|
||
(常见是 1/10),而 agent 流量会反复重放长前缀——正是缓存要让它便宜的那类流量。
|
||
|
||
```
|
||
fresh = prompt_tokens - cache_hit_tokens → 全价
|
||
cached = cache_hit_tokens → 全价 × cache_discount
|
||
```
|
||
|
||
`cache_discount` 默认 **0.1**(10 倍折扣),因为 DeepSeek / Qwen / Kimi 等都是这个
|
||
量级。它是**每个 provider 的事实、不是自然常数**,所以可以按条目覆盖:
|
||
|
||
```json
|
||
"models": { "gpt-5.4": { "prompt": 1.25e-6, "completion": 1e-5, "cache_discount": 0.25 } }
|
||
```
|
||
|
||
设成 `1` 恢复成旧的"prompt 一律全价"行为,设成 `0` 表示该 provider 不打折。
|
||
|
||
优先级与 token 价一致(`keys` > `models` > `default`)。
|
||
|
||
> **这一条改过行为。** 修复前缓存命中按全价算:100 万 prompt token 里 90 万是
|
||
> 缓存命中,会算出 10 USD 而不是 ~1.9——**高估约 10 倍**,而且恰好发生在缓存
|
||
> 最有价值的高频流量上。
|
||
|
||
### 7.8 数据从哪来
|
||
|
||
`request_end` 的 `prompt_tokens` / `completion_tokens` 优先取上游真实的
|
||
`usage`;上游没报时网关用字节估算(`len/3+1`)。流式请求在流结束后用上游真实
|
||
数字**覆盖**估算值。所以计费数字的精度取决于上游是否报 usage。
|
||
|
||
---
|
||
|
||
## 8. 排错
|
||
|
||
| 现象 | 查什么 |
|
||
|---|---|
|
||
| 插件没出现在 `/api/plugins` | 目录对不对(响应里的 `plugin_dir`);文件是不是 `.lua` |
|
||
| `loaded: false` 且有 `error` | 语法错误或没 `return table`,错误信息在 `error` 字段 |
|
||
| 功能"没反应"但无报错 | 查 `hook_errors`——坏钩子只记录不抛出 |
|
||
| 钩子没被调用 | 该 stage 确实触发了吗(`routed` 只在**成功**选源后触发,调度全失败时不触发) |
|
||
| 界面空白 | 内核的 `GET /api/ui-inject` 里有没有你的 `page_id`;脚本有没有报错(浏览器 console) |
|
||
| `pluginAPI` 未定义 | 脚本在注入前执行了;确认用的是 `mount` 而不是别的方式插入 |
|
||
|
||
### 调试钩子
|
||
|
||
插件的 `log(level, msg)` 输出到网关的日志:
|
||
|
||
```
|
||
journalctl -u llmsproxy -f | grep -i plugin
|
||
```
|
||
|
||
---
|
||
|
||
## 9. 与适配器的区别
|
||
|
||
| | 适配器 | 插件 |
|
||
|---|---|---|
|
||
| 位置 | `adapter_dir` | `plugin_dir` |
|
||
| 作用 | 上游协议转换 | 请求流水线 + 界面 |
|
||
| 钩子 | `transform_request` / `transform_response` / `transform_stream_chunk` / `build_headers` / `transform_error` | `request_start` / `routed` / `request_end` |
|
||
| 状态 | 每个源独立,多 worker | 单状态,见 §6.1 |
|
||
| 出错后果 | 该 (源,模型) 进入冷却退避 | 仅该功能缺失 |
|
||
| 鉴权 | 需要 `gateway_keys` 之外的独立凭据 | 网关 key |
|
||
| 必需性 | 核心 | 可选,缺了网关照常跑 |
|
||
|
||
完整适配器协议见 [lua-adapters.md](lua-adapters.md)。
|