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)。
This commit is contained in:
1 parent
5b2b385524
commit
72b95df22b
39 files changed
+2496
-377
No files matched your search
+77
-10
@@ -1,16 +1,83 @@
|
||||
package main
|
||||
|
||||
import "git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
// contextBudget 返回循环的上下文溢出压缩预算(CONTEXT_MAX_TOKENS,
|
||||
// design/04 §1.3)。**非正 = 关闭压缩**,行为与本条接线前逐字节一致;
|
||||
// 未配置时由 config 层给出 DefaultContextMaxTokens(即默认启用)。
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
)
|
||||
|
||||
// 本文件是**上下文预算的唯一决策点**(组合根):把「配置 + 生效模型组」摊平成
|
||||
// config.ContextBudgetInput,喂给纯函数 config.DeriveContextBudget,再把结论交给
|
||||
// loop.Options.MaxContextTokens 与 loop.Options.DocumentMaxInjectChars。
|
||||
//
|
||||
// 取值只从配置来,组合根不做二次猜测:阈值语义(含「0 关掉」)由配置层
|
||||
// 一处定义,组合根只负责把它交给 loop.Options.MaxContextTokens。
|
||||
func (a *app) contextBudget() int {
|
||||
if a.cfg == nil {
|
||||
return config.DefaultContextMaxTokens
|
||||
// 为什么推导放在这里而不是配置层:窗口与 maxTokens 都在 models.json 的**生效组**
|
||||
// 上(面板可编辑、热重载),而 CONTEXT_MAX_TOKENS 在 .env。两个来源的会合点只有
|
||||
// 组合根,推导函数本身因此保持纯粹、可表驱动测试。
|
||||
//
|
||||
// 语义(design/04 §1.3 + 附录 A.3):
|
||||
// - CONTEXT_MAX_TOKENS 未设置 ⇒ 按窗口推导 budget = min(窗口 × 比例, 窗口 − 输出保留);
|
||||
// - > 0 ⇒ 显式覆盖;0 / 负数 ⇒ 关闭压缩(与接线前一致);
|
||||
// - 窗口未声明 ⇒ 回落 config.DefaultModelContextWindow 并记**恰好一条** WARN;
|
||||
// - 比例非法(CONTEXT_BUDGET_RATIO)⇒ 记一条带原始取值的 WARN 并回落 2/3。
|
||||
|
||||
// ctxBudget 是组合根推导出的两项预算:循环的溢出压缩预算与单份附件注入预算。
|
||||
type ctxBudget struct {
|
||||
// Budget 是压缩预算的完整结论(含推导来源,供日志与断言)。
|
||||
Budget config.ContextBudget
|
||||
// DocumentInjectChars 是单份附件注入上下文的字符上限(T1 §7.2)。
|
||||
DocumentInjectChars int
|
||||
}
|
||||
|
||||
// contextBudget 计算本次运行生效的上下文预算。envPath 是启动时合并的 .env 路径
|
||||
// (比例键的读取与其余配置同一套优先级;空串表示不看 .env)。
|
||||
//
|
||||
// 模型组取**生效组**(models.json 下标 0,或 .env 合成的组):压缩预算与注入预算
|
||||
// 都是循环级的单一值,而生效组才是新请求默认用的那个。
|
||||
func (a *app) contextBudget(envPath string) ctxBudget {
|
||||
in := config.ContextBudgetInput{}
|
||||
docOverride := 0
|
||||
if a.cfg != nil {
|
||||
in.Override = a.cfg.Context.MaxTokens
|
||||
in.OverrideConfigured = a.cfg.Context.MaxTokensSet
|
||||
docOverride = a.cfg.Context.DocumentMaxInjectChars
|
||||
}
|
||||
return a.cfg.Context.MaxTokens
|
||||
if a.llm != nil {
|
||||
g := a.llm.Group()
|
||||
in.Window = g.ContextWindow
|
||||
in.GroupMaxTokens = g.MaxTokens
|
||||
}
|
||||
return deriveContextBudget(sourcedWiringLog(a.log), envPath, in, docOverride)
|
||||
}
|
||||
|
||||
// deriveContextBudget 是 contextBudget 的可测内核:取值来源收成显式参数,
|
||||
// 日志与「恰好一条 WARN」也在这里(测试用捕获型 handler 断言条数)。
|
||||
func deriveContextBudget(log *slog.Logger, envPath string, in config.ContextBudgetInput, docOverride int) ctxBudget {
|
||||
ratio, err := config.LoadContextBudgetRatio(envPath)
|
||||
if err != nil {
|
||||
// 非法比例:记一条带原始取值的 WARN 并回落默认(AGENTS §10:不许静默失效)。
|
||||
log.Warn("context budget ratio invalid, falling back",
|
||||
"env", config.ContextBudgetRatioEnv, "error", err,
|
||||
"fallback_ratio", config.DefaultContextBudgetRatio)
|
||||
ratio = config.DefaultContextBudgetRatio
|
||||
}
|
||||
in.Ratio = ratio
|
||||
budget := config.DeriveContextBudget(in)
|
||||
// 单份附件注入预算:显式覆盖优先,否则随推导出的预算走。
|
||||
docChars := docOverride
|
||||
if docChars <= 0 {
|
||||
docChars = loop.DocumentInjectCharsFor(budget.Tokens)
|
||||
}
|
||||
if budget.Mode == config.ContextBudgetDerived && !budget.WindowDeclared {
|
||||
// 窗口未声明:**恰好一条** WARN(每次启动只推导一次)。
|
||||
log.Warn("model context window not declared, falling back",
|
||||
"env", "LLM_CONTEXT_WINDOW", "json_key", "contextWindow",
|
||||
"window", budget.Window, "hint", "声明窗口才能吃满模型的真实窗口")
|
||||
}
|
||||
log.Info("context budget",
|
||||
"mode", string(budget.Mode), "tokens", budget.Tokens,
|
||||
"window", budget.Window, "window_declared", budget.WindowDeclared,
|
||||
"ratio", budget.Ratio, "output_reserve", budget.OutputReserve,
|
||||
"document_inject_chars", docChars)
|
||||
return ctxBudget{Budget: budget, DocumentInjectChars: docChars}
|
||||
}
|
||||
+237
-42
@@ -1,7 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -14,10 +17,14 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件锁定 P0-11:上下文溢出压缩在生产里**真的启用**,且阈值来自
|
||||
// CONTEXT_MAX_TOKENS。断言落在「模型实际看到的请求消息」上:
|
||||
// 预算小时长对话被压成「一条前导摘要 + 近期尾部」,摘要消息确实进了 prompt;
|
||||
// 预算为 0 时行为与接线前逐条一致(一条消息都不少)。
|
||||
// 本文件锁定上下文预算的取值链(design/04 §1.3 + 附录 A.3):
|
||||
//
|
||||
// - **窗口声明**(models.json 组的 contextWindow / LLM_CONTEXT_WINDOW)与推导
|
||||
// budget = min(窗口 × 比例, 窗口 − 输出保留);
|
||||
// - CONTEXT_MAX_TOKENS 的三态(未设置 ⇒ 推导 / >0 ⇒ 覆盖 / 0 或负 ⇒ 关闭);
|
||||
// - 窗口未声明时**恰好一条** WARN;
|
||||
// - 单份附件的注入预算随推导预算走,且真的让「清单游标 → document-read」这条
|
||||
// 续读链路跑通(S4 实测的断裂,切片 T1 §7.2)。
|
||||
|
||||
// loopCompactSummaryMarker 是 loop 压缩摘要消息的开头(internal/runtime/loop/
|
||||
// compactor.go 的 summaryMarker)。测试按这个可观测契约判定「摘要进了 prompt」。
|
||||
@@ -56,8 +63,8 @@ func longConversation() []model.Message {
|
||||
}
|
||||
|
||||
// runWithBudget 用给定预算驱动一次真实循环(装配形状与组合根一致:
|
||||
// MaxContextTokens 取自 a.contextBudget(),Compactor 不注入 —— 由 loop 选用
|
||||
// 内置的结构化 compactor),返回模型实际看到的请求。
|
||||
// MaxContextTokens 取自推导结论,Compactor 不注入 —— 由 loop 选用内置的结构化
|
||||
// compactor),返回模型实际看到的请求。
|
||||
func runWithBudget(t *testing.T, budget int, msgs []model.Message) model.Request {
|
||||
t.Helper()
|
||||
|
||||
@@ -104,41 +111,223 @@ func loopText(m model.Message) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestContextBudget_ComesFromConfigAndDefaultsToTheLoopDefault 锁定取值链:
|
||||
// 配置层默认 == loop.DefaultMaxContextTokens(两个常量不许漂移),
|
||||
// 组合根读的就是配置值(含「0 = 关闭」这条语义)。
|
||||
func TestContextBudget_ComesFromConfigAndDefaultsToTheLoopDefault(t *testing.T) {
|
||||
// 环境里若已有 CONTEXT_MAX_TOKENS,配置层会优先用它,默认值断言就不成立;
|
||||
// 用 t.Setenv 钉空串也不行(空串会被解析成 0),因此这里显式清掉再还原。
|
||||
if prev, had := os.LookupEnv("CONTEXT_MAX_TOKENS"); had {
|
||||
_ = os.Unsetenv("CONTEXT_MAX_TOKENS")
|
||||
t.Cleanup(func() { _ = os.Setenv("CONTEXT_MAX_TOKENS", prev) })
|
||||
}
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("k", 40))
|
||||
// capturingLog 返回输出 JSON 记录的 logger 与缓冲:这里要按**条数**断言 WARN
|
||||
// (「窗口未声明恰好一条」),文本子串数不出来。
|
||||
func capturingLog() (*slog.Logger, *bytes.Buffer) {
|
||||
buf := &bytes.Buffer{}
|
||||
return slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
|
||||
}
|
||||
|
||||
cfg, err := config.Load(filepath.Join(t.TempDir(), "absent.env"))
|
||||
// logRecords 把缓冲里的 JSON 记录逐条解出来。
|
||||
func logRecords(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
|
||||
}
|
||||
|
||||
// countRecords 数出消息里包含 substr 的记录条数(只看 msg 与 level)。
|
||||
func countRecords(records []map[string]any, level, substr string) int {
|
||||
n := 0
|
||||
for _, rec := range records {
|
||||
msg, _ := rec["msg"].(string)
|
||||
if level != "" && rec["level"] != level {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(msg, substr) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// envWindowStack 造一个「生效组声明了窗口」的模型栈:models.json 不存在时
|
||||
// LoadModels 会回落到 .env 合成的组,而 LLM_CONTEXT_WINDOW 正是该组的窗口来源。
|
||||
func envWindowStack(t *testing.T, window, maxTokens int) *model.RuntimeLLM {
|
||||
t.Helper()
|
||||
return model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{
|
||||
Provider: "openai",
|
||||
BaseURL: "https://api.example/v1",
|
||||
Model: "test-model",
|
||||
ContextWindow: window,
|
||||
}, testLog())
|
||||
}
|
||||
|
||||
// modelsWindowStack 造一个「生效组是 models.json 里那一组」的模型栈:组级
|
||||
// contextWindow / maxTokens 只在 models.json 里有出处,因此这里真写一份文件。
|
||||
func modelsWindowStack(t *testing.T, window, maxTokens int) *model.RuntimeLLM {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "models.json")
|
||||
raw, err := json.Marshal([]map[string]any{{
|
||||
"id": "deepseek", "provider": "openai", "baseUrl": "https://api.example/v1",
|
||||
"apiKey": "sk-test", "model": "test-model",
|
||||
"contextWindow": window, "maxTokens": maxTokens,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("config.Load: %v", err)
|
||||
t.Fatalf("marshal models.json: %v", err)
|
||||
}
|
||||
if cfg.Context.MaxTokens != config.DefaultContextMaxTokens {
|
||||
t.Fatalf("CONTEXT_MAX_TOKENS 默认值 = %d,want %d", cfg.Context.MaxTokens, config.DefaultContextMaxTokens)
|
||||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||
t.Fatalf("write models.json: %v", err)
|
||||
}
|
||||
if config.DefaultContextMaxTokens != loop.DefaultMaxContextTokens {
|
||||
t.Fatalf("默认值与 loop 的常量漂移:%d vs %d",
|
||||
config.DefaultContextMaxTokens, loop.DefaultMaxContextTokens)
|
||||
return model.NewRuntimeLLM(path, config.LLMConfig{}, testLog())
|
||||
}
|
||||
|
||||
// TestContextBudget_DerivedFromTheDeclaredWindow 钉住推导链的接线:窗口取自
|
||||
// **生效模型组**,预算按附录 A.3 的公式算,比例来自 CONTEXT_BUDGET_RATIO。
|
||||
func TestContextBudget_DerivedFromTheDeclaredWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
envPath := filepath.Join(t.TempDir(), "absent.env")
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
|
||||
got := a.contextBudget(envPath)
|
||||
if got.Budget.Tokens != 699050 || got.Budget.Mode != config.ContextBudgetDerived {
|
||||
t.Fatalf("1M 窗口的预算 = %+v, want 699050/derived", got.Budget)
|
||||
}
|
||||
if got := (&app{cfg: cfg}).contextBudget(); got != loop.DefaultMaxContextTokens {
|
||||
t.Fatalf("contextBudget() = %d,want %d", got, loop.DefaultMaxContextTokens)
|
||||
// 单份附件注入预算随推导预算走:1M 窗口下远大于旧的 64K 卡死值。
|
||||
if got.DocumentInjectChars <= 64*1024 {
|
||||
t.Fatalf("1M 窗口下的注入预算 = %d, want > 64K", got.DocumentInjectChars)
|
||||
}
|
||||
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
|
||||
t.Fatalf("窗口已声明却记了 %d 条「未声明」WARN", n)
|
||||
}
|
||||
|
||||
// 显式取值原样透传;0 = 关闭。
|
||||
cfg.Context.MaxTokens = 4096
|
||||
if got := (&app{cfg: cfg}).contextBudget(); got != 4096 {
|
||||
t.Fatalf("contextBudget() = %d,want 4096", got)
|
||||
// 组级 maxTokens=384K(思考模式上限)⇒ 输出保留吃满 ⇒ min(699050, 1M−384K)。
|
||||
a.llm = modelsWindowStack(t, 1048576, 384*1024)
|
||||
got = a.contextBudget(envPath)
|
||||
if got.Budget.Tokens != 655360 || got.Budget.OutputReserve != 384*1024 {
|
||||
t.Fatalf("带输出保留的预算 = %+v, want 655360 / reserve 393216", got.Budget)
|
||||
}
|
||||
cfg.Context.MaxTokens = 0
|
||||
if got := (&app{cfg: cfg}).contextBudget(); got != 0 {
|
||||
t.Fatalf("contextBudget() = %d,want 0(关闭)", got)
|
||||
}
|
||||
|
||||
// TestContextBudget_UndeclaredWindowWarnsExactlyOnce 钉住回落与「恰好一条 WARN」:
|
||||
// 没声明窗口的部署照常工作,但必须被告知「要吃到 1M 得声明窗口」,且只被告知一次。
|
||||
func TestContextBudget_UndeclaredWindowWarnsExactlyOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
envPath := filepath.Join(t.TempDir(), "absent.env")
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 0, 0)}
|
||||
|
||||
got := a.contextBudget(envPath)
|
||||
if got.Budget.Window != config.DefaultModelContextWindow || got.Budget.WindowDeclared {
|
||||
t.Fatalf("未声明窗口 = %+v, want 回落 %d 且 WindowDeclared=false",
|
||||
got.Budget, config.DefaultModelContextWindow)
|
||||
}
|
||||
if got.Budget.Tokens != 87381 {
|
||||
t.Fatalf("回落窗口下的预算 = %d, want 87381(131072 × 2/3 截尾)", got.Budget.Tokens)
|
||||
}
|
||||
records := logRecords(t, buf)
|
||||
if n := countRecords(records, "WARN", "not declared"); n != 1 {
|
||||
t.Fatalf("「窗口未声明」WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
|
||||
}
|
||||
// WARN 必须点出两个声明入口,否则主人不知道该改哪儿。
|
||||
warn := ""
|
||||
for _, rec := range records {
|
||||
if msg, _ := rec["msg"].(string); strings.Contains(msg, "not declared") {
|
||||
warn = msg
|
||||
if rec["env"] != "LLM_CONTEXT_WINDOW" || rec["json_key"] != "contextWindow" {
|
||||
t.Fatalf("WARN 没点出声明入口: %v", rec)
|
||||
}
|
||||
}
|
||||
}
|
||||
if warn == "" {
|
||||
t.Fatal("没有找到窗口未声明的 WARN")
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_OverrideAndOff 钉住 CONTEXT_MAX_TOKENS 的三态:>0 覆盖、
|
||||
// 显式 0 关闭、负值关闭 —— 且关闭时**不推导**(窗口未声明也不该冒出 WARN)。
|
||||
func TestContextBudget_OverrideAndOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
envPath := filepath.Join(t.TempDir(), "absent.env")
|
||||
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
a.cfg.Context.MaxTokens = 600
|
||||
if got := a.contextBudget(envPath); got.Budget.Tokens != 600 || got.Budget.Mode != config.ContextBudgetOverride {
|
||||
t.Fatalf("显式覆盖 = %+v, want 600/override", got.Budget)
|
||||
}
|
||||
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
|
||||
t.Fatalf("显式覆盖与窗口无关,却记了 %d 条「未声明」WARN", n)
|
||||
}
|
||||
|
||||
for _, override := range []int{0, -1} {
|
||||
a.cfg.Context.MaxTokens = override
|
||||
a.cfg.Context.MaxTokensSet = true // 显式 0 = 关闭(0 与「未设置」必须分开)
|
||||
got := a.contextBudget(envPath)
|
||||
if got.Budget.Tokens != override || got.Budget.Mode != config.ContextBudgetOff {
|
||||
t.Fatalf("override=%d ⇒ %+v, want off", override, got.Budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_InvalidRatioWarnsAndFallsBack 钉住非法比例的处理:记一条带
|
||||
// 原始取值的 WARN 并回落 2/3,绝不静默生效(AGENTS §10)。
|
||||
func TestContextBudget_InvalidRatioWarnsAndFallsBack(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
envPath := filepath.Join(dir, "test.env")
|
||||
if err := os.WriteFile(envPath, []byte("CONTEXT_BUDGET_RATIO=四分之三\n"), 0o600); err != nil {
|
||||
t.Fatalf("写 .env: %v", err)
|
||||
}
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
|
||||
got := a.contextBudget(envPath)
|
||||
if got.Budget.Ratio != config.DefaultContextBudgetRatio || got.Budget.Tokens != 699050 {
|
||||
t.Fatalf("非法比例下的预算 = %+v, want 回落 2/3 ⇒ 699050", got.Budget)
|
||||
}
|
||||
records := logRecords(t, buf)
|
||||
if n := countRecords(records, "WARN", "ratio invalid"); n != 1 {
|
||||
t.Fatalf("非法比例的 WARN 条数 = %d, want 1(记录:%v)", n, records)
|
||||
}
|
||||
|
||||
// 合法比例真的生效:1/2 × 1M = 524288。
|
||||
if err := os.WriteFile(envPath, []byte("CONTEXT_BUDGET_RATIO=1/2\n"), 0o600); err != nil {
|
||||
t.Fatalf("写 .env: %v", err)
|
||||
}
|
||||
if got := a.contextBudget(envPath); got.Budget.Tokens != 524288 {
|
||||
t.Fatalf("1/2 比例下的预算 = %d, want 524288", got.Budget.Tokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_DocumentInjectCharsOverride 钉住注入预算的显式覆盖优先:
|
||||
// DOCUMENT_MAX_INJECT_CHARS 是「至少可配」的落点(任务书 §2 第 7 条)。
|
||||
func TestContextBudget_DocumentInjectCharsOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||
a.cfg.Context.DocumentMaxInjectChars = 4096
|
||||
got := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
|
||||
if got.DocumentInjectChars != 4096 {
|
||||
t.Fatalf("显式注入预算 = %d, want 4096", got.DocumentInjectChars)
|
||||
}
|
||||
if got.Budget.Tokens != 699050 {
|
||||
t.Fatalf("注入预算的覆盖不该影响压缩预算:%d", got.Budget.Tokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextBudget_DefaultsToNoiseFreeLoopDefault 锁定取值链的兜底:显式覆盖与
|
||||
// 推导都不适用时(这里用显式覆盖举最小的例子),组合根读的就是配置值本身。
|
||||
func TestContextBudget_DefaultsToNoiseFreeLoopDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
log, buf := capturingLog()
|
||||
a := &app{log: log, cfg: &config.Config{}}
|
||||
a.cfg.Context.MaxTokens = config.MinContextOutputReserve
|
||||
a.cfg.Context.MaxTokensSet = true
|
||||
|
||||
if got := a.contextBudget(filepath.Join(t.TempDir(), "absent.env")); got.Budget.Tokens != config.MinContextOutputReserve {
|
||||
t.Fatalf("contextBudget = %+v, want %d", got.Budget, config.MinContextOutputReserve)
|
||||
}
|
||||
if n := countRecords(logRecords(t, buf), "WARN", "not declared"); n != 0 {
|
||||
t.Fatalf("覆盖态下不该有窗口 WARN(记了 %d 条)", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +340,7 @@ func TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt(t *test
|
||||
a.cfg.Context.MaxTokens = 600 // 远小于 longConversation 的体量
|
||||
msgs := longConversation()
|
||||
|
||||
req := runWithBudget(t, a.contextBudget(), msgs)
|
||||
req := runWithBudget(t, a.contextBudget(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, msgs)
|
||||
|
||||
summaries := summaryMessages(req)
|
||||
if len(summaries) != 1 {
|
||||
@@ -173,16 +362,18 @@ func TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt(t *test
|
||||
}
|
||||
}
|
||||
|
||||
// TestContextCompaction_ZeroBudgetKeepsTodaysBehavior 是真断言②:预算为 0
|
||||
// TestContextCompaction_ZeroBudgetKeepsTodaysBehavior 是真断言②:显式 0
|
||||
// (关闭)时,行为与接线前完全一致 —— 喂进去的消息一条不少地进了请求,
|
||||
// 且没有任何摘要消息。
|
||||
func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := &app{log: testLog(), cfg: &config.Config{}} // Context.MaxTokens 零值 = 关闭
|
||||
a := &app{log: testLog(), cfg: &config.Config{}}
|
||||
a.cfg.Context.MaxTokens = 0
|
||||
a.cfg.Context.MaxTokensSet = true // 显式 0 = 关闭(未设置会走推导)
|
||||
msgs := longConversation()
|
||||
|
||||
req := runWithBudget(t, a.contextBudget(), msgs)
|
||||
req := runWithBudget(t, a.contextBudget(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, msgs)
|
||||
|
||||
if len(req.Messages) != len(msgs)+1 {
|
||||
t.Fatalf("请求消息数 = %d,want %d(指令 + 全部喂入消息)", len(req.Messages), len(msgs)+1)
|
||||
@@ -195,10 +386,9 @@ func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) {
|
||||
// TestWireRuntime_UsesTheCompositionRootSeams 是一道「接线存在性」防回归检查。
|
||||
//
|
||||
// 组合根本身(wireRuntime → newApp)无法在单元测试里实例化:它要开库、迁移、
|
||||
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此 P0-5 / P0-11 的
|
||||
// 「组合根确实走了这两条缝」只能在源码层钉住:wire_runtime.go 必须调用
|
||||
// a.buildGuardrailPipeline(),并且把 MaxContextTokens 取自 a.contextBudget()。
|
||||
// 有人把这两行改回空管线 / 不设预算时,这里立刻红。
|
||||
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此「组合根确实走了这些缝」
|
||||
// 只能在源码层钉住:wire_runtime.go 必须调用 a.buildGuardrailPipeline()、
|
||||
// 必须把预算取自 a.contextBudget(envPath),并且把两项预算都交给 loop。
|
||||
func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -206,7 +396,12 @@ func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read wire_runtime.go: %v", err)
|
||||
}
|
||||
for _, want := range []string{"a.buildGuardrailPipeline()", "a.contextBudget()"} {
|
||||
for _, want := range []string{
|
||||
"a.buildGuardrailPipeline()",
|
||||
"a.contextBudget(envPath)",
|
||||
"MaxContextTokens: ctxBudget.Budget.Tokens",
|
||||
"DocumentMaxInjectChars: ctxBudget.DocumentInjectChars",
|
||||
} {
|
||||
if !strings.Contains(string(src), want) {
|
||||
t.Fatalf("wire_runtime.go 里找不到 %q —— 组合根没有走这条缝", want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
iotools "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io"
|
||||
)
|
||||
|
||||
// 本文件是切片 T1 §7.2 的**端到端**成对契约(S4 实测断裂的正面用例):
|
||||
// 装配期注入预算与文档读侧共用同一套字符坐标系,因此清单行给的续读游标真的能
|
||||
// 被 document-read 用起来。预算推导本身的用例在 wire_context_test.go。
|
||||
|
||||
// docE2EName 是合法存储名形态(32 位十六进制 + .txt)。
|
||||
const docE2EName = "0123456789abcdef0123456789abcdef.txt"
|
||||
|
||||
// docBudgetFixture 是一份端到端夹具:隔离数据目录 + 已落盘的正文 + 生产解析器 +
|
||||
// 生产 document-read 工具 + 一个能捕获模型请求的循环。
|
||||
type docBudgetFixture struct {
|
||||
cfg *config.Config
|
||||
body string
|
||||
ad *compactProbeAdapter
|
||||
read sdk.Tool
|
||||
cursor int
|
||||
}
|
||||
|
||||
// newDocBudgetFixture 造夹具并跑**一次真实的回合**,返回模型看到的清单游标。
|
||||
func newDocBudgetFixture(t *testing.T, injectBudget int) *docBudgetFixture {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
uploads := filepath.Join(dataDir, "uploads", "documents")
|
||||
if err := os.MkdirAll(uploads, 0o700); err != nil {
|
||||
t.Fatalf("建上传目录: %v", err)
|
||||
}
|
||||
fx := &docBudgetFixture{cfg: &config.Config{DataDir: dataDir}, body: strings.Repeat("abcdefghij", 20000)}
|
||||
if err := os.WriteFile(filepath.Join(uploads, docE2EName), []byte(fx.body), 0o600); err != nil {
|
||||
t.Fatalf("写上传件: %v", err)
|
||||
}
|
||||
|
||||
adapter := &compactProbeAdapter{}
|
||||
ag, err := agent.New("你是昔涟。", nil, nil, agent.WithModel(adapter, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
l := loop.New(loop.Options{
|
||||
Agent: ag,
|
||||
DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
|
||||
DocumentMaxInjectChars: injectBudget,
|
||||
OnStream: func(model.StreamEvent) {},
|
||||
})
|
||||
msg := model.DocumentMessage(model.RoleUser, "看看这个附件",
|
||||
model.Document{Name: docE2EName, MIME: "text/plain", Size: int64(len(fx.body))})
|
||||
if _, err := l.Run(context.Background(), "sess-doc", []model.Message{msg}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(adapter.reqs) == 0 {
|
||||
t.Fatal("没有捕获到模型请求")
|
||||
}
|
||||
fx.ad = adapter
|
||||
|
||||
tools, err := iotools.New(fx.cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("iotools.New: %v", err)
|
||||
}
|
||||
fx.read = documentReadToolByName(t, tools)
|
||||
|
||||
req := adapter.reqs[len(adapter.reqs)-1]
|
||||
prompt := loopText(req.Messages[len(req.Messages)-1])
|
||||
fx.cursor, _ = cursorFromManifest(t, prompt)
|
||||
return fx
|
||||
}
|
||||
|
||||
// TestDocumentCursor_ContinuationReadsBeyondTheInjectionBudget 是切片 T1 §7.2 的
|
||||
// **端到端**成对契约(S4 实测断裂的正面用例):正文远大于注入预算时
|
||||
//
|
||||
// ①清单行带 truncated + 续读游标(游标 = 实际注入的字符数);
|
||||
// ②把清单行里那个游标原样交给 `document-read`,拿到的是注入预算**之外**的正文。
|
||||
//
|
||||
// 三个角色都是生产实现:装配期是真实的 loop.resolveDocuments,sidecar 由真实的
|
||||
// documents.Resolver 写出,读取走真实的 document-read 工具。唯一被单独钉住的是
|
||||
// 「注入预算 = 1000 字符」,那正是本切片新增的可配置项。
|
||||
func TestDocumentCursor_ContinuationReadsBeyondTheInjectionBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
const injectBudget = 1000
|
||||
fx := newDocBudgetFixture(t, injectBudget)
|
||||
|
||||
if fx.cursor != injectBudget {
|
||||
t.Fatalf("清单游标 = %d, want %d(= 实际注入的字符数)", fx.cursor, injectBudget)
|
||||
}
|
||||
prompt := loopText(fx.ad.reqs[len(fx.ad.reqs)-1].Messages[len(fx.ad.reqs[len(fx.ad.reqs)-1].Messages)-1])
|
||||
_, injected := cursorFromManifest(t, prompt)
|
||||
if injected != fx.body[:injectBudget] {
|
||||
t.Fatalf("注入正文 = %d 字符,want 正文的前 %d 字符", len(injected), injectBudget)
|
||||
}
|
||||
|
||||
// ② 照游标续读:拿到的是注入预算之外的正文(旧实现这里恒为空 —— 断裂所在)。
|
||||
first := callDocumentRead(t, fx.read, map[string]any{"name": docE2EName, "offset": fx.cursor})
|
||||
if first["offset"] != float64(fx.cursor) {
|
||||
t.Fatalf("续读 offset = %v, want %d", first["offset"], fx.cursor)
|
||||
}
|
||||
text, _ := first["text"].(string)
|
||||
if text == "" {
|
||||
t.Fatal("按清单游标续读读到了空正文 —— 清单给的承诺是假的")
|
||||
}
|
||||
if want := fx.body[fx.cursor : fx.cursor+len(text)]; text != want {
|
||||
t.Fatalf("续读正文不是注入预算之外的那一段:\n got %q\nwant %q", text, want)
|
||||
}
|
||||
|
||||
// 一路翻页到底:拼起来必须正好是注入预算之外的全文(两边同一坐标系)。
|
||||
joined := text
|
||||
offset := fx.cursor + len(text)
|
||||
for i := 0; i < 100 && first["truncated"] == true; i++ {
|
||||
first = callDocumentRead(t, fx.read, map[string]any{"name": docE2EName, "offset": offset})
|
||||
page, _ := first["text"].(string)
|
||||
joined += page
|
||||
offset += len(page)
|
||||
}
|
||||
if joined != fx.body[fx.cursor:] {
|
||||
t.Fatalf("分页续读拼出 %d 字符,want %d(注入预算之外的全文)", len(joined), len(fx.body)-fx.cursor)
|
||||
}
|
||||
if total, _ := first["totalChars"].(float64); int(total) != len(fx.body) {
|
||||
t.Fatalf("totalChars = %v, want 全文 %d 字符", first["totalChars"], len(fx.body))
|
||||
}
|
||||
}
|
||||
|
||||
// cursorFromManifest 从模型看到的上下文里取出清单行与续读游标(offset=N)。它刻意
|
||||
// **解析文本**而不是读内部字段:模型能看到的就只有这一行,游标能不能被模型用起来,
|
||||
// 正是这条契约要证明的事。返回游标与注入块(清单行 + 换行 + 注入正文)。
|
||||
func cursorFromManifest(t *testing.T, prompt string) (int, string) {
|
||||
t.Helper()
|
||||
start := strings.Index(prompt, "[附件 ")
|
||||
if start < 0 {
|
||||
t.Fatalf("上下文里没有清单行:%q", prompt)
|
||||
}
|
||||
block := prompt[start:]
|
||||
line, injected, ok := strings.Cut(block, "]\n")
|
||||
if !ok {
|
||||
t.Fatalf("清单行没有闭合或没有正文行:%q", block)
|
||||
}
|
||||
if !strings.Contains(line, "已截断") {
|
||||
t.Fatalf("清单行没有如实标注截断:%q", line)
|
||||
}
|
||||
i := strings.Index(line, "offset=")
|
||||
if i < 0 {
|
||||
t.Fatalf("清单行没有续读游标:%q", line)
|
||||
}
|
||||
digits := line[i+len("offset="):]
|
||||
end := 0
|
||||
for end < len(digits) && digits[end] >= '0' && digits[end] <= '9' {
|
||||
end++
|
||||
}
|
||||
n, err := strconv.Atoi(digits[:end])
|
||||
if err != nil {
|
||||
t.Fatalf("续读游标不是整数:%q", digits[:end])
|
||||
}
|
||||
return n, injected
|
||||
}
|
||||
|
||||
// documentReadToolByName 在真实工具集里找出 document-read 工具。
|
||||
func documentReadToolByName(t *testing.T, tools []sdk.Tool) sdk.Tool {
|
||||
t.Helper()
|
||||
for _, tl := range tools {
|
||||
if tl.Name() == "document-read" {
|
||||
return tl
|
||||
}
|
||||
}
|
||||
t.Fatal("工具集里没有 document-read")
|
||||
return nil
|
||||
}
|
||||
|
||||
// callDocumentRead 执行一次 document-read 并把返回的 JSON 文本解回 map。
|
||||
func callDocumentRead(t *testing.T, tl sdk.Tool, args map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := tl.Execute(context.Background(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("document-read: %v", err)
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
t.Fatalf("返回值类型 = %T, want string(JSON 文本)", raw)
|
||||
}
|
||||
out := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(text), &out); err != nil {
|
||||
t.Fatalf("返回值不是合法 JSON: %v(%q)", err, text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
+17
-11
@@ -252,6 +252,12 @@ func (a *app) wireRuntime() error {
|
||||
approver := audit.NewApprover(a.log)
|
||||
approvalGate := a.buildApprovalGate(approver)
|
||||
|
||||
// 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时;
|
||||
// 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在
|
||||
// wire_context.go 一处完成。Compactor 不注入,由 loop 选用内置的结构化
|
||||
// compactor(离线、不调模型),并按同一份注入预算估算文档部件的代价。
|
||||
ctxBudget := a.contextBudget(envPath)
|
||||
|
||||
a.loop = loop.New(loop.Options{
|
||||
Agent: defaultAgent,
|
||||
Agents: agents,
|
||||
@@ -277,17 +283,17 @@ func (a *app) wireRuntime() error {
|
||||
PersonaKnowledgeFor: a.personaKnowledgeFor(envPath),
|
||||
Timezone: a.cfg.Timezone,
|
||||
MaxRounds: defaultMaxRounds,
|
||||
// 上下文溢出压缩:预算取自 CONTEXT_MAX_TOKENS(非正 = 关闭,
|
||||
// 与接线前一致),默认启用(见 wire_context.go)。Compactor 不注入,
|
||||
// 由 loop 选用内置的结构化 compactor(离线、不调模型)。
|
||||
MaxContextTokens: a.contextBudget(),
|
||||
WorkingMemory: memory.NewWorkingMemory(0),
|
||||
OnStream: a.streamChunk,
|
||||
Vision: visionResolver,
|
||||
DocumentResolver: docResolver,
|
||||
Thinking: thinking,
|
||||
ReasoningEffort: reasoningEffort,
|
||||
ThinkingShowToUser: thinkingShow,
|
||||
MaxContextTokens: ctxBudget.Budget.Tokens,
|
||||
// 单份附件的注入预算随推导出的上下文预算走(T1 §7.2):1M 窗口下大附件
|
||||
// 才真的进得来,而清单行的续读游标也因此真的是继续读的起点。
|
||||
DocumentMaxInjectChars: ctxBudget.DocumentInjectChars,
|
||||
WorkingMemory: memory.NewWorkingMemory(0),
|
||||
OnStream: a.streamChunk,
|
||||
Vision: visionResolver,
|
||||
DocumentResolver: docResolver,
|
||||
Thinking: thinking,
|
||||
ReasoningEffort: reasoningEffort,
|
||||
ThinkingShowToUser: thinkingShow,
|
||||
// 按调用(而非整批)准入:敏感与否取决于该次调用的参数
|
||||
// (file 的 read 自主、write 代审),见 toolAdmissionHooks。
|
||||
Hooks: a.toolAdmissionHooks(approvalGate),
|
||||
|
||||
Reference in new issue
Block a user