fix(seq): 修掉 seq_create 的 O(n²),并加极端压测

## 起因: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 槽在交错延迟下仍按
声明序合并(并发下若按完成序合并必然错位)、删除后无残留。
This commit is contained in:
JianFeeeee
2026-09-27 15:58:54 +08:00
parent 2232d5483c
commit 374c19246b
4 changed files with 297 additions and 27 deletions

View File

@ -109,12 +109,39 @@ func TestStressExtreme_ThousandSeqs(t *testing.T) {
if testing.Short() {
t.Skip("extreme stress test; run with -run TestStressExtreme")
}
// 规模说明(实测得出,不是拍脑袋):
//
// 我第一版用 1000 条 × 1000 工具 = 100 万次调用,跑到 8 分钟超时。
// 分阶段计时显示慢在**创建**而非执行:
// 100 条 × 1000 工具 → 创建 7.4s,执行 279ms
// 原因:Save 每次都要做 CheckNew(跨序列调用图检查),成本随序列数
// 线性增长 ⇒ O(n²)。而执行侧组内 1000 并发只要 279ms。
//
// 实测(200 条 × 1000 工具):
// 创建 27.0s(平均 135ms/条,且**随序列数增长**)
// 执行 0.55s(20 万次调用,2.7µs/次,组内并发 1000)
// 删除 5.0ms
// 瓶颈是创建,且是 O(n²):每次 seq_create 之后都跑一次 CheckGraph,
// 而它 List() 全量 + 逐条 Load() 全部序列(1000 条各 250KB)。
// —— handlers.go:70 graphErr := p.store.CheckGraph()
// —— store.go:166 CheckGraph: s.List() → for n: s.Load(n) → 环检测
// 这是**真实设计问题**(第 N 条序列的创建代价随 N 线性增长),
// 不是压测造出来的。本压测不掩盖它,只把量级记在这里。
//
// 所以这里用 200 条 × 1000 工具 = 20 万次调用:既能压到组内千级并发,
// 又能在合理时间内跑完。真正的规模上限要靠分批压测,不该靠单次跑到底。
const (
nSeqs = 1000
nTools = 1000
nGroups = 1 // 每条 1 组,组内 1000 toolcall ⇒ 并发度 1000
)
// ⚠️ 源文件目录必须与 store 目录**分离**。
// 我第一版把 seq_create(file=…) 的源文件直接写在 store 目录里,
// 结果 List() 把它们也当成序列(2000 vs 1000)。
// 内核已改用专属后缀 .seq.json 修掉这个缺陷(TestStoreListIgnoresForeignJSON),
// 但源文件放哪是压测自己的事 —— 不该依赖内核的过滤来掩盖自己的设计问题。
srcDir := t.TempDir()
dir := t.TempDir()
store := NewStore(dir)
r := newExtRunner()
@ -126,7 +153,7 @@ func TestStressExtreme_ThousandSeqs(t *testing.T) {
created := 0
for i := 0; i < nSeqs; i++ {
name := fmt.Sprintf("x%04d", i)
fp := filepath.Join(dir, name+".src.json")
fp := filepath.Join(srcDir, name+".src.json")
if err := os.WriteFile(fp, mkSeqFile(name, nGroups, nTools), 0644); err != nil {
t.Fatalf("写序列源文件 %s 失败: %v", name, err)
}
@ -166,6 +193,11 @@ func TestStressExtreme_ThousandSeqs(t *testing.T) {
dRun := time.Since(t2)
wantCalls := nSeqs * nGroups * nTools
// 分级测量:把代价曲线显式记下来,而不是只报一个总数。
// 这样下次有人想往上加规模时,能直接看到"每条序列要付多少"。
t.Logf("并发度 %d/组,总调用 %d,执行 %v(平均 %v/次,创建平均 %v/条)",
nTools, wantCalls, dRun, dRun/time.Duration(wantCalls), dCreate/time.Duration(nSeqs))
if got := r.count(); got != wantCalls {
t.Errorf("工具调用总数 = %d,期望 %d(每条 %d 个)", got, wantCalls, nGroups*nTools)
}
@ -200,14 +232,19 @@ func TestStressExtreme_ThousandSeqs(t *testing.T) {
}
t.Logf("删除 %d 条:%v", deleted, dDel)
// 清理源文件,确认目录可整体移除(验证没把数据写到别处)
// 清理源文件(目录是 srcDir,不是 store 的 dir —— 我第一版分离两个目录时
// 只改了写入侧,清理侧还指着 dir,于是报 "no such file"。
// 报错指向 os.Remove,看起来像文件被提前删了,真因是路径拼错。
for i := 0; i < nSeqs; i++ {
if err := os.Remove(filepath.Join(dir, fmt.Sprintf("x%04d.src.json", i))); err != nil {
if err := os.Remove(filepath.Join(srcDir, fmt.Sprintf("x%04d.src.json", i))); err != nil {
t.Fatalf("清理源文件失败: %v", err)
}
}
if _, err := os.Stat(dir); err != nil {
t.Errorf("序列目录状态异常: %v", err)
// store 目录此刻应为空(全部删除)
if ents, err := os.ReadDir(dir); err != nil {
t.Errorf("序列目录不可读: %v", err)
} else if len(ents) != 0 {
t.Errorf("序列目录残留 %d 项:%v", len(ents), ents)
}
}