fix(guardrail): 输入护栏上限按推导预算派生(修「大附件被拦死」)+ 第 5 条 flake
- 真 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 份各撞注入预算的大附件仍可能被拦。
This commit is contained in:
1 parent
d37d4a899b
commit
51c1672355
10 files changed
+577
-48
No files matched your search
+15
-3
@@ -226,11 +226,23 @@ SUBAGENT_TIMEOUT_SEC=600
|
|||||||
# 单份附件注入模型可见上下文的字符上限(sidecar 仍存全文,只受 8 MiB 安全上限约束)。
|
# 单份附件注入模型可见上下文的字符上限(sidecar 仍存全文,只受 8 MiB 安全上限约束)。
|
||||||
# 留空 = 按推导出的预算算:推导预算的 1/8(按一个回合最多 5 个附件留余量),下限 64K 字符。
|
# 留空 = 按推导出的预算算:推导预算的 1/8(按一个回合最多 5 个附件留余量),下限 64K 字符。
|
||||||
# 它决定「每回合往上下文里放多少」,也因此决定清单行给的续读游标从哪里开始。
|
# 它决定「每回合往上下文里放多少」,也因此决定清单行给的续读游标从哪里开始。
|
||||||
|
# ⚠️ 它必须**不超过**下面 GUARDRAIL_MAX_INPUT_CHARS 生效的输入上限,否则大附件会在装配完之后
|
||||||
|
# 被输入护栏整回合拦死(input_length_exceeded)。改了本键就顺手核对那一项。
|
||||||
# DOCUMENT_MAX_INJECT_CHARS=
|
# DOCUMENT_MAX_INJECT_CHARS=
|
||||||
|
# 请求侧字符上限(输入护栏跑在**组装完成的请求**上:系统提示词 + 记忆块 + 整段对话 + 工具结果)。
|
||||||
|
# 三种取值:
|
||||||
|
# · 未设置 / 0(默认)= 由推导出的上下文预算派生:max(262144, 推导预算 token 数)
|
||||||
|
# (1 字符 ≈ 1 token 的保守口径);1M 窗口 ⇒ 预算 699050 token ⇒ 上限 699050 字符,
|
||||||
|
# 而单份附件注入预算是 349525 字符 ⇒ 关系不变量「注入预算 ≤ 输入上限」成立,
|
||||||
|
# 「分享大文件」才走得通。小窗口部署下派生的 token 数低于 262144,行为与旧硬编码一致。
|
||||||
|
# · > 0 = 显式覆盖(原样使用;调小到注入预算之下会让大附件被拦,届时启动日志里有 WARN)
|
||||||
|
# · < 0 = 非法:记一条 WARN 并回落派生值(绝不静默生效)
|
||||||
|
# 它只动**输入**侧;输出上限(65536 字符)防的是模型输出失控,与上下文预算无关,不开放配置。
|
||||||
|
# 敏感信息(API key / bearer token / 密码 / 私钥)为阻断档,同样是代码内定的安全默认值。
|
||||||
|
# (取值与关系不变量的落点在 cmd/cyrene/wire_guardrail.go)
|
||||||
|
# GUARDRAIL_MAX_INPUT_CHARS=
|
||||||
# 内容策略关键词表(逗号分隔,大小写不敏感的子串匹配)。留空 = 不挂关键词护栏
|
# 内容策略关键词表(逗号分隔,大小写不敏感的子串匹配)。留空 = 不挂关键词护栏
|
||||||
# (项目不内置任何词表,只有你显式声明过的词才会被拦)。敏感信息(API key /
|
# (项目不内置任何词表,只有你显式声明过的词才会被拦)。
|
||||||
# bearer token / 密码 / 私钥)为阻断档、请求与响应各有长度上限,这两项是代码
|
|
||||||
# 内定的安全默认值,不对外开放配置(见 cmd/cyrene/wire_guardrail.go)。
|
|
||||||
# GUARDRAIL_KEYWORDS=
|
# GUARDRAIL_KEYWORDS=
|
||||||
|
|
||||||
# ---- 主人身份说明书注入范围(design/11 §0 红线) ----
|
# ---- 主人身份说明书注入范围(design/11 §0 红线) ----
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func TestPersonaInstructions_StaticPromptHasNoIdentityKnowledge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
llm := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{}, testLog())
|
llm := model.NewRuntimeLLM(filepath.Join(t.TempDir(), "models.json"), config.LLMConfig{}, testLog())
|
||||||
for id, ag := range a.buildPersonaAgents(llm, nil, a.buildGuardrailPipeline()) {
|
for id, ag := range a.buildPersonaAgents(llm, nil, a.buildGuardrailPipeline(noWindowBudget())) {
|
||||||
if ag.Instructions() != base {
|
if ag.Instructions() != base {
|
||||||
t.Fatalf("角色 %q 的静态指令与人格指令不一致(含身份说明书?):\n%q", id, ag.Instructions())
|
t.Fatalf("角色 %q 的静态指令与人格指令不一致(含身份说明书?):\n%q", id, ag.Instructions())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -387,8 +387,8 @@ func TestContextCompaction_ZeroBudgetKeepsTodaysBehavior(t *testing.T) {
|
|||||||
//
|
//
|
||||||
// 组合根本身(wireRuntime → newApp)无法在单元测试里实例化:它要开库、迁移、
|
// 组合根本身(wireRuntime → newApp)无法在单元测试里实例化:它要开库、迁移、
|
||||||
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此「组合根确实走了这些缝」
|
// 建网关并绑定端口,在主人后端正在运行时尤其不能碰。因此「组合根确实走了这些缝」
|
||||||
// 只能在源码层钉住:wire_runtime.go 必须调用 a.buildGuardrailPipeline()、
|
// 只能在源码层钉住:wire_runtime.go 必须把预算取自 a.contextBudget(envPath),
|
||||||
// 必须把预算取自 a.contextBudget(envPath),并且把两项预算都交给 loop。
|
// 在这份预算上建护栏管线(buildGuardrailPipeline(ctxBudget)),并把两项预算都交给 loop。
|
||||||
func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
|
func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -397,7 +397,7 @@ func TestWireRuntime_UsesTheCompositionRootSeams(t *testing.T) {
|
|||||||
t.Fatalf("read wire_runtime.go: %v", err)
|
t.Fatalf("read wire_runtime.go: %v", err)
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
"a.buildGuardrailPipeline()",
|
"a.buildGuardrailPipeline(ctxBudget)",
|
||||||
"a.contextBudget(envPath)",
|
"a.contextBudget(envPath)",
|
||||||
"MaxContextTokens: ctxBudget.Budget.Tokens",
|
"MaxContextTokens: ctxBudget.Budget.Tokens",
|
||||||
"DocumentMaxInjectChars: ctxBudget.DocumentInjectChars",
|
"DocumentMaxInjectChars: ctxBudget.DocumentInjectChars",
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
// 组合根的默认护栏策略(design/04 §5)。本条修的就是「护栏是空壳」:
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
// 此前 `guardrail.New()` 不带任何 Option,管线里一个检查都没有,
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
||||||
// 输入/输出两侧的 guardrail span 每次都只是走过场。
|
)
|
||||||
//
|
|
||||||
// 三条默认策略与它们的依据:
|
// 本文件是组合根的默认护栏策略(design/04 §5)。三条默认策略与它们的依据:
|
||||||
//
|
//
|
||||||
// 1. 敏感信息 = **阻断**档。API key / bearer token / 密码 / 私钥这类东西
|
// 1. 敏感信息 = **阻断**档。API key / bearer token / 密码 / 私钥这类东西
|
||||||
// 一旦进了请求或回复就是既成事实的泄露,标记档(放行 + 记一笔)救不回来,
|
// 一旦进了请求或回复就是既成事实的泄露,标记档(放行 + 记一笔)救不回来,
|
||||||
@@ -15,19 +16,29 @@ import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
|||||||
// 3. 关键词表 = **只在配置提供时**挂。项目不内置任何词表:凭空猜的词只会
|
// 3. 关键词表 = **只在配置提供时**挂。项目不内置任何词表:凭空猜的词只会
|
||||||
// 误伤正常对话,且没有部署方能为它的命中负责(见 config.GuardrailConfig)。
|
// 误伤正常对话,且没有部署方能为它的命中负责(见 config.GuardrailConfig)。
|
||||||
const (
|
const (
|
||||||
// guardrailMaxInputChars 是请求侧的字符上限。这个检查跑在**组装完成的
|
// guardrailMinInputChars 是请求侧字符上限的**下限**(256 Ki 字符)。这个检查
|
||||||
// 请求**上(系统提示词 + 记忆块 + 整段对话 + 工具结果),不是单条用户
|
// 跑在**组装完成的请求**上(系统提示词 + 记忆块 + 整段对话 + 工具结果),
|
||||||
// 消息,因此上限必须按「整个上下文」来定:默认压缩预算 32K token
|
// 不是单条用户消息,因此上限按「整个上下文」定。
|
||||||
// 折算成字符约 65K(中文)~131K(拉丁),取 262144(256 Ki)字符约为
|
//
|
||||||
// 它的 2~4 倍 —— 正常对话(哪怕很长)永远碰不到,只有把几百 KB 的
|
// 为什么是「下限」而不是「定值」(本切片修的真 bug):单份附件的注入预算随
|
||||||
// 整块文本直接灌进来这种病态输入才会被拦下。
|
// 推导出的上下文预算走,1M 窗口下已是 349525 字符(T1)。上限若还是硬编码的
|
||||||
guardrailMaxInputChars = 262144
|
// 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 是响应侧的字符上限。一条回复正常在几十到几百字,
|
// guardrailMaxOutputChars 是响应侧的字符上限。一条回复正常在几十到几百字,
|
||||||
// 长代码/长文也就几千字,65536(64 Ki)字符比正常回复高 2~3 个数量级,
|
// 长代码/长文也就几千字,65536(64 Ki)字符比正常回复高 2~3 个数量级,
|
||||||
// 因此不会截断任何正常输出,只拦失控的生成(例如无限复读)。这一点在主
|
// 因此不会截断任何正常输出,只拦失控的生成(例如无限复读)。这一点在主
|
||||||
// 流式路径上尤其重要:那里输出侧检查是异步旁路(只会审计,不挡已产出的
|
// 流式路径上尤其重要:那里输出侧检查是异步旁路(只会审计,不挡已产出的
|
||||||
// 回复),但在静音/非流式路径(backthink、子代理)上否判决会替换答案,
|
// 回复),但在静音/非流式路径(backthink、子代理)上否判决会替换答案,
|
||||||
// 所以上限必须留足余量。
|
// 所以上限必须留足余量。
|
||||||
|
//
|
||||||
|
// **它不随上下文预算派生**:输出失控与「窗口有多大」无关,本切片的输入侧
|
||||||
|
// 解耦不该顺手把它也放宽。
|
||||||
guardrailMaxOutputChars = 65536
|
guardrailMaxOutputChars = 65536
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,14 +46,19 @@ const (
|
|||||||
// 它是组合根里唯一一处决定「护栏里到底有什么」的地方,因此测试可以直接
|
// 它是组合根里唯一一处决定「护栏里到底有什么」的地方,因此测试可以直接
|
||||||
// 断言这个函数的产物,而不必启动整个运行时。
|
// 断言这个函数的产物,而不必启动整个运行时。
|
||||||
//
|
//
|
||||||
|
// 取 budget 参数而不是自己去推导:输入上限是**上下文预算的函数**(关系不变量
|
||||||
|
// 「文档注入预算 ≤ 输入上限」的两端都在这份预算里),而预算的推导只该在
|
||||||
|
// wire_context.go 发生一次 —— 各自推导会把「窗口未声明」那条 WARN 记两遍。
|
||||||
|
// 调用顺序因此固定为「先算预算,再建管线」(wire_runtime.go)。
|
||||||
|
//
|
||||||
// 选项顺序即检查顺序(guardrail.New 按序应用),且先跑开销低、语义硬的
|
// 选项顺序即检查顺序(guardrail.New 按序应用),且先跑开销低、语义硬的
|
||||||
// 敏感信息检查:命中即返回该判决,长度与关键词检查不会再跑。
|
// 敏感信息检查:命中即返回该判决,长度与关键词检查不会再跑。
|
||||||
func (a *app) buildGuardrailPipeline() *guardrail.Pipeline {
|
func (a *app) buildGuardrailPipeline(budget ctxBudget) *guardrail.Pipeline {
|
||||||
opts := []guardrail.Option{
|
opts := []guardrail.Option{
|
||||||
// ① 敏感信息外泄必须拦:阻断档。
|
// ① 敏感信息外泄必须拦:阻断档。
|
||||||
guardrail.WithSensitiveInfo(guardrail.SensitiveBlock),
|
guardrail.WithSensitiveInfo(guardrail.SensitiveBlock),
|
||||||
// ② 长度上限:0 会关掉那一侧,因此两侧都给上保守上限。
|
// ② 长度上限:0 会关掉那一侧,因此两侧都给上保守上限。
|
||||||
guardrail.WithLengthLimits(guardrailMaxInputChars, guardrailMaxOutputChars),
|
guardrail.WithLengthLimits(a.guardrailInputLimit(budget), guardrailMaxOutputChars),
|
||||||
}
|
}
|
||||||
// ③ 关键词表仅在配置提供时挂:没有配置就不挂(KeywordGuard 空表本也
|
// ③ 关键词表仅在配置提供时挂:没有配置就不挂(KeywordGuard 空表本也
|
||||||
// 全放行,这里显式分支是为了让「没配置」在管线上就是没有这个检查)。
|
// 全放行,这里显式分支是为了让「没配置」在管线上就是没有这个检查)。
|
||||||
@@ -52,6 +68,51 @@ func (a *app) buildGuardrailPipeline() *guardrail.Pipeline {
|
|||||||
return guardrail.New(opts...)
|
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);
|
// guardrailKeywords 返回部署方声明的内容策略词表(GUARDRAIL_KEYWORDS);
|
||||||
// 未配置时返回 nil,管线里就不含关键词检查。
|
// 未配置时返回 nil,管线里就不含关键词检查。
|
||||||
func (a *app) guardrailKeywords() []string {
|
func (a *app) guardrailKeywords() []string {
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||||
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件锁定切片 G2 的输入侧解耦(端到端实测抓到的真 bug:大附件被输入护栏拦死):
|
||||||
|
//
|
||||||
|
// - 输入上限 = max(256 Ki, 推导预算 token 数)(1 字符 ≈ 1 token 的保守口径);
|
||||||
|
// - GUARDRAIL_MAX_INPUT_CHARS 的三态(未设/0 = 派生、>0 = 覆盖、<0 = WARN 回落);
|
||||||
|
// - 关系不变量「文档注入预算 ≤ 输入护栏上限」,人为破坏它必须**有 WARN**;
|
||||||
|
// - 回归:注入正文比旧上限(262144 字符)还大时,回合**不得**以 guardrail_input 结束;
|
||||||
|
// - 反证:显式把上限收紧后,同一个大附件回合**真的**被拦(护栏没被削弱)。
|
||||||
|
//
|
||||||
|
// 输出上限(guardrailMaxOutputChars)不属本切片:它防的是模型输出失控,与上下文
|
||||||
|
// 预算无关,因此保持 65536 不动(回归见 TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads)。
|
||||||
|
|
||||||
|
// largeDocChars 是回归夹具的附件正文长度:既大于旧上限 262144(否则复现不到 bug),
|
||||||
|
// 又大于 1M 窗口下的注入预算 349525(因此走「已截断 + 续读游标」那条路径,
|
||||||
|
// 与主人实测时历史 DTO 里 `"chars":349525,"truncated":true` 的形状一致)。
|
||||||
|
const largeDocChars = 400000
|
||||||
|
|
||||||
|
// TestGuardrailInputLimit_DerivedFromBudget 钉住派生公式(表驱动):
|
||||||
|
// 输入上限 = max(下限, 推导预算 token 数) —— 小窗口部署落回下限,大窗口随预算抬高。
|
||||||
|
func TestGuardrailInputLimit_DerivedFromBudget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
tokens int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"1M 窗口推导出的预算(699050)⇒ 随之上抬", 699050, 699050},
|
||||||
|
{"预算正好等于下限 ⇒ 下限", guardrailMinInputChars, guardrailMinInputChars},
|
||||||
|
{"小预算(600)⇒ 回落下限", 600, guardrailMinInputChars},
|
||||||
|
{"关闭压缩(0)⇒ 下限", 0, guardrailMinInputChars},
|
||||||
|
{"负预算 ⇒ 下限", -1, guardrailMinInputChars},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
c := c
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
log, _ := capturingLog()
|
||||||
|
if got := deriveGuardrailInputLimit(log, 0, c.tokens, 0); got != c.want {
|
||||||
|
t.Fatalf("deriveGuardrailInputLimit(budget=%d) = %d, want %d", c.tokens, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuardrailInputLimit_OverrideAndIllegalValue 钉住 env 三态:>0 覆盖、<0 记
|
||||||
|
// **恰好一条** WARN 后回落派生值(带原始取值,绝不静默)。
|
||||||
|
func TestGuardrailInputLimit_OverrideAndIllegalValue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
log, buf := capturingLog()
|
||||||
|
if got := deriveGuardrailInputLimit(log, 1000, 699050, 0); got != 1000 {
|
||||||
|
t.Fatalf("显式覆盖 = %d, want 1000(覆盖优先于派生)", got)
|
||||||
|
}
|
||||||
|
if n := countRecords(logRecords(t, buf), "WARN", "guardrail input limit invalid"); n != 0 {
|
||||||
|
t.Fatalf("合法覆盖却记了 %d 条 WARN", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
log, buf = capturingLog()
|
||||||
|
if got := deriveGuardrailInputLimit(log, -1, 699050, 0); got != 699050 {
|
||||||
|
t.Fatalf("非法覆盖后的回落 = %d, want 派生值 699050", got)
|
||||||
|
}
|
||||||
|
records := logRecords(t, buf)
|
||||||
|
if n := countRecords(records, "WARN", "guardrail input limit invalid"); n != 1 {
|
||||||
|
t.Fatalf("非法覆盖的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
|
||||||
|
}
|
||||||
|
// WARN 必须点出「改哪个键」与原始取值,否则部署方不知道该去改哪儿。
|
||||||
|
found := false
|
||||||
|
for _, rec := range records {
|
||||||
|
if msg, _ := rec["msg"].(string); strings.Contains(msg, "guardrail input limit invalid") {
|
||||||
|
found = true
|
||||||
|
if rec["env"] != config.GuardrailMaxInputCharsEnv {
|
||||||
|
t.Fatalf("WARN 没点出配置键: %v", rec)
|
||||||
|
}
|
||||||
|
if rec["value"] != float64(-1) || rec["fallback"] != float64(699050) {
|
||||||
|
t.Fatalf("WARN 没带原始取值与回落值: %v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("没有找到非法覆盖的 WARN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuardrailInputLimit_RelationInvariantWithInjectBudget 钉住关系不变量
|
||||||
|
// 「文档注入预算 ≤ 输入护栏上限」:对 1M 窗口的推导值成立,且人为把上限设得比
|
||||||
|
// 注入预算小 ⇒ **记一条 WARN**(说明「大附件会被护栏拦下」),不静默。
|
||||||
|
func TestGuardrailInputLimit_RelationInvariantWithInjectBudget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// 1M 窗口的推导:预算 699050 token ⇒ 单份附件注入预算 349525 字符。
|
||||||
|
a := &app{log: testLog(), cfg: &config.Config{}, llm: envWindowStack(t, 1048576, 0)}
|
||||||
|
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
|
||||||
|
if budget.DocumentInjectChars != 349525 {
|
||||||
|
t.Fatalf("1M 窗口下的注入预算 = %d, want 349525(推导链变了,本用例的前提需要复核)",
|
||||||
|
budget.DocumentInjectChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
log, buf := capturingLog()
|
||||||
|
limit := deriveGuardrailInputLimit(log, 0, budget.Budget.Tokens, budget.DocumentInjectChars)
|
||||||
|
if budget.DocumentInjectChars > limit {
|
||||||
|
t.Fatalf("关系不变量被破:注入预算 %d > 输入上限 %d", budget.DocumentInjectChars, limit)
|
||||||
|
}
|
||||||
|
if limit != 699050 {
|
||||||
|
t.Fatalf("1M 窗口下的输入上限 = %d, want 699050(= 推导预算 token 数)", limit)
|
||||||
|
}
|
||||||
|
if n := countRecords(logRecords(t, buf), "WARN", "exceeds guardrail input limit"); n != 0 {
|
||||||
|
t.Fatalf("不变量成立却记了 %d 条 WARN", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 人为破坏:把上限压到注入预算之下 ⇒ 必须有一条 WARN 明说后果。
|
||||||
|
log, buf = capturingLog()
|
||||||
|
if got := deriveGuardrailInputLimit(log, 4096, budget.Budget.Tokens, budget.DocumentInjectChars); got != 4096 {
|
||||||
|
t.Fatalf("收紧后的上限 = %d, want 4096", got)
|
||||||
|
}
|
||||||
|
records := logRecords(t, buf)
|
||||||
|
if n := countRecords(records, "WARN", "exceeds guardrail input limit"); n != 1 {
|
||||||
|
t.Fatalf("不变量被破的 WARN 条数 = %d, want 恰好 1(记录:%v)", n, records)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, rec := range records {
|
||||||
|
if msg, _ := rec["msg"].(string); strings.Contains(msg, "exceeds guardrail input limit") {
|
||||||
|
found = true
|
||||||
|
if rec["document_inject_chars"] != float64(budget.DocumentInjectChars) ||
|
||||||
|
rec["guardrail_max_input_chars"] != float64(4096) {
|
||||||
|
t.Fatalf("WARN 没写清两端取值: %v", rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("没有找到关系不变量被破的 WARN")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// largeAttachmentTurn 是「一个回合 + 一份大附件」的观测结果。
|
||||||
|
type largeAttachmentTurn struct {
|
||||||
|
// result 是该回合的循环结论(StopReason / Text / Documents 都在这里)。
|
||||||
|
result *loop.Result
|
||||||
|
// modelCalls 是模型被调用的次数(= 捕获到的请求数)。
|
||||||
|
modelCalls int
|
||||||
|
// assembledChars 是模型实际看到的文本总量(装配后、注入正文已在内)。
|
||||||
|
assembledChars int
|
||||||
|
// injectChars 是本回合如实记录的注入字符数(Result.Documents[0].Chars)。
|
||||||
|
injectChars int
|
||||||
|
}
|
||||||
|
|
||||||
|
// runLargeAttachmentTurn 跑一个回合:1M 窗口的推导预算(注入 349525 字符)+
|
||||||
|
// 一份 largeDocChars 字符的 txt 附件 + 真实的护栏管线。inputLimitOverride 非 0 时
|
||||||
|
// 模拟 GUARDRAIL_MAX_INPUT_CHARS 的覆盖态。
|
||||||
|
//
|
||||||
|
// 三个角色都是生产实现:预算取自 a.contextBudget、注入走真实的
|
||||||
|
// loop.resolveDocuments + documents.Resolver、护栏是 a.buildGuardrailPipeline 的产物。
|
||||||
|
// 唯一被单独钉住的是「窗口 = 1M」。
|
||||||
|
func runLargeAttachmentTurn(t *testing.T, inputLimitOverride int) largeAttachmentTurn {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
uploads := filepath.Join(dataDir, "uploads", "documents")
|
||||||
|
if err := os.MkdirAll(uploads, 0o700); err != nil {
|
||||||
|
t.Fatalf("建上传目录: %v", err)
|
||||||
|
}
|
||||||
|
body := strings.Repeat("abcdefghij", largeDocChars/10)
|
||||||
|
if err := os.WriteFile(filepath.Join(uploads, docE2EName), []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatalf("写上传件: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{DataDir: dataDir}
|
||||||
|
cfg.Guardrail.MaxInputChars = inputLimitOverride
|
||||||
|
a := &app{log: testLog(), cfg: cfg, llm: envWindowStack(t, 1048576, 0)}
|
||||||
|
budget := a.contextBudget(filepath.Join(t.TempDir(), "absent.env"))
|
||||||
|
guards := a.buildGuardrailPipeline(budget)
|
||||||
|
|
||||||
|
adapter := &compactProbeAdapter{}
|
||||||
|
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("agent.New: %v", err)
|
||||||
|
}
|
||||||
|
l := loop.New(loop.Options{
|
||||||
|
Agent: ag,
|
||||||
|
Guards: guards,
|
||||||
|
DocumentResolver: documents.NewResolver(documents.Config{DataDir: dataDir, UploadsDir: uploads}),
|
||||||
|
DocumentMaxInjectChars: budget.DocumentInjectChars,
|
||||||
|
OnStream: func(model.StreamEvent) {},
|
||||||
|
})
|
||||||
|
msg := model.DocumentMessage(model.RoleUser, "看看这个附件",
|
||||||
|
model.Document{Name: docE2EName, MIME: "text/plain", Size: int64(len(body))})
|
||||||
|
res, err := l.Run(context.Background(), "sess-large-doc", []model.Message{msg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
turn := largeAttachmentTurn{result: res, modelCalls: len(adapter.reqs)}
|
||||||
|
if len(res.Documents) > 0 {
|
||||||
|
turn.injectChars = res.Documents[0].Chars
|
||||||
|
}
|
||||||
|
if len(adapter.reqs) > 0 {
|
||||||
|
req := adapter.reqs[len(adapter.reqs)-1]
|
||||||
|
for _, m := range req.Messages {
|
||||||
|
turn.assembledChars += len(loopText(m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return turn
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuardrailWiring_LargeAttachmentPassesInputGuard 是**回归**,对准主人端到端
|
||||||
|
// 实测抓到的那个真 bug:`POST /api/v1/chat` 带一个 744 KB 的 txt 附件 ⇒
|
||||||
|
// `{"reply":"input_length_exceeded","stopReason":"guardrail_input"}` —— 历史 DTO 里
|
||||||
|
// 已如实写下 `"chars":349525,"truncated":true`,工具 document-read 也正常,
|
||||||
|
// 是回合在**输入护栏**这一层就没被放进去,「分享大文件」这条主线因此被打死。
|
||||||
|
//
|
||||||
|
// 这里在进程内复现同一形状,断言它不再发生:注入正文比旧上限还大,但回合照常
|
||||||
|
// 抵达模型(adapter 被调用一次)并以正常回答结束。
|
||||||
|
func TestGuardrailWiring_LargeAttachmentPassesInputGuard(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
turn := runLargeAttachmentTurn(t, 0)
|
||||||
|
|
||||||
|
// ① bug 的症状先说清楚:回合不得以 guardrail_input 结束,也不得被拦在模型之前。
|
||||||
|
if turn.result.StopReason == agent.StopReasonGuardrailInput {
|
||||||
|
t.Fatalf("大附件回合仍以 guardrail_input 结束(这正是被修的 bug):%q", turn.result.Text)
|
||||||
|
}
|
||||||
|
if turn.modelCalls != 1 {
|
||||||
|
t.Fatalf("大附件回合的模型调用次数 = %d, want 1(护栏把回合拦在模型之前了)", turn.modelCalls)
|
||||||
|
}
|
||||||
|
// ② 前提自证:这一回合真的注入了 349525 字符(= 主人实测 DTO 里那个数),
|
||||||
|
// 比旧上限 262144 大,所以上面那条断言不是碰巧成立。
|
||||||
|
if turn.injectChars != 349525 {
|
||||||
|
t.Fatalf("注入字符数 = %d, want 349525(1M 窗口的注入预算;形状变了要复核本用例)",
|
||||||
|
turn.injectChars)
|
||||||
|
}
|
||||||
|
if turn.assembledChars <= guardrailMinInputChars {
|
||||||
|
t.Fatalf("装配后的请求只有 %d 字符(≤ 旧上限 %d),复现不到「大附件被拦死」",
|
||||||
|
turn.assembledChars, guardrailMinInputChars)
|
||||||
|
}
|
||||||
|
if !turn.result.Documents[0].Truncated {
|
||||||
|
t.Fatal("清单没有如实标注注入被截断(design/24 §4.3)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuardrailWiring_TightInputLimitStillBlocks 是**护栏没被削弱**的自证:
|
||||||
|
// 同一个大附件回合,只要显式把输入上限压到 1000 字符(GUARDRAIL_MAX_INPUT_CHARS
|
||||||
|
// 的覆盖态),回合必须真的被拦成 guardrail_input,且**在模型调用之前**拦下。
|
||||||
|
//
|
||||||
|
// 没有这条,上面那条回归有可能是「把输入侧长度检查放空了」才通过的。
|
||||||
|
func TestGuardrailWiring_TightInputLimitStillBlocks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
turn := runLargeAttachmentTurn(t, 1000)
|
||||||
|
|
||||||
|
if turn.result.StopReason != agent.StopReasonGuardrailInput {
|
||||||
|
t.Fatalf("收紧上限后 StopReason = %q, want %q", turn.result.StopReason, agent.StopReasonGuardrailInput)
|
||||||
|
}
|
||||||
|
if !strings.Contains(turn.result.Text, "input_length_exceeded") {
|
||||||
|
t.Fatalf("收紧上限后的回答 = %q, want 含 input_length_exceeded", turn.result.Text)
|
||||||
|
}
|
||||||
|
if turn.modelCalls != 0 {
|
||||||
|
t.Fatalf("被拦的回合仍调用了模型 %d 次 —— 输入侧不是前置门", turn.modelCalls)
|
||||||
|
}
|
||||||
|
// 注入本身照旧发生(记录如实),被拦是**上限**那一侧的判决。
|
||||||
|
if turn.injectChars != 349525 {
|
||||||
|
t.Fatalf("注入字符数 = %d, want 349525(注入事实不因上限收紧而改写)", turn.injectChars)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuardrailInputLimit_NotAFunctionOfInjectBudget 是变异反证:注入预算单独变化
|
||||||
|
// **不影响**输入上限(上限只由上下文预算与显式覆盖决定),因此「注入预算 ≤ 上限」
|
||||||
|
// 不可能靠两边同源凑出来 —— 两端确实各自有出处。
|
||||||
|
func TestGuardrailInputLimit_NotAFunctionOfInjectBudget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
log, _ := capturingLog()
|
||||||
|
if got := deriveGuardrailInputLimit(log, 0, 699050, 0); got != 699050 {
|
||||||
|
t.Fatalf("注入预算为 0 时的输入上限 = %d, want 699050", got)
|
||||||
|
}
|
||||||
|
if got := deriveGuardrailInputLimit(log, 0, 699050, 349525); got != 699050 {
|
||||||
|
t.Fatalf("注入预算 349525 时的输入上限 = %d, want 699050(不受注入预算影响)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,11 @@ func (a *guardProbeAdapter) ChatStream(ctx context.Context, req model.Request, o
|
|||||||
return a.Chat(ctx, req, model.Options{})
|
return a.Chat(ctx, req, model.Options{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noWindowBudget 是「没声明窗口的小部署」的预算形状:压缩预算与注入预算两项
|
||||||
|
// 推导值都是 0,于是派生的输入上限落回下限 guardrailMinInputChars —— 也就是
|
||||||
|
// 接线前那个硬编码值的等价场景。只关心「护栏里有什么」的用例传它即可。
|
||||||
|
func noWindowBudget() ctxBudget { return ctxBudget{} }
|
||||||
|
|
||||||
// guardrailApp 返回带给定关键词表的组合根切片;cfg 为 nil 表示「一点配置都没有」。
|
// guardrailApp 返回带给定关键词表的组合根切片;cfg 为 nil 表示「一点配置都没有」。
|
||||||
func guardrailApp(t *testing.T, keywords []string) *app {
|
func guardrailApp(t *testing.T, keywords []string) *app {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -84,7 +89,7 @@ func truncate(s string, n int) string {
|
|||||||
func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) {
|
func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
p := guardrailApp(t, nil).buildGuardrailPipeline()
|
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
|
||||||
|
|
||||||
req := model.Request{Messages: []model.Message{
|
req := model.Request{Messages: []model.Message{
|
||||||
model.TextMessage(model.RoleSystem, "你是昔涟,温柔俏皮的小女友。"),
|
model.TextMessage(model.RoleSystem, "你是昔涟,温柔俏皮的小女友。"),
|
||||||
@@ -108,7 +113,7 @@ func TestGuardrailPipeline_NormalConversationPasses(t *testing.T) {
|
|||||||
func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) {
|
func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
p := guardrailApp(t, nil).buildGuardrailPipeline()
|
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
|
||||||
|
|
||||||
secret := "sk-abcdefghijklmnopqrstuvwxyz0123456789"
|
secret := "sk-abcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@@ -145,10 +150,21 @@ func TestGuardrailPipeline_BlocksSensitiveInfo(t *testing.T) {
|
|||||||
|
|
||||||
// TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads 锁定长度上限的
|
// TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads 锁定长度上限的
|
||||||
// 边界:恰好等于上限放行、超一个字符就被拦;并且「很长但正常」的上下文不被误伤。
|
// 边界:恰好等于上限放行、超一个字符就被拦;并且「很长但正常」的上下文不被误伤。
|
||||||
|
//
|
||||||
|
// 这里用的是 noWindowBudget(小窗口部署):派生的输入上限落回下限
|
||||||
|
// guardrailMinInputChars,也就是接线前那个 262144 的行为。
|
||||||
func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T) {
|
func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
p := guardrailApp(t, nil).buildGuardrailPipeline()
|
p := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
|
||||||
|
if limit := guardrailApp(t, nil).guardrailInputLimit(noWindowBudget()); limit != guardrailMinInputChars {
|
||||||
|
t.Fatalf("小窗口部署的输入上限 = %d, want 下限 %d", limit, guardrailMinInputChars)
|
||||||
|
}
|
||||||
|
// 输出上限是**另一个**数:它防的是模型输出失控,与上下文预算无关,
|
||||||
|
// 因此本切片只动输入侧,输出侧不得顺手放宽(回归钉住取值本身)。
|
||||||
|
if guardrailMaxOutputChars != 65536 {
|
||||||
|
t.Fatalf("输出上限 = %d, want 65536(本切片只动输入侧)", guardrailMaxOutputChars)
|
||||||
|
}
|
||||||
|
|
||||||
if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars)); !v.Allow {
|
if v := checkOutput(t, p, strings.Repeat("a", guardrailMaxOutputChars)); !v.Allow {
|
||||||
t.Fatalf("恰好到上限的输出被拦:%+v", v)
|
t.Fatalf("恰好到上限的输出被拦:%+v", v)
|
||||||
@@ -170,7 +186,7 @@ func TestGuardrailPipeline_LengthCapsOnlyCatchPathologicalPayloads(t *testing.T)
|
|||||||
}
|
}
|
||||||
// 超过上限则被拦。
|
// 超过上限则被拦。
|
||||||
over := model.Request{Messages: []model.Message{
|
over := model.Request{Messages: []model.Message{
|
||||||
model.TextMessage(model.RoleUser, strings.Repeat("a", guardrailMaxInputChars+1)),
|
model.TextMessage(model.RoleUser, strings.Repeat("a", guardrailMinInputChars+1)),
|
||||||
}}
|
}}
|
||||||
if v, _ := p.CheckInput(context.Background(), &over); v.Allow {
|
if v, _ := p.CheckInput(context.Background(), &over); v.Allow {
|
||||||
t.Fatal("超出输入上限的载荷却放行")
|
t.Fatal("超出输入上限的载荷却放行")
|
||||||
@@ -185,11 +201,11 @@ func TestGuardrailPipeline_KeywordsOnlyWhenConfigured(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
text := "这段文本里出现了违禁词。"
|
text := "这段文本里出现了违禁词。"
|
||||||
if v := checkInput(t, guardrailApp(t, nil).buildGuardrailPipeline(), text); !v.Allow {
|
if v := checkInput(t, guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget()), text); !v.Allow {
|
||||||
t.Fatalf("未配置词表却拦下了文本:%+v", v)
|
t.Fatalf("未配置词表却拦下了文本:%+v", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline()
|
configured := guardrailApp(t, []string{"违禁词"}).buildGuardrailPipeline(noWindowBudget())
|
||||||
v := checkInput(t, configured, text)
|
v := checkInput(t, configured, text)
|
||||||
if v.Allow {
|
if v.Allow {
|
||||||
t.Fatal("配置了词表却没拦下命中文本")
|
t.Fatal("配置了词表却没拦下命中文本")
|
||||||
@@ -235,7 +251,7 @@ func TestGuardrailPipeline_RealPromptPasses(t *testing.T) {
|
|||||||
t.Fatalf("测试前提不成立:真实身份说明书没被读进来:%q", instructions)
|
t.Fatalf("测试前提不成立:真实身份说明书没被读进来:%q", instructions)
|
||||||
}
|
}
|
||||||
|
|
||||||
pipeline := a.buildGuardrailPipeline()
|
pipeline := a.buildGuardrailPipeline(noWindowBudget())
|
||||||
req := model.Request{Messages: []model.Message{
|
req := model.Request{Messages: []model.Message{
|
||||||
model.TextMessage(model.RoleSystem, instructions),
|
model.TextMessage(model.RoleSystem, instructions),
|
||||||
model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?"),
|
model.TextMessage(model.RoleUser, "亲爱的,今天想我了没?"),
|
||||||
@@ -256,7 +272,7 @@ func TestGuardrailPipeline_RealPromptPasses(t *testing.T) {
|
|||||||
func TestGuardrailWiring_EndToEnd(t *testing.T) {
|
func TestGuardrailWiring_EndToEnd(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
guards := guardrailApp(t, nil).buildGuardrailPipeline()
|
guards := guardrailApp(t, nil).buildGuardrailPipeline(noWindowBudget())
|
||||||
adapter := &guardProbeAdapter{reply: "我在呢♪"}
|
adapter := &guardProbeAdapter{reply: "我在呢♪"}
|
||||||
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
|
ag, err := agent.New("你是昔涟。", nil, guards, agent.WithModel(adapter, nil))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -152,9 +152,19 @@ func (a *app) wireRuntime() error {
|
|||||||
}
|
}
|
||||||
a.wireVision()
|
a.wireVision()
|
||||||
|
|
||||||
|
// 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时;
|
||||||
|
// 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在
|
||||||
|
// wire_context.go 一处完成。Compactor 不注入,由 loop 选用内置的结构化
|
||||||
|
// compactor(离线、不调模型),并按同一份注入预算估算文档部件的代价。
|
||||||
|
//
|
||||||
|
// 它必须在护栏**之前**算出来:请求侧字符上限是这份预算的函数(切片 G2:
|
||||||
|
// 输入上限 = max(256 Ki, 推导预算 token 数)),否则 1M 窗口下 349525 字符的
|
||||||
|
// 附件注入会先被硬编码的 262144 上限整回合拦死。
|
||||||
|
ctxBudget := a.contextBudget(envPath)
|
||||||
|
|
||||||
// 默认护栏策略在 buildGuardrailPipeline 里集中定义(敏感信息阻断 +
|
// 默认护栏策略在 buildGuardrailPipeline 里集中定义(敏感信息阻断 +
|
||||||
// 长度上限 + 仅当配置提供时的关键词表,见 wire_guardrail.go)。
|
// 长度上限 + 仅当配置提供时的关键词表,见 wire_guardrail.go)。
|
||||||
guards := a.buildGuardrailPipeline()
|
guards := a.buildGuardrailPipeline(ctxBudget)
|
||||||
instructions := a.defaultInstructions()
|
instructions := a.defaultInstructions()
|
||||||
// 默认角色的 agent 也要绑它自己的 persona 视图:models.json 的 personaModels /
|
// 默认角色的 agent 也要绑它自己的 persona 视图:models.json 的 personaModels /
|
||||||
// personaProviders 可能给默认角色单独指定模型或提供商,用裸栈会漏掉这一层
|
// personaProviders 可能给默认角色单独指定模型或提供商,用裸栈会漏掉这一层
|
||||||
@@ -252,12 +262,6 @@ func (a *app) wireRuntime() error {
|
|||||||
approver := audit.NewApprover(a.log)
|
approver := audit.NewApprover(a.log)
|
||||||
approvalGate := a.buildApprovalGate(approver)
|
approvalGate := a.buildApprovalGate(approver)
|
||||||
|
|
||||||
// 上下文溢出压缩:预算按模型窗口推导(CONTEXT_MAX_TOKENS 未设置时;
|
|
||||||
// 显式 >0 = 覆盖、0/负 = 关闭),推导与「窗口未声明」的 WARN 都在
|
|
||||||
// wire_context.go 一处完成。Compactor 不注入,由 loop 选用内置的结构化
|
|
||||||
// compactor(离线、不调模型),并按同一份注入预算估算文档部件的代价。
|
|
||||||
ctxBudget := a.contextBudget(envPath)
|
|
||||||
|
|
||||||
a.loop = loop.New(loop.Options{
|
a.loop = loop.New(loop.Options{
|
||||||
Agent: defaultAgent,
|
Agent: defaultAgent,
|
||||||
Agents: agents,
|
Agents: agents,
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
// GuardrailConfig 承载内容护栏里**由部署方声明**的那一部分(design/04 §5)。
|
// 本文件承载内容护栏里**由部署方声明**的那一部分(design/04 §5)。
|
||||||
//
|
//
|
||||||
// 敏感信息阻断档与长度上限是代码内定的默认策略(见组合根的
|
// 敏感信息阻断档是代码内定的安全默认值(见组合根的 buildGuardrailPipeline),
|
||||||
// buildGuardrailPipeline),不对外开放配置:它们是安全默认值,不该被
|
// 不对外开放配置:它不该被一次误配置关掉。这里放的是两项**与部署环境相关**的
|
||||||
// 一次误配置关掉。这里只放内容策略词表 —— 它取决于部署方的场景,
|
// 取值 —— 内容策略词表,以及请求侧字符上限的显式覆盖。
|
||||||
// 项目不内置任何词表。
|
//
|
||||||
|
// 请求侧字符上限为什么必须能配:它的默认值由**推导出的上下文预算**派生
|
||||||
|
// (预算被抬高到 1M 窗口能吃的量级后,硬编码的 256 Ki 字符反而成了瓶颈,
|
||||||
|
// 会把「分享大文件」整条主线打成 guardrail_input 结束,见
|
||||||
|
// cmd/cyrene/wire_guardrail.go 的说明)。派生值已经能自适应窗口,本键只是
|
||||||
|
// 给需要收紧或放宽的部署留一个显式落点。
|
||||||
|
|
||||||
|
// GuardrailMaxInputCharsEnv 是请求侧字符上限的配置键名。
|
||||||
|
//
|
||||||
|
// 它必须与 GuardrailConfig.MaxInputChars 的 env 标签**逐字一致**:WARN 文案要能
|
||||||
|
// 直接点出「改哪个键」,因此键名只能有一处出处(一致性由 guardrail_test.go 钉住)。
|
||||||
|
const GuardrailMaxInputCharsEnv = "GUARDRAIL_MAX_INPUT_CHARS"
|
||||||
|
|
||||||
|
// GuardrailConfig 承载内容护栏里由部署方声明的取值(design/04 §5)。
|
||||||
type GuardrailConfig struct {
|
type GuardrailConfig struct {
|
||||||
// Keywords 是内容策略关键词表(GUARDRAIL_KEYWORDS,逗号分隔)。
|
// Keywords 是内容策略关键词表(GUARDRAIL_KEYWORDS,逗号分隔)。
|
||||||
//
|
//
|
||||||
@@ -13,4 +26,21 @@ type GuardrailConfig struct {
|
|||||||
// 只会误伤正常对话,且没有任何部署方能对它的命中负责。只有显式声明过
|
// 只会误伤正常对话,且没有任何部署方能对它的命中负责。只有显式声明过
|
||||||
// 的词才会被拦(大小写不敏感的子串匹配)。
|
// 的词才会被拦(大小写不敏感的子串匹配)。
|
||||||
Keywords []string `env:"GUARDRAIL_KEYWORDS"`
|
Keywords []string `env:"GUARDRAIL_KEYWORDS"`
|
||||||
|
|
||||||
|
// MaxInputChars 是**请求侧**字符上限的显式覆盖(GUARDRAIL_MAX_INPUT_CHARS)。
|
||||||
|
// 三态:
|
||||||
|
//
|
||||||
|
// - 未设置 / 0(默认)= 按推导出的上下文预算派生:
|
||||||
|
// 输入上限 = max(256 Ki, 推导预算 token 数)(1 字符 ≈ 1 token 的保守口径);
|
||||||
|
// - > 0 = 显式覆盖(原样使用);
|
||||||
|
// - < 0 = 非法:记一条 WARN 并回落派生值,绝不静默生效(AGENTS §10)。
|
||||||
|
//
|
||||||
|
// 派生态下「文档注入预算 ≤ 输入上限」这条关系不变量恒成立(1M 窗口 ⇒
|
||||||
|
// 注入 349525 字符 ≤ 上限 699050 字符);显式覆盖把它设得比注入预算小时,
|
||||||
|
// 组合根记一条 WARN 明说「大附件会被护栏拦下」,而不是等到用户上传大文件
|
||||||
|
// 时才以一个没有上下文的 input_length_exceeded 表现出来。
|
||||||
|
//
|
||||||
|
// 它只动**输入**侧:输出上限(65536)防的是模型输出失控,与上下文预算无关,
|
||||||
|
// 因此不随本键变化、也不开放配置。
|
||||||
|
MaxInputChars int `env:"GUARDRAIL_MAX_INPUT_CHARS"`
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 本文件锁定 GUARDRAIL_MAX_INPUT_CHARS 的配置侧契约(切片 G2):键名一致、三态可解。
|
||||||
|
// 派生与两条 WARN 的落点在组合根(cmd/cyrene/wire_guardrail.go 的
|
||||||
|
// deriveGuardrailInputLimit),这里只保证「键真的能配上、值真的能进来」。
|
||||||
|
|
||||||
|
// TestGuardrailMaxInputCharsEnvMatchesTag 钉住键名与 env 标签**逐字一致**:
|
||||||
|
// 组合根的 WARN 用 GuardrailMaxInputCharsEnv 点出「改哪个键」,两处一旦漂移,
|
||||||
|
// 日志就会把部署方指到不存在的键上。
|
||||||
|
func TestGuardrailMaxInputCharsEnvMatchesTag(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
field, ok := reflect.TypeOf(GuardrailConfig{}).FieldByName("MaxInputChars")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("GuardrailConfig 里没有 MaxInputChars 字段")
|
||||||
|
}
|
||||||
|
if got := field.Tag.Get("env"); got != GuardrailMaxInputCharsEnv {
|
||||||
|
t.Fatalf("MaxInputChars 的 env 标签 = %q, want %q", got, GuardrailMaxInputCharsEnv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoad_GuardrailMaxInputCharsTriState 钉住三态取值真的能配进来:
|
||||||
|
// 未设置 ⇒ 0(= 按推导预算派生);进程环境或 .env 给出正数 ⇒ 覆盖值;
|
||||||
|
// 负数 ⇒ **原样**进来(非法判定在组合根,那里负责记 WARN 并回落,不在这一层报错,
|
||||||
|
// 免得一个笔误让整个进程起不来)。
|
||||||
|
func TestLoad_GuardrailMaxInputCharsTriState(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("JWT_SECRET", "0123456789012345678901234567890123456789")
|
||||||
|
unsetenvForTest(t, GuardrailMaxInputCharsEnv)
|
||||||
|
|
||||||
|
cfg, err := Load(filepath.Join(dir, "absent.env"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Guardrail.MaxInputChars != 0 {
|
||||||
|
t.Fatalf("未配置时 = %d, want 0(0 = 按推导预算派生)", cfg.Guardrail.MaxInputChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// .env 里写了也算配上(与进程环境同一优先级链)。
|
||||||
|
envFile := filepath.Join(dir, "test.env")
|
||||||
|
if err := os.WriteFile(envFile, []byte(GuardrailMaxInputCharsEnv+"=1000\n"), 0o600); err != nil {
|
||||||
|
t.Fatalf("写 .env: %v", err)
|
||||||
|
}
|
||||||
|
cfg, err = Load(envFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Guardrail.MaxInputChars != 1000 {
|
||||||
|
t.Fatalf(".env 配置时 = %d, want 1000", cfg.Guardrail.MaxInputChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 负数原样进来:非法值的处理在组合根(WARN + 回落派生值),这一层不拦。
|
||||||
|
t.Setenv(GuardrailMaxInputCharsEnv, "-1")
|
||||||
|
cfg, err = Load(envFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Guardrail.MaxInputChars != -1 {
|
||||||
|
t.Fatalf("负数配置时 = %d, want -1(原样进来,判定在组合根)", cfg.Guardrail.MaxInputChars)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -205,21 +206,65 @@ func TestTerminal_WorkDir_OutsideRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTerminal_Timeout_Killed 校验超时到点后**子进程真的被杀**,而不是被等到自然结束。
|
||||||
|
//
|
||||||
|
// 为什么不能拿「~200ms 的精度」当判据(改动前基线:三进程并行整树 2/9 红,
|
||||||
|
// `timeout took 3.8202774s, want ~200ms (process not killed)`):那条断言测的是
|
||||||
|
// 「起进程 → 到点杀进程 → 收尸」的墙钟总和,而宿主重压之下**进程创建**这一段本身就
|
||||||
|
// 能吃掉整个时限(本切片勘察实测:重压下 cmd.Start 直接返回 context deadline
|
||||||
|
// exceeded,且已到「启动即超时」的量级),于是耗时断言退化成「宿主此刻有多忙」的
|
||||||
|
// 代理变量 —— 红行却报「process not killed」,指向一个并不存在的生产缺陷。
|
||||||
|
//
|
||||||
|
// 现在按语义分两层断言,墙钟只负责区分「被杀」与「被等到自然结束」:
|
||||||
|
// - ① 控制组:同一个 sleep 子命令在不设短超时时**自己跑完并以 0 退出**;
|
||||||
|
// 因此超时组那次拿到的非 nil waitErr 只可能来自「到点被杀」——这是与宿主
|
||||||
|
// 负载无关的确定性判别,也正是本用例名字要断言的事实。
|
||||||
|
// - ② 超时组:Timeout 到点 ⇒ ErrTimeout,且耗时远小于子进程的自然存活期
|
||||||
|
// (判别界 10s ≪ 30s:宿主即便卡到秒级也不会误判成红;而「没被杀」会一路
|
||||||
|
// 跑到 30s,落界之外必红)。
|
||||||
func TestTerminal_Timeout_Killed(t *testing.T) {
|
func TestTerminal_Timeout_Killed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
requireBin(t, "sleep")
|
requireBin(t, "sleep")
|
||||||
ex := newEnabledExecutor(t, enabledCfg("sleep", t.TempDir()))
|
ex := newEnabledExecutor(t, enabledCfg("sleep", t.TempDir()))
|
||||||
|
|
||||||
start := time.Now()
|
const (
|
||||||
_, err := ex.Execute(context.Background(), ExecRequest{
|
// childSleepSeconds 是子进程的自然存活期(秒):必须远大于任何宿主负载噪声,
|
||||||
Command: []string{"sleep", "5"},
|
// 「进程没被杀」才会落进判别界之外。
|
||||||
Timeout: 200 * time.Millisecond,
|
childSleepSeconds = 30
|
||||||
|
// killDeadline 是超时时间:远小于自然存活期,到点必须被杀。
|
||||||
|
killDeadline = 200 * time.Millisecond
|
||||||
|
// controlTimeout 是控制组的超时:只要求子命令能跑完,故留足余量。
|
||||||
|
controlTimeout = 30 * time.Second
|
||||||
|
// killProofBound 是「被杀」与「被等到自然结束」的判别界:耗时超过它,
|
||||||
|
// 就说明这一回合是等到了子进程自己结束,而不是被超时杀掉。
|
||||||
|
killProofBound = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// ① 控制组:确认这个子命令本来就以 0 正常退出,下面的推论才成立。
|
||||||
|
control, err := ex.Execute(context.Background(), ExecRequest{
|
||||||
|
Command: []string{"sleep", "0.05"},
|
||||||
|
Timeout: controlTimeout,
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("控制组 Execute: %v(子命令自己都跑不完,超时组的结论便不成立)", err)
|
||||||
|
}
|
||||||
|
if control.ExitCode != 0 {
|
||||||
|
t.Fatalf("控制组 ExitCode = %d, want 0(sleep 正常结束本就该是 0)", control.ExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 超时组:到点必须被杀,绝不等到它自己结束。
|
||||||
|
start := time.Now()
|
||||||
|
_, err = ex.Execute(context.Background(), ExecRequest{
|
||||||
|
Command: []string{"sleep", strconv.Itoa(childSleepSeconds)},
|
||||||
|
Timeout: killDeadline,
|
||||||
|
})
|
||||||
|
elapsed := time.Since(start)
|
||||||
if !errors.Is(err, ErrTimeout) {
|
if !errors.Is(err, ErrTimeout) {
|
||||||
t.Fatalf("err = %v, want ErrTimeout", err)
|
t.Fatalf("err = %v, want ErrTimeout", err)
|
||||||
}
|
}
|
||||||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
if elapsed >= killProofBound {
|
||||||
t.Errorf("timeout took %v, want ~200ms (process not killed)", elapsed)
|
t.Fatalf("超时回合耗时 %v(判别界 %v,子进程自然存活期 %ds):进程没有被杀,而是被等到了自然结束",
|
||||||
|
elapsed, killProofBound, childSleepSeconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user