- 真 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 份各撞注入预算的大附件仍可能被拦。
124 lines
6.7 KiB
Go
124 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"log/slog"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
|
)
|
|
|
|
// 本文件是组合根的默认护栏策略(design/04 §5)。三条默认策略与它们的依据:
|
|
//
|
|
// 1. 敏感信息 = **阻断**档。API key / bearer token / 密码 / 私钥这类东西
|
|
// 一旦进了请求或回复就是既成事实的泄露,标记档(放行 + 记一笔)救不回来,
|
|
// 因此默认按阻断处理。
|
|
// 2. 长度上限 = 只拦病态载荷,不误伤正常对话(两个上限的取值依据见下方常量)。
|
|
// 3. 关键词表 = **只在配置提供时**挂。项目不内置任何词表:凭空猜的词只会
|
|
// 误伤正常对话,且没有部署方能为它的命中负责(见 config.GuardrailConfig)。
|
|
const (
|
|
// guardrailMinInputChars 是请求侧字符上限的**下限**(256 Ki 字符)。这个检查
|
|
// 跑在**组装完成的请求**上(系统提示词 + 记忆块 + 整段对话 + 工具结果),
|
|
// 不是单条用户消息,因此上限按「整个上下文」定。
|
|
//
|
|
// 为什么是「下限」而不是「定值」(本切片修的真 bug):单份附件的注入预算随
|
|
// 推导出的上下文预算走,1M 窗口下已是 349525 字符(T1)。上限若还是硬编码的
|
|
// 262144,任何正文超过它的附件都会让**整个**回合以 guardrail_input 结束
|
|
// (端到端实测:`POST /chat` 带一个 744 KB 的 txt ⇒
|
|
// `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}`),
|
|
// 也就是「护栏成了上下文预算的瓶颈」,把「分享大文件」这条主线直接打死。
|
|
// 因此生效值由 guardrailInputLimit 派生:max(本下限, 推导预算 token 数),
|
|
// 取 1 字符 ≈ 1 token 的保守口径。小窗口部署下派生的 token 数本就小于它,
|
|
// 输入上限与接线前逐字节一致。
|
|
guardrailMinInputChars = 262144
|
|
// guardrailMaxOutputChars 是响应侧的字符上限。一条回复正常在几十到几百字,
|
|
// 长代码/长文也就几千字,65536(64 Ki)字符比正常回复高 2~3 个数量级,
|
|
// 因此不会截断任何正常输出,只拦失控的生成(例如无限复读)。这一点在主
|
|
// 流式路径上尤其重要:那里输出侧检查是异步旁路(只会审计,不挡已产出的
|
|
// 回复),但在静音/非流式路径(backthink、子代理)上否判决会替换答案,
|
|
// 所以上限必须留足余量。
|
|
//
|
|
// **它不随上下文预算派生**:输出失控与「窗口有多大」无关,本切片的输入侧
|
|
// 解耦不该顺手把它也放宽。
|
|
guardrailMaxOutputChars = 65536
|
|
)
|
|
|
|
// buildGuardrailPipeline 组装循环与各 agent 共用的护栏管线(design/04 §5)。
|
|
// 它是组合根里唯一一处决定「护栏里到底有什么」的地方,因此测试可以直接
|
|
// 断言这个函数的产物,而不必启动整个运行时。
|
|
//
|
|
// 取 budget 参数而不是自己去推导:输入上限是**上下文预算的函数**(关系不变量
|
|
// 「文档注入预算 ≤ 输入上限」的两端都在这份预算里),而预算的推导只该在
|
|
// wire_context.go 发生一次 —— 各自推导会把「窗口未声明」那条 WARN 记两遍。
|
|
// 调用顺序因此固定为「先算预算,再建管线」(wire_runtime.go)。
|
|
//
|
|
// 选项顺序即检查顺序(guardrail.New 按序应用),且先跑开销低、语义硬的
|
|
// 敏感信息检查:命中即返回该判决,长度与关键词检查不会再跑。
|
|
func (a *app) buildGuardrailPipeline(budget ctxBudget) *guardrail.Pipeline {
|
|
opts := []guardrail.Option{
|
|
// ① 敏感信息外泄必须拦:阻断档。
|
|
guardrail.WithSensitiveInfo(guardrail.SensitiveBlock),
|
|
// ② 长度上限:0 会关掉那一侧,因此两侧都给上保守上限。
|
|
guardrail.WithLengthLimits(a.guardrailInputLimit(budget), guardrailMaxOutputChars),
|
|
}
|
|
// ③ 关键词表仅在配置提供时挂:没有配置就不挂(KeywordGuard 空表本也
|
|
// 全放行,这里显式分支是为了让「没配置」在管线上就是没有这个检查)。
|
|
if keywords := a.guardrailKeywords(); len(keywords) > 0 {
|
|
opts = append(opts, guardrail.WithKeywords(keywords...))
|
|
}
|
|
return guardrail.New(opts...)
|
|
}
|
|
|
|
// guardrailInputLimit 返回本次运行生效的请求侧字符上限:
|
|
// 配置显式覆盖优先,否则由推导出的上下文预算派生(见 deriveGuardrailInputLimit)。
|
|
func (a *app) guardrailInputLimit(budget ctxBudget) int {
|
|
override := 0
|
|
if a.cfg != nil {
|
|
override = a.cfg.Guardrail.MaxInputChars
|
|
}
|
|
return deriveGuardrailInputLimit(
|
|
sourcedWiringLog(a.log), override,
|
|
budget.Budget.Tokens, budget.DocumentInjectChars,
|
|
)
|
|
}
|
|
|
|
// deriveGuardrailInputLimit 是 guardrailInputLimit 的可测内核:取值来源收成显式
|
|
// 参数,两条 WARN(非法覆盖 / 关系不变量被破)都在这里,测试可按条数断言。
|
|
//
|
|
// 派生公式:输入上限 = max(guardrailMinInputChars, 推导预算 token 数)。字符与
|
|
// token 按 1:1 的保守口径换算 —— 上限宁可放宽一点也不该误伤正常上下文,而它仍然
|
|
// 只拦「几百 KB 整块文本直接灌进来」这种病态载荷(真正的上下文控制由压缩预算负责)。
|
|
func deriveGuardrailInputLimit(log *slog.Logger, override, budgetTokens, docInjectChars int) int {
|
|
derived := guardrailMinInputChars
|
|
if budgetTokens > derived {
|
|
derived = budgetTokens
|
|
}
|
|
limit := derived
|
|
switch {
|
|
case override < 0:
|
|
// 非法覆盖:记一条带原始取值的 WARN 并回落派生值(AGENTS §10:不许静默)。
|
|
log.Warn("guardrail input limit invalid, falling back to derived",
|
|
"env", config.GuardrailMaxInputCharsEnv, "value", override, "fallback", derived)
|
|
case override > 0:
|
|
limit = override
|
|
}
|
|
if docInjectChars > limit {
|
|
// 关系不变量被破:注入预算比输入上限还大,意味着「能注入的正文」本身就会
|
|
// 触发输入护栏 —— 大附件会在装配完之后被整回合拦下,现象是用户看到一个
|
|
// 没有上下文的 input_length_exceeded。这里明说一句,不静默。
|
|
log.Warn("document inject budget exceeds guardrail input limit; large attachments will be blocked",
|
|
"env", config.GuardrailMaxInputCharsEnv, "guardrail_max_input_chars", limit,
|
|
"document_inject_chars", docInjectChars,
|
|
"hint", "调大本键或调小 DOCUMENT_MAX_INJECT_CHARS,使注入预算不超过输入上限")
|
|
}
|
|
return limit
|
|
}
|
|
|
|
// guardrailKeywords 返回部署方声明的内容策略词表(GUARDRAIL_KEYWORDS);
|
|
// 未配置时返回 nil,管线里就不含关键词检查。
|
|
func (a *app) guardrailKeywords() []string {
|
|
if a.cfg == nil {
|
|
return nil
|
|
}
|
|
return a.cfg.Guardrail.Keywords
|
|
}
|