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 }