Claude Code 101|06|Tool Runtime
Claude Code 的工具不是普通函数,而是带 schema、权限、安全分类、UI 渲染、并发语义和模型序列化的执行单元。
如果说上一篇的 Context Engineering 决定模型“看见什么”,那么 Tool Runtime 决定模型“能做什么”。这一步是 Coding Agent 从聊天系统变成工程系统的分界线。
很多教程把工具定义成函数:给模型一个 name、description、parameters,再写一个 handler。模型输出 tool call,程序执行函数,把结果塞回去。这种抽象足够演示天气查询、计算器、简单搜索,但不足以支撑一个真实代码 Agent。原因很简单:工具一旦触碰文件系统、shell、网络、语言服务器、外部 MCP server,就不再是纯函数调用,而是一次受治理的环境行动。
Claude Code 的工具层提供了一个更接近生产系统的答案:Tool 是执行协议,不是函数。它包含 schema、验证、权限、并发语义、安全分类、执行逻辑、UI 渲染、模型可见结果序列化、hook 观测、transcript 索引、工具搜索、延迟加载以及 MCP 适配。src/Tool.ts 的接口定义非常长,恰恰说明工具不是一个 handler 能概括的对象。
这篇围绕一个工程问题展开:当模型提出一个工具调用时,runtime 如何把它变成可验证、可授权、可执行、可观察、可恢复、可回填的协议事件。
工程问题:工具调用必须被治理
一个工具调用在真实 Agent 中至少有七个问题。
第一,模型是否按协议传参。自然语言模型输出 JSON,不等于 JSON 一定合法。工具需要 schema,也需要运行时验证,还需要把验证失败变成模型可理解的反馈。
第二,这次调用是否允许。读文件、写文件、执行 shell、访问 Web、调用 MCP,不同风险完全不同。权限不能只靠 prompt 提醒模型“请谨慎”。
第三,工具是否可以并发。多个只读搜索可以并行,多个写文件操作通常不应该并行,shell 命令是否可并发还要看命令语义。并发不是调度器的独立问题,而是工具定义的一部分。
第四,工具输出如何展示给用户。用户需要看到“正在读哪个文件”“执行了什么命令”“diff 是什么”“哪些结果被截断”。这和模型看到的 tool_result 不一定相同。
第五,工具输出如何回填给模型。模型需要结构化、节省上下文、避免噪音的结果;用户 UI 则可以更丰富。两者必须分离。
第六,工具是否会打开外部世界。Web、MCP、shell 都可能接触本地项目之外的世界。只读不等于安全,开放世界访问也不等于一定禁止,但必须显式建模。
第七,工具数量增长后如何管理上下文。几十个内置工具和外部 MCP 工具的 schema 会吞掉 prompt 窗口,因此工具也需要搜索、延迟加载和 always-load 策略。
这些问题共同构成 Tool Runtime。它不是“模型调用函数”的语法糖,而是 Agent Runtime 的执行协议层。
概念边界:它承接上下文,服务权限与具体工具
Tool Runtime 位于模型循环与具体环境能力之间。模型在 context 中看到 tool schema,输出 tool_use;Tool Runtime 找到对应 Tool,验证输入,执行权限流程,调度调用,收集进度,生成 UI 事件,把结果映射成模型下一轮能看到的 tool_result。
下一篇会专门讨论 Permissions。Permissions 不是 Tool Runtime 的外部附属物,而是 Tool Runtime 的一条关键子协议。每个工具既要接入通用权限规则,也要表达自己的风险语义。例如 FileEdit 和 Bash 都需要权限,但一个关注路径和 staleness,一个关注命令 AST、重定向和沙箱。
再下一篇会讨论 File、Bash、Web、LSP 四类工具。它们共享 Tool Runtime,但差异巨大。Tool Runtime 的价值,正是在统一执行协议的同时,不抹平具体工具的边界。
机制一:Tool 定义是多层协议对象
src/Tool.ts 中的 Tool 不是简单函数。它包含 name、schema、permission、prompt、UI、result mapping、summary、activity description、classifier input、concurrency、read-only、destructive、open-world、MCP/LSP 标记、defer loading、alwaysLoad 等能力。
这说明 Tool 需要同时面对四个消费者。
第一个消费者是模型。模型需要 name、description、input schema,以及工具说明。它关心“我如何调用这个工具”。
第二个消费者是 runtime。runtime 需要 validate、permission、call、concurrency、result mapping。它关心“这次调用如何安全执行”。
第三个消费者是用户界面。UI 需要 renderToolUseMessage、renderToolResultMessage、progress、rejected message、error message、grouped rendering。它关心“用户如何理解 Agent 正在做什么”。
第四个消费者是治理系统。权限、auto classifier、hooks、analytics、transcript search、tool search 都需要工具提供结构化摘要。它关心“系统如何审计、分类和调度这个行动”。
如果只把工具写成 (input) => output,这四类消费者都会被迫从外部猜测工具语义,最终形成大量 if-else。Claude Code 的做法是把语义放回工具定义本身。
可迁移的抽象是:
type RuntimeTool<Input, Output> = {
identity: ToolIdentity
modelContract: {
description: string
inputSchema: Schema<Input>
outputSchema?: Schema<Output>
deferLoading?: boolean
alwaysLoad?: boolean
}
governance: {
validate(input: unknown, ctx: ToolContext): Promise<ValidationResult<Input>>
checkPermissions(input: Input, ctx: ToolContext): Promise<PermissionDecision>
classify(input: Input): ToolRisk
}
execution: {
isReadOnly(input: Input): boolean
isConcurrencySafe(input: Input): boolean
isDestructive(input: Input): boolean
isOpenWorld(input: Input): boolean
call(input: Input, ctx: ToolContext): Promise<Output>
}
presentation: {
renderUse(input: Partial<Input>): UIBlock
renderResult(output: Output): UIBlock | null
toModelResult(output: Output, id: string): ToolResultBlock
}
}
这个类型比简单函数复杂,但它把复杂性放在正确的位置:工具知道自己的语义,runtime 只负责执行协议。
机制二:默认值降低接入成本,但安全语义必须显式覆盖
src/Tool.ts 的 buildTool() 为一组字段提供默认值:isEnabled 默认 true,isConcurrencySafe 默认 false,isReadOnly 默认 false,isDestructive 默认 false,checkPermissions 默认 allow,toAutoClassifierInput 默认空字符串,userFacingName 默认工具名。
这些默认值反映了一个微妙平衡。一方面,工具系统要容易扩展,不可能要求每个小工具都手写所有样板。另一方面,默认值必须尽量保守。例如并发默认 false,读写默认不是 read-only,这避免了新工具无意间被并行调度或被当成安全只读工具。
这里要特别理解 checkPermissions 默认 allow。它并不意味着系统整体没有权限边界,而是表示通用权限系统和安全关键工具会在自己的实现中覆盖。对于真正触碰环境边界的工具,权限不能依赖默认值。File、Bash、Web、MCP 等都需要显式接入权限。
这给工程实现一个明确原则:默认值可以用于消除样板,但不能用于隐式授予高风险能力。更具体地说:
- 并发安全应该默认否定。
- 只读性应该默认否定。
- 破坏性和开放世界访问应该由工具显式声明。
- 权限允许可以作为低风险工具的默认路径,但环境工具必须覆盖。
- 安全分类输入默认空,意味着安全相关工具必须主动参与分类器。
伪代码可以这样表达:
const SAFE_DEFAULTS = {
enabled: true,
concurrencySafe: false,
readOnly: false,
destructive: false,
openWorld: false,
autoClassifierInput: () => null,
}
function defineTool<T extends PartialToolDef>(def: T): RuntimeTool<any, any> {
const tool = { ...SAFE_DEFAULTS, ...def }
if (tool.touchesEnvironment && !def.checkPermissions) {
throw new ToolDefinitionError('environment tools must define permissions')
}
return tool
}
生产系统可以更进一步,把“触碰环境”的标记做成类型级约束,避免高风险工具忘记实现权限。
机制三:执行前不只验证 schema,还要验证语义
schema 只能回答“字段是否存在、类型是否正确”,不能回答“这个文件路径是否允许”“这个命令是否危险”“这个 URL 是否应该访问”。因此 Tool Runtime 的验证分两层:输入结构验证和工具语义验证。
src/Tool.ts 中 validateInput() 的注释说明,它决定工具在当前 context 中是否允许运行,并把失败原因告知模型。checkPermissions() 则在 validate 之后调用,处理是否需要用户授权。这个顺序很重要:先确认输入是可理解的,再进入权限判断。否则权限系统可能在无效输入上做错误推断。
以 FileEdit 为例,schema 可以要求有 file_path、old_string、new_string,但无法保证 old_string 在文件中唯一,也无法保证文件自读取以来没有变化。以 Bash 为例,schema 可以要求有 command,但无法判断命令替换、重定向、管道、cd、git 组合的风险。以 WebFetch 为例,schema 可以要求 URL 格式正确,但无法判断 domain 权限和跨 host redirect。
所以成熟工具执行链应该类似:
async function prepareToolUse(raw: ToolUseBlock, ctx: RuntimeContext) {
const tool = ctx.registry.find(raw.name)
if (!tool) return toolError('unknown tool')
const parsed = tool.modelContract.inputSchema.safeParse(raw.input)
if (!parsed.success) return toolError(renderSchemaError(parsed.error))
const semantic = await tool.governance.validate(parsed.data, ctx)
if (!semantic.ok) return toolError(semantic.message)
const permission = await tool.governance.checkPermissions(parsed.data, ctx)
if (permission.behavior !== 'allow') return permission
return { tool, input: permission.updatedInput ?? parsed.data }
}
这里的 updatedInput 也很关键。权限或 hook 可能返回调整后的输入,runtime 必须把实际执行输入与原始模型输入区分开,否则 transcript、UI 和安全审计会混乱。
机制四:并发调度由工具语义驱动
Agent Loop 中经常出现一个 assistant message 同时包含多个 tool_use。它们能不能并行执行?不能只看数量,也不能由调度器拍脑袋决定。
src/services/tools/toolOrchestration.ts 会根据工具的 isConcurrencySafe 把 tool calls 分批:安全的可以合并并行,不安全的单独执行。src/services/tools/StreamingToolExecutor.ts 也维护 queued、executing、completed、yielded 等状态,并用工具自身的并发语义决定是否可以执行。
这背后的原则是:工具的并发安全是输入相关的。Bash 对某些只读命令可能是并发安全的,对写命令则不是;文件读可以并发,文件写要谨慎;搜索可以并发,修改共享状态的工具不应该并发。把并发标记放在工具定义里,才能表达这种 input-sensitive 语义。
伪代码如下:
function partitionForExecution(calls: ToolCall[], registry: ToolRegistry): ExecutionBatch[] {
const batches: ExecutionBatch[] = []
for (const call of calls) {
const tool = registry.get(call.name)
const input = tool.modelContract.inputSchema.parse(call.input)
const safe = tool.execution.isConcurrencySafe(input)
const last = batches[batches.length - 1]
if (safe && last?.concurrencySafe) {
last.calls.push(call)
} else {
batches.push({ concurrencySafe: safe, calls: [call] })
}
}
return batches
}
这种设计让 runtime 可以在安全情况下提高吞吐,同时避免并行写入、并行 shell 副作用、并行状态修改造成不可恢复的竞态。
机制五:模型可见结果与用户可见 UI 必须分离
工具结果有两种读者:用户和模型。用户需要可解释、可审计、可展开的 UI;模型需要紧凑、结构化、不会污染上下文的结果。src/Tool.ts 中同时存在 renderToolResultMessage() 和 mapToolResultToToolResultBlockParam(),正是这个分离的体现。
例如 Bash 输出很长时,用户可能希望看到 stdout/stderr、return code、是否后台运行、是否超时、是否被沙箱限制;模型则不应该总是看到完整日志。src/Tool.ts 还定义了 maxResultSizeChars:当工具结果超过阈值时,可以持久化到磁盘,只把 preview 和路径交给模型。注释中特别提到 Read 这类工具可以设置 Infinity,因为它自身已经有边界,且持久化会形成 Read 到文件再 Read 的循环。
这种分离解决了三个问题。
第一,节省上下文。模型看到的是下一步决策所需信息,而不是 UI 全量内容。
第二,避免语义错位。UI 可以有颜色、diff、分组、展开状态;模型需要纯文本或结构化 block。
第三,支持审计。transcript search 需要索引用户实际看到的文本,而不是模型可见序列化。src/Tool.ts 中关于 extractSearchText() 的注释就说明了这一点:索引文本必须和 transcript mode 实际渲染一致,避免“索引中有、屏幕上没有”的幻影。
可迁移设计如下:
type ToolOutputEnvelope<Output> = {
raw: Output
ui: UIBlock
model: ToolResultBlock
audit: SearchIndexText
storage?: PersistedArtifact
}
function serializeToolOutput(tool: RuntimeTool<any, any>, output: unknown, id: string): ToolOutputEnvelope<any> {
return {
raw: output,
ui: tool.presentation.renderResult(output),
model: tool.presentation.toModelResult(output, id),
audit: extractRenderedText(tool, output),
}
}
不要让一个字符串同时承担 UI、模型、审计三种职责。这是工具层可维护性的底线。
机制六:工具 schema 本身也消耗上下文
工具越多,模型可用能力越强;但工具 schema 也会占用上下文窗口。Claude Code 的 Tool 类型中有 shouldDefer 和 alwaysLoad。注释说明,deferred tool 需要先通过 ToolSearch 才能调用;alwaysLoad 则保证某些工具即使用了 ToolSearch 也会在初始 prompt 中出现。MCP 工具还可以通过 _meta['anthropic/alwaysLoad'] 设置 always-load。
这说明工具注册表不能简单等于“每次把所有 schema 全发给模型”。当内置工具、MCP 工具、团队工具、技能工具都进入系统后,schema 爆炸会成为上下文问题。Tool Runtime 必须支持工具发现协议:常用、基础、安全关键的工具可以直接加载;长尾工具延迟加载;模型需要长尾能力时先搜索。
伪代码如下:
function selectToolsForPrompt(allTools: RuntimeTool<any, any>[], turn: Turn): PromptToolSet {
const eager = allTools.filter(t => t.modelContract.alwaysLoad)
const core = allTools.filter(t => isCoreForMode(t, turn.mode))
const deferred = allTools.filter(t => shouldDefer(t, turn))
return {
visibleSchemas: unique([...eager, ...core]).map(toModelSchema),
searchableIndex: deferred.map(toToolSearchDocument),
}
}
这个机制把 Tool Runtime 和 Context Engineering 连接起来:工具不是越多越好,工具可见性也需要预算治理。
机制七:内置工具与 MCP 工具要进入同一协议
Claude Code 不把 MCP 工具当成另一套旁路执行系统。src/tools/MCPTool/MCPTool.ts 定义 MCP 工具模板,src/services/mcp/client.ts 从 MCP server 的 tools/list 动态生成工具,设置 name、description、input JSON schema、call、annotations、readOnly/destructive/openWorld 等字段。
这带来一个重要架构收益:外部工具被适配成内部 Tool 后,可以共享权限、UI、日志、hook、工具搜索、结果序列化和并发调度。否则每接一种外部工具协议,都要复制一套安全和观测逻辑,系统会迅速失控。
MCP annotations 也不能被误解为绝对可信。readOnly、destructive、openWorld 是外部 server 提供的语义提示,runtime 可以利用它们,但仍需要自己的连接授权、工具调用权限和用户策略。统一协议不是盲目信任,而是把外部能力放进同一治理框架。
Claude Code 作为 Tool Runtime 案例
从 Agent Runtime 的角度看,Claude Code 的工具层是一套 runtime 协议栈,而不是 handler 列表。每个工具都要同时表达模型契约、执行契约、权限契约、并发语义、UI 展示和结果回填方式。
这也是 Tool Runtime 的关键:工具不是“模型能调用的函数”,而是 runtime 用来连接真实工程边界的受治理协议。
工程启发
第一,把工具定义成协议对象。至少要包含模型契约、执行契约、权限契约和展示契约。
第二,把安全语义放进工具本身。只读、并发安全、破坏性、开放世界访问,都应该是工具根据输入计算出来的属性,而不是调度器外部猜测。
第三,模型可见结果和用户可见 UI 要分离。工具输出越复杂,这个分离越重要。
第四,工具注册表也需要上下文预算。支持延迟加载、工具搜索和 always-load,否则工具 schema 会吞掉上下文窗口。
第五,外部工具协议应该适配进内部 Tool Runtime。不要为 MCP、插件、脚本各写一套旁路执行逻辑。
小结
Tool Runtime 把模型的 tool_use 变成一次受治理的环境行动。它负责 schema、验证、权限、并发、执行、UI、结果序列化、审计和工具可见性。没有这一层,Agent 只是会生成 JSON 的聊天模型;有了这一层,Agent 才能可靠地读写工程环境。
下一篇会把 Tool Runtime 中最关键的治理面单独展开:Permissions。真正的权限系统不是一个确认弹窗,而是把文件、命令、Web、MCP 等行动风险建模成可组合的规则与决策流程。