feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
+30
-1
@@ -203,7 +203,7 @@ Harness = 围绕 LLM 的可靠性 / 安全 / 质量治理层。4 个组件均为
|
|||||||
|
|
||||||
| 优先级 | 项目 | 说明 |
|
| 优先级 | 项目 | 说明 |
|
||||||
|:------:|------|------|
|
|:------:|------|------|
|
||||||
| P0 | 真实负载 + 长稳压测,给容量曲线 | "生产级并发"需数据背书 |
|
| ~~P0~~ 🟡 | ~~容量压测曲线~~(首版):`cmd/loadtest` 闭环加压器 + 平台天花板实测(见下「容量实测」)。剩长稳/真实 LLM 大压 | "生产级并发"需数据背书 |
|
||||||
| ~~P0~~ ✅ | ~~本地模型(vLLM/Ollama) + reasoning_content 适配~~:Pool provider 感知(Ollama/vLLM 自动补 /v1 + 占位 key),统一走 OpenAI 兼容路径;ChatStream 加 onReasoning,思考过程 surface 到 exec 轨迹(不污染答案)。控制台加 ollama 选项。live:Ollama qwen2.5:0.5b 端到端出答案;deepseek-v4-pro「推理过程」入轨迹 | 对齐生产 Qwen |
|
| ~~P0~~ ✅ | ~~本地模型(vLLM/Ollama) + reasoning_content 适配~~:Pool provider 感知(Ollama/vLLM 自动补 /v1 + 占位 key),统一走 OpenAI 兼容路径;ChatStream 加 onReasoning,思考过程 surface 到 exec 轨迹(不污染答案)。控制台加 ollama 选项。live:Ollama qwen2.5:0.5b 端到端出答案;deepseek-v4-pro「推理过程」入轨迹 | 对齐生产 Qwen |
|
||||||
| P1 | 高可用:网关/调度多副本 + NATS 集群 + 自愈 | 解单点 |
|
| P1 | 高可用:网关/调度多副本 + NATS 集群 + 自愈 | 解单点 |
|
||||||
| P1 | 备份/灾备演练(PG/Milvus/Neo4j) | 数据安全 |
|
| P1 | 备份/灾备演练(PG/Milvus/Neo4j) | 数据安全 |
|
||||||
@@ -215,5 +215,34 @@ Harness = 围绕 LLM 的可靠性 / 安全 / 质量治理层。4 个组件均为
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 容量实测(首版,2026-06-26)
|
||||||
|
|
||||||
|
**方法**:`cmd/loadtest` 闭环加压(阶梯并发,SSE 流检测完成、非轮询,避免轮询放大)。
|
||||||
|
dispatcher 开 `LLM_FORCE_STUB=1 LLM_STUB_*_MS=0` 绕开真实 LLM 推理,量**平台自身全链路天花板**
|
||||||
|
(网关→NATS/JetStream→调度→图执行→工具RTT→Token回流→PG状态写),单 dispatcher、DISPATCHER_CONCURRENCY=64。
|
||||||
|
|
||||||
|
| 并发 | 吞吐/s | p50 | p95 | 备注 |
|
||||||
|
|---:|---:|---:|---:|---|
|
||||||
|
| 1 | 23 | 42ms | 46ms | **单任务纯平台开销 ~42ms** |
|
||||||
|
| 8 | 76 | 103ms | 115ms | |
|
||||||
|
| 16 | 96 | 161ms | 176ms | |
|
||||||
|
| 32 | 112 | 269ms | 345ms | **吞吐峰值** |
|
||||||
|
| 64 | 112 | 557ms | 676ms | 饱和(延迟翻倍、吞吐不增)|
|
||||||
|
| 128 | 102 | 1173ms | 1590ms | 过载、优雅降速 |
|
||||||
|
| 256 | 0 | — | — | 硬崩(连接/DNS 抖动)|
|
||||||
|
|
||||||
|
**结论**:
|
||||||
|
1. **单节点平台天花板 ≈ 110 全链路任务/秒**,单任务固有开销 ~42ms,饱和点 ~并发 32–64。
|
||||||
|
2. **吞吐瓶颈不是 DB 连接数**(池 25→80 吞吐不变),而是每任务多跳管线(JetStream fsync + 多次
|
||||||
|
NATS 回写 + Redis 录制 + 3 次 PG 状态写)的综合成本。
|
||||||
|
3. **C=256 硬崩**根因:DSN 用 `localhost`,pgx 每新建连接解析一次,极端并发下连接churn致 DNS 取消
|
||||||
|
→ 易修(DSN 换 `127.0.0.1` / 连接复用)。
|
||||||
|
4. **关键判断**:平台开销 42ms ≪ 真实 LLM 出一轮答案的秒级延迟,**平台不是瓶颈,GPU 才是**。
|
||||||
|
单 GPU 跑 Qwen 32B 约出几十轮/秒,远低于平台的 ~110/s → 横向拆服务(队列组多副本喂满 GPU 集群)
|
||||||
|
的意义成立;要提总吞吐应加 dispatcher 副本与 GPU,而非优化平台代码。
|
||||||
|
5. 待补:长稳(小时级)压测看内存/句柄泄漏;真实 LLM 端到端容量(受 provider 限流,需自部署后测)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
*本报告由 Opus 4.8 基于全程搭建经验 + 本次实测撰写;刻意压低自评水分。功能盘点可信,
|
*本报告由 Opus 4.8 基于全程搭建经验 + 本次实测撰写;刻意压低自评水分。功能盘点可信,
|
||||||
评分按成熟度折扣阅读。如与旧版(自动分析)冲突,以本版为准。*
|
评分按成熟度折扣阅读。如与旧版(自动分析)冲突,以本版为准。*
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// Command loadtest 是平台容量压测器:闭环加压、阶梯并发,输出吞吐/延迟分位曲线。
|
||||||
|
//
|
||||||
|
// 它只通过网关 HTTP API 打真实流量(登录 → POST /tasks → SSE 流等收尾),
|
||||||
|
// 故压的是「网关→NATS→调度→工具RTT→状态回流」全链路。配合 dispatcher 的 LLM_FORCE_STUB=1
|
||||||
|
// + LLM_STUB_*_MS=0 可绕开真实 LLM 推理,量出平台自身吞吐天花板(而非被模型节奏掩盖)。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
//
|
||||||
|
// go run ./cmd/loadtest -url http://localhost:8080 -email keytest@local.dev -pass keytest123456 \
|
||||||
|
// -levels 1,2,4,8,16,32,64 -dur 12s
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
base = flag.String("url", "http://localhost:8080", "网关地址")
|
||||||
|
email = flag.String("email", "keytest@local.dev", "登录邮箱")
|
||||||
|
pass = flag.String("pass", "keytest123456", "登录密码")
|
||||||
|
levelStr = flag.String("levels", "1,2,4,8,16,32,64", "并发阶梯(逗号分隔)")
|
||||||
|
dur = flag.Duration("dur", 12*time.Second, "每个阶梯加压时长")
|
||||||
|
prompt = flag.String("prompt", "用一句话介绍杭州", "任务输入")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
token, err := login(*base, *email, *pass)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("登录失败: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
body := taskBody(*prompt)
|
||||||
|
|
||||||
|
fmt.Printf("== 平台容量压测 == 目标 %s 每阶梯 %s (FORCE_STUB 下为平台天花板)\n", *base, *dur)
|
||||||
|
fmt.Printf("%-6s %-10s %-10s %-9s %-9s %-9s %-7s\n", "并发", "完成", "吞吐/s", "p50ms", "p95ms", "max ms", "错误")
|
||||||
|
for _, ls := range strings.Split(*levelStr, ",") {
|
||||||
|
c, err := strconv.Atoi(strings.TrimSpace(ls))
|
||||||
|
if err != nil || c <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r := runLevel(*base, token, body, c, *dur)
|
||||||
|
fmt.Printf("%-6d %-10d %-10.1f %-9d %-9d %-9d %-7d\n",
|
||||||
|
c, r.done, float64(r.done)/dur.Seconds(), r.p50, r.p95, r.max, r.errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
done, errs int64
|
||||||
|
p50, p95, max int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// runLevel 在并发 c 下闭环加压 dur:c 个 worker 不停 submit→等终态→再来,统计完成数与 e2e 延迟。
|
||||||
|
func runLevel(base, token string, body []byte, c int, dur time.Duration) result {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), dur)
|
||||||
|
defer cancel()
|
||||||
|
var done, errs int64
|
||||||
|
var mu sync.Mutex
|
||||||
|
var lats []int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < c; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
cl := &http.Client{Timeout: 60 * time.Second}
|
||||||
|
for ctx.Err() == nil {
|
||||||
|
t0 := time.Now()
|
||||||
|
if runOne(ctx, cl, base, token, body) {
|
||||||
|
ms := time.Since(t0).Milliseconds()
|
||||||
|
mu.Lock()
|
||||||
|
lats = append(lats, ms)
|
||||||
|
mu.Unlock()
|
||||||
|
atomic.AddInt64(&done, 1)
|
||||||
|
} else {
|
||||||
|
atomic.AddInt64(&errs, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return result{done: done, errs: errs, p50: pct(lats, 50), p95: pct(lats, 95), max: pct(lats, 100)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOne 提交一个任务并经 SSE token 流等到收尾(流结束即任务完成);返回是否成功。
|
||||||
|
// 用流而非轮询:每任务 1 条推送连接,避免轮询放大把网关自身压成瓶颈,量出真实管线吞吐。
|
||||||
|
func runOne(ctx context.Context, cl *http.Client, base, token string, body []byte) bool {
|
||||||
|
id, err := submit(ctx, cl, base, token, body)
|
||||||
|
if err != nil || id == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", base+"/api/v1/tasks/"+id+"/stream", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := cl.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
// 读到 EOF:网关在 token 流结束(任务收尾)时关闭 SSE。
|
||||||
|
_, err = io.Copy(io.Discard, resp.Body)
|
||||||
|
return err == nil && ctx.Err() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func submit(ctx context.Context, cl *http.Client, base, token string, body []byte) (string, error) {
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "POST", base+"/api/v1/tasks", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := cl.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusAccepted {
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
return "", fmt.Errorf("submit %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var r struct {
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&r)
|
||||||
|
return r.TaskID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func login(base, email, pass string) (string, error) {
|
||||||
|
b, _ := json.Marshal(map[string]string{"email": email, "password": pass})
|
||||||
|
resp, err := http.Post(base+"/api/v1/auth/login", "application/json", bytes.NewReader(b))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var r struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(resp.Body).Decode(&r)
|
||||||
|
if r.Token == "" {
|
||||||
|
return "", fmt.Errorf("no token (status %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return r.Token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskBody(prompt string) []byte {
|
||||||
|
graph := map[string]any{
|
||||||
|
"nodes": []any{
|
||||||
|
map[string]any{"id": "i", "kind": "input", "config": map[string]any{"text": prompt}},
|
||||||
|
map[string]any{"id": "a", "kind": "agent", "config": map[string]any{"system": "你是简洁的助手"}},
|
||||||
|
},
|
||||||
|
"edges": []any{map[string]any{"source": "i", "target": "a"}},
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(map[string]any{"graph": graph, "meta": map[string]any{}})
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// pct 返回延迟切片的第 p 百分位(p=100 即 max)。
|
||||||
|
func pct(v []int64, p int) int64 {
|
||||||
|
if len(v) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
sort.Slice(v, func(i, j int) bool { return v[i] < v[j] })
|
||||||
|
idx := (p * (len(v) - 1)) / 100
|
||||||
|
return v[idx]
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -39,10 +41,14 @@ type Pool struct {
|
|||||||
|
|
||||||
func NewPool() *Pool { return &Pool{} }
|
func NewPool() *Pool { return &Pool{} }
|
||||||
|
|
||||||
|
// forceStub 报告是否强制走降级桩(LLM_FORCE_STUB=1)——压测平台自身吞吐时用,
|
||||||
|
// 绕开真实 LLM 推理与计费,只压全链路 plumbing(网关→NATS→调度→工具RTT→回流)。
|
||||||
|
func forceStub() bool { return os.Getenv("LLM_FORCE_STUB") == "1" }
|
||||||
|
|
||||||
// SetConfig 热更新后端配置:重建 ChatModel 实例(控制面变更时调用)。
|
// SetConfig 热更新后端配置:重建 ChatModel 实例(控制面变更时调用)。
|
||||||
func (p *Pool) SetConfig(cfg *contract.ModelConfig) {
|
func (p *Pool) SetConfig(cfg *contract.ModelConfig) {
|
||||||
var cm model.BaseChatModel
|
var cm model.BaseChatModel
|
||||||
if cfg != nil && cfg.Ready() {
|
if cfg != nil && cfg.Ready() && !forceStub() {
|
||||||
built, err := buildChatModel(cfg)
|
built, err := buildChatModel(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[llm] 构建 ChatModel 失败(降级桩运行): %v\n", err)
|
fmt.Printf("[llm] 构建 ChatModel 失败(降级桩运行): %v\n", err)
|
||||||
@@ -222,18 +228,31 @@ func toSchema(msgs []ChatMessage) []*schema.Message {
|
|||||||
// ---- 占位降级(未配置后端时)----
|
// ---- 占位降级(未配置后端时)----
|
||||||
|
|
||||||
// 占位参数:模拟真实后端的 TTFT(首 token 延迟) 与逐 token 间隔。
|
// 占位参数:模拟真实后端的 TTFT(首 token 延迟) 与逐 token 间隔。
|
||||||
const (
|
// 可经 env 调整(压测平台吞吐时设 0 → 近瞬时桩,测出 plumbing 天花板而非被桩节奏掩盖)。
|
||||||
timeToFirstToken = 700 * time.Millisecond
|
var (
|
||||||
interTokenDelay = 60 * time.Millisecond
|
timeToFirstToken = envDuration("LLM_STUB_TTFT_MS", 700*time.Millisecond)
|
||||||
|
interTokenDelay = envDuration("LLM_STUB_INTERTOKEN_MS", 60*time.Millisecond)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// envDuration 读毫秒 env(允许 0),缺省回退 def。
|
||||||
|
func envDuration(key string, def time.Duration) time.Duration {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||||
|
return time.Duration(n) * time.Millisecond
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
// StreamText 按节奏把给定文本流式回调(未配置真实后端时的降级桩)。
|
// StreamText 按节奏把给定文本流式回调(未配置真实后端时的降级桩)。
|
||||||
func (p *Pool) StreamText(ctx context.Context, text string, onToken func([]byte)) error {
|
func (p *Pool) StreamText(ctx context.Context, text string, onToken func([]byte)) error {
|
||||||
|
if timeToFirstToken > 0 {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case <-time.After(timeToFirstToken):
|
case <-time.After(timeToFirstToken):
|
||||||
}
|
}
|
||||||
|
}
|
||||||
for _, tok := range tokenize(text) {
|
for _, tok := range tokenize(text) {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -241,8 +260,10 @@ func (p *Pool) StreamText(ctx context.Context, text string, onToken func([]byte)
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
onToken([]byte(tok))
|
onToken([]byte(tok))
|
||||||
|
if interTokenDelay > 0 {
|
||||||
time.Sleep(interTokenDelay)
|
time.Sleep(interTokenDelay)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -83,3 +84,13 @@ func splitEnv(key string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envInt 读正整数环境变量,缺省回退 def。
|
||||||
|
func envInt(key string, def int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ func Guardrail() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RateLimit 基于 Redis 的会话级限流(按客户端 IP,每分钟上限)。
|
// RateLimit 基于 Redis 的会话级限流(按客户端 IP,每分钟上限)。
|
||||||
// Redis 降级时 Allow 始终放行,不阻断业务。
|
// 上限经 RATE_LIMIT_PER_MIN 配置(缺省 120);压测可调高。Redis 降级时始终放行,不阻断业务。
|
||||||
func RateLimit(cache *store.Redis) gin.HandlerFunc {
|
func RateLimit(cache *store.Redis) gin.HandlerFunc {
|
||||||
const perMinute = 120
|
perMinute := int64(envInt("RATE_LIMIT_PER_MIN", 120))
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
ok, _ := cache.Allow(c.Request.Context(), c.ClientIP(), perMinute, time.Minute)
|
ok, _ := cache.Allow(c.Request.Context(), c.ClientIP(), perMinute, time.Minute)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
Reference in New Issue
Block a user