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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user