Files
HomeAgent/internal/plugins/seq/e2e_test.go
JianFeeeee 374c19246b 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 槽在交错延迟下仍按
声明序合并(并发下若按完成序合并必然错位)、删除后无残留。
2026-09-27 15:58:54 +08:00

371 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package seq
import (
"encoding/json"
"os"
"strings"
"sync"
"testing"
"time"
)
// 阶段 P4 端到端:真实 Plugin + 真实工具执行,串通
// seq_create → seq_list → seq_run → seq_call。
//
// 此前 P1–P4 的判据都在**包内**(假 runner / 直接调函数),
// 覆盖的是语义;本文件覆盖的是**接线**:
// 参数名对不对、返回值能不能被模型读懂、跨层调用会不会断。
// 接线层的 bug 是语义判据抓不到的——例如工具注册了但参数名拼错,
// 单元判据全绿而模型永远传不进来。
//
// 不依赖内核:这里直接构造 Plugin 并注入**真实的** kernelRunner 替身
// (它按 ParallelSafe 声明决定并发,与内核同一判据口径)。
// e2eRunner 是真实执行面:它按名字查工具声明、真的返回结果。
type e2eRunner struct {
defs map[string]toolDefInfo
// mu 保护 called:组内并发时多个 goroutine 同时 append,
// 无锁会**真的**触发 -race(压测首次跑就报出来了)。
mu sync.Mutex
called []string
// results 覆盖默认返回
results map[string]string
// delay 按工具名制造延迟(毫秒),用于**打乱完成顺序**——
// 并发下若按完成顺序合并,槽内容就会错位;压力测试需要能造出这种乱序。
delay map[string]int
}
func newE2ERunner(names ...string) *e2eRunner {
r := &e2eRunner{
defs: map[string]toolDefInfo{},
results: map[string]string{},
delay: map[string]int{},
}
for _, n := range names {
// 默认**不**声明并发安全 ⇒ 序列会整批退回串行(保守默认)
r.defs[n] = toolDefInfo{Name: n, Description: n}
}
return r
}
func (r *e2eRunner) markParallel(names ...string) {
for _, n := range names {
d := r.defs[n]
d.ParallelSafe = true
r.defs[n] = d
}
}
func (r *e2eRunner) call(name string, args map[string]interface{}) (string, error) {
if d := r.delay[name]; d > 0 {
time.Sleep(time.Duration(d) * time.Millisecond)
}
r.mu.Lock()
r.called = append(r.called, name)
r.mu.Unlock()
if s, ok := r.results[name]; ok {
return s, nil
}
return "ok:" + name, nil
}
func (r *e2eRunner) exists(name string) bool { _, ok := r.defs[name]; return ok }
func (r *e2eRunner) parallelSafe(name string) bool {
d, ok := r.defs[name]
return ok && d.ParallelSafe
}
// newE2EPlugin 造一个不依赖内核的 Plugin。
//
// 参数用**接口** seqRunner 而非具体类型:压力测试需要不同替身
// (e2eRunner 用于串行场景、extRunner 用于千级并发),写死类型会逼着
// 压测去改这个 helper —— TestStressExtreme 第一次跑就编译不过,正是这个原因。
func newE2EPlugin(t *testing.T, runner seqRunner) *Plugin {
t.Helper()
return &Plugin{
name: "seq",
store: NewStore(t.TempDir()),
runner: runner,
}
}
// ① 端到端:建序列 → 列表可见 → 跑通 → 变量槽回填。
func TestE2E_CreateListRun(t *testing.T) {
r := newE2ERunner("uptime", "top")
p := newE2EPlugin(t, r)
// —— seq_create:groups 传参 ——
groups := []interface{}{
map[string]interface{}{
"name": "collect",
"in": map[string]string{},
"out": map[string]string{"summary": "string", "load": "string"},
"tools": `{"tool":"uptime","args":{},"as":"summary"} ; ` +
`{"tool":"top","args":{},"as":"load"} ;`,
},
}
out, err := p.dispatch("seq_create", map[string]interface{}{
"name": "collect",
"description": "采集两项",
"groups": groups,
})
if err != nil {
t.Fatalf("seq_create 失败: %v", err)
}
if s, _ := out.(string); !strings.Contains(s, "已保存") {
t.Errorf("seq_create 返回不可读: %v", out)
}
// —— seq_list:应能看到它 ——
out, err = p.dispatch("seq_list", map[string]interface{}{})
if err != nil {
t.Fatalf("seq_list 失败: %v", err)
}
listed, _ := out.(string)
if !strings.Contains(listed, "collect") {
t.Errorf("seq_list 未列出刚建的序列: %s", listed)
}
// 签名必须可见(模型据此按名调用)
if !strings.Contains(listed, "出参") {
t.Errorf("seq_list 未展示签名(模型无从知道有哪些出参): %s", listed)
}
// —— seq_run:应真的执行了两个工具并回填槽 ——
out, err = p.dispatch("seq_run", map[string]interface{}{"name": "collect"})
if err != nil {
t.Fatalf("seq_run 失败: %v", err)
}
ran, _ := out.(string)
if len(r.called) != 2 {
t.Fatalf("应执行 2 个工具,实际 %d(%v)", len(r.called), r.called)
}
if r.called[0] != "uptime" || r.called[1] != "top" {
t.Errorf("执行顺序应按 tools 声明序,实际 %v", r.called)
}
// 槽必须回填,且可被模型读懂(紧凑 JSON / 纯文本,不出现 map[...] 语法)
if !strings.Contains(ran, "summary") {
t.Errorf("seq_run 未回填 summary 槽: %s", ran)
}
if strings.Contains(ran, "map[") {
t.Errorf("结果里出现 Go 的 map 语法(模型读不懂): %s", ran)
}
}
// ② 端到端:seq_call 按名调用 group,返回该组出参。
func TestE2E_CallGroupByName(t *testing.T) {
r := newE2ERunner("uptime")
p := newE2EPlugin(t, r)
_, err := p.dispatch("seq_create", map[string]interface{}{
"name": "one",
"groups": []interface{}{
map[string]interface{}{
"name": "step1",
"in": map[string]string{},
"out": map[string]string{"summary": "string"},
"tools": `{"tool":"uptime","args":{},"as":"summary"} ;`,
},
},
})
if err != nil {
t.Fatalf("seq_create: %v", err)
}
out, err := p.dispatch("seq_call", map[string]interface{}{
"name": "one",
"target": "step1",
})
if err != nil {
t.Fatalf("seq_call 失败: %v", err)
}
res, _ := out.(string)
if !strings.Contains(res, "summary") {
t.Errorf("seq_call 未返回该组出参: %s", res)
}
if len(r.called) != 1 || r.called[0] != "uptime" {
t.Errorf("seq_call 未真正执行组内工具: %v", r.called)
}
}
// ③ 端到端:seq_when_call 条件为假 ⇒ 跳过且**不执行**任何工具。
func TestE2E_WhenCallSkips(t *testing.T) {
r := newE2ERunner("uptime")
p := newE2EPlugin(t, r)
if _, err := p.dispatch("seq_create", map[string]interface{}{
"name": "cond",
"groups": []interface{}{
map[string]interface{}{
"name": "step1",
"in": map[string]string{"flag": "bool"},
"out": map[string]string{"summary": "string"},
"tools": `{"tool":"uptime","args":{},"as":"summary"} ;`,
},
},
}); err != nil {
t.Fatalf("seq_create: %v", err)
}
out, err := p.dispatch("seq_when_call", map[string]interface{}{
"name": "cond",
"target": "step1",
"args": map[string]interface{}{"flag": false},
"when": "$args.flag == true",
})
if err != nil {
t.Fatalf("seq_when_call 失败: %v", err)
}
if s, _ := out.(string); !strings.Contains(s, "跳过") {
t.Errorf("条件为假应返回『跳过』,实际: %v", out)
}
if len(r.called) != 0 {
t.Errorf("条件为假却执行了工具: %v", r.called)
}
}
// ④ 端到端:seq_when_call 条件**畸形**必须报错,不得静默跳过。
func TestE2E_WhenCallMalformedErrors(t *testing.T) {
r := newE2ERunner("uptime")
p := newE2EPlugin(t, r)
if _, err := p.dispatch("seq_create", map[string]interface{}{
"name": "cond2",
"groups": []interface{}{
map[string]interface{}{
"name": "s",
"in": map[string]string{},
"out": map[string]string{"x": "string"},
"tools": `{"tool":"uptime","args":{},"as":"x"} ;`,
},
},
}); err != nil {
t.Fatalf("seq_create: %v", err)
}
_, err := p.dispatch("seq_when_call", map[string]interface{}{
"name": "cond2", "target": "s", "when": "$args.",
})
if err == nil {
t.Fatal("畸形条件被静默当成跳过了 —— 序列会安静地少做一步而模型以为跑完了")
}
if len(r.called) != 0 {
t.Errorf("条件求值失败却执行了工具: %v", r.called)
}
}
// ⑤ 端到端:seq_create 走**文件**路径(长序列的主力用法)。
func TestE2E_CreateFromFile(t *testing.T) {
r := newE2ERunner("uptime")
p := newE2EPlugin(t, r)
dir := t.TempDir()
path := dir + "/flow.json"
// 文件里放一个带 group 的序列(group 名与参数名与工具面一致)
doc := map[string]interface{}{
"name": "fromfile",
"description": "从文件创建",
"groups": []interface{}{
map[string]interface{}{
"name": "g1",
"in": map[string]string{},
"out": map[string]string{"summary": "string"},
"tools": `{"tool":"uptime","args":{},"as":"summary"} ;`,
},
},
}
b, _ := json.MarshalIndent(doc, "", " ")
if err := os.WriteFile(path, b, 0644); err != nil {
t.Fatalf("写序列文件失败: %v", err)
}
if _, err := p.dispatch("seq_create", map[string]interface{}{
"name": "fromfile", "file": path,
}); err != nil {
t.Fatalf("seq_create(file) 失败: %v", err)
}
out, err := p.dispatch("seq_run", map[string]interface{}{"name": "fromfile"})
if err != nil {
t.Fatalf("seq_run 失败: %v", err)
}
if !strings.Contains(out.(string), "summary") {
t.Errorf("从文件创建的序列执行结果异常: %v", out)
}
}
// ⑥ 端到端:groups 与 file 同时传必须报错(歧义输入)。
func TestE2E_CreateRejectsBothInputs(t *testing.T) {
p := newE2EPlugin(t, newE2ERunner("uptime"))
_, err := p.dispatch("seq_create", map[string]interface{}{
"name": "x",
"file": "/tmp/nope.json",
"groups": []interface{}{},
})
if err == nil {
t.Fatal("同时传 groups 与 file 却成功了")
}
if !strings.Contains(err.Error(), "二选一") {
t.Errorf("错误应说明二选一,实际: %v", err)
}
}
// ⑦ 端到端:删除不存在的序列**报错**(模型会以为删掉了)。
func TestE2E_DeleteMissingErrors(t *testing.T) {
p := newE2EPlugin(t, newE2ERunner())
if _, err := p.dispatch("seq_delete", map[string]interface{}{"name": "ghost"}); err == nil {
t.Fatal("删除不存在的序列却返回成功")
}
}
// ⑧ ★ 变量槽的值不得**静默**截断(方案 B 的要求在 seq 侧同样适用)。
//
// 背景:seq_run / seq_call 回填变量槽时,我当初随手写了 truncate(…, 160)。
// 那正是本仓反复吃亏的「静默降级」——模型拿到 160 字的残缺值,
// **不知道**后面还有内容,会基于残缺数据下结论。
//
// 正确做法:要么给全,要么**显式标注**被截断(并说明有多少)。
func TestSeqRunDoesNotSilentlyTruncateSlot(t *testing.T) {
long := strings.Repeat("L", 5000) // 远超 160
r := newE2ERunner("uptime")
r.results["uptime"] = long
p := newE2EPlugin(t, r)
if _, err := p.dispatch("seq_create", map[string]interface{}{
"name": "trunc",
"groups": []interface{}{
map[string]interface{}{
"name": "g1",
"in": map[string]string{},
"out": map[string]string{"summary": "string"},
"tools": `{"tool":"uptime","args":{},"as":"summary"} ;`,
},
},
}); err != nil {
t.Fatalf("seq_create: %v", err)
}
out, err := p.dispatch("seq_run", map[string]interface{}{"name": "trunc"})
if err != nil {
t.Fatalf("seq_run: %v", err)
}
res, _ := out.(string)
if !strings.Contains(res, "summary") {
t.Fatalf("未回填 summary 槽: %s", res)
}
// 若确实截断,必须**显式标注**并说明被截了多少
if strings.Count(res, "L") < 160 {
t.Fatalf("summary 槽几乎为空: %s", res)
}
if strings.Count(res, "L") < len(long) {
// 发生了截断 —— 那必须看得见
if !strings.Contains(res, "已截断") && !strings.Contains(res, "省略") {
t.Errorf("变量槽被截到 %d/%d 字却**没有任何标注** —— 模型会基于残缺值下结论",
strings.Count(res, "L"), len(long))
}
}
}
// calledSnapshot 返回调用记录的快照(加锁)。
func (r *e2eRunner) calledSnapshot() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.called))
copy(out, r.called)
return out
}