Files
HomeAgent/internal/plugins/seq/store.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

359 lines
11 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"
"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
}