- 残留缺口:护栏量整段装配请求(字符),注入预算是单份 ⇒ 1M 窗口下 2×349525 就顶到旧上限 699050、5 份必被拦。根因是两套换算(上限按 1char=1token,注入按 loop 的 4char=1token)。 - 修法:上限 = max(262144, 推导预算 tokens × loop.CharsPerToken) ⇒ 1M 窗口 2,796,200 字符(5 份满额注入 1,747,625 有余量);不变量升级为「N 份」(maxChatDocumentsPerTurn=5,带出处注释);WARN 文案升级为多附件口径(max_documents/document_inject_chars/document_inject_total_chars/guardrail_max_input_chars/hint)。 - 同一换算常量复用:在 loop/compactor.go 的既有无导出常量旁加导出别名 loop.CharsPerToken = charsPerToken(2 行、零行为改动、不重命名)—— 这是本切片唯一触及「不要碰」清单的改动,已由主会话复核接受(替代方案是复制一个字面量 4,会重新造出两套换算)。 - 同步陈旧注释(config/guardrail.go、wire_runtime.go 里的旧公式)与 .env.example(新公式 + 多附件口径 + 1M 算例 2796200/1747625),并有「模板 ↔ 代码现算值」的一致性用例。 - 主会话独立验收:隔离 worktree(G2b 的 7 文件,排除 B1/ST1 在途)build/vet 绿、全树 59 包 0 FAIL、护栏用例 17 PASS、双平台交叉编译绿。 - 残留(未修,需裁决):预算关闭或落在 (0,81920) token 的部署,派生态上限 = 262144 而 5×64K = 327680 ⇒ 满载附件回合仍可能被拦(启动会记 WARN,不静默);真正的下限应取 max(262144, maxDocs×每份注入)。
185 lines
8.9 KiB
Go
185 lines
8.9 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|