feat(memory): T1 加保守全局速率限流(防每天数百到上千次调用失控)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 2026-10-07 裁定:按最优解加全局限流。

- 新增 t1RateGate(滑动窗口、可注入时钟,~70 行);默认
  120 次 / 60 分钟(现网 700-1000 条新记忆/天 ≈ 25-42/小时,
  取 120 留约 3-5 倍余量)
- 过闸摆在「t1Enabled + 有灰区候选」两个守卫之后、发请求之前
  ⇒ 不调模型就不占额度;放行才记账
- 超限语义与既有失败路径同源:跳过 T1、决定仍是 create、零改写,
  只多一个 reason=t1_throttled 进留痕 + ReviewStats.T1Throttled 计数
- 配置键 t1PerWindow / t1WindowSec 放 $DATA_DIR/config/memory-tidy.json
  (零新 env 键,启动期读一次);坏配置回落默认 + WARN,不启动失败
- 每窗口只记一条 WARN(超限是预期行为,一天几百条会淹掉真故障)
- IngestRuleVersion 仍为 d3.2:判定规则集与解析契约没变,变的只是
  额度状态,reason 列已可分辨(升版会有 §17.6 记过的幂等键重复行副作用)

自研而非复用 session.NewRateLimiter 的理由(design/12 §N C.5 自己登记过
这条取舍):既有件 Allow() 内部写死 time.Now(),没有时钟注入缝,
而本切片必须能注入可控时钟;且复用会把 channel 层概念拖进 memory 包。
This commit is contained in:
AskaEth committed 2026-10-07 16:25:54 +08:00
1 parent 987cefdf28
commit a684328b76
10 files changed
+782 -10

No files matched your search

@@ -17,6 +17,12 @@
// 是同一条 —— 它是一处**会花掉模型额度**的开关,必须能一行配置关掉(关掉即回落
// 纯规则、零模型调用),所以同样落在这里,不新开文件家族、不加 env 键。
//
// 2026-10-07 同日追加 `t1PerWindow` / `t1WindowSec`(T1 的**全局限速**,
// design/23 §15.4 主人裁定「加一层保守的全局速率限流」):T1 默认开,每次「有灰区
// 候选的审查」就发一次调用,现网每天数百到上千条新记忆 ⇒ 那是一处**没有刹车**的
// 额度出口。额度必须能按现网节奏调(且不许为它开 env 键),因此落在这里;
// **零值不表示「无限制」**,只表示「没配、用代码默认值」。
//
// 边界与 backthink.go 同源(那是同一族的手续):
//
// 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落
@@ -87,6 +93,21 @@ type MemoryTidyTuning struct {
// 指针也是同一个理由:代码默认值是「开」,用 bool 会让「这个键没写」与
// 「显式写 false」不可区分。
T1Judge *bool `json:"t1Judge"`
// T1PerWindow 是 T1 的**全局**窗口额度(次数)—— 全进程共享一个窗口,超限
// 即跳过 T1 并回落 `create`(`reason=t1_throttled`,design/23 §15.4)。
// 零/负 = 没配 ⇒ 用代码默认值(memory 侧的 defaultT1PerWindow)。
//
// **刻意没有「无上限」语义**(与 autoApplyPerRound 同一口径):≤0 一律回落
// 默认额 —— 「不限流」不该是敲一个 0 就能得到的状态(design/12 §N C.5 G3
// 登记过那条静默失败形态)。要完全不花模型额度,请关 `t1Judge`。
T1PerWindow int `json:"t1PerWindow"`
// T1WindowSec 是上面那个额度的时间窗(**秒**)。零/负 = 没配 ⇒ 用代码默认值
// (memory 侧的 defaultT1Window)。
//
// 用秒而不是 Go 的时长字面量(`"1h"`)是刻意的:这个文件是给人改的,写
// `3600` 比要求主人记住 Go 的时长语法更不容易写错,也与既有
// `$DATA_DIR/config/backthink.json` 的 `*_SEC` 口径一致。
T1WindowSec int `json:"t1WindowSec"`
}
// AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
@@ -134,6 +134,54 @@ func TestLoadMemoryTidyTuning_IngestGate(t *testing.T) {
}
}
// TestLoadMemoryTidyTuning_T1RateLimit 钉住 **T1 全局限速**的两个数值键
// (design/23 §15.4,主人 2026-10-07 裁定):它们与 `t1Judge` 同一个文件、
// **零新 env 键**,且**零值只能是「没配」**——绝不能是「无上限」。
func TestLoadMemoryTidyTuning_T1RateLimit(t *testing.T) {
t.Parallel()
// ① 没写 ⇒ 保持零值(调用方据此回落代码默认额,不是「不限流」)。
absent := writeMemoryTidyFile(t, `{"t1Judge": true}`)
got, err := LoadMemoryTidyTuning(absent)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning: %v", err)
}
if got.T1PerWindow != 0 || got.T1WindowSec != 0 {
t.Fatalf("没写限速键应当给零值:%+v", got)
}
// ② 写了 ⇒ 如实回读,且不连带改动别的键(一个键不能顺手关掉另一件事)。
set := writeMemoryTidyFile(t, `{"t1PerWindow": 240, "t1WindowSec": 1800}`)
got, err = LoadMemoryTidyTuning(set)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(set): %v", err)
}
if got.T1PerWindow != 240 || got.T1WindowSec != 1800 {
t.Fatalf("限速键回读 = %d/%d, want 240/1800", got.T1PerWindow, got.T1WindowSec)
}
if got.T1JudgeOff() || got.IngestGateOff() || got.AutoApplyOff() {
t.Fatalf("限速键顺手改了别的开关:%+v", got)
}
// ③ 显式 0 ⇒ 仍是零值(「没法用 0 关掉限流」是刻意的:那会是一处静默的失效面)。
zero := writeMemoryTidyFile(t, `{"t1PerWindow": 0, "t1WindowSec": 0}`)
got, err = LoadMemoryTidyTuning(zero)
if err != nil {
t.Fatalf("LoadMemoryTidyTuning(zero): %v", err)
}
if got.T1PerWindow != 0 || got.T1WindowSec != 0 {
t.Fatalf("显式 0 的回读 = %+v", got)
}
// ④ 类型错 ⇒ 分类错误(调用方据此记 WARN 并回落代码默认额,绝不静默不限流)。
for _, body := range []string{`{"t1PerWindow": "many"}`, `{"t1WindowSec": "1h"}`} {
broken := writeMemoryTidyFile(t, body)
if _, err := LoadMemoryTidyTuning(broken); !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("坏配置 %s 的 err = %v, want ErrInvalidMemoryTidy", body, err)
}
}
}
func TestDefaultMemoryTidyPathIsDataDirConfig(t *testing.T) {
t.Parallel()
got := DefaultMemoryTidyPath(filepath.Join("root", "data"))