主人 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 包。
343 lines
18 KiB
Go
343 lines
18 KiB
Go
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
|
||
}
|