DeepSeek Harness 工具、权限和沙箱:Agent 如何被允许,也如何被拒绝
本文承接第 4 篇的
tool/call -> tool/result -> next-step,也承接第 5 篇关于 Session、checkpoint 与恢复的结论。前两篇回答"工具结果怎样让任务继续"和"这些事实怎样留下来";这一篇只追一个更危险的问题:模型提出一次工具调用之后,系统凭什么让它真的发生?
设想一个部署助手收到任务:"把 orders 服务重启。"模型很快就能生成一段工具调用:
{ "name": "bash", "arguments": { "command": "kubectl rollout restart deploy/orders" } }
这只是模型的意图,不是操作已经被授权,更不是命令已经在机器上执行。一个生产级 Agent Runtime 至少还要回答:
执行前,系统必须回答的三个问题:
- 这个工具是否暴露给该 Agent?——工具可见性
- 参数是否合法?——参数校验
- 当前策略是否允许?是否需要用户同意?——执行策略与审批
执行中及执行后,系统必须回答的两个问题:
- 若获准,进程到底能写哪些文件?——资源隔离
- 进程在命令刚执行后崩溃,恢复时如何避免"既不知道是否执行过,又直接重试一次"?——可恢复性
这五问分属两个阶段:前三问决定"能否开始",后两问决定"如何执行"和"如何恢复"。它们在 DSH 中被拆解到 Tool Runtime、Approval、Sandbox 与 Session checkpoint 四组机制里——这个拆分,是本篇最值得迁移的工程思想。
在逐节展开之前,先用一张速查表看清完整治理链路的九个环节。 后续各节会逐一展开每个阶段,但先看到全貌,才不会在细节中丢失方向。
九层治理链路速查:
| 层级 | 核心职责 | 一句话概括 |
|---|---|---|
| 1. 工具可见性控制 | 模型能选择哪些工具 | 可见性 ≠ 授权 |
| 2. 参数校验 | 模型传参是否合法 | 不校验就不进入审批 |
| 3. pre-execute 策略 | 这次调用是否允许 | allow / deny / ask |
| 4. 单调 guard | 守住不可退让的底线 | 拒绝后不能被重新放行 |
| 5. 一次性审批 | 需要外部确认才能继续 | 缺失即拒绝,不默认放行 |
| 6. checkpoint 耐久化 | 副作用前先留痕 | 不持久则不执行 |
| 7. sandbox 资源约束 | 实际能碰什么范围 | prompt 是说明,backend 才是 enforcement |
| 8. post-execute 结果治理 | 检查、阻断或改写结果 | 改 content ≠ 改 canonical |
| 9. 有序提交 | 并发执行、顺序落库 | 并发 body,顺序提交 |
这也是阅读本篇的索引:第 3 节解释第一层,第 4 节解释第二至第四层,第 5 节解释第六层及其升权,第 6 节解释第五层,第 7 节收束最后三层。第 2 篇的装配与第 3 篇的插件生命周期决定这些层是否存在;第 4、5 篇则分别提供最后一层的调度顺序和 checkpoint 所依赖的持久化真源。
1. 先校正概念:四件事不是同一件事
很多 Agent 的实现会把“模型看得见工具”“用户点了允许”“进程不能写工作区外文件”统称为权限。这样写 demo 没问题,但排查一次越权或恢复事故时就会立刻失去抓手。
DSH 中,这四件事的归属不同:
| 问题 | DSH 的主要机制 | 它控制的对象 | 它不保证什么 |
|---|---|---|---|
| 模型能否选择一个工具? | 工具注册、Agent scoped visibility、ctx.tools.restrict() | 请求中呈现哪些 tool schema | 不阻止已知调用绕过“不可见”进入运行时 |
| 某一次调用是否应当执行? | tools/pre-execute、ctx.tools.guard()、ask | 这次调用的允许/拒绝/询问决策 | 不把进程限制在文件系统白名单里 |
| 用户是否同意一次危险动作? | ctx.approval.request()、approval/request | 一次性 allowed-once / 拒绝 / 取消 | 不是长期权限令牌,也不是 UI 本身 |
| 获准的进程实际能碰什么资源? | ctx.sandbox 的 sandbox backend | 文件副作用的实际约束 | 不替代工具调用策略;也不等于网络隔离 |
| 外部副作用前是否留下可恢复事实? | session-checkpoint-policy | 模型请求、顶层工具 body 前的耐久屏障 | 不承诺每个流式 chunk 都同步落盘 |
尤其要记住一条容易犯错的结论:
ctx.tools.restrict()是 Agent 视角的工具可见性/呈现控制,不是安全授权边界。
如果把“从模型的工具列表里隐藏 bash”当作安全措施,那么一个来自重放、协议接入或错误实现的 bash 调用会直接暴露设计漏洞。真正的执行决策必须落在 pre-execute 与不可被后续放行的 guard 上;真正的资源隔离还要由 sandbox backend 执行。
这也把前三篇串起来了:第 2 篇讲 Profile/Bundle 决定哪些插件被装配,第 3 篇讲 Cordis 把能力放在可替换的 service seam 上。到了这里可以看到它们的落点:同一个运行可以装配工具消费者、审批回答者、sandbox provider、持久化与 checkpoint policy;少装一个,行为会改变,而且应当由机制明确地暴露出来,而不是静默“默认允许”。
图中的线不是每次都完整经过。例如普通只读工具可以不走 ask;参数无效会在更早处停止;未装审批服务时需要审批的调用会被拒绝。但把完整路径先看清,后面的分支才不会混乱。
图中的九层对应引言速查表中的前八层——从“模型产生 tool call”到“结果处理与实时通知”,最后“Agent Loop 追加 Session tool/result 并入队”对应第九层“有序提交”。后文各节将按这个顺序逐层展开。
SOURCE:packages/core/tools/README.md 定义 Tool Runtime 的完整流水线;packages/interaction/user-approval/README.zh.md 定义一次性审批与审计;packages/sandbox/sandbox/README.zh.md 定义 sandbox service 的词汇和 fail-closed 行为。
2. 工具调用的“执行”与“记录”:为什么需要分开?
本节回答一个具体问题:工具的实际执行和日志记录,为什么不是同一个系统完成的?
2.1 第 4 篇追到了哪里?
第 4 篇已经追到:模型输出 tool-call 后,Agent Loop 从 assistant message 中取出多个 tool-call block,executeToolCalls() 先把每个待执行调用以 tool/call 写入 Session,再交给 ctx.tools 调度。完成后,Loop 按模型原始顺序追加 tool/result,并把工具携带的 additionalContexts 交给 Inbox,等待同一 Turn 的下一 Step 领取。
但第 4 篇没有展开的一个细节是:“记录日志”和“执行工具”是两套不同的系统完成的。
- Agent Loop 负责写 Session(
tool/call、tool/result) - Tool Runtime 负责实际执行(参数校验、策略、审批、sandbox、body)
两者不能合并,因为职责不同。
2.2 两个“结果”的区别
图里有两个听起来很像的东西:
| 名称 | 谁产生 | 实时通知? | 持久事实? | 用途 |
|---|---|---|---|---|
tools/result | Tool Runtime | 是 | 否 | UI/指标/日志插件实时观察 |
tool/result | Agent Loop | 否 | 是 | 模型下次请求看到、Session 恢复重放 |
tools/result:Tool Runtime 执行完工具后发出的实时通知。指标、界面或日志插件可以旁观它。observer 抛错不应改写工具结果。
tool/result:Agent Loop 写进 Session 的持久事件。模型下次请求会看到它,进程恢复会用到它。第 5 篇的“模型可见内容必须能从日志重建”在这里落地。
2.3 为什么不能让工具自己写 tool/result?
一个直观的疑问:既然 Tool Runtime 执行完了工具,为什么不直接让它把结果写进 Session?
因为并行调用时,哪个工具先执行完是不确定的。
模型可能同时调用了三个工具:A、B、C。执行顺序可能 B 先结束、A 第二、C 最后。但模型期望的顺序是 A、B、C——这是模型调用时的顺序。
如果每个工具执行完就立即自己写 tool/result,Session 里的顺序会变成 B、A、C,与模型期望的顺序不一致。回放时 transcript 的顺序就乱了。
所以设计是:
| 阶段 | 负责者 | 顺序要求 |
|---|---|---|
| 工具 body 执行 | Tool Runtime | 可并发 |
tool/result 提交 | Agent Loop | 按原始 tool-call 顺序 |
executeToolCalls() 让 body 并发执行以提升吞吐,但结果提交由 Agent Loop 按原始顺序保证(commitReady())。这样既得到并发吞吐,又保证 transcript 可确定地重放。
第 4 篇的“并发执行、顺序提交”不是调度细节,而是第 5 篇可恢复日志的前提。
2.4 本节小结
| 问题 | 答案 |
|---|---|
| 日志和执行是一套系统吗? | 不是。Agent Loop 写日志,Tool Runtime 做执行 |
| 两个“结果”的区别是什么? | tools/result 是实时通知(给 UI/指标),tool/result 是持久事实(给模型/恢复) |
| 为什么不让工具自己写日志? | 并发执行时,结束顺序 ≠ 调用顺序;必须由 Agent Loop 按原始顺序提交 |
SOURCE:packages/core/agent-loop/src/tool-calls.ts 的 appendToolCall()、appendToolResult()、commitReady();packages/core/tools/src/index.ts 的 notifyResult()。
3. 工具首先是一份可验证的声明,而不是一个 execute() 函数
第 4 篇的 executeToolCalls() 可以把 assistant message 中的调用交给 ctx.tools,前提是这里已经有一份能解释参数、结果和执行方式的工具声明。本节展开的正是这个前提:没有 schema 的“函数调用”无法成为可审计的模型输入。
3.1 为什么工具不能只是一个函数?
在普通项目里,一个工具就是函数:
const readFile = async (path: string) => fs.readFile(path, 'utf8')
这没问题——程序员知道怎么调用它。但模型调用工具的方式完全不同:模型发来的是 JSON 字符串,系统需要知道参数类型、格式、输出结构。
没有 schema 的“函数”对模型调用方来说是黑盒。
DSH 要求工具插件向 ctx.tools.register() 注册时,必须包含两样东西:
- 输入 schema:参数叫什么、什么类型、是否必填
- 输出声明:结果的结构是什么样的
这套声明的核心目的是:让工具调用成为一份可验证、可审计的记录,而不是一次不可追溯的函数执行。
3.2 工具结果为什么分三层
工具执行完,返回的数据被三类消费者同时需要:
| 消费者 | 需要什么 | 如果混在一起会怎样 |
|---|---|---|
| 模型 | 适合推理的精简信息 | 看到 UI 数据,token 浪费,推理失效 |
| UI | 富展示内容(颜色、格式) | 模型看到 UI 文案,以为那是真实数据 |
| 审计/策略 | 可验证的结构化数据 | 只看文本,不知道真实返回值 |
DSH 的做法是把它们分开:
| 字段 | 给谁用 | 内容 |
|---|---|---|
canonical value | 程序、策略、审计 | 结构化真实结果(如 { temperature: 25 }) |
content | 模型 | 精简文本描述(如 "北京今天晴,25°C") |
meta | UI | 展示数据(颜色、图标、格式) |
这样模型看到适合推理的内容,审计看到可验证的结构,UI 看到可展示的数据——互不干扰。
3.3 为什么参数校验必须早于审批?
模型传来的 JSON 可能不合法。比如工具要求 path 是 string,模型传了 123。
DSH 的顺序是:先校验参数,再决定是否审批。
如果反过来——先审批、再校验,会发生什么?
用户收到一个审批请求:“批准执行 bash,参数 rm -rf /”。用户点了“批准”。然后系统发现参数非法,什么也没执行。
事后审计看到:
- 审批记录:用户批准了
rm -rf / - 执行记录:什么都没发生
用户到底批准了什么? 他以为自己批准了危险操作,但系统压根没执行。审计无法对齐“用户同意的内容”和“系统实际做的内容”。
审计歧义的本质是:用户批准时的语义 ≠ 系统执行时的语义。 即使事后能通过
seq查到不一致,这个“不一致”本身已经发生了——用户以为自己在批准一件事,系统做的是另一件。
正确的顺序是:先校验,确认调用合法,再交给审批。 这样用户批准的每一个请求都是“可执行、可描述”的合法动作,事后审计时审批记录和执行记录完全匹配。
3.4 restrict() 为什么不是安全机制?
restrict() 控制的是:模型能看到哪些工具。
它适合做:
- 子 Agent 只看到检索工具,不看到部署工具
- Plan 模式只展示无副作用工具
它不适合做:
- 防止危险调用被执行
原因很简单:调用可以绕过模型。
| 调用来源 | 是否经过模型? | restrict() 能拦住吗? |
|---|---|---|
| 模型在对话中调用 | 是 | 能 |
| 历史重放一个调用 | 否 | 不能 |
| API 直接提交 tool-call | 否 | 不能 |
| 另一个 consumer 直接调用 | 否 | 不能 |
restrict()管的是“模型能建议什么”。pre-execute/guard管的是“系统能执行什么”。 前者发生在模型层,后者发生在执行层。如果把restrict()当成安全边界,相当于把门锁装在“建议”上,而不是装在“门”上。
3.5 本节小结
| 问题 | 答案 |
|---|---|
| 工具在 DSH 中是什么? | 一份可验证的声明,包含 schema + 输出规范 + 元数据 |
| 结果为什么要分三层? | 模型、UI、审计三类消费者需求不同,混在一起会互相污染 |
| 参数校验为什么在审批之前? | 保证用户批准的每一个请求都是“可描述、可执行”的合法动作 |
restrict() 为什么不算安全机制? | 它管的是“模型能建议什么”,不是“系统能执行什么”——调用可以绕过模型 |
SOURCE:packages/core/tools/README.md 的 registration、canonical result、scoped restrict 与 pipeline 说明;packages/core/tools/src/index.ts 的 execution scheduler。
4. 执行前策略:allow、deny、ask
上一节回答了“工具是什么”(一份可验证的声明)。本节回答:声明合法之后,系统凭什么决定让不让它执行?
4.1 三类决策:allow / deny / ask
工具真正进入 body 前,先经过 tools/pre-execute waterfall。它输出三类决策:
| 决策 | 含义 | 后续发生什么 |
|---|---|---|
allow | 这次调用可继续 | 进入 guard、sandbox、执行 body |
deny(reason) | 这次调用被拒绝 | 生成带原因的错误结果,不进入 body |
ask(reason?) | 需要外部决定 | 交给审批服务,等待用户/管理员确认 |
注意:allow 不代表“这个工具永远安全”,只代表“这一层策略没有拒绝这一次调用”。策略可以按工具名、参数、当前 session 状态或业务规则动态决定——读公开文档 allow,执行 kubectl ask,删生产库 deny。
这是三层而不是两层的原因是:有些动作系统自己不能判断,需要外部输入(如人工审批)。ask 正是为此存在。
4.2 ask:缺失即拒绝,不默认放行
ask 的语义是“需要外部回答才能继续”,不是“显示一个提示,没人回应就默认放行”。
如果审批通道不可用(UI 没弹出来、服务没装、没人应答),系统应该怎么办?
ask → 请求审批
├── allowed-once → 继续执行
├── rejected → 拒绝
├── cancelled → 拒绝
├── unavailable / 无审批服务 → 拒绝
└── 审计记录写失败 → 拒绝(不给未记录决定)
只有 allowed-once 这一条路径能继续执行。 其他所有路径(拒绝、取消、服务不可用、审计失败)都映射为拒绝。
审计要求:每次审批请求都会追加 approval/asked 和 approval/decided 两条事件。如果这对审计记录在提交前失败,整个请求会失败,而不是制造一个“已允许”但无法追溯的记录。
模型不会看到权限 UI 的内容。模型只会看到最终写进 Session 的工具结果——允许则看到正常结果,拒绝则看到错误结果。
4.3 guard:单调的底线拦截器
除了可组合的 pre-execute 策略,Tool Runtime 还支持 ctx.tools.guard()。它在 pre-execute 之后运行,并具有 单调性:
一个 guard 拒绝后,后续 waterfall 不能再把它重新放行为
allow。
为什么需要 guard?
pre-execute listener 是可替换、可组合的部署策略。但因为可组合,就存在一个问题:后安装的“允许”插件可能意外覆盖前面“拒绝”插件的决定。
guard 解决的是策略所有权问题:
pre-execute:适合可替换的部署策略- guard:适合拥有不可退让底线的组件,如“某类凭证绝不能通过这个工具发送出去”
如果没有单调约束,后安装的通用“允许”插件可能覆盖安全插件的拒绝。
注意:guard 不等于操作系统隔离。它仍在决定“要不要调用工具”,与 sandbox(决定“调用后能碰什么资源”)是前后相邻、职责不同的两层。
| 层级 | 职责 |
|---|---|
pre-execute | 可组合的策略决策(allow/deny/ask) |
| guard | 不可退让的单调底线 |
| sandbox | 已获准调用的资源约束 |
4.4 本节小结
| 问题 | 答案 |
|---|---|
| pre-execute 输出什么? | 三类:allow、deny、ask |
| ask 的语义是什么? | “需要外部决定才能继续”,不是“先试试” |
| 审批通道不可用时怎么办? | 拒绝——fail-closed,缺失即拒绝 |
| guard 和 pre-execute 的区别? | guard 是单调的,拒绝后不能被重新放行;pre-execute 是可组合的 |
| guard 和 sandbox 的区别? | guard 决定“要不要调用”,sandbox 决定“调用后能碰什么资源” |
SOURCE:packages/core/tools/README.md 的 tools/pre-execute、guard()、ask 和 fail-closed 描述;packages/interaction/user-approval/README.zh.md 的 approval outcome、turn 归属与审计对。
5. 用户审批与沙箱升权:批准的是这一次,不是把门拆掉
前面两节解决了“调用应不应该开始”(第3、4节)。本节回答紧随其后的两个问题:开始后的进程实际能影响哪里?如果默认权限不够,模型能不能临时申请更高的权限?
先理解一个基础概念:sandbox 是执行期的资源隔离层。
pre-execute 和 guard 决定的是“要不要调用工具”——它们在调用发生之前做出判断。sandbox 处理的是另一个问题:调用已经获准,但实际执行时,进程能碰哪些文件、能写哪些目录?
这是两层不同的职责。前一层管“入口”,后一层管“边界”。即使调用被允许了,sandbox 仍然可以把它的活动限制在特定范围内。这也是为什么不能把 restrict() 当作安全边界——sandbox 才是真正执行资源约束的地方。
本节讨论的是 sandbox 的文件副作用约束,不是网络隔离、凭证控制或所有系统资源的万能隔离。DSH 的 sandbox 词汇在源码中明确定义为 file effects 的范围。
5.1 sandbox 的三个 mode
DSH 定义了三个针对文件操作的 mode:
| mode | 文件副作用含义 | 典型场景 |
|---|---|---|
read-only | 限制写入;可有面向宿主的必要输出 sink | 代码分析、日志查看、文档检索 |
workspace-write | 可写 session workspace 根目录,以及后端定义的临时区域 | 修改项目代码、创建配置文件 |
danger-full-access | sandbox 不再约束可用操作的文件修改 | 系统维护、紧急修复 |
两个重要限定:
第一,这三个 mode 只针对文件操作。不能把 danger-full-access 宣传成“所有系统资源都被隔离”——它只管文件,不管网络、凭证或进程。
第二,sandbox mode 是实际执行的策略,不是给模型的建议。如果对应的 sandbox backend 不可用,系统会明确拒绝执行并报错,而不是悄悄降低安全标准来“让任务跑通”。
5.2 prompt 是说明,不是 enforcement
sandbox-policy 会把当前文件策略作为 system prompt context 提供给模型。模型知道“当前是 read-only,如果需要写文件该如何申请升权”。这改善了模型行为,减少了无意义的失败调用。
但 prompt 是说明,不是强制执行。模型可能忽略它,或者调用根本不来自模型(历史重放、API 直接调用)。安全性始终取决于 sandbox backend 在执行时实际施加的限制。
这形成了一个清晰的分工:
工具列表 / Prompt:影响模型的选择与预期 —— 但不强制
pre-execute / guard:决定这次调用是否可以开始 —— 入口控制
sandbox backend:实际执行时限制资源范围 —— 真正的约束
三者协作,任何一个都不能替代另一个。restrict() 不替代 sandbox,prompt 也不替代 sandbox。
5.3 升权:临时申请更高权限,不是永久授权
模型在调用参数中可以提出 sandbox_permissions 与 justification,申请比当前默认模式更高的权限。升权不是永久改变 Session 的默认模式,而是为这一次调用请求一个严格更宽的 mode。
升权规则是单向严格递增的:
read-only → 可申请 workspace-write 或 danger-full-access
workspace-write → 可申请 danger-full-access
danger-full-access → 不存在更宽目标
两个必要条件:
- 请求的 mode 必须严格更宽(read-only → workspace-write 合法;workspace-write → read-only 不合法)
- 必须带非空
justification(理由)
只给 mode 不给理由、只给理由不请求 mode,都是无效参数,不会触发审批。
检查通过后,经由 approval channel 请求一次许可。拒绝、取消、无人应答、无 Agent 可审计,都会在工具真正执行前失败。只有 allowed-once 才能继续执行。
“一次允许”很关键:本次调用结束后,权限恢复为原来的 mode。模型下次想再升权,必须重新申请、重新审批。这避免了“一次批准变成万能通行证”的风险——不会产生一个可被后续任意命令复用的长期 token。
5.4 本节小结
| 问题 | 答案 |
|---|---|
| sandbox 的职责是什么? | 执行期资源隔离,限制已获准调用能碰哪些文件 |
| 三个 mode 分别是什么? | read-only / workspace-write / danger-full-access |
| prompt 是安全边界吗? | 不是,prompt 是说明,backend 才是 enforcement |
| 升权是永久的吗? | 不是,只对这一次调用有效,下次需要重新申请 |
| 升权需要什么条件? | 严格更宽 + 非空 justification + 一次审批 |
SOURCE:packages/sandbox/sandbox/src/index.ts、README.zh.md 定义 mode、enforcement 与 unavailable;packages/sandbox/sandbox/src/escalation.ts 定义严格升权表、参数配对与 approval-before-execute 顺序;packages/sandbox/sandbox-policy 的测试验证三个 mode 的 prompt 表述。
6. 执行前 checkpoint:让意图先于副作用持久化
本节回答第 5 篇留下的问题:在工具 body 执行之前,为什么还要多一道 checkpoint 屏障?
6.1 什么是 checkpoint?
checkpoint 是把内存中的事件强制写入磁盘,使之在进程崩溃后仍然可恢复。崩溃的是 DSH Agent 主进程,不是执行命令的子进程
Session.append() 是内存操作——它快,但进程崩溃后丢失。Session.flush() 是磁盘操作——它慢,但能跨越进程边界存活。
checkpoint 在 body 执行前调用一次 flush,是为了在主进程崩溃后,仍能恢复‘曾经发起过这个调用’的意图。我们不在每个操作上都 flush,只在可能产生外部副作用的边界上做一次——用一次同步 I/O 的代价,换取崩溃后可追溯性。
Session.append(tool/call) 成功
= 内存日志已有调用事实
≠ 硬崩溃后一定能恢复
Session.flush() 成功
= 持久化 listener 已完成本次耐久检查点
6.2 为什么需要 checkpoint?
假设审批已通过,sandbox 也就绪。现在 kubectl rollout restart 即将执行。
最糟糕的时序是: 命令已经对外部系统产生效果,进程却在 tool/call 还没落盘时硬崩溃。
重启后会出现一个不可判定的世界:外部服务可能已重启,Session 却没有“我准备执行什么”的记录。系统既不能安全重试,也无法解释这次变更来自哪里。
6.3 checkpoint 的位置:为什么在这里?
checkpoint 放在 pre-execute / guard 之后、工具 body 之前。
为什么在审批之后?
被拒绝的调用不会产生外部副作用,没必要为它建立“副作用前耐久屏障”。allow / ask / guard 全部通过后,接下来首次可能发生的外部效果正是 tool body,因此 checkpoint 放在这里刚好覆盖风险窗口。
为什么在 body 之前?
因为一旦 body 开始执行,外部世界就可能发生变化。如果这个时候还没有持久化记录,崩溃后就无法追溯意图来源。checkpoint 必须发生在 body 之前,才能保证“外部变化”和“意图记录”同时存在。
6.4 checkpoint 失败:fail-closed
如果 flush 失败,系统拒绝执行 tool body。
原因很直接:如果 flush 失败仍然执行,外部可能已经变了,日志却没有留下意图——系统又回到了那个不可判定的状态。
这个设计牺牲的是短暂可用性,保住的是可审计与可恢复性。
6.5 checkpoint 之后崩溃:TOOL_OUTCOME_UNKNOWN
checkpoint 保证的是“调用意图已经持久化”,不保证“执行结果”已经写入。
命令开始后硬崩溃,日志可能留下 tool/call,却没有 tool/result。DSH 的恢复语义会为这种未配对调用提供模型可见的 TOOL_OUTCOME_UNKNOWN。
恢复后未配对的 tool/call
→ 系统明确告知模型“结果未知”
→ 模型不能盲目重试有副作用的操作
→ 应按工具幂等性、外部状态核验或用户确认来决定
这不是“完全消灭崩溃”,而是把不可避免的不确定性编码为显式结果。读操作可以重试;创建订单、重启服务、发送邮件必须先确认外部世界的状态。
6.6 和第 5 篇的关系
第 5 篇定义了 append vs flush 的耐久语义,并提到 checkpoint policy 会调用 flush()。
第 6 节展开的是:checkpoint policy 在工具层如何使用 flush()——在哪个时机调用、如果失败怎么办、崩溃后留下什么。
| 第 5 篇的语义 | 第 6 节的应用 |
|---|---|
append = 内存 | 工具执行前需要 flush 才能保证可恢复 |
flush = 磁盘持久化 | checkpoint 在 body 前调用 flush |
恢复依赖持久化的 turn/end | 恢复也依赖持久化的 tool/call 配对检查 |
6.7 本节小结
| 问题 | 答案 |
|---|---|
| checkpoint 是什么? | body 执行前调用 flush(),把调用意图持久化到磁盘 |
| 为什么需要? | 防止“外部已变、日志没有记录”的不可判定状态 |
| 位置在哪? | pre-execute/guard 之后、body 之前 |
| flush 失败怎么办? | fail-closed:拒绝执行,不制造不可判定状态 |
| 崩溃后结果丢了怎么办? | 返回 TOOL_OUTCOME_UNKNOWN,让模型确认外部状态,不自动重试 |
SOURCE:packages/session/session-checkpoint-policy/src/index.ts 的 tools/execute listener;packages/session/session-checkpoint-policy/README.md 的 fail-closed、嵌套调用与 TOOL_OUTCOME_UNKNOWN 恢复说明。
7. 执行后:结果治理、取消与顺序
前面几节回答的是“工具能否开始执行”和“执行时如何受限”。这一节回答:工具 body 返回后,结果还需要经过什么处理,才能成为 Session 里的一条记录?
这些内容在第 4 篇已经分散出现过,本节把它们收束成一张速查表,并补上两个容易被忽略的边界。
7.1 执行后的三件事
工具 body 返回后,结果并非直接写回 Session。它还要依次经过三个阶段:
| 阶段 | 做什么 | 谁负责 | 失败影响 |
|---|---|---|---|
tools/post-execute + finalizeContent | 检查结果、改写 content、阻断反馈、追加 additionalContexts | 策略插件 + 工具定义 | 失败时结果不提交 |
tools/result | 发实时通知给 UI / 指标 / 日志 | observer(旁观者) | observer 失败不影响结果 |
commitReady() | 按模型调用顺序写入 Session,并将 additionalContexts 入 Inbox | Agent Loop | 写入失败则 Turn 失败 |
这三个阶段是串行的,但职责完全不同:
| 阶段 | 能不能改结果 | 失败后是否阻断提交流程 |
|---|---|---|
post-execute + finalizeContent | 能改 content,能阻断 | 会阻断 |
tools/result | 只读旁观 | 不阻断 |
commitReady() | 只做顺序提交 | 会阻断 |
为什么 observer 不能阻断?
tools/result 是给 UI、指标、日志插件“看”的通知。如果一个 observer 插件抛错了(比如 UI 渲染失败),不应该导致工具结果无法写入 Session。结果已经产生了,UI 看没看到是 UI 自己的问题。
为什么 post-execute 能阻断,但只改 content 不等于安全处理?
post-execute 是策略可以检查结果的最后机会。比如某个工具返回了敏感数据,策略可以阻断它,不让它进入模型上下文。
但一个容易犯的边界错误是:只改 content,不改 canonical。
{
"canonical": { "temperature": 25, "city": "Beijing", "apiKey": "secret" },
"content": "北京今天晴,25°C",
"meta": { "icon": "☀️" }
}
如果策略只是把 content 里的“25°C”删掉,但 canonical.apiKey 仍然存在,那么某个同进程的消费者仍然可以读到 apiKey。content 是模型视图,canonical 才是结构化真源。要真正阻断数据访问,必须根据消费者边界决定替换或阻断哪个字段。
7.2 取消时的顺序:等待 body 停稳
第 4 篇已经详细定义了 Turn 取消的语义,这里只补充一点:
取消信号到达后,如果 body 已经开始执行,Tool Runtime 会等待 body 自然结束或超时,再返回取消结果,而不是立刻放弃。
| 做法 | 含义 | 风险 |
|---|---|---|
Promise.race() 立即放弃 | 取消信号来了,不等 body,直接返回 | body 可能还在后台运行,你不知道它是否完成、是否还在执行 |
| 等待 body 停稳 | 取消信号来了,等待 body 结束或超时,再返回 | body 可能浪费一些时间,但状态是确定的 |
为什么不用 Promise.race()?
因为外部副作用是真实的。如果 kubectl rollout restart 已经开始执行,放弃等待不代表命令停止了——它可能还在继续。上层已经认为任务取消了,但命令还在后台运行,系统状态变得不可判定。
7.3 本节小结
| 问题 | 答案 |
|---|---|
| 执行后的三个阶段是什么? | post-execute 可改写结果,tools/result 只旁观,commitReady() 保证顺序提交 |
| observer 失败会阻断结果吗? | 不会,observer 只旁观,不影响结果提交 |
只改 content 能隐藏数据吗? | 不能,canonical 仍然存在,其他消费者可能读到它 |
| 取消时如何对待正在运行的 body? | 等待 body 停稳,不立即放弃,保证状态确定 |
执行后三阶段的完整数据流
为了帮助你理解这一节在整个工具调用链路中的位置,这里把执行前后的完整数据流串起来:
从第 2 节到第 7 节覆盖了从“模型输出 tool-call”到“结果成为 Session 持久事实”的完整路径:
| 环节 | 对应节 | 核心问题 |
|---|---|---|
| 参数校验 | 第 3 节 | 参数合法吗? |
| pre-execute / guard | 第 4 节 | 这次调用应不应该开始? |
| approval | 第 4 节 | 需要外部确认吗? |
| checkpoint | 第 6 节 | 副作用前的意图持久化了吗? |
| sandbox | 第 5 节 | 执行时能碰什么? |
| body 执行 | 第 4 节 | 实际执行工具 |
| post-execute / tools/result / commitReady | 第 7 节 | 结果如何交给模型? |
SOURCE:packages/core/tools/README.md 的 post-execute、finalizeContent、cancellation 与 result observer 约定;packages/core/agent-loop/src/tool-calls.ts 的并行 pool 与 commitReady()。
8. 用“重启服务”走完一遍:每个失败点到底阻断了什么?
前面几节把工具调用的治理链路拆成了 9 个阶段。这一节用一个具体例子把整条链串起来——回到开头的 kubectl rollout restart deploy/orders。
下面的表格记录的是这条路径上每个阶段如果出错,会发生什么。它不是“理想路径”,而是工程上更有价值的故障地图。
8.1 全链路故障表
| 时刻 | 发生的事实 | 如果这里出问题 | 外部世界变了没? | 日志里留下什么? |
|---|---|---|---|---|
| 1 | 模型产生 bash tool-call | 参数格式不对,schema 校验失败 | 没变 | 一条规范错误结果,不会进入审批,不会执行任何东西 |
| 2 | Loop 把 tool/call 追加到 Session | append() 本身失败 | 没变 | 当前 Step 无法继续,没有可信的调用记录 |
| 3 | 策略判定需要审批(ask) | 无人应答、用户拒绝或取消 | 没变 | 拒绝结果;approval/asked + approval/decided 成对存在时可追溯 |
| 4 | guard 执行 | 安全底线拒绝(如凭证黑名单) | 没变 | 拒绝结果,且后续 listener 不能再把它改成“允许” |
| 5 | checkpoint 执行 flush() | flush 失败,或在等待落盘时被取消 | 没变 | ABORTED_BEFORE_DISPATCH,不会进入 body |
| 6 | sandbox 启动命令 | 沙箱不可用、文件访问被拦 | 没变(命令未成功启动) | 错误结果,绝不无约束降级 |
| 7 | 工具 body 开始执行(命令已发起) | 主进程硬崩溃 | 可能已变 | tool/call 已落盘,但没有 tool/result;恢复后补 TOOL_OUTCOME_UNKNOWN |
| 8 | body 返回,Loop 提交结果 | 后续结果处理失败或被取消 | 可能已变 | 结果按具体错误/取消语义写入,不能假装成功 |
| 9 | additionalContexts 入 Inbox | 下一 Step 前崩溃 | 结果已经产生,以日志为准 | 依第 5 篇的设计:tool/result + agent/inbox/spliced 两条记录共同决定恢复行为 |
8.2 这张表想说明什么?
重点不是“DSH 绝不会出错”,而是每一种错误都被安排在恰当的边界上:
| 阶段 | 原则 | 为什么 |
|---|---|---|
| 执行前(时刻 1-6) | 错误发生时,外部世界没有被改变 | 调用还没真正执行,阻断是安全的 |
| 执行中(时刻 7) | 崩溃不假装成功,但意图已记录 | 外部可能已变,日志里至少留下“我准备做什么” |
| 执行后(时刻 8-9) | 结果按实际状态写入,不猜测 | body 可能已完成,按真实结果记录;入队失败可恢复 |
8.3 如果你正在设计自己的 Agent 项目
这张表比照搬一个“危险工具确认弹窗”更值得复用。在设计阶段,先给每个工具回答清楚这组问题:
- 副作用类型:这个工具是只读的,还是会修改外部状态?
- 幂等性:重复执行会有什么后果?
- 审批人:谁有权批准这个操作?
- sandbox mode:执行时应该限制在什么范围?
- checkpoint 边界:执行前必须持久化什么?
- 未知结果后的处置:崩溃后恢复时,应该重试、查询外部状态,还是标记失败?
然后再考虑界面怎么画。
9. 治理链路能迁移到哪些场景?
这套治理链路本质上回答了六个通用问题:
模型能看到什么?→ 参数合法吗?→ 允许执行吗?→ 有底线红线吗?→ 需要外部确认吗?→ 执行时受什么约束?→ 结果怎么交给模型?
任何工具调用都绕不开这组问题。下面用六个不同领域的具体场景展示这套框架的通用性。
9.1 六场景对照表
| 场景 | 工具调用示例 | pre-execute / approval | 执行约束 | 未知结果处置 |
|---|---|---|---|---|
| 运维助手 | 发布、扩缩容、重启 | 生产环境 ask;高危 deny | 受限凭证、目标集群白名单 | 查询发布状态,不直接重试 |
| 客服助手 | 发券、退款、改订单 | 金额/频率阈值 ask;超限 deny | API scope、租户隔离、用户身份校验 | 用业务单号查询结果 |
| 知识助手 | 检索内部文档、数据库 | 数据分类策略;敏感库 ask | 数据源 ACL、脱敏代理 | 标记检索结果“未确认” |
| 自动化工作流 | 发邮件、创建工单 | 收件人域名与模板策略 | 邮件/工单 API scoped token | 用幂等键或外部 ID 查重 |
| 财务/数据分析 | 导出报表、执行统计查询 | 数据范围 ask;大查询 deny | 只读副本、脱敏视图、行级权限 | 检查数据快照版本,不直接重跑 |
| 代码审查 / 研发效能 | 提交 PR、合入主干、回滚 | 受保护分支 ask;不合规 deny | 分支保护规则、CI 检查门禁 | 查询 PR 状态,不重复提交 |
| 供应链 / 采购 | 创建采购单、修改供应商信息 | 金额阈值 ask;不合规 deny | ERP 权限模型、审批流绑定 | 用采购单号查询执行状态 |
| 人力资源 / 员工服务 | 更新员工信息、发起入职流程 | 敏感字段 ask;越权 deny | 组织架构 scope、数据脱敏 | 用员工 ID 查询 HR 系统状态 |
| 物联网 / 边缘设备 | 重启设备、更新固件、修改配置 | 设备类型 ask;高危 deny | 设备分组权限、操作日志 | 查询设备状态,不盲目重试 |
9.2 场景背后的共同结构
所有场景都遵循同一套模式:
| 治理层 | 每个场景都要问的同一个问题 |
|---|---|
| 可见性控制 | 这个 Agent 应该看到哪些工具? |
| 参数校验 | 模型传的参数合法吗? |
| pre-execute | 这次调用应该允许、拒绝还是询问? |
| guard | 有没有不可退让的底线规则? |
| approval | 需要谁确认才能继续? |
| checkpoint | 执行前需要留下什么可恢复记录? |
| 执行约束 | 执行时能访问什么范围? |
| 结果治理 | 结果如何分发给模型、UI、审计? |
9.3 迁移时不要做的事
不要把“治理”简化成一个权限开关。
常见错误是把“模型能不能执行这个工具”浓缩成一个布尔值(allow: true/false)。这是最早期的 Agent 设计方式——它无法回答“为什么这次拒绝了那次却允许了”“谁批准了这次高危操作”“崩溃后如何恢复”。
分层治理的收益在于:
| 问题 | 如果只有一个权限开关 | 如果分层设计 |
|---|---|---|
| “为什么这次拒绝了?” | 不知道,只有 allow/deny | 可以追溯到具体层(策略、guard、审批人、参数校验) |
| “谁批准了这次操作?” | 没有记录 | approval/asked + approval/decided 成对审计 |
| “崩溃后怎么判断?” | 不知道 | tool/call + tool/result 配对检查 |
| “这个 Agent 能看到什么工具?” | 全有或全无 | restrict() 控制可见性 |
9.4 最小可行迁移
如果要在自己的项目中落地这套分层治理,不一定要实现全部九层。四层即可:
| 四层简化 | 对应的 DSH 机制 | 每个项目都可以有 |
|---|---|---|
| ① 可见性 | restrict() | Agent 按角色看到不同工具列表 |
| ② 允许/拒绝/询问 | pre-execute + ask | 策略判断 + 人工审批 |
| ③ 执行约束 | sandbox / ACL / scope token | 执行时限制能碰什么 |
| ④ 可恢复留痕 | tool/call + tool/result | 每次调用都有开始和结束记录 |
最重要的不是复刻 DSH 的全部实现,而是理解“模型意图”和“系统执行”之间的那条边界必须被显式治理——而不只是一个 if (allowed) { execute(); }。
10. 源码阅读地图与实验
本篇建议不要从 bash 工具开始倒读。先读通用 Tool Runtime,再看它如何被审批、checkpoint、sandbox consumer 连接起来:
packages/core/tools/README.md
-> packages/core/tools/src/index.ts
-> packages/core/tools/tests/tools.spec.ts
packages/interaction/user-approval/README.zh.md
-> packages/interaction/user-approval/tests/approval.spec.ts
packages/session/session-checkpoint-policy/src/index.ts
-> packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts
packages/sandbox/sandbox/src/index.ts
-> packages/sandbox/sandbox/src/escalation.ts
-> packages/sandbox/sandbox/tests/escalation.spec.ts
11. 本篇结论
现在可以把“模型调用工具”改写成一句更精确的话:
模型只提出一个待验证的行动意图;DSH 先决定它是否可见、是否允许、是否获得一次性审批和足够的实际资源约束,再在副作用前留下耐久事实,最后才执行,并按模型顺序把结果交回下一 Step。
这句话拆分出五条可复述的原则:
| 原则 | 核心意思 | 一句话记忆 |
|---|---|---|
| ① 可见性 ≠ 授权 | restrict() 只控制模型能看到什么,不能替代执行期策略 | “看不见”不等于“不能执行” |
| ② 策略可组合,底线不可逆 | pre-execute 处理可替换的部署策略;guard 是单调的,拒绝后不能被重新放行 | 策略可以商量,底线不能商量 |
| ③ ask 是一次性、可审计、缺失即拒绝 | 审批请求不是长期 token;通道不可用时必须 fail-closed | 没人应答,就等于拒绝 |
| ④ sandbox 是执行期约束,prompt 不是 enforcement | sandbox 决定已获准调用实际能碰什么;prompt 只是说明 | 告诉模型“有限制”,不等于真的有限制 |
| ⑤ checkpoint 不保证外部操作完成,但保证意图可恢复 | 副作用前先留痕;中断后的未知结果必须显式表示为 TOOL_OUTCOME_UNKNOWN | 不假装知道不知道的事 |
系列位置
到这里,第 2 至第 6 篇已经形成一条连续链:
Profile/Bundle(第2篇)→ 装配能力
Cordis(第3篇)→ 管理能力的生命周期
Agent Loop(第4篇)→ 调度一次任务
Session(第5篇)→ 保存可信历史
Tool Runtime(第6篇)→ 把模型意图变成受控行动
12. 闭卷复述
不看文章,尝试回答下面六题:
- 为什么
ctx.tools.restrict()不能作为安全授权边界? tools/result与 Session 的tool/result分别由谁产生、分别服务谁?ask没有可用 approval answerer 时,为什么应拒绝而非默认允许?pre-execute与单调 guard 的职责有什么不同?- sandbox prompt、执行期 policy、sandbox backend 各自解决什么问题?
- 为什么顶层工具 body 前的 checkpoint 能改善崩溃恢复,却不能保证崩溃时外部命令到底有没有完成?
能不看源码回答清楚,再回到以下四个入口逐段验证:
packages/core/tools/src/index.ts
packages/core/agent-loop/src/tool-calls.ts
packages/session/session-checkpoint-policy/src/index.ts
packages/sandbox/sandbox/src/escalation.ts
下一篇将进入另一个经常被混为“上下文窗口”的问题:Context、Skill、Compaction 与 KV Cache 如何共同决定模型到底看见什么、何时压缩,以及为什么“压缩历史”不能破坏可恢复与可审计的 Session 真源。
更多推荐


所有评论(0)