Files
Cyrene_v2/internal/runtime/memory/preinsert_stats.go
T
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

87 lines
3.7 KiB
Go
Raw 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 "fmt"
// 本文件是入库前审查(design/23 §15.1 D3)的**观测面**:进程内计数与审计出口。
// 它从 preinsert.go 拆出来只为守 AGENTS §10 的文件行数上限(≤500 行)与
// 「模块按职责拆源文件」那条规范 —— 口径逐字未变。
//
// 为什么要**分列**闸与异步队列的计数:同步入库闸(写路径上当场拍板)与异步
// 审查队列(后台补审)是两件事,把它们加成一个数会让「闸拦下了多少重复」与
// 「事后补审了多少」不可分辨。分列之后,面板/日志能分别读到两条路径的量。
// ReviewStats 是审查的观测计数(进程内累计,供日志/测试断言)。
type ReviewStats struct {
Reviewed int
Skipped int
Merged int
Modified int
Created int
Dropped int
Failed int
// GateChecked 是**同步入库闸**实际做了一次判定的条数(跳过闸的条目不计)。
GateChecked int
// GateAbsorbed 是闸当场拦下并落成 `merged` 的条数(真重复,含 modify 的
// 「旧行被吸收」)。
GateAbsorbed int
// GateFailed 是闸因预算/候选源不可用而 fail-open 的次数(照常插入 + 留痕)。
GateFailed int
// T1Called 是 T1 实际发出过**模型调用**的次数。每次审查至多 +1
// (defaultT1MaxGray 只是把多条灰区候选打包进同一次 prompt,不多发请求)。
T1Called int
// T1Decided 是 T1 真的定下结论(merge/modify/keep)的次数。它与 T1Called
// 的差值就是「调了但没用上」(非法回复 / 超时 / 报错回落 create)——
// 这个差值本身是可观测信号,因此两个数分开计。
T1Decided int
// T1Throttled 是**全局限速**命中、因而跳过 T1 的次数(design/23 §15.4,
// reason=`t1_throttled`)。它**不**计入 T1Called(一次模型调用都没发),
// 也不计入 T1Decided(没定任何结论)——「想调但被闸拦下」是与「调了没用上」
// 不同的一件事,分开计才看得出额度是不是成了瓶颈。
T1Throttled int
}
// bump 在互斥下修改计数(工作协程、手动 Review 与同步闸都可能调用)。
func (r *PreInsertReviewer) bump(apply func(*ReviewStats)) {
r.statsMu.Lock()
defer r.statsMu.Unlock()
apply(&r.stats)
}
// recordDecision 累计观测计数(含降级计数,便于运维判读)。
func (r *PreInsertReviewer) recordDecision(rev IngestReview) {
r.bump(func(s *ReviewStats) {
s.Reviewed++
switch rev.Decision {
case IngestSkip:
s.Skipped++
case IngestMerge:
s.Merged++
case IngestModify:
s.Modified++
default:
s.Created++
}
})
}
// auditWith 写一条审计。verb 让「同步闸当场拦下的」与「异步审查事后合并的」
// 在审计链上可分(前者 memory.ingest.gate,后者 memory.review.ingest)。
// 它**只带决定与 id,绝不带任何记忆正文**。
func (r *PreInsertReviewer) auditWith(verb string, rev IngestReview) {
result := fmt.Sprintf("decision=%s reason=%s item=%s target=%s degraded=%t score=%.3f",
rev.Decision, rev.Reason, rev.ItemID, rev.TargetID, rev.Degraded, rev.Score)
if r.deps.Audit == nil {
r.log.Debug("memory ingest review audit (no sink configured)", "what", verb, "result", result)
return
}
r.deps.Audit(reviewAuditWho, verb, result)
}
// reviewStatsLabel 把计数渲染成一条稳定日志串。
func reviewStatsLabel(s ReviewStats) string {
return fmt.Sprintf("reviewed=%d skip=%d merge=%d modify=%d create=%d dropped=%d failed=%d "+
"gate_checked=%d gate_absorbed=%d gate_failed=%d t1_called=%d t1_decided=%d t1_throttled=%d",
s.Reviewed, s.Skipped, s.Merged, s.Modified, s.Created, s.Dropped, s.Failed,
s.GateChecked, s.GateAbsorbed, s.GateFailed, s.T1Called, s.T1Decided, s.T1Throttled)
}