perf: 亲和表分片、记账按驱动并发、访问日志与原始体控量
继续上一轮性能改造的剩余项: - 会话亲和表分 16 片(原来一个全局 mutex,每个请求都要走一遍), 键改为内联 FNV-1a 的 64 位摘要(原先是拼接字符串 + hex,每请求两次分配), 请求路径上零分配;新增键稳定性/无分配/分片并发测试。 - 记账落库按驱动自适应并发:Recorder 按用户分片到多个 worker(同一用户恒定 同片,流水顺序不变)。SQLite 只有一个写者,实测 4 worker 会互相抢锁把 p99 拖到 19–34ms,所以 SQLite 固定 1 个、PostgreSQL 用 4 个。 新增多分片落库与顺序测试(用显式 worker 数在 SQLite 上验证语义)。 - 访问日志:每请求一次 log.Printf 直写 stderr,高 QPS 下是系统调用热点; 改为 64 KiB 缓冲 + 每秒 flush(写/flush 互斥,-race 已验)。 - 原始体控量:LogRaw 落库的请求体截到 256 KiB、响应体截到 1 MiB 并标注原始 体积,避免单条日志几十 MB 拖慢 usage_logs 查询与备份。 - 渠道加权随机选路从 crypto/rand + big.Int 换成 math/rand/v2 的 IntN, 去掉每请求的系统调用与两次分配。 跳过的两项与原因: - 「PG 下把 model 目录查询合并成一次 JOIN」:目录已缓存 10s,冷路径每模型 10 秒才走一次,合并收益接近 0。 - 「每流一个 heartbeat goroutine」:Go 的 ticker 成本很低,千级并发流仍在 可接受范围,改动收益不抵风险。 实测(16 并发、mock 上游、SQLite):p50 679µs→558µs、p99 2.6ms→2.2ms、 RPS 18.7k→23.9k。验证:go vet、go test ./...、-race(proxy/channel/usage)全绿。
This commit is contained in:
1 parent
8c12207478
commit
f1181150aa
11 files changed
+401
-69
No files matched your search
@@ -1,9 +1,6 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -12,24 +9,33 @@ import (
|
||||
// 同一会话粘住上次应答的渠道,让上游的提示词缓存(整段上下文每轮重发)被再次
|
||||
// 命中,而不是被加权随机打散后全价重算。
|
||||
//
|
||||
// 会话身份 = 密钥 + 模型 + 请求文本稳定前缀的摘要。编码代理每轮重发全量上下文,
|
||||
// 系统提示与最早的用户消息不变,取前缀即可区分会话;不需要解析各家客户端的
|
||||
// 会话头。命中亲和的渠道仍必须在候选集里(健康、未冷却、认识该模型)——
|
||||
// 冷却或下线后自然回退到加权随机。
|
||||
// 会话身份 = 密钥 + 模型 + 请求体稳定前缀的 64 位摘要。编码代理每轮重发全量上下文,
|
||||
// 开头是稳定的(模型、系统提示、最早的几条消息),取前缀即可区分会话;
|
||||
// 不需要解析各家客户端的会话头。命中亲和的渠道仍必须在候选集里(健康、未冷却、
|
||||
// 认识该模型)——冷却或下线后自然回退到加权随机。
|
||||
//
|
||||
// TTL 默认 5 分钟(对齐 Anthropic 提示词缓存窗口),每次成功应答刷新;
|
||||
// 上游不报缓存的(OpenAI 自动前缀缓存)同样受益:粘住就是省。
|
||||
//
|
||||
// 表按摘要分 16 片:每个请求都要查一次亲和,全局单锁会成为高并发下的串行点。
|
||||
// 键直接是 64 位摘要(而不是拼接的字符串),请求路径上零分配。
|
||||
|
||||
const (
|
||||
// affinityMax 亲和表上限:超过先清过期,仍超则不再新增(防御性,正常流量到不了)。
|
||||
affinityMax = 8192
|
||||
// affinityPrefix 会话身份摘要取请求文本的前缀字节数。
|
||||
// affinityShards 分片数(2 的幂,取低位做下标)。
|
||||
affinityShards = 16
|
||||
// affinityPerShard 每片的条目上限:超过先清过期,仍满则不再新增(防御性)。
|
||||
affinityPerShard = 8192
|
||||
// affinityPrefix 取请求体的前多少字节参与摘要。
|
||||
affinityPrefix = 512
|
||||
)
|
||||
|
||||
type affinityTable struct {
|
||||
shards [affinityShards]affinityShard
|
||||
}
|
||||
|
||||
type affinityShard struct {
|
||||
mu sync.Mutex
|
||||
m map[string]affinityEntry
|
||||
m map[uint64]affinityEntry
|
||||
}
|
||||
|
||||
type affinityEntry struct {
|
||||
@@ -38,47 +44,86 @@ type affinityEntry struct {
|
||||
}
|
||||
|
||||
func newAffinityTable() *affinityTable {
|
||||
return &affinityTable{m: map[string]affinityEntry{}}
|
||||
t := &affinityTable{}
|
||||
for i := range t.shards {
|
||||
t.shards[i].m = map[uint64]affinityEntry{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *affinityTable) shard(key uint64) *affinityShard {
|
||||
return &t.shards[key&(affinityShards-1)]
|
||||
}
|
||||
|
||||
// get 返回该会话上次应答的渠道;过期条目顺手清理。
|
||||
func (t *affinityTable) get(key string) (uint64, bool) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
e, ok := t.m[key]
|
||||
func (t *affinityTable) get(key uint64) (uint64, bool) {
|
||||
s := t.shard(key)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
e, ok := s.m[key]
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if time.Now().After(e.until) {
|
||||
delete(t.m, key)
|
||||
delete(s.m, key)
|
||||
return 0, false
|
||||
}
|
||||
return e.channelID, true
|
||||
}
|
||||
|
||||
// put 记录(或刷新)会话→渠道的绑定。
|
||||
func (t *affinityTable) put(key string, channelID uint64, ttl time.Duration) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if len(t.m) >= affinityMax {
|
||||
func (t *affinityTable) put(key, channelID uint64, ttl time.Duration) {
|
||||
s := t.shard(key)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.m) >= affinityPerShard {
|
||||
now := time.Now()
|
||||
for k, e := range t.m {
|
||||
for k, e := range s.m {
|
||||
if now.After(e.until) {
|
||||
delete(t.m, k)
|
||||
delete(s.m, k)
|
||||
}
|
||||
}
|
||||
if len(t.m) >= affinityMax {
|
||||
return // 表满且全是活的:放弃本次记录,旧绑定继续服务
|
||||
if len(s.m) >= affinityPerShard {
|
||||
return // 分片满且全是活的:放弃本次记录,旧绑定继续服务
|
||||
}
|
||||
}
|
||||
t.m[key] = affinityEntry{channelID: channelID, until: time.Now().Add(ttl)}
|
||||
s.m[key] = affinityEntry{channelID: channelID, until: time.Now().Add(ttl)}
|
||||
}
|
||||
|
||||
// affinityKey 会话身份:密钥 + 模型 + 请求文本稳定前缀的摘要。
|
||||
func affinityKey(keyID uint64, model, firstText string) string {
|
||||
if len(firstText) > affinityPrefix {
|
||||
firstText = firstText[:affinityPrefix]
|
||||
// len 当前条目数(测试与诊断用)。
|
||||
func (t *affinityTable) len() int {
|
||||
n := 0
|
||||
for i := range t.shards {
|
||||
t.shards[i].mu.Lock()
|
||||
n += len(t.shards[i].m)
|
||||
t.shards[i].mu.Unlock()
|
||||
}
|
||||
h := sha256.Sum256([]byte(firstText))
|
||||
return fmt.Sprintf("%d|%s|%s", keyID, model, hex.EncodeToString(h[:8]))
|
||||
return n
|
||||
}
|
||||
|
||||
// affinityKey 会话身份摘要:密钥 + 模型 + 请求体前 affinityPrefix 字节。
|
||||
// 内联 FNV-1a,避免在热路径上分配哈希器或拼接字符串。
|
||||
func affinityKey(keyID uint64, model string, prefix []byte) uint64 {
|
||||
const (
|
||||
offset64 = 14695981039346656037
|
||||
prime64 = 1099511628211
|
||||
)
|
||||
h := uint64(offset64)
|
||||
mix := func(b byte) {
|
||||
h ^= uint64(b)
|
||||
h *= prime64
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
mix(byte(keyID >> (8 * i)))
|
||||
}
|
||||
for i := 0; i < len(model); i++ {
|
||||
mix(model[i])
|
||||
}
|
||||
if len(prefix) > affinityPrefix {
|
||||
prefix = prefix[:affinityPrefix]
|
||||
}
|
||||
for _, b := range prefix {
|
||||
mix(b)
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -75,7 +76,7 @@ func TestAffinityRespectsCooldown(t *testing.T) {
|
||||
// 预置亲和绑定指向渠道1,随后让渠道1 进运行期冷却
|
||||
var ch1row store.Channel
|
||||
h.db.Where("name = ?", "c1").First(&ch1row)
|
||||
h.gw.aff.put(affinityKey(1, "m1", "会话的第一条消息"), ch1row.ID, 5*time.Minute)
|
||||
h.gw.aff.put(affinityKey(1, "m1", []byte("会话的第一条消息")), ch1row.ID, 5*time.Minute)
|
||||
for i := 0; i < 3; i++ {
|
||||
h.gw.ch.ReportFailure(&ch1row, channel.FailRate, 0)
|
||||
}
|
||||
@@ -85,3 +86,77 @@ func TestAffinityRespectsCooldown(t *testing.T) {
|
||||
t.Fatalf("冷却后亲和应失效并切换渠道2:%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 亲和键:同输入稳定、任一要素不同即不同;请求路径上不分配。
|
||||
func TestAffinityKeyStable(t *testing.T) {
|
||||
prefix := []byte(`{"model":"m1","messages":[{"role":"user","content":"hi"}]}`)
|
||||
a := affinityKey(7, "m1", prefix)
|
||||
if b := affinityKey(7, "m1", prefix); a != b {
|
||||
t.Fatal("同一会话的键必须稳定")
|
||||
}
|
||||
if affinityKey(8, "m1", prefix) == a {
|
||||
t.Fatal("不同密钥应得到不同键")
|
||||
}
|
||||
if affinityKey(7, "m2", prefix) == a {
|
||||
t.Fatal("不同模型应得到不同键")
|
||||
}
|
||||
if affinityKey(7, "m1", []byte("other")) == a {
|
||||
t.Fatal("不同前缀应得到不同键")
|
||||
}
|
||||
// 前缀只有前 affinityPrefix 字节参与:同一个开头、更长的尾部不影响键
|
||||
head := make([]byte, affinityPrefix)
|
||||
for i := range head {
|
||||
head[i] = byte('a' + i%26)
|
||||
}
|
||||
tailA := append(append([]byte{}, head...), []byte("AAAA")...)
|
||||
tailB := append(append([]byte{}, head...), []byte("BBBB")...)
|
||||
if affinityKey(7, "m1", tailA) != affinityKey(7, "m1", tailB) {
|
||||
t.Fatal("超过前缀长度的部分不应影响键")
|
||||
}
|
||||
if affinityKey(7, "m1", head) != affinityKey(7, "m1", tailA) {
|
||||
t.Fatal("尾部长短不同但前 512 字节相同,键应一致")
|
||||
}
|
||||
inner := append([]byte{}, head...)
|
||||
inner[len(inner)-1] = 'z' // 改动前缀内最后一个字节
|
||||
if affinityKey(7, "m1", head) == affinityKey(7, "m1", inner) {
|
||||
t.Fatal("前缀以内的差异必须影响键")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAffinityKeyNoAlloc(t *testing.T) {
|
||||
prefix := []byte(`{"model":"m1"}`)
|
||||
allocs := testing.AllocsPerRun(200, func() { _ = affinityKey(7, "m1", prefix) })
|
||||
if allocs != 0 {
|
||||
t.Fatalf("affinityKey 分配了 %v 次,期望 0", allocs)
|
||||
}
|
||||
}
|
||||
|
||||
// 分片表的过期与并发安全(-race 下验证)。
|
||||
func TestAffinityTableSharded(t *testing.T) {
|
||||
tbl := newAffinityTable()
|
||||
tbl.put(1, 10, 50*time.Millisecond)
|
||||
tbl.put(2, 11, time.Minute)
|
||||
if id, ok := tbl.get(1); !ok || id != 10 {
|
||||
t.Fatalf("get(1) = %d/%v", id, ok)
|
||||
}
|
||||
if tbl.len() != 2 {
|
||||
t.Fatalf("len = %d, 期望 2", tbl.len())
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
if _, ok := tbl.get(1); ok {
|
||||
t.Fatal("过期条目应失效")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 200; j++ {
|
||||
k := uint64(n*1000 + j)
|
||||
tbl.put(k, k, time.Minute)
|
||||
tbl.get(k)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -138,7 +138,7 @@ type sinkHolder struct {
|
||||
// 仅当开关开启且用户为管理员时记录;响应侧以 c.Get("raw_request") 是否非空判断是否需要捕获响应。
|
||||
func (g *Gateway) recordRawRequest(c *gin.Context, u *store.User, body []byte) {
|
||||
if g.logRaw && u.Role == store.RoleAdmin {
|
||||
c.Set("raw_request", string(body))
|
||||
c.Set("raw_request", capRaw(body, rawRequestCap))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -242,9 +241,9 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
}
|
||||
// 会话亲和:上次应答过该会话的渠道优先(候选集已过滤健康/冷却/绑定),
|
||||
// 让上游提示词缓存被再次命中;命中不了(首次/冷却/下线)走加权随机。
|
||||
affKey := g.affinityKeyFor(c)
|
||||
affKey, hasAff := g.affinityKeyFor(c)
|
||||
startIdx := -1
|
||||
if affKey != "" {
|
||||
if hasAff {
|
||||
if chID, ok := g.aff.get(affKey); ok {
|
||||
for i, cand := range cands {
|
||||
if cand.Channel.ID == chID {
|
||||
@@ -318,7 +317,7 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
done, served, retry, st, b := g.proxyOne(c, ch, plan, stream, sink, sh, clientProto)
|
||||
release()
|
||||
if done {
|
||||
if served && affKey != "" {
|
||||
if served && hasAff {
|
||||
g.aff.put(affKey, ch.ID, g.affinityTTL)
|
||||
}
|
||||
return
|
||||
@@ -361,27 +360,27 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
|
||||
g.recordError(c, lastCh, nil, now(), "all_channels_failed")
|
||||
}
|
||||
|
||||
// affinityKeyFor 计算本请求的会话亲和键;亲和关闭或缺少身份要素时返回空。
|
||||
func (g *Gateway) affinityKeyFor(c *gin.Context) string {
|
||||
// affinityKeyFor 计算本请求的会话亲和键;亲和关闭或缺少身份要素时 has=false。
|
||||
func (g *Gateway) affinityKeyFor(c *gin.Context) (key uint64, has bool) {
|
||||
if g.affinityTTL <= 0 {
|
||||
return ""
|
||||
return 0, false
|
||||
}
|
||||
kid, ok := c.Get(CtxKeyID)
|
||||
kID, ok2 := kid.(uint64)
|
||||
if !ok || !ok2 {
|
||||
return ""
|
||||
return 0, false
|
||||
}
|
||||
mn, _ := c.Get("model_name")
|
||||
model, _ := mn.(string)
|
||||
// 亲和键取原始请求体的前缀:每轮重发全量上下文时开头是稳定的(模型、系统提示、
|
||||
// 最早的几条消息),足够区分会话;不必为了它整包解析 JSON。
|
||||
var prefix string
|
||||
var prefix []byte
|
||||
if v, ok := c.Get("req_body"); ok {
|
||||
if raw, ok2 := v.([]byte); ok2 {
|
||||
prefix = string(raw) // affinityKey 内部只取前 512 字节
|
||||
prefix = raw // affinityKey 内部只取前 affinityPrefix 字节
|
||||
}
|
||||
}
|
||||
return affinityKey(kID, model, prefix)
|
||||
return affinityKey(kID, model, prefix), true
|
||||
}
|
||||
|
||||
// streamErrorEvent 把错误构造成客户端协议的 SSE 错误事件(响应头已写出时的唯一交付方式)。
|
||||
@@ -556,7 +555,7 @@ func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, resp *http.R
|
||||
c.Status(http.StatusOK)
|
||||
_, _ = c.Writer.Write(out)
|
||||
if _, ok := c.Get("raw_request"); ok {
|
||||
c.Set("raw_response", string(data)) // 上游原始响应(未转换)
|
||||
c.Set("raw_response", capRaw(data, rawResponseCap)) // 上游原始响应(未转换)
|
||||
}
|
||||
g.finishUsage(c, ch, start, store.UsageStatusSuccess, "")
|
||||
return true, true, false, 0, nil
|
||||
@@ -575,7 +574,7 @@ func (g *Gateway) streamHoldCopy(c *gin.Context, sh *streamHold, ch *store.Chann
|
||||
var rawResp strings.Builder
|
||||
commitRaw := func() {
|
||||
if capture {
|
||||
c.Set("raw_response", rawResp.String())
|
||||
c.Set("raw_response", capRaw([]byte(rawResp.String()), rawResponseCap))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1036,7 +1035,7 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
||||
} else {
|
||||
// 模型在记账时已被删除(请求进行中被下架):无法定价,按 0 记账留痕。
|
||||
// 虚构单价扣用户的钱既不可审计也无法对账。
|
||||
log.Printf("proxy: trace=%s model %q not found at billing time, cost recorded as 0", trace, mn)
|
||||
accessf("proxy: trace=%s model %q not found at billing time, cost recorded as 0", trace, mn)
|
||||
}
|
||||
// 落库的 input_tokens 存输入总量(含缓存读/写),与上游 message_start 口径一致,便于对账展示。
|
||||
in += cacheRead + cacheCreate
|
||||
@@ -1112,7 +1111,7 @@ func (g *Gateway) finishUsage(c *gin.Context, ch *store.Channel, start time.Time
|
||||
})
|
||||
|
||||
// 请求级一行日志:usage_logs 落库是异步的且可能丢,排障时日志是唯一即时依据
|
||||
log.Printf("proxy: trace=%s user=%d key=%d model=%q served=%q channel=%d proto=%s in=%d out=%d r=%d cache_r=%d cache_w=%d(1h=%d) cost=%.6f status=%s http=%d latency=%dms err=%s",
|
||||
accessf("proxy: trace=%s user=%d key=%d model=%q served=%q channel=%d proto=%s in=%d out=%d r=%d cache_r=%d cache_w=%d(1h=%d) cost=%.6f status=%s http=%d latency=%dms err=%s",
|
||||
traceStr, uidVal, kidVal, mn, servedModel, chID, p, in, out, reasoning, cacheRead, cacheCreate, cache1h, cost, status,
|
||||
c.Writer.Status(), latency, errCode)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 原始体会话与访问日志。
|
||||
|
||||
// 原始请求/响应落库的上限:开启 LogRaw 时流式响应可能是整段 SSE(百 KB~MB 级),
|
||||
// 单条日志几十 MB 会让 usage_logs 膨胀、列表查询和备份都变慢。超出部分截断并标注。
|
||||
const (
|
||||
rawRequestCap = 256 << 10 // 256 KiB
|
||||
rawResponseCap = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
// capRaw 把原始体截到 limit,并在末尾标注原始大小。
|
||||
func capRaw(b []byte, limit int) string {
|
||||
if len(b) <= limit {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:limit]) + fmt.Sprintf("\n…(truncated by openteam: %d bytes total)", len(b))
|
||||
}
|
||||
|
||||
// accessLog 访问日志:每个请求一行,直接写 stderr 会在高 QPS 下变成系统调用热点。
|
||||
// 这里套一层 64KB 缓冲 + 每秒 flush,log.Logger 自带互斥,写入只是内存拷贝。
|
||||
var (
|
||||
accessLogOnce sync.Once
|
||||
accessLog *log.Logger
|
||||
)
|
||||
|
||||
// syncBuf 让 bufio.Writer 的写与定时 flush 互斥:
|
||||
// bufio 本身不支持并发访问,而 flush 在独立 goroutine 里跑。
|
||||
type syncBuf struct {
|
||||
mu sync.Mutex
|
||||
w *bufio.Writer
|
||||
}
|
||||
|
||||
func (s *syncBuf) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.w.Write(p)
|
||||
}
|
||||
|
||||
func (s *syncBuf) Flush() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.w.Flush()
|
||||
}
|
||||
|
||||
func initAccessLog() {
|
||||
w := &syncBuf{w: bufio.NewWriterSize(os.Stderr, 64<<10)}
|
||||
accessLog = log.New(w, "", log.LstdFlags)
|
||||
go func() {
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
_ = w.Flush()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// accessf 写一行访问日志(带缓冲,不阻塞请求)。
|
||||
func accessf(format string, args ...any) {
|
||||
accessLogOnce.Do(initAccessLog)
|
||||
accessLog.Printf(format, args...)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCapRaw(t *testing.T) {
|
||||
small := []byte("hello")
|
||||
if got := capRaw(small, 10); got != "hello" {
|
||||
t.Fatalf("未超限应原样返回,实际 %q", got)
|
||||
}
|
||||
big := []byte(strings.Repeat("x", 100))
|
||||
got := capRaw(big, 10)
|
||||
if !strings.HasPrefix(got, strings.Repeat("x", 10)) {
|
||||
t.Fatalf("应保留前 10 字节:%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "truncated by openteam: 100 bytes total") {
|
||||
t.Fatalf("应标注被截断与原始大小:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 两个上限都远小于流式响应可能的体积,且响应上限比请求上限宽(响应通常是更大的那份)
|
||||
func TestRawCapsSane(t *testing.T) {
|
||||
if rawRequestCap <= 0 || rawResponseCap <= 0 {
|
||||
t.Fatal("上限必须为正")
|
||||
}
|
||||
if rawRequestCap > rawResponseCap {
|
||||
t.Fatalf("响应上限(%d)应不小于请求上限(%d)", rawResponseCap, rawRequestCap)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user