diff --git a/backend/internal/app/cloud_agent.go b/backend/internal/app/cloud_agent.go index a0fa597a7..2f994c2a9 100644 --- a/backend/internal/app/cloud_agent.go +++ b/backend/internal/app/cloud_agent.go @@ -456,6 +456,10 @@ func (s *Service) CreateCloudAgentRun(userID string, req CloudAgentRequest, pare state := cloudAgentState{Version: 1, Request: req, ParentID: parentID, Fingerprint: fingerprint, CreativeAnchor: creativeAnchor, Plan: inheritedPlan, Skills: skillSnapshots, Profile: profile, Policy: policy} canonical := cloudAgentCanonicalFor(system, history, req.Prompt, req, len(profile.Layers) > 0) s.attachCloudAgentLessons(&canonical, userID, req.Prompt) + // 必须登记进 state.Policy(值拷贝):state 才是随任务持久化、被运行期读取的那份, + // 在这里改局部 policy 不会生效。登记之后压力读数的"系统提示分段"才能把 memory 摊开, + // 并与 system 桶合计对齐。 + cloudAgentRecordMemorySegment(&state.Policy, canonical.SystemPrompt) canonical.PromptCacheKey = cloudAgentPromptCacheKey(req.CanvasID, canonical.SystemPrompt) attachCloudAgentPlan(&canonical, inheritedPlan) input := map[string]any{"mode": "text", "prompt": req.Prompt, "textHistory": history, "textOptions": map[string]any{"stream": true, "thinking": cloudAgentReasoningEnabled(policy.ReasoningMode)}, "cloudAgent": state, diff --git a/backend/internal/app/cloud_agent_context_budget.go b/backend/internal/app/cloud_agent_context_budget.go index 3d86031f3..0e96e5b42 100644 --- a/backend/internal/app/cloud_agent_context_budget.go +++ b/backend/internal/app/cloud_agent_context_budget.go @@ -22,15 +22,22 @@ type cloudAgentContextBudget struct { InputBudgetTokens int CompactAtTokens int Source string + // Configured 说明这份预算里的窗口是不是"模型自己声明的值":false 表示没有解析到 + // 可用窗口、上面几个数都来自兜底默认值。压力读数据此决定要不要给出窗口占用率—— + // 拿默认窗口冒充真实能力(例如给未声明窗口的模型算一个"占满 80%")会误导排查。 + Configured bool } func defaultCloudAgentContextBudget() cloudAgentContextBudget { - return cloudAgentContextBudgetFor(defaultCloudAgentContextWindowTokens, defaultCloudAgentMaxOutputTokens, "default") + budget := cloudAgentContextBudgetFor(defaultCloudAgentContextWindowTokens, defaultCloudAgentMaxOutputTokens, "default") + budget.Configured = false + return budget } func cloudAgentContextBudgetFor(contextWindow, maxOutput int, source string) cloudAgentContextBudget { + configured := true if contextWindow < minCloudAgentContextWindowTokens || contextWindow > maxCloudAgentContextWindowTokens { - contextWindow = defaultCloudAgentContextWindowTokens + contextWindow, configured = defaultCloudAgentContextWindowTokens, false } if maxOutput < 256 || maxOutput >= contextWindow { maxOutput = min(defaultCloudAgentMaxOutputTokens, contextWindow/4) @@ -50,6 +57,7 @@ func cloudAgentContextBudgetFor(contextWindow, maxOutput int, source string) clo InputBudgetTokens: inputBudget, CompactAtTokens: max(1_024, inputBudget*85/100), Source: source, + Configured: configured, } } @@ -88,15 +96,31 @@ func (s *Service) cloudAgentContextBudgetForRequest(req CloudAgentRequest) cloud return defaultCloudAgentContextBudget() } if req.ChannelID != "" && req.ChannelModelKey != "" { - if channelModel, err := s.repo.ChannelModelByKey(req.ChannelID, req.ChannelModelKey); err == nil { - if capability, err := normalizedChannelModelCapability(channelModel); err == nil && capability != nil && capability.Text != nil { - return cloudAgentContextBudgetFor(capability.Text.ContextWindowTokens, capability.Text.MaxOutputTokens, "channel-model") - } + if capability := s.cloudAgentChannelTextCapability(req); capability != nil { + return cloudAgentContextBudgetFor(capability.ContextWindowTokens, capability.MaxOutputTokens, "channel-model") } } return defaultCloudAgentContextBudget() } +// cloudAgentChannelTextCapability resolves the text capability of the channel +// model this turn will actually run on. Logical models have no single channel +// model, so they return nil and keep the route-intersection path. +func (s *Service) cloudAgentChannelTextCapability(req CloudAgentRequest) *TextCapabilityConfig { + if s == nil || s.repo == nil || req.ChannelID == "" || req.ChannelModelKey == "" { + return nil + } + channelModel, err := s.repo.ChannelModelByKey(req.ChannelID, req.ChannelModelKey) + if err != nil { + return nil + } + capability, err := normalizedChannelModelCapability(channelModel) + if err != nil || capability == nil || capability.Text == nil { + return nil + } + return capability.Text +} + // cloudAgentEstimatedTokens is deliberately conservative for non-ASCII text. // It is a planning estimate, not a provider tokenizer; underestimation would // make a request fail only after reaching an upstream provider. diff --git a/backend/internal/app/cloud_agent_context_compaction_test.go b/backend/internal/app/cloud_agent_context_compaction_test.go index e5071a43d..3efaae64e 100644 --- a/backend/internal/app/cloud_agent_context_compaction_test.go +++ b/backend/internal/app/cloud_agent_context_compaction_test.go @@ -169,8 +169,11 @@ func TestCloudAgentCompactionResumesStepLoopWithCheckpoint(t *testing.T) { map[string]any{"role": "user", "content": "把第 4 行补上光影"}, map[string]any{"role": "assistant", "content": "好的,先看一下现有配色"}, ) + lastTaskID, lastEstimate := state.LastStepTaskID, state.LastStepEstimate + state.TokenAnchor = &cloudAgentTokenAnchor{TaskID: lastTaskID, Step: state.Step, InputTokens: 10000, EstimatedTokens: lastEstimate, Accepted: true} run = saveCloudAgentCompactionState(t, s, run, &state) stepBefore := state.Step + pressureCount := len(cloudAgentCompactionEvents(t, s, run, "context_pressure")) if requested, err := s.cloudAgentRequestCompaction(run, &state, budget, budget.CompactAtTokens); err != nil || !requested { t.Fatalf("没有触发压缩: %v", err) } @@ -185,6 +188,12 @@ func TestCloudAgentCompactionResumesStepLoopWithCheckpoint(t *testing.T) { if persisted.Step != stepBefore { t.Fatalf("压缩调用占掉了步数: %d → %d", stepBefore, persisted.Step) } + if persisted.LastStepTaskID != lastTaskID || persisted.LastStepEstimate != lastEstimate || persisted.TokenAnchor == nil || !persisted.TokenAnchor.Accepted { + t.Fatalf("压缩调用不得覆盖普通模型步骤和锚点: last=%s estimate=%d anchor=%+v", persisted.LastStepTaskID, persisted.LastStepEstimate, persisted.TokenAnchor) + } + if got := len(cloudAgentCompactionEvents(t, s, run, "context_pressure")); got != pressureCount { + t.Fatalf("压缩任务不得发普通模型请求读数: %d → %d", pressureCount, got) + } task, err := s.repo.TaskForUser("user", persisted.ActiveTaskID) if err != nil { t.Fatal(err) diff --git a/backend/internal/app/cloud_agent_context_pressure.go b/backend/internal/app/cloud_agent_context_pressure.go new file mode 100644 index 000000000..474f6943f --- /dev/null +++ b/backend/internal/app/cloud_agent_context_pressure.go @@ -0,0 +1,296 @@ +package app + +import ( + "encoding/json" + "math" + "unicode" + "unicode/utf8" +) + +// cloudAgentContextPressure 是"这次请求还能装多少输入"的读数。 +// 消费方(上下文计量弹窗、运行诊断)直接读这份结构,不自己再算一遍。 +type cloudAgentContextPressure struct { + EstimatedInputTokens int `json:"estimatedInputTokens"` + ContextWindowTokens int `json:"contextWindowTokens"` + ReservedOutputTokens int `json:"reservedOutputTokens"` + UsableInputTokens int `json:"usableInputTokens"` + PressureRatio float64 `json:"pressureRatio"` + SourceBytes int `json:"sourceBytes"` + PromptChars int `json:"promptChars"` + PromptLimitChars int `json:"promptLimitChars"` + ModelLimitConfigured bool `json:"modelLimitConfigured"` + Estimate bool `json:"estimate"` + // 三个窗口口径字段说明"这条读数按哪个算式算出来":OverheadTokens 是工具 schema / + // 协议包装 / 兜底轮的比例预留,InputBudgetTokens 才是真正可供输入使用的额度。 + OverheadTokens int `json:"overheadTokens,omitempty"` + InputBudgetTokens int `json:"inputBudgetTokens,omitempty"` + BudgetSource string `json:"budgetSource,omitempty"` + // CompactAtTokens 是上游既有的"可重读正文就地裁剪"触发线(输入预算的 85%,见 + // compactCloudAgentContext)。它只是给读数一个参照刻度,不是本读数触发的动作。 + CompactAtTokens int `json:"compactAtTokens,omitempty"` +} + +// cloudAgentProjectedInputTokens 合成"下一步输入 token"的权威读数: +// provider 锚点(模型自己的分词器读数)+ 本地估算的有符号增量;没有可用锚点时退回纯估算。 +// 增量带符号是刻意的:上下文被裁剪(图片移出、正文卸载)后本地估算会变小, +// 投影也必须跟着变小,否则读数只会单调上涨。 +func cloudAgentProjectedInputTokens(pressure cloudAgentContextPressure, state *cloudAgentRuntime) (int, string) { + if state == nil || state.TokenAnchor == nil || !state.TokenAnchor.Accepted { + return pressure.EstimatedInputTokens, "estimate" + } + anchor := state.TokenAnchor + delta := pressure.EstimatedInputTokens - anchor.EstimatedTokens + return max(0, int(anchor.InputTokens)+delta), "provider" +} + +// cloudAgentContextPressurePayload 把读数摊成事件载荷。 +// +// 两条硬规则(消费方不必猜): +// 1. 两个量纲各自命名、互不覆盖:本地估算描述**下一次**请求(readingScope=next_request / +// estimateMethod=local_v1),上游实测量的是**上一次**请求(providerMeasurementScope= +// previous_request),两者不能画成同一条曲线。 +// 2. 没有解析到模型自己声明的窗口时不下发窗口字段与占用率:宁可不给百分比, +// 也不要拿兜底默认窗口编一个看起来很像真的"已占用 80%"。 +func cloudAgentContextPressurePayload(pressure cloudAgentContextPressure, state *cloudAgentRuntime, actual ...canonicalAgentRequest) map[string]any { + payload := map[string]any{ + "estimatedInputTokens": pressure.EstimatedInputTokens, + "sourceBytes": pressure.SourceBytes, + "promptChars": pressure.PromptChars, + "estimate": pressure.Estimate, + // provider usage measures the previous request; the estimate describes + // the next request. Consumers must not draw them as one series. + "readingScope": "next_request", "estimateMethod": "local_v1", + // 快照版本与相位:读数属于"下一次请求发出之前"的测量。 + "schemaVersion": 2, "phase": "before_request", + } + if pressure.PromptLimitChars > 0 { + payload["promptLimitChars"] = pressure.PromptLimitChars + } + payload["modelLimitConfigured"] = pressure.ModelLimitConfigured + if pressure.ModelLimitConfigured { + payload["contextWindowTokens"] = pressure.ContextWindowTokens + payload["reservedOutputTokens"] = pressure.ReservedOutputTokens + payload["usableInputTokens"] = pressure.UsableInputTokens + payload["inputBudgetTokens"] = pressure.InputBudgetTokens + payload["overheadTokens"] = pressure.OverheadTokens + payload["compactAtTokens"] = pressure.CompactAtTokens + payload["budgetSource"] = firstNonEmpty(pressure.BudgetSource, "channel-model") + if pressure.UsableInputTokens > 0 { + payload["pressureRatio"] = pressure.PressureRatio + } + } + if state == nil { + return payload + } + // 上游实测锚点:provider 用模型自己的分词器报出的 prompt 规模,是权威读数。 + projectedTokens, tokenSource := cloudAgentProjectedInputTokens(pressure, state) + if anchor := state.TokenAnchor; anchor != nil { + payload["anchorStep"] = anchor.Step + payload["anchor"] = map[string]any{ + "id": anchor.TaskID, "valid": anchor.Accepted, "ageSteps": max(0, state.Step-anchor.Step), + } + if anchor.Accepted { + payload["pressureTokens"] = anchor.InputTokens + payload["providerMeasurementScope"] = "previous_request" + payload["providerUsage"] = map[string]any{ + "inputTokens": anchor.InputTokens, "cacheReadTokens": anchor.CachedTokens, + "uncachedInputTokens": max(0, anchor.InputTokens-anchor.CachedTokens), + "outputTokens": anchor.OutputTokens, + } + // anchorDeltaTokens 是"本地估算相对锚点那一步的增量",与投影的分母无关: + // 消费方拿它解释"较锚点 +N",不能改成"投影 − 估算"。 + payload["anchorDeltaTokens"] = pressure.EstimatedInputTokens - anchor.EstimatedTokens + if anchor.EstimatedTokens > 0 { + payload["tokenScale"] = math.Round(float64(anchor.InputTokens)/float64(anchor.EstimatedTokens)*10000) / 10000 + } + tokenSource = "provider" + } else if anchor.RejectReason != "" { + payload["anchorRejected"] = anchor.RejectReason + } + } + payload["projectedTokens"] = projectedTokens + payload["tokenSource"] = tokenSource + // 两种量纲各自命名,消费方不需要靠 tokenSource 猜哪个数字属于哪把尺子。 + payload["measurementSource"] = tokenSource + payload["normalizedInputTokens"] = pressure.EstimatedInputTokens + if tokenSource == "provider" { + payload["normalizedInputTokens"] = state.TokenAnchor.InputTokens + } + payload["projectedNextInputTokens"] = projectedTokens + if pressure.UsableInputTokens > 0 && tokenSource == "provider" { + payload["projectedPressureRatio"] = math.Min(9.99, float64(projectedTokens)/float64(pressure.UsableInputTokens)) + } + payload["breakdown"] = cloudAgentContextBreakdownPayload(state, actual...) + return payload +} + +// cloudAgentContextBreakdownPayload 报"这次要发出去的请求被谁占着":三个 canonical 桶 +// 加上编译进系统提示的各分段,用的是与占用读数同一把估算尺子。 +// 桶合计与 canonical 总量的差是协议外壳(tool choice、缓存键),单独报而不摊进任何一个桶。 +func cloudAgentContextBreakdownPayload(state *cloudAgentRuntime, actual ...canonicalAgentRequest) map[string]any { + canonical := state.Canonical + if len(actual) > 0 { + canonical = actual[0] + } + system := []byte(canonical.SystemPrompt) + tools, _ := json.Marshal(canonical.Tools) + messages, _ := json.Marshal(canonical.Messages) + whole, _ := json.Marshal(canonical) + systemTokens, toolTokens, messageTokens := estimateCloudAgentTokens(system), estimateCloudAgentTokens(tools), estimateCloudAgentTokens(messages) + // 构成永远由本地估算给出;已有上游实测锚点时再给一份按锚点比例换算的读数, + // 让展示口径与 provider 的计数同尺度(原值不覆盖)。 + scale := 1.0 + if anchor := state.TokenAnchor; anchor != nil && anchor.Accepted && anchor.EstimatedTokens > 0 { + scale = float64(anchor.InputTokens) / float64(anchor.EstimatedTokens) + } + scaled := func(tokens int) int { return int(math.Round(float64(tokens) * scale)) } + var systemSegments []cloudAgentContextSegment + if segments := state.Policy.SystemSegments; len(segments) > 0 { + // 分段必须把 system 桶填满:编译之外拼接的块(个人记忆)由 + // cloudAgentRecordMemorySegment 登记,剩余零头(标题、分隔与拼接文本)归入"其它"。 + // 否则分段合计会小于 system 桶,看起来像少算了一截。 + systemSegments = append([]cloudAgentContextSegment{}, segments...) + accountedBytes, accountedTokens := 0, 0 + for _, segment := range systemSegments { + accountedBytes += segment.Bytes + accountedTokens += segment.Tokens + } + if remainder := len(system) - accountedBytes; remainder > 0 { + systemSegments = append(systemSegments, cloudAgentContextSegment{ + Key: "other", Label: "其它(标题与拼接)", Bytes: remainder, + Tokens: max(0, systemTokens-accountedTokens), + }) + } + // 逐段向上取整不满足可加性(Σ⌈ascii/4⌉ ≥ ⌈Σascii/4⌉),所以 system 桶的 token + // 口径直接取分段合计:桶与分段是同一份分解,消费方会拿分段算占比, + // 两边差几 token 就会显示成"分段占了 101%"。 + systemTokens = 0 + for index := range systemSegments { + systemSegments[index].ScaledTokens = scaled(systemSegments[index].Tokens) + systemTokens += systemSegments[index].Tokens + } + } + buckets := []map[string]any{ + {"key": "system", "label": "系统提示(含画布摘要)", "bytes": len(system), "tokens": systemTokens, "scaledTokens": scaled(systemTokens)}, + {"key": "tools", "label": "工具 schema", "bytes": len(tools), "tokens": toolTokens, "scaledTokens": scaled(toolTokens)}, + {"key": "messages", "label": "会话消息(含工具结果)", "bytes": len(messages), "tokens": messageTokens, "scaledTokens": scaled(messageTokens)}, + } + bucketBytes := len(system) + len(tools) + len(messages) + breakdown := map[string]any{ + "totalBytes": len(whole), "totalTokens": estimateCloudAgentTokens(whole), + "bucketBytes": bucketBytes, "bucketTokens": systemTokens + toolTokens + messageTokens, + "envelopeBytes": max(0, len(whole)-bucketBytes), + "buckets": buckets, + "tokenScale": math.Round(scale*10000) / 10000, + "scaledTotalTokens": scaled(estimateCloudAgentTokens(whole)), + } + if len(systemSegments) > 0 { + breakdown["systemSegments"] = systemSegments + } + return breakdown +} + +// estimateCloudAgentTokens 是计量用的本地估算:ASCII 按 4 字节 1 token,CJK 等非 ASCII +// 按 1 字符 1 token 计,数值上刻意保守(CJK 创作内容偏多)。 +// +// 它与同文件的 cloudAgentEstimatedTokens 不是同一把尺子,也不要合并:后者是 +// **请求准入**的判据(非 ASCII 按 1.5 token/字符,宁可高估也不能把装不下的请求放出去), +// 这里是**读数与锚点**的基准——锚点的采信区间(0.5×–2×)与 tokenScale 都是照着这把尺子 +// 标定的,换尺子等于把标定作废。它只是压力展示,既不是计费 token 数, +// 也不能替代上游 tokenizer。 +func estimateCloudAgentTokens(value []byte) int { + if len(value) == 0 { + return 0 + } + asciiUnits, nonASCII := 0, 0 + for len(value) > 0 { + r, size := utf8.DecodeRune(value) + if r == utf8.RuneError && size == 1 { + asciiUnits++ + value = value[1:] + continue + } + value = value[size:] + if r <= unicode.MaxASCII { + asciiUnits++ + } else { + nonASCII++ + } + } + return int(math.Ceil(float64(asciiUnits)/4.0)) + nonASCII +} + +// cloudAgentContextPressure 解析"这次请求能用多少输入"。 +// +// 没有解析到真实窗口时 UsableInputTokens 保持 0 且 ModelLimitConfigured=false, +// 消费方据此显示"未配置模型上限"而不是编一个占用率。 +func (s *Service) cloudAgentContextPressure(canonical canonicalAgentRequest, prompt string, request CloudAgentRequest) cloudAgentContextPressure { + raw, _ := json.Marshal(canonical) + pressure := cloudAgentContextPressure{ + EstimatedInputTokens: estimateCloudAgentTokens(raw), + SourceBytes: len(raw), + PromptChars: utf8.RuneCountInString(prompt), + Estimate: true, + } + // 提示词字符上限只来自渠道模型能力(逻辑模型没有单一渠道模型,保持 0 = 不声明)。 + if text := s.cloudAgentChannelTextCapability(request); text != nil { + pressure.PromptLimitChars = text.References.PromptMaxChars + } + budget := s.cloudAgentContextBudgetForRequest(request) + if !budget.Configured { + return pressure + } + pressure.ModelLimitConfigured = true + pressure.ContextWindowTokens = budget.ContextWindowTokens + pressure.ReservedOutputTokens = budget.MaxOutputTokens + pressure.OverheadTokens = budget.OverheadTokens + pressure.InputBudgetTokens = budget.InputBudgetTokens + pressure.CompactAtTokens = budget.CompactAtTokens + pressure.BudgetSource = budget.Source + // 展示口径与请求准入判据共用同一份输入预算:否则会出现"界面 79%、后台按 82% 拒绝"。 + pressure.UsableInputTokens = budget.InputBudgetTokens + if pressure.UsableInputTokens > 0 { + pressure.PressureRatio = math.Min(9.99, float64(pressure.EstimatedInputTokens)/float64(pressure.UsableInputTokens)) + } + return pressure +} + +// canonicalAgentRequestFromInput 从已落库的任务输入里取回"实际发出去的那份 canonical": +// 压力读数与锚点都必须对着真实信封,而不是内存里另算一份可能与准入改写结果不同的副本。 +func canonicalAgentRequestFromInput(input map[string]any) (canonicalAgentRequest, bool) { + requests, ok := input["agentRequests"].(map[string]any) + if !ok { + return canonicalAgentRequest{}, false + } + if requests["canonical"] == nil { + return canonicalAgentRequest{}, false + } + raw, err := json.Marshal(requests["canonical"]) + if err != nil { + return canonicalAgentRequest{}, false + } + var canonical canonicalAgentRequest + if json.Unmarshal(raw, &canonical) != nil { + return canonical, false + } + return canonical, true +} + +// cloudAgentNoteContextWindowResolved 在"窗口从未确认变为已确认"时落一条 context_transition。 +// +// 这一步只在读数口径变化时发生(例如早先拿不到模型能力、后来解析到了窗口):真实上下文 +// 没有变小,消费方必须能把它标成"模型窗口已识别",否则一识别就被画成上下文骤降。 +func cloudAgentNoteContextWindowResolved(runID string, state *cloudAgentRuntime, pressure cloudAgentContextPressure) { + if state == nil || runID == "" || !pressure.ModelLimitConfigured || state.ContextWindowKnown { + return + } + state.ContextWindowKnown = true + state.event(runID, "context_transition", map[string]any{ + "kind": "window_resolved", "reason": "window_resolved", + "after": map[string]any{ + "contextWindowTokens": pressure.ContextWindowTokens, "usableInputTokens": pressure.UsableInputTokens, + "source": firstNonEmpty(pressure.BudgetSource, "channel-model"), + }, + "text": "模型窗口已识别:读数改用窗口口径,这不是上下文变少", + }) +} diff --git a/backend/internal/app/cloud_agent_context_pressure_test.go b/backend/internal/app/cloud_agent_context_pressure_test.go new file mode 100644 index 000000000..8cc855a1b --- /dev/null +++ b/backend/internal/app/cloud_agent_context_pressure_test.go @@ -0,0 +1,689 @@ +package app + +import ( + "encoding/json" + "strings" + "testing" + + "infinite-canvas/backend/internal/model" +) + +// 计量用的估算尺子:ASCII 每 4 字节 1 token 向上取整,非 ASCII 每字符 1 token。 +func TestEstimateCloudAgentTokensTreatsCJKConservatively(t *testing.T) { + if got := estimateCloudAgentTokens([]byte("abcd中文")); got != 3 { + t.Fatalf("estimate = %d, want 3", got) + } + if got := estimateCloudAgentTokens([]byte("hello")); got != 2 { + t.Fatalf("estimate = %d, want 2", got) + } + if got := estimateCloudAgentTokens(nil); got != 0 { + t.Fatalf("empty estimate = %d, want 0", got) + } +} + +// 两把尺子不混画:本地估算描述下一次请求,上游实测量的是上一次请求。 +func TestCloudAgentContextPressureSeparatesNextRequestEstimateFromPreviousProviderMeasurement(t *testing.T) { + pressure := cloudAgentContextPressure{EstimatedInputTokens: 20000, UsableInputTokens: 100000} + state := &cloudAgentRuntime{TokenAnchor: &cloudAgentTokenAnchor{ + Step: 2, InputTokens: 10500, EstimatedTokens: 19500, Accepted: true, + }} + payload := cloudAgentContextPressurePayload(pressure, state) + if payload["readingScope"] != "next_request" || payload["estimateMethod"] != "local_v1" { + t.Fatalf("next request scope missing: %+v", payload) + } + if payload["providerMeasurementScope"] != "previous_request" { + t.Fatalf("provider scope = %v", payload["providerMeasurementScope"]) + } + if payload["estimatedInputTokens"] != 20000 || payload["projectedTokens"] != 11000 { + t.Fatalf("estimate/projected readings were mixed: %+v", payload) + } +} + +// 快照字段:读数必须带版本、相位、测量来源、窗口与锚点状态,消费方不该靠字段名猜。 +func TestCloudAgentContextPressurePayloadCarriesSnapshotFields(t *testing.T) { + pressure := cloudAgentContextPressure{ + EstimatedInputTokens: 20000, UsableInputTokens: 100000, ContextWindowTokens: 128000, + ReservedOutputTokens: 16000, OverheadTokens: 5120, InputBudgetTokens: 100000, + CompactAtTokens: 85000, BudgetSource: "channel-model", ModelLimitConfigured: true, Estimate: true, + } + state := &cloudAgentRuntime{ + Step: 6, + TokenAnchor: &cloudAgentTokenAnchor{TaskID: "task-anchor", Step: 4, InputTokens: 10500, CachedTokens: 2000, OutputTokens: 300, EstimatedTokens: 19500, Accepted: true}, + } + payload := cloudAgentContextPressurePayload(pressure, state) + if payload["schemaVersion"] != 2 || payload["phase"] != "before_request" { + t.Fatalf("snapshot version/phase 缺失:%+v", payload) + } + if payload["measurementSource"] != "provider" || payload["normalizedInputTokens"] != int64(10500) || payload["projectedNextInputTokens"] != 11000 { + t.Fatalf("测量来源与两种量纲没分开:%+v", payload) + } + if payload["contextWindowTokens"] != 128000 || payload["usableInputTokens"] != 100000 || + payload["reservedOutputTokens"] != 16000 || payload["overheadTokens"] != 5120 || + payload["inputBudgetTokens"] != 100000 || payload["compactAtTokens"] != 85000 || + payload["budgetSource"] != "channel-model" || payload["modelLimitConfigured"] != true { + t.Fatalf("窗口口径字段不齐:%+v", payload) + } + anchor, ok := payload["anchor"].(map[string]any) + if !ok || anchor["id"] != "task-anchor" || anchor["valid"] != true || anchor["ageSteps"] != 2 { + t.Fatalf("anchor 快照不对:%+v", payload["anchor"]) + } + usage, ok := payload["providerUsage"].(map[string]any) + if !ok || usage["cacheReadTokens"] != int64(2000) || usage["uncachedInputTokens"] != int64(8500) { + t.Fatalf("providerUsage 不对:%+v", payload["providerUsage"]) + } + if payload["tokenSource"] != "provider" || payload["tokenScale"] != 0.5385 { + t.Fatalf("tokenSource/tokenScale 不对:%+v", payload) + } +} + +// 窗口未配置时不假装有百分比:既不下发窗口字段,也不下发占用率,只留可用的本地估算。 +func TestCloudAgentContextPressureWithoutWindowDoesNotFakeRatio(t *testing.T) { + // 没有 repository:解析不到任何模型声明的窗口。 + service := &Service{} + canonical := canonicalAgentRequest{ + SystemPrompt: "系统提示", + Messages: []map[string]interface{}{{"role": "user", "content": "你好"}}, + } + pressure := service.cloudAgentContextPressure(canonical, "你好", CloudAgentRequest{}) + if pressure.ModelLimitConfigured || pressure.UsableInputTokens != 0 || pressure.PressureRatio != 0 { + t.Fatalf("未声明窗口时不该声称配置了模型上限:%+v", pressure) + } + payload := cloudAgentContextPressurePayload(pressure, &cloudAgentRuntime{}) + for _, key := range []string{ + "contextWindowTokens", "usableInputTokens", "reservedOutputTokens", "overheadTokens", + "inputBudgetTokens", "compactAtTokens", "budgetSource", "pressureRatio", "projectedPressureRatio", + } { + if _, exists := payload[key]; exists { + t.Fatalf("窗口未确认时不该下发 %s:%+v", key, payload) + } + } + if payload["modelLimitConfigured"] != false { + t.Fatalf("modelLimitConfigured = %v", payload["modelLimitConfigured"]) + } + if payload["tokenSource"] != "estimate" || payload["measurementSource"] != "estimate" { + t.Fatalf("没有实测时必须如实标注估算:%+v", payload) + } + if payload["estimatedInputTokens"] != pressure.EstimatedInputTokens || payload["normalizedInputTokens"] != pressure.EstimatedInputTokens { + t.Fatalf("估算读数缺失:%+v", payload) + } +} + +// 声明了窗口的渠道模型:窗口/输出预留/overhead/输入预算按同一算式给出,读数才有百分比。 +func TestCloudAgentContextPressureReportsWindowBudgetFromChannelCapability(t *testing.T) { + service, _, _, _ := creationTestService(t) + canonical := canonicalAgentRequest{ + SystemPrompt: "系统提示", + Messages: []map[string]interface{}{{"role": "user", "content": "分析剧情"}}, + } + pressure := service.cloudAgentContextPressure(canonical, "分析剧情", agentTestRequest()) + if !pressure.ModelLimitConfigured { + t.Fatalf("渠道模型声明了窗口,读数必须按窗口口径:%+v", pressure) + } + if pressure.ContextWindowTokens != defaultCloudAgentContextWindowTokens { + t.Fatalf("contextWindowTokens = %d", pressure.ContextWindowTokens) + } + // overhead = 窗口的 4% 夹在 4096–32768;输入预算 = 窗口 − 输出预留 − overhead。 + wantOverhead := defaultCloudAgentContextWindowTokens / 25 + if pressure.OverheadTokens != wantOverhead { + t.Fatalf("overheadTokens = %d, want %d", pressure.OverheadTokens, wantOverhead) + } + if pressure.InputBudgetTokens != pressure.ContextWindowTokens-defaultCloudAgentMaxOutputTokens-wantOverhead { + t.Fatalf("inputBudgetTokens = %d", pressure.InputBudgetTokens) + } + if pressure.UsableInputTokens != pressure.InputBudgetTokens || pressure.BudgetSource != "channel-model" { + t.Fatalf("预算来源/可用输入不一致:%+v", pressure) + } + payload := cloudAgentContextPressurePayload(pressure, &cloudAgentRuntime{}) + if payload["budgetSource"] != "channel-model" || payload["usableInputTokens"] != pressure.InputBudgetTokens { + t.Fatalf("载荷缺少窗口口径:%+v", payload) + } + if _, exists := payload["pressureRatio"]; !exists { + t.Fatalf("已确认窗口时必须给出占用率:%+v", payload) + } +} + +// 窗口从未确认变为已确认:要落且只落一条 context_transition。 +func TestCloudAgentNoteContextWindowResolvedEmitsOnce(t *testing.T) { + state := &cloudAgentRuntime{} + pressure := cloudAgentContextPressure{ModelLimitConfigured: true, ContextWindowTokens: 1000000, UsableInputTokens: 967232, BudgetSource: "channel-model"} + cloudAgentNoteContextWindowResolved("run-1", state, pressure) + cloudAgentNoteContextWindowResolved("run-1", state, pressure) + transitions := 0 + for _, event := range state.Events { + if event.Type == "context_transition" { + transitions++ + if event.Payload["kind"] != "window_resolved" { + t.Fatalf("过渡类型不对:%+v", event.Payload) + } + } + } + if transitions != 1 { + t.Fatalf("context_transition 条数 = %d,期望 1", transitions) + } + if !state.ContextWindowKnown { + t.Fatal("窗口识别标记没落下") + } +} + +// 第一步没有实测:读数是估算;上一步的 provider 用量到达后,第二步改用实测锚点, +// 并给出 tokenScale 与投影(锚点 + 本地有符号增量)。 +func TestCloudAgentContextPressureUsesEstimateOnFirstStepThenProviderAnchor(t *testing.T) { + service, db, _, _ := creationTestService(t) + request := agentTestRequest() + budget := service.cloudAgentContextBudgetForRequest(request) + if !budget.Configured { + t.Fatal("测试夹具的渠道模型应当声明了窗口") + } + state := &cloudAgentRuntime{Request: request, Step: 1, Canonical: canonicalAgentRequest{SystemPrompt: "系统提示"}} + + // 第一步:还没有任何上游实测,只能如实标注估算。 + first := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000, ModelLimitConfigured: true, UsableInputTokens: budget.InputBudgetTokens}, state) + if first["tokenSource"] != "estimate" || first["measurementSource"] != "estimate" { + t.Fatalf("第一步必须标估算:%+v", first) + } + if _, exists := first["tokenScale"]; exists { + t.Fatalf("没有锚点就不该有 tokenScale:%+v", first) + } + + // 上一步的模型调用回来了,上游上报了用量(qwen 系实测过本地估算偏高 ~18%)。 + if err := db.Create(&model.ApiCallLog{ + ID: "log-step-1", UserID: "user", TaskID: "task-step-1", Capability: "text", Model: "text-test", + Status: model.ApiCallStatusSucceeded, InputTokens: 10500, CachedTokens: 2000, OutputTokens: 300, UsageAvailable: true, + }).Error; err != nil { + t.Fatal(err) + } + state.Step = 2 + state.LastStepTaskID = "task-step-1" + state.LastStepOperation = cloudAgentStepOperation + state.LastStepEstimate = 20000 + state.LastStepSourceBytes = 40000 + state.LastStepSignature = cloudAgentStepSignature(state) + service.recordCloudAgentTokenAnchor("user", state) + if state.TokenAnchor == nil || !state.TokenAnchor.Accepted { + t.Fatalf("合理的上游用量必须被采信:%+v", state.TokenAnchor) + } + + // 第二步:主读数换成"锚点实测 + 本地增量",并带上换算比例。 + second := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 22000, ModelLimitConfigured: true, UsableInputTokens: budget.InputBudgetTokens}, state) + if second["tokenSource"] != "provider" || second["measurementSource"] != "provider" { + t.Fatalf("拿到实测后主读数必须换成 provider:%+v", second) + } + if second["pressureTokens"] != int64(10500) || second["normalizedInputTokens"] != int64(10500) { + t.Fatalf("锚点实测值不对:%+v", second) + } + if second["projectedNextInputTokens"] != 12500 || second["projectedTokens"] != 12500 { + t.Fatalf("投影应为 10500 + (22000-20000):%+v", second) + } + if second["tokenScale"] != 0.525 || second["anchorDeltaTokens"] != 2000 { + t.Fatalf("换算比例/增量不对:%+v", second) + } + if second["estimatedInputTokens"] != 22000 { + t.Fatalf("本地估算不能被实测覆盖:%+v", second) + } +} + +// 锚点跨步沿用,但超过 3 步未刷新就作废(真机踩过"锚点冻结")。 +func TestCloudAgentTokenAnchorSurvivesWithinMaxAgeAndExpiresAfterwards(t *testing.T) { + state := &cloudAgentRuntime{Request: agentTestRequest(), Step: 2, TokenAnchor: &cloudAgentTokenAnchor{TaskID: "task-1", Step: 2, Accepted: true, InputTokens: 10500, EstimatedTokens: 20000}} + state.TokenAnchor.Signature = cloudAgentStepSignature(state) + + state.Step = 2 + cloudAgentAnchorMaxAgeSteps + cloudAgentExpireTokenAnchor("run-1", state, 0) + if !state.TokenAnchor.Accepted || state.TokenAnchor.RejectReason != "" { + t.Fatalf("第 %d 步仍在有效期内,不该作废:%+v", cloudAgentAnchorMaxAgeSteps, state.TokenAnchor) + } + pressure := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000}, state) + if pressure["tokenSource"] != "provider" { + t.Fatalf("沿用中的锚点必须继续当主读数:%+v", pressure) + } + anchor, ok := pressure["anchor"].(map[string]any) + if !ok || anchor["valid"] != true || anchor["ageSteps"] != cloudAgentAnchorMaxAgeSteps { + t.Fatalf("anchor 快照不对:%+v", pressure["anchor"]) + } + + state.Step++ + cloudAgentExpireTokenAnchor("run-1", state, 0) + if state.TokenAnchor.Accepted || !strings.Contains(state.TokenAnchor.RejectReason, "未刷新") { + t.Fatalf("超期锚点必须作废:%+v", state.TokenAnchor) + } + pressure = cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000}, state) + if pressure["tokenSource"] != "estimate" || pressure["anchorRejected"] == nil { + t.Fatalf("作废后必须退回估算并给出原因:%+v", pressure) + } +} + +// 窗口变化后锚点作废:同一个 token 数在 8K 窗口和 256K 窗口下的含义完全不同。 +func TestCloudAgentTokenAnchorExpiresWhenWindowChanges(t *testing.T) { + state := &cloudAgentRuntime{ + Request: agentTestRequest(), Step: 4, + TokenAnchor: &cloudAgentTokenAnchor{TaskID: "task-1", Step: 1, Accepted: true, InputTokens: 10500, EstimatedTokens: 20000, ContextWindowTokens: 128000}, + } + state.TokenAnchor.Signature = cloudAgentStepSignature(state) + cloudAgentExpireTokenAnchor("run-1", state, 256000) + if state.TokenAnchor.Accepted || !strings.Contains(state.TokenAnchor.RejectReason, "窗口已变化") { + t.Fatalf("换窗口后锚点必须作废:%+v", state.TokenAnchor) + } + transitions := 0 + for _, event := range state.Events { + if event.Type == "context_transition" && event.Payload["kind"] == "window_changed" { + transitions++ + before, _ := event.Payload["before"].(map[string]any) + after, _ := event.Payload["after"].(map[string]any) + if before["contextWindowTokens"] != 128000 || after["contextWindowTokens"] != 256000 { + t.Fatalf("窗口过渡事件缺少前后值:%+v", event.Payload) + } + } + } + if transitions != 1 { + t.Fatalf("窗口变化应落一条 context_transition(window_changed),实际 %d", transitions) + } + // 窗口未知(未解析到真实能力)时不比较,也不该据此作废。 + unknown := &cloudAgentRuntime{ + Request: agentTestRequest(), Step: 2, + TokenAnchor: &cloudAgentTokenAnchor{TaskID: "task-2", Step: 1, Accepted: true, InputTokens: 10500, EstimatedTokens: 20000, ContextWindowTokens: 128000}, + } + unknown.TokenAnchor.Signature = cloudAgentStepSignature(unknown) + cloudAgentExpireTokenAnchor("run-1", unknown, 0) + if !unknown.TokenAnchor.Accepted { + t.Fatal("窗口未知时不该作废锚点") + } +} + +func TestCloudAgentTokenAnchorExpiresOnlyWhenRequestSignatureChanges(t *testing.T) { + for _, testCase := range []struct { + name, model, channelID, system, kind string + changeTools bool + }{ + {name: "model", model: "other-model", channelID: "channel", system: "policy", kind: "model_changed"}, + {name: "route", model: "text-test", channelID: "other-channel", system: "policy", kind: "route_changed"}, + {name: "system", model: "text-test", channelID: "channel", system: "new policy"}, + {name: "tools", model: "text-test", channelID: "channel", system: "policy", changeTools: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + canonical := canonicalAgentRequest{SystemPrompt: "policy", Tools: []map[string]interface{}{{"name": "a"}}} + state := &cloudAgentRuntime{Request: agentTestRequest(), Step: 2} + signature := cloudAgentRequestSignature(state, canonical, "channel", "text-test") + state.TokenAnchor = &cloudAgentTokenAnchor{ + TaskID: "task", Step: 1, Accepted: true, InputTokens: 10000, EstimatedTokens: 18000, + Signature: signature, Model: "text-test", ChannelID: "channel", + } + canonical.Messages = []map[string]interface{}{{"role": "user", "content": "changed message"}} + cloudAgentExpireTokenAnchorForRequest("run", state, 0, cloudAgentRequestSignature(state, canonical, "channel", "text-test"), "text-test", "channel") + if !state.TokenAnchor.Accepted { + t.Fatal("messages alone must not invalidate the tokenizer anchor") + } + canonical.SystemPrompt = testCase.system + if testCase.changeTools { + canonical.Tools = []map[string]interface{}{{"name": "b"}} + } + cloudAgentExpireTokenAnchorForRequest("run", state, 0, cloudAgentRequestSignature(state, canonical, testCase.channelID, testCase.model), testCase.model, testCase.channelID) + if state.TokenAnchor.Accepted || state.TokenAnchor.RejectReason == "" { + t.Fatalf("signature changed without invalidation: %+v", state.TokenAnchor) + } + if testCase.kind != "" && (len(state.Events) != 1 || state.Events[0].Payload["kind"] != testCase.kind) { + t.Fatalf("missing transition %s: %+v", testCase.kind, state.Events) + } + }) + } +} + +// 上游实测与本地估算差出一个量级(<0.5× / >2×)时不采信,并说明原因。 +func TestCloudAgentTokenAnchorRejectsImplausibleProviderUsage(t *testing.T) { + cases := []struct { + name string + usage int64 + found string + }{ + {name: "远低于本地估算", usage: 5000, found: "远低于"}, + {name: "远高于本地估算", usage: 45000, found: "远高于"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + service, db, _, _ := creationTestService(t) + request := agentTestRequest() + taskID := "task-" + testCase.name + if err := db.Create(&model.ApiCallLog{ + ID: "log-" + testCase.name, UserID: "user", TaskID: taskID, Capability: "text", Model: "text-test", + Status: model.ApiCallStatusSucceeded, InputTokens: testCase.usage, OutputTokens: 300, UsageAvailable: true, + }).Error; err != nil { + t.Fatal(err) + } + state := &cloudAgentRuntime{ + Request: request, Step: 2, LastStepTaskID: taskID, LastStepOperation: cloudAgentStepOperation, + LastStepEstimate: 20000, LastStepSourceBytes: 40000, + } + state.LastStepSignature = cloudAgentStepSignature(state) + service.recordCloudAgentTokenAnchor("user", state) + if state.TokenAnchor == nil || state.TokenAnchor.Accepted { + t.Fatalf("离谱的实测不该被采信:%+v", state.TokenAnchor) + } + if !strings.Contains(state.TokenAnchor.RejectReason, testCase.found) { + t.Fatalf("拒绝原因不对:%q", state.TokenAnchor.RejectReason) + } + payload := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000}, state) + if payload["tokenSource"] != "estimate" { + t.Fatalf("被拒绝的锚点不得当主读数:%+v", payload) + } + if reason, _ := payload["anchorRejected"].(string); !strings.Contains(reason, testCase.found) { + t.Fatalf("事件必须说明为什么不用这个实测:%+v", payload) + } + }) + } +} + +// 没有上游用量(usage_available=false)时不瞎猜:退回估算,且不产生锚点。 +func TestCloudAgentTokenAnchorRequiresProviderReportedUsage(t *testing.T) { + service, db, _, _ := creationTestService(t) + request := agentTestRequest() + if err := db.Create(&model.ApiCallLog{ + ID: "log-no-usage", UserID: "user", TaskID: "task-no-usage", Capability: "text", Model: "text-test", + Status: model.ApiCallStatusSucceeded, InputTokens: 10500, OutputTokens: 300, UsageAvailable: false, + }).Error; err != nil { + t.Fatal(err) + } + state := &cloudAgentRuntime{ + Request: request, Step: 2, LastStepTaskID: "task-no-usage", LastStepOperation: cloudAgentStepOperation, + LastStepEstimate: 20000, LastStepSourceBytes: 40000, + } + service.recordCloudAgentTokenAnchor("user", state) + if state.TokenAnchor != nil { + t.Fatalf("上游没报用量就不该定锚:%+v", state.TokenAnchor) + } + payload := cloudAgentContextPressurePayload(cloudAgentContextPressure{EstimatedInputTokens: 20000}, state) + if payload["tokenSource"] != "estimate" || payload["normalizedInputTokens"] != 20000 { + t.Fatalf("没有实测时必须退回估算:%+v", payload) + } +} + +// 媒体任务不是"模型调用"这一步:拿它的用量当锚点会把压力算到错误的信封上。 +func TestCloudAgentTokenAnchorIgnoresNonStepOperations(t *testing.T) { + service, db, _, _ := creationTestService(t) + request := agentTestRequest() + if err := db.Create(&model.ApiCallLog{ + ID: "log-media", UserID: "user", TaskID: "task-media", Capability: "text", Model: "text-test", + Status: model.ApiCallStatusSucceeded, InputTokens: 10500, OutputTokens: 300, UsageAvailable: true, + }).Error; err != nil { + t.Fatal(err) + } + state := &cloudAgentRuntime{ + Request: request, Step: 2, LastStepTaskID: "task-media", LastStepOperation: "cloud_agent_media", + LastStepEstimate: 20000, LastStepSourceBytes: 40000, + } + service.recordCloudAgentTokenAnchor("user", state) + if state.TokenAnchor != nil { + t.Fatalf("非模型调用步骤不该定锚:%+v", state.TokenAnchor) + } +} + +func TestCloudAgentTokenAnchorOnlyUsesOwnedSuccessfulTextCall(t *testing.T) { + service, db, _, _ := creationTestService(t) + state := &cloudAgentRuntime{ + Request: agentTestRequest(), Step: 2, LastStepTaskID: "task-owned", + LastStepOperation: cloudAgentStepOperation, LastStepEstimate: 20000, + LastStepChannelID: "channel", + } + state.LastStepSignature = cloudAgentStepSignature(state) + for _, log := range []model.ApiCallLog{ + {ID: "other-user", UserID: "other", TaskID: "task-owned", Capability: "text", Status: model.ApiCallStatusSucceeded, UsageAvailable: true, InputTokens: 11000}, + {ID: "failed", UserID: "user", TaskID: "task-owned", Capability: "text", Status: model.ApiCallStatusFailed, UsageAvailable: true, InputTokens: 11000}, + {ID: "video", UserID: "user", TaskID: "task-owned", Capability: "video", Status: model.ApiCallStatusSucceeded, UsageAvailable: true, InputTokens: 11000}, + } { + if err := db.Create(&log).Error; err != nil { + t.Fatal(err) + } + } + service.recordCloudAgentTokenAnchor("user", state) + if state.TokenAnchor != nil { + t.Fatalf("foreign/failed/non-text log must not become an anchor: %+v", state.TokenAnchor) + } + valid := model.ApiCallLog{ID: "owned", UserID: "user", TaskID: "task-owned", ChannelID: "channel", Capability: "text", Status: model.ApiCallStatusSucceeded, UsageAvailable: true, InputTokens: 11000} + if err := db.Create(&valid).Error; err != nil { + t.Fatal(err) + } + service.recordCloudAgentTokenAnchor("user", state) + if state.TokenAnchor == nil || !state.TokenAnchor.Accepted { + t.Fatalf("owned successful text usage must be accepted: %+v", state.TokenAnchor) + } + state.TokenAnchor = nil + state.LastStepChannelID = "different-channel" + service.recordCloudAgentTokenAnchor("user", state) + if state.TokenAnchor != nil { + t.Fatalf("route changed after request construction: %+v", state.TokenAnchor) + } +} + +// 占用分布:分段合计必须与 system 桶严格对得上(字节逐字节、token 逐 token)。 +func TestCloudAgentContextBreakdownSystemSegmentsFillSystemBucket(t *testing.T) { + system, policy, err := compileCloudAgentPolicies(agentTestRequest(), nil, "", cloudAgentProfileSnapshot{Revision: agentProfileRevision(nil), Hash: agentProfileHash("")}) + if err != nil { + t.Fatal(err) + } + if len(policy.SystemSegments) == 0 { + t.Fatal("编译器必须登记系统提示分段") + } + // 编译之后拼接的个人记忆块:不登记就会让分段合计小于 system 桶。 + canonical := canonicalAgentRequest{ + SystemPrompt: system + "\n\n## 个人记忆\n\n- 标题:适用场景\n", + Messages: []map[string]interface{}{{"role": "user", "content": "分析剧情"}}, + Tools: []map[string]interface{}{{"type": "function", "function": map[string]interface{}{"name": "canvas_get_state"}}}, + } + cloudAgentRecordMemorySegment(&policy, canonical.SystemPrompt) + state := &cloudAgentRuntime{Canonical: canonical, Policy: policy} + + breakdown := cloudAgentContextBreakdownPayload(state) + segments, ok := breakdown["systemSegments"].([]cloudAgentContextSegment) + if !ok || len(segments) == 0 { + t.Fatalf("缺少系统提示分段:%+v", breakdown["systemSegments"]) + } + buckets, ok := breakdown["buckets"].([]map[string]any) + if !ok || len(buckets) != 3 { + t.Fatalf("buckets 不对:%+v", breakdown["buckets"]) + } + systemBucket := buckets[0] + if systemBucket["key"] != "system" { + t.Fatalf("第一个桶必须是 system:%+v", systemBucket) + } + segmentBytes, segmentTokens := 0, 0 + keys := []string{} + for _, segment := range segments { + segmentBytes += segment.Bytes + segmentTokens += segment.Tokens + keys = append(keys, segment.Key) + if segment.Bytes <= 0 { + t.Fatalf("分段 %s 的字节数必须为正:%+v", segment.Key, segment) + } + } + if segmentBytes != systemBucket["bytes"] || segmentBytes != len(canonical.SystemPrompt) { + t.Fatalf("分段字节合计 %d 与 system 桶 %v(真实 %d)对不上:%+v", segmentBytes, systemBucket["bytes"], len(canonical.SystemPrompt), keys) + } + if segmentTokens != systemBucket["tokens"] { + t.Fatalf("分段 token 合计 %d 与 system 桶 %v 对不上:%+v", segmentTokens, systemBucket["tokens"], keys) + } + if !strings.Contains(strings.Join(keys, ","), "memory") { + t.Fatalf("个人记忆必须登记为分段:%+v", keys) + } + if buckets[1]["key"] != "tools" || buckets[2]["key"] != "messages" { + t.Fatalf("三桶顺序不对:%+v", buckets) + } + // 未登记的内容归入"其它",合计仍然对得上,不会出现"少算一截"。 + state.Canonical.SystemPrompt += "\n未登记的一段拼接内容" + breakdown = cloudAgentContextBreakdownPayload(state) + segments = breakdown["systemSegments"].([]cloudAgentContextSegment) + buckets = breakdown["buckets"].([]map[string]any) + segmentBytes, segmentTokens, hasOther := 0, 0, false + for _, segment := range segments { + segmentBytes += segment.Bytes + segmentTokens += segment.Tokens + if segment.Key == "other" { + hasOther = true + } + } + if !hasOther { + t.Fatalf("零头必须归入 other:%+v", segments) + } + if segmentBytes != buckets[0]["bytes"] || segmentTokens != buckets[0]["tokens"] { + t.Fatalf("有零头时分段合计仍须等于 system 桶:%d/%v %d/%v", segmentBytes, buckets[0]["bytes"], segmentTokens, buckets[0]["tokens"]) + } +} + +// 有锚点时,占用分布另给一份按换算比例校准的读数,原值不覆盖。 +func TestCloudAgentContextBreakdownScalesBucketsWithAnchor(t *testing.T) { + state := &cloudAgentRuntime{ + Canonical: canonicalAgentRequest{SystemPrompt: "系统提示内容", Messages: []map[string]interface{}{{"role": "user", "content": "你好"}}}, + TokenAnchor: &cloudAgentTokenAnchor{ + TaskID: "task-1", Step: 1, Accepted: true, InputTokens: 10000, EstimatedTokens: 20000, + }, + } + breakdown := cloudAgentContextBreakdownPayload(state) + if breakdown["tokenScale"] != 0.5 { + t.Fatalf("tokenScale = %v,期望 0.5", breakdown["tokenScale"]) + } + buckets := breakdown["buckets"].([]map[string]any) + systemTokens := buckets[0]["tokens"].(int) + if buckets[0]["scaledTokens"].(int) != systemTokens/2 { + t.Fatalf("scaledTokens 应按锚点比例换算:%+v", buckets[0]) + } +} + +// 走真实运行路径(调度器推进 + 任务接缝注入模型输出):每一步都落一条 context_pressure, +// 第一步是估算、第二步换成上游实测锚点。这条用例守的是运行期接线(登记的是哪一步、 +// 哪一次调用、什么时候配锚点),而不是载荷函数本身。 +func TestCloudAgentRuntimeEmitsContextPressurePerStep(t *testing.T) { + s, db, _, _ := creationTestService(t) + if err := db.Create(&model.CanvasProject{ID: "agent-canvas", UserID: "user", PayloadJSON: `{"nodes":[],"connections":[]}`}).Error; err != nil { + t.Fatal(err) + } + root, err := s.CreateCloudAgentRun("user", agentTestRequest(), "") + if err != nil { + t.Fatal(err) + } + load := func() (*model.CloudAgentExecution, cloudAgentRuntime) { + t.Helper() + run, err := s.repo.CloudAgent("user", root.ID) + if err != nil { + t.Fatal(err) + } + state, err := cloudAgentDecode(run) + if err != nil { + t.Fatal(err) + } + return run, state + } + advance := func() { + t.Helper() + if err := s.advanceCloudAgentByID("user", root.ID); err != nil { + t.Fatal(err) + } + } + pressures := func(state cloudAgentRuntime) []CloudAgentEvent { + events := []CloudAgentEvent{} + for _, event := range state.Events { + if event.Type == "context_pressure" { + events = append(events, event) + } + } + return events + } + + // 建运行:第一步的模型调用就是根任务,读数在发出请求之前就落下了。 + advance() + _, state := load() + if state.ActiveTaskID != root.ID || state.LastStepTaskID != root.ID || state.LastStepOperation != cloudAgentStepOperation { + t.Fatalf("第一步的记账不对:active=%s last=%s op=%s", state.ActiveTaskID, state.LastStepTaskID, state.LastStepOperation) + } + first := pressures(state) + if len(first) != 1 { + t.Fatalf("第一步应落一条 context_pressure,实际 %d", len(first)) + } + if first[0].Payload["tokenSource"] != "estimate" || first[0].Payload["measurementSource"] != "estimate" { + t.Fatalf("第一步没有实测,必须标估算:%+v", first[0].Payload) + } + if first[0].Payload["requestId"] != root.ID || first[0].Payload["readingScope"] != "next_request" { + t.Fatalf("读数必须钉在本次请求上:%+v", first[0].Payload) + } + if _, ok := first[0].Payload["breakdown"].(map[string]any); !ok { + t.Fatalf("读数必须带占用分布:%+v", first[0].Payload) + } + assertMatchesTask := func(taskID string, event CloudAgentEvent) { + t.Helper() + task, err := s.repo.TaskForUser("user", taskID) + if err != nil { + t.Fatal(err) + } + var input map[string]any + if err := json.Unmarshal([]byte(task.InputJSON), &input); err != nil { + t.Fatal(err) + } + canonical, ok := canonicalAgentRequestFromInput(input) + if !ok { + t.Fatalf("task %s missing canonical envelope", taskID) + } + raw, err := json.Marshal(canonical) + if err != nil { + t.Fatal(err) + } + breakdown := event.Payload["breakdown"].(map[string]any) + if breakdown["totalBytes"] != float64(len(raw)) || event.Payload["sourceBytes"] != float64(len(raw)) { + t.Fatalf("pressure/breakdown not based on actual task envelope: task=%s event=%+v", taskID, event.Payload) + } + buckets := breakdown["buckets"].([]any) + messages := buckets[2].(map[string]any) + messageBytes, _ := json.Marshal(canonical.Messages) + if messages["bytes"] != float64(len(messageBytes)) { + t.Fatalf("message bucket not based on task input: %v != %d", messages["bytes"], len(messageBytes)) + } + } + assertMatchesTask(root.ID, first[0]) + + // 第一步的模型调用回来了,上游上报了用量。 + if err := db.Create(&model.ApiCallLog{ + ID: "log-root", UserID: "user", TaskID: root.ID, Capability: "text", Model: "text-test", + Status: model.ApiCallStatusSucceeded, InputTokens: 10500, CachedTokens: 2000, OutputTokens: 300, UsageAvailable: true, + }).Error; err != nil { + t.Fatal(err) + } + // 在任务接缝注入模型输出:一次只读工具调用,让这一轮进入第二步。 + call := cloudAgentCall{ID: "call-1"} + call.Function.Name, call.Function.Arguments = "canvas_get_state", `{}` + body, _ := json.Marshal(map[string]any{"toolCalls": []cloudAgentCall{call}}) + if err := db.Model(&model.Task{}).Where("id = ?", state.ActiveTaskID).Updates(map[string]any{"status": model.TaskStatusSucceeded, "result_json": string(body)}).Error; err != nil { + t.Fatal(err) + } + // 模型结果 → 执行工具 → 记锚点 → 发下一步:推进到第二步的读数落下为止(有界)。 + for attempt := 0; attempt < 6; attempt++ { + advance() + if _, current := load(); len(pressures(current)) == 2 { + break + } + } + run, state := load() + if run.Status != "running" { + t.Fatalf("这一步应当继续(工具已执行、下一步已提交):status=%s failure=%s", run.Status, run.FailureMessage) + } + if state.TokenAnchor == nil || !state.TokenAnchor.Accepted || state.TokenAnchor.TaskID != root.ID { + t.Fatalf("根任务的上游用量必须配成锚点:%+v", state.TokenAnchor) + } + if state.LastStepTaskID == root.ID || state.LastStepOperation != cloudAgentStepOperation { + t.Fatalf("第二步的记账不对:last=%s op=%s", state.LastStepTaskID, state.LastStepOperation) + } + second := pressures(state) + if len(second) != 2 { + t.Fatalf("第二步应再落一条 context_pressure,实际 %d", len(second)) + } + payload := second[1].Payload + // 事件是经 JSON 落库再读回来的,数字到这一层都是 float64(消费方也不该假设整型)。 + if payload["tokenSource"] != "provider" || payload["measurementSource"] != "provider" { + t.Fatalf("拿到实测后主读数必须换成 provider:%+v", payload) + } + if payload["normalizedInputTokens"] != float64(10500) || payload["requestId"] != state.LastStepTaskID { + t.Fatalf("实测值与请求绑定不对:%+v", payload) + } + assertMatchesTask(state.LastStepTaskID, second[1]) + if _, exists := payload["tokenScale"]; !exists { + t.Fatalf("provider 口径必须带换算比例:%+v", payload) + } + anchor, ok := payload["anchor"].(map[string]any) + if !ok || anchor["valid"] != true || anchor["ageSteps"] != float64(0) || anchor["id"] != root.ID { + t.Fatalf("anchor 快照不对:%+v", payload["anchor"]) + } +} diff --git a/backend/internal/app/cloud_agent_lessons.go b/backend/internal/app/cloud_agent_lessons.go index 5216cb1c3..a16edf5df 100644 --- a/backend/internal/app/cloud_agent_lessons.go +++ b/backend/internal/app/cloud_agent_lessons.go @@ -108,6 +108,26 @@ func (s *Service) attachCloudAgentLessons(canonical *canonicalAgentRequest, user } } +// cloudAgentRecordMemorySegment 把系统提示里已经存在的个人记忆块登记为分段。 +// 记忆块是在策略编译之后拼接的,编译器录不到它;不登记就会让"系统提示分段"合计 +// 小于 system 桶,读数看起来像少算了一截。 +func cloudAgentRecordMemorySegment(policy *cloudAgentPolicySnapshot, system string) { + if policy == nil { + return + } + index := strings.Index(system, cloudAgentLessonBlockMarker) + if index < 0 { + for i := range policy.SystemSegments { + if policy.SystemSegments[i].Key == "memory" { + policy.SystemSegments = append(policy.SystemSegments[:i], policy.SystemSegments[i+1:]...) + break + } + } + return + } + cloudAgentRecordSystemSegment(policy, "memory", "个人记忆", system[index:]) +} + func cloudAgentRememberLesson(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall) (any, error) { if state == nil { return nil, BadAuthRequest("当前运行状态无效") diff --git a/backend/internal/app/cloud_agent_policy.go b/backend/internal/app/cloud_agent_policy.go index f89ba8729..2135a9deb 100644 --- a/backend/internal/app/cloud_agent_policy.go +++ b/backend/internal/app/cloud_agent_policy.go @@ -27,6 +27,52 @@ type cloudAgentPolicySnapshot struct { CompilerVersion string `json:"compilerVersion"` ProfileRevision string `json:"profileRevision,omitempty"` ProfileHash string `json:"profileHash,omitempty"` + // SystemSegments 记录编译出来的系统提示由哪些块组成。压力读数把它摊开上报, + // 排查时不必再从提示词正文反推"系统提示里谁是画布摘要、谁是个人记忆"。 + SystemSegments []cloudAgentContextSegment `json:"systemSegments,omitempty"` +} + +// cloudAgentContextSegment 是系统提示里的一块(编译期按写入顺序量出来的)。 +type cloudAgentContextSegment struct { + Key string `json:"key"` + Label string `json:"label"` + Bytes int `json:"bytes"` + Tokens int `json:"tokens"` + // ScaledTokens 是按上游实测锚点比例校准后的读数;没有锚点时与 Tokens 相同。 + ScaledTokens int `json:"scaledTokens,omitempty"` +} + +// cloudAgentSegmentRecorder 在编译系统提示时逐块量尺寸:它只记录"上一次标记之后追加了 +// 多少字节",因此分段合计与最终 system 正文逐字节一致(含块之间的标题与分隔符)。 +type cloudAgentSegmentRecorder struct { + segments []cloudAgentContextSegment + last int +} + +func (r *cloudAgentSegmentRecorder) mark(builder *strings.Builder, key, label string) { + end := builder.Len() + if size := end - r.last; size > 0 { + text := builder.String()[r.last:end] + r.segments = append(r.segments, cloudAgentContextSegment{Key: key, Label: label, Bytes: size, Tokens: estimateCloudAgentTokens([]byte(text))}) + } + r.last = end +} + +// cloudAgentRecordSystemSegment 登记"编译之后"追加进系统提示的块(当前是个人记忆索引), +// 让分段合计与实际 system 桶一致;同 key 重复调用只保留最新一次,因此它既能在创建运行时 +// 登记,也能在每一步幂等补登记。 +func cloudAgentRecordSystemSegment(policy *cloudAgentPolicySnapshot, key, label, text string) { + if policy == nil || strings.TrimSpace(text) == "" { + return + } + segment := cloudAgentContextSegment{Key: key, Label: label, Bytes: len(text), Tokens: estimateCloudAgentTokens([]byte(text))} + for index := range policy.SystemSegments { + if policy.SystemSegments[index].Key == key { + policy.SystemSegments[index] = segment + return + } + } + policy.SystemSegments = append(policy.SystemSegments, segment) } type cloudAgentProfileSnapshot struct { @@ -112,11 +158,15 @@ func compileCloudAgentPolicies(req CloudAgentRequest, skills []cloudAgentSkill, ProfileRevision: profile.Revision, ProfileHash: profile.Hash, } var b strings.Builder + var recorder cloudAgentSegmentRecorder b.WriteString(system.Text) b.WriteString("\n\n") + recorder.mark(&b, "policy", "系统行为策略") b.WriteString(media.Text) b.WriteString("\n\n") + recorder.mark(&b, "mediaPolicy", "媒体策略") b.WriteString(cloudAgentCapabilityGuide()) + recorder.mark(&b, "capabilities", "节点能力与选型") // Behavior belongs to versioned policies; the compiler only projects facts. context := map[string]any{ "source": "server_snapshot", "permissionMode": req.PermissionMode, @@ -148,6 +198,8 @@ func compileCloudAgentPolicies(req CloudAgentRequest, skills []cloudAgentSkill, } b.WriteString("\n\n本轮执行上下文:\n") b.Write(encoded) + recorder.mark(&b, "execution", "本轮执行上下文(事实快照)") + snapshot.SystemSegments = recorder.segments text := strings.TrimSpace(b.String()) if text == "" { return "", cloudAgentPolicySnapshot{}, fmt.Errorf("compiled Agent policy is empty") diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index 403698ab8..f3688a409 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -157,6 +157,23 @@ type cloudAgentRuntime struct { // 内存只保留最近一窗(repository.CloudAgentJournalWindow),因此它是"窗口在整条 // 日志里的偏移",而不是累计条数。只在内存里有效,不进检查点(检查点里的事件为空)。 EventSeqBase int `json:"-"` + // LastStep* 记下"最近一次已发出的模型调用"的本地计价,与上游回填的实测用量 + // 配成锚点用。估算与实测指向同一份 canonical:估算取自任务 input 里实际发出的那份, + // 因此"信封一致"是构造保证,不需要额外比对。 + LastStepTaskID string `json:"lastStepTaskId,omitempty"` + LastStepOperation string `json:"lastStepOperation,omitempty"` + LastStepEstimate int `json:"lastStepEstimate,omitempty"` + LastStepSourceBytes int `json:"lastStepSourceBytes,omitempty"` + LastStepSignature string `json:"lastStepSignature,omitempty"` + LastStepModel string `json:"lastStepModel,omitempty"` + LastStepChannelID string `json:"lastStepChannelId,omitempty"` + LastStepWindowTokens int `json:"lastStepWindowTokens,omitempty"` + // TokenAnchor 是上一步上游上报的用量(模型自己的分词器计数),上下文压力的权威锚点。 + TokenAnchor *cloudAgentTokenAnchor `json:"tokenAnchor,omitempty"` + // ContextWindowKnown 记录本轮是否已经看到过"模型窗口已确认"的读数:从"未确认"变为 + // "已确认"时要落一条 context_transition,消费方据此标"模型窗口已识别", + // 而不是把口径切换画成上下文骤降。 + ContextWindowKnown bool `json:"contextWindowKnown,omitempty"` } type cloudAgentTransientReference struct { @@ -170,6 +187,7 @@ type cloudAgentTransientReference struct { func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgentState) error { var input struct { TextHistory []providerTextMessage `json:"textHistory"` + Config map[string]any `json:"config"` Requests struct { Canonical canonicalAgentRequest `json:"canonical"` } `json:"agentRequests"` @@ -194,6 +212,27 @@ func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgent } state.event(task.ID, "tool_completed", map[string]any{"toolName": "skills_load", "skillIds": skillIDs, "text": fmt.Sprintf("已启用 %d 个技能,正文将按需读取", len(initial.Skills))}) } + pressure := s.cloudAgentContextPressure(input.Requests.Canonical, initial.Request.Prompt, initial.Request) + // 第一步的模型调用就是根任务本身(不经过 enqueueCloudAgentTask):在这里登记任务 id + // 与本次请求的本地计价,它回来时才能与上游实测配成锚点。根任务的操作名是 + // cloud_agent,但它就是第一步的模型调用,按"步骤"口径登记,否则回来配锚点时会被 + // 操作名守卫挡掉。 + state.LastStepTaskID = task.ID + state.LastStepOperation = cloudAgentStepOperation + state.LastStepEstimate = pressure.EstimatedInputTokens + state.LastStepSourceBytes = pressure.SourceBytes + state.LastStepModel = stringValue(input.Config["model"]) + state.LastStepChannelID = stringValue(input.Config["channelId"]) + state.LastStepSignature = cloudAgentRequestSignature(&state, input.Requests.Canonical, state.LastStepChannelID, state.LastStepModel) + if pressure.ModelLimitConfigured { + state.LastStepWindowTokens = pressure.ContextWindowTokens + } + // 第一步的窗口是"起始状态"而不是"刚刚识别":只播种标记,不落 window_resolved, + // 否则每轮开头都会报一次"模型窗口已识别"。 + state.ContextWindowKnown = pressure.ModelLimitConfigured + firstPressure := cloudAgentContextPressurePayload(pressure, &state, input.Requests.Canonical) + firstPressure["requestId"] = task.ID + state.event(task.ID, "context_pressure", firstPressure) run := &model.CloudAgentExecution{ID: task.ID, UserID: task.UserID, Status: "running", Revision: 1, CreatedAt: task.CreatedAt, UpdatedAt: time.Now()} if err := cloudAgentSave(run, &state); err != nil { return err @@ -913,6 +952,9 @@ func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error) return s.failCloudAgent(run, &state, fmt.Sprintf("达到 %d 次模型调用上限,本轮已停止", stepLimit)) } cloudAgentDrainInterjections(run.ID, &state) + // 上一步的模型调用已经回来,先用它的上游实测用量更新压力锚点,再发下一步: + // 下一步的读数与后面的正文裁剪判定都要用到这份锚点。 + s.recordCloudAgentTokenAnchor(run.UserID, &state) // 轮内唯一裁剪 = 图片:超出保留轮次的看图结果换成文字回执(正文一律保留)。 // 它必须在压缩判定之前跑:图片是最贵的一类内容,先移出再评估 token 压力才有意义。 if changed, pruned := cloudAgentPruneInspectedImages(&state.Canonical, nil); changed { @@ -1633,6 +1675,21 @@ func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *c return err } task.InputJSON = string(raw) + // 压力读数只对"模型调用"这一步有意义:媒体任务发的是另一份请求(另一套信封), + // 把读数挂到那份信封上会误导消费方。 + var contextPressure *cloudAgentContextPressure + var requestCanonical canonicalAgentRequest + if media == nil { + if req.Operation != cloudAgentContextCompactionOperation { + canonical, ok := canonicalAgentRequestFromInput(input) + if !ok { + return fmt.Errorf("Agent 模型任务缺少可计量的请求信封") + } + requestCanonical = canonical + value := s.cloudAgentContextPressure(canonical, state.Request.Prompt, state.Request) + contextPressure = &value + } + } if prepare.Order != nil { task.BillingOrderID = prepare.Order.ID } @@ -1682,6 +1739,25 @@ func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *c if state.ContextCompaction != nil && req.Operation == cloudAgentContextCompactionOperation { state.ContextCompaction.Status = "running" } else { + if contextPressure != nil { + config, _ := input["config"].(map[string]any) + model, channelID := stringValue(config["model"]), stringValue(config["channelId"]) + signature := cloudAgentRequestSignature(state, requestCanonical, channelID, model) + cloudAgentExpireTokenAnchorForRequest(run.ID, state, contextPressure.ContextWindowTokens, signature, model, channelID) + cloudAgentRecordMemorySegment(&state.Policy, requestCanonical.SystemPrompt) + payload := cloudAgentContextPressurePayload(*contextPressure, state, requestCanonical) + payload["requestId"] = task.ID + cloudAgentNoteContextWindowResolved(run.ID, state, *contextPressure) + state.event(run.ID, "context_pressure", payload) + state.LastStepTaskID = task.ID + state.LastStepOperation = req.Operation + state.LastStepEstimate = contextPressure.EstimatedInputTokens + state.LastStepSourceBytes = contextPressure.SourceBytes + state.LastStepSignature = signature + state.LastStepModel = model + state.LastStepChannelID = channelID + state.LastStepWindowTokens = contextPressure.ContextWindowTokens + } state.Step++ } } diff --git a/backend/internal/app/cloud_agent_stuck_test.go b/backend/internal/app/cloud_agent_stuck_test.go index ff1f358f7..3fcf3cd10 100644 --- a/backend/internal/app/cloud_agent_stuck_test.go +++ b/backend/internal/app/cloud_agent_stuck_test.go @@ -15,9 +15,9 @@ func TestStuckCloudAgentIsTerminated(t *testing.T) { } run, state := agentMediaRun(t, s, a, "request_approval", "stuck-idle") state.ActiveTaskID, state.MediaTaskID, state.StoryboardTaskID = "", "", "" - if len(state.Events) == 0 { - state.event(run.ID, "assistant_message", map[string]any{"text": "起个头"}) - } + // 事件日志是 append-only(已落库的行不能再改写),所以这里用"追加一条时间戳在十分钟前的 + // 新事件"表达"最后进展在十分钟前",而不是回头改上一条已落库的事件。 + state.event(run.ID, "assistant_message", map[string]any{"text": "起个头"}) state.Events[len(state.Events)-1].CreatedAt = time.Now().Add(-10 * time.Minute) if err := s.repo.MutateCloudAgent("user", run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { return cloudAgentSave(current, &state) @@ -77,9 +77,8 @@ func TestStuckDetectionSkipsRunsWithLiveWork(t *testing.T) { } state.ActiveTaskID = live.ID state.TaskIDs = append(state.TaskIDs, live.ID) - if len(state.Events) == 0 { - state.event(run.ID, "assistant_message", map[string]any{"text": "起个头"}) - } + // 同上:只能追加新事件,不能改写已有事件的时间戳。 + state.event(run.ID, "assistant_message", map[string]any{"text": "起个头"}) state.Events[len(state.Events)-1].CreatedAt = time.Now().Add(-10 * time.Minute) if err := s.repo.MutateCloudAgent("user", run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { return cloudAgentSave(current, &state) @@ -105,9 +104,8 @@ func TestStuckDetectionTreatsTerminalTaskAsIdle(t *testing.T) { } state.ActiveTaskID, state.MediaTaskID = "", "" state.StoryboardTaskID = dead.ID - if len(state.Events) == 0 { - state.event(run.ID, "assistant_message", map[string]any{"text": "起个头"}) - } + // 同上:只能追加新事件,不能改写已有事件的时间戳。 + state.event(run.ID, "assistant_message", map[string]any{"text": "起个头"}) state.Events[len(state.Events)-1].CreatedAt = time.Now().Add(-10 * time.Minute) if err := s.repo.MutateCloudAgent("user", run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { return cloudAgentSave(current, &state) diff --git a/backend/internal/app/cloud_agent_usage_anchor.go b/backend/internal/app/cloud_agent_usage_anchor.go new file mode 100644 index 000000000..161a2d19d --- /dev/null +++ b/backend/internal/app/cloud_agent_usage_anchor.go @@ -0,0 +1,159 @@ +package app + +import ( + "encoding/json" + "strconv" + "strings" +) + +// cloudAgentTokenAnchor 是"上游实测 + 本地估算"的一对读数。 +// 上游用量来自模型自己的分词器(provider 上报),是压力读数可以采信的权威值; +// 本地估算记录的是发出同一次请求时的读法,二者的差就是投影后续请求所需的换算基准。 +type cloudAgentTokenAnchor struct { + TaskID string `json:"taskId"` + Step int `json:"step"` + InputTokens int64 `json:"inputTokens"` + CachedTokens int64 `json:"cachedTokens"` + OutputTokens int64 `json:"outputTokens"` + EstimatedTokens int `json:"estimatedTokens"` + SourceBytes int `json:"sourceBytes"` + Accepted bool `json:"accepted"` + RejectReason string `json:"rejectReason,omitempty"` + // Signature / Model / ChannelID 记录"定锚时的口径":换了模型、线路、系统提示或工具 schema + // 之后,旧实测不再可比,必须作废。 + Signature string `json:"signature,omitempty"` + Model string `json:"model,omitempty"` + ChannelID string `json:"channelId,omitempty"` + // ContextWindowTokens 是定锚时解析到的模型窗口:窗口是"离满窗还有多远"的分母, + // 换了窗口(管理员改了渠道模型能力、或换了路由落点)之后同一个 token 数含义就变了。 + ContextWindowTokens int `json:"contextWindowTokens,omitempty"` +} + +// cloudAgentAnchorMaxAgeSteps 是锚点的最长有效期:超过这么多步没有刷新就作废。 +// 真机实测过"锚点冻结"——估算从 42,581 涨到 66,105,压力却一直停在老读数上。 +const cloudAgentAnchorMaxAgeSteps = 3 + +// cloudAgentStepSignature 是"这一步的口径指纹":模型/渠道/逻辑模型 + 系统提示 + 工具 schema。 +// 它刻意不含消息正文:消息每步都在变,含进去等于每步作废,锚点就永远用不上。 +func cloudAgentStepSignature(state *cloudAgentRuntime) string { + if state == nil { + return "" + } + return cloudAgentRequestSignature(state, state.Canonical, "", "") +} + +func cloudAgentRequestSignature(state *cloudAgentRuntime, canonical canonicalAgentRequest, channelID, model string) string { + tools, _ := json.Marshal(canonical.Tools) + return creationHash(strings.Join([]string{ + state.Request.Model, state.Request.ChannelID, state.Request.ChannelModelKey, state.Request.LogicalModelID, + channelID, model, canonical.SystemPrompt, string(tools), + }, "\u0000")) +} + +// cloudAgentAnchorWindowTokens 把预算折算成锚点口径的窗口读数:没有解析到模型自己声明的 +// 窗口时报 0(未知),调用方据此跳过窗口比较,而不是拿兜底默认窗口当判据。 +func cloudAgentAnchorWindowTokens(budget cloudAgentContextBudget) int { + if !budget.Configured { + return 0 + } + return budget.ContextWindowTokens +} + +// cloudAgentExpireTokenAnchor 让"口径已变、窗口已变或太久没刷新"的锚点作废。 +// 只标记不删除:读数仍要能显示"这个实测是多少、为什么不再用它"。 +func cloudAgentExpireTokenAnchor(runID string, state *cloudAgentRuntime, windowTokens int) { + if state == nil { + return + } + cloudAgentExpireTokenAnchorForRequest(runID, state, windowTokens, cloudAgentStepSignature(state), state.Request.Model, state.Request.ChannelID) +} + +func cloudAgentExpireTokenAnchorForRequest(runID string, state *cloudAgentRuntime, windowTokens int, signature, model, channelID string) { + if state == nil || state.TokenAnchor == nil || !state.TokenAnchor.Accepted { + return + } + anchor := state.TokenAnchor + if anchor.Signature != "" && anchor.Signature != signature { + switch { + case anchor.Model != "" && anchor.Model != model: + anchor.RejectReason = "模型已变化,锚点作废" + state.event(runID, "context_transition", map[string]any{"kind": "model_changed", "reason": "anchor_signature_changed", "text": anchor.RejectReason}) + case anchor.ChannelID != "" && anchor.ChannelID != channelID: + anchor.RejectReason = "供应线路已变化,锚点作废" + state.event(runID, "context_transition", map[string]any{"kind": "route_changed", "reason": "anchor_signature_changed", "text": anchor.RejectReason}) + default: + anchor.RejectReason = "系统提示或工具 schema 已变化,锚点作废" + } + anchor.Accepted = false + return + } + if windowTokens > 0 && anchor.ContextWindowTokens > 0 && windowTokens != anchor.ContextWindowTokens { + anchor.RejectReason = "模型窗口已变化,锚点作废" + anchor.Accepted = false + state.event(runID, "context_transition", map[string]any{ + "kind": "window_changed", "reason": "anchor_window_changed", + "before": map[string]any{"contextWindowTokens": anchor.ContextWindowTokens}, + "after": map[string]any{"contextWindowTokens": windowTokens}, + "text": anchor.RejectReason, + }) + return + } + if state.Step-anchor.Step > cloudAgentAnchorMaxAgeSteps { + anchor.RejectReason = "锚点超过 " + strconv.Itoa(cloudAgentAnchorMaxAgeSteps) + " 步未刷新,已作废" + anchor.Accepted = false + } +} + +// cloudAgentAnchorMinRatio / MaxRatio 是采信上游用量的合理区间。 +// 实测与自估差出一个量级时通常意味着换了模型或计量口径(例如上游只报缓存命中、 +// 或走了不同的协议分支),此时宁可继续用估算,也不要把压力曲线锚到错误基准上。 +const ( + cloudAgentAnchorMinRatio = 0.5 + cloudAgentAnchorMaxRatio = 2.0 +) + +// recordCloudAgentTokenAnchor 用上一步的上游实测用量给上下文压力定锚。 +// 幂等:同一任务只采信一次;没有实测或比值离谱时记录拒绝原因并保留估算。 +// 当前请求的口径变化要等到实际任务输入确定后再判断,不能拿运行态副本替代请求信封。 +func (s *Service) recordCloudAgentTokenAnchor(userID string, state *cloudAgentRuntime) { + if s == nil || state == nil { + return + } + if s.repo == nil || state.LastStepTaskID == "" || state.LastStepEstimate <= 0 || state.LastStepSignature == "" { + return + } + // 只有"模型调用"这一步能配锚点:媒体任务发的是另一份请求(另一套信封), + // 拿它当锚点会把压力算到错误的信封上。 + if state.LastStepOperation != cloudAgentStepOperation { + return + } + if state.TokenAnchor != nil && state.TokenAnchor.TaskID == state.LastStepTaskID { + return + } + log, ok, err := s.repo.APICallLogUsageForTask(userID, state.LastStepTaskID) + if err != nil || !ok { + return + } + // A routed model may select another channel after the request was assembled. + // Do not project that provider's tokenizer onto a different known route. + if log.ChannelID != "" && state.LastStepChannelID != "" && log.ChannelID != state.LastStepChannelID { + return + } + anchor := &cloudAgentTokenAnchor{ + TaskID: state.LastStepTaskID, Step: state.Step, InputTokens: log.InputTokens, + CachedTokens: log.CachedTokens, OutputTokens: log.OutputTokens, + EstimatedTokens: state.LastStepEstimate, SourceBytes: state.LastStepSourceBytes, + Signature: state.LastStepSignature, Model: state.LastStepModel, ChannelID: state.LastStepChannelID, + ContextWindowTokens: state.LastStepWindowTokens, + } + ratio := float64(anchor.InputTokens) / float64(anchor.EstimatedTokens) + switch { + case ratio < cloudAgentAnchorMinRatio: + anchor.RejectReason = "上游实测远低于本地估算,可能换了模型或口径" + case ratio > cloudAgentAnchorMaxRatio: + anchor.RejectReason = "上游实测远高于本地估算,可能换了模型或口径" + default: + anchor.Accepted = true + } + state.TokenAnchor = anchor +} diff --git a/backend/internal/repository/analytics.go b/backend/internal/repository/analytics.go index 95609c53e..b3ddfd95a 100644 --- a/backend/internal/repository/analytics.go +++ b/backend/internal/repository/analytics.go @@ -210,6 +210,25 @@ func (r *Repository) LatestProviderRequestIDsForTasks(taskIDs []string) (map[str return result, nil } +// APICallLogUsageForTask 返回一次任务调用里上游上报的用量。 +// 这是模型自己的分词器算出的计数,是上下文压力可以采信的权威锚点; +// 没有上报(usage_available=false 或输入为 0)时返回 false,调用方退回本地估算。 +func (r *Repository) APICallLogUsageForTask(userID, taskID string) (model.ApiCallLog, bool, error) { + if strings.TrimSpace(userID) == "" || strings.TrimSpace(taskID) == "" { + return model.ApiCallLog{}, false, nil + } + var log model.ApiCallLog + err := r.db.Where("user_id = ? AND task_id = ? AND capability = ? AND status = ? AND usage_available = ?", userID, taskID, "text", model.ApiCallStatusSucceeded, true). + Order("created_at DESC").Limit(1).Find(&log).Error + if err != nil { + return model.ApiCallLog{}, false, err + } + if log.ID == "" || log.InputTokens <= 0 { + return model.ApiCallLog{}, false, nil + } + return log, true, nil +} + func (r *Repository) HasAPICallLogForTask(taskID string) (bool, error) { if strings.TrimSpace(taskID) == "" { return false, nil diff --git a/docs/content/docs/backend/http-api.mdx b/docs/content/docs/backend/http-api.mdx index fd19d17c3..7a3bd05af 100644 --- a/docs/content/docs/backend/http-api.mdx +++ b/docs/content/docs/backend/http-api.mdx @@ -201,6 +201,11 @@ Token 报价的 `estimated` 为 `true`,`quantity` 是预留 Token 数。参考 - Agent 按实际文本模型的 `contextWindowTokens` 与 `maxOutputTokens` 计算输入预算,并在达到该模型预算的压缩阈值时移除较早工具结果中可重新读取的正文,保留工具调用/结果配对、用户原始要求、生成回执及最近完整工具轮次,并允许重新读取。此机制是确定性正文卸载,不是 LLM 语义摘要;运行 checkpoint 的持久化上限与模型上下文窗口独立。技能读取事件只保存回执,不复制正文,正文页只进入模型对话。 - 达到同一条压缩线时整轮不再直接判死:Agent 先暂停步进循环,用一次独立模型调用(任务操作名 `cloud_agent_context_compaction`,按单步墙钟计时但不占本轮的模型调用步数)把历史压成结构化检查点,再以 `Resume` 用压缩后的上下文继续本轮;同一轮最多压缩 4 次,压完仍超过输入预算才回到原有的预算失败路径。检查点是固定字段的 JSON(`version` 为 1,字段为 `historySummary`、`scriptDesign`、`operationHistory`、`pendingTasks`、`currentWork`、`nextStep`、`decisions`、`constraints`、`userPreferences`、`compactedTurnCount`),未知字段一律拒绝,超过 64KiB 判为不合格。压缩后的上下文是「检查点帧 + 确认回执 + 最近 2 对完整对话」:不以 tool 回执或带工具调用的 assistant 开头,检查点帧不计为用户轮次,续轮交接不会再重复追加本轮的用户要求与回复。渠道没有配置模型上下文窗口时,判据退回既有的跨轮历史闸门(`cloudAgentHistoryKeepRounds` 轮与 `cloudAgentHistoryMaxBytes` 字节)。 - 压缩过程对外只有两个事件:`context_compaction_requested` 带触发读数(`basis` 为 `tokens`/`bytes`、`projectedTokens`、`usableInputTokens`、`compactAtTokens`、`pressureRatio`、`budgetSource`、`sourceBytes`、`turnCount`),`context_compacted` 带 `mode`(`model` 为压缩模型输出,`fallback` 为服务端保底检查点)、`resume`、`historyMessages`、`compactedTurnCount`、`droppedTurns`,降级时附 `reason`。压缩模型失败、输出不合规、或本轮在压缩期间被取消/失败,服务端都落保底检查点并写 `mode=fallback`;终态轮次的收尾只落检查点与事件,不改写已经失败或已取消的状态。 +- 每次普通 Agent 步进模型调用发出之前都会落一条 `context_pressure` 事件(`schemaVersion: 2`、`phase: "before_request"`),把"这次请求装了多少、离装不下还有多远"作为可观测读数下发,`requestId` 是本次请求的任务 ID。主读数是 `projectedNextInputTokens`:有可采信的上游实测锚点时等于"锚点实测 + 本地估算的有符号增量"(`tokenSource` / `measurementSource` 为 `provider`,`tokenScale` 给出实测÷估算的换算比例);没有实测时退回纯本地估算(`tokenSource` 为 `estimate`)。两把尺子各有自己的名字、谁也**不覆盖**谁:`estimatedInputTokens`(`readingScope: "next_request"`、`estimateMethod: "local_v1"`)永远是本地下一次请求的估算,`pressureTokens` / `providerUsage`(`providerMeasurementScope: "previous_request"`)永远是上游对**上一次**请求的实测,消费方不得把两者画成一条曲线。本读数只增加可观测性:请求准入与正文裁剪的既有判据不变。 +- 上游实测锚点的采信与治理:上游没有回 usage 的调用(`usage_available=false`)不定锚、不猜用量,读数继续用估算;只有"模型调用"这一步能配锚(媒体任务发的是另一份信封,`operation` 不匹配即跳过)。上游实测与本地估算差出一个量级时不采信(比值低于 0.5× 或高于 2×,通常是换了模型或计量口径),事件给 `anchorRejected` 说明原因并继续用估算。锚点可以跨步沿用,但**换模型、换线路、换系统提示或工具 schema、换模型窗口,以及超过 3 步未刷新**(`anchor.ageSteps`)都会作废:真机上出现过锚点被冻结、估算已经涨了 50% 而读数还停在老值的情况。 +- 事件还带 `breakdown` 占用分布:三个桶(`system` 系统提示(含画布摘要)、`tools` 工具 schema、`messages` 会话消息(含工具结果))各自的字节与估算 Token,协议外壳余量 `envelopeBytes`(tool choice、缓存键这类不属于任何桶的部分,单独报而不摊进桶里),按锚点比例换算的 `tokenScale` 与每桶/每分段的 `scaledTokens`,以及 `systemSegments` 系统提示内部分段(`policy` / `mediaPolicy` / `capabilities` / `execution` / `memory` 个人记忆 / `other` 零头)。分段按编译顺序逐块量出,合计与 `system` 桶严格相等:编译之后才拼接的个人记忆由 `memory` 登记,零头归入 `other`,不出现"分段加不到桶"的错觉。 +- 窗口口径由 `contextWindowTokens` / `reservedOutputTokens` / `overheadTokens` / `inputBudgetTokens` / `usableInputTokens` / `budgetSource` 给出:输入预算 = 上下文窗口 − 输出预留 − overhead,其中 overhead 是工具 schema、协议包装与兜底轮的比例预留(窗口的 4%,夹在 4096–32768),`compactAtTokens` 是既有的"可重读正文就地裁剪"触发线(输入预算的 85%),`budgetSource` 说明这份窗口来自渠道模型还是逻辑模型的线路交集。**没有解析到模型自己声明的窗口时不下发这些字段、也不给占用率**(`modelLimitConfigured: false`):窗口未知就说未知,不拿兜底默认窗口冒充真实能力编一个百分比。 +- `context_transition` 事件说明"读数为什么变":`window_resolved`(窗口从未确认变为已确认,读数改用窗口口径,**不是**上下文变少)、`model_changed` / `route_changed` / `window_changed`(锚点因换模型、换线路或换窗口而作废;窗口变化另带 `before` / `after` 的窗口读数)。它只解释口径与锚点状态,不携带任何授权语义,也不改变执行边界。 - 画布 Agent 的看图能力:当且仅当本轮渠道模型的能力合同声明了图片输入(`text.references.maxImages > 0`)时,额外暴露 `canvas_inspect_image(nodeId, refresh?)`。工具校验图片节点、资源归属、就绪状态、媒体类型和模型文件大小限制;检查点与任务输入只保存 `resource:ID` 占位及授权参考清单。worker 复用资源读取链路,在内存中将占位替换为真实图片数据后展开模型协议,不持久化 base64 或签名 URL,不依赖 `CANVAS_PUBLIC_BASE_URL`,不接受任意外部地址。多图在本批所有 tool 回执后统一附送;图片保留最近 3 个工具轮次,发给模型时还按数量上限保留最新图片,本批超限则明确要求分批查看。同一张图一轮内附送两次后只回执文字,确需重看时传 `refresh=true`。工具成功不等于识别成功,普通模型正文不再自动写入 `visualNote`,旧 `inspected` 标记及观察不跨轮继承;`visualIdentity=unknown` 表示没有已确认缓存,不表示模型不能查看随附图片。画面内文字仍按不可信数据处理;资源读取失败则真实失败,不凭标题推断画面。其他需要公网 URL 的生成协议仍遵循各自存储配置合同。 - 平台支持工具全集为 `agent_profile_read`、`plan_update`、`ask_user`、`recall_lessons`、`remember_lesson`、`canvas_list_node_types`、`canvas_get_state`、`canvas_inspect_image`、`canvas_read_batch_table`、`canvas_read_storyboard`、`skill_read_file`、`skill_search`、`task_get`、`model_list`、`canvas_create_storyboard`、`canvas_edit_storyboard`、`canvas_edit_batch_table`、`canvas_apply_ops`、`canvas_arrange_nodes`、`generate_media`、`image_layer_split`、`image_text_detect`、`image_annotation_render`;单轮实际工具仍按偏好快照、技能、权限、画布上下文和生成预算裁剪。画布写入工具要求本轮授权 `contextScope: ["canvas"]`。禁止 shell、任意网络请求和外部 MCP。`remember_lesson` 在 `read_only` 下不可用。 - `plan_update` 维护本轮结构化待办清单(整表替换,最多 20 项),跨轮继承未完成项,并在界面以 PlanBar 展示。权限为 `request_approval` 时,首次建立至少 2 项的清单会先进入审批;`auto` 直接写入清单。新一轮的第一次模型请求就会带上继承的清单。模型在没有工具调用且清单仍有未完成项时,服务端会催办并要求继续调用工具;这是软约束,不验证工作是否真的完成,也不会在没有清单时强行催建。