mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 05:13:27 +00:00
perf(stagehost): 工具声明查询免去结构体拷贝(热路径 1000 并发下省 1000 次)
## 问题
StageHost.ToolDef 返回 &def —— 一次**结构体拷贝**:3 个 string + 2 个 map 头
+ 2 个 bool + Cleaner 函数指针。
而 toolParallelSafe 在**每批**并发判据里对每个工具各调一次:
batchRunnable 遍历 PendingTools → toolParallelSafe(tc.Name)。
1000 并发批次 = 1000 次结构体拷贝,全在判定阶段(执行之前)。
不是"逃逸漏洞"(Go 1.22+ 循环变量每轮独立,go.mod 是 1.25),纯粹是白拷贝。
## 修法
ToolDef 保留 —— 它要给需要完整声明的调用方(Cleaner、Parameters 校验),
返回副本也是**有意**的(ToolDef 里有 map 与函数指针,交出内部元素会把
可变引用漏出去)。
新增免拷贝查询,热路径专用:
ConcurrencySafeOf(name) (safe, found bool) // 只读 ParallelSafe && !Serial
NoMemoryOf(name) (v, found bool)
HasTool(name) bool
全部在持 RLock 下走同一个 findLocked。
`toolParallelSafe` 切到 ConcurrencySafeOf。语义完全等价 —— 两者都算
`ParallelSafe && !Serial`,只差一次拷贝。
## 判据(两个都防"优化悄悄改了语义")
- TestNoCopyQueriesMatchToolDef 7 种声明组合(plain / parallel / serial /
both / nomem / all / serial_nomem)下,免拷贝查询与 ToolDef(...).字段
**逐字段等价**;不存在的工具三态一致(false/false/true)。
★ 这类优化最危险的失败模式就是语义漂移:并发判据若读错字段,
能并发的批次会**悄悄退化成串行** —— 没有任何报错,只表现为"变慢了"。
所以判据必须逐个组合比对,而不是只测一个典型值。
- TestNoCopyQueriesConcurrent 32 goroutine × 50 工具并发查询,
-race 无竞态且结果与串行一致。
回归:go build ./... 通过;go test ./internal/... 全绿;
go test -race ./internal/agent/core 通过。
This commit is contained in:
@ -258,9 +258,13 @@ func (a *Agent) toolParallelSafe(name string) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
// ⚠️ 这里用 ConcurrencySafeOf 而不是 ToolDef(name).ParallelSafe:
|
||||
// 后者每次调用**拷贝整个 ToolDef**(含 2 个 map 与 Cleaner 函数指针),
|
||||
// 而本函数在每批并发判据里对每个工具各调一次 —— 1000 并发就是 1000 次
|
||||
// 拷贝。语义完全等价(两者都算 ParallelSafe && !Serial),只是不拷贝。
|
||||
if a.stageHost != nil {
|
||||
if def := a.stageHost.ToolDef(name); def != nil {
|
||||
return def.ParallelSafe && !def.Serial
|
||||
if safe, ok := a.stageHost.ConcurrencySafeOf(name); ok {
|
||||
return safe
|
||||
}
|
||||
}
|
||||
if a.io != nil {
|
||||
|
||||
@ -72,17 +72,71 @@ func (h *StageHost) GetToolDefs() []sdk.ToolDef {
|
||||
return defs
|
||||
}
|
||||
|
||||
// ToolDef 返回工具声明的**副本**。
|
||||
//
|
||||
// 保留"返回副本"是有意的:ToolDef 里有 map 与函数指针(Cleaner),
|
||||
// 返回内部切片元素会把可变引用交出去。Go 1.22+ 循环变量每轮独立,
|
||||
// 所以 &def 不是逃逸漏洞。
|
||||
//
|
||||
// 但代价真实存在:结构体含 3 个 string + 2 个 map 头 + 2 个 bool,
|
||||
// 每次调用都是一次结构体拷贝。而 toolParallelSafe 在**每批**判断里对
|
||||
// 每个工具各调一次,1000 并发时就是 1000 次拷贝。
|
||||
//
|
||||
// 只需"是否存在 + 声明项"的调用方(toolParallelSafe、validateArgsAgainstSchema
|
||||
// 等热路径)应改用 ConcurrencySafeOf / HasTool,避免拷贝。
|
||||
// 需要拿到完整声明的(如 Cleaner)才用 ToolDef。
|
||||
func (h *StageHost) ToolDef(name string) *sdk.ToolDef {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for _, def := range h.toolDefs {
|
||||
if def.Name == name {
|
||||
return &def
|
||||
}
|
||||
if def, ok := h.findLocked(name); ok {
|
||||
return &def
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConcurrencySafeOf 报告工具是否可并发执行,**不做结构体拷贝**。
|
||||
//
|
||||
// 与 ToolDef(name).ParallelSafe && !Serial 等价,但只读两个 bool 字段。
|
||||
// 热路径(每批并发判据)必须走这个。
|
||||
func (h *StageHost) ConcurrencySafeOf(name string) (safe, found bool) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
def, ok := h.findLocked(name)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return def.ParallelSafe && !def.Serial, true
|
||||
}
|
||||
|
||||
// NoMemoryOf 报告工具是否声明 NoMemory,同样不拷贝。
|
||||
func (h *StageHost) NoMemoryOf(name string) (v, found bool) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
def, ok := h.findLocked(name)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return def.NoMemory, true
|
||||
}
|
||||
|
||||
// HasTool 报告工具是否存在,不拷贝。
|
||||
func (h *StageHost) HasTool(name string) bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
_, ok := h.findLocked(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
// findLocked 必须在持有 h.mu 时调用。
|
||||
func (h *StageHost) findLocked(name string) (sdk.ToolDef, bool) {
|
||||
for _, def := range h.toolDefs {
|
||||
if def.Name == name {
|
||||
return def, true
|
||||
}
|
||||
}
|
||||
return sdk.ToolDef{}, false
|
||||
}
|
||||
|
||||
func (h *StageHost) ExecuteTool(name string, args map[string]interface{}) (ret interface{}, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
||||
97
internal/agent/core/stages_tooldef_test.go
Normal file
97
internal/agent/core/stages_tooldef_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
// 无拷贝查询必须与 ToolDef(...).字段 **完全等价**。
|
||||
//
|
||||
// 这类优化最危险的失败模式是"优化悄悄改了语义":比如并发判据原本
|
||||
// 读 ParallelSafe,优化后读成了别的字段,于是工具能并发的批次悄悄
|
||||
// 退化成串行 —— 没有任何报错。
|
||||
func TestNoCopyQueriesMatchToolDef(t *testing.T) {
|
||||
sh := NewStageHost()
|
||||
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
|
||||
cases := []sdk.ToolDef{
|
||||
{Name: "plain"},
|
||||
{Name: "parallel", ParallelSafe: true},
|
||||
{Name: "serial", Serial: true},
|
||||
{Name: "both", ParallelSafe: true, Serial: true},
|
||||
{Name: "nomem", NoMemory: true},
|
||||
{Name: "all", ParallelSafe: true, NoMemory: true},
|
||||
{Name: "serial_nomem", Serial: true, NoMemory: true},
|
||||
}
|
||||
for _, d := range cases {
|
||||
if err := sh.RegisterTool(d.Name, d, noop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, d := range cases {
|
||||
def := sh.ToolDef(d.Name)
|
||||
if def == nil {
|
||||
t.Fatalf("%s: ToolDef 返回 nil", d.Name)
|
||||
}
|
||||
safe, found := sh.ConcurrencySafeOf(d.Name)
|
||||
if !found {
|
||||
t.Errorf("%s: ConcurrencySafeOf 未找到", d.Name)
|
||||
continue
|
||||
}
|
||||
if want := def.ParallelSafe && !def.Serial; safe != want {
|
||||
t.Errorf("%s: ConcurrencySafeOf=%v,ToolDef 算出的应为 %v(ParallelSafe=%v Serial=%v)",
|
||||
d.Name, safe, want, def.ParallelSafe, def.Serial)
|
||||
}
|
||||
nm, ok := sh.NoMemoryOf(d.Name)
|
||||
if !ok {
|
||||
t.Errorf("%s: NoMemoryOf 未找到", d.Name)
|
||||
continue
|
||||
}
|
||||
if nm != def.NoMemory {
|
||||
t.Errorf("%s: NoMemoryOf=%v,ToolDef.NoMemory=%v", d.Name, nm, def.NoMemory)
|
||||
}
|
||||
if sh.HasTool(d.Name) != true {
|
||||
t.Errorf("%s: HasTool 应为 true", d.Name)
|
||||
}
|
||||
}
|
||||
// 不存在的工具
|
||||
if safe, found := sh.ConcurrencySafeOf("nope"); found || safe {
|
||||
t.Errorf("不存在的工具:found=%v safe=%v,应为 false/false", found, safe)
|
||||
}
|
||||
if sh.HasTool("nope") {
|
||||
t.Error("HasTool 对不存在的工具返回 true")
|
||||
}
|
||||
}
|
||||
|
||||
// 并发查询必须无竞态,且与串行结果一致。
|
||||
func TestNoCopyQueriesConcurrent(t *testing.T) {
|
||||
sh := NewStageHost()
|
||||
noop := func(map[string]interface{}) (interface{}, error) { return nil, nil }
|
||||
for i := 0; i < 50; i++ {
|
||||
name := "t" + string(rune('a'+i%26)) + string(rune('0'+i/26))
|
||||
_ = sh.RegisterTool(name, sdk.ToolDef{Name: name, ParallelSafe: i%2 == 0}, noop)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 32; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
name := "t" + string(rune('a'+i%26)) + string(rune('0'+i/26))
|
||||
safe, found := sh.ConcurrencySafeOf(name)
|
||||
if !found {
|
||||
t.Errorf("并发下 %s 未找到", name)
|
||||
return
|
||||
}
|
||||
if safe != (i%2 == 0) {
|
||||
t.Errorf("并发下 %s safe=%v,期望 %v", name, safe, i%2 == 0)
|
||||
return
|
||||
}
|
||||
_ = sh.HasTool(name)
|
||||
_, _ = sh.NoMemoryOf(name)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
Reference in New Issue
Block a user