feat(harness): 成本/Token 预算护栏 —— 单任务硬上限 + 单用户日预算(恒温器最后一环)

token 用量估算计量(CJK≈1/字、ASCII≈1/4字,无需分词器,护栏够用)。

单任务硬上限(dispatcher):Budget 挂 ctx 沿图透传,各 LLM 节点(对话/ReAct/compose/
报告)入口计输入 token、出口计输出,触顶即中止整图——防失控成本(死循环/超大报告)。
报告路径优雅降级:触顶跳过剩余章节出部分稿,不整体失败。预算来源 Meta.token_budget
或 env TASK_TOKEN_BUDGET(默认 20 万)。

单用户日预算(gateway):dispatcher 收尾经 NATS 回写 UsageEvent → 网关按用户按天累计
Redis(48h 过期自滚动)→ 提交前门控 USER_DAILY_TOKEN_BUDGET(0=不限,超额 402)。
/billing 升级为真实用量:当日已用 / 日预算 / 余额。

契约 UsageEvent + MetaTokenBudget + SubjectUsage;bus Publish/SubscribeUsage;
orchestrator SetUsageSink + 预算触顶 failed(不计熔断)。harness budget 5 单测,三模块全绿。
live:单任务 budget=30 → failed(已用约689);用户日 budget=200 → /billing remaining=0 → 402。

至此 harness 由「测温计」完成向「恒温器」的演进(评测闭环/纠偏/忠实度/脱敏/输入护栏/预算六项)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-26 10:00:51 +08:00
parent 2f78fc565e
commit faa1871760
16 changed files with 442 additions and 17 deletions
+14
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"log"
"os"
"time"
"github.com/sundynix/sundynix-gateway/internal/blob"
"github.com/sundynix/sundynix-gateway/internal/nats"
@@ -78,6 +79,19 @@ func main() {
log.Printf("[gateway] subscribe eval: %v", err)
}
// 成本护栏:订阅 dispatcher 回写的任务 token 用量,按用户按天累计到 Redis(供提交前日预算门控 / 计费)。
if _, err := bus.SubscribeUsage(func(ev *contract.UsageEvent) {
if ev.UserID == "" || ev.TotalTok <= 0 {
return
}
day := time.UnixMilli(ev.TS).Format("20060102")
if _, err := cache.AddUsage(context.Background(), ev.UserID, day, ev.TotalTok); err != nil {
log.Printf("[gateway] 累计用量 user=%s 失败: %v", ev.UserID, err)
}
}); err != nil {
log.Printf("[gateway] subscribe usage: %v", err)
}
r := router.New(db, cache, bus, blobStore)
addr := envOr("GATEWAY_ADDR", ":8080")
log.Printf("[gateway] listening on %s", addr)
@@ -7,6 +7,8 @@ import (
"io"
"log"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-contrib/sse"
@@ -42,6 +44,17 @@ func (h *Handler) SubmitTask(c *gin.Context) {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
// 成本护栏:单用户当日 token 日预算门控(USER_DAILY_TOKEN_BUDGET0=不限)。超额则拒绝新任务。
if budget := userDailyTokenBudget(); budget > 0 {
uid := userID(c)
used := h.cache.GetUsage(c.Request.Context(), uid, time.Now().Format("20060102"))
if used >= int64(budget) {
c.JSON(http.StatusPaymentRequired, gin.H{
"error": "已达当日 token 预算上限", "used": used, "budget": budget,
})
return
}
}
// 附上用户标识(召回偏好记忆)与会话标识(召回短期多轮历史)。
// 真实场景由鉴权/会话中间件注入;此处用请求头,缺省匿名/默认会话。
task.Meta[contract.MetaUserID] = userID(c)
@@ -362,12 +375,36 @@ func sessionID(c *gin.Context) string {
return "default"
}
// userDailyTokenBudget 读单用户当日 token 预算(env USER_DAILY_TOKEN_BUDGET,缺省/非法=0 即不限)。
func userDailyTokenBudget() int {
if v := os.Getenv("USER_DAILY_TOKEN_BUDGET"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return n
}
}
return 0
}
func (h *Handler) Billing(c *gin.Context) {
// TODO: 商业化与计费模块;暂以已提交任务计数演示真实读库。
n, err := h.db.CountTasks(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "tasks_submitted": n, "persisted": h.db.Enabled()})
// 成本护栏:当日 token 用量与预算(按用户隔离)。
uid := userID(c)
used := h.cache.GetUsage(c.Request.Context(), uid, time.Now().Format("20060102"))
budget := userDailyTokenBudget()
remaining := -1 // -1 表示不限额
if budget > 0 {
if r := budget - int(used); r > 0 {
remaining = r
} else {
remaining = 0
}
}
c.JSON(http.StatusOK, gin.H{
"status": "ok", "tasks_submitted": n, "persisted": h.db.Enabled(),
"token_used_today": used, "daily_budget": budget, "remaining": remaining,
})
}
@@ -74,6 +74,11 @@ func (b *Bus) SubscribeEval(onEvent func(*contract.EvalEvent)) (func() error, er
return b.inner.SubscribeEval(onEvent)
}
// SubscribeUsage 订阅 dispatcher 回写的任务 token 用量(累计到用户日预算 / 计费)。
func (b *Bus) SubscribeUsage(onEvent func(*contract.UsageEvent)) (func() error, error) {
return b.inner.SubscribeUsage(onEvent)
}
// ServeConfig 让网关作为配置控制面,响应某 kind 的配置请求。
func (b *Bus) ServeConfig(kind string, provide func() *contract.ModelConfig) (func() error, error) {
return b.inner.ServeConfig(kind, provide)
+35
View File
@@ -50,6 +50,41 @@ func (r *Redis) Allow(ctx context.Context, key string, limit int64, window time.
// ---- Token 流持久化(Redis Stream:可回放的追加日志,根治 SSE 连晚/重连丢 token----
// ---- Token 用量日预算(成本护栏:按用户按天累计 token,供提交前门控)----
// usageDailyKey 是某用户某天的累计 token keyday 形如 20260626,便于到期自然滚动)。
func usageDailyKey(userID, day string) string { return "sundynix:usage:" + userID + ":" + day }
// AddUsage 给某用户当天累计 token,并返回累计后总量(首次设 48h 过期自动清理)。降级返回 (0,nil)。
func (r *Redis) AddUsage(ctx context.Context, userID, day string, tokens int) (int64, error) {
if r.rdb == nil || userID == "" {
return 0, nil
}
rk := usageDailyKey(userID, day)
n, err := r.rdb.IncrBy(ctx, rk, int64(tokens)).Result()
if err != nil {
return 0, err
}
if n == int64(tokens) { // 首次累计:设过期
_ = r.rdb.Expire(ctx, rk, 48*time.Hour).Err()
}
return n, nil
}
// GetUsage 取某用户当天已用 token(不存在/降级返回 0)。
func (r *Redis) GetUsage(ctx context.Context, userID, day string) int64 {
if r.rdb == nil || userID == "" {
return 0
}
n, err := r.rdb.Get(ctx, usageDailyKey(userID, day)).Int64()
if err != nil {
return 0
}
return n
}
// ---- Token 流持久化(Redis Stream:可回放的追加日志,根治 SSE 连晚/重连丢 token----
const streamTTL = 10 * time.Minute
func streamKey(taskID string) string { return "sundynix:stream:" + taskID }