Files
Cyrene_v2/cmd/cyrene/wire_guardrail_limit_test.go
T
AskaEth 51c1672355
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(guardrail): 输入护栏上限按推导预算派生(修「大附件被拦死」)+ 第 5 条 flake
- 真 bug:文档注入预算(1M 窗口 ⇒ 349525 字符)> 输入护栏硬编码上限(262144)⇒ 带 744KB 附件的 POST /chat 直接 input_length_exceeded/guardrail_input,整个回合作废(主会话活服务实测)。

- 修法:guardrailMaxInputChars → guardrailMinInputChars(256Ki 下限) + 纯函数 deriveGuardrailInputLimit(log, override, budgetTokens, docInjectChars):派生值 = max(下限, 推导预算 tokens)(1M ⇒ 699050);新增 env GUARDRAIL_MAX_INPUT_CHARS(未设/0=派生、>0=覆盖、<0=WARN 回落);预算只在 wire_context 推导一次并经 buildGuardrailPipeline(ctxBudget) 传入(避免「窗口未声明」WARN 记两遍);输出上限 65536 刻意不参与派生(有显式回归钉住)。

- 关系不变量:文档注入预算 ≤ 输入上限,人为破坏 ⇒ 一条 WARN(不静默)。

- 第 5 条 flake(exec/TestTerminal_Timeout_Killed):仅改测试、terminal.go 保持 pristine;根因不是「杀进程失效」而是旧断言的 ~200ms 精度把宿主负载下的进程创建耗时算了进去(重压下 cmd.Start 会直接返回 deadline exceeded,红行却报 process not killed)⇒ 改为「控制组证明同命令不设短超时正常退出」的确定性证明 + 把墙钟断言降级为 10s 语义判别界(子进程自然存活期拉到 30s)。

- 主会话独立验收:隔离 worktree(G2 的 10 个文件,排除 M1 在途的 wire_channels.go)build/vet 绿、全树 59 包 0 FAIL、双平台交叉编译绿、Guardrail* 全 PASS、exec -count=20 绿;变异独立复现(把派生值退回硬编码 ⇒ 4 条红,含 budget=699050 → 262144)。

- 残留缺口(已排 G2b):护栏量的是整段装配请求、注入预算是单份 ⇒ ≥2 份各撞注入预算的大附件仍可能被拦。
2026-10-04 19:15:12 +08:00

294 lines
13 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 (
"context"
"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/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定切片 G2 的输入侧解耦(端到端实测抓到的真 bug:大附件被输入护栏拦死):
//
// - 输入上限 = max(256 Ki, 推导预算 token 数)(1 字符 ≈ 1 token 的保守口径);
// - GUARDRAIL_MAX_INPUT_CHARS 的三态(未设/0 = 派生、>0 = 覆盖、<0 = WARN 回落);
// - 关系不变量「文档注入预算 ≤ 输入护栏上限」,人为破坏它必须**有 WARN**;
// - 回归:注入正文比旧上限(262144 字符)还大时,回合**不得**以 guardrail_input 结束;
// - 反证:显式把上限收紧后,同一个大附件回合**真的**被拦(护栏没被削弱)。
//
// 输出上限(guardrailMaxOutputChars)不属本切片:它防的是模型输出失控,与上下文
// 预算无关,因此保持 65536 不动(回归见 TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads)。
// largeDocChars 是回归夹具的附件正文长度:既大于旧上限 262144(否则复现不到 bug),
// 又大于 1M 窗口下的注入预算 349525(因此走「已截断 + 续读游标」那条路径,
// 与主人实测时历史 DTO 里 `"chars":349525,"truncated":true` 的形状一致)。
const largeDocChars = 400000
// TestGuardrailInputLimit_DerivedFromBudget 钉住派生公式(表驱动):
// 输入上限 = max(下限, 推导预算 token 数) —— 小窗口部署落回下限,大窗口随预算抬高。
func TestGuardrailInputLimit_DerivedFromBudget(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tokens int
want int
}{
{"1M 窗口推导出的预算(699050)⇒ 随之上抬", 699050, 699050},
{"预算正好等于下限 ⇒ 下限", guardrailMinInputChars, guardrailMinInputChars},
{"小预算(600)⇒ 回落下限", 600, guardrailMinInputChars},
{"关闭压缩(0)⇒ 下限", 0, guardrailMinInputChars},
{"负预算 ⇒ 下限", -1, guardrailMinInputChars},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
log, _ := capturingLog()
if got := deriveGuardrailInputLimit(log, 0, c.tokens, 0); got != c.want {
t.Fatalf("deriveGuardrailInputLimit(budget=%d) = %d, want %d", c.tokens, got, c.want)
}
})
}
}
// TestGuardrailInputLimit_OverrideAndIllegalValue 钉住 env 三态:>0 覆盖、<0 记
// **恰好一条** WARN 后回落派生值(带原始取值,绝不静默)。
func TestGuardrailInputLimit_OverrideAndIllegalValue(t *testing.T) {
t.Parallel()
log, buf := capturingLog()
if got := deriveGuardrailInputLimit(log, 1000, 699050, 0); got != 1000 {
t.Fatalf("显式覆盖 = %d, want 1000(覆盖优先于派生)", got)
}
if n := countRecords(logRecords(t, buf), "WARN", "guardrail input limit invalid"); n != 0 {
t.Fatalf("合法覆盖却记了 %d 条 WARN", n)
}
log, buf = capturingLog()
if got := deriveGuardrailInputLimit(log, -1, 699050, 0); got != 699050 {
t.Fatalf("非法覆盖后的回落 = %d, want 派生值 699050", got)
}
records := logRecords(t, buf)
if n := countRecords(records, "WARN", "guardrail input limit invalid"); n != 1 {
t.Fatalf("非法覆盖的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
}
// WARN 必须点出「改哪个键」与原始取值,否则部署方不知道该去改哪儿。
found := false
for _, rec := range records {
if msg, _ := rec["msg"].(string); strings.Contains(msg, "guardrail input limit invalid") {
found = true
if rec["env"] != config.GuardrailMaxInputCharsEnv {
t.Fatalf("WARN 没点出配置键: %v", rec)
}
if rec["value"] != float64(-1) || rec["fallback"] != float64(699050) {
t.Fatalf("WARN 没带原始取值与回落值: %v", rec)
}
}
}
if !found {
t.Fatal("没有找到非法覆盖的 WARN")
}
}
// TestGuardrailInputLimit_RelationInvariantWithInjectBudget 钉住关系不变量
// 「文档注入预算 ≤ 输入护栏上限」:对 1M 窗口的推导值成立,且人为把上限设得比
// 注入预算小 ⇒ **记一条 WARN**(说明「大附件会被护栏拦下」),不静默。
func TestGuardrailInputLimit_RelationInvariantWithInjectBudget(t *testing.T) {
t.Parallel()
// 1M 窗口的推导:预算 699050 token ⇒ 单份附件注入预算 349525 字符。
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
if budget.DocumentInjectChars != 349525 {
t.Fatalf("1M 窗口下的注入预算 = %d, want 349525(推导链变了,本用例的前提需要复核)",
budget.DocumentInjectChars)
}
log, buf := capturingLog()
limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars)
if budget.DocumentInjectChars > limit {
t.Fatalf("关系不变量被破:注入预算 %d > 输入上限 %d", budget.DocumentInjectChars, limit)
}
if limit != 699050 {
t.Fatalf("1M 窗口下的输入上限 = %d, want 699050(= 推导预算 token 数)", limit)
}
if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 {
t.Fatalf("不变量成立却记了 %d 条 WARN", n)
}
// 人为破坏:把上限压到注入预算之下 ⇒ 必须有一条 WARN 明说后果。
log, buf = capturingLog()
if got := deriveGuardrailInputLimit(log, 4096, budget.Budget.Tokens, budget.DocumentInjectChars); got != 4096 {
t.Fatalf("收紧后的上限 = %d, want 4096", got)
}
records := logRecords(t, buf)
if n := countRecords(records, "WARN", "exceeds guardrail input limit"); n != 1 {
t.Fatalf("不变量被破的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
}
found := false
for _, rec := range records {
if msg, _ := rec["msg"].(string); strings.Contains(msg, "exceeds guardrail input limit") {
found = true
if rec["document_inject_chars"] != float64(budget.DocumentInjectChars) ||
rec["guardrail_max_input_chars"] != float64(4096) {
t.Fatalf("WARN 没写清两端取值: %v", rec)
}
}
}
if !found {
t.Fatal("没有找到关系不变量被破的 WARN")
}
}
// largeAttachmentTurn 是「一个回合 + 一份大附件」的观测结果。
type largeAttachmentTurn struct {
// result 是该回合的循环结论(StopReason / Text / Documents 都在这里)。
result *loop.Result
// modelCalls 是模型被调用的次数(= 捕获到的请求数)。
modelCalls int
// assembledChars 是模型实际看到的文本总量(装配后、注入正文已在内)。
assembledChars int
// injectChars 是本回合如实记录的注入字符数(Result.Documents[0].Chars)。
injectChars int
}
// runLargeAttachmentTurn 跑一个回合:1M 窗口的推导预算(注入 349525 字符)+
// 一份 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 {
t.Helper()
dataDir := t.TempDir()
uploads := filepath.Join(dataDir, "uploads", "documents")
if err := os.MkdirAll(uploads, 0o700); err != nil {
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)
}
cfg := &config.Config{DataDir: dataDir}
cfg.Guardrail.MaxInputChars = inputLimitOverride
a := &app{log: testLog(), cfg: cfg, llm: envWindowStack(t, 1048576, 0)}
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
guards := a.buildGuardrailPipeline(budget)
adapter := &compactProbeAdapter{}
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
if err != nil {
t.Fatalf("agent.New: %v", err)
}
l := loop.New(loop.Options{
Agent: ag,
Guards: guards,
DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
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})
if err != nil {
t.Fatalf("Run: %v", err)
}
turn := largeAttachmentTurn{result: res, modelCalls: len(adapter.reqs)}
if len(res.Documents) > 0 {
turn.injectChars = res.Documents[0].Chars
}
if len(adapter.reqs) > 0 {
req := adapter.reqs[len(adapter.reqs)-1]
for _, m := range req.Messages {
turn.assembledChars += len(loopText(m))
}
}
return turn
}
// TestGuardrailWiring_LargeAttachmentPassesInputGuard 是**回归**,对准主人端到端
// 实测抓到的那个真 bug:`POST /api/v1/chat` 带一个 744 KB 的 txt 附件 ⇒
// `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}` —— 历史 DTO 里
// 已如实写下 `"chars":349525,"truncated":true`,工具 document-read 也正常,
// 是回合在**输入护栏**这一层就没被放进去,「分享大文件」这条主线因此被打死。
//
// 这里在进程内复现同一形状,断言它不再发生:注入正文比旧上限还大,但回合照常
// 抵达模型(adapter 被调用一次)并以正常回答结束。
func TestGuardrailWiring_LargeAttachmentPassesInputGuard(t *testing.T) {
t.Parallel()
turn := runLargeAttachmentTurn(t, 0)
// ① bug 的症状先说清楚:回合不得以 guardrail_input 结束,也不得被拦在模型之前。
if turn.result.StopReason == agent.StopReasonGuardrailInput {
t.Fatalf("大附件回合仍以 guardrail_input 结束(这正是被修的 bug):%q", turn.result.Text)
}
if turn.modelCalls != 1 {
t.Fatalf("大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", turn.modelCalls)
}
// ② 前提自证:这一回合真的注入了 349525 字符(= 主人实测 DTO 里那个数),
// 比旧上限 262144 大,所以上面那条断言不是碰巧成立。
if turn.injectChars != 349525 {
t.Fatalf("注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)",
turn.injectChars)
}
if turn.assembledChars <= guardrailMinInputChars {
t.Fatalf("装配后的请求只有 %d 字符(≤ 旧上限 %d),复现不到「大附件被拦死」",
turn.assembledChars, guardrailMinInputChars)
}
if !turn.result.Documents[0].Truncated {
t.Fatal("清单没有如实标注注入被截断(design/24 §4.3)")
}
}
// TestGuardrailWiring_TightInputLimitStillBlocks 是**护栏没被削弱**的自证:
// 同一个大附件回合,只要显式把输入上限压到 1000 字符(GUARDRAIL_MAX_INPUT_CHARS
// 的覆盖态),回合必须真的被拦成 guardrail_input,且**在模型调用之前**拦下。
//
// 没有这条,上面那条回归有可能是「把输入侧长度检查放空了」才通过的。
func TestGuardrailWiring_TightInputLimitStillBlocks(t *testing.T) {
t.Parallel()
turn := runLargeAttachmentTurn(t, 1000)
if turn.result.StopReason != agent.StopReasonGuardrailInput {
t.Fatalf("收紧上限后 StopReason = %q, want %q", turn.result.StopReason, agent.StopReasonGuardrailInput)
}
if !strings.Contains(turn.result.Text, "input_length_exceeded") {
t.Fatalf("收紧上限后的回答 = %q, want 含 input_length_exceeded", turn.result.Text)
}
if turn.modelCalls != 0 {
t.Fatalf("被拦的回合仍调用了模型 %d 次 —— 输入侧不是前置门", turn.modelCalls)
}
// 注入本身照旧发生(记录如实),被拦是**上限**那一侧的判决。
if turn.injectChars != 349525 {
t.Fatalf("注入字符数 = %d, want 349525(注入事实不因上限收紧而改写)", turn.injectChars)
}
}
// TestGuardrailInputLimit_NotAFunctionOfInjectBudget 是变异反证:注入预算单独变化
// **不影响**输入上限(上限只由上下文预算与显式覆盖决定),因此「注入预算 ≤ 上限」
// 不可能靠两边同源凑出来 —— 两端确实各自有出处。
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, 349525); got != 699050 {
t.Fatalf("注入预算 349525 时的输入上限 = %d, want 699050(不受注入预算影响)", got)
}
}