feat(config,runtime): 上下文预算按模型窗口推导 + usage/缓存可观测 + sidecar 存全文(T1)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 窗口声明: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:
AskaEth committed 2026-10-04 17:57:08 +08:00
1 parent 5b2b385524
commit 72b95df22b
39 files changed
+2496 -377

No files matched your search

+198
View File
@@ -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
}