mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
fix(gateway): 上下文超窗错误归一化,客户端才能压缩重试
问题:上游返回上下文超窗时,客户端(pi)既不压缩也不重试,只看到一条 普通上游错误。链路两处叠加: 1. 措辞不在客户端识别列表里。pi 靠 @earendil-works/pi-ai 的 OVERFLOW_PATTERNS(25 条正则)判断超窗,而 justworker 返回的是 「请精简对话历史…(Context window is full…)」——与那 25 条一条都不匹配 (最接近的 /context window exceeds limit/i 也不命中,因为措辞是 "is full" 而非 "exceeds limit")。 2. AUTO 链路的每条 tier 错误按 80 字节 OneLine 截断,而 "Context window is full" 这类短诊断词常出现在尾部,正好被按字节切掉(直连路径是 160 才保住)。 修法: - 新增 overflowMarkers 识别超窗措辞(含中文写法),命中时把客户端可见 错误归一化为 context_length_exceeded 前缀——它命中 pi 的 /context[_ ]length[_ ]exceeded/i,超窗因此可被发现并触发压缩重试。 - AUTO 链路每条 tier 错误宽度 80→160,短诊断词不再被截断。 归一化只加前缀,原始诊断信息保留,便于定位是哪一层超窗。 测试:internal/gateway/overflow_err_test.go(5 例,含「标记必须命中 pi 正则」、非超窗不得误标、80 vs 160 宽度的回归对比)。
This commit is contained in:
@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@ -512,21 +513,67 @@ func upstreamErrStatus(err error) int {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
|
||||
// overflowMarkers 匹配上游「上下文超窗」类措辞。
|
||||
//
|
||||
// 上游写法五花八门,且**不在 pi 客户端的识别列表里**。pi 靠
|
||||
// @earendil-works/pi-ai 的 OVERFLOW_PATTERNS 判断超窗并据此触发压缩重试,
|
||||
// 而 justworker 返回的是「请精简对话历史…(Context window is full…)」——
|
||||
// 与那 25 条正则一条都不匹配,于是 pi 既不压缩也不重试,只把它当成一条
|
||||
// 普通上游错误。这里把可识别的超窗措辞归一化成 pi 一定认得的标记。
|
||||
var overflowMarkers = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)context[ _-]?window is full`),
|
||||
regexp.MustCompile(`(?i)context[_ ]length[_ ]exceeded`),
|
||||
regexp.MustCompile(`(?i)exceeds? the context window`),
|
||||
regexp.MustCompile(`(?i)maximum context length`),
|
||||
regexp.MustCompile(`(?i)reduce the length of the messages`),
|
||||
regexp.MustCompile(`(?i)too many tokens`),
|
||||
regexp.MustCompile(`(?i)token limit exceeded`),
|
||||
regexp.MustCompile(`请精简对话历史`),
|
||||
regexp.MustCompile(`上下文(长度)?超(出|限)`),
|
||||
regexp.MustCompile(`对话历史过长`),
|
||||
}
|
||||
|
||||
// overflowCanonical 命中 pi 的 /context[_ ]length[_ ]exceeded/i。
|
||||
const overflowCanonical = "context_length_exceeded"
|
||||
|
||||
// looksLikeOverflow 判断错误文本是否属于上下文超窗。
|
||||
func looksLikeOverflow(s string) bool {
|
||||
for _, re := range overflowMarkers {
|
||||
if re.MatchString(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// clientUpstreamErr collapses upstream failure details into a short message
|
||||
// for the client: per-tier bodies (WAF HTML pages, quota payloads, ...) stay
|
||||
// in rec.Err / the stats API and the server log instead of the response.
|
||||
// Per-tier one-line reasons are kept (quota/cooling skips carry no error body
|
||||
// and are the actionable part); each is capped so HTML dumps can't leak.
|
||||
//
|
||||
// 超窗会被归一化成 overflowCanonical 前缀,见 overflowMarkers 的说明。
|
||||
func clientUpstreamErr(err error) string {
|
||||
log.Printf("[gateway] upstream failure surfaced to client: %v", err)
|
||||
msg := upstreamErrSummary(err)
|
||||
if looksLikeOverflow(err.Error()) || looksLikeOverflow(msg) {
|
||||
return fmt.Sprintf("%s: %s", overflowCanonical, msg)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// upstreamErrSummary 把上游失败压成一行短消息。
|
||||
func upstreamErrSummary(err error) string {
|
||||
var ce *scheduler.ChainErr
|
||||
if errors.As(err, &ce) {
|
||||
parts := make([]string, 0, len(ce.Tiers)+len(ce.Skipped))
|
||||
for _, t := range ce.Tiers {
|
||||
parts = append(parts, types.OneLine(fmt.Sprintf("%s/%s: %v", t.Source, t.Model, t.Err), 80))
|
||||
// 160 而不是 80:短诊断词("Context window is full")常落在尾部,
|
||||
// 80 字节按字节截断正好会把它切掉,超窗就再也认不出来。
|
||||
parts = append(parts, types.OneLine(fmt.Sprintf("%s/%s: %v", t.Source, t.Model, t.Err), 160))
|
||||
}
|
||||
for _, sk := range ce.Skipped {
|
||||
parts = append(parts, types.OneLine(sk, 80))
|
||||
parts = append(parts, types.OneLine(sk, 160))
|
||||
}
|
||||
msg := strings.Join(parts, "; ")
|
||||
if len(msg) > 300 {
|
||||
|
||||
96
internal/gateway/overflow_err_test.go
Normal file
96
internal/gateway/overflow_err_test.go
Normal file
@ -0,0 +1,96 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/scheduler"
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// piOverflowRe 是 @earendil-works/pi-ai 的 OVERFLOW_PATTERNS 中对应的一条。
|
||||
// 归一化标记必须命中它,否则改了等于没改:客户端仍不会压缩重试。
|
||||
var piOverflowRe = regexp.MustCompile(`context[_ ]length[_ ]exceeded`)
|
||||
|
||||
func TestOverflowCanonicalMatchesPiPattern(t *testing.T) {
|
||||
if !piOverflowRe.MatchString(overflowCanonical) {
|
||||
t.Fatalf("归一化标记 %q 不匹配 pi 的超窗正则,客户端仍不会压缩重试", overflowCanonical)
|
||||
}
|
||||
}
|
||||
|
||||
// justworker 的真实措辞:与 pi 现有 25 条正则一条都不匹配,
|
||||
// 所以必须在网关侧归一化,否则超窗对客户端完全不可见。
|
||||
func TestJustworkerOverflowIsNormalized(t *testing.T) {
|
||||
raw := "请精简对话历史…(Context window is full, please reduce the conversation history)…"
|
||||
|
||||
// 前提校验:原始措辞确实不被 pi 认出(否则本用例失去意义)。
|
||||
if piOverflowRe.MatchString(raw) {
|
||||
t.Fatalf("前提不成立:原始措辞本就匹配 pi 正则: %q", raw)
|
||||
}
|
||||
if !looksLikeOverflow(raw) {
|
||||
t.Fatalf("网关应识别出这是超窗: %q", raw)
|
||||
}
|
||||
|
||||
err := &scheduler.ChainErr{Tiers: []scheduler.TierError{{
|
||||
Tier: 1, Source: "justworker", Model: "claude-opus-5", Err: errors.New(raw),
|
||||
}}}
|
||||
|
||||
got := clientUpstreamErr(err)
|
||||
if !piOverflowRe.MatchString(got) {
|
||||
t.Fatalf("归一化后仍不匹配 pi 正则,客户端不会压缩重试: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 直连路径(非 AUTO 链)也要归一化——它走的是同一个入口。
|
||||
func TestDirectPathOverflowIsNormalized(t *testing.T) {
|
||||
got := clientUpstreamErr(errors.New("upstream 400: Context window is full"))
|
||||
if !piOverflowRe.MatchString(got) {
|
||||
t.Fatalf("直连超窗未被归一化: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 非超窗错误不能被加上超窗标记,否则会把普通失败误报成需要压缩。
|
||||
func TestNonOverflowNotNormalized(t *testing.T) {
|
||||
err := &scheduler.ChainErr{Tiers: []scheduler.TierError{{
|
||||
Tier: 1, Source: "x", Model: "y", Err: errors.New("502 Bad Gateway: upstream connect error"),
|
||||
}}}
|
||||
got := clientUpstreamErr(err)
|
||||
if strings.Contains(got, overflowCanonical) {
|
||||
t.Fatalf("普通上游失败被误标为超窗: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "502 Bad Gateway") {
|
||||
t.Fatalf("普通失败的诊断信息应保留: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:短诊断词常落在错误尾部,80 字节截断会正好把它切掉,
|
||||
// 超窗就永远认不出来。宽度放宽后必须保住。
|
||||
func TestOverflowPhraseSurvivesTruncationWidth(t *testing.T) {
|
||||
prefix := strings.Repeat("x", 100)
|
||||
// OneLine 的长度限制作用于整条 "source/model: err",不是只算 err 本身。
|
||||
full := "justworker/claude-opus-5: " + prefix + " Context window is full"
|
||||
|
||||
// 前提:80 字节下短语确实会被切掉(这正是修复前的行为)。
|
||||
if got := types.OneLine(full, 80); strings.Contains(got, "Context window is full") {
|
||||
t.Fatalf("前提不成立:80 字节下短语居然还在: %q", got)
|
||||
}
|
||||
// 160 字节下必须保住,否则超窗识别会被截断悄悄破坏。
|
||||
if got := types.OneLine(full, 160); !strings.Contains(got, "Context window is full") {
|
||||
t.Fatalf("160 字节应保住尾部短诊断词: %q", got)
|
||||
}
|
||||
|
||||
err := &scheduler.ChainErr{Tiers: []scheduler.TierError{{
|
||||
Tier: 1, Source: "justworker", Model: "claude-opus-5",
|
||||
Err: errors.New(prefix + " Context window is full"),
|
||||
}}}
|
||||
|
||||
got := clientUpstreamErr(err)
|
||||
if !strings.Contains(strings.ToLower(got), "context window is full") {
|
||||
t.Fatalf("宽度 160 应保住尾部短诊断词,实际: %q", got)
|
||||
}
|
||||
if !piOverflowRe.MatchString(got) {
|
||||
t.Fatalf("归一化标记丢失: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user