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

143 lines
8.2 KiB
Go
Raw 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 (
"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
}