18 KiB
工具调用并行化改造:执行计划
设计依据:
docs/zh/toolcall-contract-and-sequence-design.md(本文只讲怎么一步步做, 设计取舍与实证依据在那份文档里,不重复)。状态:阶段 0 已完成。阶段 0.5 起为待办。 纪律:每个阶段的「判据」必须先写且必须能失败,再改实现; 判据通过前不进入下一阶段(见文末「阶段纪律」)。
阶段总览与依赖
| 阶段 | 内容 | 依赖 | 状态 |
|---|---|---|---|
| 0 | 修 map 迭代顺序(flush 乱序) | — | ✅ 已完成 |
| 0.2 | 工具错误类型化(已完成) | — | ✅ 已完成 |
| 0.5 | 补多 tool_call 批内路径判据 | — | ✅ 已完成 |
| 1 | 结果契约 + 参数预校验 | — | ✅ 已完成(1a/1b/1c/1d) |
| 2 | 并行执行层 | 1 | ✅ 已完成(2a/2b/2c/2d) |
| 2.5 | 提示词改为「默认并行」 | 2 | ✅ 已完成 |
| 3/4 | 序列插件 | 2 | ✅ 已完成(P1–P4,见下) |
主线(内核)0 → 0.2 → 0.5 → 1 → 2 → 2.5 已全部完成; 插件线 P1 → P2 → P3 → P4 也已完成。两部分详见下文。
⚠️ 顺序不可调换的两处
- 2.5 必须在 2 之后:先改提示词说「默认并行」而内核仍串行 = 提示词对模型说谎。
- 1 必须在 2 之前:并行化需要「诚实的 Success」与「结构化值」来判断结果与做条件; 先并行后补契约,等于把两处改动叠在同一段逻辑上,回归时无法定位是哪一处引起。
阶段 0 ✅ 已完成
问题:flushToolCall 由 for idx := range accs 驱动,Go map 迭代顺序随机化
⇒ 同一批并行 tool_call 进入 resp.ToolCalls 的顺序每次运行都可能不同。
对 output_send__ 这类用户可见通道 ⇒ 分段消息到达顺序不可复现。
改动:process.go —— 抽出闭包 flushAll,收集 index 后 sort.Ints 再 flush。
两个调用点(ch 关闭、ctx.Done())统一走它。
判据:stream_flush_order_test.go —— 8 工具 × 200 轮,断言输出严格按投递顺序。
已做变异验证:退回 map 遍历后判据于 round 0 即 FAIL(实际顺序 [tool_02…tool_00 tool_01])。
修复后 core 包全绿。
阶段 0.2 ✅ 工具错误类型化(已完成)
问题(两条,第二个是静默 bug):
- 内核用
strings.Contains(err, "not found in any plugin")判别「工具不存在」 (原toolcall.go:104)——约定不是契约。插件错误文案若含该子串即被误判。 - ⚠️
IOManager向父兜底时吞掉父的执行失败(channel.go:655-660), 误报为「工具不存在」。后果放大:设备离线这类本该 retry 的失败被判为 「工具没了」⇒ 整组被跳过,与「插件真没加载」无法区分。
改动:
io/channel.go:新增哨兵ErrToolNotFound+ToolNotFound(name)+IsToolNotFound(err)(沿用仓内ErrInputChannelUnknown的先例)。IOManager.ExecuteTool的父兜底 改为只传递「确实不存在」,其余错误如实上抛。core/stages.go:StageHost的 not-found 改用agentIO.ToolNotFound。core/toolcall.go:字符串匹配 →agentIO.IsToolNotFound;「不存在」时给出 可执行文案(提示get_plugin_tools/output_list_channels), 而非含糊的「执行失败」——后者会让模型反复重试同一个不存在的名字。
判据:toolcall_error_test.go(类型化 vs 诱饵子串、%w 穿透、执行期文案)+
channel_error_test.go(父失败不吞、真的不存在仍可判别)。
变异验证:退回父兜底吞噬后,TestExecuteTool_DoesNotSwallowParentFailureAsNotFound
FAIL(父的执行失败被误报为『工具不存在』);修复后全绿。
⚠️ 过程中的一次自伤:我先改了 channel.go 却漏了 stages.go 的 import,
导致整包 build 失败。已修。另:第一次写判据时只覆盖了类型化,没覆盖父兜底吞噬,
变异后仍绿 —— 说明「判据通过」不等于「修的东西被测到」。补了 channel_error_test.go 才闭合。
阶段 0.5 ✅ 补批内路径判据(已完成)
前提更正(此前我写「无任何测试直接驱动批内路径」——不准确):
scheduler_critical_test.go:125 的 TestBatch_NotAbandonedWithoutPreemption
已经驱动了「同批两个工具按序执行」。漏查是因为我只 grep 了
PendingTools / ToolIdx 这两个字段名,没查断言内容。
真正缺的是该测试未覆盖的三条(已核实全仓无对应断言):
| 缺口 | 风险 | 新增判据 |
|---|---|---|
tool_call_id 配对完整性 |
阶段 2 改消息落法时,配对一旦断裂上游直接报错 | TestBatchToolCallIDsAllPaired |
ContentOnce 批内语义 |
同一段 assistant 文本在批内重复 N 次,撑爆上下文 | TestBatchAssistantTextAppearsOnce |
| denied 后继续批内 | 改成 abort 会丢掉本可执行的后续调用 | TestBatchContinuesAfterDeniedTool |
另加 TestBatchExecutesEveryToolCall(批内全序列 + 顺序 + ToolResults 完整性)。
判据:toolbatch_test.go(4 条)+ 复用既有 TestBatch_NotAbandonedWithoutPreemption。
全部确定性断言(阶段 0 已消除 map 随机性)。
变异验证:令 stepToolBegin 跳过批内最后一个工具后,4 条判据同时 FAIL
(既有那条也 FAIL),报错直指 ToolsUsed = [tool_alpha]、tool_call_id "c2" 被声明 0 次。
⚠️ 过程中三次自伤(都靠"判据先写"暴露):
- 臆造了不存在的 helper(
sdkToolDef/sdkStageCtx)⇒ build 失败 - 把
a.stageHost = nil后又用它注册 stage handler - 给
newTaskFrame传nil⇒stepPrepare于task.go:519nil 解引用 panic ⇒ 改用仓内既有的a.stageCtxFromInput(...)(与scheduler_preempt_test一致) ★ 顺带记录:生产路径两处newTaskFrame调用(task.go:228/422)都传真实 ctx, 但stepPrepare对f.StageCtx无 nil 兜底。本次不修(无生产触发路径), 记为潜在健壮性缺口。
阶段 2 ✅ 并行执行层(已完成)
| 子项 | 内容 | 状态 |
|---|---|---|
| 2a | 消息落法:一条 assistant 带全部 tool_calls | ✅ 28b42bc |
| 2b | ConsumeToolBlocks 改 per-call 归档 |
✅ 25f5c53 |
| 2c | StageContext 拆 per-tool |
✅ 3d12e82(判据由 2d 闭合) |
| 2d | 批次调度与保序(ParallelSafe + 同通道保序) |
✅ 34c0df2 |
改动要点(细节见设计文档 §6):
- 2a 现状每工具一对消息;改为一条 assistant 带全部 tool_calls。 这本身是协议上更正确的形态(现状不表达「这是一批」)。
- 2b
ConsumeToolBlocks原本是 IOManager 级单队列,并发下会互相抢媒体 ⇒ 破坏「媒体必须紧跟自己 toolMsg」那条三轮实测的结论。改按call_id归档。 - 2c
f.StageCtx原本是单槽;改为每工具一份,Extra逐份浅拷贝 (output_channel被stage.go:18依赖)。 - 2d 全批
ParallelSafe才并发,否则整批降级串行(不做部分并发—— 收益不抵不可预测性);同output_send__<通道>多次发送保序。
并发规则(三条全满足才并发):批内 >1 个工具;全部声明 ParallelSafe;
不含需保序的同通道输出发送。
结构:StepToolBatch —— fan-out(每工具一 goroutine,各写自己的
toolCtxs[i])→ join → 按索引顺序串行收尾。收尾必须串行且按索引:
f.Msgs 是共享切片,且按索引落才能让模型读到的上下文顺序与它自己发出的
顺序一致。
⚠️ 修掉一个我自己引入的竞争:resolveTurnScenes 会把结果记进共享的
f.sceneDone / f.turnScene。最初在每个 goroutine 里各调一次 —— 既是数据
竞争,又会各自触发一次 EnterSceneWithHint,重复计入场景强度
(正是 sceneDone 注释警告过的问题)。改为 fan-out 之前解析一次。
2c 判据缺口:已由 2d 闭合
2c 落地时做过变体验证(toolCtxFor 退回单槽),两条判据仍全绿 ——
因为串行路径下「单槽」与「per-tool」行为完全一致,差别只在并发下显现。
当时据实记为「实现已就位、判据未闭合」。
2d 落地后补了并发版判据(TestBatchConcurrentEachToolSeesOwnContext):
退回单槽时触发 6 处 DATA RACE 报告 + 串味断言失败。缺口至此关闭。
遇到的一处既有测试竞态(非本次引入,但会污染回归信号)
TestResidualKeepReturnsTasksToParent / TestResidualDropNotifiesSyncCaller
偶发失败,报「应处置 2 条,实际 1」。
根因(已核实):offload_test.go:473 的 SpawnResident 会启动子 agent 的
调度器 goroutine,而测试随后 child.sched.enqueue(...) 两条任务、
立刻 ApplyResidual 去读同一队列——全程无任何同步。调度器与测试读并发
同一份 sched.queue,条数可能已被取走。
为什么以前没暴露:干净基线(阶段 2 之前)连跑 3 次恰好全绿,是运气,
不是确定性。-race 单跑该用例也过(无并发源)。本次改动让 core 包耗时略增、
调度时序变化,才把它翻出来。
处置:属测试侧缺陷,不在本阶段范围内,记为待修(修法:测试里改用 不启动调度器的子 agent,或给 enqueue/读取加同步)。记录在此以免后续误判为 「并行化引入的回归」。
2c 的诚实记录:判据在串行下测不出差别
toolCtxFor 退回单槽后,两条 2c 判据仍然全绿。原因是:
串行路径下「单槽」与「per-tool」行为完全一致——每个工具跑完才进下一个,
不存在交错。差别只在并发下显现(互相覆写 / after 读到别人的结果)。
⇒ 因此 2c 的真正判据必须与 2d 一起写:并发执行批内多工具时,
断言每个工具的 ctx 只带自己的 ToolCalls、after_toolcall 读到自己结果,
并以 go test -race 确认无数据竞争。
在 2d 落地前,2c 只能算"实现已就位、判据未闭合",不得记为已验证。
阶段 1 ✅ 结果契约 + 参数预校验(已完成)
对应设计文档 §4。动机:Success 硬编码 true(task.go:757 唯一赋值点)、
required 无消费方、结果被降级为 string——这三项让阶段 2/3 都缺地基。
1a. SDK 侧新增(纯新增,无签名变更)
third_party/homeagent-sdk/sdk/plugin.go:
// ToolError 描述失败原因。存在的理由:失败若只表达为文本,模型无法定位到字段,
// 只能原样重试(实测 cmd_run 失败率 34%~48% 源于同一成因)。
type ToolError struct {
Field string `json:"field,omitempty"`
Reason string `json:"reason"` // required/type/unauthorized/timeout/not_found
Detail string `json:"detail,omitempty"`
Hint string `json:"hint,omitempty"`
}
ToolDef增ParallelSafe bool(零值false= 不可并行 = 现状行为, 刻意让存量插件升级后得到保守行为)。- 仓内
internal/sdk/plugin.go补对应别名再导出。
1b. 诚实化 Success
Success = (err == nil) && !isToolError(result)。
判据必须同时覆盖新旧两种形态:存量插件返回 map[string]interface{}{"error": ...}
(如 files/plugin.go:228)要判为失败,而返回普通 map/string 仍算成功。
漏了后者 = 升级会把存量插件的成功误判成失败,这是本阶段最大的回归风险。
1c. 参数预校验
在 executeToolCallInner(toolcall.go:47)分派之前执行:逐项查 required、
逐项查 properties.type。失败返回结构化 ToolError,不进分派。
⚠️ getBool(utils.go:26)的注释记载 bool/string/float 三种都出现过 ⇒
校验不能把合法的 "true" 判为非法。判据必须覆盖这一点。
1d. 保留结构化值
executeToolCall 内部保留 rawResult interface{}(不降级为 string),
渲染交给统一函数:紧凑 JSON,禁止 fmt.Sprintf("%v")(那会产出
map[status:sent id:123] 这种模型读不懂的 Go 语法)。
顺带修一个静默失效:task.go:771 的 ToolResults[0].Result.(string) 断言
几乎恒失败(插件返回的多是 map)⇒ after_toolcall 阶段插件对结构化结果的改写当前无效。
阶段 1 判据
toolerror_test.go:isToolError对新旧两种形态的判定(各 3 例:失败 map / 成功 map / 成功 string)argvalidate_test.go:缺 required、类型不符、"true"宽松放行- 回归:core 包全绿;存量插件 smoke(
internal/plugins不得新增 FAIL)
阶段 2.5 ✅ 提示词改为「默认并行」(已完成)
⚠️ 有硬性顺序约束:必须在阶段 2 落地之后。反序(先说"并发"、内核仍 串行)会让提示词对模型说谎 —— 模型据此推断安全性,写出真正依赖顺序的 调用。宁可晚改,不可错改。
新增【工具执行顺序】段,讲清四件事:
- 同一条回复里的多个工具调用默认并行(同时跑),不是依次执行
- 不要依赖执行顺序 —— 参数依赖前一个结果就分两轮
- 例外一:同通道
output_send__保序(用户可见消息顺序敏感) - 例外二:不并发安全的工具整批退回串行(写类工具 / 未声明者)
措辞刻意与 batchRunnable 的真实判据一致,而不是理想化表述 ——
提示词与实现不符,比不说更坏。
顺带改掉 spawn_child 的落空表述(原文「应并行 spawn,不要自己串行逐个执行」
在并行化之前是落空的)。
判据 prompt_parallel_test.go(5 条),其中负向那条最关键:
只讲并行、不讲例外 ⇒ FAIL("提示词说谎"的失败模式已被覆盖)。
阶段 3 / 4 ✅ 工具序列插件(已完成)
| 阶段 | 内容 | 提交 |
|---|---|---|
| P1 | AST 解析 + 静态校验 | 76030ae |
| P2 | 执行引擎(组内并行 + 具名槽 + 条件求值) | 7532af7 |
| P3 | 存储 + 跨序列调用图 + missing 策略 | 3d75312 |
| P4 | 六个 seq_* 工具 + 插件装配 |
71c894c |
插件线复用内核并行面,但不要求内核开任何新接口(见设计文档 §7 边界声明)。
顺带补上的内核两处缺口
P3 落地时暴露的真实缺陷(不是新需求):
GetAllTools丢ParallelSafe⇒ 插件看到的设备工具一律"不可并发", 设备工具的并发声明对插件不可见。ToolAPI缺按名查 ⇒ 新增ToolDefByName。插件需在运行前判断 目标是否存在(动态注册下"不存在"是常态),而GetAllTools只能拿全量列表。
本阶段解决的两个设计问题
-
跨序列目标存在性:
Save若要求"目标必须先存在",互调的两条序列 谁也存不下来(A 要 B 先在、B 要 A 先在)——依赖在设计上无解。 ⇒Save只校验同序列内的 group 引用;跨序列目标由CheckGraph兜底。 -
黑名单分层:我一度把
seq_call/seq_when_call也禁掉,但那正是 本包的核心能力(按名调用),禁掉序列就退化成单层脚本。 ⇒ 黑名单只管对外发消息 / 起子 agent / 改插件表 / 再跑整条序列;seq_call系列留给序列内部组合,递归由maxCallDepth+ 环检测负责 (设计文档 §8.3 本来就这么定,是我把两层混了)。
遗留项状态
| 项 | 内容 | 状态 |
|---|---|---|
| D4 | 设备授权闸在 ToolAPI 路径失效 |
✅ 已解决(994f198)。ToolAPI 新增 CanUse,由 bootstrap 注入 agent 的授权判据;两条路径判定一致性由 TestCanUseAgreesWithInnerPath 钉住 |
| 端到端 | 真机跑一次 seq_create → seq_run |
✅ 已完成(3d31037)。并抓出「传参方式完全不可用」的真 bug |
| 提权 | seq 是否默认启用 |
✅ 无需决策:仓内已有 IsPluginDisabled / AddDisabledPlugin 机制,任何插件(含内置)都可被用户禁用,seq 无需特殊处理 |
仍需注意的两点
-
缺
device_id时是 fail-open(放行)。这是内核既有语义 (TestDeviceToolAuth_*依赖它),本次未擅自改;已由TestCanUseMatchesInnerFailOpenOnMissingDeviceID钉住现状。 若要改成 fail-closed,必须内核与ToolAPI.CanUse两处同时改, 否则两条路径判定不一致本身就是漏洞。 -
TestResidual*既有竞态(offload_test.go):SpawnResident起了 子调度器 goroutine,而测试 enqueue 后无同步就读同一队列。 与本次改动无关,已定位根因,待修。
阶段纪律 [沿用本仓既有教训]
- 判据先写,且必须能失败。写完先跑一次确认 FAIL,再改实现。
- 变异验证:改完把修复回退一次,确认判据重新 FAIL。 「判据全绿」不等于「判据有效」——本仓 T23 教训(fixture 照臆测字段编, 判据全绿而实现全错)就是跳过这一步。
- 判据只断言代码真实产出的字段。我本次就栽过一次:先写了
assert tc.StreamIndex == i,而flushToolCall根本不给ToolCall赋StreamIndex⇒ 恒假信号。判据必须对着实际输出写。 - 期望值由独立来源算出,不引用被测代码。
- 每阶段结束跑全包
go test ./internal/agent/core/ -count=1, 并检查存量插件 smoke 无新增 FAIL。