diff --git a/internal/agent/api/context_window_test.go b/internal/agent/api/context_window_test.go new file mode 100644 index 0000000..9344808 --- /dev/null +++ b/internal/agent/api/context_window_test.go @@ -0,0 +1,39 @@ +package api + +import "testing" + +// 回归(2026-09-19):模型名推断不出窗口时,此前会静默退回 32768。 +// 生产实际配的是 core.llm.model="AUTO",于是整个预算按 32768 算, +// 而该源真实窗口是 1M(实测 990,034 token 的 prompt 通过)—— 小 30 倍。 +// +// 这里钉死两点:① deepseek-v4 系能推断出真实窗口;② AUTO 仍走兜底 +// (兜底值本身不猜大:猜大会让请求直接撞上游 400)。 +func TestModelContextWindow(t *testing.T) { + cases := map[string]int{ + "deepseek/deepseek-v4.1-flash": 1048576, + "deepseek-v4-flash": 1048576, + "deepseek-chat": 65536, + "claude-opus-5": 100000, + "gpt-4-turbo": 128000, + "llama-3-70b": 8192, + "AUTO": 32768, // 推断不出 → 兜底,靠 context_window 覆盖 + } + for model, want := range cases { + if got := ModelContextWindow(model); got != want { + t.Errorf("ModelContextWindow(%q) = %d, want %d", model, got, want) + } + } +} + +// 显式声明的 context_window 必须覆盖模型名推断 —— 这是部署方绕开 +// “AUTO 推断不出窗口”的唯一手段,不能反过来被推断值盖掉。 +func TestExplicitContextWindowWinsOverInference(t *testing.T) { + p := &LuaAdaptedProvider{cfg: BaseConfig{Model: "AUTO", ContextWindow: 1048576}} + if got := p.MaxContextTokens(); got != 1048576 { + t.Errorf("显式 context_window 未生效:got %d, want 1048576", got) + } + p2 := &LuaAdaptedProvider{cfg: BaseConfig{Model: "AUTO"}} + if got := p2.MaxContextTokens(); got != 32768 { + t.Errorf("未声明时应走推断兜底:got %d, want 32768", got) + } +} diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index 0c5f429..858d1d3 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -8,9 +8,9 @@ import ( "fmt" "io" "log" - "os" "net" "net/http" + "os" "sort" "strings" "sync" @@ -260,11 +260,21 @@ func ProviderSupportsAudio(p Provider) bool { return false } +// defaultInferredContextWindow 是模型名无法推断窗口时的兜底。 +// +// 32768 是个保守值,但它属于**静默降级**:模型名写 AUTO(网关自己选上游)时 +// ModelContextWindow 匹配不到任何分支,内核就会拿着一份比真实小得多的窗口 +// 去算全部预算(实测:deepseek-v4.1-flash 能吞 990,034 token,而预算按 32768 算)。 +// 因此推断不出来时留一条日志,并让部署方用 per-source context_window 显式声明。 +const defaultInferredContextWindow = 32768 + // ModelContextWindow 返回模型的最大上下文窗口(token 数) // 标称窗口 ≠ 有效窗口:接近满时注意力涣散,调用方应取 70-80% 为目标利用率 func ModelContextWindow(model string) int { model = strings.ToLower(model) switch { + case strings.Contains(model, "deepseek-v4") || strings.Contains(model, "deepseek-v3"): + return 1048576 case strings.Contains(model, "deepseek-r1") || strings.Contains(model, "deepseek-chat"): return 65536 case strings.Contains(model, "gpt-4") && (strings.Contains(model, "turbo") || strings.Contains(model, "mini") || strings.Contains(model, "omni")): @@ -296,7 +306,13 @@ func ModelContextWindow(model string) int { case strings.Contains(model, "moonshot") || strings.Contains(model, "kimi"): return 131072 default: - return 32768 + // 模型名推断不出窗口(如 "AUTO"):不要静静退回一个比真实小得多的值。 + // 报一行日志,让“窗口被低估”这件事可见;部署方用 per-source + // core.llm.sources..context_window 声明真实值即可覆盖。 + log.Printf("[provider] 模型 %q 无法推断上下文窗口,回退 %d;"+ + "若真实窗口更大,请设置 core.llm.sources..context_window", + model, defaultInferredContextWindow) + return defaultInferredContextWindow } } @@ -1268,8 +1284,8 @@ func getFloat(m map[string]interface{}, key string) float64 { // ToolOutput 是工具 handler 返回的结构化结果,支持多模态内容。 // 返回 string 时等价于 ToolOutput{Text: result}。 type ToolOutput struct { - Text string `json:"text"` // LLM 看到的文字描述 - Blocks []ContentBlock `json:"blocks,omitempty"` // 附加的多模态块(image_url/audio_url),追加到 tool message + Text string `json:"text"` // LLM 看到的文字描述 + Blocks []ContentBlock `json:"blocks,omitempty"` // 附加的多模态块(image_url/audio_url),追加到 tool message } func (t ToolOutput) String() string { return t.Text } diff --git a/internal/agent/core/tokenbudget.go b/internal/agent/core/tokenbudget.go index 422cf24..c1ddef6 100644 --- a/internal/agent/core/tokenbudget.go +++ b/internal/agent/core/tokenbudget.go @@ -8,12 +8,12 @@ import ( // TokenBudget 上下文 token 预算分配结果 type TokenBudget struct { - MaxContext int // 模型窗口上限 - TargetUsage int // 目标使用量(max * utilizationRate) - FixedTokens int // 固定部分(system prompt base + tools + rules) - MemoryTokens int // memory context 可用预算 - ContextTokens int // 上下文事件可用预算 - Reserved int // 预留(response 空间) + MaxContext int // 模型窗口上限 + TargetUsage int // 目标使用量(受 maxTargetTokens 与 utilizationRate 共同约束) + FixedTokens int // 固定部分(system prompt base + tools + rules) + MemoryTokens int // memory context 可用预算 + ContextTokens int // 上下文事件可用预算 + Reserved int // 预留(response 空间) } // EstimateTokens 粗略估算 token 数 @@ -34,6 +34,16 @@ func EstimateTokens(text string) int { return t } +// maxTargetTokens 是**有效工作区间**的上限(不是模型窗口)。 +// +// 为什么窗口 1M 却不能按 800K 干活:标称窗口 ≠ 有效窗口。接近满窗口时注意力 +// 明显涣散、成本与延迟也随 prompt 线性上升。该源(llmsproxy)实测 990,034 token +// 仍能返回,但 600K 才是它的最优工作区间 —— 超过这个量级,回答质量与延迟都不划算。 +// +// 因此把「窗口上限」(判断请求会不会被上游拒)与「工作区间」(分配记忆/历史预算) +// 分开:前者由 provider.MaxContextTokens() 给,后者封顶在这里。 +const maxTargetTokens = 600000 + // ComputeTokenBudget 计算各部分的 token 预算 // utilizationRate 为目标窗口利用率(0.0-1.0),预留 1-utilizationRate 给 response // 固定部分优先保障,剩余预算 1:2 分配给 memory context 和 context events @@ -45,7 +55,14 @@ func ComputeTokenBudget(provider api.Provider, systemPromptBase string) TokenBud utilizationRate := 0.8 targetUsage := int(float64(maxCtx) * utilizationRate) + // 窗口很大(如 1M)时不要把 80% 当成工作面:按 maxTargetTokens 封顶。 + if targetUsage > maxTargetTokens { + targetUsage = maxTargetTokens + } reserved := maxCtx - targetUsage + if reserved < 0 { + reserved = 0 + } fixedTokens := EstimateTokens(systemPromptBase) diff --git a/internal/agent/core/tokenbudget_test.go b/internal/agent/core/tokenbudget_test.go new file mode 100644 index 0000000..0d4d5b1 --- /dev/null +++ b/internal/agent/core/tokenbudget_test.go @@ -0,0 +1,51 @@ +package core + +import ( + "context" + "testing" + + agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" +) + +// windowProvider 是只声明窗口的 Provider 桩(预算是纯函数,不需要真推理)。 +type windowProvider struct{ n int } + +func (windowProvider) Name() string { return "window-stub" } +func (windowProvider) Chat(context.Context, *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) { + return nil, nil +} +func (windowProvider) ChatStream(context.Context, *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) { + return nil, nil +} +func (p windowProvider) MaxContextTokens() int { return p.n } + +// 回归(2026-09-19):窗口声明为 1M 后,工作面不能被 80% 带到 838K —— +// 该源最优区间是 600K,超过就只剩成本与延迟。 +func TestComputeTokenBudgetCapsTargetAtWorkingBand(t *testing.T) { + const sys = "系统提示词" + b := ComputeTokenBudget(windowProvider{1048576}, sys) + + if b.MaxContext != 1048576 { + t.Errorf("MaxContext 应保留真实窗口:%d", b.MaxContext) + } + if b.TargetUsage != 600000 { + t.Errorf("TargetUsage 应封顶在 600K(最优区间),实际 %d", b.TargetUsage) + } + + avail := 600000 - EstimateTokens(sys) + if b.MemoryTokens != avail/3 { + t.Errorf("MemoryTokens=%d want %d", b.MemoryTokens, avail/3) + } + if b.MemoryTokens+b.ContextTokens != avail { + t.Errorf("两部分应恰好用完可用预算:%d + %d != %d", b.MemoryTokens, b.ContextTokens, avail) + } +} + +// 封顶只作用于“窗口远大于工作区间”的情形;普通窗口仍按 80% 走, +// 不能让这条约束把小窗口的预算也一起改掉。 +func TestComputeTokenBudgetSmallWindowUnchanged(t *testing.T) { + b := ComputeTokenBudget(windowProvider{32768}, "x") + if b.TargetUsage != 26214 { + t.Errorf("小窗口应仍取 80%%:got %d want %d", b.TargetUsage, 26214) + } +} diff --git a/internal/config/registry.go b/internal/config/registry.go index fe98223..3a6aa3f 100644 --- a/internal/config/registry.go +++ b/internal/config/registry.go @@ -186,6 +186,15 @@ var sourceFieldDefs = []struct { {"adapter", "string", "适配器"}, {"adapter_path", "string", "适配器路径"}, {"max_concurrent", "int", "并发上限"}, + // context_window 必须显式声明:它已被 readInt 读进 LLMSource 并透传到 provider, + // 但早先没进这张表 ⇒ WebUI 里看不见、也改不了,内核就一直在用 + // ModelContextWindow(model) 的推断值。模型名是 AUTO 时那个函数匹配不到任何 + // 分支、掉进 default 32768,而真实窗口是 1M(实测 990,034 token 的 prompt 通过) + // ⇒ 整个预算比真实小 30 倍。声明后它才是个可配的挡位。 + // + // 注:per-source 的 max_tokens 不在这里—— LLMSource 没有该字段(输出上限 + // 是全局 core.llm.max_tokens)。别加一个读不到的旋钮。 + {"context_window", "int", "上下文窗口(token;留空按模型名推断)"}, {"priority", "int", "AUTO 优先级(大者优先)"}, {"vision", "bool", "支持图片"}, {"audio", "bool", "支持音频"},