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
@@ -59,11 +59,12 @@ func messageTextOf(m model.Message) string {
|
||||
}
|
||||
|
||||
// TestResolveDocuments_InjectsListAndBody 钉住主路径:清单行恒在,正文紧随其后,
|
||||
// 文档部件全部被替换掉(同一位置)。
|
||||
// 文档部件全部被替换掉(同一位置)。Chars 是**实际注入**的字符数(装配期按注入
|
||||
// 预算算出,切片 T1 §7.2),不是解析器自称的那个数。
|
||||
func TestResolveDocuments_InjectsListAndBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
res := &fakeDocumentResolver{
|
||||
refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 5}},
|
||||
refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 4}},
|
||||
bodies: map[string]string{docA: "正文内容"},
|
||||
}
|
||||
l := New(Options{DocumentResolver: res})
|
||||
@@ -83,8 +84,8 @@ func TestResolveDocuments_InjectsListAndBody(t *testing.T) {
|
||||
if !strings.HasPrefix(text, "看看这个") {
|
||||
t.Fatalf("用户原文顺序被改变: %q", text)
|
||||
}
|
||||
if len(refs) != 1 || refs[0].Status != history.DocumentStatusExtracted || refs[0].Chars != 5 {
|
||||
t.Fatalf("结论 = %+v, want extracted/chars=5", refs)
|
||||
if len(refs) != 1 || refs[0].Status != history.DocumentStatusExtracted || refs[0].Chars != 4 {
|
||||
t.Fatalf("结论 = %+v, want extracted/chars=4", refs)
|
||||
}
|
||||
if refs[0].Name != docA || refs[0].MIME != "text/plain" || refs[0].Size != 2048 {
|
||||
t.Fatalf("结论的身份字段 = %+v, want 取自部件", refs[0])
|
||||
@@ -110,21 +111,25 @@ func TestResolveDocuments_KeepsListWithoutResolver(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestResolveDocuments_TruncatedGivesCursor 钉住截断诚实(design/24 §4.3):
|
||||
// 截断时清单行给出续读游标(已注入字符数),绝不静默截断。
|
||||
// 注入被截断时清单行给出续读游标(已注入字符数 = 继续读的起点),绝不静默截断。
|
||||
func TestResolveDocuments_TruncatedGivesCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
res := &fakeDocumentResolver{
|
||||
refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 700, Truncated: true}},
|
||||
bodies: map[string]string{docA: "前 700 字符"},
|
||||
refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 700}},
|
||||
bodies: map[string]string{docA: strings.Repeat("正", 700)},
|
||||
}
|
||||
l := New(Options{DocumentResolver: res})
|
||||
l := New(Options{DocumentResolver: res, DocumentMaxInjectChars: 100})
|
||||
out, _ := l.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 99)})
|
||||
text := messageTextOf(out[0])
|
||||
for _, want := range []string{"已截断", "offset=700"} {
|
||||
for _, want := range []string{"已截断", "offset=100"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("注入块 %q 缺 %q", text, want)
|
||||
}
|
||||
}
|
||||
_, body, _ := strings.Cut(text, "\n")
|
||||
if body != strings.Repeat("正", 100) {
|
||||
t.Fatalf("注入正文不是 100 个字符的 rune 前缀: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDocuments_UnreadableIsHonest 钉住失败可见(design/24 §4.4):
|
||||
@@ -279,6 +284,89 @@ func TestUserMessageKey_IncludesDocumentRef(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDocuments_AppliesInjectionBudget 钉住切片 T1 §7.2 第 2 条:注入预算在
|
||||
// **装配期**生效 —— ref.Chars 是实际注入的字符数、ref.Truncated 是注入是否被截断,
|
||||
// 清单行的续读游标因此是「继续读的起点」(sidecar 存全文,游标之外还有正文)。
|
||||
func TestResolveDocuments_AppliesInjectionBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
const budget = 10
|
||||
body := "0123456789ABCDEFGHIJ"
|
||||
res := &fakeDocumentResolver{
|
||||
// 解析器给的是抽取侧的事实(sidecar 全文 20 字符、未撞安全上限)。
|
||||
refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: len(body)}},
|
||||
bodies: map[string]string{docA: body},
|
||||
}
|
||||
l := New(Options{DocumentResolver: res, DocumentMaxInjectChars: budget})
|
||||
out, refs := l.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 20)})
|
||||
|
||||
text := messageTextOf(out[0])
|
||||
if !strings.Contains(text, "已截断") || !strings.Contains(text, fmt.Sprintf("offset=%d", budget)) {
|
||||
t.Fatalf("注入块缺截断与续读游标: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, body[:budget]) || strings.Contains(text, body[budget:]) {
|
||||
t.Fatalf("注入正文不是预算内的前缀: %q", text)
|
||||
}
|
||||
if len(refs) != 1 || refs[0].Chars != budget || !refs[0].Truncated {
|
||||
t.Fatalf("结论 = %+v, want chars=%d + truncated(注入侧事实)", refs, budget)
|
||||
}
|
||||
|
||||
// 正文在预算内时:不截断、不给游标,Chars 仍是实际注入的字符数。
|
||||
small := &fakeDocumentResolver{
|
||||
refs: map[string]history.DocumentRef{docA: {Status: history.DocumentStatusExtracted, Chars: 3}},
|
||||
bodies: map[string]string{docA: "短正文"},
|
||||
}
|
||||
l2 := New(Options{DocumentResolver: small, DocumentMaxInjectChars: budget})
|
||||
out2, refs2 := l2.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 9)})
|
||||
if text2 := messageTextOf(out2[0]); strings.Contains(text2, "已截断") {
|
||||
t.Fatalf("预算内却被标成截断: %q", text2)
|
||||
}
|
||||
if len(refs2) != 1 || refs2[0].Chars != 3 || refs2[0].Truncated {
|
||||
t.Fatalf("结论 = %+v, want chars=3 + 未截断", refs2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnexportedInjectBudgetDefaultsOnConstruction 钉住零值语义:Options 不设
|
||||
// DocumentMaxInjectChars 时用缺省 64K 字符(小窗口部署行为不退化),而不是 0
|
||||
// (0 会把正文整个截掉)。
|
||||
func TestUnexportedInjectBudgetDefaultsOnConstruction(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := New(Options{}).documentInjectChars; got != defaultDocumentInjectChars {
|
||||
t.Fatalf("缺省注入预算 = %d, want %d", got, defaultDocumentInjectChars)
|
||||
}
|
||||
if got := New(Options{DocumentMaxInjectChars: -5}).documentInjectChars; got != defaultDocumentInjectChars {
|
||||
t.Fatalf("非正注入预算 = %d, want 回落 %d", got, defaultDocumentInjectChars)
|
||||
}
|
||||
if got := New(Options{DocumentMaxInjectChars: 1234}).documentInjectChars; got != 1234 {
|
||||
t.Fatalf("显式注入预算 = %d, want 1234", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDocumentInjectCharsFor 钉住「注入预算随上下文预算推导」这条口径(表驱动):
|
||||
// 1M 窗口推导出的 699050 token ⇒ 单份附件可注入 300K+ 字符(远超旧的 64K),
|
||||
// 但小预算下不跌破历史默认,且绝不超过预算本身。
|
||||
func TestDocumentInjectCharsFor(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
budget int
|
||||
want int
|
||||
}{
|
||||
{"1M 窗口推导出的预算", 699050, 699050 * charsPerToken / documentInjectBudgetShare},
|
||||
{"fallback 窗口推导出的预算(推导值跌破历史默认 ⇒ 取默认)", 87381, defaultDocumentInjectChars},
|
||||
{"小预算不超过预算本身", 32768, 32768},
|
||||
{"预算关闭", 0, defaultDocumentInjectChars},
|
||||
{"负预算", -1, defaultDocumentInjectChars},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := DocumentInjectCharsFor(c.budget); got != c.want {
|
||||
t.Fatalf("%s: DocumentInjectCharsFor(%d) = %d, want %d", c.name, c.budget, got, c.want)
|
||||
}
|
||||
}
|
||||
if big := DocumentInjectCharsFor(699050); big <= defaultDocumentInjectChars {
|
||||
t.Fatalf("大窗口下的注入预算 = %d, want > 旧的 64K 卡死值", big)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEstimateContextTokens_CountsDocuments 是 design/24 §9.5 移交的回归:文档
|
||||
// 部件必须在 token 估算里占分量,否则纯文档回合被估成 0 token,在压缩里等同于
|
||||
// 空内容。
|
||||
@@ -292,12 +380,21 @@ func TestEstimateContextTokens_CountsDocuments(t *testing.T) {
|
||||
if estimateContextTokens(big) <= estimateContextTokens(small) {
|
||||
t.Fatalf("大附件估算未变大:%d vs %d", estimateContextTokens(big), estimateContextTokens(small))
|
||||
}
|
||||
if got := documentPartTokens(model.Content{Type: model.ContentTypeDocument}); got != documentMinTokens {
|
||||
if got := documentPartTokens(model.Content{Type: model.ContentTypeDocument}, 0); got != documentMinTokens {
|
||||
t.Fatalf("documentPartTokens(未知字节数) = %d, want %d", got, documentMinTokens)
|
||||
}
|
||||
if got := documentPartTokens(model.Content{Type: model.ContentTypeDocument, DocumentSize: documentInjectedCharsCap * 4}) /
|
||||
1; got != documentInjectedCharsCap/charsPerToken {
|
||||
t.Fatalf("documentPartTokens(超大附件) = %d, want 按注入预算封顶 %d", got, documentInjectedCharsCap/charsPerToken)
|
||||
if got := documentPartTokens(model.Content{Type: model.ContentTypeDocument, DocumentSize: defaultDocumentInjectChars * 4},
|
||||
defaultDocumentInjectChars); got != defaultDocumentInjectChars/charsPerToken {
|
||||
t.Fatalf("documentPartTokens(超大附件) = %d, want 按注入预算封顶 %d", got, defaultDocumentInjectChars/charsPerToken)
|
||||
}
|
||||
// 注入预算变大(1M 窗口部署)时,同一个文档部件必须按新预算计入 —— 否则
|
||||
// 注入 683K 字符的附件在估算里只按 64K 计,压缩永远迟一步。
|
||||
bigger := DocumentInjectCharsFor(699050)
|
||||
if bigger <= defaultDocumentInjectChars {
|
||||
t.Fatalf("DocumentInjectCharsFor(1M 窗口推导出的预算) = %d, want > %d", bigger, defaultDocumentInjectChars)
|
||||
}
|
||||
if got, want := documentPartTokens(model.Content{Type: model.ContentTypeDocument, DocumentSize: int64(bigger)}, bigger), bigger/charsPerToken; got != want {
|
||||
t.Fatalf("按更大注入预算的估算 = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user