主人 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 包。
103 lines
4.8 KiB
Go
103 lines
4.8 KiB
Go
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
|
||
}
|