mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
## 起因:1000×1000 压测直接跑爆
用户要求「1000 条序列 × 每条 1000 个组内 toolcall」。第一版跑满 8 分钟超时。
分阶段计时定位到瓶颈:
| 阶段 | 200 条 × 1000 工具 |
|---|---|
| 创建 | **27.0s**(135ms/条,**随序列数线性增长**) |
| 执行(组内 1000 并发) | 0.55s(20 万次调用,2.7µs/次) |
| 删除 | 4.7ms |
瓶颈在创建,不在执行。
## 根因
```go
// handlers.go:70 —— 每次 seq_create 之后
graphErr := p.store.CheckGraph()
// store.go:166 —— List() 全量 + 逐条 Load() 全部序列
```
1000 条各 250KB ⇒ 每次创建都重读 250MB 并反序列化。第 N 条的创建代价
随 N 线性增长,总计 O(n²)。
## ★ 走过的弯路:我一度建议「把校验挪到运行期」—— 那是错的
store.go:163 明确写着:
两条检查(都必须在**建序列/保存**时做,而不是等运行):
1. 每个 seq_call 的目标必须存在(不存在会在运行期才发现,浪费一整轮)
**校验时机是语义,不是性能旋钮。** 目标不存在若等到运行才发现,模型已经
白白花掉一整轮工具调用。性能问题不能靠挪语义来解。
## 修法:缓存调用边,Save 做 O(1) 增量
```go
// Store 新增
graph map[string][]string // 序列名 → 它调用的目标(裸名)
// Save: 只更新这一条的边
s.graph[seq.Name] = edgesOf(seq)
// Delete: 移除这一条的边
delete(s.graph, name)
```
`callTargets` 只依赖 AST,不必每次从盘重建。**校验语义完全不变** —— 目标
存在性与三色 DFS 环检测都照旧在建序列时执行。
## 判据
- TestStoreGraphCacheKeepsSemantics 逐条钉住三个保证:目标存在性 ✓、
环检测 ✓、删除后不再误报成环 ✓
(这类优化最危险的失败模式是"校验还在跑但少查了某种情况")
- TestStoreSaveScalesLinearly 分段对比后半程/前半程每条耗时。
★ 判据自己改过一次:初版用「总耗时 ÷ 单条耗时」,而单条只有 48µs 时
噪声占比过高,同一份代码两次跑出 84× 和 203× —— 判据不稳定时报的
失败就是噪声,比没判据更糟。改成分段对比(平方时后半程会慢约 n/2
倍,线性时基本持平),阈值 3 倍留足磁盘与 GC 抖动余量。
实测 300 条:84~203× 单条(线性期望 300×),平方会是 90000×。
## 压测本身也修了两个自己的 bug
- 源文件目录与 store 目录分离时只改了写入侧,清理侧还指着 store 目录 ⇒
报 "no such file"。看起来像文件被提前删了,真因是路径拼错。
- newE2EPlugin 的 runner 参数写死 *e2eRunner,压测换替身就编译不过 ⇒
改为接受 seqRunner 接口。
## 压测规模
TestStressExtreme_ThousandSeqs 现为 1000 条 × 1000 toolcall(O(n²) 修复后
可跑)。判据全是**不变量**:每工具恰好调一次、1000 槽在交错延迟下仍按
声明序合并(并发下若按完成序合并必然错位)、删除后无残留。
359 lines
11 KiB
Go
359 lines
11 KiB
Go
package seq
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
)
|
||
|
||
// maxCallDepth 是**嵌套调用的结构上界**,不是配置项。
|
||
//
|
||
// 沿用内核 MaxInterruptFrames 的做法(见 core/scheduler.go:271「是中断栈帧数的
|
||
// 结构上界,不是配置项」):上界一旦可配,总有人会把它调大到栈溢出。
|
||
//
|
||
// 取 4 与内核的 4 级中断一致。
|
||
const maxCallDepth = 4
|
||
|
||
// Store 负责序列的持久化:**存 AST,不存文本**。
|
||
//
|
||
// 为什么不存原始文本:执行期若重新解析文本,一次格式改动就会改变已保存
|
||
// 序列的行为;存 AST 则解析只发生在创建时,注释/空白/引号形式在 AST
|
||
// 层面已消失,不引入执行期差异。
|
||
type Store struct {
|
||
dir string
|
||
mu sync.RWMutex
|
||
|
||
// graph 是**已解析的调用边**缓存:序列名 → 它调用的目标(裸名,无 #)。
|
||
//
|
||
// ★ 为什么需要它:CheckGraph 原来每次都 s.List() + 逐条 s.Load(n),
|
||
// 把**全部**序列重新读盘并反序列化(1000 条各 250KB ⇒ 每次创建都重读
|
||
// 250MB)。实测创建 200 条要 27s、平均 135ms/条且**随序列数线性增长**
|
||
// —— O(n²)。
|
||
//
|
||
// 正确修法不是"挪到运行期检查":store.go:163 明确写了
|
||
// "都必须在建序列/保存时做,而不是等运行",因为目标不存在要等到
|
||
// 运行才发现会浪费一整轮。校验时机是**语义**,不能为了性能挪。
|
||
// 该做的是让保存时的全图检查不必重读盘。
|
||
graph map[string][]string
|
||
}
|
||
|
||
// edgesOf 返回某序列的调用边(已解析)。
|
||
func edgesOf(seq *Sequence) []string {
|
||
var out []string
|
||
for _, tgt := range callTargets(seq) {
|
||
out = append(out, strings.TrimPrefix(tgt, "#"))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// graphOf 返回调用图快照(读时加锁)。
|
||
func (s *Store) graphOf() map[string][]string {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
out := make(map[string][]string, len(s.graph))
|
||
for k, v := range s.graph {
|
||
out[k] = append([]string(nil), v...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// invalidateGraph 丢弃缓存,下次访问时从磁盘重建。
|
||
func (s *Store) invalidateGraph() {
|
||
s.mu.Lock()
|
||
s.graph = nil
|
||
s.mu.Unlock()
|
||
}
|
||
|
||
// NewStore 在 dir 下管理序列文件(不创建目录,由 Save 惰性创建)。
|
||
func NewStore(dir string) *Store { return &Store{dir: dir} }
|
||
|
||
// fileOf 返回某序列的落盘路径。
|
||
func (s *Store) fileOf(name string) string {
|
||
return filepath.Join(s.dir, name+".json")
|
||
}
|
||
|
||
// Save 落盘一条序列的 AST。
|
||
func (s *Store) Save(seq *Sequence) error {
|
||
if seq == nil || strings.TrimSpace(seq.Name) == "" {
|
||
return fmt.Errorf("序列缺少 name")
|
||
}
|
||
if err := s.checkName(seq.Name); err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(s.dir, 0755); err != nil {
|
||
return fmt.Errorf("创建序列目录失败: %w", err)
|
||
}
|
||
b, err := json.MarshalIndent(seq, "", " ")
|
||
if err != nil {
|
||
return fmt.Errorf("序列化序列 %q 失败: %w", seq.Name, err)
|
||
}
|
||
// 静态校验:同序列内的 group 引用必须存在、不得自调用。
|
||
// 跨序列目标的存在性由 CheckGraph 统一查(此时新序列还没落盘)。
|
||
if err := s.CheckNew(seq); err != nil {
|
||
return err
|
||
}
|
||
// 先写临时文件再 rename:避免写一半被读(与内核原子替换同一思路)
|
||
tmp := s.fileOf(seq.Name) + ".tmp"
|
||
if err := os.WriteFile(tmp, b, 0644); err != nil {
|
||
return fmt.Errorf("写序列 %q 失败: %w", seq.Name, err)
|
||
}
|
||
if err := os.Rename(tmp, s.fileOf(seq.Name)); err != nil {
|
||
// 清理失败**有意忽略**:rename 已失败,再报一个清理错误只会
|
||
// 盖掉真正的失败原因(这正是 rename 失败要暴露的那条)。
|
||
// 残留的 .tmp 由下次 Save 覆盖。
|
||
_ = os.Remove(tmp)
|
||
return fmt.Errorf("替换序列 %q 失败: %w", seq.Name, err)
|
||
}
|
||
// 增量维护调用图:只更新**这一条**的边,不重读全量。
|
||
//
|
||
// 不这样做的话,CheckGraph 每次都要从盘重建图,O(n²) 会原样回来
|
||
// (实测 200 条创建 27s、平均 135ms/条且随序列数线性增长)。
|
||
s.mu.Lock()
|
||
if s.graph == nil {
|
||
s.graph = make(map[string][]string)
|
||
}
|
||
s.graph[seq.Name] = edgesOf(seq)
|
||
s.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// Load 读回一条序列的 AST。
|
||
func (s *Store) Load(name string) (*Sequence, error) {
|
||
if err := s.checkName(name); err != nil {
|
||
return nil, err
|
||
}
|
||
b, err := os.ReadFile(s.fileOf(name))
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, fmt.Errorf("序列 %q 不存在(用 seq_list 看可用序列)", name)
|
||
}
|
||
return nil, fmt.Errorf("读序列 %q 失败: %w", name, err)
|
||
}
|
||
var seq Sequence
|
||
dec := json.NewDecoder(strings.NewReader(string(b)))
|
||
dec.DisallowUnknownFields()
|
||
if err := dec.Decode(&seq); err != nil {
|
||
return nil, fmt.Errorf("序列 %q 的存档损坏: %w", name, err)
|
||
}
|
||
return &seq, nil
|
||
}
|
||
|
||
// Delete 删除一条序列。不存在时报错(不静默成功 —— 模型会以为删掉了)。
|
||
func (s *Store) Delete(name string) error {
|
||
if err := s.checkName(name); err != nil {
|
||
return err
|
||
}
|
||
if err := os.Remove(s.fileOf(name)); err != nil {
|
||
if os.IsNotExist(err) {
|
||
return fmt.Errorf("序列 %q 不存在(用 seq_list 看可用序列)", name)
|
||
}
|
||
return fmt.Errorf("删除序列 %q 失败: %w", name, err)
|
||
}
|
||
// 该序列的边已从图里移除,否则 CheckGraph 会报"调用了不存在的序列"。
|
||
s.mu.Lock()
|
||
delete(s.graph, name)
|
||
s.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// List 列出全部序列名(升序)。
|
||
func (s *Store) List() []string {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
entries, err := os.ReadDir(s.dir)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for _, e := range entries {
|
||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||
continue
|
||
}
|
||
out = append(out, strings.TrimSuffix(e.Name(), ".json"))
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// checkName 校验序列名:必须能安全用作文件名。
|
||
//
|
||
// ⚠️ 名字来自模型,且会被拼进路径(Load/Save/Delete 都用)⇒ 必须挡住
|
||
// 路径穿越(`../`)与分隔符,否则 `seq_load` 能读到任意文件。
|
||
func (s *Store) checkName(name string) error {
|
||
if strings.TrimSpace(name) == "" {
|
||
return fmt.Errorf("序列名不能为空")
|
||
}
|
||
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||
return fmt.Errorf("序列名 %q 非法:不能包含路径分隔符或 ..", name)
|
||
}
|
||
if strings.HasPrefix(name, ".") {
|
||
return fmt.Errorf("序列名 %q 非法:不能以 . 开头", name)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// callTargets 返回某序列内所有 seq_call 的跨序列目标。
|
||
func callTargets(seq *Sequence) []string {
|
||
var out []string
|
||
for _, g := range seq.Groups {
|
||
for _, t := range g.Tools {
|
||
if t.Tool != "seq_call" && t.Tool != "seq_when_call" {
|
||
continue
|
||
}
|
||
if tgt, ok := t.Args["target"].(string); ok && strings.HasPrefix(tgt, "#") {
|
||
out = append(out, tgt)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// CheckGraph 校验跨序列调用图。
|
||
//
|
||
// 两条检查(都必须在**建序列/保存**时做,而不是等运行):
|
||
// 1. 每个 `seq_call` 的目标必须存在(不存在会在运行期才发现,浪费一整轮)
|
||
// 2. 不得有环(否则无限嵌套,每层都真的在调工具)
|
||
func (s *Store) CheckGraph() error {
|
||
// 用**缓存的调用边**,不重读全部序列。
|
||
//
|
||
// 校验语义与原来完全一致(同样在建序列时做、同样报同样的错),
|
||
// 只是不再为拿边信息把每条序列反序列化一遍。
|
||
graph := s.loadGraph()
|
||
// 目标存在性
|
||
for name, targets := range graph {
|
||
for _, bare := range targets {
|
||
if _, ok := graph[bare]; !ok {
|
||
return fmt.Errorf("序列 %q 调用了不存在的序列 %q(用 seq_list 看可用序列)", name, "#"+bare)
|
||
}
|
||
}
|
||
}
|
||
// 环检测(三色 DFS),错误里带**环路径**便于定位
|
||
const (
|
||
white = 0 // 未访问
|
||
gray = 1 // 在栈上
|
||
black = 2 // 已完成
|
||
)
|
||
color := make(map[string]int, len(graph))
|
||
var path []string
|
||
var dfs func(n string) error
|
||
dfs = func(n string) error {
|
||
color[n] = gray
|
||
path = append(path, "#"+n)
|
||
for _, bare := range graph[n] {
|
||
switch color[bare] {
|
||
case gray:
|
||
// 找到环:从 path 里第一次出现 bare 处截断,给出完整环
|
||
// path 里存的是带 # 前缀的显示名,graph 的键是裸名
|
||
ring := path
|
||
for i, p := range path {
|
||
if p == "#"+bare {
|
||
ring = path[i:]
|
||
break
|
||
}
|
||
}
|
||
return fmt.Errorf("跨序列调用成环: %s → #%s",
|
||
strings.Join(ring, " → "), bare)
|
||
case white:
|
||
if err := dfs(bare); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
path = path[:len(path)-1]
|
||
color[n] = black
|
||
return nil
|
||
}
|
||
for n := range graph {
|
||
if color[n] == white {
|
||
if err := dfs(n); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CheckNew 在**保存前**校验一条新序列:组内/跨序列引用是否存在。
|
||
//
|
||
// 分两步:先查**同序列内**的 seq_call 目标(组名)是否存在,再查
|
||
// **跨序列**目标是否已存在(存盘之后才能查全图,故由 Save 后的
|
||
// CheckGraph 负责)。
|
||
func (s *Store) CheckNew(seq *Sequence) error {
|
||
groupNames := map[string]bool{}
|
||
for _, g := range seq.Groups {
|
||
groupNames[g.Name] = true
|
||
}
|
||
for _, g := range seq.Groups {
|
||
for i, t := range g.Tools {
|
||
if t.Tool != "seq_call" && t.Tool != "seq_when_call" {
|
||
continue
|
||
}
|
||
tgt, _ := t.Args["target"].(string)
|
||
if strings.TrimSpace(tgt) == "" {
|
||
return fmt.Errorf("group %q 第 %d 个工具的 seq_call 缺少 target", g.Name, i+1)
|
||
}
|
||
if strings.HasPrefix(tgt, "#") {
|
||
bare := strings.TrimPrefix(tgt, "#")
|
||
if bare == seq.Name {
|
||
return fmt.Errorf("序列 %q 调用了自身(会造成无限递归)", seq.Name)
|
||
}
|
||
// ⚠️ 跨序列目标**不在这里**要求存在:互调的两条序列
|
||
// 谁也存不下来(A 要 B 先在、B 要 A 先在),是设计上死锁。
|
||
// 存在性与环统一由 CheckGraph 在保存后兜底。
|
||
continue
|
||
}
|
||
if !groupNames[tgt] {
|
||
return fmt.Errorf("group %q 第 %d 个工具调用了不存在的 group %q"+
|
||
"(本序列现有:%s)", g.Name, i+1, tgt, joinNames(groupNames))
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func joinNames(m map[string]bool) string {
|
||
if len(m) == 0 {
|
||
return "(无)"
|
||
}
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return strings.Join(out, ", ")
|
||
}
|
||
|
||
// loadGraph 返回调用图,必要时从磁盘重建。
|
||
//
|
||
// 只在**缓存未建立**时重建;之后由 Save 增量维护。
|
||
// 外部直接改文件(删了序列文件、改了内容)会让缓存过期 ——
|
||
// Delete 已显式失效,跨进程改动不属于本 Store 的职责范围。
|
||
func (s *Store) loadGraph() map[string][]string {
|
||
s.mu.RLock()
|
||
g := s.graph
|
||
s.mu.RUnlock()
|
||
if g != nil {
|
||
return g
|
||
}
|
||
|
||
names := s.List()
|
||
g2 := make(map[string][]string, len(names))
|
||
for _, n := range names {
|
||
seq, err := s.Load(n)
|
||
if err != nil {
|
||
// 读不出来的序列(并发删除/损坏)不参与图检查,
|
||
// 但不能因此让整次检查失败 —— 真正的错误会在 Load 时报。
|
||
continue
|
||
}
|
||
g2[n] = edgesOf(seq)
|
||
}
|
||
s.mu.Lock()
|
||
s.graph = g2
|
||
s.mu.Unlock()
|
||
return g2
|
||
}
|