ByteNoteByteNote
权限只能收紧:DeepSeek Harness 工具系统拆解
字

字节笔记本

2026年10月6日 · 约 9 分钟读完

权限只能收紧:DeepSeek Harness 工具系统拆解

API中转
¥120

DeepSeek 在 GitHub 上开源的 agent 框架 DeepSeek Harness(简称 dsh)把「一切皆插件」写进了仓库口号,目前已收获约 24.4 万颗星,采用 MIT 协议,一条 npx @deepseek-ai/dsh web 就能在本地把 Web 界面跑起来。在这个框架里,模型与外部世界的每一次接触都要收敛成一次工具调用,承载这件事的工具子系统(dsh-tools 包)因此成了最核心的一条管线:字段怎么暴露给模型、参数怎么校验、权限怎么裁剪、调用怎么执行、结果怎么呈现,每一步都有明确契约。本文基于它的官方工具子系统文档,按注册、校验、限制、执行、呈现五个层面拆解这套设计。项目尚处 developer preview 阶段,接口仍可能快速演进。

ToolDefinition 字段白名单

模型看得见的,只有白名单里的字段

一个注册进框架的工具叫 ToolDefinition,由两部分拼成:前半是模型可见的 ToolSchema,只有名字、描述、参数三样;后半是全部留在宿主侧的执行设施,包括强制的输出声明 output、执行函数 execute、毫秒级超时预算 timeoutMs、并发安全分类器 isConcurrencySafe,以及可选的 finalizeContent 回调和 presentCall、presentResult 两个呈现器。

关键在注册表的 schemas() 方法:构建模型请求时,它按显式白名单做投影,只有 name、description、parameters 三个字段能上线,其余宿主字段一概不外泄。执行与呈现逻辑在类型层面就被挡在模型请求之外,不依赖开发者自觉。

参数校验由工具自己负责,execute 收到的 args 类型是 unknown,由工具自行收窄。第一方工具不手写这些样板,而是走 defineTool:先校验并收窄参数,再从 output 声明推断函数体返回类型,两个输出投影器随之获得精确类型。超时是协作式的:声明 timeoutMs 等于向框架承诺,工具会把 exec.signal 传给内部实现、能在信号中止时安静收尾;超时策略以 tools/execute 包装器的形式生效,这个数值永远不会发给模型。isConcurrencySafe 是并行的准入闸门,只有精确返回 true 的调用才允许与兄弟调用重叠,缺省、抛错或返回其他值一律独占,判定是 fail-closed 的。

一套词表,同时描述参数和输出

插件作者面对的不是裸 JSON Schema,而是一层作者友好的 DSL。ValueSchemaSpec 支持 string、number、integer、boolean、null、array、object 七种基础类型,外加作者专用的 json 和要求恰好命中一支的 oneOf;参数根是一个隐式的开放对象,必填以逐属性 required 标注,对象节点必须显式声明 additionalProperties 为真或假。

类型推断相当克制:InferValue 精确推断到 16 层容器之后回退成 JsonValue,避免撑爆 TypeScript 的类型栈;运行时校验却始终走完整 schema,推断上偷的懒不影响执行期的严格。参数不匹配抛 ToolArgsError(INVALID_ARGS),函数体或后置策略产出非法值抛 ToolOutputError(INVALID_TOOL_OUTPUT),都汇入统一的工具错误路径,调用失败但回合继续。

这套词表同样向外部开放:来自子代理、工作流、MCP 服务器或动态注册的原始 JSON Schema,先经 assertSupportedJsonSchema 检查组合合法,再由 validateJsonSchemaValue 在运行时执行,不支持的关键字会被拒绝而不是静默放行;oneOf 要求至少两个分支,且一个值必须恰好命中其中一个。需要对象根的消费者用 assertObjectJsonSchema 拿到对象根保证,结构化输出因此保持对象根,又不必限制共享词表。

限制作用于继承,多条取交集

工具注册分两层:部署全局一层,各代理作用域一层,作用域内的注册遮蔽全局同名。ToolRestriction 则是某个作用域对继承工具的活过滤器,作用范围是全局层加上祖先链上所有作用域提供的工具;注册表把多个限制编译成名字集合求交集,而作用域自己注册的工具豁免于限制,被委托的子代理因此仍保留它负责回答的工具。

allow 与 deny 的语义不对称值得一提:deny 过滤对之后新注册的未列名工具放行,allow 列表则天然排除一切未列名者。另外 presentAs 允许一个预设把覆盖范围内的代理整体切到 code 呈现模式,此时只有带父调用的嵌套分发才允许执行原生工具名,模型直接点名原生工具会在进入策略管线之前就被 UNKNOWN_TOOL 拒绝。

一次工具调用的六道关卡

一次调用的六道关卡

ctx.tools.execute() 是整条管线的入口。调用先经过一次无损 JSON 物化并深冻结,随后依次穿过六段。

第一段 tools/pre-execute,是可重排序的 allow、deny、ask 瀑布:ask 只有在审批服务返回 allowed-once 时才放行,缺审批通道、缺服务或无代理的请求一律变成拒绝;参数不可在此改写,因为历史记录、审计、界面与执行必须看到同一份实参。第二段是单调守卫 ToolGuard,在所有前置监听之后、函数体之前评估,它的返回类型刻意没有 allow:返回 undefined 维持瀑布裁决,返回 reason 只能把权限往下收,任何监听顺序都无法把一次拒绝翻回允许,这是整套设计里最硬的一条不变量。

第三段 tools/execute 是环绕分发,用于超时、重试、指标采集:包装器只允许替换 exec.signal,调用身份不可改动,注册表会在进入函数体前把替换信号与调用方原始信号重新融合,取消不会脱钩。第四段才是函数体 execute,拿到冻结参数与执行上下文;deferContext 可以把上下文挂到本次结果上,等结果抵达代理循环后再注入,适合复合工具摆渡嵌套分发的上下文,concludeTurn 则把一次成功结果标记为本回合终点。

第五段 tools/post-execute 负责接受、替换或拦截:content 与 value 二选一替换,换 content 保留规范值,属于呈现策略而非保密策略,换 value 会重新校验并重算内容与元数据,block 则把纠错反馈变成错误结果。第六段是 finalizeContent 与 tools/result:finalizeContent 对每个归一化结果恰好调用一次,连绕过 post-execute 的管线失败也会到达;tools/result 收到的是冻结的不可变快照,观察者只能读,监听者抛错会被就地隔离。并发调度由注册表分类:parallel 可与兄弟调用重叠进滚动池,exclusive 独占并形成排序屏障。

执行期的值,持久化的内容

ToolExecutionResult 用可辨识联合区分成败,成功才带规范值 value。这个 value 是执行期本地的:会话持久化只落 content、error 与 meta,回放能原样重现呈现,却重建不出中间值。调用身份住在伴随结果走完全程的不可变 ToolExecution 上,嵌套调用对外只见一个不透明 token,任何包装器都造不出第二份不一致的身份。未知工具与抛异常的工具都会物化成结构化错误(如 UNKNOWN_TOOL),调用失败,回合继续。

工具自己描述怎么显示

presentCall 与 presentResult 返回带 card 标记的渲染意图,是一套与任何客户端协议解耦的中性词表:generic 是默认卡,可携带读写的文件位置;terminal 对应命令、输出与退出码;diff 对应文件增改的内联差异;search 对应 grep、glob 结果,truncated 与 total 明示是否截断,界面永远不会把半截结果当成完整结果展示;read 是带行号的代码窗口;web 覆盖搜索与抓取,带来源与状态码。两个呈现器都是只依赖参数的纯函数,直播流式与会话回放可以安全复用同一份渲染,宿主与客户端再把这套词表投影成各自的界面。

写在最后

这份文档体现的取向很清晰:把安全边界内建进类型与管线结构,白名单防字段泄漏,单调守卫防权限回滚,冻结快照防结果篡改,而不是依赖开发者约定。对想给 agent 框架做工具层的团队来说,这是一份值得对照的参考实现。项目仍在 developer preview,接口可能变化,仓库在 github.com/deepseek-ai/deepseek-harness,感兴趣可以跑 npx @deepseek-ai/dsh web 亲手试一次工具调用。

相关文章

分享: