Files
AskaEth 72b95df22b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
- 窗口声明:models.json 组级 contextWindow + env LLM_CONTEXT_WINDOW(per-group materialize 播种,0 不写盘、负数报错)。

- 预算三态:CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导;>0 显式覆盖;0|负 关闭压缩(不变)。推导 budget = min(窗口×比例, 窗口−输出保留),比例默认 2/3(CONTEXT_BUDGET_RATIO,1M ⇒ 699050 tokens ≈ 683Ki);输出保留 = max(32768, 该组 maxTokens) 且封顶窗口/2(防配置矛盾时退化成 1 token);窗口未声明 ⇒ 回落 131072 + 恰好一条 WARN。接线在组合根 wire_context.go(唯一决策点)。

- usage/缓存可观测(此前**完全不可观测**):Response 增 CacheHit/CacheMiss/Reasoning tokens + CacheHitRate;解析 OpenAI 兼容(prompt_cache_hit_tokens/prompt_cache_miss_tokens/cached_tokens/reasoning_tokens)与 anthropic(cache_read_input_tokens/cache_creation_input_tokens)两种形状;记录点在适配器 record(换入口不丢成本日志),日志无凭据;单次输入 ≥预算 90% 记 WARN(不阻断)。

- 根治清单「续读」假承诺:抽取期不再按注入预算截断(sidecar 存全文,只受 MaxSidecarBytes=8 MiB 安全上限,ExtractorVersion v1→v2 旧 sidecar 失效重抽);注入预算改在装配期生效 ⇒ ref.Chars/Truncated 是注入侧事实、游标真是继续读的起点;document-read 的 limit 上限改为独立具名常量。

- 顺手修越界:config.go 505 行(超 500)拆出 llm.go。

- 主会话独立验收:隔离 worktree(HEAD + 本切片 39 文件,排除在途 gateway)build/vet 绿、全树 59 包 0 FAIL、linux+darwin 交叉编译绿;变异独立复现(比例改 0.25 ⇒ 推导用例红 262144 vs 699050)。
2026-10-04 17:57:08 +08:00

122 lines
4.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loop
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// TestReportContextPressureWarnsAtNinetyPercent 钉住输入逼近预算的 WARN
// (design/04 附录 A.3 第 5 条):达到预算 90% 记一条 WARN、低于不记,且**不阻断**
// 任何流程(它只是日志)。
func TestReportContextPressureWarnsAtNinetyPercent(t *testing.T) {
t.Parallel()
const budget = 1000
cases := []struct {
name string
promptTokens int
wantWarn bool
}{
{"恰好 90%", 900, true},
{"超过 90%", 990, true},
{"低于 90%", 899, false},
{"提供商未上报用量", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
log, buf := captureLoopLog()
l := New(Options{MaxContextTokens: budget, Log: log})
l.reportContextPressure(c.promptTokens)
got := strings.Contains(buf.String(), "near the context budget")
if got != c.wantWarn {
t.Fatalf("promptTokens=%d budget=%d: WARN=%v, want %v(日志:%q)",
c.promptTokens, budget, got, c.wantWarn, buf.String())
}
if !c.wantWarn {
return
}
records := loopJSONRecords(t, buf)
if len(records) != 1 || records[0]["prompt_tokens"] != float64(c.promptTokens) {
t.Fatalf("告警记录 = %+v, want 恰好一条且带上 prompt_tokens=%d", records, c.promptTokens)
}
})
}
}
// TestReportContextPressureSilentWhenCompactionOff 钉住「压缩关闭时不吵」:
// 关闭压缩(预算 0/负)的部署没有预算可言,绝不能因为预算为 0 而把每条调用都
// 判成「超 90%」。
func TestReportContextPressureSilentWhenCompactionOff(t *testing.T) {
t.Parallel()
for _, budget := range []int{0, -1} {
log, buf := captureLoopLog()
l := New(Options{MaxContextTokens: budget, Log: log})
l.reportContextPressure(1 << 20)
if strings.Contains(buf.String(), "near the context budget") {
t.Fatalf("预算 %d 时不该记 WARN:%q", budget, buf.String())
}
}
}
// TestStreamStepCarriesPromptTokensForPressure 钉住接线:流式主路径把提供商上报的
// 输入 token 带进 stepOutcome,逼近预算的判定因此真的会在这条路径上发生 ——
// 而不是只在 reportContextPressure 这个函数里「理论上可行」。
func TestStreamStepCarriesPromptTokensForPressure(t *testing.T) {
t.Parallel()
log, buf := captureLoopLog()
ad := &mockAdapter{stream: []mgResp{{resp: model.Response{Model: "m", Content: "好", PromptTokens: 950}}}}
l := New(Options{Agent: newTestAgent(t, ad, nil), MaxContextTokens: 1000, Log: log,
OnStream: func(model.StreamEvent) {}})
if _, err := l.Run(context.Background(), "sess-pressure", []model.Message{model.TextMessage(model.RoleUser, "在吗")}); err != nil {
t.Fatalf("Run: %v", err)
}
if !strings.Contains(buf.String(), "near the context budget") {
t.Fatalf("流式路径没有触发预算告警:%q", buf.String())
}
records := loopJSONRecords(t, buf)
found := false
for _, rec := range records {
if rec["msg"] == "model input is near the context budget" {
found = true
if rec["prompt_tokens"] != float64(950) || rec["budget"] != float64(1000) {
t.Fatalf("告警字段 = %+v, want prompt_tokens=950 / budget=1000", rec)
}
}
}
if !found {
t.Fatalf("日志里没有预算告警记录:%q", buf.String())
}
}
// captureLoopLog 返回输出 JSON 记录的 logger 与缓冲:断言结构化字段(而不是文本
// 子串)时用它。nil logger 在循环里会被换成丢弃 logger,所以测试必须显式注入。
func captureLoopLog() (*slog.Logger, *bytes.Buffer) {
buf := &bytes.Buffer{}
return slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
}
// loopJSONRecords 把缓冲里的 JSON 记录逐条解出来,便于按字段断言。
func loopJSONRecords(t *testing.T, buf *bytes.Buffer) []map[string]any {
t.Helper()
out := []map[string]any{}
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
rec := map[string]any{}
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("日志行不是合法 JSON: %v(%q)", err, line)
}
out = append(out, rec)
}
return out
}