系列「企业级 AI Agent 实现拆解」第七篇。上一篇讲了 Hook 系统,这篇看工具调用的完整设计。
工具是什么
LLM 本质上只能做一件事:根据输入文本预测输出文本。它没有手,没有眼,没办法查数据库、发邮件、调接口。工具调用(Tool Use)给了 Agent 这些能力------LLM 输出一个结构化的"我想调这个工具、传这些参数",系统真正执行,把结果塞回给 LLM,LLM 再决定下一步。
工具注册表(tool-broker BC)是这个系统的中心:所有工具都在这里注册、管理、调度。Agent 不直接知道工具的实现细节,只通过 gRPC 调 ToolBroker.Invoke()。
一次工具调用的完整流程
scss
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(实现类型)**决定工具怎么执行:
go
type Impl string
const (
ImplBuiltin Impl = "builtin" // 同进程 Go 函数(kb.search、memory.set)
ImplWASM Impl = "wasm" // 租户上传 WASM,gVisor 沙箱隔离
ImplHTTP Impl = "http" // 远程 HTTP API,走 Connector 管 secret
ImplMCP Impl = "mcp" // MCP 协议工具
)
**Danger(危险等级)**决定是否触发 PreToolUse hook:
go
type Danger string
const (
DangerSafe Danger = "safe" // 纯读,不需审批(kb.search)
DangerCaution Danger = "caution" // 写但可回滚(memory.set)
DangerHigh Danger = "high" // 不可逆/对外副作用(sql.exec、email.send)
)
DangerHigh 的工具,PreToolUse hook 会评估是否需要 HITL。评估逻辑写在 hook 配置里,不写在工具代码里------不同租户对"高危"的定义可以不同。
工具名必须带命名空间
go
func NewTool(tenantID, name, desc string, ...) (*Tool, error) {
if !strings.Contains(name, ".") {
return nil, errors.New("name must use namespace.method format (e.g. kb.search)")
}
...
}
工具名强制 namespace.method 格式,原因有两个:
- 避免命名冲突------不同租户的自定义工具和平台内置工具混在一起,命名空间做天然隔离
- PreToolUse hook 的 matcher 支持通配符------
{"tool_name": "sql.*"}能匹配所有 SQL 类工具,不需要为每个工具单独写规则
调用记录:每次都留档
每次工具调用都写一条 Invocation 记录:
go
type Invocation struct {
ID string
ToolName string
TenantID string
SessionID string
CallID string // 关联 agent ToolCall.ID
Args string // JSON
Result string // JSON,>16k 走 MinIO + url
Error string
LatencyMs int64
StartedAt 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、摘要)。
内部逻辑是两步走:
ini
第 1 步:调 Wikipedia search API,拿到标题列表(TopK=3,默认返回 3 条)
第 2 步:对每个标题调 page API,拿正文摘要(截断到 2000 字符)
为什么分两步?因为 Wikipedia 的搜索 API 只返回标题和摘要片段,不返回完整正文。先搜到标题,再逐个取详情,是最稳的做法。
关键代码(简化):
go
func BuildWikipediaSearch(cfg WikipediaConfig) HandlerFn {
return func(ctx context.Context, raw string) (string, error) {
// 1. 解析入参
var req struct{ Query string `json:"query"` }
json.Unmarshal([]byte(raw), &req)
// 2. 搜索:拿标题列表
titles, _ := search(ctx, req.Query)
// 3. 逐个取详情
results := []wikipediaResult{}
for _, title := range titles {
extract, url, _ := getPage(ctx, title)
results = append(results, wikipediaResult{Title: title, URL: url, Extract: extract})
}
// 4. 返回 JSON
out, _ := json.Marshal(map[string]any{"results": results})
return string(out), nil
}
}
设计亮点:
WikipediaConfig有合理默认值(语言 en、TopK 3、超时 15s、截断 2000 字符),零值配置就能用- 纯标准库
net/http,没有外部依赖 - 结果截断到
DocMaxChars,防止超长摘要吃掉 LLM 的上下文窗口
案例二:osm.geocode --- 地理编码
这也是一个 safe 级别的只读工具。输入地名(比如"北京天安门"),返回经纬度、完整地址、类型、重要性评分。
调用方式: Agent 传 {"query": "天安门广场"},工具返回匹配的地理信息列表。
关键代码(简化):
go
func BuildOSMGeocode(cfg OSMGeocodeConfig) HandlerFn {
return func(ctx context.Context, raw string) (string, error) {
// 1. 解析入参
var req struct {
Query string `json:"query"`
Limit int `json:"limit,omitempty"`
}
json.Unmarshal([]byte(raw), &req)
// 2. 限制结果数量(防滥用)
limit := req.Limit
if limit <= 0 { limit = 5 } // 默认 5 条
if limit > 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})
return string(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 默认安全):
go
// register.go --- 内置工具注册
func RegisterStdBuiltins(r *BuiltinRunner, d StdDeps) []string {
if d.SQL != nil {
r.Register("sql.exec", BuildSQLExec(d.SQL)) // high · 执行 SQL
}
if d.FS != nil {
r.Register("file.read", BuildFileRead(d.FS, ...)) // safe · 读文件
}
if d.Shell != nil {
r.Register("shell.run", BuildShellRun(d.Shell, ...)) // high · 执行 Shell
}
if d.Calendar != nil {
r.Register("calendar.list", BuildCalendarList(...)) // safe · 日历查询
}
if d.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 调用包成 Eino BaseTool,让 Eino 的 ReAct 图能直接调度:
go
// tool.go --- toolBrokerAdapter
func (t *toolBrokerAdapter) InvokableRun(ctx context.Context, args string, ...) (string, error) {
// 从 context 取 sessionID(优先于 struct 字段)
// 原因:Runnable 按 AgentConfig 缓存,跨 session 复用
// struct 里的 sessionID 可能是第一次创建时的旧值
sessionID, _ := ctx.Value(port.ContextKeyAgentSessionID{}).(string)
if sessionID == "" {
sessionID = t.sessionID // fallback
}
return t.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 ------ 不改一行业务代码换模型