package main import ( "log/slog" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" ) // 本文件是组合根的默认护栏策略(design/04 §5)。三条默认策略与它们的依据: // // 1. 敏感信息 = **阻断**档。API key / bearer token / 密码 / 私钥这类东西 // 一旦进了请求或回复就是既成事实的泄露,标记档(放行 + 记一笔)救不回来, // 因此默认按阻断处理。 // 2. 长度上限 = 只拦病态载荷,不误伤正常对话(两个上限的取值依据见下方常量)。 // 3. 关键词表 = **只在配置提供时**挂。项目不内置任何词表:凭空猜的词只会 // 误伤正常对话,且没有部署方能为它的命中负责(见 config.GuardrailConfig)。 const ( // guardrailMinInputChars 是请求侧字符上限的**下限**(256 Ki 字符)。这个检查 // 跑在**组装完成的请求**上(系统提示词 + 记忆块 + 整段对话 + 工具结果), // 不是单条用户消息,因此上限按「整个上下文」定。 // // 为什么是「下限」而不是「定值」(切片 G2 修的真 bug):单份附件的注入预算随 // 推导出的上下文预算走,1M 窗口下已是 349525 字符(T1)。上限若还是硬编码的 // 262144,任何正文超过它的附件都会让**整个**回合以 guardrail_input 结束 // (端到端实测:`POST /chat` 带一个 744 KB 的 txt ⇒ // `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}`), // 也就是「护栏成了上下文预算的瓶颈」,把「分享大文件」这条主线直接打死。 // 因此生效值由 guardrailInputLimit 派生:max(本下限, 推导预算 token 数 × CharsPerToken)。 // 预算关闭(0)或预算小到乘完仍低于本下限时,上限就是本下限。 guardrailMinInputChars = 262144 // guardrailMaxOutputChars 是响应侧的字符上限。一条回复正常在几十到几百字, // 长代码/长文也就几千字,65536(64 Ki)字符比正常回复高 2~3 个数量级, // 因此不会截断任何正常输出,只拦失控的生成(例如无限复读)。这一点在主 // 流式路径上尤其重要:那里输出侧检查是异步旁路(只会审计,不挡已产出的 // 回复),但在静音/非流式路径(backthink、子代理)上否判决会替换答案, // 所以上限必须留足余量。 // // **它不随上下文预算派生**:输出失控与「窗口有多大」无关,本切片的输入侧 // 解耦不该顺手把它也放宽。 guardrailMaxOutputChars = 65536 ) // maxChatDocumentsPerTurn 是**单回合**附件数上限(design/24 §6.1、§9.4-3 的定稿值 5)。 // 它是关系不变量「N 份」的那一端:输入上限必须容得下「一整个回合的附件都撞满单份 // 注入预算」的装配结果,否则满载附件的回合会在装配完之后被整回合拦死(切片 G2 只 // 对了 **1 份**,本切片补 **N 份**)。 // // 出处:真源是 documents.MaxPerMessage(网关侧以 // `maxChatDocuments = documents.MaxPerMessage` 引用它)。本切片**不跨包引用**它 —— // documents 正被并行切片占用,且这里只需要一个数、不需要它的任何行为;因此取同值, // 并用测试里的来源说明 + 不变量断言钉住。待并行切片落定后统一成一个共享常量。 const maxChatDocumentsPerTurn = 5 // 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, maxChatDocumentsPerTurn, ) } // deriveGuardrailInputLimit 是 guardrailInputLimit 的可测内核:取值来源收成显式 // 参数,两条 WARN(非法覆盖 / 关系不变量被破)都在这里,测试可按条数断言。 // // 派生公式:输入上限 = max(guardrailMinInputChars, 推导预算 token 数 × CharsPerToken)。 // 换算必须与**附件注入预算**同源(G2 的残留缺口正在这里):G2 把预算 token 数按 // 1 字符 ≈ 1 token 兑成字符,而注入预算按 4 字符 ≈ 1 token 兑(loop.CharsPerToken), // 两边两套换算 ⇒ 上限 699050 字符只够 **2 份**各自撞满注入预算的附件 // (2 × 349525 = 699050),5 份必被拦。改用注入的同一常量后,1M 窗口的上限是 // 2796200 字符,5 份满额注入 1747625 字符仍有明确余量;上限仍然只拦「几百 KB // 整块文本直接灌进来」这种病态载荷(真正的上下文控制由压缩预算负责)。 func deriveGuardrailInputLimit(log *slog.Logger, override, budgetTokens, docInjectChars, maxDocs int) int { derived := guardrailMinInputChars if scaled := budgetTokens * loop.CharsPerToken; scaled > derived { derived = scaled } 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 injected := docInjectChars * maxDocs; injected > limit { // 关系不变量被破(**多附件**口径):一个回合最多 maxDocs 份附件,每份可注入 // docInjectChars 字符,装配后的请求至少要容得下「全部附件都撞满注入预算」。 // 破了就说明满载附件的回合会在装配完之后被整回合拦下,现象是用户看到一个 // 没有上下文的 input_length_exceeded。这里明说一句,不静默。 log.Warn("document inject budget x max attachments exceeds guardrail input limit; multi-attachment turns may be blocked", "env", config.GuardrailMaxInputCharsEnv, "guardrail_max_input_chars", limit, "max_documents", maxDocs, "document_inject_chars", docInjectChars, "document_inject_total_chars", injected, "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 }