系列「企业级 AI Agent 实现拆解」E51 篇,Part 10 生产工程篇第九章。上一篇 讲了 ReAct Agent 的 Graph 结构,它用到了 ToolsNode 但没展开。这篇把 ToolsNode 拆到底:工具注册表、4 种工具类型、并行调度、中间件洋葱、HITL Rerun 和幻觉工具兜底。
读完这篇你会知道
toolsTuple:ToolsNode 内部的工具注册表是什么结构- 4 种工具类型:Invokable / Streamable / EnhancedInvokable / EnhancedStreamable 的区别
- 为什么只实现一种接口就够了:自动互转逻辑
parallelRunToolCall:LLM 同时调多个工具时并行执行的实现- 中间件洋葱:
wrapToolCall如何倒序串联 middlewareToolsInterruptAndRerunExtra: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 的工作:
- 从
tool_calls数组里提取每个工具调用 - 根据
name找到对应的工具 - 并行执行(默认)
- 把结果包装成
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()
}
几个细节:
- tasks0 在主 goroutine 执行:避免多开一个 goroutine,让主 goroutine 也干活
- panic 保护 :每个 goroutine 都有
recover(),单个工具 panic 不会崩整个 Agent 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
}
工作流:
- 某个工具调用
WrapInterruptAndRerunIfNeeded(触发 HITL) - 已成功执行的工具结果存进
ExecutedTools - 触发 HITL 的工具 ID 存进
RerunTools - HITL 审批通过后,Graph 恢复,
GetInterruptState读出 state 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 |
工具名/参数别名:兼容不同模型输出风格 |