04 · 工具系统:调用能执行,也能拦住
设计可验证的 tool schema、dispatch 和权限链,知道何时串行、并行或拒绝调用。
本章任务
要回答的问题
模型发出 Tool Call 后,参数校验、权限判断和执行派发应该在哪一层发生?
读完你能
- 写出模型可用但不会放宽权限的工具契约
- 区分参数错误、策略拒绝、执行失败和结果持久化
- 决定哪些调用可以并行,哪些必须串行验证
- 适合现在读
- 正在设计工具 Schema、Dispatcher、Hook 或 MCP 适配层的工程师
- 先修知识
- 能读懂 JSON Schema 与结构化 tool call
- 实践产物
- 一个工具契约与执行策略管线草图
- 证据边界
- 源码展示工具边界和派发方式,不代表某种粒度在所有模型上更准确
工具调用先过哪一道门
Section titled “工具调用先过哪一道门”场景:模型生成了合法的 deploy_service({ env: "prod", version: "v42" })。JSON Schema 全部通过,但当前调用方只拥有 staging 权限;如果 Dispatcher 把“参数合法”等同于“动作允许”,一次正确格式的 Tool Call 就会越权上线。
通过标准:协议解码、Schema 校验、身份解析、策略判断、执行和结果持久化是六个独立阶段;策略拒绝不会被模型文本覆盖;写操作带 operation_id;工具结果明确区分 invalid、denied、failed、committed 与 persisted。
先把工具边界写清楚
Section titled “先把工具边界写清楚”为了比较,本文把工具栈拆成 4 层:
四家在每一层都走自己的路:
| 维度 | Codex | Claude Code | OpenClaw | Hermes |
|---|---|---|---|---|
| 定义层 | Responses API `function_tool` JSON schema + `apply_patch` 内嵌 DSL | Anthropic tool spec + 内置 Edit / Bash / Read 等十几个 | tool-catalog.ts 11 大类 + ToolProfileId | registry 单源定义 → adapter 适配 OpenAI / Anthropic / Gemini |
| 注册策略 | 模型选定 = 工具集选定(按 model + prompt 文件配套) | `canUseTool` 钩子在 runtime 过滤 | ToolProfileId: `minimal` / `coding` / `messaging` / `full` 四档 | 启动时全部注册,runtime 按用户配置过滤 |
| dispatch 时机 | function_call 出现即调度(串行) | 流式收完一个 message 后扫 `tool_use` block,多个并行 | pi-agent-core 事件流,单 session 串行 | 收完一 turn 才调度,subagent 才能并行 |
| 权限层 | `execpolicy` + `approval_mode` (auto/on-request/off) + sandbox 模板 | `canUseTool` hook + permission mode + acceptEdits | tool-policy-pipeline + tool-fs-policy + skill policy | per-tool permission check + `skills_guard` 硬 deny |
| MCP | `codex-rs/mcp-*` 多个 crate(client / server / protocol / types) | 内置 MCP client,工具自动注册成 tool_use | MCP plugin + tool-display overrides | 内置 MCP server 配置,runtime 把 MCP tool 桥接成 registry tool |
只比较会改变执行决策的实现
Section titled “只比较会改变执行决策的实现”Codex · function_tool 加 apply_patch DSL 加 execpolicy 形成三维权限矩阵
Section titled “Codex · function_tool 加 apply_patch DSL 加 execpolicy 形成三维权限矩阵”Codex 在工具系统上的出发点是复用 Responses API 的 function_tool,而不是再发明一套协议。大 patch 会碰到 provider 和模型各自的输入限制;当前源码没有一个可移植的容量上限,因此 apply_patch 采用了不同的承载方式。
同时,coding agent 会直接触碰文件和进程。命令策略、用户审批和 sandbox 解决的是不同边界,是否需要每一层取决于部署威胁模型。
工具注册大部分走标准 Responses function_tool 路线,每个工具是 JSON schema(参数、返回值、描述都用 schema 表达)。在这个源码快照里,apply_patch 是一个特例。它不走标准 function call,而是把 V4A diff 格式(详见 06 章)直接教给模型,让模型在 assistant message 里 inline 输出整段 patch,由 Rust 端的 apply-patch crate 解析执行。
这种内嵌 DSL 把 patch 从 function-call 参数移到 assistant 输出;容量边界因此改变,但仍受上下文、输出和 provider 限制,不能理解为无限大。
权限层叫 execpolicy(详见 07 章),是 Codex 中实现较集中的部分之一。每次 shell 命令执行前过一道规则审查(allow、ask、deny 三档),规则用 Starlark DSL(一种类 Python 的配置语言)描述,可以 git 入仓、可以自带测试用例(match、not_match 让 CI 验证规则正确性)。
这套命令级审查配合 approval_mode 三档(auto 自动批准、on-request 按需问用户、off 关闭审批)和 sandbox_mode 三档(read-only 只读、workspace-write 工作区可写、danger-full-access 全开),形成一个三维权限矩阵(命令 × 审批 × 沙箱)。
Codex 用户可以按场景组合这些模式(例如自动化任务用 auto 加 workspace-write,本地开发用 on-request 加 read-only);可组合项较多,但效果仍要按部署边界验证。
MCP 相关实现分布在 codex-rs/mcp-client、codex-rs/mcp-server、codex-rs/mcp-protocol、codex-rs/mcp-types 四个 crate 中,覆盖 client、server、protocol 和 types。MCP 工具被注册成普通 function_tool;在模型接口上,本地工具和 MCP 工具使用同一种 schema。
Claude Code · 同 turn 多工具并行加 canUseTool 钩子加 permission mode 四档
Section titled “Claude Code · 同 turn 多工具并行加 canUseTool 钩子加 permission mode 四档”Claude Code 在工具系统上的出发点是:作为 IDE 集成的 coding agent,一次用户请求经常需要多个独立工具配合(比如修这个 bug 要同时 Read 多个文件、Grep 关键字、Glob 找类似 pattern)。串行会增加往返等待,并行可减少等待,但也要处理部分失败。Anthropic 的 tool_use block 协议支持一个 assistant message 里多个 tool_use block,Claude Code 用它来并行调度。
实际实现用 Anthropic 原生的 tool_use block 协议。模型在 assistant message 里输出多个 tool_use block(每个 block 一个工具调用),harness 收完整个 message 后扫一遍所有 tool_use block,全部塞给 dispatchToolUseBlocks 用 Promise.all 并行执行。
一个工程细节值得注意:queryLoop 中 line 557 的注释承认 stop_reason === 'tool_use' 不可靠。Anthropic API 的 stop_reason 字段理论上应该是 'tool_use' 时表示有工具要调,但实际有时候 stop_reason 是 'end_turn' 但消息里还是有 tool_use block。Claude Code 不信 stop_reason,代码自己数 block 数量(更可靠)。
权限层有两个机制配合:
canUseTool钩子允许 runtime 在每次工具调用前做过滤。一个工具被拒,harness 拼出一个 deny tool_result(带拒绝原因)回给模型,让模型自己看到「这次不能调」然后换个方案(而不是直接 throw error 中断 loop)。permission mode四档提供场景模式切换:plan关闭工具调用,acceptEdits自动批准编辑,default按调用询问;bypassPermissions会跳过权限询问,只有在隔离且受信任的自动化环境中才应显式启用,不能当作 CI 默认。
内置工具集(Edit、Read、Bash、Glob、Grep、Task、TodoWrite、WebFetch、WebSearch 等)加 MCP 工具一起进入 tool_use schema。模型在同一接口上根据 schema 决定调用,不需要处理两套协议。
OpenClaw · 工具栈拆成 11 大类加 4 档 profile 加中间件链
Section titled “OpenClaw · 工具栈拆成 11 大类加 4 档 profile 加中间件链”OpenClaw 在工具系统上的出发点是:作为通用 agent 控制面(同时支持 coding、messaging、automation 等多种工作负载),不同场景需要不同工具面。messaging 场景的 agent 通常不需要 fs 工具,coding agent 通常不需要 messaging 工具;把所有工具同时开放可能增加选择负担,实际影响要用任务日志验证。
OpenClaw 把工具按职能分大类,每个工具明确归属某些场景 profile,启动时按 profile 过滤。一个 messaging agent 启动时只看到 messaging、web、memory 这几类工具。
实际实现是 tool-catalog.ts 把工具按 11 大类组织(fs / runtime / web / memory / sessions / ui / messaging / automation / nodes / agents / media),每个工具属于某些 ToolProfileId:
OpenClaw openclaw/src/agents/tool-catalog.ts:1-39 ToolProfileId + CORE_TOOL_SECTION_ORDER
export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
const CORE_TOOL_SECTION_ORDER: Array<{ id: string; label: string }> = [ { id: "fs", label: "Files" }, { id: "runtime", label: "Runtime" }, { id: "web", label: "Web" }, { id: "memory", label: "Memory" }, { id: "sessions", label: "Sessions" }, { id: "ui", label: "UI" }, { id: "messaging", label: "Messaging" }, { id: "automation", label: "Automation" }, { id: "nodes", label: "Nodes" }, { id: "agents", label: "Agents" }, { id: "media", label: "Media" },];ToolProfileId 4 档分别对应不同 agent 形态:minimal(最小工具集,比如纯聊天 agent)、coding(开 fs、runtime、web 等 coding 常用类)、messaging(开 messaging、web、memory,给客服、通讯 agent 用)、full(全开,给需要较宽工具面的 agent 用)。这种按场景预配置可以减少逐项配置,但仍要检查 profile 是否覆盖部署需求。
tool-policy-pipeline.ts 是 OpenClaw 中实现较集中的部分。它把「工具调用前后该做什么」组织成中间件链。before_tool_call(调用前)、after_tool_call(调用后)、tool_result_persist(结果持久化)三个 hook 点都可注册外部 plugin,让权限检查、审计、缓存、mocking 等逻辑进入同一条管线。想给某个工具加「调用前过 LLM 判断意图是否符合公司政策」的检查?
写个 plugin 注册到 before_tool_call 即可。
除了通用中间件链,OpenClaw 还有几个专门的工具子系统:
tool-loop-detection.ts:单独检测模型在死循环调同一个工具(避免 N 个连续相同工具调用浪费 token,命中后强制退出 loop)。tool-fs-policy.ts:文件系统专用的二级权限层(详见 06 章 workspaceOnly 设计,不同于通用的 hook)。tool-mutation.ts:把工具调用结果做加工再回给模型(比如自动截断超长结果、屏蔽敏感字段、添加上下文 hint)。
这些文件共同构成了该源码快照里的工具中间件实现。
工具事件桥到独立的 tool 流(subscribeEmbeddedPiSession),订阅者可以接收该流公开的 tool call、参数和结果事件。它可作为审计、调试和监控的一个入口;企业部署仍要按敏感字段、留存和完整性要求设计外部日志。
Hermes · 单源 registry 加多模型 adapter 加 skills_guard 硬 deny
Section titled “Hermes · 单源 registry 加多模型 adapter 加 skills_guard 硬 deny”Hermes 在工具系统上的出发点是:长跑 agent 可能需要换模型(例如分别选择 GPT、Claude 或 Gemini)。如果工具定义跟模型协议绑定,每次切换都会增加维护成本。Hermes 因此把工具定义和模型协议解耦:工具定义用一种统一格式,runtime 按当前模型翻译成对应协议。
实际实现是 registry 里只写一次工具定义(用 OpenAI function calling 风格作为 internal 格式,因为这是社区最广泛支持的格式),三个 adapter 文件各自负责一种协议:
anthropic_adapter.py:把 OpenAI-style 消息翻译成 Anthropic 的 tool_use block 格式(注意 tool_use_id 关联、stop_reason 处理等差异)。bedrock_adapter.py:处理 AWS Bedrock 的 Anthropic 模型(基本同 anthropic_adapter 但有些 Bedrock 特有的字段)。gemini_native_adapter.py:处理 Gemini 的 functionDeclarations 加 functionCall 格式(注意 thinking 模式与 text content 混合的处理)。
三个 adapter 让工具定义可以复用;切换模型通常只需调整 provider 配置,但协议差异仍要通过集成测试核对。
权限层走 per-tool permission check 加 skills_guard 双层。skills_guard 是硬 deny 工具,在每次 dispatch 前用一个独立 LLM 判断这次调用是不是合法或危险(比如 rm -rf /、试图读 ~/.ssh、试图执行 curl | bash 等危险路径),命中就直接拦截不让工具执行。
permission check 在每个工具函数内部(每个工具自己判断需不需要审批,自己处理审批),比中间件方案更直接但扩展性差一点(加新工具要重写 permission 逻辑,没法做全局策略)。
工具默认串行执行(trajectory 模型假设单线时间轴。一个 trajectory 文件记录每一步发生的事,并发会让 trajectory 顺序混乱)。并行要显式 spawn subagent,subagent 自己独立一条 trajectory 加工具栈。这种设计让 trajectory 始终是一条线性故事,调试、复盘、训练数据生成都方便。
MCP 通过 runtime 配置注入:~/.hermes/config.json 里写 mcp_servers 字段,运行时 server 把每个 MCP tool 桥接成 registry 中的普通 tool。这个路径把新增 MCP server 主要收敛为配置变更,通常不用改 Hermes 源码。
先固定 schema、policy 和 trace
Section titled “先固定 schema、policy 和 trace”四个样本在工具系统设计上有四个可复用的观察;是否采用仍要按工具副作用和部署场景验证:
1 · 工具签名用 JSON schema 描述,而不是只写自然语言:四家都用 schema 表达工具输入。即使是 Codex 的 apply_patch DSL,也保留了结构化槽位。schema 能让类型、必填项和校验错误显式化;具体调用失败率要在目标模型和工具集上测。
2 · 高影响工具要有显式权限层:四个样本都在工具执行前提供某种拦截(execpolicy、canUseTool、tool-policy-pipeline 或 per-tool check)。是否需要审批、隔离或拒绝,仍要按工具副作用和部署威胁模型配置;不要把一个样本的默认模式当成通用安全底线。
3 · 如果要接外部工具,再评估 MCP:四个样本都能接 MCP,但桥接方式不同。Codex 用独立 crate,OpenClaw 走 plugin,Claude Code 和 Hermes 把 MCP 工具映射成普通 tool。MCP 能降低接入成本,但并非每个 agent 都需要;引入前还要评估供应链、权限和可观测性。
4 · 有副作用的工具要留下可检索记录:四个样本都暴露了不同形式的 rollout、trajectory 或 tool event。是否单独建立事件流取决于审计和调试需求;至少要记录工具、参数摘要、结果状态和时间,并处理敏感数据。
把速度和拒绝路径放在一起
Section titled “把速度和拒绝路径放在一起”四家代表了工具系统设计的四种典型取舍:
做 coding agent 加复用 OpenAI Responses 生态:参考 Codex 的 function_tool 加 apply_patch DSL 加 execpolicy 路线。直接对接 OpenAI Responses API(不用发明协议)。apply_patch 用 DSL 改变大 diff 的承载方式。execpolicy 三维权限矩阵覆盖命令执行这一类 coding 风险,但不能代替其他安全边界。代价是二开钩子止于 execpolicy(想加自定义 verifier、自定义中间件通常需要 fork 或包裹),非 coding 场景没有等价的命令级权限层。适合 OpenAI 生态内的 coding agent。
需要同一轮多工具并行和多种权限模式:参考 Claude Code 的 tool_use 加并行 dispatch 加 permission mode 路线。同一 turn 的独立工具并行可以减少等待;canUseTool 钩子让 runtime 动态过滤工具。permission mode 四档(plan、acceptEdits、bypassPermissions、default)按场景切换。代价是 stop_reason 不可靠要自己数 block,钩子接入点也较少,没有 OpenClaw 那样的多层中间件链。适合 IDE、桌面、工具型 agent。
需要多租户控制面和多种工具 profile:可以看 OpenClaw 的 catalog、policy pipeline 和 profile。中间件承载权限、审计、缓存和 mocking;代价是调试链路更长,4 档 profile 对定制部署也可能过粗。
做多模型兼容(同一套工具跑 OpenAI、Anthropic、Gemini):参考 Hermes 的 registry 加 adapter 路线。一份 registry 定义跑三种协议。skills_guard 在 dispatch 前用 LLM 判断危险动作,能处理语义规则,也会引入模型误判。MCP 桥接缩短了外部工具接入路径。代价是默认串行不并行(trajectory 模型限制),权限分散在每个工具函数内部(难做全局策略改动)。适合长跑助理、跨模型实验、研究型 agent。
高风险工具先收紧策略
Section titled “高风险工具先收紧策略”| 工具约束 | 借鉴路线 | 代价或边界 |
|---|---|---|
| 同一轮有独立、无副作用调用 | Claude Code 的并行 tool_use | 失败聚合和顺序语义要自己定义 |
| 多模型协议需要统一 registry | Hermes adapter 与 schema 转换 | 最小公分母会丢 provider 特性 |
| 多通道运行需要 policy middleware | OpenClaw tool-policy pipeline | 链路长,通常需要保留 trace;是否全量留存取决于审计与隐私边界 |
| coding 工具需要严格 patch 和 shell 门禁 | Codex 的工具与 execpolicy | 抽象绑定 coding 工作流 |
从一个可拒绝的工具开始
Section titled “从一个可拒绝的工具开始”自己写工具系统时,先把工具声明、执行结果和策略边界分开,再补观测、循环检测和 provider adapter。
复刻方案
最小可行
- 工具用 JSON schema 表达(可从 OpenAI function calling 风格起步):schema 把参数类型和必填项变成可校验契约;用真实任务记录 parse error、missing field 和 wrong-tool rate
- 按工具风险做 permission check:只读查询、工作区写入和外部副作用不应共用一条默认规则。策略是否询问用户、拒绝或自动放行,应由威胁模型与部署边界决定
- 保留工具调用日志(哪个工具、什么参数、什么结果、什么时间):出问题时应能追溯(用户投诉 agent 改了我的文件时,需要知道哪个工具改了什么)
- 提供 dry-run 模式让用户在调用前看到将要执行的命令:危险命令第一次跑前先 dry-run,避免「模型一时糊涂跑了 rm -rf /」的灾难
进阶
- 抽象 adapter 层让同一个 tool 定义跑多种协议(参考 Hermes 的 anthropic_adapter、bedrock_adapter、gemini_native_adapter):换模型不用重写工具,是多模型 agent 的关键
- before/after tool call hook 系统允许外部插中间件(参考 OpenClaw 的 tool-policy-pipeline):verifier、审计、缓存、mocking 可以进入同一管线,便于插入额外逻辑
- 工具 profile 分档(参考 OpenClaw 的 minimal、coding、messaging、full):按场景开关工具,减少无关选项。工具数量和模型选错率要用自己的任务日志校准,不要套用固定阈值
- 工具调用事件流单独发到一条 stream(参考 OpenClaw 的 subscribeEmbeddedPiSession):外部审计、实时监控、训练数据采集都从这条流走,不污染主 conversation
一开始别做
- 把权限检查写在每个工具函数内部:违反 DRY、难做全局策略改动(想给所有工具加 LLM 判意图这一层就要改 N 个工具)。若需要跨工具的统一策略,可以集中到中间件链
- `stop_reason === "tool_use"` 当唯一信号:Anthropic API 的 stop_reason 不可靠(实测有时候 stop_reason 是 end_turn 但有 tool_use block),代码自己数 block 数量才稳
- 内置工具和 MCP 工具走两条 dispatch 路径:模型决策时要分别处理两种工具(增加复杂度),且 UI 渲染逻辑也要写两套。统一走一条路径让模型透明
- 一开始就上工具并行:并行处理失败、state isolation、顺序问题都比串行复杂得多。先把串行 dispatch 稳定下来(包括 timeout、retry、错误处理),再考虑并行优化
一次工具调用怎样穿过系统
Section titled “一次工具调用怎样穿过系统”核对一次调用的执行链
Section titled “核对一次调用的执行链”本章带走什么与下一步实验
Section titled “本章带走什么与下一步实验”Schema 只证明参数形状,不证明身份有权执行。工具系统要把“模型能否表达动作”和“宿主是否允许动作”彻底分离,并把每个阶段的失败写成可观测状态。
下一步实验:构造五个调用:非法参数、合法但越权、允许的只读、重复的写入、两个独立可并行调用。验收 Dispatcher 是否给出不同状态,重复写入是否只提交一次,并行是否没有共享状态竞态。把每个结果写进事件流,而不是只返回一段错误字符串。
附录:练习与复盘
Section titled “附录:练习与复盘”按需展开练习和十道复盘题
- 🟢 入门:给你的 agent 加一个
before_tool_call钩子。最简实现:打印[tool] {name}({args}),不修改也不拦截,仅观察。等收集到足够的真实调用后,再决定哪些工具值得进入默认 profile。 - 🟠 进阶:实现一个最小版
apply_patchDSL:模型在 assistant 文字里输出*** Begin Patch ...块,你的代码解析并应用到文件。比起 function_call 传 string,能塞多大的 diff? - 🔴 挑战:实现一个
tool-loop-detection练习版:先用“连续 5 次同工具、参数变化很小”作为测试参数,再用正常重试与死循环 trajectory 调阈值。报告它在哪一步拦截,以及误报了什么。
Q1 · 概念:tool / function call / MCP server / skill 四个词,怎么分?
Tool 是最底层的概念:一段「模型可触发、harness 实际执行、结果回写」的代码。所有别的词都是 tool 的具体形态。
Function call 是协议层概念。OpenAI 2023 年把 tool 调用规范化为 function call schema(name + parameters JSON schema),后来 Anthropic 用 tool_use block,Gemini 用 function_call proto,本质都是同一件事:模型在结构化字段里说「我想调 X(args)」。
Codex 在协议层叫 function_tool(Responses API 的 wrapper)。
MCP server 是 2024 年 Anthropic 推的 Model Context Protocol。一个 MCP server 暴露一组 tool(通过 stdio 或 SSE),harness 把它们 bridge 进自己的 tool registry。
四家全支持:Codex 用 4 个独立 crate(mcp-client、mcp-server、mcp-protocol、mcp-types),Claude Code 和 Hermes 直接把 MCP tool 当普通 tool 喂给模型,OpenClaw 走 plugin 通道。MCP 解决的是「同一组 tool 给不同 agent 用」。
Skill(chapter 17 会专讲)是 Anthropic 提出的更高层封装:一个 skill 等于 SKILL.md(说明)加 scripts/ 加 references/ 加 assets/,本质是带文档和资源的 tool 集合。Hermes 和 Claude Code 都实现了 skill,可以 lazy-load。
四者关系:tool 是核心,function call 是序列化协议,MCP 是 tool 的分发协议,skill 是 tool 加资源的打包格式。
源码定位:codex/codex-rs/codex-mcp/、claude-code/src/tools/、openclaw/src/agents/tool-catalog.ts、hermes-agent/skills/。
追问:「LangChain 的 Tool 抽象算哪种?」算 tool 加框架级 binding。它有自己的协议层(不是 OpenAI function call),可以在 LangChain 内自动转换给不同模型。本质是个迷你 MCP,但绑死 Python。
Q2 · 架构:Claude Code 是「同一 turn 多 tool_use 并行 dispatch」,Codex 是「function_call 出现即单个调度」,谁更好?
各有适用场景。决定因素是工具是否独立加并行是否会破坏 trajectory 语义。
**Claude Code 风格(一 turn 多 tool 并行)**的好处:
- 节省往返:模型一次性发 3 个
Readtool_use,3 个文件并行读,比串行省 2 个 token roundtrip。 - 对应自然语言:用户说「打开 A、B、C 三个文件」,模型自然会并发发 3 个 tool_use,并行 dispatch 完美匹配。
- 缺点:任何一个 tool 失败,3 个 result 都得回(部分成功状态),模型需要处理 partial failure。
**Codex 风格(function_call 出现即调度)**的好处:
- 状态简单:每次 tool 完一个,模型再继续,trajectory 单调可追溯。
- 验证器(chapter 05)容易接:每步一个验证点,不用算 3 个并行哪个被验证了。
- 缺点:慢。3 个独立 read 串行等于 3 倍 round trip。
OpenClaw 和 Hermes 都偏 Codex 风格(串行),原因是 trajectory 模型基于单调时间轴。并行会破坏「这步之前所有 tool 都完成了」这个不变量。
实操建议:起步先做串行(Codex 风格),调通后再加并行白名单(只允许 read-only tool 并行)。一上来全并行的话,调试时找 race condition 会极为困难。
源码定位:claude-code/src/query.ts:440-680(dispatchToolUseBlocks 用 Promise.all)、codex/codex-rs/core/src/session/turn.rs(单 function_call dispatch)。
追问:「跨 turn 并行又怎么样?」那就是 subagent 了(chapter 10)。同一 trajectory 内并行和跨 trajectory 并行是两个不同问题,混在一起讨论容易乱。
Q3 · 工程:Claude Code 注释里说 stop_reason === 'tool_use' 不可靠,为何?
来源在 query.ts:557 的注释:「stop_reason === 'tool_use' is not reliable; count blocks instead」。本质问题:流式 API 在多个 tool_use block 同时出现时,stop_reason 可能在中间 block 就提前设置,也可能根本不出现。
具体可能的情况:
- 多 tool_use 加文字混合:模型先输出一段 thinking 文字,然后 tool_use,再继续文字,再 tool_use。
stop_reason可能是end_turn也可能是tool_use,取决于最后一个 block 是什么。 - 网络中断恢复:Anthropic 的 streaming 在 fallback 时可能会重发
message_stop,stop_reason已经写了再覆盖。 - 历史 message 中:从 storage 重新构造 message 时,
stop_reason字段可能丢。
可靠的做法:直接遍历 content 数组数 type === 'tool_use' 的 block。有几个就 dispatch 几个,不依赖 stop_reason。Claude Code 注释告诉你这是 Anthropic 自己内部踩过的坑。
这种「不要相信元数据,要直接看数据」的设计模式在其他三家也常见。Codex 不信 finish_reason,自己看 content 解析。Hermes 不信 done,自己 detect trajectory 终止条件。protocol 字段是兜底,业务逻辑要自己重算。
源码定位:claude-code/src/query.ts:557(原文注释),进一步的健壮性逻辑在 dispatchToolUseBlocks。
追问:「OpenAI 的 finish_reason 可靠吗?」相对可靠,但同样建议自己数 tool_calls 数组长度。Anthropic 比 OpenAI 在这个字段上滚出过的 bug 更多。
Q4 · 架构:Codex 的 apply_patch 不走 function call,而是让模型在 assistant 文本里 inline 输出 V4A diff,为何?
核心约束:function_call 的 arguments 字段有大小限制。具体上限由 provider、模型和 SDK 配置决定,不能用一组跨 provider 的固定数字概括。大 patch 需要拆分、改用正文承载,或在宿主侧做专门的上传协议。
apply_patch DSL 把 patch 放在 assistant 的 text content 里,而不是 tool_use args。模型输出 *** Begin Patch ... *** End Patch,Codex 再用 apply-patch crate 解析。它避开了 arguments 这条承载路径,但仍受模型输出、context、provider 和宿主限制,不能视为无限容量。
代价:
- 模型要学一种新 DSL:Codex 的 system prompt 包含 V4A 说明。它增加多少 prompt 与输出开销,应在固定 tokenizer 和模型版本上测量。
- 解析要覆盖常见 malformed 输入:模型可能缺
*** End Patch或少写 patch 行空格;Codex 的apply-patchcrate 在这份快照里提供了多处容错,具体覆盖范围仍应由测试样本确认。 - 可观测性变差:普通 function_call 容易在 trajectory log 里 grep
apply_patch(。DSL 嵌在文本里要专门 parser 才能识别。
Claude Code 选了另一路:内置 Edit 和 MultiEdit,每个 edit 是一个独立 tool_use。编辑大小受当前工具实现与 provider payload 限制;大 refactor 往往要拆成多个 tool_use,具体分块应通过集成测试确定。
实操建议:
- 项目早期:用 Claude Code 风格
Edittool(小 patch,多次调用)。 - 项目成熟需要支持大 refactor:参考 Codex
apply_patchDSL,但务必保留Edit兜底。
源码定位:codex/codex-rs/apply-patch/src/lib.rs(DSL 实现),codex/codex-rs/apply-patch/apply_patch_tool_instructions.md(教模型的 prompt)。
追问:「Aider 用的 diff 格式跟 V4A 一样吗?」不一样。Aider 用 unified diff(标准 git diff 风格),V4A 是 OpenAI 内部设计的,结构更严格便于解析。两者都把 patch 内容移出 function_call args;哪种更合适,要按 payload 上限、解析失败率和审计方式测量,不能仅凭格式下结论。
Q5 · 概念:什么是 tool middleware?OpenClaw 的 tool-policy-pipeline 比 Claude Code 的 canUseTool 多出什么?
Tool middleware 是工具调用前后插入的处理逻辑链,类似 web framework 的 request middleware。一次 tool 调用从 model 发起到 result 返回之间,可以插入任意层 hook。
Claude Code 的 canUseTool 是单点钩子:在 tool 执行前问一次「这次能不能调」,返回 yes 或 no。简单易用,但只能做权限判断,不能改写 args、不能记录、不能 mutate result。
在这份 OpenClaw 快照里,tool-policy-pipeline 是一条多阶段中间件链:
before_tool_call:可拒绝、可改写 args、可注入 metadata、可触发 confirmation。tool-mutation:执行后改写 result(比如对大 result 截断、对二进制做 base64)。after_tool_call:写 audit log、上报 telemetry、触发 webhook。tool_result_persist:把整条记录写进持久化存储。tool-loop-detection:检测连续相同调用,注入「你在死循环」信号。
差别在「能不能链式组合」。Claude Code 在这个路径上暴露一个 canUseTool,OpenClaw 可以按注册顺序叠加多层 middleware。是否同时需要审计、telemetry、限流和人工批准,要看数据类型、租户边界与合规要求。
代价:调试链路变长,一个 tool 调用要 trace 5 个 middleware。OpenClaw 在 dev mode 提供更详细的 trace,prod 则只保留 essential;这是该快照的运行配置,不代表所有部署都如此。
实操时先列威胁模型,再配置 permission、日志、限流或审批。当多个策略需要独立演进和复用时,多阶段 pipeline 才可能抵消更长调试链路的成本;简单工作负载可以从较少 hook 起步。
源码定位:openclaw/src/agents/tool-policy-pipeline.ts,对比 claude-code/src/hooks/useCanUseTool.tsx。
追问:「Express middleware 和 tool middleware 设计是不是一样?」思路一样(next() 链),但 tool middleware 多了双向:既能改 args 也能改 result。Express 的 middleware 只走单向(request → response)。
Q6 · 实操:你要给一个 agent 加 web_search tool。protocol / permission / observability 三层各做什么?
Protocol 层:
- Schema:
name: web_search,parameters: { query: string, max_results: number (default 5), recency_days?: number }。 - Result 格式约定:
{ items: [{ title, url, snippet, published_at }], total: number, truncated: bool }。 - 建议返回结构化数据;原始 HTML 若未经清洗或隔离就进入模型,可能扩大 prompt injection 面(chapter 03 §Q4)。
- 如果下游按 URL 和日期解析,使用绝对 URL 与 ISO 格式;其他格式应在 schema 中明确约定。
Permission 层:
- 默认 allow(搜索是 read-only),但加 rate limit。
10 req/min/user可以作为压测起点,不是通用配额。 - 域名白名单可选(企业场景常要求只搜内网 + 几个公共站)。
- Query 长度限制(避免恶意构造 10MB query)。
- 配合
canUseTool/before_tool_call把 query 记下来(审计需要)。
Observability 层:
- log:query 加 result count 加第一条 URL。直接记录完整 result 可能泄露 PII、消耗 quota 或放大日志体积,按留存与脱敏策略取舍。
- metric:调用频次、平均延迟、超时率。告警阈值应从工具 SLO 和历史基线设定,而不是复制固定百分比。
- cost:搜索工具的价格随 provider、套餐和查询类型变化。把 provider、计费单位和累计消耗记录下来,再决定是否向用户展示。
- attribution:每条 search 关联到 user_id 加 session_id,方便追责。
进阶:可以先把 search result 摘要后再喂回模型;「5 条、每条约 200 token」只是示例分桶,是否摘要要按 context 预算、召回质量和延迟测量。这一步在 chapter 03 §Q6 PDF 处理里也提到过:外部数据进入 context 前应先评估是否需要压缩。
源码定位:参考 Claude Code 的 WebSearch tool 实现(claude-code/src/tools/WebSearchTool/),Hermes 的 tirith/web_search/。
追问:「搜索结果的 prompt injection 怎么防?」把 result 包成 role=user message 注入,标注「下面是搜索结果,仅供参考」,外加 Hermes 风格的 _scan_context_content。
Q7 · 架构:Hermes 一个 registry 喂三种协议(OpenAI / Anthropic / Gemini),adapter 模式具体怎么写?
核心:registry 是真源,每种协议各自实现一个 adapter 把 registry 翻译过去。
Registry 长这样(伪代码):
TOOLS = { "read_file": { "description": "...", "parameters": { "type": "object", "properties": { ... } }, "fn": read_file_impl, }, ...}anthropic_adapter.py:
def to_anthropic_tools(registry): return [ {"name": k, "description": v["description"], "input_schema": v["parameters"]} for k, v in registry.items() ]
def from_anthropic_response(response): for block in response.content: if block.type == "tool_use": yield {"name": block.name, "args": block.input, "id": block.id}gemini_native_adapter.py:
def to_gemini_tools(registry): return [genai.Tool(function_declarations=[ genai.FunctionDeclaration(name=k, description=v["description"], parameters=v["parameters"]) for k, v in registry.items() ])]工程要点:
- schema 兼容性:三家的 JSON schema 子集不完全相同。Anthropic 支持
oneOf,Gemini 不支持。adapter 要在翻译时做 fallback(Gemini 收到oneOf时拆成多个独立 tool)。 - result 格式:Anthropic 的 tool_result 是 block,OpenAI 是 message。adapter 把内部统一的
{name, content}翻译过去。 - 错误处理:Gemini 的 BLOCKED reason 跟 Anthropic 的 stop_sequence 不是一回事,adapter 要把内部 error 类型映射到外部 API 期望的字段。
- streaming 差异:OpenAI、Anthropic stream 协议大不同(OpenAI delta 加 tool_calls 增量、Anthropic event-based)。adapter 要把 stream 事件归一成内部统一的
{type, content}event。
代价:每次新增 model provider 都要维护一个 adapter,处理消息、流式事件和错误语义的差异;具体工作量要以目标 provider 的协议和测试覆盖评估。
源码定位:hermes-agent/agent/anthropic_adapter.py、bedrock_adapter.py、gemini_native_adapter.py。
追问:「LiteLLM 跟 Hermes adapter 是不是同一思路?」是。LiteLLM 是开源版的 adapter 层,覆盖 100 多家 provider。如果不想自己写,可以直接接 LiteLLM,但牺牲了对协议细节的精确控制(比如 prompt caching 配置)。
Q8 · 工程:tool-loop-detection 到底怎么判定「死循环」?怎么避免误杀?
判定逻辑(OpenClaw tool-loop-detection.ts 的思路):
- 维护一个滑动窗口(最近 N 次 tool call;N 是待校准参数)。
- 计算窗口内相同 tool name 的占比,并从已标注 trajectory 选择触发阈值。
- 在 same name 基础上比较 args 相似度。连续重复且参数变化很小时触发;窗口和编辑距离都要按工具类型校准。
- 命中后注入信号到下一个 tool result:把 result 替换或追加「[loop detected] 你在第 N 步连续调用了 X,建议尝试别的方案」。
为什么不直接 deny?因为 deny 会让模型只能放弃,但有时它是合理重试(API 偶发失败、文件刚改完再读一次)。注入信号让模型自己决定换方向,相对温和。
避免误杀的 3 个技巧:
- 工具名不能单独定罪:连续调用
Read可能是在看不同文件;只有调用名、参数与结果都没有推进时,才有较强的循环信号。 - args 相似度门限要按工具分开:读文件的路径变化和查询工具的文本变化含义不同。先用标注过的正常重试与死循环样本选阈值。
- 窗口按工具校准:
N=5是 OpenClaw 当前源码快照中的默认值,可作为实验起点。用正常重试与已确认循环的标注样本比较误报、漏报和发现延迟后再调整。
实测的常见 false positive:
- 数据爬取 task:连续 10 次
web_search不同 query。解决:把 search 排除在检测器之外(或者用 args 相似度兜底)。 - TodoWrite:模型连续刷状态。解决:状态更新类 tool 排除。
实操建议:先以 read-only 模式运行,只记录不注入。积累覆盖主要工具和失败类型的代表性 trajectory,经人工复核误报与漏报后再开 inject;样本量由调用频率决定,不预设一周。
源码定位:openclaw/src/agents/tool-loop-detection.ts,Hermes 在 agent/loop_guard.py 也有类似实现。
追问:「死循环靠 token budget 兜底也行吧?」预算可以止损,但 detector 能更早发现。token 兜底是「跑空了再说」,detector 是「跑歪了立刻调」。
Q9 · 概念:什么叫 tool profile?OpenClaw 的 minimal/coding/messaging/full 在什么时候使用?
Tool profile 指「同一个 agent,在不同场景下暴露不同子集的工具」。本质是工具集的 named subset。
OpenClaw 4 档:
minimal:只暴露Read、TodoWrite等纯 read-only 工具。给 subagent 用(chapter 10),让它不能改文件、不能跑命令。coding:加上Edit、Bash、Grep、Glob,给主 agent 做开发任务。messaging:换成SendMessage、ReadMessages、Schedule,给客服 agent 用,没有 coding 工具。full:全开,给信任的 advanced user 用。
为什么不让所有 agent 都用 full?三个原因:
- prompt 长度:工具 schema 会占用上下文。把不适用的工具从 profile 中移除,并在目标模型上测量缓存命中和选择错误,而不是预设固定 token 成本。
- 决策准确性:工具越多,候选之间越容易混淆,但幅度依赖模型、描述和任务。用同一评测集比较精简前后的 wrong-tool rate。
- 权限收敛:subagent 不需要写文件,给它
Edit就是给了潜在攻击面。最小权限原则。
实操建议:
- 起步只做
full(一档),等真有 subagent、messaging 场景再分。 - 分档时不要按「工具技术分类」分(比如「所有 fs 工具一档」),而是按「场景任务分」(「这种 agent 要干什么」)。前者技术上整齐,后者真用起来才合理。
Codex、Claude Code、Hermes 都没有 profile 抽象,但用别的等价方案:Codex 用 model 加 prompt 文件配套,Claude Code 用 canUseTool filter,Hermes 用 skills_guard 黑名单。
源码定位:openclaw/src/agents/tool-catalog.ts:1-39。
追问:「profile 之间能动态切换吗?」OpenClaw 不支持运行时切换(启动时定)。运行时切换的需求一般用 dynamic skill loading(chapter 17)解决,更灵活。
Q10 · 开放:你要做一个开源 agent 框架的 tool system,会选哪几个特性组合?
我会选这套组合(依次解释为什么不全部沿用一家):
核心层(必选):
- registry 单源加 adapter 多协议(参考 Hermes)。先定义内部表示,再为需要的 provider 写 adapter;接入时间取决于协议差异和测试范围。
- tool_use block 并行 dispatch(参考 Claude Code 但加白名单)。所有 read-only tool 自动并行,所有 write 类 tool 串行。需要在 metadata 标注
parallel_safe: bool。 - canUseTool 单点钩子(参考 Claude Code)加 after_tool_call hook(参考 OpenClaw)。两点是中间件链的最小集,扩展性够,调试链路不至于太长。
中间件层(本站方案,按风险选配):
- tool-loop-detection(参考 OpenClaw):先开 read-only mode;可从当前快照的
N=5开始实验,再按标注 trajectory 校准。 - apply_patch DSL fallback(参考 Codex):当集成测试出现稳定的 payload 截断或解析失败边界时切换传输方式,不预设通用的
8K阈值。 - execpolicy 静态规则(参考 Codex):每个 tool 在 registry 里声明
risk_level: low、medium、high,自动应用 deny 规则。
MCP 层:
- 直接桥接成普通 tool(参考 Claude Code、Hermes,不沿用 Codex)。Codex 4 个 crate 太重,开源框架应该让 MCP tool 和 built-in tool 走同一 dispatch 路径,模型完全感知不到。
Observability 层:
- tool 事件单独流(参考 OpenClaw
subscribeEmbeddedPiSession):除了 trajectory,单独发到tool.*topic,外部观察者订阅这个 topic 就能看到全部工具活动。 - per-tool token budget:每个 tool 在 registry 声明
max_tokens,单次调用超出自动 truncate 加 warn。chapter 15 会讲。
不沿用的:
- OpenClaw 4 档 profile:开源框架场景多变,让用户自己 filter 比固定 4 档灵活。
- Hermes per-tool permission check:集中到 canUseTool 钩子里更易维护。
落地节奏:
- 先让核心层 1-3 在目标任务集上跑通。
- 再按审计、缓存或多模型需求补中间件与 adapter。
- 最后决定是否接 MCP 和独立 observability 流,并记录引入后的失败模式。
工程量取决于既有 runtime、支持的 provider 和隔离要求;用目标任务集拆分里程碑,不要把固定周数当成承诺。
源码定位:综合所有四家的源码,参考路径见本章末尾的 SourceTrail。 追问:「为什么不沿用 LangChain 的 Tool?」LangChain Tool 抽象太厚(每个 tool 是个 Class),对快速迭代不友好。开源框架应该让 tool 是个简单的 dict,必要时让用户自己包装成 class。