Files
homeagent-sdk/docs/guide/parallel-tool-declaration.md
JianFeeeee 82e8d9dbac docs: 补两篇指南 —— 工具并发声明、流式多 tool_call
`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 只是行号漂移,未改它。
2026-09-27 22:25:38 +08:00

4.1 KiB
Raw Blame History

工具并发声明: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

  1. handler 自身线程安全 —— 不持有跨调用的可变状态
  2. 不与同批其它工具争抢同一资源 —— SQLite 写、设备、同一输出通道
  3. 执行顺序无关 —— 顺序敏感的工具应留 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 相关)