`ParallelSafe` / `Serial` / `stream_index` 三个新能力此前**零文档**: README 提到 `Serial` 的那处是 UART 串口,与并发声明无关。插件作者只能 读源码注释才能知道这些字段存在及其优先级。 ## docs/guide/parallel-tool-declaration.md - 保守 opt-in 的理由:存量插件不改一行就得串行,不会被升级意外并发 - 声明 `ParallelSafe` 的三个条件(线程安全 / 不争抢资源 / 顺序无关) - `Serial` 存在的意义:让"我确认过**必须**串行"与"我没想过"可区分 - **`Serial` 胜出**,不允许被 `ParallelSafe` 或默认值覆盖 - 声明字段放在 `ToolDef` 末尾,遵循既有 `NoMemory` 风格 - 压测效果 N=2/4/8 → 1.41×/2.22×/3.88×,并附"必须同时统计实际执行数" 的理由(旧适配器耗时更短但实际处理 0 个工具) ## docs/guide/stream-tool-call-index.md 面向写 Lua 适配器的人: - 上游 `index` 字段的用途:分桶累积 `id`/`name`/`arguments` - **键名是 `stream_index` 不是 `index`** —— 写错会被 Go 解码器静默丢弃 - 不透传的实际后果:name 互相覆盖、args 碎片混拼、工具被当空参数调用 - 顺带记两个易踩点:不能按 name 过滤分片;扁平结构的协议族同样要带 - 自检命令;并注明 `gemini.lua` 不涉及(协议是 `functionCall`) ## 其它 - `mkdocs.yml` nav 登记两篇 —— 之前它们会被 mkdocs 明确警告 "not included in the nav configuration",等于在站点里不可达 - 重跑 `tools/apidoc/build.sh` 同步 `docs/api/*`、`llms.txt`(生成物) 核实过的事实,避免臆造: - 内置工具声明是**核心仓**的 `toolDefOptions`/`parallelOpts()` (`internal/agent/core/tooldefs.go`),不是 `sdk.BuiltinToolDef` —— 初稿写错 - `gemini.lua` 对 `functionCall|tool_calls` 匹配数为 0,确认无流式实现 - `server/kimicode/anthropic/ollama` 四个适配器确有 `stream_index`(2~5 处) - 跨仓相对链接不可解析,故改为纯文本路径指路 `docs/api/tools.md` 属生成物(首行注明"请勿手改"),其 `ToolDef` 签名被截断、 不展示字段 —— 这是生成器既有行为,本次 diff 只是行号漂移,未改它。
4.1 KiB
工具并发声明:ParallelSafe / Serial
对应
sdk.ToolDef的两个字段。内核在同一轮收到多个tool_call时, 依据它们决定并发还是整批串行。状态:已随 2026-09 的并行内核落地并在生产启用。
1. 为什么是"保守 opt-in"
默认整批串行。 只有当批内每一个工具都显式声明 ParallelSafe: true
时,那一批才并发;只要有一个不声明,整批退回串行。
这不是"漏了声明导致退化"的将就,而是刻意的设计:
- 存量插件不改一行就得到保守行为(整批串行),不会被升级意外并发
- 声明是责任而非特权 —— 声明者必须自己确认线程安全
- 宁可慢,不可错:一次错误的并发可能让两个工具抢同一个 SQLite 写、 同一台设备、或同一个输出通道
// 同批全是安全工具 → 并发
tools: [a(ParallelSafe), b(ParallelSafe)] ⇒ 并发
// 只要有一个没声明 → 整批串行
tools: [a(ParallelSafe), b(默认)] ⇒ 串行
2. 三个条件都满足才可以声明 ParallelSafe
- handler 自身线程安全 —— 不持有跨调用的可变状态
- 不与同批其它工具争抢同一资源 —— SQLite 写、设备、同一输出通道
- 执行顺序无关 —— 顺序敏感的工具应留
false,由内核保序
第 3 条常被忽略:内核能保证用户可见的消息按声明顺序落盘,但工具
之间的实际执行先后在并发模式下不确定。有顺序依赖就留 false。
3. Serial:显式的反向标记
Serial bool `json:"serial,omitempty"`
ParallelSafe 的零值 false 已经表达"串行",插件无法区分:
- "我没想过"
- "我确认过必须串行,且有原因"
一旦工具作者需要把"这里故意串行,是有原因的"写进代码(而不只是没填), 这个区分就是必需的 —— 否则只能靠命名约定传递意图。
适用场景:读操作但有隐含顺序约束(终端 read/resize 这类共享会话
状态);写操作虽已加锁但需要串行以获得可预测的交错顺序。
优先级:Serial 胜出。 即使同时写了 ParallelSafe: true,Serial
仍然生效 —— 显式声明"必须串行"不允许被 ParallelSafe 或任何默认值覆盖。
4. 写法
声明字段放在 ToolDef 结构体的末尾,遵循既有 NoMemory 的风格:
sdk.RegisterTool(sdk.ToolDef{
Name: "my_readonly_query",
Description: "……",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
Handler: h.query,
ParallelSafe: true, // 声明在末尾
}, s)
需要"故意串行"时:
sdk.RegisterTool(sdk.ToolDef{
Name: "my_terminal_input",
Handler: h.input,
Serial: true, // 胜出,忽略 ParallelSafe
}, s)
5. 内置工具
核心仓的内置工具用 toolDefOptions / parallelOpts() 声明
(internal/agent/core/tooldefs.go),最终由 buildToolDefs 汇总成
ToolDef.ParallelSafe。
内核只提供并行调度基础设施,不硬编码任何工具名的安全状态表 —— 状态由每个工具在自己的声明结构里给出,查询时走聚合表。
6. 效果(实测)
同批 N 个各约 250ms 的工具,新版内核"内部并发"对"强制串行":
| N | 加速比 |
|---|---|
| 2 | ~1.41× |
| 4 | ~2.22× |
| 8 | ~3.88× |
批耗时几乎不随 N 增长,串行批严格线性。
压测时必须同时记录实际执行的工具数,不能只看耗时。 某次对照中旧版本耗时更短、但因适配器缺
stream_index导致 实际处理 0 个工具 —— 那不是性能提升,是全失败。
相关
设计背景与踩坑见核心仓文档(不在本仓):
docs/zh/toolcall-contract-and-sequence-design.md—— 契约与序列设计docs/zh/toolcall-parallel-execution-plan.md—— 阶段、实测压测数据docs/zh/deploy-runbook.md—— 生产部署(含适配器stream_index相关)