Commit Graph

2 Commits

Author SHA1 Message Date
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
3d753126a3 feat(seq): 序列存储、跨序列调用图与 missing 策略(插件线 P3)
store.go:
· **存 AST 不存文本**。执行期不重新解析原始文本 ⇒ 一次格式改动不会
  悄悄改变已保存序列的行为。
· 先写 .tmp 再 rename,避免写一半被读。
· **路径穿越防护**:序列名来自模型且被直接拼进文件路径,不校验的话
  `seq_load("../secret")` 能读任意文件、`seq_delete` 能删任意文件。
· CheckGraph:跨序列调用的**目标存在性** + **环检测**(三色 DFS),
  报错时给出**环路径**(#A → #B → #A),便于定位。
· maxCallDepth = 4 是**结构常量**不是配置项 —— 沿用内核
  MaxInterruptFrames 的做法(core/scheduler.go:271「结构上界,不是配置项」):
  上界一旦可配,总有人会把它调到栈溢出。

exec.go 补 missing 策略(动态注册下「工具不存在」是**常态**):
· fail(默认)/ skip / degrade,与「执行失败」严格分开
· ⚠️ missing 分支**必须先于**通用 on_error 检查:否则「插件挂了」会被
  on_error=abort 连坐整组中断,skip/degrade 形同虚设
· skip 时**不赋值槽**(与「条件为假」同一情形,下游要能应对槽缺失)
· 本包自带 errToolNotFound 哨兵而**不复用** io 包的同名错误:seq 是插件,
  拿得到 sdk.ToolAPI,拿不到 io 包类型(见设计文档 §7 边界声明)

★ 过程中解决一个**设计死锁**(值得单列):
我最初让 Save 校验「跨序列目标必须已存在」。但互调的两条序列
谁也存不下来——A 要 B 先在、B 要 A 先在,**依赖在设计上无解**。
⇒ Save 只校验**同序列内**的 group 引用(那部分信息自足);
  跨序列目标的存在性与环由 CheckGraph 在保存后统一兜底。
  判据与实现都写明了这个分工的理由。

判据(store_test.go,7 条):
· 存取往返保住 AST(含 out 声明——它是签名的一部分)
· 列表 / 删除;删不存在的**报错**(不静默成功,模型会以为删掉了)
· ★ 跨序列成环被拒且错误含环路径;无环通过
· maxCallDepth 是正的结构常量
· ★ missing 三种取值各有明确行为
· ★ 路径穿越:7 种恶意名既读不到也删不掉,且**在 store 目录外**放真实
  文件断言它仍在(不是"读代码看着对",是跑出来的)

过程中三次自伤:
1. 序列名我写成 "#A"/"#B"——`#` 只是 target 里的前缀标记,
   落盘名不带它,于是 CheckGraph 找不到、误报「不存在」。
2. missing 策略与 on_error 检查的**顺序**反了,导致 skip/degrade 被
   abort 连坐(判据直接暴露)。
3. 为压掉 unused import 写了 `var _ = os.Remove` 这种占位 hack ——
   正是检查项 go-ignored-call-result 指出的那类东西,已删;
   另把 rename 失败分支的 `os.Remove(tmp)` 加上注释说明
   「清理失败有意忽略,否则会盖掉真正的失败原因」。

变异验证:去掉环检测(三色 DFS 全放行)⇒ 成环判据 FAIL
("A→B→A 成环却通过检查")。

回归:-race 下 seq 全绿;internal/plugins/... 全绿。
core 包偶发 TestResidualKeep 失败是**已记录的既有竞态**
(offload_test.go 的 SpawnResident 起了子调度器而测试无同步就读队列),
与本阶段无关,已在执行计划中记为待修。
2026-09-27 12:47:18 +08:00