feat(memory): T1 加保守全局速率限流(防每天数百到上千次调用失控)
主人 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:
1 parent
987cefdf28
commit
a684328b76
10 files changed
+782
-10
No files matched your search
@@ -39,7 +39,9 @@ var errT1ModelNotReady = errors.New("memory review: t1 judge: model adapter not
|
||||
// 🔴 两个开关都不在 env 里(`.env` 已冻结,design/13 §2.1),它们读
|
||||
// `$DATA_DIR/config/memory-tidy.json`,与整理自动档同一个文件、**零新 env 键**:
|
||||
// - `{"ingestGate": false}` ⇒ 一键回到「只插不审」(同步入库闸);
|
||||
// - `{"t1Judge": false}` ⇒ 一键回到纯规则(T1 灰区判定,**零模型调用**)。
|
||||
// - `{"t1Judge": false}` ⇒ 一键回到纯规则(T1 灰区判定,**零模型调用**);
|
||||
// - `{"t1PerWindow": 120, "t1WindowSec": 3600}` ⇒ T1 的**全局限速**额度
|
||||
// (design/23 §15.4);缺位 = 代码默认 120 次/60 分钟。
|
||||
func (a *app) wireMemoryReview() error {
|
||||
cfg, err := config.LoadMemoryReviewConfig(envPath)
|
||||
if err != nil {
|
||||
@@ -55,6 +57,10 @@ func (a *app) wireMemoryReview() error {
|
||||
// 一键回到纯规则(零模型调用)。与入库闸同一个文件、同一套「没配 = 保持默认」
|
||||
// 口径,**零新 env 键**。
|
||||
t1Off := a.memoryT1JudgeOff()
|
||||
// T1 的全局限速额度(design/23 §15.4):同样读那个文件,缺位/坏文件一律
|
||||
// 交零值给 normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟)——
|
||||
// 坏配置绝不能让一处限速静默变成「无上限」。
|
||||
t1PerWindow, t1WindowSec := a.memoryT1RateLimit()
|
||||
reviewer, err := memory.NewPreInsertReviewer(memory.ReviewConfig{
|
||||
Enabled: cfg.Enabled,
|
||||
QueueSize: cfg.QueueSize,
|
||||
@@ -70,6 +76,12 @@ func (a *app) wireMemoryReview() error {
|
||||
// T1 的时长走代码默认值(8s,且被单条审查的 Timeout 收紧取小):
|
||||
// 它是**实现参数**不是运维旋钮 —— 要让 T1 完全不花钱,就关掉它。
|
||||
DisableT1: t1Off,
|
||||
// T1 的**全局限速**额度(design/23 §15.4):数值来自
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的 `t1PerWindow` / `t1WindowSec`,
|
||||
// 缺位或坏配置时是零值 ⇒ 由 normalizeReviewConfig 回落代码默认
|
||||
// (120 次/60 分钟)。**没有「无上限」这个状态**:要零花费请关 t1Judge。
|
||||
T1PerWindow: t1PerWindow,
|
||||
T1Window: time.Duration(t1WindowSec) * time.Second,
|
||||
}, memory.ReviewDeps{
|
||||
Store: a.memStore,
|
||||
Embedder: a.embedder,
|
||||
@@ -114,6 +126,31 @@ func (a *app) memoryT1JudgeOff() bool {
|
||||
return tuning.T1JudgeOff()
|
||||
}
|
||||
|
||||
// memoryT1RateLimit 读 **T1 全局限速**的两项旋钮:窗口额度(次)与窗口长度(秒)。
|
||||
//
|
||||
// 口径与 memoryIngestGateOff / memoryT1JudgeOff 逐字相同:读不到/解不开
|
||||
// `memory-tidy.json` 时返回零值(= 不覆盖)并记 WARN —— 零值经
|
||||
// normalizeReviewConfig 回落**代码默认值**(120 次/60 分钟),因此坏配置既不会
|
||||
// 让限速静默变成「无上限」,也不会让 T1 静默失效。
|
||||
//
|
||||
// 只有**两项都写了且为正**才报一条 Info(自解释的配置回执);只写了一项时静默
|
||||
// 回落缺的那一项,不报「已配置」以免报出一个与生效值不符的印象。
|
||||
func (a *app) memoryT1RateLimit() (perWindow int, windowSec int) {
|
||||
tuning, path, err := a.readMemoryTidyTuning()
|
||||
if err != nil {
|
||||
logger.WithSource(a.log, "memory/review").Warn(
|
||||
"memory t1 rate limit tuning unreadable, falling back to code defaults",
|
||||
"path", path, "error", err)
|
||||
return 0, 0
|
||||
}
|
||||
if tuning.T1PerWindow > 0 && tuning.T1WindowSec > 0 {
|
||||
logger.WithSource(a.log, "memory/review").Info(
|
||||
"memory t1 rate limit loaded",
|
||||
"path", path, "t1_per_window", tuning.T1PerWindow, "t1_window_sec", tuning.T1WindowSec)
|
||||
}
|
||||
return tuning.T1PerWindow, tuning.T1WindowSec
|
||||
}
|
||||
|
||||
// memoryT1Judge 把「调用时才解析的模型适配器」包成 T1 的一次性调用口。
|
||||
//
|
||||
// 为什么是闭包而不是直接塞 `a.modelAdapter`:组合根的顺序是 **wireMemory(建
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -68,6 +70,11 @@ func TestMemoryReviewIsWired(t *testing.T) {
|
||||
"a.memoryT1JudgeOff()",
|
||||
"DisableT1: t1Off",
|
||||
"Judge: a.memoryT1Judge()",
|
||||
// T1 的**全局限速**额度(design/23 §15.4)同样来自那个文件,
|
||||
// 缺位/坏文件一律回落代码默认(120 次/60 分钟)。
|
||||
"a.memoryT1RateLimit()",
|
||||
"T1PerWindow: t1PerWindow",
|
||||
"T1Window: time.Duration(t1WindowSec) * time.Second",
|
||||
},
|
||||
"main.go": {
|
||||
"a.memoryReview.Start(ctx)",
|
||||
@@ -186,6 +193,52 @@ func TestMemoryT1Judge_ReadsDataDirConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryT1RateLimit_ReadsDataDirConfig 钉住 **T1 全局限速**的额度确实来自
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的 `t1PerWindow` / `t1WindowSec`
|
||||
// (design/23 §15.4):T1 默认开,每次有灰区候选的审查就发一次调用 ⇒ 额度必须能
|
||||
// 按现网节奏调,而 `.env` 已冻结、不许为它开 env 键。
|
||||
//
|
||||
// 口径与入库闸/T1 开关同一条:没配 = 返回零值(交给 normalizeReviewConfig 回落
|
||||
// 代码默认 120 次/60 分钟);坏文件 = 同样回落默认额 **并记 WARN** ——
|
||||
// 坏配置绝不能让限速静默变成「无上限」。
|
||||
func TestMemoryT1RateLimit_ReadsDataDirConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
empty := &app{cfg: &config.Config{DataDir: t.TempDir()}, log: tidyTestLog()}
|
||||
if perWindow, windowSec := empty.memoryT1RateLimit(); perWindow != 0 || windowSec != 0 {
|
||||
t.Fatalf("没配过时应当返回零值(= 回落代码默认):%d/%d", perWindow, windowSec)
|
||||
}
|
||||
|
||||
dataDir := t.TempDir()
|
||||
dir := filepath.Join(dataDir, "config")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "memory-tidy.json")
|
||||
if err := os.WriteFile(path, []byte(`{"t1PerWindow": 240, "t1WindowSec": 1800}`), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
set := &app{cfg: &config.Config{DataDir: dataDir}, log: tidyTestLog()}
|
||||
if perWindow, windowSec := set.memoryT1RateLimit(); perWindow != 240 || windowSec != 1800 {
|
||||
t.Fatalf("额度没被读到:%d/%d, want 240/1800", perWindow, windowSec)
|
||||
}
|
||||
|
||||
// 坏配置:回落默认额(零值)+ 一条 WARN,绝不静默不限流。
|
||||
if err := os.WriteFile(path, []byte(`{"t1PerWindow": "many"}`), 0o600); err != nil {
|
||||
t.Fatalf("write broken: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
broken := &app{cfg: &config.Config{DataDir: dataDir},
|
||||
log: slog.New(slog.NewTextHandler(&buf, nil))}
|
||||
if perWindow, windowSec := broken.memoryT1RateLimit(); perWindow != 0 || windowSec != 0 {
|
||||
t.Fatalf("坏配置下应当回落零值:%d/%d", perWindow, windowSec)
|
||||
}
|
||||
if logged := buf.String(); !strings.Contains(logged, "t1 rate limit tuning unreadable") ||
|
||||
!strings.Contains(logged, "level=WARN") {
|
||||
t.Fatalf("坏配置没有留下 WARN:%q", logged)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryT1Judge_UsesTheWiredModelStack 钉住 T1 的模型出口**复用既有模型栈**
|
||||
// (不新造 LLM 客户端),且返回一个闭包而不是当场取值 —— 本服务在 wireMemory
|
||||
// 阶段装配,而 a.modelAdapter 要等更晚的 wireRuntime 才建好。
|
||||
|
||||
Reference in new issue
Block a user