Files
AskaEth a684328b76
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 包。
2026-10-07 16:25:54 +08:00

103 lines
4.8 KiB
Go
Raw Permalink 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 memory
import (
"sync"
"time"
)
// 本文件是 T1(灰区模型判定)的**全局限速闸**(design/23 §15.4 的「加一层保守的
// 全局速率限流」,主人 2026-10-07 裁定)。
//
// 为什么要有它:T1 默认**开**,而它只在「T0 判不出结论 + 存在灰区候选」时才发一次
// 模型调用 —— 现网节奏是每天 700–1000 条新记忆,若其中多数都带灰区候选,就是
// **每天数百到上千次**额外调用。在此之前 T1 上**没有任何上限**:那是一处没有刹车
// 的额度出口。加的这层闸是**全进程共享的一个窗口额度**(不是每条各自的限制):
// 超限 ⇒ 跳过 T1 并回落 `create`(reason=`t1_throttled`),与既有失败路径逐字同
// 语义(**只改 reason、决定恒 create、零改写**)。超限**不是错误**,是「这一轮
// 先不判、留给以后」—— 记忆一条不丢、一个字不改。
//
// 为什么自研这一件、而不是复用既有滑窗件 —— 两条都是硬理由(不是图省事):
//
// 1. **窗口过期必须可确定性测试**。既有共享件是 `session.NewRateLimiter`
// (`internal/channel/session/proactive.go`,design/12 §N C.5 指定的那个),
// 它内部写死 `time.Now()`,**没有时钟注入缝**:它的「窗口过后额度恢复」只能
// 靠「极小窗口 + 真实短等」覆盖 —— 那正是 design/12 §N C.5 自己登记的已知
// 取舍。本切片要求用**注入时钟**验这条通路(与本包 `ReviewDeps.Now` 同一
// 手法),而 `allow()` 的每一次判定都要看时间,没有缝就做不到。
// 2. **依赖方向**:那一件住在 `internal/channel/session`,且会带进
// `internal/channel/adapter`(渠道层的适配器/对话概念)。记忆运行时为 20 行的
// 计数去依赖渠道层,是拿架构换省事。
//
// design/12 §N C.5 对同一取舍已经写下了结论:「若主会话要求完全确定,改法是让闸
// 自带 `now func() time.Time`(约 +15 行,代价是出现第二份滑窗实现)」。本文件
// 就是那一改法的落地,并**刻意只做这一件事**:它不认识令牌桶、不按 key 分桶,
// 只有一个窗口一份额度 —— 不是「第二套通用限流机制」,是 T1 的一处刹车。
// t1RateGate 是 T1 的全局限速闸:一个滑动窗口额度,**全进程共享一个实例**
// (组合根只建一个 `PreInsertReviewer`,闸就挂在那一个实例上 ⇒ 事实上是全进程
// 额度;AGENTS §10 禁包级全局变量,因此它只能是实例字段)。
//
// 并发安全:审查可能来自后台工作协程,也可能来自测试/手动 Review,状态全部在
// 互斥下读写。
type t1RateGate struct {
limit int
window time.Duration
now func() time.Time
mu sync.Mutex
hits []time.Time
// refused 记账「本窗口已经拒过一次」,只为把日志收敛成**每窗口一条 WARN**:
// 超限是预期内的省钱行为、不是故障,一天几百条 WARN 会把真正的故障淹掉。
refused bool
}
// newT1RateGate 构建闸。limit / window **必须是 normalize 之后的正值**:非正会
// 得到一个恒放行的闸(见 allow),那正是 design/12 §N C.5 G3 登记过的静默失败
// 形态「≤0 被读成无上限」—— 因此调用方(NewPreInsertReviewer)保证它在
// `normalizeReviewConfig` **之后**构建,绝不让未归一的 ≤0 直达滑窗。
func newT1RateGate(limit int, window time.Duration, now func() time.Time) *t1RateGate {
if now == nil {
now = time.Now
}
return &t1RateGate{limit: limit, window: window, now: now}
}
// allow 报告这一次 T1 调用是否放行;第二个返回值是「本窗口的第一次拒绝」,
// 调用方据此把日志收成每窗口一条 WARN、其余记 Debug。
//
// 额度口径与既有滑窗件一致:**放行才消耗** —— 被拒的那次不记账,因此超限不会把
// 窗口往后推,额度到点(最后一次放行的窗口之后)自然恢复。
//
// nil 或非正额度 ⇒ 恒放行:那是「没配限流器」的安全默认,而不是「静默关掉一处
// 防护」(构造处已由 normalize 保证不会发生)。
func (g *t1RateGate) allow() (allowed bool, firstRefusal bool) {
if g == nil || g.limit <= 0 || g.window <= 0 {
return true, false
}
now := g.now()
cutoff := now.Add(-g.window)
g.mu.Lock()
defer g.mu.Unlock()
keep := 0
for _, t := range g.hits {
if t.After(cutoff) {
g.hits[keep] = t
keep++
}
}
g.hits = g.hits[:keep]
if keep == 0 {
// 窗口已排空 ⇒ 新一轮开始,「本窗口已报过一次 WARN」随之复位。
g.refused = false
}
if keep >= g.limit {
first := !g.refused
g.refused = true
return false, first
}
g.hits = append(g.hits, now)
return true, false
}