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
@@ -34,6 +34,10 @@ import (
|
||||
// 4. **有界 + 不阻塞写入**:每次审查**最多一次**模型调用(灰区候选打包进同一个
|
||||
// prompt,最多 `defaultT1MaxGray` 条),且有自己的子超时(≤ 单条审查总预算)。
|
||||
// 超时 / 报错 / 非法回复 ⇒ `create` + WARN + **零改写**,绝不阻塞、绝不丢记忆。
|
||||
// 此外还有一层**全局滑窗限速**(`defaultT1PerWindow` 次 / `defaultT1Window`,
|
||||
// 全进程共享一个窗口)堵住最坏情形:现网每天数百到上千条新记忆,若大半带灰区
|
||||
// 候选就是每天数百到上千次调用;超限 ⇒ 跳过 T1 + 回落 `create` +
|
||||
// `t1_throttled`(见 preinsert_t1_limit.go)。
|
||||
// T1 只挂在**异步审查层**(`PreInsertReviewer.Review`);同步入库闸
|
||||
// (preinsert_gate.go)不碰它 ⇒ 写入路径仍是零模型调用。
|
||||
// 5. **不新造 LLM 客户端**:注入的是**既有** `model.Adapter` 的调用口(组合根
|
||||
@@ -41,10 +45,10 @@ import (
|
||||
// 厂商协议,也不新增依赖。
|
||||
//
|
||||
// 可观测(§15 D3-8):T1 是否参与、结论是什么,在留痕 `reason` 列上如实可辨
|
||||
// (`t1_merge` / `t1_modify` / `t1_keep` / `t1_reply_invalid` / `t1_unavailable`),
|
||||
// 规则版本随之升到 `IngestRuleVersion`。模型的自由文本理由**既不落库也不落日志**:
|
||||
// 留痕表按设计不存正文,而模型的理由可能改述正文 ⇒ 一律丢弃(它在 prompt 里的
|
||||
// 唯一作用,是逼模型先表态,从而把动作判得更稳)。
|
||||
// (`t1_merge` / `t1_modify` / `t1_keep` / `t1_reply_invalid` / `t1_unavailable` /
|
||||
// `t1_throttled`),规则版本随之升到 `IngestRuleVersion`。模型的自由文本理由
|
||||
// **既不落库也不落日志**:留痕表按设计不存正文,而模型的理由可能改述正文 ⇒ 一律
|
||||
// 丢弃(它在 prompt 里的唯一作用,是逼模型先表态,从而把动作判得更稳)。
|
||||
|
||||
const (
|
||||
// defaultT1MaxGray 是打包进**一次** prompt 的灰区候选上限。灰区候选按召回
|
||||
@@ -71,6 +75,11 @@ const (
|
||||
t1ReasonKeep = "t1_keep"
|
||||
t1ReasonReplyInvalid = "t1_reply_invalid"
|
||||
t1ReasonUnavailable = "t1_unavailable"
|
||||
// t1ReasonThrottled 是**全局限速**命中时的原因码(preinsert_t1_limit.go,
|
||||
// design/23 §15.4)。它与上面两个失败码同族、同样只改 `reason`:决定恒
|
||||
// `create`、零改写。与它们的唯一区别是语义 —— 失败是「模型没答上」,
|
||||
// 限流是「这一轮先不判、留给以后」,因此不走失败那条 WARN。
|
||||
t1ReasonThrottled = "t1_throttled"
|
||||
)
|
||||
|
||||
// ErrT1ReplyInvalid 是模型回复解不出三态枚举(或候选编号越界)时的分类错误。
|
||||
@@ -118,12 +127,19 @@ func (r *PreInsertReviewer) t1Timeout() time.Duration {
|
||||
// maybeJudgeT1 在「T0 判不出结论 + 有灰区候选」时,用**一次**模型调用改判。
|
||||
//
|
||||
// 任何非「模型给出合法三态」的情形都**原样返回 v**:未启用、没有模型出口、
|
||||
// 没有灰区候选、调用超时/报错、回复非法。因此「失败 ⇒ create + 零改写」是
|
||||
// 结构性成立的 —— 这条路径上根本没有改写的代码。
|
||||
// 没有灰区候选、**全局额度用尽(限流)**、调用超时/报错、回复非法。因此
|
||||
// 「失败/限流 ⇒ create + 零改写」是结构性成立的 —— 这条路径上根本没有改写的
|
||||
// 代码。
|
||||
func (r *PreInsertReviewer) maybeJudgeT1(ctx context.Context, item MemoryItem, v ingestVerdict) ingestVerdict {
|
||||
if !r.t1Enabled() || len(v.Gray) == 0 {
|
||||
return v
|
||||
}
|
||||
// 全局限速(design/23 §15.4)摆在**发请求之前**,且排在两个前置守卫**之后**:
|
||||
// 没有灰区候选的审查一次额度都不占(它本来就不调模型),只有真的要花钱时才
|
||||
// 过闸、才记账 —— 于是「超限」永远等于「本来会多花一次调用」。
|
||||
if allowed, firstRefusal := r.t1Gate.allow(); !allowed {
|
||||
return r.t1Throttled(item, v, firstRefusal)
|
||||
}
|
||||
gray := v.Gray
|
||||
if len(gray) > defaultT1MaxGray {
|
||||
gray = gray[:defaultT1MaxGray]
|
||||
@@ -149,6 +165,9 @@ func (r *PreInsertReviewer) maybeJudgeT1(ctx context.Context, item MemoryItem, v
|
||||
// callT1 发起**唯一**的那次 T1 调用(每次审查至多一次)。它自带子超时,并把
|
||||
// 同一个预算也交给适配器(model.Options.Timeout)—— 进程内与 HTTP 侧各把一道,
|
||||
// 一次灰区判定绝不可能拖长审查。模型正文(prompt 与响应)**不进日志**。
|
||||
//
|
||||
// 走到这里说明额度**已经**被闸放行(放行才记账):因此「这一条发得出去」与
|
||||
// 「这条占掉了一格额度」是同一件事,不存在「调了但没记账」的缝。
|
||||
func (r *PreInsertReviewer) callT1(ctx context.Context, item MemoryItem, gray []ingestCandidate) (t1Reply, error) {
|
||||
budget := r.t1Timeout()
|
||||
cctx, cancel := context.WithTimeout(ctx, budget)
|
||||
@@ -178,6 +197,30 @@ func (r *PreInsertReviewer) t1Fallback(ctx context.Context, item MemoryItem, v i
|
||||
return v
|
||||
}
|
||||
|
||||
// t1Throttled 组装**限流**回落结论:与 t1Fallback 逐字同结果 —— 决定恒 `create`、
|
||||
// 只把 `reason` 换成 `t1_throttled`、**零改写**,差分只在外面的口径与日志档位。
|
||||
//
|
||||
// 为什么日志与失败路径不同:失败(超时 / 报错 / 回复非法)是**故障**,每条都该
|
||||
// 现形;超限是**预期内的省钱行为**(主人 2026-10-07:「超限不是错误,是这一轮先
|
||||
// 不判、留给以后」),一天可能几百条 —— 全记 WARN 会把真正的故障淹掉。因此:
|
||||
// 每个窗口只记**一条** WARN(附窗口额度与窗口长度,够运维定位),其余记 Debug。
|
||||
// 留痕 `reason` 一律如实(那才是机器可查的证据面)。日志只带 id / 作用域 / 额度,
|
||||
// **绝不带记忆正文**。
|
||||
func (r *PreInsertReviewer) t1Throttled(item MemoryItem, v ingestVerdict, firstRefusal bool) ingestVerdict {
|
||||
v.Reason = t1ReasonThrottled
|
||||
r.bump(func(s *ReviewStats) { s.T1Throttled++ })
|
||||
args := []any{
|
||||
"item_id", item.ID, "scope", string(item.Scope), "reason", t1ReasonThrottled,
|
||||
"t1_per_window", r.cfg.T1PerWindow, "t1_window", r.cfg.T1Window.String(),
|
||||
}
|
||||
if firstRefusal {
|
||||
r.log.Warn("memory ingest review t1 judge throttled, gray-zone item kept as create", args...)
|
||||
return v
|
||||
}
|
||||
r.log.Debug("memory ingest review t1 judge throttled, gray-zone item kept as create", args...)
|
||||
return v
|
||||
}
|
||||
|
||||
// t1Verdict 把模型回复映射成判定结论。ok=false 表示回复不可用(非法枚举 /
|
||||
// 编号越界),调用方据此回落 create —— **绝不猜、绝不挑一个最近的候选**。
|
||||
func t1Verdict(reply t1Reply, item MemoryItem, gray []ingestCandidate, base ingestVerdict) (ingestVerdict, bool) {
|
||||
|
||||
Reference in new issue
Block a user