Files
Cyrene_v2/cmd/cyrene/wire_memory_review.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

190 lines
9.4 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 main
import (
"context"
"errors"
"fmt"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// errT1ModelNotReady 在 T1 需要模型而模型栈尚未就绪时返回。
//
// 它是 **fail-safe** 信号,不是故障:审查据此回落 `create` + WARN,绝不阻塞、
// 绝不丢记忆(design/23 §15 D3-5)。之所以可能存在这一刻:本服务在
// `wireMemory` 阶段装配,模型栈在更晚的 `wireRuntime` 阶段才建好。
var errT1ModelNotReady = errors.New("memory review: t1 judge: model adapter not ready")
// 本文件把 D3「入库前审查」(DevDocs/design/23-记忆整理与去重 §15.1 D3)接进
// 组合根。三件事只在这里定:
//
// - **形态**:记忆管理会话(复用既有的 sess-memorytidy 装配,见
// memory.TidySessionID)的有界后台队列,不是会话内派 subagent。它与整理
// 心跳并列启动、在 close 里先于关库停止;
// - **依赖面**:只给它记忆存储、embedder,以及 **T1 的一次性模型调用口**
// (memoryT1Judge,§15.1 D3-2 的灰区判定)。服务侧拿不到任何会话/历史读数,
// 因此「独立会话、零上下文、不碰主会话上下文」(§15 D3)是结构性成立的,
// 而不是靠自觉;
// - **审计**:接到网关的 AuditEvent(口径 memory.review.ingest)。出口在调用
// 时才解析 a.gw —— 网关比本服务晚组装(wireFrontend),与 memoryTidy 同
// 一手法。未就绪时只记日志,绝不假装写过审计。
//
// 关闭(MEMORY_REVIEW_ENABLED=false)时**不建、不接**:store 的写入路径因此
// 逐字回到本次改动之前(纯增量、无破坏性变更)。
//
// 🔴 两个开关都不在 env 里(`.env` 已冻结,design/13 §2.1),它们读
// `$DATA_DIR/config/memory-tidy.json`,与整理自动档同一个文件、**零新 env 键**:
// - `{"ingestGate": false}` ⇒ 一键回到「只插不审」(同步入库闸);
// - `{"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 {
return fmt.Errorf("memory review config: %w", err)
}
if !cfg.Enabled {
logger.WithSource(a.log, "memory/review").Info(
"memory ingest review disabled by configuration", "env", "MEMORY_REVIEW_ENABLED")
return nil
}
gateOff := a.memoryIngestGateOff()
// T1 灰区模型判定(design/23 §15.1 D3-2):代码默认**开**,`{"t1Judge": false}`
// 一键回到纯规则(零模型调用)。与入库闸同一个文件、同一套「没配 = 保持默认」
// 口径,**零新 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,
ScanLimit: cfg.ScanLimit,
Timeout: time.Duration(cfg.TimeoutSec) * time.Second,
// 召回门复用记忆整理的既有阈值(design/23 §13 开放点 2 = 0.80):
// 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。
Threshold: a.cfg.MemoryTidy.RecallThreshold,
// 同步入库闸:`$DATA_DIR/config/memory-tidy.json` 的 `ingestGate: false`
// 一键回到「只插不审」(= 本次改动之前的异步行为)。预算与扫描上限走
// 代码默认值(闸的总墙钟预算必须有界,但它是**实现参数**不是运维旋钮)。
DisableIngestGate: gateOff,
// 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,
Audit: a.auditMemoryReview,
Log: a.log,
Judge: a.memoryT1Judge(),
})
if err != nil {
return fmt.Errorf("memory ingest review: %w", err)
}
a.memStore.AttachIngestReviewer(reviewer)
a.memoryReview = reviewer
return nil
}
// memoryIngestGateOff 报告同步入库闸是否被配置显式关掉。读不到/解不开
// `memory-tidy.json` 时返回 false(= 保持代码默认的「开」)并记 WARN ——
// 坏配置绝不能让一处防护静默失效,也绝不能让主人以为它关了却其实开着。
func (a *app) memoryIngestGateOff() bool {
tuning, path, err := a.readMemoryTidyTuning()
if err != nil {
logger.WithSource(a.log, "memory/review").Warn(
"memory ingest gate tuning unreadable, falling back to code default (enabled)",
"path", path, "error", err)
return false
}
return tuning.IngestGateOff()
}
// memoryT1JudgeOff 报告 **T1 灰区模型判定**是否被配置显式关掉。口径与
// memoryIngestGateOff 逐字相同:读不到/解不开 `memory-tidy.json` 时返回 false
// (= 保持代码默认的「开」)并记 WARN —— 坏配置绝不能让一处防护静默失效,
// 也绝不能让主人以为它关了却其实开着。
func (a *app) memoryT1JudgeOff() bool {
tuning, path, err := a.readMemoryTidyTuning()
if err != nil {
logger.WithSource(a.log, "memory/review").Warn(
"memory t1 judge tuning unreadable, falling back to code default (enabled)",
"path", path, "error", err)
return false
}
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(建
// 记忆栈,含本服务)→ wireRuntime(建模型栈)**,装配这一刻 `a.modelAdapter`
// 还是 nil。闭包把解析推迟到调用时,于是既保住了「记忆模块只拿到一个调用口、
// 拿不到模型栈」这条依赖面(§15 D3:独立会话、零上下文),又不必改动既有装配
// 顺序 —— 与 a.gw 的晚组装是同一手法(见本文件顶部)。
//
// 用的是**既有**的模型栈本身(含按角色路由与主→备→本地降级),不新造 LLM
// 客户端;`PersonaID` 留空即「无角色绑定」,由生效组的默认链服务它。模型栈仍
// 未就绪时返回 errT1ModelNotReady:那是 fail-safe(回落 create + WARN)。
//
// 读 `a.modelAdapter` 无锁是安全的:它在 wireRuntime 里被**写一次**,而任何
// 读到它的路径(审查工作协程)都在 main 里 `memoryReview.Start` 之后才开始,
// 写入严格先于读取。
func (a *app) memoryT1Judge() memory.T1Judge {
return func(ctx context.Context, req model.Request, opts model.Options) (model.Response, error) {
adapter := a.modelAdapter
if adapter == nil {
return model.Response{}, errT1ModelNotReady
}
return adapter.Chat(ctx, req, opts)
}
}
// auditMemoryReview 把一条审查结论写进网关审计链。它**只带决定、依据与 id**,
// 绝不带任何记忆正文(design §15 D3-8「记忆正文不进日志」)。
func (a *app) auditMemoryReview(who, what, result string) {
if a.gw != nil {
a.gw.AuditEvent(who, what, result)
return
}
logger.WithSource(a.log, "memory/review").Info(
"memory ingest review audit dropped: gateway not ready",
"who", who, "what", what, "result", result)
}