ARTICLE · 1014056
AI Harness 工具(tool)执行引擎:一切皆可为工具


👉目录
1 大多数框架,把工具调用仅作为一个函数
2 工具的定义:Definition 与参数 Schema
3 工具的注册:Registry 与 Register
4 工具的调用与执行:Call、Result 与两层 Execute
5 函数调用(Function Calling)的本质与原理
6 多模型适配:一套工具,适配所有模型
7 一切皆工具:MCP、记忆、技能,都是工具
8 完整示例:从读到响应的完整链路
9 写在最后
如果要给 AI Agent 下一个最简的定义,一个广为流传的公式是:Agent = LLM + Tool + Memory。
LLM 是大脑,负责理解和推理;Memory 是记忆,负责记住上下文;Tool 是手脚,负责真正动手——读文件、跑命令、发请求、查数据。
这个公式里,大脑和记忆被聊得最多,唯独手脚这一环,很多人以为就是调个函数就够了,可偏偏是这一环决定了 Agent 到底是真能干活,还是只会说、不会做。
这篇文章就专讲 Tool 这一环,以及撑起它的那一整套工具执行引擎。
Function Calling,就是模型侧的那只手:模型输出一句结构化的「我想调用这个函数,参数是这些」。
而工具执行引擎,是 Harness 侧的那只手:它收到这个调用请求,真正去执行——查注册、过治理、跑逻辑、返回结果。
两者一体两面:Function Calling 负责决定调用什么,工具执行引擎负责把调用真正落地,这篇文章,讲的就是这套机制。
01
先看主流框架是怎么定义工具的。
从 OpenAI 的 Function Calling,到 Anthropic 的 Tool Use,再到 LangChain 的 @tool的工具机制,本质上是同一条流水线:
定义 Schema → 模型输出调用 → 你的代码执行 → 返回结果。
这条流水线很成熟,也够用——但它的关注点,几乎全在怎么让模型调用、怎么定义、怎么执行上,漏掉了更关键的一层:执行之前的安全。
换句话说,主流框架定义怎么调用工具,但未进一步阐述怎么安全地调用工具,具体缺四样:
缺拦截。
模型说调就调,中间没有一层能拦下来做校验、限流、改写,参数错了、越权了,只能眼睁睁看它执行。
缺治理。
工具是静态定义、全部暴露的,没有「哪些工具在当前场景允许用」的策略——被禁的工具,模型照样看得见、调得了。
缺审批。
没有 human-in-the-loop,危险的调用,没有一个「挂起等人类批准」的机制。
缺审计。
执行完就完了,谁、在什么时候、调了什么、结果如何,没有记录,出了事没法追。
而这四道闸——拦截、治理、审批、审计——靠的是 hook 管道 + 审批机制,这套东西在系列前面系列片篇《Hook 管道引擎》、《AI Harness 动态治理引擎》《AI Harness 安全治理终章》里已经讲述了核心,这里不再重复。
本文要讲的,是工具执行引擎的另一半:工具怎么定义、怎么注册、怎么执行、怎么适配所有模型——执行机制本身。
这才是工具执行引擎和函数调用(Function Calling)的根本区别:函数调用是能调,工具执行引擎是既敢调、又调得对、还换模型不换工具。
我们工具执行引擎要做的是把执行这一层做成一套可注册、可拦截、可治理、可适配的完整机制。
02
一个工具,先得把它是什么说清楚,落到具体实现上就是 Definition——工具定义,直接看它的 Go 结构:
type Definition struct {Name string `json:"name"`Description string `json:"description"`Display string `json:"display,omitempty"`Parameters []Parameter `json:"parameters"`}
三个字段各司其职:Name 是工具名(模型调用时喊的名字),Description 是给模型看的说明(模型靠它决定什么时候用这个工具),Display 是给人看的展示模板(可选),Parameters 是参数列表。
其中参数,又是单独一个结构:
type Parameter struct {Name string `json:"name"`Type ParamType `json:"type"`Description string `json:"description"`Required bool `json:"required"`Enum []string `json:"enum,omitempty"`Minimum *float64 `json:"minimum,omitempty"`Maximum *float64 `json:"maximum,omitempty"`Pattern string `json:"pattern,omitempty"`Items *ParameterSchema `json:"items,omitempty"`Properties map[string]*ParameterSchema `json:"properties,omitempty"`}
Parameter 里有两个字段指向 ParameterSchema,Items 和 Properties,它们正是用来嵌套的。
当参数是数组时,Items 描述数组元素长什么样。
当参数是对象时,Properties 描述对象的每个属性长什么样。
而 ParameterSchema,就是嵌套参数的那个结构:
type ParameterSchema struct {Type ParamType `json:"type"`Description string `json:"description,omitempty"`Enum []string `json:"enum,omitempty"`Minimum *float64 `json:"minimum,omitempty"`Maximum *float64 `json:"maximum,omitempty"`Pattern string `json:"pattern,omitempty"`Properties map[string]*ParameterSchema `json:"properties,omitempty"`Items *ParameterSchema `json:"items,omitempty"`Required []string `json:"required,omitempty"`}
两个结构几乎一样,区别只有两处——Parameter 描述的是有名字的顶级参数,ParameterSchema 描述的是嵌套的、没有名字的参数:
Parameter 有 Name 字段,ParameterSchema 没有——因为嵌套参数的名字就是 map 的 key(对象属性名)或数组下标,不需要单独的 Name。 Parameter的Required是bool(这个参数本身是否必填),ParameterSchema 的 Required 是 []string(当它是 object 时,记录哪些属性必填),这正是 JSON Schema 里 object 的 required 字段是数组的规范。
每个参数有类型(string / number / boolean / object / array)、描述、是否必填,还有一串约束字段——枚举值(Enum)、数值范围(Minimum / Maximum)、正则(Pattern)。
如果参数是数组或对象,还能嵌套 Items 和 Properties,往下一直套娃。
这套东西,就是 JSON Schema 的一个子集,它有两个用途:给模型看(让模型知道能传什么),给引擎用(做参数校验的依据)。
也正因为它就是 JSON Schema,后面讲 strict 模式时你会发现,它俩能直接对上。
Definition是工具的说明书,Name 是名字,Description 给模型看,Parameters 是一套带约束的 JSON Schema 子集。
03
定义有了,还得把怎么干活绑上去,再挂进一张表里,这就是注册。
干活的那个函数叫 Handler,签名很简单:
type Handler func(ctx context.Context, args json.RawMessage) (string, error)入参是参数 JSON,出参是内容加错误,Definition 是说明书,Handler 是干活的手。
注册表 Registry,本质上是一张 map——工具名 → 定义 + 处理函数:
type Registry struct {mu sync.RWMutextools map[string]registration}type registration struct {Definition DefinitionHandler HandlerCreatedAt time.Time}
注册动作,就是 Register 这个函数:
func(r *Registry) Register(def Definition, handler Handler) error {if def.Name == "" {return errs.Newf(errs.CodeCapability, "tools.register", "tool name cannot be empty")}if handler == nil {return errs.Newf(errs.CodeCapability, "tools.register", "tool handler cannot be nil")}r.mu.Lock()defer r.mu.Unlock()if _, exists := r.tools[def.Name]; exists {return errs.Newf(errs.CodeCapability, "tools.register", "tool %q already registered", def.Name)}r.tools[def.Name] = registration{Definition: def,Handler: handler,CreatedAt: time.Now(),}return nil}
注册时,三道校验一个不能少:name 不能为空、handler 不能为 nil、同名工具不能重复注册。
都过了,才把 Definition 和 Handler 绑成一个 registration,挂进 map。
有了 Register,自然还有 Replace(覆盖同名)和 Unregister(注销)等等——增删改,至此一套已齐活。
注册 = 把 Definition 和 Handler 绑在一起,挂进 map;Register 三道校验(name 非空、handler 非 nil、不重复),一个都不能少。
04
工具注册好了,模型就能调了,调用进来的是一个 Call,执行完出去的是一个 Result:
type Call struct {ID string `json:"id"`Name string `json:"name"`Arguments json.RawMessage `json:"arguments"`}type Result struct {CallID string `json:"call_id"`Name string `json:"name"`Content string `json:"content"`IsError bool `json:"is_error"`}
Call 回答"想调什么"——一个函数名加一段参数 JSON。
Result 回答"干成了什么"——一段内容加一个是否出错的标记。
真正执行的,是 Execute,它分两层。
外层,是 InterceptedExecutor——拦截器链的装饰器:
func(e *InterceptedExecutor) Execute(ctx context.Context, call Call) Result {for _, ic := range e.interceptors {if err := ic.Before(ctx, &call); err != nil {return Result{CallID: call.ID, Content: err.Error(), IsError: true}}}result := e.inner.Execute(ctx, call)for i := len(e.interceptors) - 1; i >= 0; i-- {e.interceptors[i].After(ctx, &call, &result)}return result}
Before 链按顺序跑,任何一个返回错误就短路(不执行,直接当 IsError 返回);全过了才调 inner;After 链按逆序跑,做善后。
内层,是 Registry.Execute——真正干活:
func(r *Registry) Execute(ctx context.Context, call Call) (result Result) {reg, exists := r.tools[call.Name]if !exists {return Result{CallID: call.ID, Name: call.Name, Content: "tool not found", IsError: true}}if !r.allowed(call.Name) {return Result{CallID: call.ID, Name: call.Name, Content: "denied by policy", IsError: true}}output, err := reg.Handler(ctx, call.Arguments)if err != nil {return Result{CallID: call.ID, Name: call.Name, Content: "tool error", IsError: true}}return Result{CallID: call.ID, Name: call.Name, Content: output, IsError: false}}
四步,一条直线:查表(不在 → not found)→ 过治理(不允许 → denied by policy)→ 调 handler(出错 → tool error)→ 返回结果。
每一个失败分支,都返回 IsError: true。
这里要顺带厘清一个关系,避免和前面文章的Hook 管道混淆。
拦截器链的 Before/After,是一套通用的插桩机制——它只定义执行前后能挂东西,不定义挂的是什么。
而Hook 管道(tool.pre / tool.post)正是这套机制的一个实现:hooks.Pipeline 实现了 Interceptor 接口,它的 Before 就是去派发 tool.pre 事件。
所以两者是插座和插头的关系,拦截器链是插座,Hook 管道是插在上面的一个插头。
本文重点是插座本身(执行机制),插头(治理逻辑)前面文章已经讲透,这里不展开。
Call 是想调什么、Result 是干成了什么;Execute 是两层——外层拦截器装饰(Before 顺序、After 逆序),内层查表 → 过治理 → 调 handler → 返回,每个失败分支都 IsError。
05
那 Function Calling 到底是什么?
先从形说起,Function Calling 在传输上,就是一套 JSON 往返协议,分三段:
- 工具定义
(引擎 → 模型):一段 JSON Schema,告诉模型有哪些函数、收什么参数; - 调用请求
(模型 → 引擎):一段 JSON,模型声明我要调这个函数、参数是这些; - 工具结果
(引擎 → 模型):一段 JSON,把执行结果还回去。
三段全是 JSON,一次往返,所以很多人把它理解成一种JSON 协议,工程上也完全成立。
但神要再深一层:JSON 只是载体,真正变的是模型的能力——它从只会输出自由文本,升级到了能输出结构化、可被程序解析的内容。
这个能力跃迁,才是 Function Calling 的本质;JSON 只是这个能力的落地格式。
有了这层理解,再看它的本质,一句话:模型不执行函数,它只声明想调用哪个函数、传什么参数。
而声明这件事,靠的是一个关键信号——finish_reason。
finish_reason:模型的暂停信号。
每次模型响应,都会带一个 finish_reason,告诉调用方我为什么停下来了,以 DeepSeek 官方文档为例,它有这几个值:
关键就在 tool_calls 这个值:它不是结束,而是暂停,模型在说我要回答你,但得先调某个工具,等结果回来再继续说。
所以引擎拿到响应,第一件事就是看 finish_reason:是 tool_calls,就去执行工具;是 stop,就是最终答案。
这一步,正是后面完整示例里那个 switch 在做的事。
strict 模式:参数校验的模型侧前置。
模型产出的参数,不一定合法,DeepSeek 官方文档自己也提醒:模型不总是生成合法 JSON,可能幻觉出 schema 里没有的参数。
为此,DeepSeek 提供了一个 strict 模式:开启后,模型输出 Function 调用时会严格遵循你定义的 JSON Schema,不再凭空捏造参数。
它支持的 Schema 子集——object / string / number / integer / boolean / array / enum / anyOf,外加 pattern、format、minimum、maximum、multipleOf、additionalProperties:false
正好和我们的工具定义里那套参数 Schema 一一对应。
换句话说,ToOpenAIFormat 把工具定义转成 function calling 格式时,就是在把这份 Schema 喂给 strict 模式做服务端强校验。
所以参数校验其实是双保险:模型侧靠 strict 模式减少幻觉,引擎侧靠 pre 钩子和治理再拦一道,模型别乱说,引擎别乱做。
JSON Output:模型侧的不确定性。
另一个模型侧的事实是:DeepSeek 的 JSON Output 功能,官方明确提示有概率返回空 content。
也就是说,模型报了 tool_calls,结果 tool_calls 里可能解析出来是空的。
这就解释了为什么引擎里要有这种防御——当 finish_reason = tool_calls 但解析出来却是空的,不能当成功,得当成退化响应处理掉。
一句话收尾:Function Calling本身就不完全可靠——参数会幻觉、结果会为空。
所以它只能是意图,真正的可靠性,得靠引擎侧的校验、治理、兜底来补。
Function Calling 是模型侧的意图声明,工具执行引擎是 Harness 侧的真实执行;模型侧会幻觉、会为空,所以执行的可靠性只能靠引擎兜底。
06
那把工具包一层,到底图什么?
四个目的:统一抽象(隔离执行细节)、多模型适配(换模型不改定义)、拦截治理(Before/After 插桩)、统一数据流(四个对象一条流)。
其中最能体现价值、也最容易被忽视的,是多模型适配。
工具定义,是模型无关的。
回头看 02 节的 Definition——name、description、parameters,这里面没有任何一个字段是 OpenAI 专属的,也没有 Anthropic 专属的。它是一份中性的描述。
ToOpenAIFormat:把定义翻译成普通话。
但模型要的是它自己协议里的格式,所以引擎先把 Definition 转成 OpenAI 的 function calling 格式,作为中间交流桥梁的普通话。
Provider 层:把普通话翻译成各家方言。
真正的适配在 completion 包。它定义了一个 Provider 接口(Complete / CompleteStream / SetFallbacks),把调模型这个动作抽象掉,每个厂商一个实现:
OpenAI:原生实现; DeepSeek、Kimi、智谱、Copilot:因为它们都兼容 OpenAI 协议,所以不单独写实现,而是注册一个别名,直接指向 OpenAI 的实现——一行代码完成适配; Anthropic、Gemini:协议不同,需要双向转换——出方向把统一请求转成它的格式,入方向把响应转回来。
所以 DeepSeek 的适配,答案就是这么简单:它兼容 OpenAI,注册一个别名就够了,根本不用单独写 provider。
一个 read_file,三种形态。
把这个翻译过程,用 read_file 工具具体走一遍,注册时,它长这样(02 节讲过 Definition):
def := tools.Definition{Name: "read_file",Description: "读取指定路径的文件内容",Parameters: []tools.Parameter{{Name: "path", Type: tools.TypeString, Description: "文件路径", Required: true},},}
注意一件事:这里的 Parameters 是个切片(一个数组,每个元素是"参数名 + 类型 + 是否必填")。
但各家模型要的不是切片,而是一个 JSON Schema 对象——{type: "object", properties: {...}, required: [...]},两者结构完全不同,所以中间必须先做一次转换。
这次转换是 ToOpenAIFormat 的职责:遍历切片,把每个参数转成 properties 里的一个 key,把必填参数名收集进 required,最后包成一个标准 JSON Schema 对象:
properties := make(map[string]any)required := []string{}for _, p := range def.Parameters {properties[p.Name] = map[string]any{"type": string(p.Type), "description": p.Description,}if p.Required { required = append(required, p.Name) }}// 结果包成:{ "type": "object", "properties": properties, "required": required }
{"type": "function","function": {"name": "read_file","description": "读取指定路径的文件内容","parameters": {"type": "object","properties": {"path": {"type": "string", "description": "文件路径"}},"required": ["path"]}}}
RegisterProvider(&providerAlias{name: "deepseek", factory: f}) // f 是 openai 工厂{"name": "read_file","description": "读取指定路径的文件内容","input_schema": {"type": "object","properties": {"path": {"type": "string", "description": "文件路径"}},"required": ["path"]}}
看input_schema 里的内容和 parameters 一模一样,只是外层字段名换了。
所以真正的转换只有一步:切片 → JSON Schema 对象(ToOpenAIFormat 做)。这一步做完,OpenAI 和 Anthropic 都只是换个字段名包一层。
同一个 Definition,三种形态,但无论换哪个模型,注册表里那份 Definition 一行都不用改,定义只写一次,翻译交给适配层。
finish_reason,也要归一化。
各家的 finish_reason 还不一样
OpenAI 是 stop / length / tool_calls / content_filter
Anthropic是 end_turn / max_tokens / tool_use / stop_sequence
DeepSeek 又多了一个 insufficient_system_resource。
适配层的职责,就是把这些方言统一映射成一套通用的值,让 turn 循环里的 switch 只需要守一套。
07
client, _ := NewClient(ctx, name, transport)toolDefs, _ := client.ListTools(ctx) // 发现 MCP 服务器上的工具for _, td := range toolDefs {def := tools.Definition{Name: "mcp__" + name + "_" + td.Name, // mcp__ 前缀Description: "[MCP:" + name + "] " + td.Description,Parameters: convertInputSchema(td.InputSchema), // Schema 也转成我们的 Parameter}handler := func(ctx context.Context, args json.RawMessage) (string, error) {result, err := client.CallTool(ctx, td.Name, params) // 转发给 MCP 客户端if err != nil {return "", err}var text stringfor _, item := range result.Content { // Content 是 []sdk.Contentif tc, ok := item.(*sdk.TextContent); ok { // 只取文本块,拼成字符串text += tc.Text}}return text, nil}registry.Register(def, handler)}
这段代码用的是官方 MCP Go SDK(github.com/modelcontextprotocol/go-sdk v1.7.0,支持 2025-06-18 新版协议)。
注意两个新版细节:result.Content 是 []sdk.Content 接口切片,要逐个断言成 *sdk.TextContent 才能取文本;
而 MCP 新版协议里 CallToolResult 其实还多了一个 structuredContent 字段,可以返回结构化数据——我们这里只取了文本部分,够用。
工具名加 mcp__ 前缀(比如 mcp__github_list_issues)不是为了好看,而是为了治理:policy 里写一条 mcp__* 就能把整个 MCP 服务器的工具一网打尽。
而 handler 干的是转发——所以对模型来说,调用一个 MCP 工具和调用一个原生工具,没有任何区别。
记忆:读写记忆,也是两个工具。
funcRegisterMemoryTools(registry *tools.Registry, m *Memory) error {registry.Register(memoryReadDef(), memoryReadHandlerFacade(m)) // memory_readregistry.Register(memoryWriteDef(), memoryWriteHandlerFacade(m)) // memory_writereturn nil}
funcregisterOne(registry *tools.Registry, sk SkillDef) error {def := tools.Definition{Name: sk.Name,Description: "Invoke the " + sk.Name + " skill. " + sk.Description,Parameters: []tools.Parameter{}, // 无参数}handler := func(ctx context.Context, args json.RawMessage) (string, error) {return sk.Body, nil // 直接返回 skill 的指令 + 内嵌工具/hook 定义}return registry.Register(def, handler)}
所以调用一个技能,本质是调一个工具,把这个技能的操作步骤拿回来,照着做。
三种来源,同一个入口,模型看到的世界,就是一张统一的工具表——无论能力来自原生代码、外部 MCP 服务器、记忆系统、还是技能文件,对模型来说都是一个能调用的函数。
MCP、记忆、技能,都是注册表里的普通工具——Definition 说是什么、Handler 说怎么干,来源不同,入口相同。
08
把前面所有东西串起来,看一次读取文件从用户输入到最终响应的完整链路。
用户问:读取 /tmp/note.txt 的内容。
{ "model": "deepseek-chat","messages": [ { "role": "user", "content": "读取 /tmp/note.txt 的内容" } ],"tools": [ /* read_file 定义 */ ] }
第一次响应(finish_reason = tool_calls):
{ "choices": [ { "finish_reason": "tool_calls","message": { "role": "assistant", "content": null,"tool_calls": [ { "id": "call_abc123", "type": "function","function": { "name": "read_file", "arguments": "{\"path\": \"/tmp/note.txt\"}" } } ] } } ] }
两个细节:finish_reason 是 tool_calls、content 是 null——模型这轮没说话,只发出了调用指令;arguments 是 JSON 字符串(外面那层引号),不是对象。
引擎拿到响应,第一件事是看 finish_reason,是 tool_calls,就走工具分支:
解出 tool_calls,构造 tools.Call{ID, Name, Arguments}; 并发执行:每个调用先过 tool.pre 钩子(block / pending→审批 / modify 改参),再 executor.Execute; 执行 read_file,拿到结果"你好世界"。
{ "model": "deepseek-chat","messages": [{ "role": "user", "content": "读取 /tmp/note.txt 的内容" },{ "role": "assistant", "content": null, "tool_calls": [ /* 原样返回 */ ] },{ "role": "tool", "tool_call_id": "call_abc123", "content": "你好世界" } ],"tools": [ /* read_file 定义 */ ] }
这里有个硬约定:role:tool 的 tool_call_id 必须和上一轮 tool_calls[0].id 完全对应,模型靠这个 id 才知道结果对应哪个调用。
第二次响应(finish_reason = stop):
{ "choices": [ { "finish_reason": "stop","message": { "role": "assistant", "content": "文件 /tmp/note.txt 的内容是:你好世界" } } ] }
这次 finish_reason 是 stop、content 有值,就是最终答案,循环结束。
整个链路的本质,是一个产出调用 → 执行 → 回填 → 再推理的循环,直到模型不再要求调工具为止。
一次完整的工具调用,不是调一次就结束,而是请求 → tool_calls → 执行 → role:tool 回填 → 再请求 → stop的循环,直到模型给出最终答案。
09
回到标题那句话:一切皆工具。
读文件、跑命令、发 HTTP,是工具;MCP、记忆、技能,也是工具。
在AI Harness 里,所有 AI 能动手的能力,最终都收敛成注册表里的一个 Definition + 一个 Handler,模型看到的世界,就是这一张统一的工具表。
而这张表要能放心用,靠的是它背后的两层执行、多模型适配,以及 Hook + 审批的治理。
它们合起来,才让 Function Calling 从一句不可靠的意图,变成一次受控的、可审计的真实执行。
所以,当你给AI 接工具时,真正的工程难点从来不是「让模型能调用函数」,而是「让这次调用值得被信任」。
前者是 Function Calling,后者是工具执行引擎。
而治理负责兜底里的治理——策略、pre/post 钩子、审批、审计——本质就是我们前面系列反复讲的 hook 管道 + 审批机制。
模型负责说,引擎负责做,治理负责兜底——三者合起来,AI 才算真正有了一双能被信任的手。
以上全文基于个人理解,欢迎批评指正。
