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 槽在交错延迟下仍按
声明序合并(并发下若按完成序合并必然错位)、删除后无残留。
371 lines
12 KiB
Go
371 lines
12 KiB
Go
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
|
||
}
|