Files
Cyrene_v2/cmd/cyrene/wire_guardrail_limit_test.go
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

356 lines
16 KiB
Go
Raw Permalink 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"
"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/documents"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定切片 G2 / G2b 的输入侧解耦(端到端实测抓到的真 bug:大附件被输入护栏拦死):
//
// - 输入上限 = max(256 Ki, 推导预算 token 数 × CharsPerToken)(与附件注入预算
// **同一套换算**;G2 按 1 字符 ≈ 1 token 兑,于是 2 份满额注入的附件就顶到上限,
// G2b 统一到注入侧那个常量);
// - GUARDRAIL_MAX_INPUT_CHARS 的三态(未设/0 = 派生、>0 = 覆盖、<0 = WARN 回落);
// - 关系不变量「单回合全部附件的注入预算合计 ≤ 输入护栏上限」,人为破坏它必须**有 WARN**;
// - 回归:注入正文比旧上限(262144 字符)还大时,回合**不得**以 guardrail_input 结束;
// - 反证:显式把上限收紧后,同一个大附件回合**真的**被拦(护栏没被削弱)。
//
// 多附件的回归与「N 份」不变量的用例在 wire_guardrail_multidoc_test.go。
//
// 输出上限(guardrailMaxOutputChars)不属本切片:它防的是模型输出失控,与上下文
// 预算无关,因此保持 65536 不动(回归见 TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads)。
// largeDocChars 是回归夹具的附件正文长度:既大于旧上限 262144(否则复现不到 bug),
// 又大于 1M 窗口下的注入预算 349525(因此走「已截断 + 续读游标」那条路径,
// 与主人实测时历史 DTO 里 `"chars":349525,"truncated":true` 的形状一致)。
const largeDocChars = 400000
// TestGuardrailInputLimit_DerivedFromBudget 钉住派生公式(表驱动):
// 输入上限 = max(下限, 推导预算 token 数 × CharsPerToken) —— 小预算落回下限,
// 大窗口随预算抬高(换算与附件注入预算同一个常量,否则多附件回合会被拦死)。
func TestGuardrailInputLimit_DerivedFromBudget(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tokens int
want int
}{
{"1M 窗口推导出的预算(699050 token)⇒ 按 CharsPerToken 上抬", 699050, 2796200},
{"预算正好等于下限 ÷ CharsPerToken ⇒ 下限", guardrailMinInputChars / loop.CharsPerToken, guardrailMinInputChars},
{"小预算(600 token)⇒ 回落下限", 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, maxChatDocumentsPerTurn); 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, maxChatDocumentsPerTurn); 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, maxChatDocumentsPerTurn); got != 2796200 {
t.Fatalf("非法覆盖后的回落 = %d, want 派生值 2796200", 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(2796200) {
t.Fatalf("WARN 没带原始取值与回落值: %v", rec)
}
}
}
if !found {
t.Fatal("没有找到非法覆盖的 WARN")
}
}
// TestGuardrailInputLimit_RelationInvariantWithInjectBudget 钉住关系不变量
// 「单回合全部附件的注入预算合计 ≤ 输入护栏上限」:对 1M 窗口的推导值成立
// (maxDocuments 份 × 单份注入),且人为把上限压到之下 ⇒ **记一条 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, maxChatDocumentsPerTurn)
if total := budget.DocumentInjectChars * maxChatDocumentsPerTurn; total > limit {
t.Fatalf("关系不变量被破:%d 份注入合计 %d > 输入上限 %d",
maxChatDocumentsPerTurn, total, 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 明说后果,
// 且带上 N 份 / 单份 / 合计三项取值(否则没人看得出这是多附件问题)。
log, buf = capturingLog()
if got := deriveGuardrailInputLimit(log, 4096, budget.Budget.Tokens, budget.DocumentInjectChars, maxChatDocumentsPerTurn); 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 !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) ||
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)
}
}
}
if !found {
t.Fatal("没有找到关系不变量被破的 WARN")
}
}
// largeAttachmentTurn 是「一个回合 + 一份大附件」的观测结果。
type attachmentTurn struct {
// result 是该回合的循环结论(StopReason / Text / Documents 都在这里)。
result *loop.Result
// modelCalls 是模型被调用的次数(= 捕获到的请求数)。
modelCalls int
// assembledChars 是模型实际看到的文本总量(装配后、注入正文已在内)。
assembledChars 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
}
// runAttachmentTurn 跑一个回合:1M 窗口的推导预算(单份注入 349525 字符)+ docs 份
// largeDocChars 字符的 txt 附件 + 真实的护栏管线。inputLimitOverride 非 0 时
// 模拟 GUARDRAIL_MAX_INPUT_CHARS 的覆盖态。
//
// 三个角色都是生产实现:预算取自 a.contextBudget、注入走真实的
// loop.resolveDocuments + documents.Resolver、护栏是 a.buildGuardrailPipeline 的产物。
// 唯一被单独钉住的是「窗口 = 1M」。
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")
if err := os.MkdirAll(uploads, 0o700); err != nil {
t.Fatalf("建上传目录: %v", err)
}
body := strings.Repeat("abcdefghij", largeDocChars/10)
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}
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) {},
})
res, err := l.Run(context.Background(), "sess-large-doc",
[]model.Message{documentTurnMessage("看看这些附件", refs)})
if err != nil {
t.Fatalf("Run: %v", err)
}
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.perDocInject = 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
}
// 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 里
// 已如实写下 `"chars":349525,"truncated":true`,工具 document-read 也正常,
// 是回合在**输入护栏**这一层就没被放进去,「分享大文件」这条主线因此被打死。
//
// 这里在进程内复现同一形状,断言它不再发生:注入正文比旧上限还大,但回合照常
// 抵达模型(adapter 被调用一次)并以正常回答结束。
func TestGuardrailWiring_LargeAttachmentPassesInputGuard(t *testing.T) {
t.Parallel()
turn := runAttachmentTurn(t, 1, 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.perDocInject != 349525 {
t.Fatalf("注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)",
turn.perDocInject)
}
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 := runAttachmentTurn(t, 1, 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.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, maxChatDocumentsPerTurn); got != 2796200 {
t.Fatalf("注入预算为 0 时的输入上限 = %d, want 2796200", got)
}
if got := deriveGuardrailInputLimit(log, 0, 699050, 349525, maxChatDocumentsPerTurn); got != 2796200 {
t.Fatalf("注入预算 349525 时的输入上限 = %d, want 2796200(不受注入预算影响)", got)
}
}