系列「企业级 AI Agent 实现拆解」第七篇。上一篇讲了 Hook 系统,这篇看工具调用的完整设计。
工具是什么
LLM 本质上只能做一件事:根据输入文本预测输出文本。它没有手,没有眼,没办法查数据库、发邮件、调接口。工具调用(Tool Use)给了 Agent 这些能力——LLM 输出一个结构化的"我想调这个工具、传这些参数",系统真正执行,把结果塞回给 LLM,LLM 再决定下一步。
工具注册表(tool-broker BC)是这个系统的中心:所有工具都在这里注册、管理、调度。Agent 不直接知道工具的实现细节,只通过 gRPC 调ToolBroker.Invoke()。
一次工具调用的完整流程
LLM 输出 ToolCall(工具名 + 参数 JSON) │ ▼ Eino 的 tools 节点 → toolBrokerAdapter.InvokableRun() │ 从 context 取 sessionID + tenantID │ ▼ gRPC 调用 tool-broker 的 InvokeHandler.Handle() │ ├── 1. 查工具:LoadByName(租户私有优先,找不到查全局) ├── 2. 选 runner:runners[tool.Impl()] → builtin/wasm/http/mcp ├── 3. 超时控制:tool.TimeoutMs() > 0 时设 context timeout ├── 4. 执行:runner.Run(ctx, tool, args) → 实际调用 ├── 5. 记审计:invLog.Append(Invocation{...}) → tool_invocations 表 │ 审计失败不影响结果返回,但会发 outbox 事件告警 └── 6. 返回结果 + latency工具的两个核心属性
注册一个工具时,有两个属性最重要:
**Impl(实现类型)**决定工具怎么执行:
typeImplstringconst(ImplBuiltin Impl="builtin"// 同进程 Go 函数(kb.search、memory.set)ImplWASM Impl="wasm"// 租户上传 WASM,gVisor 沙箱隔离ImplHTTP Impl="http"// 远程 HTTP API,走 Connector 管 secretImplMCP Impl="mcp"// MCP 协议工具)**Danger(危险等级)**决定是否触发 PreToolUse hook:
typeDangerstringconst(DangerSafe Danger="safe"// 纯读,不需审批(kb.search)DangerCaution Danger="caution"// 写但可回滚(memory.set)DangerHigh Danger="high"// 不可逆/对外副作用(sql.exec、email.send))DangerHigh的工具,PreToolUse hook 会评估是否需要 HITL。评估逻辑写在 hook 配置里,不写在工具代码里——不同租户对"高危"的定义可以不同。
工具名必须带命名空间
funcNewTool(tenantID,name,descstring,...)(*Tool,error){if!strings.Contains(name,"."){returnnil,errors.New("name must use namespace.method format (e.g. kb.search)")}...}工具名强制namespace.method格式,原因有两个:
- 避免命名冲突——不同租户的自定义工具和平台内置工具混在一起,命名空间做天然隔离
- PreToolUse hook 的 matcher 支持通配符——
{"tool_name": "sql.*"}能匹配所有 SQL 类工具,不需要为每个工具单独写规则
调用记录:每次都留档
每次工具调用都写一条Invocation记录:
typeInvocationstruct{IDstringToolNamestringTenantIDstringSessionIDstringCallIDstring// 关联 agent ToolCall.IDArgsstring// JSONResultstring// JSON,>16k 走 MinIO + urlErrorstringLatencyMsint64StartedAt time.Time EndedAt time.Time}CallID关联 LLM 输出的ToolCall.ID,审计时能从"这次 session 里某个工具调用"直接跳转到 LLM 当时给出的完整 tool_call。结果超 16KB 存 MinIO,表里只存 URL。
实战案例:两个免费 builtin 工具
光说模型可能还是抽象,看两个实际注册的 builtin 工具。它们都不需要密钥、不花钱,适合当入门案例。
案例一:web.wikipedia— 搜索维基百科
这是一个 safe 级别的只读工具。LLM 想查一个知识性问题(比如"量子计算的基本原理"),调这个工具拿维基百科的摘要。
调用方式:Agent 传{"query": "quantum computing"},工具返回匹配的条目列表(标题、URL、摘要)。
内部逻辑是两步走:
第 1 步:调 Wikipedia search API,拿到标题列表(TopK=3,默认返回 3 条) 第 2 步:对每个标题调 page API,拿正文摘要(截断到 2000 字符)为什么分两步?因为 Wikipedia 的搜索 API 只返回标题和摘要片段,不返回完整正文。先搜到标题,再逐个取详情,是最稳的做法。
关键代码(简化):
funcBuildWikipediaSearch(cfg WikipediaConfig)HandlerFn{returnfunc(ctx context.Context,rawstring)(string,error){// 1. 解析入参varreqstruct{Querystring`json:"query"`}json.Unmarshal([]byte(raw),&req)// 2. 搜索:拿标题列表titles,_:=search(ctx,req.Query)// 3. 逐个取详情results:=[]wikipediaResult{}for_,title:=rangetitles{extract,url,_:=getPage(ctx,title)results=append(results,wikipediaResult{Title:title,URL:url,Extract:extract})}// 4. 返回 JSONout,_:=json.Marshal(map[string]any{"results":results})returnstring(out),nil}}设计亮点:
WikipediaConfig有合理默认值(语言 en、TopK 3、超时 15s、截断 2000 字符),零值配置就能用- 纯标准库
net/http,没有外部依赖 - 结果截断到
DocMaxChars,防止超长摘要吃掉 LLM 的上下文窗口
案例二:osm.geocode— 地理编码
这也是一个 safe 级别的只读工具。输入地名(比如"北京天安门"),返回经纬度、完整地址、类型、重要性评分。
调用方式:Agent 传{"query": "天安门广场"},工具返回匹配的地理信息列表。
关键代码(简化):
funcBuildOSMGeocode(cfg OSMGeocodeConfig)HandlerFn{returnfunc(ctx context.Context,rawstring)(string,error){// 1. 解析入参varreqstruct{Querystring`json:"query"`Limitint`json:"limit,omitempty"`}json.Unmarshal([]byte(raw),&req)// 2. 限制结果数量(防滥用)limit:=req.Limitiflimit<=0{limit=5}// 默认 5 条iflimit>conf.MaxLimit{limit=10}// 最多 10 条// 3. 调 Nominatim API(单次请求)// GET https://nominatim.openstreetmap.org/search?q=...&format=json&limit=5// 4. 解析返回,输出标准化结构out,_:=json.Marshal(map[string]any{"results":results})returnstring(out),nil}}设计亮点:
- 遵守 Nominatim 使用政策:User-Agent 必填、不并发批量请求
- 结果数量有上限保护(MaxLimit=10),防 Agent 无意中请求太多数据
Accept-Language默认中文优先,对中文用户更友好
两个案例的共性
| 特性 | web.wikipedia | osm.geocode |
|---|---|---|
| Danger | safe | safe |
| 外部依赖 | 无(纯 stdlib) | 无(纯 stdlib) |
| 密钥 | 不需要 | 不需要 |
| 超时 | 15s(可配置) | 10s(可配置) |
| 结果截断 | DocMaxChars=2000 | MaxLimit=10 |
两个工具都是只读的(safe),不需要密钥,纯标准库实现,配置有合理默认值。它们展示了 builtin 工具的典型模式:接收 JSON 参数 → 调外部 API → 返回 JSON 结果。写一个新的 builtin 工具,照这个模板写就行。
Builtin 工具一览
tool-broker 启动时通过RegisterStdBuiltins注册内置工具。注册哪些取决于环境变量配置——没配的就不注册,调用时返回 “no handler”(fail-closed 默认安全):
// register.go — 内置工具注册funcRegisterStdBuiltins(r*BuiltinRunner,d StdDeps)[]string{ifd.SQL!=nil{r.Register("sql.exec",BuildSQLExec(d.SQL))// high · 执行 SQL}ifd.FS!=nil{r.Register("file.read",BuildFileRead(d.FS,...))// safe · 读文件}ifd.Shell!=nil{r.Register("shell.run",BuildShellRun(d.Shell,...))// high · 执行 Shell}ifd.Calendar!=nil{r.Register("calendar.list",BuildCalendarList(...))// safe · 日历查询}ifd.Slack!=nil{r.Register("slack.send",BuildSlackSend(...))// caution · 发 Slack 消息}}| 工具名 | 危险等级 | 说明 |
|---|---|---|
sql.exec | high | 执行 SQL(需审批) |
file.read | safe | 读文件(可配置白名单目录) |
shell.run | high | 执行 Shell 命令(可配置白名单命令) |
calendar.list | safe | 日历查询 |
slack.send | caution | 发 Slack 消息(可配置白名单频道) |
web.wikipedia | safe | 维基百科全文搜索(免费,不需密钥) |
osm.geocode | safe | OpenStreetMap 地理编码(免费,不需密钥) |
sql.exec和shell.run标DangerHigh,每次调用都会触发 PreToolUse hook 评估。每个内置工具都支持配置限制参数(白名单目录、白名单命令、最大长度等),防止工具被滥用。
kb.search、memory.set/get等工具由各自的 BC(知识库、记忆)注册,不走 tool-broker 的 builtin runner。
跟 Eino 的关系
Eino 定义了tool.BaseTool接口:Info()(返回工具描述和参数 schema)和InvokableRun()(执行)。infrastructure/einoadapter/tool.go把 tool-broker 的 gRPC 调用包成 EinoBaseTool,让 Eino 的 ReAct 图能直接调度:
// tool.go — toolBrokerAdapterfunc(t*toolBrokerAdapter)InvokableRun(ctx context.Context,argsstring,...)(string,error){// 从 context 取 sessionID(优先于 struct 字段)// 原因:Runnable 按 AgentConfig 缓存,跨 session 复用// struct 里的 sessionID 可能是第一次创建时的旧值sessionID,_:=ctx.Value(port.ContextKeyAgentSessionID{}).(string)ifsessionID==""{sessionID=t.sessionID// fallback}returnt.broker.Invoke(ctx,t.tenantID,sessionID,model.ToolCall{Name:t.schema.Name,Arguments:args,})}注意 sessionID 的取法:先从 context 取,取不到才用 struct 字段。这是因为 Runnable(包含工具适配器的 Eino 图)按 AgentConfig 缓存,同一个图可能被多个 session 复用。sessionID 必须从每次请求的 context 里动态读取,不能绑在 struct 上。
Eino 负责工具的编排(按 LLM 输出决定调哪个),tool-broker 负责工具的执行(沙箱隔离、审计记录、Hook 链)。两者各管一块。
小结
工具调用这层设计的重点在四个地方:
- Danger 三态:在注册阶段就标记危险等级(safe/caution/high),hook 系统按等级决策
- Impl 四态:builtin/wasm/http/mcp 统一
Runner接口(Kind()+Run()),agent 不知道执行细节 - Invocation 记录:每次调用留档,
CallID串联 LLM 决策和实际执行,审计失败不阻塞结果返回 - 环境驱动注册:内置工具按环境变量决定是否注册,没配就不注册(fail-closed)
下一篇:多 LLM Provider —— 不改一行业务代码换模型