diff --git a/.env.example b/.env.example index 1fc104c..d0ab82d 100644 --- a/.env.example +++ b/.env.example @@ -226,16 +226,23 @@ SUBAGENT_TIMEOUT_SEC=600 # 单份附件注入模型可见上下文的字符上限(sidecar 仍存全文,只受 8 MiB 安全上限约束)。 # 留空 = 按推导出的预算算:推导预算的 1/8(按一个回合最多 5 个附件留余量),下限 64K 字符。 # 它决定「每回合往上下文里放多少」,也因此决定清单行给的续读游标从哪里开始。 -# ⚠️ 它必须**不超过**下面 GUARDRAIL_MAX_INPUT_CHARS 生效的输入上限,否则大附件会在装配完之后 +# ⚠️ 它必须**不超过**下面 GUARDRAIL_MAX_INPUT_CHARS 生效的输入上限 —— 严格说是 +# 「单份注入 × 单回合附件数(最多 5)」不超过上限,否则满载附件的回合会在装配完之后 # 被输入护栏整回合拦死(input_length_exceeded)。改了本键就顺手核对那一项。 # DOCUMENT_MAX_INJECT_CHARS= # 请求侧字符上限(输入护栏跑在**组装完成的请求**上:系统提示词 + 记忆块 + 整段对话 + 工具结果)。 # 三种取值: -# · 未设置 / 0(默认)= 由推导出的上下文预算派生:max(262144, 推导预算 token 数) -# (1 字符 ≈ 1 token 的保守口径);1M 窗口 ⇒ 预算 699050 token ⇒ 上限 699050 字符, -# 而单份附件注入预算是 349525 字符 ⇒ 关系不变量「注入预算 ≤ 输入上限」成立, -# 「分享大文件」才走得通。小窗口部署下派生的 token 数低于 262144,行为与旧硬编码一致。 -# · > 0 = 显式覆盖(原样使用;调小到注入预算之下会让大附件被拦,届时启动日志里有 WARN) +# · 未设置 / 0(默认)= 由推导出的上下文预算派生: +# 上限 = max(262144, 推导预算 token 数 × 4) +# 那个 4 与附件注入预算用的是**同一个换算常量**(loop.CharsPerToken = 4 字符 ≈ 1 token)。 +# 上限量的是整段装配请求,而注入预算是单份的,两边必须是同一套换算,否则**多附件**回合 +# 会被拦死(G2b 修的正是这个:旧口径按 1 字符 ≈ 1 token 兑,2 份满额注入就顶到上限): +# 1M 窗口 ⇒ 预算 699050 token ⇒ 上限 2796200 字符; +# 单份附件注入预算 349525 字符 ⇒ 5 份(单回合附件上限)满额注入合计 1747625 字符, +# 关系不变量「单回合全部附件的注入预算合计 ≤ 输入上限」成立,「分享大文件」才走得通。 +# 预算关闭(CONTEXT_MAX_TOKENS=0)或预算很小时,上限落回 262144。 +# · > 0 = 显式覆盖(原样使用;压到「单份注入 × 单回合附件数」之下会让多附件回合被拦, +# 届时启动日志里有 WARN) # · < 0 = 非法:记一条 WARN 并回落派生值(绝不静默生效) # 它只动**输入**侧;输出上限(65536 字符)防的是模型输出失控,与上下文预算无关,不开放配置。 # 敏感信息(API key / bearer token / 密码 / 私钥)为阻断档,同样是代码内定的安全默认值。 diff --git a/cmd/cyrene/wire_guardrail.go b/cmd/cyrene/wire_guardrail.go index e8f9624..f772c4f 100644 --- a/cmd/cyrene/wire_guardrail.go +++ b/cmd/cyrene/wire_guardrail.go @@ -5,6 +5,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" ) // 本文件是组合根的默认护栏策略(design/04 §5)。三条默认策略与它们的依据: @@ -20,15 +21,14 @@ const ( // 跑在**组装完成的请求**上(系统提示词 + 记忆块 + 整段对话 + 工具结果), // 不是单条用户消息,因此上限按「整个上下文」定。 // - // 为什么是「下限」而不是「定值」(本切片修的真 bug):单份附件的注入预算随 + // 为什么是「下限」而不是「定值」(切片 G2 修的真 bug):单份附件的注入预算随 // 推导出的上下文预算走,1M 窗口下已是 349525 字符(T1)。上限若还是硬编码的 // 262144,任何正文超过它的附件都会让**整个**回合以 guardrail_input 结束 // (端到端实测:`POST /chat` 带一个 744 KB 的 txt ⇒ // `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}`), // 也就是「护栏成了上下文预算的瓶颈」,把「分享大文件」这条主线直接打死。 - // 因此生效值由 guardrailInputLimit 派生:max(本下限, 推导预算 token 数), - // 取 1 字符 ≈ 1 token 的保守口径。小窗口部署下派生的 token 数本就小于它, - // 输入上限与接线前逐字节一致。 + // 因此生效值由 guardrailInputLimit 派生:max(本下限, 推导预算 token 数 × CharsPerToken)。 + // 预算关闭(0)或预算小到乘完仍低于本下限时,上限就是本下限。 guardrailMinInputChars = 262144 // guardrailMaxOutputChars 是响应侧的字符上限。一条回复正常在几十到几百字, // 长代码/长文也就几千字,65536(64 Ki)字符比正常回复高 2~3 个数量级, @@ -42,14 +42,25 @@ const ( guardrailMaxOutputChars = 65536 ) +// maxChatDocumentsPerTurn 是**单回合**附件数上限(design/24 §6.1、§9.4-3 的定稿值 5)。 +// 它是关系不变量「N 份」的那一端:输入上限必须容得下「一整个回合的附件都撞满单份 +// 注入预算」的装配结果,否则满载附件的回合会在装配完之后被整回合拦死(切片 G2 只 +// 对了 **1 份**,本切片补 **N 份**)。 +// +// 出处:真源是 documents.MaxPerMessage(网关侧以 +// `maxChatDocuments = documents.MaxPerMessage` 引用它)。本切片**不跨包引用**它 —— +// documents 正被并行切片占用,且这里只需要一个数、不需要它的任何行为;因此取同值, +// 并用测试里的来源说明 + 不变量断言钉住。待并行切片落定后统一成一个共享常量。 +const maxChatDocumentsPerTurn = 5 + // buildGuardrailPipeline 组装循环与各 agent 共用的护栏管线(design/04 §5)。 // 它是组合根里唯一一处决定「护栏里到底有什么」的地方,因此测试可以直接 // 断言这个函数的产物,而不必启动整个运行时。 // // 取 budget 参数而不是自己去推导:输入上限是**上下文预算的函数**(关系不变量 -// 「文档注入预算 ≤ 输入上限」的两端都在这份预算里),而预算的推导只该在 -// wire_context.go 发生一次 —— 各自推导会把「窗口未声明」那条 WARN 记两遍。 -// 调用顺序因此固定为「先算预算,再建管线」(wire_runtime.go)。 +// 「单回合全部附件的注入预算合计 ≤ 输入上限」的两端都在这份预算里),而预算的 +// 推导只该在 wire_context.go 发生一次 —— 各自推导会把「窗口未声明」那条 WARN +// 记两遍。调用顺序因此固定为「先算预算,再建管线」(wire_runtime.go)。 // // 选项顺序即检查顺序(guardrail.New 按序应用),且先跑开销低、语义硬的 // 敏感信息检查:命中即返回该判决,长度与关键词检查不会再跑。 @@ -77,20 +88,24 @@ func (a *app) guardrailInputLimit(budget ctxBudget) int { } return deriveGuardrailInputLimit( sourcedWiringLog(a.log), override, - budget.Budget.Tokens, budget.DocumentInjectChars, + budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn, ) } // deriveGuardrailInputLimit 是 guardrailInputLimit 的可测内核:取值来源收成显式 // 参数,两条 WARN(非法覆盖 / 关系不变量被破)都在这里,测试可按条数断言。 // -// 派生公式:输入上限 = max(guardrailMinInputChars, 推导预算 token 数)。字符与 -// token 按 1:1 的保守口径换算 —— 上限宁可放宽一点也不该误伤正常上下文,而它仍然 -// 只拦「几百 KB 整块文本直接灌进来」这种病态载荷(真正的上下文控制由压缩预算负责)。 -func deriveGuardrailInputLimit(log *slog.Logger, override, budgetTokens, docInjectChars int) int { +// 派生公式:输入上限 = max(guardrailMinInputChars, 推导预算 token 数 × CharsPerToken)。 +// 换算必须与**附件注入预算**同源(G2 的残留缺口正在这里):G2 把预算 token 数按 +// 1 字符 ≈ 1 token 兑成字符,而注入预算按 4 字符 ≈ 1 token 兑(loop.CharsPerToken), +// 两边两套换算 ⇒ 上限 699050 字符只够 **2 份**各自撞满注入预算的附件 +// (2 × 349525 = 699050),5 份必被拦。改用注入的同一常量后,1M 窗口的上限是 +// 2796200 字符,5 份满额注入 1747625 字符仍有明确余量;上限仍然只拦「几百 KB +// 整块文本直接灌进来」这种病态载荷(真正的上下文控制由压缩预算负责)。 +func deriveGuardrailInputLimit(log *slog.Logger, override, budgetTokens, docInjectChars, maxDocs int) int { derived := guardrailMinInputChars - if budgetTokens > derived { - derived = budgetTokens + if scaled := budgetTokens * loop.CharsPerToken; scaled > derived { + derived = scaled } limit := derived switch { @@ -101,14 +116,18 @@ func deriveGuardrailInputLimit(log *slog.Logger, override, budgetTokens, docInje case override > 0: limit = override } - if docInjectChars > limit { - // 关系不变量被破:注入预算比输入上限还大,意味着「能注入的正文」本身就会 - // 触发输入护栏 —— 大附件会在装配完之后被整回合拦下,现象是用户看到一个 + if injected := docInjectChars * maxDocs; injected > limit { + // 关系不变量被破(**多附件**口径):一个回合最多 maxDocs 份附件,每份可注入 + // docInjectChars 字符,装配后的请求至少要容得下「全部附件都撞满注入预算」。 + // 破了就说明满载附件的回合会在装配完之后被整回合拦下,现象是用户看到一个 // 没有上下文的 input_length_exceeded。这里明说一句,不静默。 - log.Warn("document inject budget exceeds guardrail input limit; large attachments will be blocked", - "env", config.GuardrailMaxInputCharsEnv, "guardrail_max_input_chars", limit, + log.Warn("document inject budget x max attachments exceeds guardrail input limit; multi-attachment turns may be blocked", + "env", config.GuardrailMaxInputCharsEnv, + "guardrail_max_input_chars", limit, + "max_documents", maxDocs, "document_inject_chars", docInjectChars, - "hint", "调大本键或调小 DOCUMENT_MAX_INJECT_CHARS,使注入预算不超过输入上限") + "document_inject_total_chars", injected, + "hint", "调大本键或调小 DOCUMENT_MAX_INJECT_CHARS,使「单份注入 × 单回合附件数」不超过输入上限") } return limit } diff --git a/cmd/cyrene/wire_guardrail_limit_test.go b/cmd/cyrene/wire_guardrail_limit_test.go index ed47066..7f5853b 100644 --- a/cmd/cyrene/wire_guardrail_limit_test.go +++ b/cmd/cyrene/wire_guardrail_limit_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -14,14 +15,18 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) -// 本文件锁定切片 G2 的输入侧解耦(端到端实测抓到的真 bug:大附件被输入护栏拦死): +// 本文件锁定切片 G2 / G2b 的输入侧解耦(端到端实测抓到的真 bug:大附件被输入护栏拦死): // -// - 输入上限 = max(256 Ki, 推导预算 token 数)(1 字符 ≈ 1 token 的保守口径); +// - 输入上限 = max(256 Ki, 推导预算 token 数 × CharsPerToken)(与附件注入预算 +// **同一套换算**;G2 按 1 字符 ≈ 1 token 兑,于是 2 份满额注入的附件就顶到上限, +// G2b 统一到注入侧那个常量); // - GUARDRAIL_MAX_INPUT_CHARS 的三态(未设/0 = 派生、>0 = 覆盖、<0 = WARN 回落); -// - 关系不变量「文档注入预算 ≤ 输入护栏上限」,人为破坏它必须**有 WARN**; +// - 关系不变量「单回合全部附件的注入预算合计 ≤ 输入护栏上限」,人为破坏它必须**有 WARN**; // - 回归:注入正文比旧上限(262144 字符)还大时,回合**不得**以 guardrail_input 结束; // - 反证:显式把上限收紧后,同一个大附件回合**真的**被拦(护栏没被削弱)。 // +// 多附件的回归与「N 份」不变量的用例在 wire_guardrail_multidoc_test.go。 +// // 输出上限(guardrailMaxOutputChars)不属本切片:它防的是模型输出失控,与上下文 // 预算无关,因此保持 65536 不动(回归见 TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads)。 @@ -31,7 +36,8 @@ import ( const largeDocChars = 400000 // TestGuardrailInputLimit_DerivedFromBudget 钉住派生公式(表驱动): -// 输入上限 = max(下限, 推导预算 token 数) —— 小窗口部署落回下限,大窗口随预算抬高。 +// 输入上限 = max(下限, 推导预算 token 数 × CharsPerToken) —— 小预算落回下限, +// 大窗口随预算抬高(换算与附件注入预算同一个常量,否则多附件回合会被拦死)。 func TestGuardrailInputLimit_DerivedFromBudget(t *testing.T) { t.Parallel() @@ -40,9 +46,9 @@ func TestGuardrailInputLimit_DerivedFromBudget(t *testing.T) { tokens int want int }{ - {"1M 窗口推导出的预算(699050)⇒ 随之上抬", 699050, 699050}, - {"预算正好等于下限 ⇒ 下限", guardrailMinInputChars, guardrailMinInputChars}, - {"小预算(600)⇒ 回落下限", 600, guardrailMinInputChars}, + {"1M 窗口推导出的预算(699050 token)⇒ 按 CharsPerToken 上抬", 699050, 2796200}, + {"预算正好等于下限 ÷ CharsPerToken ⇒ 下限", guardrailMinInputChars / loop.CharsPerToken, guardrailMinInputChars}, + {"小预算(600 token)⇒ 回落下限", 600, guardrailMinInputChars}, {"关闭压缩(0)⇒ 下限", 0, guardrailMinInputChars}, {"负预算 ⇒ 下限", -1, guardrailMinInputChars}, } @@ -51,7 +57,7 @@ func TestGuardrailInputLimit_DerivedFromBudget(t *testing.T) { t.Run(c.name, func(t *testing.T) { t.Parallel() log, _ := capturingLog() - if got := deriveGuardrailInputLimit(log, 0, c.tokens, 0); got != c.want { + if got := deriveGuardrailInputLimit(log, 0, c.tokens, 0, maxChatDocumentsPerTurn); got != c.want { t.Fatalf("deriveGuardrailInputLimit(budget=%d) = %d, want %d", c.tokens, got, c.want) } }) @@ -64,7 +70,7 @@ func TestGuardrailInputLimit_OverrideAndIllegalValue(t *testing.T) { t.Parallel() log, buf := capturingLog() - if got := deriveGuardrailInputLimit(log, 1000, 699050, 0); got != 1000 { + if got := deriveGuardrailInputLimit(log, 1000, 699050, 0, maxChatDocumentsPerTurn); got != 1000 { t.Fatalf("显式覆盖 = %d, want 1000(覆盖优先于派生)", got) } if n := countRecords(logRecords(t, buf), "WARN", "guardrail input limit invalid"); n != 0 { @@ -72,8 +78,8 @@ func TestGuardrailInputLimit_OverrideAndIllegalValue(t *testing.T) { } log, buf = capturingLog() - if got := deriveGuardrailInputLimit(log, -1, 699050, 0); got != 699050 { - t.Fatalf("非法覆盖后的回落 = %d, want 派生值 699050", got) + if got := deriveGuardrailInputLimit(log, -1, 699050, 0, maxChatDocumentsPerTurn); got != 2796200 { + t.Fatalf("非法覆盖后的回落 = %d, want 派生值 2796200", got) } records := logRecords(t, buf) if n := countRecords(records, "WARN", "guardrail input limit invalid"); n != 1 { @@ -87,7 +93,7 @@ func TestGuardrailInputLimit_OverrideAndIllegalValue(t *testing.T) { if rec["env"] != config.GuardrailMaxInputCharsEnv { t.Fatalf("WARN 没点出配置键: %v", rec) } - if rec["value"] != float64(-1) || rec["fallback"] != float64(699050) { + if rec["value"] != float64(-1) || rec["fallback"] != float64(2796200) { t.Fatalf("WARN 没带原始取值与回落值: %v", rec) } } @@ -98,8 +104,9 @@ func TestGuardrailInputLimit_OverrideAndIllegalValue(t *testing.T) { } // TestGuardrailInputLimit_RelationInvariantWithInjectBudget 钉住关系不变量 -// 「文档注入预算 ≤ 输入护栏上限」:对 1M 窗口的推导值成立,且人为把上限设得比 -// 注入预算小 ⇒ **记一条 WARN**(说明「大附件会被护栏拦下」),不静默。 +// 「单回合全部附件的注入预算合计 ≤ 输入护栏上限」:对 1M 窗口的推导值成立 +// (maxDocuments 份 × 单份注入),且人为把上限压到之下 ⇒ **记一条 WARN** +// (消息与字段都要让人一眼看出是**多附件**问题),不静默。 func TestGuardrailInputLimit_RelationInvariantWithInjectBudget(t *testing.T) { t.Parallel() @@ -112,20 +119,23 @@ func TestGuardrailInputLimit_RelationInvariantWithInjectBudget(t *testing.T) { } log, buf := capturingLog() - limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars) - if budget.DocumentInjectChars > limit { - t.Fatalf("关系不变量被破:注入预算 %d > 输入上限 %d", budget.DocumentInjectChars, limit) + limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn) + if total := budget.DocumentInjectChars * maxChatDocumentsPerTurn; total > limit { + t.Fatalf("关系不变量被破:%d 份注入合计 %d > 输入上限 %d", + maxChatDocumentsPerTurn, total, limit) } - if limit != 699050 { - t.Fatalf("1M 窗口下的输入上限 = %d, want 699050(= 推导预算 token 数)", limit) + if limit != 2796200 { + t.Fatalf("1M 窗口下的输入上限 = %d, want 2796200(= 推导预算 token 数 × %d)", + limit, loop.CharsPerToken) } if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 { t.Fatalf("不变量成立却记了 %d 条 WARN", n) } - // 人为破坏:把上限压到注入预算之下 ⇒ 必须有一条 WARN 明说后果。 + // 人为破坏:把上限压到「全部附件注入合计」之下 ⇒ 必须有一条 WARN 明说后果, + // 且带上 N 份 / 单份 / 合计三项取值(否则没人看得出这是多附件问题)。 log, buf = capturingLog() - if got := deriveGuardrailInputLimit(log, 4096, budget.Budget.Tokens, budget.DocumentInjectChars); got != 4096 { + if got := deriveGuardrailInputLimit(log, 4096, budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn); got != 4096 { t.Fatalf("收紧后的上限 = %d, want 4096", got) } records := logRecords(t, buf) @@ -136,9 +146,17 @@ func TestGuardrailInputLimit_RelationInvariantWithInjectBudget(t *testing.T) { for _, rec := range records { if msg, _ := rec["msg"].(string); strings.Contains(msg, "exceeds guardrail input limit") { found = true + if !strings.Contains(msg, "multi-attachment") { + t.Fatalf("WARN 文案看不出是多附件问题: %q", msg) + } if rec["document_inject_chars"] != float64(budget.DocumentInjectChars) || - rec["guardrail_max_input_chars"] != float64(4096) { - t.Fatalf("WARN 没写清两端取值: %v", rec) + rec["guardrail_max_input_chars"] != float64(4096) || + rec["max_documents"] != float64(maxChatDocumentsPerTurn) || + rec["document_inject_total_chars"] != float64(budget.DocumentInjectChars*maxChatDocumentsPerTurn) { + t.Fatalf("WARN 没写清 N 份与两端取值: %v", rec) + } + if hint, _ := rec["hint"].(string); hint == "" { + t.Fatalf("WARN 没给出处置提示: %v", rec) } } } @@ -148,26 +166,38 @@ func TestGuardrailInputLimit_RelationInvariantWithInjectBudget(t *testing.T) { } // largeAttachmentTurn 是「一个回合 + 一份大附件」的观测结果。 -type largeAttachmentTurn struct { +type attachmentTurn struct { // result 是该回合的循环结论(StopReason / Text / Documents 都在这里)。 result *loop.Result // modelCalls 是模型被调用的次数(= 捕获到的请求数)。 modelCalls int // assembledChars 是模型实际看到的文本总量(装配后、注入正文已在内)。 assembledChars int - // injectChars 是本回合如实记录的注入字符数(Result.Documents[0].Chars)。 - injectChars int + // perDocInject 是本回合如实记录的**每份**附件注入字符数 + // (Result.Documents[i].Chars;夹具里各份等长)。 + perDocInject int + // documents 是 Result.Documents 的条数(本回合真的注入了几份)。 + documents int + // g2Limit 是 G2 时代的上限(推导预算 token 数按 1 字符 ≈ 1 token 兑成字符)。 + // 它让回归用例能自证「装配结果确实落在 G2/G2b 的缺口上」:装配总量超过了它。 + g2Limit int + // inputLimit 是本回合实际生效的输入上限(a.guardrailInputLimit 的产物), + // 供用例实证「装配总量落在上限之内」。 + inputLimit int } -// runLargeAttachmentTurn 跑一个回合:1M 窗口的推导预算(注入 349525 字符)+ -// 一份 largeDocChars 字符的 txt 附件 + 真实的护栏管线。inputLimitOverride 非 0 时 +// runAttachmentTurn 跑一个回合:1M 窗口的推导预算(单份注入 349525 字符)+ docs 份 +// largeDocChars 字符的 txt 附件 + 真实的护栏管线。inputLimitOverride 非 0 时 // 模拟 GUARDRAIL_MAX_INPUT_CHARS 的覆盖态。 // // 三个角色都是生产实现:预算取自 a.contextBudget、注入走真实的 // loop.resolveDocuments + documents.Resolver、护栏是 a.buildGuardrailPipeline 的产物。 // 唯一被单独钉住的是「窗口 = 1M」。 -func runLargeAttachmentTurn(t *testing.T, inputLimitOverride int) largeAttachmentTurn { +func runAttachmentTurn(t *testing.T, docs, inputLimitOverride int) attachmentTurn { t.Helper() + if docs < 1 { + t.Fatalf("docs = %d, want ≥ 1(夹具至少要一份附件)", docs) + } dataDir := t.TempDir() uploads := filepath.Join(dataDir, "uploads", "documents") @@ -175,8 +205,13 @@ func runLargeAttachmentTurn(t *testing.T, inputLimitOverride int) largeAttachmen t.Fatalf("建上传目录: %v", err) } body := strings.Repeat("abcdefghij", largeDocChars/10) - if err := os.WriteFile(filepath.Join(uploads, docE2EName), []byte(body), 0o600); err != nil { - t.Fatalf("写上传件: %v", err) + refs := make([]model.Document, 0, docs) + for i := 0; i < docs; i++ { + name := attachmentName(i) + if err := os.WriteFile(filepath.Join(uploads, name), []byte(body), 0o600); err != nil { + t.Fatalf("写上传件: %v", err) + } + refs = append(refs, model.Document{Name: name, MIME: "text/plain", Size: int64(len(body))}) } cfg := &config.Config{DataDir: dataDir} @@ -197,16 +232,19 @@ func runLargeAttachmentTurn(t *testing.T, inputLimitOverride int) largeAttachmen DocumentMaxInjectChars: budget.DocumentInjectChars, OnStream: func(model.StreamEvent) {}, }) - msg := model.DocumentMessage(model.RoleUser, "看看这个附件", - model.Document{Name: docE2EName, MIME: "text/plain", Size: int64(len(body))}) - res, err := l.Run(context.Background(), "sess-large-doc", []model.Message{msg}) + res, err := l.Run(context.Background(), "sess-large-doc", + []model.Message{documentTurnMessage("看看这些附件", refs)}) if err != nil { t.Fatalf("Run: %v", err) } - turn := largeAttachmentTurn{result: res, modelCalls: len(adapter.reqs)} + turn := attachmentTurn{ + result: res, modelCalls: len(adapter.reqs), + documents: len(res.Documents), g2Limit: budget.Budget.Tokens, + inputLimit: a.guardrailInputLimit(budget), + } if len(res.Documents) > 0 { - turn.injectChars = res.Documents[0].Chars + turn.perDocInject = res.Documents[0].Chars } if len(adapter.reqs) > 0 { req := adapter.reqs[len(adapter.reqs)-1] @@ -217,6 +255,30 @@ func runLargeAttachmentTurn(t *testing.T, inputLimitOverride int) largeAttachmen return turn } +// attachmentName 造第 i 份附件的服务端存储名(32 位十六进制 + .txt,与 +// wire_document_budget_test.go 的 docE2EName 同一形态)。解析器按名取件, +// 因此多份附件必须各有一个名字。 +func attachmentName(i int) string { + return fmt.Sprintf("%032x.txt", i+1) +} + +// documentTurnMessage 造一条携带 N 份文档引用的用户消息:model 包只提供单份的 +// 构造器(DocumentMessage),多份按同一形状续部件 —— 这正是「一个回合最多 +// maxChatDocumentsPerTurn 份附件」的落库形状。 +func documentTurnMessage(text string, refs []model.Document) model.Message { + msg := model.DocumentMessage(model.RoleUser, text, refs[0]) + for _, ref := range refs[1:] { + msg.Content = append(msg.Content, model.Content{ + Type: model.ContentTypeDocument, + DocumentName: ref.Name, + DocumentMIME: ref.MIME, + DocumentURL: ref.URL, + DocumentSize: ref.Size, + }) + } + return msg +} + // TestGuardrailWiring_LargeAttachmentPassesInputGuard 是**回归**,对准主人端到端 // 实测抓到的那个真 bug:`POST /api/v1/chat` 带一个 744 KB 的 txt 附件 ⇒ // `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}` —— 历史 DTO 里 @@ -228,7 +290,7 @@ func runLargeAttachmentTurn(t *testing.T, inputLimitOverride int) largeAttachmen func TestGuardrailWiring_LargeAttachmentPassesInputGuard(t *testing.T) { t.Parallel() - turn := runLargeAttachmentTurn(t, 0) + turn := runAttachmentTurn(t, 1, 0) // ① bug 的症状先说清楚:回合不得以 guardrail_input 结束,也不得被拦在模型之前。 if turn.result.StopReason == agent.StopReasonGuardrailInput { @@ -239,9 +301,9 @@ func TestGuardrailWiring_LargeAttachmentPassesInputGuard(t *testing.T) { } // ② 前提自证:这一回合真的注入了 349525 字符(= 主人实测 DTO 里那个数), // 比旧上限 262144 大,所以上面那条断言不是碰巧成立。 - if turn.injectChars != 349525 { + if turn.perDocInject != 349525 { t.Fatalf("注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)", - turn.injectChars) + turn.perDocInject) } if turn.assembledChars <= guardrailMinInputChars { t.Fatalf("装配后的请求只有 %d 字符(≤ 旧上限 %d),复现不到「大附件被拦死」", @@ -260,7 +322,7 @@ func TestGuardrailWiring_LargeAttachmentPassesInputGuard(t *testing.T) { func TestGuardrailWiring_TightInputLimitStillBlocks(t *testing.T) { t.Parallel() - turn := runLargeAttachmentTurn(t, 1000) + turn := runAttachmentTurn(t, 1, 1000) if turn.result.StopReason != agent.StopReasonGuardrailInput { t.Fatalf("收紧上限后 StopReason = %q, want %q", turn.result.StopReason, agent.StopReasonGuardrailInput) @@ -272,22 +334,22 @@ func TestGuardrailWiring_TightInputLimitStillBlocks(t *testing.T) { t.Fatalf("被拦的回合仍调用了模型 %d 次 —— 输入侧不是前置门", turn.modelCalls) } // 注入本身照旧发生(记录如实),被拦是**上限**那一侧的判决。 - if turn.injectChars != 349525 { - t.Fatalf("注入字符数 = %d, want 349525(注入事实不因上限收紧而改写)", turn.injectChars) + if turn.perDocInject != 349525 { + t.Fatalf("注入字符数 = %d, want 349525(注入事实不因上限收紧而改写)", turn.perDocInject) } } // TestGuardrailInputLimit_NotAFunctionOfInjectBudget 是变异反证:注入预算单独变化 -// **不影响**输入上限(上限只由上下文预算与显式覆盖决定),因此「注入预算 ≤ 上限」 +// **不影响**输入上限(上限只由上下文预算与显式覆盖决定),因此「N 份注入 ≤ 上限」 // 不可能靠两边同源凑出来 —— 两端确实各自有出处。 func TestGuardrailInputLimit_NotAFunctionOfInjectBudget(t *testing.T) { t.Parallel() log, _ := capturingLog() - if got := deriveGuardrailInputLimit(log, 0, 699050, 0); got != 699050 { - t.Fatalf("注入预算为 0 时的输入上限 = %d, want 699050", got) + if got := deriveGuardrailInputLimit(log, 0, 699050, 0, maxChatDocumentsPerTurn); got != 2796200 { + t.Fatalf("注入预算为 0 时的输入上限 = %d, want 2796200", got) } - if got := deriveGuardrailInputLimit(log, 0, 699050, 349525); got != 699050 { - t.Fatalf("注入预算 349525 时的输入上限 = %d, want 699050(不受注入预算影响)", got) + if got := deriveGuardrailInputLimit(log, 0, 699050, 349525, maxChatDocumentsPerTurn); got != 2796200 { + t.Fatalf("注入预算 349525 时的输入上限 = %d, want 2796200(不受注入预算影响)", got) } } diff --git a/cmd/cyrene/wire_guardrail_multidoc_test.go b/cmd/cyrene/wire_guardrail_multidoc_test.go new file mode 100644 index 0000000..abe4ff3 --- /dev/null +++ b/cmd/cyrene/wire_guardrail_multidoc_test.go @@ -0,0 +1,184 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "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/loop" +) + +// 本文件锁定切片 G2b:把输入护栏上限从「1 份附件」对齐到「**N 份**附件」。 +// +// G2(51c1672)修的是「单份大附件被拦死」,残留缺口是**多附件**: +// +// - 护栏量的是整段装配请求(字符数),而注入预算是**单份**的: +// 1M 窗口下每份注入 349525 字符、上限 699050 字符 ⇒ **2 份**满额注入就顶到上限, +// 再加系统提示词/历史即被拦成 guardrail_input,5 份(单回合上限)必被拦; +// - 根因是两套换算:上限按 1 char ≈ 1 token 兑,注入按 loop.CharsPerToken(4)兑。 +// +// 修法:上限 = max(guardrailMinInputChars, 推导预算 token 数 × loop.CharsPerToken), +// 关系不变量升级为「maxChatDocumentsPerTurn 份 × 单份注入 ≤ 输入上限」,破了就 WARN。 + +// TestGuardrailWiring_TwoFullAttachmentsPassInputGuard 是切片 G2b 对准缺口的回归: +// **2 份**各自撞满注入预算的附件(+ 系统提示词/历史)**不得**再以 guardrail_input +// 结束。G2 之后的上限是 699050 字符(预算 token 数按 1:1 兑),而 2 份满额注入恰好 +// 就是 699050 字符 ⇒ 加上装配的固定开销必然超限,这正是主人实测的「多附件回合被拦死」。 +func TestGuardrailWiring_TwoFullAttachmentsPassInputGuard(t *testing.T) { + t.Parallel() + + turn := runAttachmentTurn(t, 2, 0) + + // ① 前提自证:这一回合真的带了 2 份附件,且每份都撞满注入预算(349525 字符)。 + if turn.documents != 2 { + t.Fatalf("本回合注入的附件份数 = %d, want 2", turn.documents) + } + if turn.perDocInject != 349525 { + t.Fatalf("每份注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)", + turn.perDocInject) + } + // ② bug 的症状先说清楚:回合不得以 guardrail_input 结束,也不得被拦在模型之前。 + if turn.result.StopReason == agent.StopReasonGuardrailInput { + t.Fatalf("2 份大附件回合仍以 guardrail_input 结束(G2b 要修的就是它):%q", turn.result.Text) + } + if turn.modelCalls != 1 { + t.Fatalf("2 份大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", turn.modelCalls) + } + // ③ 缺口自证:装配后的请求**超过**了 G2 时代的上限(= 推导预算 token 数), + // 也就是「2 份满额注入就顶到上限」—— 上面那条断言因此不是碰巧成立。 + if turn.assembledChars <= turn.g2Limit { + t.Fatalf("装配后的请求只有 %d 字符(≤ G2 时代的上限 %d),复现不到多附件缺口", + turn.assembledChars, turn.g2Limit) + } + // ④ 每份的注入事实如实回传(正文都比预算长 ⇒ 都该标 truncated + 续读游标)。 + for i, ref := range turn.result.Documents { + if ref.Chars != 349525 || !ref.Truncated { + t.Fatalf("第 %d 份附件的注入记录 = %+v,want 349525 字符且 truncated", i+1, ref) + } + } +} + +// TestGuardrailWiring_FiveFullAttachmentsPassInputGuard 是同一缺口的**上界**回归: +// 5 份(单回合上限)满额注入的回合同样要抵达模型。只说「2 份能过」不够 —— +// 本切片的不变量是「N 份全满也能过」,这里就按 N 跑一次真回合。 +func TestGuardrailWiring_FiveFullAttachmentsPassInputGuard(t *testing.T) { + t.Parallel() + + turn := runAttachmentTurn(t, maxChatDocumentsPerTurn, 0) + + if turn.documents != maxChatDocumentsPerTurn { + t.Fatalf("本回合注入的附件份数 = %d, want %d", turn.documents, maxChatDocumentsPerTurn) + } + if turn.result.StopReason == agent.StopReasonGuardrailInput { + t.Fatalf("%d 份大附件回合仍以 guardrail_input 结束:%q", + maxChatDocumentsPerTurn, turn.result.Text) + } + if turn.modelCalls != 1 { + t.Fatalf("%d 份大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", + maxChatDocumentsPerTurn, turn.modelCalls) + } + // 装配总量必须落在生效上限之内 —— 这条既是「没被拦」的解释,也是不变量的实证。 + if turn.assembledChars > turn.inputLimit { + t.Fatalf("装配后的请求 %d 字符 > 生效输入上限 %d", turn.assembledChars, turn.inputLimit) + } +} + +// TestGuardrailInputLimit_CoversAllAttachmentsOfATurn 钉住**N 份**不变量本身: +// 派生态下「单回合全部附件的注入预算合计 ≤ 输入上限」必须成立(且不记 WARN), +// 而把上限压到合计之下必须有一条看得懂是**多附件**问题的 WARN。 +// +// maxChatDocumentsPerTurn = 5 的出处:单回合附件数上限的**真源**是 +// documents.MaxPerMessage(网关侧以 `maxChatDocuments = documents.MaxPerMessage` +// 引用它),design/24 §6.1、§9.4-3 的定稿值。本切片**不跨包引用** —— documents 正 +// 被并行切片占用,而这里只需要一个数;因此取同值并在下面断言它仍是 5,值一旦漂移 +// 本用例即红。待并行切片落定后统一为一个共享常量(届时这两处都改为引用它)。 +func TestGuardrailInputLimit_CoversAllAttachmentsOfATurn(t *testing.T) { + t.Parallel() + + if maxChatDocumentsPerTurn != 5 { + t.Fatalf("单回合附件数上限 = %d, want 5(documents.MaxPerMessage 的定稿值)", + maxChatDocumentsPerTurn) + } + + cases := []struct { + name string + window int + wantDoc int + wantCap int + }{ + // 1M 窗口(声明了窗口的生产部署):预算 699050 token ⇒ 单份 349525、上限 2796200。 + {name: "1M 窗口", window: 1048576, wantDoc: 349525, wantCap: 2796200}, + // 未声明窗口的回落部署(131072):预算 87381 token ⇒ 单份落回 64K、上限 349524 + // (= 87381 × 4 > 256 Ki)。旧的 1:1 兑法在这里只给 262144,5 份 64K 合计 327680 + // 就会被拦 —— 缺口在回落部署上同样存在,本切片一并修掉。 + {name: "未声明窗口的回落部署", window: 0, wantDoc: 64 * 1024, wantCap: 87381 * loop.CharsPerToken}, + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, c.window, 0)} + budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env")) + if budget.DocumentInjectChars != c.wantDoc { + t.Fatalf("单份注入预算 = %d, want %d(推导链变了,本用例的前提需复核)", + budget.DocumentInjectChars, c.wantDoc) + } + + log, buf := capturingLog() + limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn) + if limit != c.wantCap { + t.Fatalf("输入上限 = %d, want %d(= 推导预算 token 数 × %d)", + limit, c.wantCap, loop.CharsPerToken) + } + if total := budget.DocumentInjectChars * maxChatDocumentsPerTurn; total > limit { + t.Fatalf("N 份不变量被破:%d 份 × %d = %d > 上限 %d", + maxChatDocumentsPerTurn, budget.DocumentInjectChars, total, limit) + } + if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 { + t.Fatalf("不变量成立却记了 %d 条 WARN", n) + } + }) + } +} + +// TestEnvExample_GuardrailInputFormulaMatchesCode 钉住模板与实现同步(AGENTS §10 铁律): +// 该键在 .env.example 里必须**逐字一致**,且注释里的算例必须与代码里的常量现算结果 +// 相同 —— 常量或公式漂移时本用例红,照模板部署的新实例才不会漏配/误解口径。 +func TestEnvExample_GuardrailInputFormulaMatchesCode(t *testing.T) { + t.Parallel() + + raw, err := os.ReadFile(filepath.Join("..", "..", ".env.example")) + if err != nil { + t.Fatalf("读 .env.example: %v", err) + } + template := string(raw) + + // 键名逐字一致(从常量拼出来,重命名时本用例即红)。 + if key := "# " + config.GuardrailMaxInputCharsEnv + "="; !strings.Contains(template, key) { + t.Fatalf(".env.example 里找不到 %q(键名必须与代码逐字一致)", key) + } + // 注释里的两个算例都由代码现算,模板与实现不许各说各话。 + // 1M 窗口 ⇒ 上限 = 699050 × CharsPerToken;5 份满额注入合计 = 单份注入 × 5。 + oneMegTokens := 699050 + needles := []string{ + fmt.Sprintf("%d", oneMegTokens*loop.CharsPerToken), + fmt.Sprintf("%d", loop.DocumentInjectCharsFor(oneMegTokens)*maxChatDocumentsPerTurn), + fmt.Sprintf("%d", guardrailMinInputChars), + } + for _, needle := range needles { + if !strings.Contains(template, needle) { + t.Fatalf(".env.example 的说明里缺少现算值 %q(模板没跟上代码口径)", needle) + } + } + // 口径本身要写清是**多附件**问题,否则部署方照旧按「单份」理解。 + for _, needle := range []string{"多附件", "CharsPerToken"} { + if !strings.Contains(template, needle) { + t.Fatalf(".env.example 的说明里缺少 %q(没写清多附件口径)", needle) + } + } +} diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index 76a08de..d844c28 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -157,9 +157,10 @@ func (a *app) wireRuntime() error { // wire_context.go 一处完成。Compactor 不注入,由 loop 选用内置的结构化 // compactor(离线、不调模型),并按同一份注入预算估算文档部件的代价。 // - // 它必须在护栏**之前**算出来:请求侧字符上限是这份预算的函数(切片 G2: - // 输入上限 = max(256 Ki, 推导预算 token 数)),否则 1M 窗口下 349525 字符的 - // 附件注入会先被硬编码的 262144 上限整回合拦死。 + // 它必须在护栏**之前**算出来:请求侧字符上限是这份预算的函数(切片 G2/G2b: + // 输入上限 = max(256 Ki, 推导预算 token 数 × loop.CharsPerToken)),否则 1M 窗口下 + // 单份 349525 字符的附件注入会先被硬编码的 262144 上限整回合拦死,多份附件更是 + // 成倍顶到上限(G2b 把「单份」对齐成「N 份」)。 ctxBudget := a.contextBudget(envPath) // 默认护栏策略在 buildGuardrailPipeline 里集中定义(敏感信息阻断 + diff --git a/internal/config/guardrail.go b/internal/config/guardrail.go index 97e3245..56b40e0 100644 --- a/internal/config/guardrail.go +++ b/internal/config/guardrail.go @@ -31,14 +31,16 @@ type GuardrailConfig struct { // 三态: // // - 未设置 / 0(默认)= 按推导出的上下文预算派生: - // 输入上限 = max(256 Ki, 推导预算 token 数)(1 字符 ≈ 1 token 的保守口径); + // 输入上限 = max(256 Ki, 推导预算 token 数 × CharsPerToken) + // (换算必须与附件注入预算同一个常量 loop.CharsPerToken,理由见 + // cmd/cyrene/wire_guardrail.go 的说明); // - > 0 = 显式覆盖(原样使用); // - < 0 = 非法:记一条 WARN 并回落派生值,绝不静默生效(AGENTS §10)。 // - // 派生态下「文档注入预算 ≤ 输入上限」这条关系不变量恒成立(1M 窗口 ⇒ - // 注入 349525 字符 ≤ 上限 699050 字符);显式覆盖把它设得比注入预算小时, - // 组合根记一条 WARN 明说「大附件会被护栏拦下」,而不是等到用户上传大文件 - // 时才以一个没有上下文的 input_length_exceeded 表现出来。 + // 派生态下「单回合全部附件的注入预算合计 ≤ 输入上限」这条关系不变量恒成立 + // (1M 窗口 ⇒ 5 份 × 349525 = 1747625 字符 ≤ 上限 2796200 字符);显式覆盖把它 + // 压到合计之下时,组合根记一条 WARN 明说「多附件回合会被护栏拦下」,而不是等到 + // 用户上传多个大文件时才以一个没有上下文的 input_length_exceeded 表现出来。 // // 它只动**输入**侧:输出上限(65536)防的是模型输出失控,与上下文预算无关, // 因此不随本键变化、也不开放配置。 diff --git a/internal/runtime/loop/compactor.go b/internal/runtime/loop/compactor.go index cc9bd0b..b9f7d91 100644 --- a/internal/runtime/loop/compactor.go +++ b/internal/runtime/loop/compactor.go @@ -46,9 +46,18 @@ const ( // imageTokens 是单个图像部件的粗略 token 计数。 imageTokens = 1000 - // charsPerToken 是离线估算使用的字符到 token 除数。 + // charsPerToken 是离线估算使用的字符到 token 除数。附件注入预算 + // (DocumentInjectCharsFor)与压缩估算共用它,是本仓库唯一的字符 ↔ token + // 换算口径。 charsPerToken = 4 + // CharsPerToken 是 charsPerToken 的**导出别名**:组合根里按同一口径换算的 + // 地方(cmd/cyrene 的输入护栏上限推导:上限 = 推导预算 token 数 × 本常量) + // 必须复用这一处定义,而不是另写一个字面量 4 —— 护栏上限与附件注入预算 + // 曾因两边各写一套换算(1 char = 1 token vs 4 char = 1 token)而对不上, + // 表现是「2 份各自撞满注入预算的附件就被整回合拦死」(切片 G2b)。 + CharsPerToken = charsPerToken + // summarySampleChars 限制被抽样进朴素摘要的每条消息文本长度。 summarySampleChars = 120 )