Files
Cyrene_v2/internal/config/runtimeconfig/memorytidy.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

162 lines
9.2 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.
// memorytidy.go 是记忆整理(design/23)**运行期调优**的可变源:
// `$DATA_DIR/config/memory-tidy.json`(design/13 §2.1「运行期生成的配置进
// `$DATA_DIR/config/`,新增配置家族从一开始就只写这里,不落老位置」)。
//
// 为什么需要它(2026-10-07 主人「那么多 link 是干什么的?该删就删,怎么这个记忆
// 整理机制这么保守呢」):轮末自动落地是一件**改记忆**的动作,它必须有一个
// 「一键回到只产建议」的开关与一个每轮上限,而 `.env` 已被主人冻结
// (`design/13 §2.1`:调优/门槛/上限/开关类参数走 `$DATA_DIR/config/**` + 代码
// 默认值,不进 `.env`)。于是这两项落在这里,**零新 env 键**。
//
// 2026-10-07 追加 `ingestGate`(同步入库闸,主人「把记忆整理功能接到昔涟和记忆库
// 中间做审查,由整理功能进行入库」):同一个理由 —— 闸是一处**改记忆落库形态**的
// 开关,必须能一键回到「只插不审」,而 `.env` 已冻结 ⇒ 它落在同一个文件里,
// 不新开文件家族、不加 env 键。
//
// 2026-10-07 再追加 `t1Judge`(T1 灰区模型判定,design/23 §15.1 D3-2):理由仍然
// 是同一条 —— 它是一处**会花掉模型额度**的开关,必须能一行配置关掉(关掉即回落
// 纯规则、零模型调用),所以同样落在这里,不新开文件家族、不加 env 键。
//
// 2026-10-07 同日追加 `t1PerWindow` / `t1WindowSec`(T1 的**全局限速**,
// design/23 §15.4 主人裁定「加一层保守的全局速率限流」):T1 默认开,每次「有灰区
// 候选的审查」就发一次调用,现网每天数百到上千条新记忆 ⇒ 那是一处**没有刹车**的
// 额度出口。额度必须能按现网节奏调(且不许为它开 env 键),因此落在这里;
// **零值不表示「无限制」**,只表示「没配、用代码默认值」。
//
// 边界与 backthink.go 同源(那是同一族的手续):
//
// 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落
// 代码默认值」那条裁决 —— 那是 `memory.normalizeTidyConfig` 的事。
// 2. **只读**:面板写路径不在本切片内,因此刻意没有 `Save*`。改动仍要
// **人手改这个文件 + 重启**(与 `backthink.json` 同口径:启动期读一次)。
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
//
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开
// = 返回错误,调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关
// 或上限读成「无限制」)。
package runtimeconfig
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// memoryTidyFileName 是文件名(家族命名 = 服务/模块名 + .json)。
memoryTidyFileName = "memory-tidy.json"
// memoryTidyConfigSubdir 是落点的第一段目录(`$DATA_DIR/config/`)。与
// backthink 各自声明一份而不是共用一个常量:某一族换位置时,共用常量会让
// 另一处**静默**跟着搬家。
memoryTidyConfigSubdir = "config"
)
// MemoryTidyConfigRelPath 是落点相对 `$DATA_DIR` 的路径(`config/memory-tidy.json`)。
// 导出它是为了让面板/文档能**如实**告诉主人改哪个文件,而不必把部署相关的绝对路径
// 塞进接口形状。
const MemoryTidyConfigRelPath = memoryTidyConfigSubdir + "/" + memoryTidyFileName
// ErrInvalidMemoryTidy 是 memory-tidy.json 结构不合法时的分类错误(errors.Is 可判类)。
var ErrInvalidMemoryTidy = errors.New("runtimeconfig: invalid memory tidy config")
// MemoryTidyTuning 是 `memory-tidy.json` 的形状:记忆模块运行期的旋钮。
// 文件以「整理」命名,但它是**记忆模块运行期调优的同一处落点**(整理自动档 +
// 入库闸 + T1 灰区判定),不再为一个开关新开一个文件家族。
//
// AutoApply / IngestGate / T1Judge 用**指针**是刻意的:它们的代码默认值都是「开」,而
// JSON 的 `bool` 零值也是 false —— 用 bool 会让「这个键没写」与「显式写
// false」不可区分,于是任何只写上限的文件都会**静默把开关关掉**。指针让
// 「没写」= nil = 不覆盖默认。
//
// 字段名是 camelCase 的 JSON 键(文件是给人看、给人改的)。
type MemoryTidyTuning struct {
// AutoApply 控制轮末自动落地。nil = 没配(用代码默认值:开);false = 一键
// 回到「只产建议」;true = 显式开(与默认同义,写它只是为了自解释)。
AutoApply *bool `json:"autoApply"`
// AutoApplyPerRound 是每轮自动落地的条数上限。零/负 = 没配 ⇒ 用代码默认值
// (**没有「无上限」语义**,最终还会被硬顶钳位)。
AutoApplyPerRound int `json:"autoApplyPerRound"`
// IngestGate 控制**同步入库闸**(D3 审查接在写入路径上当场拍板,主人
// 2026-10-07 裁定)。nil = 没配(用代码默认值:开);false = 一键回到
// 「只插不审」(= 改动之前的异步行为);true = 显式开(与默认同义)。
IngestGate *bool `json:"ingestGate"`
// T1Judge 控制 **T1 灰区模型判定**(design/23 §15.1 D3-2:灰区 —— 相似
// 但互不包含 —— 调一次轻量模型,只许回 `merge`/`modify`/`keep` + 理由、
// **禁产新内容**)。nil = 没配(用代码默认值:开);false = 一键回到纯规则
// (**零模型调用**,灰区仍然保守回落新建);true = 显式开(与默认同义)。
//
// 2026-10-07 追加,与 IngestGate 同一个文件、同一个理由:它是一处**会花掉
// 模型额度**的开关,必须能一行配置关掉,而 `.env` 已冻结(零新 env 键)。
// 指针也是同一个理由:代码默认值是「开」,用 bool 会让「这个键没写」与
// 「显式写 false」不可区分。
T1Judge *bool `json:"t1Judge"`
// T1PerWindow 是 T1 的**全局**窗口额度(次数)—— 全进程共享一个窗口,超限
// 即跳过 T1 并回落 `create`(`reason=t1_throttled`,design/23 §15.4)。
// 零/负 = 没配 ⇒ 用代码默认值(memory 侧的 defaultT1PerWindow)。
//
// **刻意没有「无上限」语义**(与 autoApplyPerRound 同一口径):≤0 一律回落
// 默认额 —— 「不限流」不该是敲一个 0 就能得到的状态(design/12 §N C.5 G3
// 登记过那条静默失败形态)。要完全不花模型额度,请关 `t1Judge`。
T1PerWindow int `json:"t1PerWindow"`
// T1WindowSec 是上面那个额度的时间窗(**秒**)。零/负 = 没配 ⇒ 用代码默认值
// (memory 侧的 defaultT1Window)。
//
// 用秒而不是 Go 的时长字面量(`"1h"`)是刻意的:这个文件是给人改的,写
// `3600` 比要求主人记住 Go 的时长语法更不容易写错,也与既有
// `$DATA_DIR/config/backthink.json` 的 `*_SEC` 口径一致。
T1WindowSec int `json:"t1WindowSec"`
}
// AutoApplyOff 报告自动档是否被这份配置**显式关掉**。nil(没配)返回 false ——
// 「没配」不等于「关掉」,那正是指针存在的理由。
func (t MemoryTidyTuning) AutoApplyOff() bool {
return t.AutoApply != nil && !*t.AutoApply
}
// IngestGateOff 报告**同步入库闸**是否被这份配置显式关掉(口径同 AutoApplyOff:
// nil 是「没配」⇒ 用代码默认的「开」,不是「关」)。
func (t MemoryTidyTuning) IngestGateOff() bool {
return t.IngestGate != nil && !*t.IngestGate
}
// T1JudgeOff 报告 **T1 灰区模型判定**是否被这份配置显式关掉(口径同 AutoApplyOff:
// nil 是「没配」⇒ 用代码默认的「开」,不是「关」)。
func (t MemoryTidyTuning) T1JudgeOff() bool {
return t.T1Judge != nil && !*t.T1Judge
}
// DefaultMemoryTidyPath 返回记忆整理调优的落点:`$DATA_DIR/config/memory-tidy.json`。
func DefaultMemoryTidyPath(dataDir string) string {
return filepath.Join(dataDir, memoryTidyConfigSubdir, memoryTidyFileName)
}
// LoadMemoryTidyTuning 读取 memory-tidy.json(**只读**)。
//
// 文件缺失或全空白 ⇒ 零值 + nil:调用方用代码默认值(normalizeTidyConfig 会把
// 非正的上限回落默认),因此「没配过」与「配成 0」得到同一个结果。
//
// 存在但解不开 ⇒ 返回错误。调用方应记一条带 source 的 WARN 并回落默认;
// **不得覆盖**那个文件(损坏的文件可以人工救回,程序盖掉它就丢了人工写的意图)。
//
// 它不建目录、不读老位置:新家族没有老位置(design/13 §2.1 第 5 条)。
func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return MemoryTidyTuning{}, nil
}
return MemoryTidyTuning{}, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
}
if strings.TrimSpace(string(raw)) == "" {
return MemoryTidyTuning{}, nil
}
var t MemoryTidyTuning
if err := json.Unmarshal(raw, &t); err != nil {
return MemoryTidyTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidMemoryTidy, path, err)
}
return t, nil
}