工具调用:Agent 的手和眼

系列「企业级 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 格式,原因有两个:

  1. 避免命名冲突------不同租户的自定义工具和平台内置工具混在一起,命名空间做天然隔离
  2. 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.execshell.runDangerHigh,每次调用都会触发 PreToolUse hook 评估。每个内置工具都支持配置限制参数(白名单目录、白名单命令、最大长度等),防止工具被滥用。

kb.searchmemory.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 链)。两者各管一块。


小结

工具调用这层设计的重点在四个地方:

  1. Danger 三态:在注册阶段就标记危险等级(safe/caution/high),hook 系统按等级决策
  2. Impl 四态 :builtin/wasm/http/mcp 统一 Runner 接口(Kind() + Run()),agent 不知道执行细节
  3. Invocation 记录 :每次调用留档,CallID 串联 LLM 决策和实际执行,审计失败不阻塞结果返回
  4. 环境驱动注册:内置工具按环境变量决定是否注册,没配就不注册(fail-closed)

下一篇:多 LLM Provider ------ 不改一行业务代码换模型

相关推荐
leeyi2 小时前
多 LLM Provider:不改一行业务代码换模型
llm·agent
leeyi2 小时前
SSE 实时推流 —— Token 怎么一个个蹦出来
后端·agent
凌奕2 小时前
微信小程序接入微信 AI:让用户"说一句话"就能下单
微信·微信小程序·agent
leeyi2 小时前
Hook 系统:插件化安全护栏怎么设计
llm·agent
Nicander2 小时前
去除中文写作AI味的Skill:write-like-human-zh
agent
leeyi2 小时前
ReAct 循环的 50 行 Go 实现,逐行拆解
后端·agent
leeyi2 小时前
HITL:让人类随时叫停 AI,并且能优雅地继续
后端·agent
hixiong1232 小时前
C# Tokenizers.DotNet测试工具
开发语言·人工智能·llm
AKAMAI2 小时前
当OpenClaw遇见Linode:一键部署7×24h云端AI助理
云计算·agent