这是《DeepSeek Harness 权威指南 》系列的第 5 篇。 本文源码基线为 deepseek-harness @
47f9438(v0.1.0-rc.5)。源码和中文文档结论在首次出现处说明来源;本机 demo 只说明该脚本、命令和环境下的行为。
你让 agent"读一下项目里的 package.json"。模型本身不会读文件——它只会生成文字。 它做的最接近"读文件"的事,是在回复里写一段特殊格式的请求:“我要调用 read_file 工具,参数是文件路径 /tmp/a.txt”。这段请求交到 dsh 手里之后,要过七道关卡才会真正执行;执行完,结果再以一段特殊格式的文字回到模型手里,模型才能"看到"文件内容。
这就是工具系统:模型的手和眼睛。 没有它,模型只能生成文本;有了它,模型能够请求读文件、跑命令、查 API、改代码。本篇把这条链路从模型请求、注册表认领到执行结果回填逐段拆开。源码契约与作者本机示例会明确区分,不把本机输出当成仓库基线的公开保证。
一、先搞懂两个基础概念
1. 模型怎么"调用"工具
模型不会执行代码。它只会输出文本。所谓“工具调用”,是模型在回复里输出一个特殊格式的请求块。dsh 的实际类型定义如下(packages/llm/llm/src/types.ts:77-93):
/** A tool invocation requested by the model. */
export interface ToolCallBlock {
type: 'tool-call'
/** Provider-issued call id; correlates with the matching tool result. */
id: CallId
name: string
/** Raw JSON string as produced by the model. */
arguments: string
}
/** The result of a tool invocation, sent back to the model. */
export interface ToolResultBlock {
type: 'tool-result'
toolCallId: CallId
content: ContentBlock[]
isError?: boolean
}
中文意思是:模型给出 type: 'tool-call'、提供方发放的调用 id、工具名和原始 JSON 字符串 arguments;结果以 toolCallId 配回这次调用,可携带多个内容块与可选错误标记。arguments 是字符串而不是对象,因此注册表必须先解析和校验,不能把模型输出直接当成可信数据。
模型在下一轮请求里看到这个 tool-result 块,就知道"文件读到了,内容是……",可以继续干活。一轮"请求→执行→回填"就是一个 step(系列第 4 篇讲过:turn 由 step 组成)。
2. 注册表是什么
dsh 里有个"工具名单",叫注册表(ctx.tools)。模型只能调用名单上有的工具——名单上没有的,调用会直接失败。一个合适的比喻:
注册表是菜单,模型是只能点菜的客人。 菜单上有什么,模型才能点什么;菜单上没有的菜,喊破喉咙也不会有人做。而且菜单会变:插件装上、菜单就加菜;插件卸载、菜就撤掉。
这个“菜单”有三个功能。工具文档生成段的原文是:
Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.
中文意思是:作用域注册会遮蔽全局注册;同一个可见性解析器同时服务于展示、查找和分派。对应到读者视角:
- 展示:菜单(工具列表 + 每个工具的说明和参数格式)会拼进系统提示词,模型每轮请求都看得到
- 查找:模型点菜(tool-call 的 name)时,注册表按名字找到对应的工具实现
- 执行:找到之后,才进入执行流程
三个环节共用一个名单,所以模型看不到的工具,调用时也会被拒——不存在"看到了但调不了"或"调了但没这个菜"的错位。
二、一次调用的完整旅程
把两个概念串起来,就是一次工具调用的完整旅程。我们用一个具体例子走一遍:用户说"读一下 package.json 的 name 字段"。

图:①模型生成 tool-call 请求 → ②dsh 注册表按名字认领、参数从文本变数据 → ③流水线把关(本篇重点)→ ④工具本体执行 → ⑤结果回填给模型 → ⑥全流程可审计。
第①步:模型生成请求。 模型的回复里出现一个 tool-call 块:name="read_file"、arguments='{"path":"package.json"}'。它没有读文件,只是"点了这道菜"。
第②步:注册表认领。 dsh 在注册表里按 read_file 找到工具定义。菜单上没有这道菜,直接失败(UNKNOWN_TOOL)。菜单上有,进入下一步——注意这一步里参数还是字符串,还没变成数据。
第③步:流水线把关。 这是本篇的核心,下一节逐道展开。简单说:一堆检查程序排队看这次调用——允许、权限、参数合法性,全部通过才放行。
第④步:工具本体执行。 你的 execute 代码真正跑起来:打开文件、读内容、返回。返回的不是自然语言,是一个规范的值(比如字符串 {"name":"dsh"})。
第⑤步:结果回填。 执行结果被格式化成 tool-result 块,写进会话日志(系列第 4 篇的投影事件之一),下一轮请求模型就能看到。
第⑥步:可审计。 整个过程的每个阶段都有事件广播(pre/execute/post/result),日志忠实记录——出了问题能查,出了事故能复现。
三、七道关卡逐道展开
现在进入流水线内部。中文工具文档对注册表契约的原文是:
ctx.tools.execute()接受由调用方拥有且包含必需 readonlysignal的ToolExecutionInput,将其解析后的 JSON 参数一次性物化为流水线拥有的ToolExecution,然后让调用依次经过tools/pre-execute(可重排的 allow/deny/ask waterfall)→ 已注册的单调 guard →tools/execute(环绕分派包装层)→tools/post-execute(检查/替换结果)→ 可选且由定义拥有的finalizeContent→tools/result(不可变的权威结果)。只有tools/execute视图可以替换必需的 signal。最终产出为ToolExecutionResult。
为便于理解,下面把这条注册表链路拆成七个读者关卡:参数物化、pre-execute、guard、工具本体、规范化、post-execute、result。会话中的 tool/result 事件由 agent-loop 在注册表得到最终结果后另行追加,不能把它误写成 ToolRuntime 内部阶段。

图:执行流水线七阶段。guard 是单调的——没有 allow 结果,任何监听器顺序都不能把拒绝变回允许。
关卡 1:参数物化(注册表内部)
干什么:把模型给的参数 JSON 字符串解析成数据对象,深冻结(谁都不能改),分配一个不透明的调用令牌 exec.token。
为什么需要:模型的参数是"文本",而工具要的是"数据"。而且这段文本是模型生成的,可能畸形、可能夹带危险内容——必须在任何代码接触它之前,完成一次可靠的解析和冻结。
没有它会怎样:每个工具自己解析 JSON,解析错误各报各的;或者某个环节偷偷改了参数,日志和实际执行对不上。
关卡 2:pre-execute 门禁(waterfall 事件)
干什么:一次调用到达后,所有注册在 tools/pre-execute 上的监听器排队检查它。每个监听器可以返回三种决策之一:
allow:放行(用next()委托给下一个检查)deny:拒绝,带原因ask:需要人确认——请求转到审批系统(ctx.approval),等待用户同意或拒绝;中文工具文档明确规定:缺少审批支持时,ask会变成拒绝。
为什么需要:权限、审批、租户策略、费用控制……所有"这次调用该不该发生"的业务逻辑都挂在这一关。参数合法性也在这里确认,全部通过才放行。这是策略的组合点:权限插件挂一个监听器、审批插件挂一个监听器、公司策略挂一个监听器,它们互不干扰地叠加。
没有它会怎样:模型说什么都执行。让 agent"删掉项目里所有测试文件"这种请求会直接到达文件系统。
关卡 3:guard 单调守卫
干什么:pre-execute 全部放行之后、工具本体执行之前,还有一个"最后的闸门"。ctx.tools.guard() 注册的守卫函数只做一件事:返回拒绝原因,或者什么都不返回(放行)。
和关卡 2 的区别是关键:pre-execute 可以"放行"(allow),guard 没有放行能力,只有拒绝能力。所以:
guard 是单调的——任何监听器的执行顺序,都不能把 guard 的拒绝变回允许。
这保证了一个安全性质:只要有一个守卫说"不行",这件事就永远不行,后来的代码无法撤销这个拒绝。pre-execute 里"先放行后拒绝"的顺序混乱是可能的(监听器顺序),guard 里不可能。
没有它会怎样:安全决策可以被"后注册的监听器"覆盖——比如门禁放行了,后面一个宽松的监听器又把拒绝顶掉了。
关卡 4:工具本体
干什么:你的 execute(args, exec) 代码。参数已经校验过、冻结过,exec.signal 是取消信号(用户取消、超时、会话关闭时触发)。
为什么需要规范:这是唯一一道"你的代码"关卡。两条硬规矩:返回规范值(output.schema 声明的类型),不返回自然语言——不要让调用方从自然语言里解析 id 和字段;遵守 exec.signal,信号触发就停手。
没有它会怎样:每个工具返回格式随心所欲,模型学不会"工具返回长什么样";长任务不响应取消,用户取消后进程还在偷偷跑。
关卡 5:规范化
干什么:execute 返回的值先校验(符合 output.schema 吗)、深冻结、再交给 output.render 变成模型能看的内容块。抛异常或返回非法值 = 本次调用是错误(isError=true)。
为什么需要:工具实现千差万别,但模型看到的格式必须统一。这一关把"工具作者的随意"挡在模型视野之外。
关卡 6:post-execute 变换(waterfall 事件)
干什么:结果规范化后,tools/post-execute 的监听器可以接受、替换、增强或阻断结果。典型用途:给结果附加指标、记录额外信息、拦截敏感输出。
为什么需要:有些事必须发生在"结果产生之后"但"模型看到之前"。比如:结果太大要截断、结果里含敏感信息要脱敏、每次调用要记一笔费用。
关卡 7:result 观察 + 落日志
干什么:tools/result 事件(emit 模式,只读)把冻结的最终结果广播给观察者,审计、遥测、UI 刷新可以挂在这里。随后 agent-loop 会把 tool/result 追加进会话日志,模型在下一轮请求中通过 A3 的投影规则看到结果。
为什么需要:模型看到的结果与日志记录严格一致——这一关把"执行过什么、结果是什么"钉进事实源,事后审计、断点续传都靠它。
四、完整跑一遍:真实代码与输出
上面七道关卡来自公开工具契约。下面是作者本机示例:它使用 @deepseek-ai/dsh-tools 注册 read_file、监听各阶段并加一个拒绝 /etc 路径的 guard。该 scratch 脚本不属于 47f9438 的已提交源码,因此输出用于说明调用形状,不作为独立可复验的基线证据。
/**
* A4 工具系统实证:注册 -> 流水线 -> 门禁
* 运行:node --import tsx packages/core/tools/scratch/tool-pipeline-demo.ts
*/
import { Context } from '@deepseek-ai/cordis'
import { ToolRuntime, defineTool } from '@deepseek-ai/dsh-tools'
import { MessageId } from '@deepseek-ai/dsh-llm'
async function main() {
const app = new Context()
// ToolRuntime 声明依赖 systemPrompt(schema 组装);demo 只验证调度流水线,给最小 stub
app.provide('systemPrompt', {
tools: () => { /* schema 组装回调,demo 不验证 */ },
section: () => { /* 非 native 模式才用 */ },
} as never)
await app.plugin(ToolRuntime)
// ---- 1. 注册一个工具(defineTool DSL)----
const dispose = app.tools.register(defineTool({
name: 'read_file',
description: 'Read a file from disk.',
parameters: {
path: { type: 'string', required: true, description: 'Absolute path' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: String(value) }],
},
async execute(args, exec) {
console.log(`[body] read_file 执行: ${args.path}`)
return `内容: ${args.path}`
},
}))
// ---- 2. 观察流水线事件 ----
const preExecute = async (exec: any, next: any) => {
console.log(`[pre-execute] ${exec.name} args=${JSON.stringify(exec.arguments)} -> 放行`)
return next()
}
const postExecute = async (exec: any, result: any, next: any) => {
console.log(`[post-execute] ${exec.name} -> ${JSON.stringify(result)}`)
return next()
}
const onResult = (exec: any, result: any) => {
console.log(`[result] 观察冻结结果: ${exec.name} isError=${result.isError}`)
}
app.on('tools/pre-execute', preExecute)
app.on('tools/post-execute', postExecute)
app.on('tools/result', onResult)
// ---- 3. 注册一个单调 guard:拒绝读取 /etc 下的文件 ----
app.tools.guard((exec) => {
const path = (exec.arguments as { path?: string }).path ?? ''
if (path.startsWith('/etc')) return 'deny: /etc 目录禁止读取(demo guard)'
return undefined
})
const signal = new AbortController().signal
console.log('=== 调用 1:正常路径 ===')
const r1 = await app.tools.execute({
callId: MessageId('c1'),
name: 'read_file',
arguments: { path: '/tmp/a.txt' },
signal,
})
console.log('调用 1 结果:', JSON.stringify(r1))
console.log('\n=== 调用 2:被 guard 拒绝 ===')
try {
await app.tools.execute({
callId: MessageId('c2'),
name: 'read_file',
arguments: { path: '/etc/passwd' },
signal,
})
} catch (e) {
console.log('调用 2 被拒:', (e as Error).message)
}
console.log('\n=== 调用 3:未知工具 ===')
try {
await app.tools.execute({
callId: MessageId('c3'),
name: 'no_such_tool',
arguments: {},
signal,
})
} catch (e) {
console.log('调用 3 失败:', (e as Error).message)
}
// ---- 4. 卸载即注销 ----
dispose()
console.log('\n=== 注销后再次调用 ===')
try {
await app.tools.execute({
callId: MessageId('c4'),
name: 'read_file',
arguments: { path: '/tmp/a.txt' },
signal,
})
} catch (e) {
console.log('调用 4 失败:', (e as Error).message)
}
await app.fiber.dispose()
}
main().catch((err) => { console.error(err); process.exit(1) })
作者本机示例输出(重点看调用 1 和调用 2 的差别):
=== 调用 1:正常路径 ===
[pre-execute] read_file args={"path":"/tmp/a.txt"} -> 放行
[body] read_file 执行: /tmp/a.txt
[post-execute] read_file -> {"isError":false,"content":[{"type":"text","text":"内容: /tmp/a.txt"}],"value":"内容: /tmp/a.txt"}
[result] 观察冻结结果: read_file isError=false
调用 1 结果: {"isError":false,"content":[{"type":"text","text":"内容: /tmp/a.txt"}],"value":"内容: /tmp/a.txt"}
=== 调用 2:被 guard 拒绝 ===
[pre-execute] read_file args={"path":"/etc/passwd"} -> 放行
[post-execute] read_file -> {"isError":true,"error":{"message":"deny: /etc 目录禁止读取(demo guard)"},"content":[{"type":"text","text":"Error: deny: /etc 目录禁止读取(demo guard)"}]}
[result] 观察冻结结果: read_file isError=true
=== 调用 3:未知工具 ===
[pre-execute] no_such_tool args={} -> 放行
[post-execute] no_such_tool -> {"isError":true,"error":{"message":"unknown tool \"no_such_tool\"","info":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"content":[{"type":"text","text":"Error: unknown tool \"no_such_tool\""}]}
[result] 观察冻结结果: no_such_tool isError=true
=== 注销后再次调用 ===
[pre-execute] read_file args={"path":"/tmp/a.txt"} -> 放行
[post-execute] read_file -> {"isError":true,"error":{"message":"unknown tool \"read_file\"","info":{"name":"ToolNotFoundError","code":"UNKNOWN_TOOL"}},"content":[{"type":"text","text":"Error: unknown tool \"read_file\""}]}
[result] 观察冻结结果: read_file isError=true
四段输出对应四个知识点:
调用 1(正常路径):[pre-execute] 放行 → [body] 执行 → [post-execute] 收到规范化结果 → [result] 观察。七道关卡按顺序走完,结果结构是 { isError, content, value }——value 是规范值,content 是模型看到的内容块。
调用 2(guard 拒绝):注意 [pre-execute] 打印了"-> 放行",但 [body] 没有出现——guard 在 pre-execute 之后、工具本体之前把它拦下了。拒绝被规范化为 isError=true 的错误结果(不是抛异常),模型会看到 Error: deny: ...,可以把拒绝当纠错信号。这就是单调性:pre-execute 放行了也没用,guard 说不行就是不行。
调用 3(未知工具):错误带结构化信息 ToolNotFoundError / UNKNOWN_TOOL——“菜单上没有这道菜”。
调用 4(注销后):dispose() 之后同一个工具变成 UNKNOWN_TOOL——注册是副作用,卸载即注销(系列第 3 篇的契约在这里兑现)。
五、作用域:每个 agent 一张工具桌
工具不是全站一张表。每个 agent 有自己的作用域:全局层注册默认工具(fs、bash、subagent 这些),单个 agent 可以在自己的作用域里注册专属工具、过滤可见工具集、加守卫。
作用域存在的理由:不同 agent 干的活不同。 写代码的 agent 需要文件工具,客服 agent 需要查订单工具——让客服 agent 看到文件工具,既是噪声(模型要在一堆无关工具里选)也是风险(它不该碰文件)。作用域让"这个 agent 能看到什么工具"成为一个可配置的边界。
三个操作对应三种能力。restrict() 的实际实现如下(packages/core/tools/src/index.ts:1064-1098):
/**
* Restrict global tools for the calling agent scope. Empty filters, unknown
* names, scope-local names, and reserved transport names fail. Restrictions
* intersect; scoped registrations remain visible.
* @param filter - global-tool mask: `allow` (keep only) and/or `deny` (remove).
* @returns the exact disposer that lifts this restriction.
*/
restrict(filter: ToolRestriction): () => void {
const scope = scopeOf(this.ctx)
if (scope === undefined) {
throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead')
}
const allow = filter.allow
const deny = filter.deny
if (allow === undefined && deny === undefined) {
throw new Error('tools.restrict({}) is a no-op: pass `allow` and/or `deny` (an empty filter is almost always a materialized-empty-config bug)')
}
const compiled: CompiledToolRestriction = {
...allow !== undefined ? { allow: new Set(allow) } : {},
...deny !== undefined ? { deny: new Set(deny) } : {},
}
if ([...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`)
}
const known = this.view(scope).restrictableNames
const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
if (unknown.length > 0) {
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
}
return this.layers.effect(
this.ctx,
layer => layer.restrictions.append(compiled),
{ label: 'tools.restrict()' },
)
}
中文解释:它要求调用方是 agent.ctx 这样的 scope context;在 context-global 调用会直接抛错,而不是“全局生效”。allow/deny 只能指向已知的全局工具;多个限制取交集,scope 自己注册的工具仍可见。
| 操作 | 作用 | 关键约束 |
|---|---|---|
register(definition) | 注册工具 | 全局注册是部署级;agent 专属工具通过该 agent 的 agent.ctx 注册 |
restrict(filter) | 过滤某 scope 继承来的模型可见工具 | 必须在 agent.ctx 调用;context-global 调用直接抛错,自身 scope 注册不被该过滤器移除 |
guard(guard) | 作用域级单调守卫 | 只拒绝、不放行(第三节) |
restrict 是"渐进式披露"的底座:工具太多时,模型先看到一小部分,需要时再展开——注册表保持展示、查找、执行三者对齐,模型永远只能请求它看得到的工具。
六、安全分层:三个独立旋钮
工具系统的安全不只靠一个 API。本文把沙箱模式、审批策略、权限预设/guard 归纳为三个分析轴;这是本文的组织方式,不是官方定义的“正交旋钮”原句。仓库可直接核对的事实分别是 ctx.sandbox、ctx.approval 与 ctx.permissionPresets 的职责。

图:注册表按作用域分层;图中把沙箱、审批与预设/guard 画为三个分析轴,便于区分“能做什么”“是否需确认”和“如何组合策略”。
分析轴一:沙箱模式——管文件系统效果。 中文 sandbox 文档定义:read-only 拒绝写入,workspace-write 允许工作区及后端承诺的临时区域写入,danger-full-access 绕过隔离。网络和进程可见性不由这三个名称本身定义,需看具体后端。
分析轴二:审批策略——管是否需要人确认。 ask 会在 pre-execute 决策中请求审批;never 则不请求。没有审批服务或 agent 时,ask 不会静默放行,而是成为拒绝。
分析轴三:权限预设——把沙箱与审批组合成命名档位。 这不是从 dump 输出重画的简化表;dsh-base 的实际 patch 是(packages/bundle/base/cordis.patch.yml:188-205):
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'"
- id: permission
name: '@deepseek-ai/dsh-permission-presets'
config:
presets:
read-only:
sandbox: read-only
approval: ask
workspace-write:
sandbox: workspace-write
approval: ask
danger-full-access:
sandbox: danger-full-access
approval: never
中文解读:当未设 DSH_PERMISSION_MODE 时,表达式以 workspace-write 作为回退值,审批策略为 ask;只有 danger-full-access 走 never。这份 base bundle 配置列出三种预设。把它们画成“能力边界、人工确认、策略表达”三轴是本文的分析方式;具体沙箱后端的实际限制仍要由 A6 的平台实现核对。
七、给工具作者的原则
下面是给工具作者的七条建议,属于本文归纳;每条以仓库的 ToolSchema、defineTool、结果规范化和取消契约为依据,不把它们伪装成官方原文。
第一,schema 就是模型侧的操作说明。 模型看到的是 ToolSchema:工具名、description 和参数 schema;不是只靠一段 description 猜用途。description 要写清动作和边界,参数 schema 要写清字段、类型和必填项。
第二,错误消息即纠错信号。 拒绝和失败都以结构化结果返回模型(isError=true + 消息),模型据此调整下一次调用。让错误可读、可行动。
第三,参数校验交给注册表。 defineTool 自动校验,execute 内不用再解析——但 schema 表达不了的约束(非空、正数、跨字段)要在 execute 里自己查。
第四,返回规范值,不返回自然语言。 execute 只返回声明 schema 的规范 JSON;渲染交给 output.render。不要让调用方从自然语言里解析 id 和字段。
第五,遵守 exec.signal,但不要误解为强杀。 工具定义契约的英文原文写的是:Async work must observe or forward exec.signal ... but it cannot hard-kill same-process code. 中文意思是:异步工作必须观察或转发取消信号,并在自己的工作停止后才 settle;注册表不能硬杀同一进程中的代码。需要硬隔离的 shell 等能力应交给沙箱后端。
第六,长时间任务走后台。 run_in_background + ctx.jobs.start(),返回类型化句柄 { kind: 'background', jobId }——不要让模型阻塞等一个 10 分钟的 shell 命令。
第七,注册借只读定义。 注册后不要修改 schema 或替换回调;热替换 = dispose 旧副作用 + 注册新工具。
八、代价与边界
工具系统的三个代价:
第一,schema DSL 表达能力有限。 复杂约束(跨字段、业务规则)要工具自己校验,错误处理代码会膨胀。DSL 是"80% 场景零代码",剩下 20% 要自己写。
第二,工具数量膨胀。 工具越多,模型的选择噪声越大。dsh 的解法是作用域(每 agent 只见自己的工具)+ 渐进式披露(restrict 按需展开)——工具系统设计的第一步不是加工具,是控制模型看到的工具集合。
第三,同进程执行的天花板。 注册表只能通过协作式 signal 取消同进程代码,不能 hard-kill 它。真正需要硬隔离的 shell 等能力走沙箱后端;A6 会把不同平台后端的限制与 fail-closed 路径单独拆开。
九、决策表
| 设计问题 | 方案 A(简单做法) | 方案 B(dsh) | 淘汰 A 的原因 |
|---|---|---|---|
| 工具怎么定义 | 裸函数 + 手动解析参数 | defineTool DSL + 注册表校验 | 手动解析必错,schema 让模型和注册表共享契约 |
| 执行把关 | 执行前一个 if 判断 | pre-execute + guard 双层 | 单点判断无法叠加策略,waterfall 让策略可组合 |
| 拒绝语义 | 抛异常 | 规范化为 isError 结果 | 异常打断模型循环,结果让模型可以纠错 |
| 工具集合 | 全局一张表 | 作用域分层 + restrict | 全局表噪声随工具数膨胀,作用域控制可见性 |
| 安全控制 | 工具内自检 | 沙箱 + 审批 + 预设三轴 | 自检不可审计、不可组合;三轴正交可独立演进 |
| 工具可见性 | 注册即全部可见 | 展示/查找/执行三处对齐 | 模型看不到的工具调用会被拒,避免幻影工具 |
十、系列路线
下一篇进入模型接入层:LLM 适配器 seam、流式词汇表、token 计量与重试——确定性系统如何与概率模型安全对齐。
FAQ
Q:模型是怎么"调用"工具的? 模型不会真的执行任何代码,它只在回复里生成一个特殊格式的请求块(tool-call):工具名 + 参数 JSON 字符串。dsh 收到后到注册表认领,走完流水线把关,执行完把结果作为 tool-result 块回填给模型。
Q:dsh 的工具是怎么定义的? 用 defineTool DSL:name、description(模型看到的)、parameters(参数 schema,自动校验)、execute(执行函数)、output(规范返回值 + render)。注册通过 ctx.tools.register(),卸载插件即注销。
Q:dsh 工具执行流水线有哪些阶段? 参数物化冻结 → pre-execute(waterfall 门禁,allow/deny/ask)→ guard(单调拒绝)→ 工具本体 → 规范化(校验/冻结/render)→ post-execute(waterfall 变换)→ result(emit 观察)→ tool/result 事件落会话日志。
Q:guard 和 pre-execute 有什么区别? pre-execute 是 waterfall 门禁,监听器可以返回 allow/deny/ask 决策,next() 委托;guard 是单调守卫,只返回拒绝原因,没有 allow 结果——所以任何监听器顺序都不能把 guard 的拒绝变回允许。
Q:dsh 的安全是怎么分层的? 三个正交旋钮:沙箱模式(read-only/workspace-write/danger-full-access,决定 fs/shell 能力边界)、审批旋钮(ask/never,pre-execute 返回 ask 转 ctx.approval)、权限预设(预设 = 沙箱模式 + 审批旋钮的组合)。
Q:工具执行被拒绝时模型看到什么? 拒绝不是抛异常,而是规范化成 isError=true 的结果,错误消息返回给模型(如 deny: xxx)。模型可以把拒绝当作纠错信号调整行为。
互动模块
① 站队:工具系统的安全,“注册表把守 + 审批确认”(dsh 模式)和"工具自检 + 事后审计"(多数 DIY 模式),你更信任哪种?A. 平台层把关是唯一解 B. 工具自检灵活,平台层太笨重 C. 混合:高风险工具平台管,普通工具自管
② 征集:你见过模型"幻影工具调用"吗——模型调用了根本不存在、或早已下线的工具?当时系统怎么反应的(报错/静默/崩溃)?评论区分享,我会在 LLM 与流式篇里结合真实案例展开。
③ 转发:如果你身边有人正准备给 dsh 写第一个工具插件,把这篇转给他——调用旅程图值得收藏。
