|
|
f8add88dbc
|
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 通过。
|
2026-09-27 16:29:24 +08:00 |
|
|
|
8a98969fac
|
refactor(parallel): 内置工具的并发声明改为 SDK 同构的结构体字段
上一提交(2232d54)把并发安全改成了声明式,但内置工具那一路仍是将就:
声明靠往 required 变参里塞字符串 "toolParallel" 传递。
## 为什么那不算声明式
对照 SDK 的 NoMemory 逐条看:
| | SDK NoMemory | 当时的内置工具 |
|---|---|---|
| 载体 | `ToolDef.NoMemory` 字段 | required 里的字符串 |
| 拼错后果 | 编译器报错 | **静默失效** |
| 内核读取 | 查结构体字段 | 遍历工具表 + 解析字符串 |
"少一个工具能并发"恰恰是最难察觉的一类问题 —— 没有任何报错,
只是并行的批悄悄退化成串行。
## 改法
### 1. sdk.BuiltinToolDef 补声明项(与 NoMemory 同构)
```go
type BuiltinToolDef struct {
Name, Description string
Parameters map[string]interface{}
ParallelSafe bool // 零值 false = 默认串行(保守)
Serial bool // 优先于 ParallelSafe
}
func (d BuiltinToolDef) ConcurrencySafe() bool { return d.ParallelSafe && !d.Serial }
func (d BuiltinToolDef) ToSchema() map[string]interface{}
```
### 2. 工具定义处声明
```go
toolDef("memory_merge", ...) // 默认串行
toolDefWith("knowledge_search", ..., []string{"query"}, parallelOpts()) // 已核实只读
```
### 3. 内核一次聚合并缓存(照 StageHost.NoMemoryToolNames)
```go
graphOf() // 快照
declareParallelTool(name) // init 里登记
concurrencySafeOf(name) // 查表
```
不再每次 toolParallelSafe 都重跑 buildToolDefs()(O(工具数) 重复劳动,
而声明是静态的)。
## ★ 一个更隐蔽的问题:声明表曾经是空的
`declareParallelTool` 最初挂在 `toolDefWith` 的**运行时调用**上。而那 9 个
工具全在 `if a.knowledge != nil` / `if a.social != nil` / `if a.parentID != ""`
之类的条件分支里 —— 测试环境根本不走进这些分支 ⇒ 聚合表始终为空。
而判据查的是同一张表,于是**自证通过**:全绿,并发能力为零。
这就是判据设计的教训 —— 判据和数据源同源时,它证明的只是"我和我一致"。
现在判据双向核对:名单里的必须真声明了,声明了不在名单里的也会报出来;
并额外验证内核**真的读得到**(concurrencySafeOf 而非读同一份 map)。
## 顺带修掉的迁移事故
用正则批量改造 30+ 个 toolDef 调用点时,把 `person_set_trait("name", "content")`
这类**变参**调用误改成 toolDefWith(... "name", "content") —— 那是**写工具**,
差点被标成可并发。已全部回退并逐一核对:9 个声明并发,0 误伤。
|
2026-09-27 15:59:11 +08:00 |
|
|
|
2232d5483c
|
feat(parallel): 并发安全改为声明式,并审计标注 37 个工具
把"能不能并发"从内核硬编码名单改成**工具自己的声明项**,形态照 SDK 的
NoMemory 走。
## ★ 起因:提示词在跟内核不一致
阶段 2.5 写进提示词的「内核默认并行执行」当时是**假的**:toolParallelSafe
只查 stageHost 与 io 两个来源,而全仓 ParallelSafe:true 的生产代码数量
是 **0**。于是除碰巧只发一个工具外,每一批都整批串行回退,而提示词正教
模型把多个查询放同一轮。**内核行为与提示词不一致 = 对模型说谎。**
并发面:0 → 37 个工具(18 插件 ParallelSafe + 19 插件 Serial + 9 内置只读)。
## 声明形态(照 SDK,不自创)
### 插件:结构体字段
s.RegisterTool("config_get", sdk.ToolDef{
Name: ..., Description: ...,
Parameters: map[string]interface{}{...},
// 已核实只读:…
ParallelSafe: true, ← 插在 Parameters 之后、handler 之前
}, p.handleGet(s))
位置与 SDK 的 NoMemory/ContextPolicy/RecallPolicy 一致:Name 在首位,
声明项在末尾,不打散 gofmt 对齐。
### 新增 SDK 声明项:ToolDef.Serial
ParallelSafe 的**反向**标记,判据优先级高于 ParallelSafe。
为什么需要:ParallelSafe 零值 false 已表达"安全",插件无法区分"我没想过"
与"我确认过必须串行"。没有这个区分,工具作者只能靠命名约定传递意图。
内核已消费它(io.ToolDef 同步加字段对齐),并有判据守"Serial 胜出"。
### 内置工具:toolDef 的 toolParallel 选项
内置工具以裸 schema map 下发,没有 ToolDef 结构,所以用变参选项:
toolDef(名字, 描述, 属性) // 默认串行
toolDef(名字, 描述, 属性, "toolParallel") // 已核实只读,可并发
读工具表的老调用点一行不用动,声明就写在工具定义那一行。
## ★ 走过的弯路(都留了判据)
1. **硬编码白名单**:先在 toolParallelSafe 里查一张
builtinParallelSafeTools map。那把声明从"工具自己"搬回了内核 ——
工具改名/新增不会自动跟着变,得靠一条 grep 源码的判据才能发现漂移,
而判据一改就忘。已删,改为从定义读。
2. **判据前提错(同一个坑踩了两次)**:拿裸 &Agent{} 的 buildToolDefs 输出
当"实际可见工具",但这 9 个内置工具全在条件分支里(a.knowledge != nil /
a.social != nil / a.parentID != ""…),裸 Agent 一个都不产出 ⇒ 全部误报
"声明形同虚设"。第一次叫它"幽灵条目",没认出是同一个坑。
3. **注释模仿真实签名污染判据**:toolParallel 的用法注释写着
`toolDef("knowledge_search", ...)`,判据按文本匹配先撞上注释。
4. **buildToolDefs 的 nil 不一致**:开头判了 a.io != nil,末尾却无条件
a.io.ListChannels()。任何无 IO 的 Agent 调它都 panic —— 而 panic 报在
io 包里,根因在 tooldefs.go。已补。
5. **插入脚本用正则找"最后一个顶层字段"**:被嵌套 map 里的同形文本骗到,
823 处错误重排把文件改坏。改用括号深度 + 记录进入深度 3 的行号
(空 properties 会让深度在同一行进出平衡,只判 depth==2 不够)。
工具在 SDK 仓 tools/annotate_parallel/,复用时用绝对路径。
## 提示词措辞同步修正
「默认并行执行」→「尽量并发执行,但这是**逐工具判断**的」,并教模型
**把查询类放同一轮、写操作单独发一轮**(写和查混在一批,整批都串行)。
## 判据
- TestSerialOverridesParallelSafe Serial 优先于 ParallelSafe
- TestToolParallelDeclarationsAudit 并发面不许再归零
- TestNoToolDeclaresBothParallelAndSerial 两者同标即谎话
- TestBuiltinParallelDeclaredWhereDefined 声明写在定义处、且内核真读到
- TestStoreListIgnoresForeignJSON 压测抓到的 List() 缺陷
|
2026-09-27 15:15:57 +08:00 |
|
|
|
34c0df2705
|
feat(toolcall): 批次并发调度与同通道保序(阶段 2d,闭合 2c 判据缺口)
规则(三条全满足才并发):
1. 批内 >1 个工具
2. **全部**工具声明 ParallelSafe —— 一个不声明就整批降级,不做部分并发
3. 不含需保序的同通道输出发送
SDK:
· ToolDef 加 ParallelSafe bool。⚠️ 零值 false 是刻意的:存量插件不改一行
就得到**保守**行为(整批串行),不会因升级被意外并发。声明它是责任
而非特权。纯新增字段,无签名变更。
· io.ToolDef 同步加该字段(设备/通道工具走 io 路径,只查 StageHost 会漏)。
core:
· 新增 StepToolBatch —— runTaskSteps 是单线程驱动状态机的,
「每步一个工具」的游标模型无法表达「一批同时跑」,故需独立 step。
· stepToolBatch:fan-out(每工具一 goroutine,各写自己的 toolCtxs[i])
→ join → **按索引顺序**串行收尾(after_toolcall / 落消息 / 事件)。
收尾必须串行且按索引:f.Msgs 是共享切片,且按索引落才能让模型读到的
上下文顺序与它自己发出的顺序一致。
· runOneTool 抽出「before_toolcall + 执行」的单工具逻辑,串行/并发两条路共用。
· toolParallelSafe / batchRunnable 判据函数。
★ 修掉一个我自己引入的竞争:resolveTurnScenes 会把结果记进**共享**的
f.sceneDone / f.turnScene(memorypass.go:289)。最初在每个 goroutine 里
各调一次 —— 既是数据竞争,又会各自触发一次 EnterSceneWithHint,
重复计入场景强度(正是 sceneDone 注释警告过的问题)。改为在 fan-out
**之前**解析一次,goroutine 内只读。
判据(parallelsched_test.go,4 条):
· 全批 ParallelSafe ⇒ 并发峰值 >= 2(用阻塞设备观察真实并发)
· 一个非 ParallelSafe ⇒ 整批串行,但**仍全部执行**
· 同 output_send__<通道> 连发 3 条 ⇒ 严格按声明顺序到达
· ★ 并发下每个工具的 ctx 只带自己的 ToolCalls、after 读到自己结果
★ 并关闭了 2c 的判据缺口:此前两条 2c 判据在**串行**下无法区分
per-tool 与单槽(变体验证后仍全绿)。新增的并发版判据在退回单槽时
触发 **6 处 DATA RACE 报告 + 串味断言失败**(dup:k_a 与 k_a 撞名)。
至此 2c 可记为已验证。
过程中三次自伤:
· resolveTurnScenes 竞争(上述);
· 我的 harness 用 StageHost 注册 handler 遮蔽了设备工具,
slowDevice 根本没被调用("实际 0")——改为在 io.ToolDef 上声明;
· 批内并发峰值判据最初用 StageHost 声明 ParallelSafe,掩盖了
「设备工具也需要该字段」这一真实缺口。
回归:internal/agent/... internal/sdk/... internal/plugin/...
internal/plugins/... 全绿(17 包);core 包 -race 全绿。
|
2026-09-27 11:27:24 +08:00 |
|
|
|
bce40b5099
|
feat(toolcall): 按 schema 预校验参数,在分派前拦下(阶段 1c)
问题:`required` 在仓内被声明 69 处,却**无任何消费方**(内核从不读)。
校验散落在每个工具内部手写成中文字符串("path is required"),
要等工具真被调用才暴露——而模型看到这类与真因无关的报错只会原样重试
(实测 cmd_run 失败率 34%~48% 的成因)。
改动:
· core/argvalidate.go: validateToolArgs(纯函数)+ validateArgsAgainstSchema。
★ 校验器刻意**宽松**:只拦真正无法解析的形态,对模型实际会写的等价形态
一律放行。依据是工具内部 getter 的既有约定(utils.go 注释:
"实际调用里 bool/string/float 三种都出现过";unitNumberRe 修的正是
`"20s"` 少引号那类)。**校验比工具更严就是在制造新失败**。
· required 判据是**键存在性** + 非空字符串;显式 null 视为已提供
(模型可能有意传 null,工具按零值处理,判成缺失即误伤)
· boolean 全放行(getBool 的 true/"1"/"0"/"yes"/0/1 全都合法)
· integer 接受 int/float64/"20"/"20s";string 接受含 JSON 的长文本
· 无 schema / 无 required / 查不到 schema ⇒ 一律放行
· core/toolcall.go: 在 __arg_error 短路**之后**、分派**之前**接入。
· io/channel.go: 新增 IOManager.ToolDefOf——没有它就只校验到插件工具,
而 cmd_run / files_write 这类**设备/通道工具会完全绕过校验**。
判据(argvalidate_test.go,7 组):
· 缺 required 被拦下并指名字段
· ★ 误伤防线:bool 传 "true"/"0"、integer 传 float64/"20"、
显式 null、字段顺序不同 —— 全部必须放行
· 类型确实不符报 type 错误
· 无约束场景一律放行(含 args 为 nil + schema 带 required ⇒ 应拦,
这条我最初**误放进放行组**,写完立刻发现改正)
· 错误文案含字段名/必填/改法(否则模型只会原样重试)
· 端到端:缺参时**设备真的没被调用** + 文案指名字段
· 端到端反向:参数齐备照常执行(校验不得阻塞正常路径)
变异验证(两轮):
· 关闭分派前校验 ⇒ 端到端判据 FAIL「仍进入了工具」
· 把 boolean 校验改严格 ⇒ 宽松防线 FAIL 两个子用例(误伤 "true"/"0")
过程中三次自伤:臆造 sdkToolError 别名;number 分支写了没有绑定的 x(v);
把"显式 null"先当成缺失、过度修正后又漏掉"键不存在"的判定——
最终改为「键存在性 + 非空串」双条件,null 与缺失各归其位。
回归:internal/agent/... internal/sdk/... internal/plugin/...
internal/plugins/... 全绿(18 包)。
|
2026-09-27 10:56:02 +08:00 |
|