ToolsNode 源码拆解:工具并行执行、中间件洋葱与 HITL Rerun(第65篇-E51)

系列「企业级 AI Agent 实现拆解」E51 篇,Part 10 生产工程篇第九章。上一篇 讲了 ReAct Agent 的 Graph 结构,它用到了 ToolsNode 但没展开。这篇把 ToolsNode 拆到底:工具注册表、4 种工具类型、并行调度、中间件洋葱、HITL Rerun 和幻觉工具兜底。

读完这篇你会知道

  • toolsTuple:ToolsNode 内部的工具注册表是什么结构
  • 4 种工具类型:Invokable / Streamable / EnhancedInvokable / EnhancedStreamable 的区别
  • 为什么只实现一种接口就够了:自动互转逻辑
  • parallelRunToolCall:LLM 同时调多个工具时并行执行的实现
  • 中间件洋葱:wrapToolCall 如何倒序串联 middleware
  • ToolsInterruptAndRerunExtra:HITL 中断工具调用后如何断点续跑
  • UnknownToolHandler:LLM 幻觉出不存在的工具时怎么兜底
  • ToolAliasConfig:工具名别名 + 参数别名

ToolsNode 解决的问题

LLM 的工具调用(Tool Call)是 JSON 结构:

json 复制代码
{
  "role": "assistant",
  "tool_calls": [
    {"id": "call_1", "function": {"name": "search", "arguments": "{\"query\": \"Eino star\"}"}},
    {"id": "call_2", "function": {"name": "calc", "arguments": "{\"expr\": \"1+1\"}"}}
  ]
}

ToolsNode 的工作:

  1. tool_calls 数组里提取每个工具调用
  2. 根据 name 找到对应的工具
  3. 并行执行(默认)
  4. 把结果包装成 schema.ToolMessage 返回

toolsTuple:工具注册表

NewToolNode 构建时,把所有工具预先注册进 toolsTuple

go 复制代码
type toolsTuple struct {
    indexes                     map[string]int  // 工具名 → 下标索引
    meta                        []*executorMeta
    endpoints                   []InvokableToolEndpoint
    streamEndpoints             []StreamableToolEndpoint
    enhancedInvokableEndpoints  []EnhancedInvokableToolEndpoint
    enhancedStreamableEndpoints []EnhancedStreamableToolEndpoint
    argsAliasMap   map[string]map[string]string  // 工具名 → (别名 → 正式参数名)
    canonicalNames []string                       // 每个下标对应的正式工具名
    toolInfos      []*schema.ToolInfo
}

indexes 是 O(1) 查找用的------LLM 输出 "name": "search",直接 tuple.indexes["search"] 拿到下标,再从下标数组里取对应的执行端点。比 map 每次分配更高效。


4 种工具类型

Eino 工具有 4 种接口,覆盖非流式 × 流式 × 普通 × 增强:

scss 复制代码
                非流式                    流式
普通   InvokableTool              StreamableTool
       InvokableRun(json) string   StreamableRun(json) StreamReader[string]

增强   EnhancedInvokableTool      EnhancedStreamableTool
       InvokableRun(ToolArgument)  StreamableRun(ToolArgument)
       *ToolResult                 StreamReader[*ToolResult]

普通 vs 增强(Enhanced)

  • 普通:工具返回 string,即文本结果
  • 增强:工具返回 *schema.ToolResult,可以是多模态内容(文字 + 图片 + 文件等)

非流式 vs 流式

  • 非流式:工具执行完才返回完整结果
  • 流式:工具边执行边返回(适合搜索结果实时显示、代码执行进度等)

自动互转:只实现一种就够了

convTools 里有一个关键设计------4 种类型可以互转,工具只需要实现其中一种:

go 复制代码
// 流式 → 非流式(把流读完拼在一起)
func streamableToInvokable(e StreamableToolEndpoint) InvokableToolEndpoint {
    return func(ctx context.Context, input *ToolInput) (*ToolOutput, error) {
        so, err := e(ctx, input)
        if err != nil { return nil, err }
        o, err := concatStreamReader(so.Result)  // 读完整个流
        return &ToolOutput{Result: o}, nil
    }
}

// 非流式 → 流式(把结果包成单元素流)
func invokableToStreamable(e InvokableToolEndpoint) StreamableToolEndpoint {
    return func(ctx context.Context, input *ToolInput) (*StreamToolOutput, error) {
        o, err := e(ctx, input)
        if err != nil { return nil, err }
        return &StreamToolOutput{
            Result: schema.StreamReaderFromArray([]string{o.Result}),
        }, nil
    }
}
go 复制代码
// convTools 里的处理
if streamable == nil && invokable != nil {
    streamable = invokableToStreamable(invokable)  // 自动补流式
}
if invokable == nil && streamable != nil {
    invokable = streamableToInvokable(streamable)  // 自动补非流式
}

含义 :Graph 在非流式模式运行时会调 Invoke,流式模式运行时会调 Stream。两种模式都必须支持。但工具作者只需实现一种,框架自动派生另一种。


中间件洋葱:倒序串联

wrapToolCall 把中间件链串成洋葱结构:

go 复制代码
func wrapToolCall(it tool.InvokableTool, middlewares []InvokableToolMiddleware, needCallback bool) InvokableToolEndpoint {
    middleware := func(next InvokableToolEndpoint) InvokableToolEndpoint {
        for i := len(middlewares) - 1; i >= 0; i-- {  // 倒序!
            next = middlewares[i](next)
        }
        return next
    }

    if needCallback {
        it = &invokableToolWithCallback{it: it}  // 自动注入 Callback
    }

    // 最内层:真正调用工具
    return middleware(func(ctx context.Context, input *ToolInput) (*ToolOutput, error) {
        result, err := it.InvokableRun(ctx, input.Arguments, input.CallOptions...)
        return &ToolOutput{Result: result}, err
    })
}

为什么倒序 :middleware 数组是 [m1, m2, m3],调用顺序应该是 m1 → m2 → m3 → tool → m3 → m2 → m1(洋葱)。倒序遍历 wrap 之后,执行顺序就是正序。

less 复制代码
middlewares = [toolResultCollector, rateLimiter, logger]

wrapToolCall 执行后:
toolResultCollector( rateLimiter( logger( actualTool ) ) )

调用时:
toolResultCollector → rateLimiter → logger → actualTool → logger → rateLimiter → toolResultCollector

E50 里 newToolResultCollectorMiddleware 被塞在数组最前面,所以它是最外层的洋葱,先执行,也最后完成------能在工具执行完后立刻把结果推给 SSE sender。


并行执行:parallelRunToolCall

LLM 一次可能调多个工具,默认并行执行:

go 复制代码
func parallelRunToolCall(ctx context.Context,
    run func(ctx2 context.Context, callTask *toolCallTask, opts ...tool.Option),
    tasks []toolCallTask, opts ...tool.Option) {

    if len(tasks) == 1 {
        run(ctx, &tasks[0], opts...)  // 单工具:不开 goroutine,省调度
        return
    }

    var wg sync.WaitGroup
    for i := 1; i < len(tasks); i++ {  // tasks[1:] 开 goroutine
        if tasks[i].executed { continue }
        wg.Add(1)
        go func(ctx_ context.Context, t *toolCallTask, opts ...tool.Option) {
            defer wg.Done()
            defer func() {
                panicErr := recover()
                if panicErr != nil {
                    t.err = safe.NewPanicErr(panicErr, debug.Stack())
                }
            }()
            run(ctx_, t, opts...)
        }(ctx, &tasks[i], opts...)
    }

    if !tasks[0].executed {
        run(ctx, &tasks[0], opts...)  // tasks[0] 在当前 goroutine 执行,不开新的
    }

    wg.Wait()
}

几个细节:

  1. tasks0 在主 goroutine 执行:避免多开一个 goroutine,让主 goroutine 也干活
  2. panic 保护 :每个 goroutine 都有 recover(),单个工具 panic 不会崩整个 Agent
  3. executed 跳过:HITL Rerun 时已经跑过的工具不再重跑

sequentialRunToolCall

go 复制代码
func sequentialRunToolCall(...) {
    for i := range tasks {
        if tasks[i].executed { continue }
        run(ctx, &tasks[i], opts...)
    }
}

顺序版本,不开 goroutine------ExecuteSequentially: true 时使用。适合工具之间有先后依赖的场景。


genToolCallTasks:任务生成

从 LLM 的 tool_calls 列表生成 toolCallTask 数组:

go 复制代码
for i := 0; i < n; i++ {
    toolCall := input.ToolCalls[i]

    // 1. HITL rerun:已执行的工具直接用缓存结果,不再执行
    if result, executed := executedTools[toolCall.ID]; executed {
        tasks[i].executed = true
        tasks[i].output = result
        continue
    }

    // 2. 工具名查找
    index, ok := tuple.indexes[toolCall.Function.Name]
    if !ok {
        if tn.unknownToolHandler == nil {
            return nil, fmt.Errorf("tool %s not found", toolCall.Function.Name)
        }
        // 3. 幻觉工具:调用 UnknownToolHandler
        tasks[i] = newUnknownToolTask(name, arg, callID, tn.unknownToolHandler)
        continue
    }

    // 4. 正常路径
    // 参数别名替换
    if aliasMap, hasAliases := tuple.argsAliasMap[canonicalToolName]; hasAliases {
        args, _ = remapArgs(args, aliasMap)
    }
    // 参数预处理
    if tn.toolArgumentsHandler != nil {
        args, _ = tn.toolArgumentsHandler(ctx, toolName, args)
    }

    tasks[i] = toolCallTask{endpoint: ..., arg: args, ...}
}

注意执行顺序 :参数别名替换 → ToolArgumentsHandler → 工具执行。这让 JSON 修复、Prompt Injection 过滤都可以通过 ToolArgumentsHandler 注入。


HITL Rerun:断点续跑

当工具调用被 HITL 中断(需要人工审批),恢复后不能重跑所有工具------已经成功执行的工具结果要保留:

go 复制代码
type ToolsInterruptAndRerunExtra struct {
    ToolCalls     []schema.ToolCall         // 所有工具调用(原始)
    ExecutedTools map[string]string          // 已成功执行的工具 → 结果
    RerunTools    []string                   // 需要重跑的工具 ID
    RerunExtraMap map[string]any             // 每个重跑工具的额外元数据
}

type toolsInterruptAndRerunState struct {
    Input         *schema.Message
    ExecutedTools map[string]string
    RerunTools    []string
}

工作流:

  1. 某个工具调用 WrapInterruptAndRerunIfNeeded(触发 HITL)
  2. 已成功执行的工具结果存进 ExecutedTools
  3. 触发 HITL 的工具 ID 存进 RerunTools
  4. HITL 审批通过后,Graph 恢复,GetInterruptState 读出 state
  5. genToolCallTasks 看到 executedTools,已跑的直接用缓存,待跑的重新执行
go 复制代码
// Invoke 开头
if wasInterrupted, hasState, tnState := GetInterruptState[*toolsInterruptAndRerunState](ctx); wasInterrupted && hasState {
    input = tnState.Input              // 用原始 input,不用 Agent 新传来的
    executedTools = tnState.ExecutedTools  // 已执行工具的缓存结果
}

UnknownToolHandler:幻觉工具兜底

LLM 偶尔会幻觉出不在工具列表里的工具名(尤其是微调模型或长对话时)。默认行为是报错;配置 UnknownToolsHandler 后,可以优雅处理:

go 复制代码
toolsNode, _ := compose.NewToolNode(ctx, &compose.ToolsNodeConfig{
    Tools: []tool.BaseTool{searchTool, calcTool},
    UnknownToolsHandler: func(ctx context.Context, name, input string) (string, error) {
        // 返回一个引导 LLM 纠正的提示
        return fmt.Sprintf("Tool '%s' does not exist. Available tools: search, calc.", name), nil
    },
})

newUnknownToolTask 把 handler 包装成和普通工具相同的 toolCallTask 结构,之后的并行执行逻辑完全复用,不需要特殊处理。


ToolAliasConfig:名称和参数别名

ToolAliasConfig 解决两个实际问题:

工具名别名

LLM 有时会输出 "search" 而你的工具名是 "web_search"

go 复制代码
ToolAliases: map[string]compose.ToolAliasConfig{
    "web_search": {
        NameAliases: []string{"search", "websearch"},  // 这些名字都映射到 web_search
    },
}

indexes map 里 "search" → web_search 的下标,工具名统一到正式名后执行。

参数别名

不同模型输出的参数名可能不一样:

go 复制代码
"web_search": {
    ArgumentsAliases: map[string][]string{
        "query":  []string{"q", "search_term", "keyword"},   // q/search_term → query
        "limit":  []string{"max_results", "count", "n"},     // max_results → limit
    },
}

remapArgs 在工具执行前把 JSON 里的别名 key 替换成正式 key:

go 复制代码
func remapArgs(args string, aliasMap map[string]string) (string, error) {
    // aliasMap 是 alias→canonical(反向映射)
    var m map[string]json.RawMessage
    sonic.Unmarshal([]byte(args), &m)

    for alias, canonical := range aliasMap {
        if v, ok := m[alias]; ok {
            if _, exists := m[canonical]; !exists {  // canonical 不存在才替换
                m[canonical] = v
                delete(m, alias)
            }
        }
    }
    return string(sonic.Marshal(m))
}

如果 canonical 和 alias 同时出现({"q": "a", "query": "b"}),canonical 优先,alias 保留原样------不会用 alias 覆盖已有值。


小结

ToolsNode 的核心是"并行安全的工具执行器":

组件 职责
toolsTuple 工具注册表:名字 → 下标 → 4种 endpoint,O(1) 查找
convTools 自动互转:只实现一种接口即可,框架派生其他 3 种
wrapToolCall 中间件洋葱:倒序串联,最外层先执行最后完成
parallelRunToolCall 并行执行:tasks0 在主 goroutine,其余开新 goroutine + panic 保护
genToolCallTasks 任务生成:参数别名替换 + ToolArgumentsHandler + HITL rerun 缓存跳过
ToolsInterruptAndRerunExtra HITL 断点:记录已执行工具结果,恢复后不重跑
UnknownToolHandler 幻觉兜底:LLM 幻觉工具时不报错,返回引导提示
ToolAliasConfig 工具名/参数别名:兼容不同模型输出风格

代码来源:eino/compose/tool_node.go

相关推荐
库拉大叔1 小时前
写小说谁家强?GPT-5.6、Claude 4.8 Opus、Gemini 3.5文学素养硬核横评
人工智能·gpt·aigc
李燚1 小时前
ReAct Agent 源码拆解:Eino 如何把 Graph 变成 Agent(第64篇-E50)
ai·agent·react·graph·multi-agent·aiagent·eino
CoovallyAIHub1 小时前
当制造业遇上 AI 智能体:Coco 把工艺知识留在了工厂里
llm·agent
东小西1 小时前
第10篇:《老板说“查一下这个月谁业绩最好”,我让AI自己写了SQL》
openai·ai编程
武子康1 小时前
从 OpenAI 披露的自主 Agent 越界事件看:高能力模型评估为什么需要一份可验证的 Containment Contract
人工智能·openai·agent
李剑一1 小时前
Kimi K3太牛了!但老板说API太贵,让我本地化部署,我算完成本,他涨红脸沉默不语了!其实成本不高,三千万足矣。
aigc·ai编程
洛卡卡了2 小时前
从 vibe coding 到 spec coding:我用 Trellis 的实践总结
人工智能·后端·agent
9i编程2 小时前
AI Agent开发实录02:Java程序员用Spring AI Alibaba 1.1.2.0从0到1手搓生产级 AI Agent —— 串起完整 Graph
openai·ai编程
赫媒派2 小时前
Postgres LN 扩展性优化:2.9K 到 60K/秒
ai编程