- 残留缺口:护栏量整段装配请求(字符),注入预算是单份 ⇒ 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×每份注入)。
143 lines
8.2 KiB
Go
143 lines
8.2 KiB
Go
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
|
||
}
|