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 份各撞注入预算的大附件仍可能被拦。
This commit is contained in:
1 parent
d37d4a899b
commit
51c1672355
10 files changed
+577
-48
No files matched your search
@@ -39,6 +39,11 @@ func (a *guardProbeAdapter) ChatStream(ctx context.Context, req model.Request, o
|
||||
return a.Chat(ctx, req, model.Options{})
|
||||
}
|
||||
|
||||
// noWindowBudget 是「没声明窗口的小部署」的预算形状:压缩预算与注入预算两项
|
||||
// 推导值都是 0,于是派生的输入上限落回下限 guardrailMinInputChars —— 也就是
|
||||
// 接线前那个硬编码值的等价场景。只关心「护栏里有什么」的用例传它即可。
|
||||
func noWindowBudget() ctxBudget { return ctxBudget{} }
|
||||
|
||||
// guardrailApp 返回带给定关键词表的组合根切片;cfg 为 nil 表示「一点配置都没有」。
|
||||
func guardrailApp(t *testing.T, keywords []string) *app {
|
||||
t.Helper()
|
||||
@@ -84,7 +89,7 @@ func truncate(s string, n int) string {
|
||||
func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := guardrailApp(t, nil).buildGuardrailPipeline()
|
||||
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
|
||||
|
||||
req := model.Request{Messages: []model.Message{
|
||||
model.TextMessage(model.RoleSystem, "你是昔涟,温柔俏皮的小女友。"),
|
||||
@@ -108,7 +113,7 @@ func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) {
|
||||
func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := guardrailApp(t, nil).buildGuardrailPipeline()
|
||||
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
|
||||
|
||||
secret := "sk-abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
cases := []struct {
|
||||
@@ -145,10 +150,21 @@ func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) {
|
||||
|
||||
// TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads 锁定长度上限的
|
||||
// 边界:恰好等于上限放行、超一个字符就被拦;并且「很长但正常」的上下文不被误伤。
|
||||
//
|
||||
// 这里用的是 noWindowBudget(小窗口部署):派生的输入上限落回下限
|
||||
// guardrailMinInputChars,也就是接线前那个 262144 的行为。
|
||||
func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
p := guardrailApp(t, nil).buildGuardrailPipeline()
|
||||
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
|
||||
if limit := guardrailApp(t, nil).guardrailInputLimit(noWindowBudget()); limit != guardrailMinInputChars {
|
||||
t.Fatalf("小窗口部署的输入上限 = %d, want 下限 %d", limit, guardrailMinInputChars)
|
||||
}
|
||||
// 输出上限是**另一个**数:它防的是模型输出失控,与上下文预算无关,
|
||||
// 因此本切片只动输入侧,输出侧不得顺手放宽(回归钉住取值本身)。
|
||||
if guardrailMaxOutputChars != 65536 {
|
||||
t.Fatalf("输出上限 = %d, want 65536(本切片只动输入侧)", guardrailMaxOutputChars)
|
||||
}
|
||||
|
||||
if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars)); !v.Allow {
|
||||
t.Fatalf("恰好到上限的输出被拦:%+v", v)
|
||||
@@ -170,7 +186,7 @@ func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T)
|
||||
}
|
||||
// 超过上限则被拦。
|
||||
over := model.Request{Messages: []model.Message{
|
||||
model.TextMessage(model.RoleUser, strings.Repeat("a", guardrailMaxInputChars+1)),
|
||||
model.TextMessage(model.RoleUser, strings.Repeat("a", guardrailMinInputChars+1)),
|
||||
}}
|
||||
if v, _ := p.CheckInput(context.Background(), &over); v.Allow {
|
||||
t.Fatal("超出输入上限的载荷却放行")
|
||||
@@ -185,11 +201,11 @@ func TestGuardrailPipeline_KeywordsOnlyWhenConfigured(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
text := "这段文本里出现了违禁词。"
|
||||
if v := checkInput(t, guardrailApp(t, nil).buildGuardrailPipeline(), text); !v.Allow {
|
||||
if v := checkInput(t, guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget()), text); !v.Allow {
|
||||
t.Fatalf("未配置词表却拦下了文本:%+v", v)
|
||||
}
|
||||
|
||||
configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline()
|
||||
configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline(noWindowBudget())
|
||||
v := checkInput(t, configured, text)
|
||||
if v.Allow {
|
||||
t.Fatal("配置了词表却没拦下命中文本")
|
||||
@@ -235,7 +251,7 @@ func TestGuardrailPipeline_RealPromptPasses(t *testing.T) {
|
||||
t.Fatalf("测试前提不成立:真实身份说明书没被读进来:%q", instructions)
|
||||
}
|
||||
|
||||
pipeline := a.buildGuardrailPipeline()
|
||||
pipeline := a.buildGuardrailPipeline(noWindowBudget())
|
||||
req := model.Request{Messages: []model.Message{
|
||||
model.TextMessage(model.RoleSystem, instructions),
|
||||
model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?"),
|
||||
@@ -256,7 +272,7 @@ func TestGuardrailPipeline_RealPromptPasses(t *testing.T) {
|
||||
func TestGuardrailWiring_EndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
guards := guardrailApp(t, nil).buildGuardrailPipeline()
|
||||
guards := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
|
||||
adapter := &guardProbeAdapter{reply: "我在呢♪"}
|
||||
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user