Files
deepseek-harness/docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.zh.md
T

51 lines
7.5 KiB
Markdown
Raw Normal View History

# RFC:显式的模型侧工具顺序
Status: implemented
[English](2026-07-06-explicit-tool-order.md) | 中文
## 问题
模型侧的工具顺序此前跟随插件注册顺序,而注册顺序取决于彼此独立的插件在并发模块加载时的竞态。这一竞态导致 CI 和快照录制中产生不同的请求头。由于顺序影响请求字节、缓存和持久化的头部,需要一个显式的确定性策略。
## 决策
系统提示词组装拥有模型侧工具的权威顺序,正如它已经拥有 section 顺序一样。`dsh-system-prompt` 上的 `toolOrder?: string[]` 是可选的显式策略:
- 列表中已注册的工具取其列出的位置。
- 列表中的名称没有对应的已注册工具,属于配置错误。形状错误(缺少 rest 条目或名称重复)在服务构造器中快速失败;未注册的名称在每次 `assemble()` 时拒绝——这是已注册工具集存在可供检查的最早时刻(工具插件在服务构造之后注册),也是唯一的通用时刻(注册随时可能变化;Cordis 没有「所有插件已加载」事件)。在已交付的 agent loop 下,第一个轮次在任何模型请求之前就会失败——确切的影响范围见下文「后果」。
- 已注册但不在列表中的工具,插入到 `'<unlisted-tools>'` rest 条目(`TOOL_ORDER_REST`)处,在其他未列出的工具之间按名称字典序排列。
- 任何已收集的工具不得使用 `TOOL_ORDER_REST` 作为其 `ToolSchema.name`;组装在排序之前就会拒绝该保留名称。
- 列表必须恰好包含一个 rest 条目,且名称不得重复。
- 当 `toolOrder` 未设置时,权威顺序为纯字典序(code-unit 比较,与 locale 无关),因此无需配置即可保证确定性。
`assemble()` 在 `system-prompt/assemble` waterfall(瀑布式事件)之前规范化提供方工具,从源头消除注册顺序差异。waterfall 从这个确定性列表出发;未被改变的顺序随后流入请求头、冻结请求和重建检查,无需循环特有的排序逻辑。
范围刻意收窄:本 RFC 修复的是注册顺序竞态,而非插件行为。`system-prompt/assemble` 的监听器仍可添加、移除或重排工具——正如它可以在 section 排序之后编辑 section——并对自身输出的确定性负责;waterfall 契约已要求监听器具有确定性(可重建性不变式会捕获在构建与回放之间表现不一致的监听器)。
配置传递沿用 `persona` 的先例,`toolOrder` 与它并列:应用配置(`dsh-stdio-demo`、`dsh-acp-demo`)接受该键,并通过 `dsh-agent-spine-demo`(其 schema 是各所有者 schema 的交集)转发给 `SystemPrompt` 子服务。有一个 schemastery 细节是关键的:schemastery 数组默认为 `[]`,但省略的 `toolOrder` 必须保持 ABSENT(= 字典序),而不是变成一个显式配置的空列表(无效——缺少 rest 条目),因此链上的每个 schema 都将默认值强制为 `undefined`。
## 曾考虑的替代方案
- **注册顺序(现状)**:并发导入竞态,依赖宿主环境(上述 CI 不稳定),评审中不可见。
- **插件依赖图的线性化**:该关系是偏序的,独立的工具插件之间不可比较;上述不稳定发生时偏序已完全满足。
- **每个插件在工具贡献上设 `weight`**:将顺序分散到各插件中,仍需一个无人拥有的全局编号约定(section 的 `order` 分段已经展示了这种协调成本需要手工承担)。
- **在 `ToolRegistry.schemas()` 中排序(注册表层)**:同样确定,但注册表是一个被组装之外的更多消费方使用的成员存储;排序是 prompt 组合的关注点,而组装已经拥有 section 的组合策略。
- **`LlmService` 配置 + 循环在记录头部前调用的 `orderTools()` 方法**:可行,但仅为在远处应用策略就增加了一个公开服务方法和一处循环改动;每个未来的请求组合者都必须记得调用。在列表诞生处规范化使无序列表不可表示,且零新增接口。
- **在 `llm.stream()` 内部规范化**:在头部事件记录之后才运行(不稳定仍存在),且需要重建深度冻结的信封,静默地解除了重建不变式。
- **穷举列表(无 rest 条目)**:每个新加载的工具插件都会导致启动失败;强制的 rest 条目使未列出的工具保持确定性,且其位置是显式的。
- **启动时校验(`dsh-app-boot` 在 `loader.await()` 之后调用 `SystemPrompt.assertToolOrderSatisfied()`)**:能将配置错误变为启动死亡而非首轮失败,但需要一个公开服务方法加上通用启动胶水对单一服务的结构耦合,且无论如何不能替代组装时检查(嵌入式调用者从不运行 app boot;注册在 boot 之后仍会变化)。也没有现成事件可以承载该检查:Cordis v4 没有 ready 类事件,`loader/entry-init`/`internal/status` 在加载中途触发(与工具注册竞态——正是本 RFC 要消除的熵源),而 agent 生命周期事件不会早于组装。在 `assemble()` 设一个执行点被判定值得接受较晚的失败时刻。
## 后果
- 每个由注册表构建的组装在任何宿主上都以确定性工具顺序开始;在没有专家监听器刻意改变的情况下,每个 `request/header` 事件和模型请求都继承该顺序。CI 与本地之间的注册顺序翻转在结构上被消除,默认为字典序。
- 初始 `PromptAssembly.tools` 是权威的,因此 waterfall 监听器从模型侧顺序出发;提供方注册顺序在该协作 seam 之前的任何地方都不可观测。
- 步骤之间的纯工具重排只能表示为 `request/header` 的 `'fallback'` 快照(基于名称键的 `ToolsDelta` 无法表达它);在稳定的权威顺序下,这种重排在实践中不再发生,因此 fallback 路径仅作为安全阀保留。
- `toolOrder` 键沿 app → `agent-core` → `SystemPrompt` 转发链传递,因此部署时在 app 配置中与 `persona` 并列设置;`dsh-llm` 和 agent loop 不受影响。
- `toolOrder` 中拼写错误或未加载的工具名称在 prompt 组装时使轮次失败,而非启动时:循环在轮次内组装(`turn/start` 之后、`step/start` 之前),因此拒绝到达轮次的外层 catch——轮次以 `error` 原因平衡关闭并携带消息,`agent/error` 镜像它,不开启步骤,不记录 `request/header`,不向适配器发出请求,agent 回到空闲。每个轮次都以相同方式失败,直到配置被修正;进程本身保持运行(与仓库规则一致:显式配置引用不得被静默忽略——执行点在组装处,因为不存在更早的通用时刻)。
- 工具提供方返回保留的 rest 条目名称时,其 prompt 组装失败形态与未知的列出名称相同。这防止哨兵值变成歧义的真实工具,并保持「从不丢弃工具」的排序契约。
## 测试
系统提示词测试覆盖字典序默认顺序、列出/rest 位置、提供方顺序无关性、共享名称、无效列表、未知或保留名称、waterfall 前的权威列表,以及监听器添加的工具不被重新排序的规则。循环测试固定跨注册排列的已记录和已分发顺序一致、通过 agent-core 和两个 app 的转发、深度冻结请求,以及在未知配置名称下的平衡轮次失败(无步骤、无头部、无适配器调用)。快照回放仅在固定的 `text-turn` 头部中保留完整的权威列表;其他 fixture(测试前置数据)继续使用 `{{tools}}`。