Files
Cyrene_v2/internal/runtime/memory/preinsert_t1.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

343 lines
18 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 (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是 D3「入库前审查」的 **T1(灰区模型判定)**(design/23 §15.1 D3-2)。
//
// 它补上 D3-2 一直缺的那一半:规则层(T0,preinsert_judge.go)判不出结论时 ——
// 候选**召回到了、互不包含、且未命中任何规则否决** —— 调**一次**轻量模型,把
// 「一律新建」变成「有依据地并入 / 改写 / 保持新建」。真机实证的那一类
// (「摄取通道验收暗号为『喵喵七号』」vs「验收暗号为『喵喵七号』,用于验证摄取
// 通道」:同一事实换措辞、互不包含)正是它要收掉的。
//
// 五条不可走样(逐条对应本目录既有的不变量,改坏任何一条都是架构偏离):
//
// 1. **只许回三态 + 理由**:`merge` / `modify` / `keep`。坏 JSON、非法枚举、
// 编号越界、任何别的词 —— 一律回落 `create`(保守,**绝不猜**)。
// 2. **禁产新内容**:模型只提供「选哪一条 + 什么动作 + 一句理由」,它的正文 /
// 摘要 / 改写文本**一个字都不采纳**。`merge` 落地时存活方是既有行(正文
// 一字不改);`modify` 落地时存活方是**新行自己的原文**、既有行置为被合并
// (§15 D3-6)—— 因此 T1 结构性地不可能把幻觉写进记忆库。
// 3. **不越硬门、不越红线**:T1 只能在 `judgeIngest` 交出来的**灰区**候选里选,
// 而灰区已经滤掉了跨作用域/跨角色/跨项目/只读来源(硬门)与时效/数值/冲突/
// 低置信(§5.2 的绝不合并清单)。`preinsert_judge.go` 文件头那条
// 「**规则否决一票否决**」是**不可走样**项,T1 不提供任何翻案入口。
// 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` 的调用口(组合根
// 装配的就是模型栈本身,含按角色路由与主→备→本地降级),本文件不认识任何
// 厂商协议,也不新增依赖。
//
// 可观测(§15 D3-8):T1 是否参与、结论是什么,在留痕 `reason` 列上如实可辨
// (`t1_merge` / `t1_modify` / `t1_keep` / `t1_reply_invalid` / `t1_unavailable` /
// `t1_throttled`),规则版本随之升到 `IngestRuleVersion`。模型的自由文本理由
// **既不落库也不落日志**:留痕表按设计不存正文,而模型的理由可能改述正文 ⇒ 一律
// 丢弃(它在 prompt 里的唯一作用,是逼模型先表态,从而把动作判得更稳)。
const (
// defaultT1MaxGray 是打包进**一次** prompt 的灰区候选上限。灰区候选按召回
// 分数降序,超出即截断(更相关的排在前面),因此截断不改变「谁最像」。
defaultT1MaxGray = 8
// defaultT1Timeout 是单次 T1 模型调用的墙钟上限。它比单条审查的总预算
// (defaultReviewTimeout = 20s)**小**:T1 是审查里的一步,不是审查本身,
// 它的超时只该让这一步失败并回落 create,不该吃掉整条审查的预算。
defaultT1Timeout = 8 * time.Second
// t1Purpose 是该次模型调用的计账标签(model.Request.Purpose),
// 让用量面板能把「灰区判定」与「对话」分开算账。
t1Purpose = "memory_t1_judge"
// T1 允许的动作枚举(模型只许回这三个,别的一律回落 create)。
t1ActionMerge = "merge"
t1ActionModify = "modify"
t1ActionKeep = "keep"
// T1 的留痕原因码(进 `reason` 列,结构化、可机器判定、与既有码同族)。
t1ReasonMerge = "t1_merge"
t1ReasonModify = "t1_modify"
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 是模型回复解不出三态枚举(或候选编号越界)时的分类错误。
// 它不是「模型挂了」,而是「这一次的回复不能用」—— 两者的回落结果相同
// (create),但留痕原因码不同,运维才分得清该修 prompt 还是该查网络。
var ErrT1ReplyInvalid = errors.New("memory/review: t1 reply is not a valid action")
// T1Judge 是 T1 的一次性模型调用口。它是 `model.Adapter` 的**单方法视图**
// (只取 Chat):T1 只要一次非流式补全,不流式、不重试、不并发 —— 窄接口让
// 这三条成为结构性事实,也让测试注入桩不必实现用不到的方法。
//
// 它是一个函数类型而不是接口:与 ReviewDeps 既有的 Now / Audit / GateCandidates
// 同一形状,且组合根因此可以用**闭包**把「模型栈晚于本服务装配」这件事就地解决
// (见 cmd/cyrene/wire_memory_review.go 的 memoryT1Judge)。
type T1Judge func(ctx context.Context, req model.Request, opts model.Options) (model.Response, error)
// t1Reply 是 T1 要求的线上形状:动作 + 候选编号(1 起;keep 写 0)+ 一句理由。
// 编号而不是 id 是刻意的 —— 模型不该、也无法**编造**一个记忆 id。
type t1Reply struct {
Action string `json:"action"`
Target int `json:"target"`
Reason string `json:"reason"`
}
// t1Enabled 报告 T1 是否可用并已开启:总开关开着、T1 没被显式关掉、
// 且模型出口**已接**。少任何一条都恒为零模型调用 —— 这就是
// 「T1 关闭 ⇒ 零模型调用」从承诺变成结构的那条线。
func (r *PreInsertReviewer) t1Enabled() bool {
return r != nil && r.cfg.Enabled && !r.cfg.DisableT1 && r.deps.Judge != nil
}
// t1Timeout 返回单次 T1 调用的墙钟上限:非正取代码默认,且**不超过**单条审查
// 的总预算(T1 超时只该让这一步回落,不该把审查本身拖长)。
func (r *PreInsertReviewer) t1Timeout() time.Duration {
d := r.cfg.T1Timeout
if d <= 0 {
d = defaultT1Timeout
}
if r.cfg.Timeout > 0 && d > r.cfg.Timeout {
return r.cfg.Timeout
}
return d
}
// maybeJudgeT1 在「T0 判不出结论 + 有灰区候选」时,用**一次**模型调用改判。
//
// 任何非「模型给出合法三态」的情形都**原样返回 v**:未启用、没有模型出口、
// 没有灰区候选、**全局额度用尽(限流)**、调用超时/报错、回复非法。因此
// 「失败/限流 ⇒ 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]
}
reply, err := r.callT1(ctx, item, gray)
if err != nil {
// 区分「模型没答上」(超时/网络/上游错)与「答了但回复不能用」:
// 两者都回落 create,但留痕原因码必须分开,运维才分得清该查网络
// 还是该修 prompt。
if errors.Is(err, ErrT1ReplyInvalid) {
return r.t1Fallback(ctx, item, v, t1ReasonReplyInvalid, err)
}
return r.t1Fallback(ctx, item, v, t1ReasonUnavailable, err)
}
verdict, ok := t1Verdict(reply, item, gray, v)
if !ok {
return r.t1Fallback(ctx, item, v, t1ReasonReplyInvalid, ErrT1ReplyInvalid)
}
r.bump(func(s *ReviewStats) { s.T1Decided++ })
return verdict
}
// 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)
defer cancel()
r.bump(func(s *ReviewStats) { s.T1Called++ })
resp, err := r.deps.Judge(cctx, model.Request{
Messages: buildT1Prompt(item, gray),
Purpose: t1Purpose,
}, model.Options{Timeout: budget})
if err != nil {
return t1Reply{}, fmt.Errorf("memory/review: t1 judge call: %w", err)
}
return parseT1Reply(resp.Content)
}
// t1Fallback 组装回落结论:决定**仍然是 create**(与 T0 逐字同结论),只把
// `reason` 换成如实的原因码,并记一条 WARN。
//
// 为什么失败也要改 reason:留痕要能回答「这一次 T1 到底有没有参与」
// (§15 D3-8)。把失败悄悄写成 T0 的原因码,等于让 T1 的缺席与失败不可分辨。
// 日志只带 id / 作用域 / 原因 / 错误,**绝不带记忆正文**。
func (r *PreInsertReviewer) t1Fallback(ctx context.Context, item MemoryItem, v ingestVerdict, reason string, cause error) ingestVerdict {
v.Reason = reason
r.log.Warn("memory ingest review t1 judge fell back to create",
"item_id", item.ID, "scope", string(item.Scope), "reason", reason,
"canceled", ctx.Err() != nil, "error", cause)
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) {
action := strings.ToLower(strings.TrimSpace(reply.Action))
if action == t1ActionKeep {
// 保持新建:结论与 T0 完全一致(create),只留下「T1 看过并判 keep」
// 这条证据 —— 它与「压根没调模型」是两件事。
base.Reason = t1ReasonKeep
base.Gray = nil
return base, true
}
if action != t1ActionMerge && action != t1ActionModify {
return ingestVerdict{}, false
}
if reply.Target < 1 || reply.Target > len(gray) {
return ingestVerdict{}, false
}
c := gray[reply.Target-1]
if action == t1ActionMerge {
// 并入既有:存活方是既有行(正文一字不改),被吸收的是刚落库的新行。
return ingestVerdict{
Decision: IngestMerge, Existing: c.item, Absorbed: item,
Score: c.score, Mode: c.mode, Reason: t1ReasonMerge,
}, true
}
// 改写既有(§15 D3-6):**新行承载更全的内容**、既有行置为被合并。
// 两行的正文都取自各自的既有原文 —— 模型给不出任何正文。
return ingestVerdict{
Decision: IngestModify, Existing: c.item, Absorbed: c.item,
Score: c.score, Mode: c.mode, Reason: t1ReasonModify,
}, true
}
// buildT1Prompt 渲染这一次判定的两段消息:固定指令 + 「新记忆 vs 编号候选」。
// 候选用**编号**(1 起)而不是 id:模型编不出、也不需要记忆 id。
func buildT1Prompt(item MemoryItem, gray []ingestCandidate) []model.Message {
var b strings.Builder
b.WriteString("新记忆:")
b.WriteString(item.Content)
b.WriteString("\n\n既有候选:\n")
for i, c := range gray {
fmt.Fprintf(&b, "%d. %s\n", i+1, c.item.Content)
}
return []model.Message{
model.TextMessage(model.RoleSystem, t1SystemPrompt),
model.TextMessage(model.RoleUser, b.String()),
}
}
// t1SystemPrompt 是 T1 的固定指令。它把三件事写死:
// - 只许回三态 + 一句理由(别的词一律不许);
// - **禁产新内容**(不许输出记忆正文/摘要/改写文本);
// - 判据与**负例**(design/23 §15 D3-2「负例进 prompt」,Mem0 口径):
// 「相似但并列」必须 keep,拿不准必须 keep。
//
// 负例刻意取自 design/23 §1.3 与 §5.2 的点名反例(红牛 vs 冰美式是「同主题但
// 不同事实」,X vs 戒了X 是「新事实覆盖旧事实」),它们是 T1 最容易被相似度
// 带偏的两处。
const t1SystemPrompt = `你是记忆库的入库审查员。给你一条刚入库的新记忆,以及同一分区里若干条既有记忆候选。
判断新记忆与既有候选的关系,只回一个 JSON 对象,不要任何其他文字、不要代码块。
动作只许取这三个值之一:
- "merge":新记忆与某条既有记忆说的是**同一件事、只是换了措辞**,且新记忆没有那条既有记忆之外的任何新信息 ⇒ 新记忆并入那条既有记忆(既有记忆的原文保持不变)。
- "modify":某条既有记忆是**新记忆的不完整版本**(新记忆覆盖它、并含它没有的细节)⇒ 那条既有记忆退役,由新记忆承载。
- "keep":其余一切情形(不同事实、不同偏好、不同对象、只是同一主题)⇒ 新记忆独立保留。
必须遵守的判据:
- 只有「同一件事换措辞」才可以 merge;「相似但并列」的两件不同事实必须 keep。反例:"主人喜欢喝红牛来提神" 与 "主人喜欢喝冰美式来提神" 是两种不同的偏好,必须 keep,即便它们很相似。
- 一方带否定、另一方没有("X" 与 "已经戒了 X"),那是新事实覆盖旧事实、不是重复,必须 keep。
- 描述某一次具体事件、或带具体日期/时间的记忆,不要与长期习惯互相合并,必须 keep。
- 只是「都在说同一个领域」不算同一件事。
- **拿不准就 keep**。宁可多留一条,也绝不把两件不同的事并成一条。
- **绝对不要输出任何记忆正文、摘要或改写文本**,只回动作、候选编号和一句理由。
输出格式(严格 JSON,无代码块):{"action":"merge 或 modify 或 keep","target":候选编号,"reason":"一句话理由"}
keep 时 target 写 0。`
// parseT1Reply 解析模型的 JSON 回复。被围栏包裹的 ```json 块会被剥离,前后夹带
// 的解说文字也被忽略(只取第一个 `{` 到最后一个 `}`)—— 容忍格式噪声,但**不猜**:
// 仍取不到 JSON 对象就返回 ErrT1ReplyInvalid,由调用方回落 create。
//
// 错误里**不带模型原文**:回复可能改述记忆正文,而错误会被记进日志。
func parseT1Reply(s string) (t1Reply, error) {
body, ok := extractT1JSON(s)
if !ok {
return t1Reply{}, fmt.Errorf("%w: no json object in reply", ErrT1ReplyInvalid)
}
var reply t1Reply
if err := json.Unmarshal([]byte(body), &reply); err != nil {
return t1Reply{}, fmt.Errorf("%w: decode: %v", ErrT1ReplyInvalid, err)
}
return reply, nil
}
// extractT1JSON 从模型回复里取出 JSON 对象文本:先剥 ``` 围栏,再取第一个 `{`
// 到最后一个 `}`。取不到成对花括号即视为格式非法。
//
// 开头的 `[` 也是非法:本协议的线上形状是**对象**,而"取第一个 `{` 到最后一个
// `}`"会把 `[{"action":"merge"}]` 这类数组回复里嵌的对象抠出来当成合法作答 ——
// 那正是「解析不出枚举就必须回落」要挡住的情形,因此显式拒掉。
func extractT1JSON(s string) (string, bool) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "json")
s = strings.TrimSpace(s)
}
if strings.HasPrefix(s, "[") {
return "", false
}
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end <= start {
return "", false
}
return s[start : end+1], true
}