主人 2026-10-07 两条口径:「.env 只做兜底用,后端自动创建独立的面板配置文件
之后就走面板配置文件,这些键优先从面板配置上取」+「频率必须调,可以调到 1 小时
或者三小时,或者动态分配(白天短、静默期长)」。
此前 $DATA_DIR/config/memory-tidy.json 只有读路径(装配期读一次 + 启动播种):
主人要改频率只能改文件 + 重启后端。本切片补上「读回来 → 改 → 落盘 → 当场生效」。
后端(读写端点,契约待塔登记 v0.92):
- GET /api/v1/admin/memories/tidy/tuning 读盘上 7 键值 + 取值规则 specs +
缺位时 .env 档的进程生效值(与 /admin/config-facts 同源)
- PUT 同路径:三态补丁(键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上一
个字节都不动)+ null 清除哨兵(删键 ⇒ 回到下一档)+ writeAuth + 审计
memory.tidy.tuning;值一个都没变时不落盘,文件解不开时拒绝覆盖(409)
- 落盘走 runtimeconfig.PatchMemoryTidyTuning(原子写 temp+rename,与
SeedMemoryTidy/SaveWebSearch 同族同手法);补丁语义保留下受管的其它键
(t1Judge/t1PerWindow/autoApplyPerRound 原样保留)
- 热生效:保存后按「面板 > .env > 代码默认」重算并叠到运行期对象;整理档会
重排心跳(否则新周期要等旧周期那一觉睡完),审查档当场改 ingestGate/tempTtlSec。
未接线/失败时如实回 hot:false + hotError,绝不把「落盘了」说成「生效了」
前端(Miuix):
- 记忆管理页新增「记忆整理调优」卡:7 键表单(中文标签、单位、留空⇒跟随谁、
env 档当前值、坏值就地报错并拦住保存、保存后回显从磁盘读回的真值)
- 试算卡的 recallMode 按契约 v0.91 如实渲染:embedding / lexical(已降级)/
mixed(部分分区降级)/ ""(无候选可比)——空串不再渲染成空标签;并把
error.code=tidy_recall_unavailable 单列一句(「尝试过但向量源不可用」,与
「没有候选可比」显式区分)
验证:go test ./internal/{services/gateway,runtime/memory,config/runtimeconfig}
全绿;三态/清除/坏文件拒写/同值不重写/热生效接线各有用例,并逐条做过变异自证;
tsc --noEmit + vite build 通过;两个 esbuild 前端测试 39 + 34 + 41 checks 通过。
144 lines
7.2 KiB
Go
144 lines
7.2 KiB
Go
package memory
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
)
|
||
|
||
// 本文件是**整理自动档的运行期调优面**(切片 MEMPANEL / M4):把
|
||
// `$DATA_DIR/config/memory-tidy.json` 的面板旋钮**当场**叠到已经在跑的 TidyService 上,
|
||
// 使主人点一次「保存」就生效,而不必重启后端。
|
||
//
|
||
// 为什么需要它(主人 2026-10-07 两条口径,逐字):
|
||
//
|
||
// 「`.env` 只做兜底用 在除了第1次部署之后 后端自动创建 独立的面板配置文件之后就走
|
||
// 面板配置文件。这些键优先从面板配置上取」
|
||
// 「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短一点 晚上到
|
||
// 了静默期稍微长一点」
|
||
//
|
||
// 在这之前,面板文件只在**装配期**被读一次(`wireMemoryTidy` → `NewTidyService`),
|
||
// 四个调度旋钮与 autoApply 被**拷进**服务的 `cfg` 字段后就不再变 —— 面板改完文件,
|
||
// 跑着的进程一个字节都不会变。
|
||
//
|
||
// 三条不可走样的口径:
|
||
//
|
||
// 1. **重算而不是叠加**:`base` 是 `.env` 层(由组合根用 `TidyConfigFromConfig`
|
||
// 从生效配置投影而来),`panel` 是刚落盘的那一份 ⇒ 结果是「面板 > `.env` >
|
||
// 代码默认」。**不从当前 cfg 上叠**:主人把某个键清掉(`null`)时必须回到
|
||
// `.env` 档,而不是留在上一次的面板值上。
|
||
// 2. **心跳要重排**:心跳正睡在「用**旧**周期算出来」的那一觉里(`nextDelay` 只在
|
||
// 每一拍之前现算),最长可到旧周期。不重排的话,主人把周期从 24 小时改成 3 小时
|
||
// 时,新周期要等那一觉睡完才生效 —— 那等于「改了跟没改一样」。因此这里 Stop +
|
||
// Start 把当前那次等待作废,并按新配置重新排一次(`catchUp` 会立刻复核「是不是
|
||
// 已经到点」,于是缩短周期往往**当场**补跑一轮)。
|
||
// 3. **失败不吞值**:重排失败(正在跑一轮、排空超时)时**值照样落到运行期**(下一次
|
||
// 调度判定就用它),并把失败如实回给调用方 —— 绝不假装热生效,也绝不让保存白写。
|
||
//
|
||
// 并发口径(为什么这样写是安全的):`s.runMu` 串行化**所有**轮次(心跳轮 `runRound`
|
||
// 与手动轮 / 试算 `DryRun` 都持它),而两个字段的读者只有这些轮次;因此「持 runMu →
|
||
// Stop 心跳 → 写 cfg → Start」这一串之后,没有任何 goroutine 会在写入的同时读这两个
|
||
// 字段。心跳 goroutine 的调度读(`nextDelay` / `dueNow` 读 `s.cfg`)在 Stop 返回后
|
||
// 已经全部结束,因此也不与写入交手。
|
||
|
||
// memoryTidyRescheduleTimeout 是重排心跳时「等正在跑的那一轮跑完」的上限。
|
||
//
|
||
// 它存在的理由:`Stop` 要等 wg 排空(可能正有一轮在扫描几百条记忆)。给上限是为了
|
||
// 不让一次面板保存把 HTTP 请求挂到天荒地老;超时**如实报错**(值照旧落到运行期),
|
||
// 绝不静默假装重排成功。
|
||
const memoryTidyRescheduleTimeout = 5 * time.Second
|
||
|
||
// ErrTidyRescheduleTimeout 在重排心跳时没能等正在跑的那一轮排空时返回(值已生效于
|
||
// 下一次调度判定,只是当前那次等待没有被作废)。
|
||
var ErrTidyRescheduleTimeout = errors.New("memory/tidy: heartbeat reschedule timed out")
|
||
|
||
// ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。
|
||
//
|
||
// 返回 nil 表示**值已生效、心跳已按新周期重排**;返回错误时**值同样已生效**,只是
|
||
// 心跳这次没排成(错误文案里说清是哪一种),调用方(网关)据此如实回 `hot:false`。
|
||
func (s *TidyService) ApplyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning) error {
|
||
next := tidyPanelTuning(base, panel, s.log)
|
||
|
||
// 只把 runMu 拿住 —— 它同时排除「正在跑的一轮」与「刚要开跑的一轮」。注意
|
||
// 顺序:**先拿 runMu 再 Stop**。反过来的话,心跳若正卡在 runMu 上等开跑,Stop
|
||
// 会等一个永远等不到退出的 goroutine(它被我抱着锁挡在门外)。
|
||
s.runMu.Lock()
|
||
defer s.runMu.Unlock()
|
||
|
||
s.mu.Lock()
|
||
unchanged := s.cfg == next
|
||
s.mu.Unlock()
|
||
|
||
if unchanged {
|
||
// 面板这次只改了别的键(如 ingestGate):处理配置根本没变 ⇒ 不打扰心跳。
|
||
s.applyTidyConfigLocked(next)
|
||
return nil
|
||
}
|
||
|
||
reschedErr := s.rescheduleTidyHeartbeat()
|
||
s.applyTidyConfigLocked(next)
|
||
return reschedErr
|
||
}
|
||
|
||
// tidyPanelTuning 是「面板 > `.env` > 代码默认」的**重算点**(口径 1)。
|
||
//
|
||
// 它与组合根 `wireMemoryTidy` 里的那一段是**同一条链**(base → autoApply 两个键 →
|
||
// ApplyTidyScheduleTuning → normalizeTidyConfig),只是可以随时再跑一次。刻意做成
|
||
// 一个函数而不是把两处各写一遍逻辑,是为了让「优先级链」只有一份可读的写法:
|
||
// 将来把组合根那一段改成调用它,两处就彻底合一(组合根那个文件不在本切片 scope 内,
|
||
// 因此这里先留一个等价的实现,并已在回报里登记)。
|
||
func tidyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
|
||
cfg := base
|
||
cfg.DisableAutoApply = panel.AutoApplyOff()
|
||
if panel.AutoApplyPerRound > 0 {
|
||
cfg.AutoApplyPerRound = panel.AutoApplyPerRound
|
||
}
|
||
cfg = ApplyTidyScheduleTuning(cfg, panel, log)
|
||
return normalizeTidyConfig(cfg)
|
||
}
|
||
|
||
// applyTidyConfigLocked 把新配置落到**两个**持有者上(调用方必须已持 runMu,并已让
|
||
// 心跳停下):
|
||
// - `s.cfg` 是调度判据(`intervalAt` / `inQuietHours`)的来源;
|
||
// - `s.tidier.cfg` 是每一轮实际用的阈值/上限/自动档开关的来源。
|
||
//
|
||
// 两处都要写:只写前者 ⇒ 轮次仍用旧阈值;只写后者 ⇒ 调度仍按旧周期。
|
||
func (s *TidyService) applyTidyConfigLocked(cfg TidyConfig) {
|
||
s.mu.Lock()
|
||
s.cfg = cfg
|
||
s.mu.Unlock()
|
||
s.tidier.cfg = cfg
|
||
}
|
||
|
||
// rescheduleTidyHeartbeat 把当前那次等待作废,并按**新配置**重新排一次心跳。
|
||
//
|
||
// 未在跑(未 Start / 已 Stop)时是空操作。排空超时时**仍把心跳挂回去**:`Stop` 已经
|
||
// 把 running 置为 false,不重启就等于让定时整理**悄悄停摆**(本项目明令禁止的静默
|
||
// 失效形态);旧的那只 goroutine 会在当前这一轮跑完后自行退出。
|
||
//
|
||
// 重排用的父 context 是 Background:本对象没有保存父 ctx 的字段(那在
|
||
// tidy_service.go 里,不在本切片 scope 内),而关闭路径本来就由组合根显式 `Stop`
|
||
// 收尾(见 wire_services.go),因此不依赖父 ctx 取消。
|
||
func (s *TidyService) rescheduleTidyHeartbeat() error {
|
||
s.mu.Lock()
|
||
running := s.running
|
||
s.mu.Unlock()
|
||
if !running {
|
||
return nil
|
||
}
|
||
stopCtx, cancel := context.WithTimeout(context.Background(), memoryTidyRescheduleTimeout)
|
||
defer cancel()
|
||
if err := s.Stop(stopCtx); err != nil {
|
||
s.log.Warn("memory tidy tuning applied, but heartbeat reschedule timed out",
|
||
"timeout", memoryTidyRescheduleTimeout.String(), "error", err)
|
||
s.Start(context.Background())
|
||
return fmt.Errorf("%w: %v", ErrTidyRescheduleTimeout, err)
|
||
}
|
||
s.Start(context.Background())
|
||
return nil
|
||
}
|