Files
Cyrene_v2/cmd/cyrene/wire_context_test.go
T
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

427 lines
17 KiB
Go
Raw 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 main
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"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/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定上下文预算的取值链(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」。
const loopCompactSummaryMarker = "[前文摘要 · history summary]"
// compactProbeAdapter 记下每次流式请求,使测试能读回模型实际看到的上下文。
type compactProbeAdapter struct{ reqs []model.Request }
func (a *compactProbeAdapter) Name() string { return "compact-probe" }
func (a *compactProbeAdapter) Chat(context.Context, model.Request, model.Options) (model.Response, error) {
return model.Response{Content: "好"}, nil
}
func (a *compactProbeAdapter) ChatStream(_ context.Context, req model.Request, on func(model.StreamEvent)) (model.Response, error) {
a.reqs = append(a.reqs, req)
if on != nil {
on(model.StreamEvent{Kind: model.StreamKindTextDelta, Delta: "好"})
}
return model.Response{Content: "好"}, nil
}
// longConversation 造一段「比小预算长得多、单看每条都很正常」的对话:
// 12 轮问答、每条约 220 个汉字(离线估算约 114 token/条,共约 2700 token)。
func longConversation() []model.Message {
nums := []string{"一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二"}
filler := strings.Repeat("今天也要好好吃饭早点休息哦", 17)
msgs := make([]model.Message, 0, 2*len(nums))
for _, n := range nums {
msgs = append(msgs,
model.TextMessage(model.RoleUser, "第"+n+"件事:"+filler),
model.TextMessage(model.RoleAssistant, "好呀,人家记下了♪"+filler),
)
}
return msgs
}
// runWithBudget 用给定预算驱动一次真实循环(装配形状与组合根一致:
// MaxContextTokens 取自推导结论,Compactor 不注入 —— 由 loop 选用内置的结构化
// compactor),返回模型实际看到的请求。
func runWithBudget(t *testing.T, budget int, msgs []model.Message) model.Request {
t.Helper()
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,
MaxContextTokens: budget,
OnStream: func(model.StreamEvent) {},
})
env := loop.SessionEnv{ActorEnv: restrict.ActorOwner, MemWritable: true, PersonaID: "cyrene"}
ctx := loop.WithSessionEnv(context.Background(), env)
if _, err := l.Run(ctx, "web", msgs); err != nil {
t.Fatalf("Run: %v", err)
}
if len(adapter.reqs) == 0 {
t.Fatal("没有捕获到任何模型请求")
}
return adapter.reqs[len(adapter.reqs)-1]
}
// summaryMessages 返回请求里所有压缩摘要消息的下标。
func summaryMessages(req model.Request) []int {
var idx []int
for i, m := range req.Messages {
if strings.HasPrefix(loopText(m), loopCompactSummaryMarker) {
idx = append(idx, i)
}
}
return idx
}
// loopText 取一条消息的文本部件拼接(与 loop 的 messageText 同义)。
func loopText(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
b.WriteString(c.Text)
}
}
return b.String()
}
// 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
}
// 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("marshal models.json: %v", err)
}
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatalf("write models.json: %v", err)
}
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)
}
// 单份附件注入预算随推导预算走: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)
}
// 组级 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)
}
}
// 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)
}
}
// TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt 是真断言①:
// 预算为正且对话超出预算时,会话**真的被压缩**,且摘要消息进了模型请求。
func TestContextCompaction_OnLongConversationGetsSummarizedIntoThePrompt(t *testing.T) {
t.Parallel()
a := &app{log: testLog(), cfg: &config.Config{}}
a.cfg.Context.MaxTokens = 600 // 远小于 longConversation 的体量
msgs := longConversation()
req := runWithBudget(t, a.contextBudget(filepath.Join(t.TempDir(), "absent.env")).Budget.Tokens, msgs)
summaries := summaryMessages(req)
if len(summaries) != 1 {
t.Fatalf("请求里的摘要消息数 = %d(共 %d 条消息),want 1", len(summaries), len(req.Messages))
}
if summaries[0] != 1 {
t.Fatalf("摘要消息的位置 = %d,want 1(紧跟指令消息的近期窗口之前)", summaries[0])
}
if len(req.Messages) >= len(msgs)+1 {
t.Fatalf("请求消息数 = %d(喂入 %d 条),压缩没有生效", len(req.Messages), len(msgs))
}
// 近期尾部保留(压缩不是把历史全丢掉):最后一条用户消息仍在。
all := ""
for _, m := range req.Messages {
all += loopText(m)
}
if !strings.Contains(all, "第十二件事") {
t.Fatalf("最近一轮已被压掉:请求里找不到最后一轮用户消息")
}
}
// TestContextCompaction_ZeroBudgetKeepsTodaysBehavior 是真断言②:显式 0
// (关闭)时,行为与接线前完全一致 —— 喂进去的消息一条不少地进了请求,
// 且没有任何摘要消息。
func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) {
t.Parallel()
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(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)
}
if summaries := summaryMessages(req); len(summaries) != 0 {
t.Fatalf("关闭压缩却出现了摘要消息:%v", summaries)
}
}
// TestWireRuntime_UsesTheCompositionRootSeams 是一道「接线存在性」防回归检查。
//
// 组合根本身(wireRuntime → newApp)无法在单元测试里实例化:它要开库、迁移、
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此「组合根确实走了这些缝」
// 只能在源码层钉住:wire_runtime.go 必须调用 a.buildGuardrailPipeline()、
// 必须把预算取自 a.contextBudget(envPath),并且把两项预算都交给 loop。
func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
t.Parallel()
src, err := os.ReadFile("wire_runtime.go")
if err != nil {
t.Fatalf("read wire_runtime.go: %v", err)
}
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)
}
}
}
// TestContextCompaction_HugeBudgetIsAlsoInert 是变异反证:同一段对话只把预算
// 放大到不可能超出的值,压缩同样不发生 —— 证明上一条的压缩确实由「预算」驱动,
// 而不是别的什么副作用。
func TestContextCompaction_HugeBudgetIsAlsoInert(t *testing.T) {
t.Parallel()
msgs := longConversation()
req := runWithBudget(t, 1<<30, msgs)
if len(req.Messages) != len(msgs)+1 {
t.Fatalf("巨大预算下请求消息数 = %d,want %d", len(req.Messages), len(msgs)+1)
}
if summaries := summaryMessages(req); len(summaries) != 0 {
t.Fatalf("巨大预算下不该出现摘要消息:%v", summaries)
}
}