Files
Cyrene_v2/cmd/cyrene/wire_guardrail_multidoc_test.go
T
AskaEth 137b3e9eaf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(guardrail): 输入上限与附件注入预算统一换算(多附件不再被拦)
- 残留缺口:护栏量整段装配请求(字符),注入预算是单份 ⇒ 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×每份注入)。
2026-10-04 19:41:44 +08:00

185 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}
}