fix(channel/flowgate): 审查移出准入路径、节流覆盖失败路径、BOM 解析韧性
- Admit 不再同步调用审查模型:触发时只把审查**派发**到后台 goroutine,准入只读 当前已有结论。三重上界:同一适配器至多一次在飞(reviewInFlight)、同一会话每窗口 至多一次(lastReview)、整个适配器每窗口至多 defaultReviewPerWindow=6 次(reviewHits, 写成可测断言)。 - st.lastReview 改为在**派发时**置位 ⇒ 成功与失败两条路径同样被节流。修复前它只在 err == nil 分支置位,而实测 68% 的审查因对端回带 BOM 解析失败 ⇒ 缓冲满后每条入站帧 都付一次 ~6s 的模型调用(qq-ws/kook-ws/weixin-poll 三路单读 goroutine 全被钉住)。 - 新增 adapter.ParseReviewIndices:容忍 UTF-8 BOM、首尾空白/换行、Markdown 代码围栏、 数组外解说文字;越界/重复编号照旧丢弃。channelbridge.parseKeepIndices 改为委派它 (仅函数体 + 注释,未动限流/丢弃/审查触发语义)。 - 语义差异(已写进 flowgate_review.go 文件头):rate_limited 的门口丢与 FIFO 上限 一字不改;审查修剪降级为后台积压去噪,只修剪它当时看到的那批历史条目 —— review_pruned 不再从门口丢消息(实测 41–74 条/日),完整性↑、噪声↑,是否开口仍由 engage_judge/冷避/owner 闸判(三处判据未动)。 - cmd/cyrene:逐账号启动日志如实报出生效的入口流控(塔裁决 A:零行为改动、不翻默认), 让「没配流控键的账号闸门仍开着」一眼可见。 - 测试:4s 慢审查替身证明入站帧不被阻塞(变异回同步 ⇒ 红)、节流在失败路径生效 (摘掉派发时置位 ⇒ 红)、每适配器窗口预算上界(摘掉预算 ⇒ 红)、同适配器单飞 (摘掉 in-flight ⇒ 红)、在飞期间到达的消息不被连带修剪、带 BOM 字节的夹具 (夹具去掉 BOM ⇒ 红)。闸门用例 -race 通过。
This commit is contained in:
1 parent
4ed5925296
commit
76d972f0f2
8 files changed
+1043
-150
No files matched your search
+63
-2
@@ -90,11 +90,62 @@ func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log *
|
||||
log.Warn("channel account build skipped", "platform", platform, "account", acct.ID, "error", berr)
|
||||
continue
|
||||
}
|
||||
logFlowControl(log, built)
|
||||
out = append(out, built)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// logFlowControl 逐账号打一条启动日志,如实报出这个账号此刻被限成什么样(M24 裁决 A):
|
||||
// 闸是否开、每群几条/分、待处理上限、审查触发水位(reviewAt)。
|
||||
//
|
||||
// 为什么需要它:namedAdapter 的 Config() 无条件暴露四个流控键(含零值),桥的 cfgInt
|
||||
// 因此永远认为「键存在」,flowOrDefault 再把 0 映射成保护性默认 —— 于是「一个流控键都
|
||||
// 没写的账号,闸门仍然是开着的」。这条事实必须一眼可见,否则「群里为什么有消息被丢」
|
||||
// 只能靠读代码解释。判据:只看这一行日志就能说出这个群的限流/审查额度。
|
||||
//
|
||||
// 判定依据是**构建出来的那个适配器本身**,不是重新读一遍配置:非 namedAdapter 的实例
|
||||
// 不暴露流控键,桥按 flowgate.go「键缺失 ⇒ 该限制关闭」处理;这时必须如实报 off,
|
||||
// 不能拿保护性默认值假装成「开着」。
|
||||
func logFlowControl(log *slog.Logger, built adapter.PlatformAdapter) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
log = logger.WithSource(log, "cmd/cyrene/channels")
|
||||
n, ok := built.(*namedAdapter)
|
||||
if !ok {
|
||||
log.Info("channel account ingress flow control",
|
||||
"account", built.Name(), "gate", "off",
|
||||
"why", "adapter exposes no flow keys, every limit stays disabled")
|
||||
return
|
||||
}
|
||||
group := flowLimitOrDefault(n.groupMsgPerMin, adapter.FlowLimitDefault)
|
||||
direct := flowLimitOrDefault(n.directMsgPerMin, adapter.FlowDirectDefault)
|
||||
pending := flowLimitOrDefault(n.maxMessages, adapter.FlowCapDefault)
|
||||
log.Info("channel account ingress flow control",
|
||||
"account", n.Name(), "gate", "on",
|
||||
"group_msg_per_min", group, "group_msg_per_min_raw", n.groupMsgPerMin,
|
||||
"direct_msg_per_min", direct, "direct_msg_per_min_raw", n.directMsgPerMin,
|
||||
"msg_per_min", max(n.msgPerMin, 0),
|
||||
"max_messages", pending, "max_messages_raw", n.maxMessages,
|
||||
"review_at", adapter.ReviewAtFor(pending),
|
||||
"note", "raw 0 (or absent in channels.json) takes the protective default; negative disables")
|
||||
}
|
||||
|
||||
// flowLimitOrDefault 把账号原始值映射为实际生效值:负值关闭该限制、零值取保护性默认、
|
||||
// 正值固定。它与 internal/services/channelbridge/flowgate.go 的 flowOrDefault 逐字同
|
||||
// 口径,这里只用于上面那条启动日志 —— 日志必须报生效值,不能报原始值。
|
||||
func flowLimitOrDefault(v, def int) int {
|
||||
switch {
|
||||
case v < 0:
|
||||
return 0
|
||||
case v == 0:
|
||||
return def
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// channelAccountsFromEnv 合成 env 兜底:单账号 CHANNEL_<NAME>_* 适配器,加上
|
||||
// CHANNELS_JSON 里的多账号条目。它是 channels.json 缺失或非法时传给
|
||||
// runtimeconfig.LoadChannels 的 .env 兜底。.env 只承载必要的连接兜底
|
||||
@@ -174,8 +225,18 @@ type namedAdapter struct {
|
||||
denyGroups []string
|
||||
allowUsers []string
|
||||
denyUsers []string
|
||||
// 流控限制(design/12 §6 策略三/四)。它们始终经 Config() 暴露,使桥能构建按账号
|
||||
// 的入口闸门;桥把零值默认映射为保护性取值,把负值映射为「关闭」。
|
||||
// 流控限制(design/12 §6 策略三/四)。**四个键始终经 Config() 暴露,包括零值**:
|
||||
// 桥的 cfgInt 因此永远认为「键存在」,flowOrDefault 于是把 0 映射成保护性默认
|
||||
// (每群 6 条/分、待处理上限 50、reviewAt = 40)—— 一个在 channels.json 里一个
|
||||
// 流控键都没写的账号,闸门**仍然是开着的**(M24 只读勘测实测:现存 qq 账号就是
|
||||
// 这种状态)。负值经桥映射为「关闭」。
|
||||
//
|
||||
// 边界的准确说法(M24):flowgate.go 那句「键缺失 ⇒ 该限制关闭」的向后兼容分支,
|
||||
// 对 CHANNELS_JSON 账号**不可达** —— config.ChannelAccount 用的是普通 int
|
||||
// (omitempty),「未配置」与「显式配 0」解出来都是 0,这里无从区分。要区分就得
|
||||
// 改 config 的类型(*int / 存在性表)或在本题二次解析原始 JSON;而那样做会改掉
|
||||
// 「闸门默认开还是关」这个产品口径(群聊限流/审查默认从开变关),属主人裁决,
|
||||
// 不在本处自行翻转。见 M24 回报里的选项 A/B/C。
|
||||
groupMsgPerMin int
|
||||
directMsgPerMin int
|
||||
msgPerMin int
|
||||
|
||||
@@ -13,21 +13,32 @@ import (
|
||||
// It is a pure noise/importance filter (design/12 §6 策略四). Implementations must
|
||||
// never decide identity: who the sender is was already resolved structurally by the
|
||||
// channel layer. A nil Reviewer makes the gate fall back to a plain FIFO cap.
|
||||
//
|
||||
// The gate calls it **off the admission path** (see FlowGate.Admit), so an
|
||||
// implementation may take as long as the model takes; it is bounded by
|
||||
// reviewTimeout and by the per-window budget, never by the caller's patience.
|
||||
type Reviewer func(ctx context.Context, msgs []UnifiedMessage) (keep []int, err error)
|
||||
|
||||
// GateVerdict reports how the gate classified one inbound message.
|
||||
type GateVerdict struct {
|
||||
// Proceed reports whether the message should be delivered downstream.
|
||||
Proceed bool
|
||||
// Reason is a short token describing a drop: "rate_limited", "review_pruned"
|
||||
// or "capped". It is empty when Proceed is true.
|
||||
// Reason is a short token describing a drop. After M24 it is always
|
||||
// "rate_limited" (over the sliding window — dropped at the door, the message
|
||||
// never enters the pending set), because nothing else on the admission path can
|
||||
// refuse a message: the pending cap evicts the **oldest** entries and the review
|
||||
// model runs in the background, so neither can reject the message that just
|
||||
// arrived. The audit hook still reports their drops with "capped" /
|
||||
// "review_pruned"; those are retained-message bookkeeping, not admissions.
|
||||
// Reason is empty when Proceed is true.
|
||||
Reason string
|
||||
// DroppedMsg is the message that failed admission (nil when Proceed is true).
|
||||
DroppedMsg *UnifiedMessage
|
||||
// Pruned lists every message the gate dropped during this admission. On the
|
||||
// review path it is the noise the reviewer rejected; on the cap path it is the
|
||||
// oldest messages evicted to honor the total cap. Each is recorded to the
|
||||
// OnPruned audit hook before being dropped, so nothing is lost silently.
|
||||
// Pruned lists every message the gate dropped during this admission: on the cap
|
||||
// path it is the oldest messages evicted to honor the total cap. Each is recorded
|
||||
// to the OnPruned audit hook before being dropped, so nothing is lost silently.
|
||||
// Backlog drops made by a background review reach the audit hook with
|
||||
// "review_pruned" when that review completes, not through this field.
|
||||
Pruned []UnifiedMessage
|
||||
}
|
||||
|
||||
@@ -56,15 +67,23 @@ type FlowGateOptions struct {
|
||||
MsgPerMin int
|
||||
// MaxMessages is the **per-conversation** pending-message cap (design/12 §6
|
||||
// 策略四 / 裁决 06.4.3). When a conversation's pending set nears it the Reviewer
|
||||
// is invoked to prune noise; with no Reviewer the oldest messages are evicted
|
||||
// FIFO. Zero disables the cap.
|
||||
// is invoked — in the background — to prune noise; with no Reviewer the oldest
|
||||
// messages are evicted FIFO. Zero disables the cap.
|
||||
MaxMessages int
|
||||
// Window is the sliding window over which the per-minute limits are counted.
|
||||
// Zero uses defaultRateWindow.
|
||||
Window time.Duration
|
||||
// Review is the optional review model. When nil the gate enforces the cap with
|
||||
// FIFO eviction instead of model judgment.
|
||||
// FIFO eviction instead of model judgment. It is always called from a background
|
||||
// goroutine, at most once per conversation per Window, with at most one call in
|
||||
// flight per gate.
|
||||
Review Reviewer
|
||||
// ReviewPerMin is the hard per-account ceiling on Reviewer calls started per
|
||||
// Window: the budget that bounds review cost no matter how many conversations
|
||||
// cross their own threshold in the same window. Zero (or negative) uses
|
||||
// defaultReviewPerWindow. It bounds calls started, not calls finished, so the
|
||||
// bound holds independently of model latency.
|
||||
ReviewPerMin int
|
||||
// OnPruned is called once per pruned/dropped message with a short reason token
|
||||
// ("review_pruned" or "capped") so the caller can record an audit trail.
|
||||
// It may be nil (then the caller only sees Pruned on the verdict).
|
||||
@@ -80,6 +99,13 @@ const (
|
||||
// runs. Design/12 §6 策略四 says "maxMessages 的 80%": this is the cost guard
|
||||
// that keeps review from firing on every message.
|
||||
defaultReviewPct = 80
|
||||
// defaultReviewPerWindow is the hard per-adapter ceiling on reviewer calls per
|
||||
// window (6/minute, the same order of magnitude as FlowLimitDefault). Before it
|
||||
// existed the only bound was "one per conversation per window", which an account
|
||||
// with many conversations could multiply without limit. At p50 5.3 s per call
|
||||
// this still allows ~half a minute of review work per minute per account, so it
|
||||
// bounds the tail without starving normal traffic.
|
||||
defaultReviewPerWindow = 6
|
||||
// FlowLimitDefault is the protective default of 6 group messages per minute
|
||||
// (design/12 §6 策略三). It is applied when an account pins no group rate limit.
|
||||
FlowLimitDefault = 6
|
||||
@@ -128,6 +154,10 @@ type flowSession struct {
|
||||
groupHits []time.Time
|
||||
directHits []time.Time
|
||||
buffer []queuedMsg
|
||||
// lastReview 是**最近一次审查被派发**的时刻(不是完成时刻,也无论成败):
|
||||
// 写在这里而不是「成功之后」,失败路径才同样被窗口节流(修复前它只在
|
||||
// err == nil 分支置位,而 68% 的审查因对端回带 BOM 解析失败 ⇒ 缓冲满之后
|
||||
// 每条入站帧都付一次 ~6 s 的模型调用)。
|
||||
lastReview time.Time
|
||||
// inFlight 是该会话**正在跑**的回合数(AcquireTurn/ReleaseTurn 维护)。受限车道
|
||||
// 用它把「同一发送者同时最多 1 个在跑的回合」落到实处。
|
||||
@@ -148,6 +178,14 @@ type flowSession struct {
|
||||
//
|
||||
// 两个策略的计数都按会话独立(裁决 06.4.3),只有 MsgPerMin 总量护栏按账号汇总。
|
||||
//
|
||||
// **审查不在准入路径上**:Admit 只读待处理集合里已经存在的结论(限流计数、
|
||||
// 上一批已完成审查留下的队列),触发条件满足时只把一次审查**交给后台**,因此
|
||||
// 适配器的读 goroutine 永远不会等模型 —— 修复前它会同步等一次 `noise_review`
|
||||
// (实测 p50 5.3 s / p95 6.7 s / max 9.2 s),把消息、心跳与探活回执全排在同一条
|
||||
// 队上(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。后台审查的三重上界:
|
||||
// 同一适配器至多一次在飞(reviewInFlight)、同一会话每 Window 至多一次
|
||||
// (lastReview)、整个适配器每 Window 至多 defaultReviewPerWindow 次(reviewHits)。
|
||||
//
|
||||
// Owner-private messages are not routed through the gate (the caller bypasses it),
|
||||
// so the owner is never throttled or pruned. Identity is never examined here.
|
||||
// The gate also carries the per-conversation running-turn slot (AcquireTurn /
|
||||
@@ -160,24 +198,48 @@ type FlowGate struct {
|
||||
reviewAt int
|
||||
window time.Duration
|
||||
review Reviewer
|
||||
reviewBudget int
|
||||
onPruned func(UnifiedMessage, string)
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
totalHits []time.Time
|
||||
sessions map[string]*flowSession
|
||||
admitted uint64
|
||||
seq uint64
|
||||
// reviewHits 是本适配器在窗口内**派发**审查的时刻,用于账号级预算
|
||||
// (defaultReviewPerWindow)。
|
||||
reviewHits []time.Time
|
||||
// reviewInFlight 表示本适配器已有一次审查在后台跑:同一适配器至多一次在飞,
|
||||
// 使后台审查有界(不会因为多个群同时越阈而并发起 N 个模型调用)。
|
||||
reviewInFlight bool
|
||||
sessions map[string]*flowSession
|
||||
admitted uint64
|
||||
seq uint64
|
||||
}
|
||||
|
||||
// queuedMsg wraps a buffer entry with a stable sequence so pruning decisions can
|
||||
// be audited and the fate of the just-admitted message be tracked across index
|
||||
// shifts (value equality on UnifiedMessage is unsafe because it holds slices).
|
||||
// The sequence also lets a review verdict land on the entries the reviewer actually
|
||||
// saw, even when the buffer has moved on while the call was in flight.
|
||||
type queuedMsg struct {
|
||||
seq uint64
|
||||
msg UnifiedMessage
|
||||
}
|
||||
|
||||
// ReviewAtFor 返回给定待处理上限对应的审查触发水位(design/12 §6 策略四:
|
||||
// maxMessages 的 80%,至少 1)。它是 NewFlowGate 内部同一处的口径,导出只为让调用方
|
||||
// **如实报出生效值**(例如 cmd/cyrene 的逐账号入口流控启动日志),而不是在别处另写
|
||||
// 一遍 80% 的算法。非正上限表示上限关闭,返回 0(永不触发审查)。
|
||||
func ReviewAtFor(maxMessages int) int {
|
||||
if maxMessages <= 0 {
|
||||
return 0
|
||||
}
|
||||
at := maxMessages * defaultReviewPct / 100
|
||||
if at < 1 {
|
||||
return 1
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
// NewFlowGate builds a gate from opts. Options that are zero disable their limit,
|
||||
// except Window which defaults to one minute when left zero.
|
||||
func NewFlowGate(opts FlowGateOptions) *FlowGate {
|
||||
@@ -188,6 +250,7 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
|
||||
maxMessages: opts.MaxMessages,
|
||||
window: opts.Window,
|
||||
review: opts.Review,
|
||||
reviewBudget: opts.ReviewPerMin,
|
||||
onPruned: opts.OnPruned,
|
||||
now: opts.Now,
|
||||
sessions: make(map[string]*flowSession),
|
||||
@@ -195,22 +258,24 @@ func NewFlowGate(opts FlowGateOptions) *FlowGate {
|
||||
if g.window <= 0 {
|
||||
g.window = defaultRateWindow
|
||||
}
|
||||
if g.maxMessages > 0 {
|
||||
g.reviewAt = g.maxMessages * defaultReviewPct / 100
|
||||
if g.reviewAt < 1 {
|
||||
g.reviewAt = 1
|
||||
}
|
||||
if g.reviewBudget <= 0 {
|
||||
g.reviewBudget = defaultReviewPerWindow
|
||||
}
|
||||
g.reviewAt = ReviewAtFor(g.maxMessages)
|
||||
if g.now == nil {
|
||||
g.now = time.Now
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// Admit applies the rate limits then the per-conversation cap/review gate to one
|
||||
// inbound message. It returns a verdict telling the caller whether to proceed;
|
||||
// messages that the gate drops are returned (and audited) so nothing is lost
|
||||
// silently.
|
||||
// Admit applies the rate limits then the per-conversation cap gate to one inbound
|
||||
// message. It returns a verdict telling the caller whether to proceed; messages
|
||||
// that the gate drops are returned (and audited) so nothing is lost silently.
|
||||
//
|
||||
// **它从不同步调用审查模型**:限流与上限是 O(1) 的判定,审查只是一次「后台修剪」
|
||||
// 的派发。修复前这里同步等 `noise_review`(p50 5.3 s),而五路入站里有三路
|
||||
// (qq-ws、kook-ws、weixin 长轮询)是「单读 goroutine + 同步派发」,于是消息、
|
||||
// 心跳、探活回执共享同一条 6 s 队列 —— 主人口中「消息一直排队」的机制。
|
||||
//
|
||||
// 计数按会话独立(裁决 06.4.3):群聊滑窗与待处理上限都落在 ConversationKey 对应的
|
||||
// flowSession 上(两个群并发互不挤占),只有 MsgPerMin 这一账号级总量护栏仍按适配器汇总。
|
||||
@@ -218,48 +283,55 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
|
||||
now := g.now()
|
||||
key := ConversationKey(msg)
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.maybeGCLocked(now)
|
||||
verdict, job := g.admitLocked(msg, key, now)
|
||||
g.mu.Unlock()
|
||||
|
||||
if job != nil {
|
||||
// 派发在解锁之后:后台审查自己会加锁把结论落回待处理集合,绝不能让它
|
||||
// 与这次准入抢同一把锁(也绝不能让准入等它)。
|
||||
go g.runReview(ctx, job)
|
||||
}
|
||||
return verdict
|
||||
}
|
||||
|
||||
// admitLocked runs the whole admission decision under g.mu and returns the verdict
|
||||
// plus, when due, the background review job the caller must launch after unlocking.
|
||||
// It never calls the Reviewer itself.
|
||||
func (g *FlowGate) admitLocked(msg UnifiedMessage, key string, now time.Time) (GateVerdict, *reviewJob) {
|
||||
// 策略三(会话级): sliding-window rate limits. 群聊上限按每个群独立计数;MsgPerMin
|
||||
// 是账号级的非主人消息总量护栏。Over-limit messages are dropped at the door
|
||||
// (downgraded) instead of blocking.
|
||||
// (downgraded) instead of blocking. 这一段的语义与修复前**一字不改**:
|
||||
// 被限流的消息根本不进待处理集合,也永远不会因为审查被「捞回来」。
|
||||
if g.groupPerMin > 0 && msg.ChannelType == ChannelGroup {
|
||||
st := g.sessionLocked(key)
|
||||
st.groupHits = g.trimHits(st.groupHits, now)
|
||||
if len(st.groupHits) >= g.groupPerMin {
|
||||
return g.drop("rate_limited", msg)
|
||||
return g.drop("rate_limited", msg), nil
|
||||
}
|
||||
st.groupHits = append(st.groupHits, now)
|
||||
}
|
||||
if g.msgPerMin > 0 {
|
||||
g.totalHits = g.trimHits(g.totalHits, now)
|
||||
if len(g.totalHits) >= g.msgPerMin {
|
||||
return g.drop("rate_limited", msg)
|
||||
return g.drop("rate_limited", msg), nil
|
||||
}
|
||||
g.totalHits = append(g.totalHits, now)
|
||||
}
|
||||
|
||||
if g.maxMessages <= 0 {
|
||||
return GateVerdict{Proceed: true}
|
||||
return GateVerdict{Proceed: true}, nil
|
||||
}
|
||||
|
||||
// 策略四(会话级): add to this conversation's pending set, then prune when it
|
||||
// nears the cap.
|
||||
// 策略四(会话级): add to this conversation's pending set, then enforce the cap
|
||||
// synchronously. 审查只被**派发**(下面的 scheduleReviewLocked),结论稍后由
|
||||
// 后台落回 —— 准入读的是「当前已有结论」。
|
||||
st := g.sessionLocked(key)
|
||||
g.seq++
|
||||
seq := g.seq
|
||||
st.buffer = append(st.buffer, queuedMsg{seq: seq, msg: msg})
|
||||
|
||||
var dropped []queuedMsg
|
||||
if g.review != nil && len(st.buffer) >= g.reviewAt && !g.reviewRecently(st, now) {
|
||||
keep, err := g.review(ctx, queueMessages(st.buffer))
|
||||
if err == nil {
|
||||
st.buffer, dropped = g.applyReview(st.buffer, keep)
|
||||
st.lastReview = now
|
||||
g.auditDropped(dropped, "review_pruned")
|
||||
}
|
||||
}
|
||||
if len(st.buffer) > g.maxMessages {
|
||||
n := len(st.buffer) - g.maxMessages
|
||||
var evicted []queuedMsg
|
||||
@@ -267,18 +339,12 @@ func (g *FlowGate) Admit(ctx context.Context, msg UnifiedMessage) GateVerdict {
|
||||
dropped = append(dropped, evicted...)
|
||||
g.auditDropped(evicted, "capped")
|
||||
}
|
||||
job := g.scheduleReviewLocked(st, key, now)
|
||||
|
||||
if !bufferHasSeq(st.buffer, seq) {
|
||||
reason := "capped"
|
||||
if containsSeq(dropped, seq) {
|
||||
reason = "review_pruned"
|
||||
}
|
||||
v := GateVerdict{Proceed: false, Reason: reason, Pruned: queueMessages(dropped)}
|
||||
c := msg
|
||||
v.DroppedMsg = &c
|
||||
return v
|
||||
}
|
||||
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}
|
||||
// 本条一定还在缓冲里:上限按新旧顺序丢最旧,而它是最新的(maxMessages ≥ 1)。
|
||||
// 于是修复后准入路径上唯一能让消息 Proceed=false 的判据就是上面的限流 —— 审查
|
||||
// (已移到后台)与上限(丢最旧)都只影响**已经放行过的**历史条目,只留审计痕迹。
|
||||
return GateVerdict{Proceed: true, Pruned: queueMessages(dropped)}, job
|
||||
}
|
||||
|
||||
// AllowDirect enforces the per-sender sliding-window rate limit for a **direct
|
||||
@@ -385,15 +451,6 @@ func (g *FlowGate) maybeGCLocked(now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// reviewRecently throttles review so it does not run on every message: it fires at
|
||||
// most once per window per conversation, which is the cost guard for the review model.
|
||||
func (g *FlowGate) reviewRecently(st *flowSession, now time.Time) bool {
|
||||
if st == nil || st.lastReview.IsZero() {
|
||||
return false
|
||||
}
|
||||
return now.Sub(st.lastReview) < g.window
|
||||
}
|
||||
|
||||
// trimHits drops event timestamps older than the sliding window.
|
||||
func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
|
||||
cutoff := now.Add(-g.window)
|
||||
@@ -407,27 +464,6 @@ func (g *FlowGate) trimHits(hits []time.Time, now time.Time) []time.Time {
|
||||
return hits[:keep]
|
||||
}
|
||||
|
||||
// applyReview keeps only the entries at the returned indices (validated, de-duped,
|
||||
// ascending) and returns the kept slice plus the dropped entries in original order.
|
||||
func (g *FlowGate) applyReview(buffer []queuedMsg, keep []int) (kept, dropped []queuedMsg) {
|
||||
seen := make(map[int]bool, len(keep))
|
||||
for _, i := range keep {
|
||||
if i >= 0 && i < len(buffer) {
|
||||
seen[i] = true
|
||||
}
|
||||
}
|
||||
out := make([]queuedMsg, 0, len(buffer))
|
||||
drop := make([]queuedMsg, 0)
|
||||
for i, entry := range buffer {
|
||||
if seen[i] {
|
||||
out = append(out, entry)
|
||||
} else {
|
||||
drop = append(drop, entry)
|
||||
}
|
||||
}
|
||||
return out, drop
|
||||
}
|
||||
|
||||
// dropFront removes n entries from the front (oldest) and returns the rest plus the
|
||||
// dropped ones.
|
||||
func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedMsg) {
|
||||
@@ -437,27 +473,6 @@ func (g *FlowGate) dropFront(buffer []queuedMsg, n int) (kept, dropped []queuedM
|
||||
return buffer[n:], append([]queuedMsg(nil), buffer[:n]...)
|
||||
}
|
||||
|
||||
// bufferHasSeq reports whether an entry with the given sequence is still buffered
|
||||
// in the conversation's pending set.
|
||||
func bufferHasSeq(buffer []queuedMsg, seq uint64) bool {
|
||||
for _, e := range buffer {
|
||||
if e.seq == seq {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// containsSeq reports whether seq appears among the dropped entries.
|
||||
func containsSeq(dropped []queuedMsg, seq uint64) bool {
|
||||
for _, e := range dropped {
|
||||
if e.seq == seq {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// auditDropped records every dropped entry to the OnPruned audit hook.
|
||||
func (g *FlowGate) auditDropped(dropped []queuedMsg, reason string) {
|
||||
for _, e := range dropped {
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
package adapter
|
||||
|
||||
// 本文件覆盖 M24:把策略四的审查**移出准入路径**,并让节流在失败路径同样生效。
|
||||
// 每条用例都对应一条可变异点(见 M24 回报里的「预期会红的用例名」)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// auditRecorder 收集 OnPruned 的审计回调。审查结论来自后台 goroutine,因此这里
|
||||
// 必须加锁(-race 会盯)。
|
||||
type auditRecorder struct {
|
||||
mu sync.Mutex
|
||||
events []auditEvent
|
||||
}
|
||||
|
||||
type auditEvent struct {
|
||||
content string
|
||||
reason string
|
||||
}
|
||||
|
||||
func (r *auditRecorder) hook() func(UnifiedMessage, string) {
|
||||
return func(m UnifiedMessage, reason string) {
|
||||
r.mu.Lock()
|
||||
r.events = append(r.events, auditEvent{content: m.Content, reason: reason})
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *auditRecorder) snapshot() []auditEvent {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]auditEvent(nil), r.events...)
|
||||
}
|
||||
|
||||
func (r *auditRecorder) reasons() []string {
|
||||
out := make([]string, 0)
|
||||
for _, e := range r.snapshot() {
|
||||
out = append(out, e.reason)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// waitReviewIdle 等到闸门没有在飞的审查调用,使后台结论已经落回待处理集合。
|
||||
// reviewInFlight 在**派发时**(Admit 持锁期间)就置位,因此 Admit 返回后立刻
|
||||
// 调用它不会漏掉刚派发的调用。
|
||||
func waitReviewIdle(t *testing.T, g *FlowGate) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
g.mu.Lock()
|
||||
busy := g.reviewInFlight
|
||||
g.mu.Unlock()
|
||||
if !busy {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("reviewer call did not finish within 5s")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// pendingLen 返回某会话待处理集合的条目数。
|
||||
func pendingLen(g *FlowGate, key string) int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if st := g.sessions[key]; st != nil {
|
||||
return len(st.buffer)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// keepAll 是「审查者什么都不想丢」的替身:返回全部索引,使待处理集合恒在阈值上,
|
||||
// 从而让节流/预算成为唯一的抑制因素(否则队列一被修剪就跌破阈值,测不出节流)。
|
||||
func keepAll(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
|
||||
keep := make([]int, len(msgs))
|
||||
for i := range msgs {
|
||||
keep[i] = i
|
||||
}
|
||||
return keep, nil
|
||||
}
|
||||
|
||||
// TestFlowGate_AdmitNeverWaitsForSlowReview 是 M24 的核心证据:一个 3–6 s 量级的
|
||||
// 慢审查替身压住闸门时,入站帧仍然按节奏被放行 —— 读循环不再被模型调用阻塞。
|
||||
//
|
||||
// 变异判据:把审查改回同步调用(在 Admit 里直接 g.review(...))⇒ 帧间隔出现一个
|
||||
// ≈4 s 的空窗 ⇒ 本用例红。
|
||||
func TestFlowGate_AdmitNeverWaitsForSlowReview(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{}, 1)
|
||||
reviewer := func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
|
||||
select {
|
||||
case started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
// 4 s 的慢审查替身:实测 noise_review 的 p50 5.3 s / max 9.2 s 同量级。
|
||||
select {
|
||||
case <-release:
|
||||
case <-time.After(4 * time.Second):
|
||||
}
|
||||
return nil, fmt.Errorf("slow stand-in returned")
|
||||
}
|
||||
g := NewFlowGate(FlowGateOptions{MaxMessages: 10, Window: time.Minute, Review: reviewer})
|
||||
defer close(release)
|
||||
|
||||
const frames = 100
|
||||
const cadence = 20 * time.Millisecond
|
||||
var maxGap time.Duration
|
||||
var slowestAdmit time.Duration
|
||||
prev := time.Now()
|
||||
for i := 0; i < frames; i++ {
|
||||
admitStart := time.Now()
|
||||
// 消息与心跳/探活回执在适配器侧走的是同一条 Admit 路,这里逐帧模拟。
|
||||
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
|
||||
t.Fatalf("frame %d dropped by the gate: %+v", i, v)
|
||||
}
|
||||
if d := time.Since(admitStart); d > slowestAdmit {
|
||||
slowestAdmit = d
|
||||
}
|
||||
if gap := time.Since(prev); gap > maxGap {
|
||||
maxGap = gap
|
||||
}
|
||||
prev = time.Now()
|
||||
time.Sleep(cadence)
|
||||
}
|
||||
|
||||
// 先断言「帧没有被拖住」——同步实现会在这里给出可读的红。
|
||||
if maxGap > 500*time.Millisecond {
|
||||
t.Fatalf("inbound frames stalled behind the reviewer: max gap %v (cadence %v)", maxGap, cadence)
|
||||
}
|
||||
if slowestAdmit > 100*time.Millisecond {
|
||||
t.Fatalf("Admit waited on the reviewer: slowest admission %v", slowestAdmit)
|
||||
}
|
||||
|
||||
// 审查确实被派发并且**仍在飞**:证明替身是慢的,上面测到的不是「审查根本没跑」。
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("reviewer was never called: the gate did not schedule the review")
|
||||
}
|
||||
g.mu.Lock()
|
||||
busy := g.reviewInFlight
|
||||
g.mu.Unlock()
|
||||
if !busy {
|
||||
t.Fatal("slow reviewer already returned; the stand-in was not slow enough to prove anything")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_ReviewThrottleHoldsOnFailure 固定「节流在失败路径也必须生效」:
|
||||
// 审查者连续失败时,同一窗口内同一会话只允许一次调用 —— 这正是修复前失守的地方
|
||||
// (lastReview 只在 err == nil 分支置位,而实测 67–85% 的审查因 BOM 解析失败)。
|
||||
//
|
||||
// 变异判据:把 scheduleReviewLocked 里的 `st.lastReview = now` 挪回「成功之后」
|
||||
// (或删掉)⇒ 11 条消息各派发一次 ⇒ 本用例红。
|
||||
func TestFlowGate_ReviewThrottleHoldsOnFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now, _ := fixedClock(time.Unix(1000, 0))
|
||||
var mu sync.Mutex
|
||||
calls := 0
|
||||
audit := &auditRecorder{}
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 4, // reviewAt = 3
|
||||
Window: time.Minute,
|
||||
Now: now,
|
||||
Review: func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
return nil, fmt.Errorf("invalid character 'ï' looking for beginning of value")
|
||||
},
|
||||
OnPruned: audit.hook(),
|
||||
})
|
||||
|
||||
for i := 0; i < 11; i++ {
|
||||
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
|
||||
t.Fatalf("message %d dropped although the reviewer only failed: %+v", i, v)
|
||||
}
|
||||
waitReviewIdle(t, g)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
got := calls
|
||||
mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("reviewer calls within one window = %d, want 1 (throttle must cover the failure path)", got)
|
||||
}
|
||||
// 失败方向:既不额外丢弃,也不给队列任何条目「保留」特权 —— 只有上限该有的
|
||||
// "capped" 审计,绝不出现 "review_pruned"。
|
||||
for _, r := range audit.reasons() {
|
||||
if r == "review_pruned" {
|
||||
t.Fatalf("a failed review must not prune anything, got audits %v", audit.reasons())
|
||||
}
|
||||
}
|
||||
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 4 {
|
||||
t.Fatalf("pending set = %d, want 4 (cap only, no review pruning)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_ReviewBudgetBoundsCallsPerWindow 把「每适配器每分钟 LLM 审查次数」的
|
||||
// 硬上界写成可测断言:40 个群在同一窗口内全部越阈,实际调用不超过
|
||||
// defaultReviewPerWindow(6)。
|
||||
//
|
||||
// 变异判据:删掉 reviewBudgetLocked 的调用(或把 reviewBudget 置 0/极大)⇒ 40 次
|
||||
// ⇒ 本用例红。
|
||||
func TestFlowGate_ReviewBudgetBoundsCallsPerWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now, _ := fixedClock(time.Unix(1000, 0))
|
||||
var mu sync.Mutex
|
||||
calls := 0
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 4, // reviewAt = 3
|
||||
Window: time.Minute,
|
||||
Now: now,
|
||||
Review: func(ctx context.Context, msgs []UnifiedMessage) ([]int, error) {
|
||||
mu.Lock()
|
||||
calls++
|
||||
mu.Unlock()
|
||||
return keepAll(ctx, msgs)
|
||||
},
|
||||
})
|
||||
|
||||
const groups = 40
|
||||
for gi := 0; gi < groups; gi++ {
|
||||
group := fmt.Sprintf("-20%d", gi)
|
||||
for i := 0; i < 4; i++ {
|
||||
g.Admit(context.Background(), groupMsgIn(group, i))
|
||||
}
|
||||
waitReviewIdle(t, g)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
got := calls
|
||||
mu.Unlock()
|
||||
if got > defaultReviewPerWindow {
|
||||
t.Fatalf("reviewer calls in one window = %d, want <= %d (per-adapter budget)", got, defaultReviewPerWindow)
|
||||
}
|
||||
if got != defaultReviewPerWindow {
|
||||
t.Fatalf("reviewer calls = %d, want exactly %d (every conversation crossed, budget is the only cap)", got, defaultReviewPerWindow)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_ReviewSingleInFlightPerAdapter 固定「同一适配器至多一次在飞」:
|
||||
// 两个群同时越阈,第二个必须让位,不并发起第二次模型调用。
|
||||
//
|
||||
// 变异判据:删掉 scheduleReviewLocked 里的 reviewInFlight 判断 ⇒ 第二个群也起一次
|
||||
// 调用 ⇒ 本用例红。
|
||||
func TestFlowGate_ReviewSingleInFlightPerAdapter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
started := make(chan string, 4)
|
||||
reviewer := func(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
|
||||
started <- msgs[0].Content
|
||||
<-release
|
||||
return keepAll(context.Background(), msgs)
|
||||
}
|
||||
g := NewFlowGate(FlowGateOptions{MaxMessages: 4, Window: time.Minute, Review: reviewer})
|
||||
defer close(release)
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
g.Admit(context.Background(), groupMsgIn("-100", i))
|
||||
}
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first review never started")
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
g.Admit(context.Background(), groupMsgIn("-200", i))
|
||||
}
|
||||
select {
|
||||
case extra := <-started:
|
||||
t.Fatalf("second review started while one was in flight (%q)", extra)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_ReviewIgnoresMessagesArrivedDuringCall 固定后台审查的落点:结论只作用于
|
||||
// 审查者**当时看到的那批**(用序号对齐)。审查在飞期间新到的消息没被审查过,绝不能被
|
||||
// 这次结论连带修剪 —— 否则审计会记出「review_pruned」却都是已经放行的消息。
|
||||
//
|
||||
// 变异判据:把 applyReviewSeqs 写成「不在 keep 集合里就丢」(不做快照过滤)⇒ 在飞期间
|
||||
// 到达的两条也被丢 ⇒ 本用例红。
|
||||
func TestFlowGate_ReviewIgnoresMessagesArrivedDuringCall(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
started := make(chan struct{}, 1)
|
||||
audit := &auditRecorder{}
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 10, // reviewAt = 8
|
||||
Window: time.Minute,
|
||||
Review: func(_ context.Context, _ []UnifiedMessage) ([]int, error) {
|
||||
started <- struct{}{}
|
||||
<-release
|
||||
return []int{0}, nil // 只保留快照里的第 0 条,其余视为噪声
|
||||
},
|
||||
OnPruned: audit.hook(),
|
||||
})
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
g.Admit(context.Background(), groupMsg(i))
|
||||
}
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("review never started")
|
||||
}
|
||||
// 审查在飞期间又到两条(seq 9、10):它们不在这次审查的快照里。
|
||||
g.Admit(context.Background(), groupMsg(8))
|
||||
g.Admit(context.Background(), groupMsg(9))
|
||||
close(release)
|
||||
waitReviewIdle(t, g)
|
||||
|
||||
// 快照第 0 条(msg 0)保留 + 在飞期间到达的两条 = 3。
|
||||
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 3 {
|
||||
t.Fatalf("pending set = %d, want 3 (msg0 + the two messages the reviewer never saw)", n)
|
||||
}
|
||||
for _, e := range audit.snapshot() {
|
||||
if e.reason != "review_pruned" {
|
||||
t.Fatalf("audit reason = %q, want review_pruned", e.reason)
|
||||
}
|
||||
if e.content == "msg 8" || e.content == "msg 9" {
|
||||
t.Fatalf("a message that arrived during the call was pruned: %q", e.content)
|
||||
}
|
||||
}
|
||||
if got := len(audit.snapshot()); got != 7 {
|
||||
t.Fatalf("review_pruned count = %d, want 7 (snapshot entries 1..7)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_CapStillEvictsOnAdmission 固定上限那一半仍是**同步**的:队列超出
|
||||
// maxMessages 时最旧的条目在准入当次就被挤掉并审计 "capped",不依赖审查。
|
||||
//
|
||||
// 变异判据:把上限修剪也挪进后台 ⇒ v.Pruned 为空 ⇒ 本用例红。
|
||||
func TestFlowGate_CapStillEvictsOnAdmission(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
audit := &auditRecorder{}
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 3,
|
||||
Review: keepAll,
|
||||
OnPruned: audit.hook(),
|
||||
})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
|
||||
t.Fatalf("message %d should fit under the cap: %+v", i, v)
|
||||
}
|
||||
}
|
||||
v := g.Admit(context.Background(), groupMsg(3))
|
||||
if !v.Proceed {
|
||||
t.Fatalf("new message should be admitted after FIFO eviction, got %+v", v)
|
||||
}
|
||||
if len(v.Pruned) != 1 || v.Pruned[0].Content != "msg 0" {
|
||||
t.Fatalf("pruned = %+v, want [msg 0]", v.Pruned)
|
||||
}
|
||||
found := false
|
||||
for _, r := range audit.reasons() {
|
||||
if r == "capped" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("cap eviction not audited as capped: %v", audit.reasons())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_RateLimitDropsAtTheDoor 固定 rate_limited 的「门口丢」语义一字不改:
|
||||
// 被滑窗挡下的消息根本不进待处理集合,审查也绝不会把它「捞回来」。
|
||||
func TestFlowGate_RateLimitDropsAtTheDoor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now, _ := fixedClock(time.Unix(1000, 0))
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
GroupMsgPerMin: 2,
|
||||
MaxMessages: 3,
|
||||
Window: time.Minute,
|
||||
Now: now,
|
||||
Review: keepAll,
|
||||
})
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if v := g.Admit(context.Background(), groupMsg(i)); !v.Proceed {
|
||||
t.Fatalf("message %d should pass the group window: %+v", i, v)
|
||||
}
|
||||
}
|
||||
v := g.Admit(context.Background(), groupMsg(2))
|
||||
if v.Proceed {
|
||||
t.Fatal("third message in the window must be rate limited")
|
||||
}
|
||||
if v.Reason != "rate_limited" {
|
||||
t.Fatalf("reason = %q, want rate_limited", v.Reason)
|
||||
}
|
||||
if v.DroppedMsg == nil || v.DroppedMsg.Content != "msg 2" {
|
||||
t.Fatalf("dropped msg = %+v, want msg 2", v.DroppedMsg)
|
||||
}
|
||||
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 2 {
|
||||
t.Fatalf("pending set = %d, want 2: a rate-limited message never enters it", n)
|
||||
}
|
||||
waitReviewIdle(t, g)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// utf8BOM 是 UTF-8 的字节序标记。对端(审查模型网关)会把它原样回带,而
|
||||
// encoding/json 见到它报 `invalid character 'ï' looking for beginning of value` ——
|
||||
// 实测 9 天 1,909 次审查里 1,288 次(67%)因此解析失败,每一次都白烧一次模型调用
|
||||
// 并把「每窗口一次」的节流打穿(见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
|
||||
const utf8BOM = "\ufeff"
|
||||
|
||||
// reviewExcerptLen 是解析失败时附带在错误里的模型输出长度(字节)。它只含模型
|
||||
// 返回的编号数组(数字/方括号/逗号/围栏),足以定位「又换了什么壳」,不至于把
|
||||
// 消息内容带进日志。
|
||||
const reviewExcerptLen = 40
|
||||
|
||||
// ParseReviewIndices decodes the keep-index array a review model returned, rising
|
||||
// above the shapes real gateways produce instead of requiring a bare JSON array:
|
||||
//
|
||||
// - a UTF-8 BOM prefix (`\ufeff`) — the actual cause of the 67% failure rate;
|
||||
// - leading/trailing whitespace and newlines;
|
||||
// - a Markdown code fence (` ```json … ``` ` or ` ``` … ``` `);
|
||||
// - surrounding prose ("好的,保留以下编号:[0, 2]") — the outermost `[ … ]`
|
||||
// span is used.
|
||||
//
|
||||
// The decoded indices are validated the same way the previous strict parser did:
|
||||
// out-of-range and duplicate values are dropped and the rest sorted ascending, so a
|
||||
// hallucinated number can never evict a real message. An empty or absent array is
|
||||
// NOT an error here — the caller decides; a missing/non-array answer is.
|
||||
func ParseReviewIndices(s string, bound int) ([]int, error) {
|
||||
body, ok := reviewArraySpan(s)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no JSON array in reviewer output %q", reviewExcerpt(s))
|
||||
}
|
||||
var idx []int
|
||||
if err := json.Unmarshal([]byte(body), &idx); err != nil {
|
||||
return nil, fmt.Errorf("decode reviewer indices %q: %w", reviewExcerpt(body), err)
|
||||
}
|
||||
seen := make(map[int]bool, len(idx))
|
||||
out := make([]int, 0, len(idx))
|
||||
for _, i := range idx {
|
||||
if i >= 0 && i < bound && !seen[i] {
|
||||
seen[i] = true
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// reviewArraySpan strips the decorations a model puts around its answer (BOM,
|
||||
// whitespace, code fence, prose) and returns the outermost JSON array span.
|
||||
func reviewArraySpan(s string) (string, bool) {
|
||||
s = strings.TrimSpace(strings.TrimPrefix(s, utf8BOM))
|
||||
s = strings.TrimSpace(unwrapCodeFence(s))
|
||||
start := strings.IndexByte(s, '[')
|
||||
end := strings.LastIndexByte(s, ']')
|
||||
if start < 0 || end < start {
|
||||
return "", false
|
||||
}
|
||||
return s[start : end+1], true
|
||||
}
|
||||
|
||||
// unwrapCodeFence drops a leading ``` fence line and the closing ``` so the fenced
|
||||
// body is left for the array scan. Anything without a leading fence is returned
|
||||
// unchanged; a fence with no body is returned as-is and fails the array scan.
|
||||
func unwrapCodeFence(s string) string {
|
||||
if !strings.HasPrefix(s, "```") {
|
||||
return s
|
||||
}
|
||||
nl := strings.IndexByte(s, '\n')
|
||||
if nl < 0 {
|
||||
return s
|
||||
}
|
||||
s = s[nl+1:]
|
||||
if close := strings.LastIndex(s, "```"); close >= 0 {
|
||||
s = s[:close]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// reviewExcerpt truncates text for an error message so the next parse failure is
|
||||
// diagnosable without logging the whole completion.
|
||||
func reviewExcerpt(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= reviewExcerptLen {
|
||||
return s
|
||||
}
|
||||
return s[:reviewExcerptLen] + "…"
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package adapter
|
||||
|
||||
// 本文件用**对端原样输出**(含 UTF-8 BOM 字节)做夹具,固定审查输出的解析韧性。
|
||||
// 生产症状(`~/cy.log`,9 天):`flow gate review output unparsable` 1,301 次,其中
|
||||
// 1,288 次的错误串逐字是 `invalid character 'ï' looking for beginning of value` ——
|
||||
// 对端在 JSON 数组前带了 BOM,而闸门用的 json.Unmarshal 不接受它。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// bomReviewFixture 是审查模型网关的实际回包形状:UTF-8 BOM(EF BB BF)紧接一个
|
||||
// JSON 数组。写死字节而不是用 \ufeff,是为了让「夹具被谁悄悄去掉 BOM」本身就会红。
|
||||
const bomReviewFixture = "\xef\xbb\xbf[0, 1, 2, 3, 4, 5]"
|
||||
|
||||
// TestReviewBOMFixtureStillReproducesTheFailure 先钉住夹具的真实性:它必须以 BOM
|
||||
// 开头,且严格的 json.Unmarshal 必须在它上面失败,错误串必须就是生产日志里那条。
|
||||
//
|
||||
// 变异判据:把夹具里的 BOM 去掉 ⇒ 本用例红(同时也说明 BOM 相关用例已失去意义)。
|
||||
func TestReviewBOMFixtureStillReproducesTheFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !strings.HasPrefix(bomReviewFixture, "\xef\xbb\xbf") {
|
||||
t.Fatal("BOM fixture lost its BOM bytes: it no longer reproduces the production failure")
|
||||
}
|
||||
var idx []int
|
||||
err := json.Unmarshal([]byte(bomReviewFixture), &idx)
|
||||
if err == nil {
|
||||
t.Fatal("strict json.Unmarshal accepted the BOM fixture: the regression it models is gone")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid character 'ï'") {
|
||||
t.Fatalf("strict parse error = %v, want the production symptom \"invalid character 'ï'\"", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReviewIndices_ToleratesBOM 是修复的正面证据:同一份带 BOM 的原样回包
|
||||
// 现在能解析出编号。
|
||||
//
|
||||
// 变异判据:去掉 ParseReviewIndices 里的 reviewArraySpan(回到裸 json.Unmarshal)
|
||||
// ⇒ 本用例红。
|
||||
func TestParseReviewIndices_ToleratesBOM(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keep, err := ParseReviewIndices(bomReviewFixture, 6)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseReviewIndices(BOM fixture) error = %v, want nil", err)
|
||||
}
|
||||
want := []int{0, 1, 2, 3, 4, 5}
|
||||
if len(keep) != len(want) {
|
||||
t.Fatalf("keep = %v, want %v", keep, want)
|
||||
}
|
||||
for i := range want {
|
||||
if keep[i] != want[i] {
|
||||
t.Fatalf("keep = %v, want %v", keep, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReviewIndices_ToleratesDecorations 覆盖模型常见的几种外壳:首尾空白与换行、
|
||||
// Markdown 代码围栏(带或不带语言标签)、以及围在数组外面的解说文字。
|
||||
func TestParseReviewIndices_ToleratesDecorations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"newlines": "\n\n[0, 2]\n\n",
|
||||
"fence": "```\n[0, 2]\n```",
|
||||
"fence with lang": "```json\n[0, 2]\n```",
|
||||
"prose prefix": "好的,保留以下编号:[0, 2]",
|
||||
"prose suffix": "[0, 2] 以上是保留的编号。",
|
||||
"bom plus fence": "\xef\xbb\xbf```json\n[0, 2]\n```",
|
||||
"spaced values": "[ 0 , 2 ]",
|
||||
}
|
||||
for name, raw := range cases {
|
||||
keep, err := ParseReviewIndices(raw, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: error = %v, want nil", name, err)
|
||||
}
|
||||
if len(keep) != 2 || keep[0] != 0 || keep[1] != 2 {
|
||||
t.Fatalf("%s: keep = %v, want [0 2]", name, keep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReviewIndices_FiltersHallucinations 固定过滤口径与修复前一致:越界、负数、
|
||||
// 重复的编号一律丢弃(模型幻觉出的数字绝不能淘汰真实消息),结果升序。
|
||||
func TestParseReviewIndices_FiltersHallucinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keep, err := ParseReviewIndices("\xef\xbb\xbf[2, 2, 99, -1, 0]", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v, want nil", err)
|
||||
}
|
||||
if len(keep) != 2 || keep[0] != 0 || keep[1] != 2 {
|
||||
t.Fatalf("keep = %v, want [0 2]", keep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReviewIndices_EmptyArrayIsNotAnError:模型明确回空数组 = 「一条都不保留」,
|
||||
// 这是有效结论(全部当噪声),不是解析失败 —— 失败方向必须留给真正的畸形输出。
|
||||
func TestParseReviewIndices_EmptyArrayIsNotAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keep, err := ParseReviewIndices("\xef\xbb\xbf[]", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v, want nil", err)
|
||||
}
|
||||
if len(keep) != 0 {
|
||||
t.Fatalf("keep = %v, want empty", keep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseReviewIndices_RejectsGarbage:真正畸形的输出仍然报错(调用方据此走
|
||||
// 「不额外丢弃、也不提权」的安全方向),错误里带一小段原文便于定位下一次变种。
|
||||
func TestParseReviewIndices_RejectsGarbage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, raw := range map[string]string{
|
||||
"no array": "抱歉,我无法判断。",
|
||||
"empty": "",
|
||||
"array of str": `["a", "b"]`,
|
||||
} {
|
||||
if _, err := ParseReviewIndices(raw, 5); err == nil {
|
||||
t.Fatalf("%s: error = nil, want a decode error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package adapter
|
||||
|
||||
// 本文件是策略四的「审查」半边:把它从准入路径上摘下来的派发器(reviewJob /
|
||||
// scheduleReviewLocked / runReview)与三重上界(同适配器在飞、每会话窗口、每适配器
|
||||
// 窗口预算)。解析韧性在 flowgate_parse.go,准入判定仍在 flowgate.go。
|
||||
//
|
||||
// 为什么必须是后台:`noise_review` 实测 p50 5,294 ms / p95 6,651 ms / max 9,204 ms
|
||||
// (`~/cy.log`,9 天 1,900 次),而五路入站里 qq-ws、kook-ws、weixin 长轮询都是
|
||||
// 「单读 goroutine + 同步派发」—— 同步跑在读循环上等于把消息、心跳、探活回执全排在
|
||||
// 一次 6 秒的模型调用后面(DevDocs/audit/群聊排队与回旧话-2026-10-24.md)。
|
||||
//
|
||||
// # 与修复前的语义差异(哪一条变了、哪一条没变)
|
||||
//
|
||||
// - `rate_limited`(策略三,门口丢):**一字不改**。超窗消息在准入当次就被拒绝、
|
||||
// 根本不进待处理集合,也不经过审查。
|
||||
// - 上限修剪(策略四的硬部分):**一字不改**。待处理集合超过 maxMessages 时,最旧的
|
||||
// 条目仍在**准入当次同步**被挤掉并审计为 "capped";触发水位仍是上限的 80%。
|
||||
// - 审查修剪(策略四的模型部分):**判据不变,时机变了**。触发条件(≥80% 上限、
|
||||
// 同一会话每窗口至多一次、返回保留编号、越界/重复编号丢弃)逐条沿用;变的是结论
|
||||
// 落地时点 —— 它不再决定「触发它的那条消息」是否放行,而是修剪**它当时看到的**
|
||||
// 那批历史条目(审计为 "review_pruned")。
|
||||
// 直接后果:`review_pruned` 不再从门口丢消息(实测原本 41–74 条/日),那些消息
|
||||
// 现在会进入流水线 —— 完整性↑、噪声↑,是否开口仍由 engage_judge / 冷避 / owner
|
||||
// 闸判(这三处判据一行未动)。
|
||||
//
|
||||
// 为什么这个差异可以接受:design/12 §6 策略四给审查的职责是「保护 loop 不被不重要的
|
||||
// 消息塞满」的历史积压去噪,不是逐条消息的准入判决;把一次 6 秒的模型调用钉在准入
|
||||
// 路径上,代价(读循环阻塞 → 消息/心跳/探活全排队)远大于收益(少进 41–74 条噪声/日)。
|
||||
// 真正决定「进不进 loop」的两道判据 —— 限流与上限 —— 都留在准入路径上,且未改口径。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// reviewTimeout bounds one background reviewer call. The in-flight slot is held
|
||||
// for the whole call, so an unbounded call would also freeze every other
|
||||
// conversation's review for that account. 上一版没有上界,最长一次实测 9.2 s;
|
||||
// 30 s 留足余量而不至于让一次挂死的调用长期占位。
|
||||
reviewTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// reviewJob is one scheduled reviewer call: the snapshot of the pending set the
|
||||
// reviewer is asked about, plus the sequence numbers that identify those entries
|
||||
// when the answer comes back (the buffer keeps moving while the call is in flight).
|
||||
type reviewJob struct {
|
||||
key string
|
||||
msgs []UnifiedMessage
|
||||
seqs []uint64
|
||||
}
|
||||
|
||||
// scheduleReviewLocked reports whether this admission should start a background
|
||||
// review and, when it should, marks that review as started and returns its job.
|
||||
//
|
||||
// 三重上界都在这里(调用方须持有 g.mu):
|
||||
// 1. 同一适配器至多一次在飞(reviewInFlight)—— 后台审查有界,不会因为多个群
|
||||
// 同时越阈而并发起 N 个模型调用;
|
||||
// 2. 同一会话每 window 至多一次(lastReview)—— 修复前它只在成功分支置位,
|
||||
// 于是 68% 解析失败时「每窗口一次」退化成「每条消息一次」;
|
||||
// 3. 整个适配器每 window 至多 reviewBudget 次(reviewHits)—— 与模型耗时无关的
|
||||
// 硬上界,会话再多也乘不上去。
|
||||
//
|
||||
// lastReview 在这里(**派发时**)写入,而不是等调用返回:成功与失败两条路径因此
|
||||
// 同样被节流,这是「每条入站帧都付一次 6 秒」的正面修复。
|
||||
func (g *FlowGate) scheduleReviewLocked(st *flowSession, key string, now time.Time) *reviewJob {
|
||||
if g.review == nil || g.maxMessages <= 0 || len(st.buffer) < g.reviewAt {
|
||||
return nil
|
||||
}
|
||||
if g.reviewInFlight || g.reviewRecently(st, now) || !g.reviewBudgetLocked(now) {
|
||||
return nil
|
||||
}
|
||||
st.lastReview = now
|
||||
g.reviewInFlight = true
|
||||
g.reviewHits = append(g.reviewHits, now)
|
||||
return &reviewJob{key: key, msgs: queueMessages(st.buffer), seqs: bufferSeqs(st.buffer)}
|
||||
}
|
||||
|
||||
// runReview executes one scheduled review off the admission path and folds its
|
||||
// verdict back into the conversation's pending set. It is the only place the
|
||||
// Reviewer is called.
|
||||
//
|
||||
// 失败方向(安全侧,与修复前 `err != nil` 分支同口径):审查失败**既不额外丢弃**
|
||||
// (失败绝不被解释成「低价值 ⇒ 丢掉」),**也不给任何条目保留特权**(失败绝不被
|
||||
// 解释成「高价值 ⇒ 提权」);待处理集合原样保留,只由既有的 FIFO 上限兜底。
|
||||
// 唯一的代价是这一批噪声没被修剪,而窗口节流保证下一次尝试要等到下个窗口。
|
||||
func (g *FlowGate) runReview(ctx context.Context, job *reviewJob) {
|
||||
rctx, cancel := reviewContext(ctx)
|
||||
defer cancel()
|
||||
keep, err := g.review(rctx, job.msgs)
|
||||
|
||||
g.mu.Lock()
|
||||
g.reviewInFlight = false
|
||||
var dropped []queuedMsg
|
||||
if err == nil {
|
||||
// 会话可能在审查期间被 GC 掉(空闲清理),那时结论无处可落,直接丢弃。
|
||||
if st := g.sessions[job.key]; st != nil {
|
||||
st.buffer, dropped = applyReviewSeqs(st.buffer, job.seqs, keep)
|
||||
}
|
||||
}
|
||||
g.mu.Unlock()
|
||||
|
||||
// 审计在锁外:钩子由 channelbridge 接(落库/面板),不该占着闸门的锁跑 I/O。
|
||||
g.auditDropped(dropped, "review_pruned")
|
||||
}
|
||||
|
||||
// reviewContext detaches the reviewer call from the admission caller's cancellation
|
||||
// — the inbound handler returns the moment the message is admitted, and on several
|
||||
// adapters its ctx dies with the frame — while still bounding the call so a hung
|
||||
// model cannot pin the single in-flight slot forever.
|
||||
func reviewContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithTimeout(context.WithoutCancel(ctx), reviewTimeout)
|
||||
}
|
||||
|
||||
// reviewRecently throttles review so it does not run on every message: it fires at
|
||||
// most once per window per conversation, which is the cost guard for the review model.
|
||||
func (g *FlowGate) reviewRecently(st *flowSession, now time.Time) bool {
|
||||
if st == nil || st.lastReview.IsZero() {
|
||||
return false
|
||||
}
|
||||
return now.Sub(st.lastReview) < g.window
|
||||
}
|
||||
|
||||
// reviewBudgetLocked enforces the per-adapter ceiling on reviewer calls started in
|
||||
// the current window. 调用方须持有 g.mu。
|
||||
func (g *FlowGate) reviewBudgetLocked(now time.Time) bool {
|
||||
g.reviewHits = g.trimHits(g.reviewHits, now)
|
||||
return len(g.reviewHits) < g.reviewBudget
|
||||
}
|
||||
|
||||
// applyReviewSeqs keeps only the buffered entries the reviewer chose. Entries are
|
||||
// matched by sequence number, so:
|
||||
// - entries evicted by the cap while the call was in flight simply do not appear
|
||||
// in the buffer any more (they were audited as "capped" at eviction time);
|
||||
// - entries that arrived **after** the snapshot are never judged by this review
|
||||
// and always survive (the reviewer never saw them).
|
||||
func applyReviewSeqs(buffer []queuedMsg, seqs []uint64, keep []int) (kept, dropped []queuedMsg) {
|
||||
reviewed := make(map[uint64]bool, len(seqs))
|
||||
for _, s := range seqs {
|
||||
reviewed[s] = true
|
||||
}
|
||||
keepSet := make(map[uint64]bool, len(keep))
|
||||
for _, i := range keep {
|
||||
if i >= 0 && i < len(seqs) {
|
||||
keepSet[seqs[i]] = true
|
||||
}
|
||||
}
|
||||
kept = make([]queuedMsg, 0, len(buffer))
|
||||
dropped = make([]queuedMsg, 0)
|
||||
for _, e := range buffer {
|
||||
if !reviewed[e.seq] || keepSet[e.seq] {
|
||||
kept = append(kept, e)
|
||||
continue
|
||||
}
|
||||
dropped = append(dropped, e)
|
||||
}
|
||||
return kept, dropped
|
||||
}
|
||||
|
||||
// bufferSeqs extracts the sequence numbers of buffered entries in order, so a
|
||||
// review verdict can be applied to exactly the entries it was asked about.
|
||||
func bufferSeqs(buffer []queuedMsg) []uint64 {
|
||||
out := make([]uint64, len(buffer))
|
||||
for i, e := range buffer {
|
||||
out[i] = e.seq
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package adapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -118,10 +119,16 @@ func TestFlowGate_MsgPerMinCapsAllNonOwner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
|
||||
// TestFlowGate_ReviewPrunesBacklogAfterAdmission 固定策略四的新口径:审查**不在准入
|
||||
// 路径上**。越阈那一条消息照常放行(它的到达只负责派发审查),审查完成后只修剪
|
||||
// **它当时看到的那批**待处理条目,并以 "review_pruned" 入审计。
|
||||
//
|
||||
// 变异判据:把审查改回在 Admit 里同步执行并按结论丢「刚到的那条」⇒ 本用例的
|
||||
// `v.Proceed` 断言红。
|
||||
func TestFlowGate_ReviewPrunesBacklogAfterAdmission(t *testing.T) {
|
||||
t.Parallel()
|
||||
now, _ := fixedClock(time.Unix(1000, 0))
|
||||
var audited []string
|
||||
audit := &auditRecorder{}
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 10,
|
||||
Window: time.Minute,
|
||||
@@ -130,9 +137,7 @@ func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
|
||||
// Keep only the first two messages; drop the rest as noise.
|
||||
return []int{0, 1}, nil
|
||||
},
|
||||
OnPruned: func(_ UnifiedMessage, reason string) {
|
||||
audited = append(audited, reason)
|
||||
},
|
||||
OnPruned: audit.hook(),
|
||||
})
|
||||
|
||||
// Seven messages stay below the 80% threshold (reviewAt = 8), no review fires.
|
||||
@@ -141,69 +146,87 @@ func TestFlowGate_ReviewPrunesWhenNearCap(t *testing.T) {
|
||||
t.Fatalf("message %d should be admitted before threshold: %+v", i, v)
|
||||
}
|
||||
}
|
||||
if len(audited) != 0 {
|
||||
t.Fatalf("review fired below threshold: %v", audited)
|
||||
waitReviewIdle(t, g)
|
||||
if n := len(audit.snapshot()); n != 0 {
|
||||
t.Fatalf("review fired below threshold: %d audits", n)
|
||||
}
|
||||
|
||||
// The 8th message crosses the 80% threshold → review runs and drops msg2..7,
|
||||
// including the just-admitted message 7.
|
||||
// The 8th message crosses the 80% threshold → the review is dispatched in the
|
||||
// background; the message itself is still admitted (admission never waits).
|
||||
v := g.Admit(context.Background(), groupMsg(7))
|
||||
if v.Proceed {
|
||||
t.Fatal("just-admitted message 7 must be pruned by the reviewer")
|
||||
if !v.Proceed {
|
||||
t.Fatalf("the message that crosses the threshold must still be admitted, got %+v", v)
|
||||
}
|
||||
if v.Reason != "review_pruned" {
|
||||
t.Fatalf("reason = %q, want review_pruned", v.Reason)
|
||||
if v.Reason != "" {
|
||||
t.Fatalf("reason = %q, want empty", v.Reason)
|
||||
}
|
||||
if len(v.Pruned) != 6 {
|
||||
t.Fatalf("pruned = %d, want 6 (msg2..msg7)", len(v.Pruned))
|
||||
waitReviewIdle(t, g)
|
||||
|
||||
// The review keeps 0 and 1, so msg2..7 are pruned from the pending set and audited.
|
||||
if n := len(audit.snapshot()); n != 6 {
|
||||
t.Fatalf("audit count = %d, want 6 (msg2..msg7)", n)
|
||||
}
|
||||
if len(audited) != 6 {
|
||||
t.Fatalf("audit count = %d, want 6", len(audited))
|
||||
}
|
||||
for _, r := range audited {
|
||||
if r != "review_pruned" {
|
||||
t.Fatalf("audit reason = %q, want review_pruned", r)
|
||||
for _, e := range audit.snapshot() {
|
||||
if e.reason != "review_pruned" {
|
||||
t.Fatalf("audit reason = %q, want review_pruned", e.reason)
|
||||
}
|
||||
}
|
||||
if n := pendingLen(g, ConversationKey(groupMsg(0))); n != 2 {
|
||||
t.Fatalf("pending set = %d, want 2 (only the kept entries)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlowGate_ReviewThrottledWithinWindow 固定「每会话每窗口至多一次审查」。
|
||||
// 审查者保留全部条目,使待处理集合恒在阈值上 —— 于是节流(而不是队列长度)是唯一的
|
||||
// 抑制因素;这正是修复前失守的那条判据(lastReview 只在成功分支置位)。
|
||||
func TestFlowGate_ReviewThrottledWithinWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
now, clock := fixedClock(time.Unix(1000, 0))
|
||||
var mu sync.Mutex
|
||||
reviews := 0
|
||||
g := NewFlowGate(FlowGateOptions{
|
||||
MaxMessages: 3,
|
||||
Window: time.Minute,
|
||||
Now: now,
|
||||
Review: func(_ context.Context, msgs []UnifiedMessage) ([]int, error) {
|
||||
Review: func(ctx context.Context, msgs []UnifiedMessage) ([]int, error) {
|
||||
mu.Lock()
|
||||
reviews++
|
||||
return []int{0}, nil
|
||||
mu.Unlock()
|
||||
return keepAll(ctx, msgs)
|
||||
},
|
||||
})
|
||||
count := func() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return reviews
|
||||
}
|
||||
|
||||
// First threshold crossing triggers a review.
|
||||
for i := 0; i < 3; i++ {
|
||||
g.Admit(context.Background(), groupMsg(i))
|
||||
}
|
||||
if reviews != 1 {
|
||||
t.Fatalf("reviews after crossing threshold = %d, want 1", reviews)
|
||||
waitReviewIdle(t, g)
|
||||
if got := count(); got != 1 {
|
||||
t.Fatalf("reviews after crossing threshold = %d, want 1", got)
|
||||
}
|
||||
|
||||
// Within the same window the gate must not re-review even when it refills.
|
||||
for i := 3; i < 6; i++ {
|
||||
for i := 3; i < 9; i++ {
|
||||
g.Admit(context.Background(), groupMsg(i))
|
||||
}
|
||||
if reviews != 1 {
|
||||
t.Fatalf("reviews within window = %d, want 1 (throttled)", reviews)
|
||||
waitReviewIdle(t, g)
|
||||
if got := count(); got != 1 {
|
||||
t.Fatalf("reviews within window = %d, want 1 (throttled)", got)
|
||||
}
|
||||
|
||||
// A fresh window allows a review again.
|
||||
*clock = clock.Add(2 * time.Minute)
|
||||
for i := 6; i < 9; i++ {
|
||||
for i := 9; i < 12; i++ {
|
||||
g.Admit(context.Background(), groupMsg(i))
|
||||
}
|
||||
if reviews != 2 {
|
||||
t.Fatalf("reviews after window roll = %d, want 2", reviews)
|
||||
waitReviewIdle(t, g)
|
||||
if got := count(); got != 2 {
|
||||
t.Fatalf("reviews after window roll = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,8 @@ package channelbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
@@ -201,22 +199,14 @@ func truncateText(s string, n int) string {
|
||||
return string(runes[:n]) + "…"
|
||||
}
|
||||
|
||||
// parseKeepIndices 解码审查模型返回的 JSON 索引数组,并按升序
|
||||
// 只保留范围内、去重后的值。越界或重复的
|
||||
// 索引被丢弃,因此模型幻觉出的数字绝不会淘汰真实消息。
|
||||
// parseKeepIndices 解码审查模型返回的编号数组,并按升序只保留范围内、去重后的值。
|
||||
// 越界或重复的索引被丢弃,因此模型幻觉出的数字绝不会淘汰真实消息。
|
||||
//
|
||||
// 解析本身在 adapter.ParseReviewIndices(解析属于闸门的契约,和 Reviewer 类型同一个包),
|
||||
// 它容忍对端回带的东西:**UTF-8 BOM**、首尾空白/换行、Markdown 代码围栏、数组外的解说
|
||||
// 文字。修复前这里是裸 json.Unmarshal,实测 9 天 1,909 次审查里 1,301 次解析失败
|
||||
// (其中 1,288 次错误串逐字是 `invalid character 'ï' looking for beginning of value`),
|
||||
// 每次失败都白烧一次模型调用 —— 见 DevDocs/audit/群聊排队与回旧话-2026-10-24.md。
|
||||
func parseKeepIndices(s string, bound int) ([]int, error) {
|
||||
var idx []int
|
||||
if err := json.Unmarshal([]byte(s), &idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[int]bool, len(idx))
|
||||
out := make([]int, 0, len(idx))
|
||||
for _, i := range idx {
|
||||
if i >= 0 && i < bound && !seen[i] {
|
||||
seen[i] = true
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out, nil
|
||||
return adapter.ParseReviewIndices(s, bound)
|
||||
}
|
||||
Reference in new issue
Block a user