Files
Sakurasan 8c12207478 perf: 目录缓存与流式正文快路径,请求热路径开销减半
按性能分析逐项落地,实测对比(16 并发、mock 上游零延迟、SQLite):

- 每请求 SQL 10 → 2:模型/渠道目录(模型行、绑定、渠道行)进程内缓存 10s,
  运行期状态(冷却、健康)仍每次实时判定;管理端写操作经中间件、
  健康状态变化经回调立即失效。新增 TestProxyRequestSQLBudget 钉住预算。
- 端到端 p50 1.3–1.6ms → 0.66–0.70ms,RPS 3.5–8.9k → 16–18k。
- 流式正文累积:字符串 += 改 strings.Builder(原为 O(n²)),
  逐行 JSON 提取改字节快路径,并加廉价的子串预过滤、拿到输出 usage 后停累积。
  500 块:949µs/7.9MB 分配 → 83µs/136KB。
- 请求体定点改写:模型映射从整包 unmarshal/marshal 改为顶层字段替换
  (100KB:585µs → 29µs),stream_options 注入同理;requestText 改为
  只在「上游没报用量」时按需提取,亲和键改用原始体前缀。
- 渠道满载背压:新增 Acquire 有界排队(OT_PROXY_CHANNEL_ACQUIRE_WAIT,
  默认 150ms);排队仍满时返回 503 + Retry-After 而不是 502。
  实测渠道上限 4、16 并发:无排队 81 次 503;排队 150ms 后 400/400 成功。
- 记账按批聚合:同批同用户一次扣款、流水批量插入、日表按 (user,model,日期)
  聚合,替代每行 5 条语句;幂等仍靠 ref_id 唯一索引兜底。
- gorm 开启 PrepareStmt(SQL 解析此前占 CPU 15%);tiktoken 编码器按模型复用。
- 管理端用量列表不再返回原始请求/响应体(可能几十 MB),
  改为 has_raw 标记 + GET /admin/usage/:id 按需拉取,前端弹窗打开时取。

验证:go vet、go test ./...、go test -race(proxy/channel/usage/tokenizer)、
vue-tsc、eslint、vite build 全绿;本地起 mock 上游实测列表/详情接口与
管理页弹窗按需加载。
2026-10-30 14:14:21 +08:00

156 lines
4.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package proxy
import (
"bytes"
"encoding/json"
)
// 请求体上的定点改写:只动需要改的那一段,不做整包 unmarshal → map → marshal。
// 整包往返在 agent 场景(上下文几十到几百 KB)实测约 0.5–0.6ms 且分配翻倍,
// 而这里只做一次 O(n) 扫描。
// rewriteModelField 改写请求体顶层 model 字段,其余字节原样保留。
// 嵌套出现的 "model"(比如消息内容里引用的 JSON)不会被误改:只认顶层键。
// 找不到顶层 model 或请求体不是对象时返回 (body, false)。
func rewriteModelField(body []byte, model string) ([]byte, bool) {
span, ok := topLevelValueSpan(body, "model")
if !ok {
return body, false
}
quoted, err := json.Marshal(model)
if err != nil {
return body, false
}
out := make([]byte, 0, len(body)-(span[1]-span[0])+len(quoted))
out = append(out, body[:span[0]]...)
out = append(out, quoted...)
out = append(out, body[span[1]:]...)
return out, true
}
// injectStreamOptions 在请求体顶层追加 stream_options,让流式末块带上 usage。
// 只在没有该键时插入;body 必须是 JSON 对象(以 '}' 结尾,允许尾部空白)。
func injectStreamOptions(body []byte, options string) ([]byte, bool) {
trimmed := bytes.TrimRight(body, " \t\r\n")
if len(trimmed) < 2 || trimmed[0] != '{' || trimmed[len(trimmed)-1] != '}' {
return body, false
}
if bytes.Contains(trimmed, []byte(`"stream_options"`)) {
return body, false
}
end := len(trimmed) - 1
out := make([]byte, 0, len(body)+len(options)+16)
out = append(out, trimmed[:end]...)
// 空对象 {} 不能加逗号
if last := bytes.TrimRight(trimmed[:end], " \t\r\n"); len(last) > 1 {
out = append(out, ',')
}
out = append(out, `"stream_options":`...)
out = append(out, options...)
out = append(out, '}')
return out, true
}
// topLevelValueSpan 找顶层键 key 的值在 body 中的字节区间 [start, end)。
// 只遍历顶层(depth==1)的键,跳过字符串字面量与转义。
func topLevelValueSpan(body []byte, key string) ([2]int, bool) {
var zero [2]int
trimmed := bytes.TrimLeft(body, " \t\r\n")
if len(trimmed) == 0 || trimmed[0] != '{' {
return zero, false
}
// 重新对齐到 trimmed 起点后的偏移
base := len(body) - len(trimmed)
keyPat := []byte(`"` + key + `"`)
i := base + 1
depth := 1
for i < len(body) {
switch body[i] {
case '{', '[':
depth++
i++
case '}', ']':
depth--
if depth == 0 {
return zero, false
}
i++
case '"':
start := i
i = skipJSONString(body, i)
// 顶层字符串:可能是"键",也可能是"值"
if depth == 1 && bytes.Equal(body[start:i], keyPat) {
j := i
for j < len(body) && (body[j] == ' ' || body[j] == '\t' || body[j] == '\r' || body[j] == '\n') {
j++
}
if j < len(body) && body[j] == ':' {
j++
for j < len(body) && (body[j] == ' ' || body[j] == '\t' || body[j] == '\r' || body[j] == '\n') {
j++
}
vs := j
ve, ok := jsonValueEnd(body, vs)
if !ok {
return zero, false
}
return [2]int{vs, ve}, true
}
}
default:
i++
}
}
return zero, false
}
// skipJSONString 返回从 body[i]('"')开始的下一个未转义引号之后的位置。
func skipJSONString(body []byte, i int) int {
i++ // 跳过开引号
for i < len(body) {
switch body[i] {
case '\\':
i += 2
case '"':
return i + 1
default:
i++
}
}
return i
}
// jsonValueEnd 返回从 i 开始的一个 JSON 值的结束位置(不含尾部空白)。
func jsonValueEnd(body []byte, i int) (int, bool) {
if i >= len(body) {
return 0, false
}
switch body[i] {
case '"':
return skipJSONString(body, i), true
case '{', '[':
depth := 0
for j := i; j < len(body); j++ {
switch body[j] {
case '"':
j = skipJSONString(body, j) - 1
case '{', '[':
depth++
case '}', ']':
depth--
if depth == 0 {
return j + 1, true
}
}
}
return 0, false
default:
j := i
for j < len(body) && body[j] != ',' && body[j] != '}' && body[j] != ']' &&
body[j] != ' ' && body[j] != '\t' && body[j] != '\r' && body[j] != '\n' {
j++
}
return j, j > i
}
}