跳到主要内容

工具与注册表

在 ai4j-agent 里,工具体系真正解决的不是“函数怎么暴露给模型”,而是四个边界如何拆开:

  • 模型能看见什么工具
  • 宿主允许执行什么工具
  • 哪一层负责结构校验,哪一层负责审批和拦截
  • 工具结果如何重新进入 Agent loop

如果这四件事没有拆开,最终结果通常是:

  • schema 暴露和权限审批混在一起
  • 不同 runtime 各自实现一套工具治理
  • 工具失败后如何继续推理没有统一语义

ai4j-agent 的设计选择非常明确:

  • AgentToolRegistry 负责暴露面
  • ToolExecutor 负责执行面
  • BaseAgentRuntime 负责把工具调用纳入主循环

1. 最短对象图:先把角色看清​

源码入口:

  • ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolRegistry.java
  • ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/ToolExecutor.java
  • ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentBuilder.java
  • ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/BaseAgentRuntime.java

最小对象关系可以压缩成一条链:

AgentBuilder
-> AgentToolRegistry
-> ToolExecutor
-> AgentContext
-> BaseAgentRuntime.runInternal()
-> AgentToolCallSanitizer
-> ToolExecutor.execute(call)
-> AgentMemory.addToolOutput(callId, output)

这个拆分非常重要,因为它让“可见工具面”和“实际执行权”不再是同一个对象的隐式副作用。

2. AgentToolRegistry 只负责一件事:给模型看 schema​

AgentToolRegistry 接口很窄:

public interface AgentToolRegistry {
List<Object> getTools();
}

它不执行工具,也不判断权限。它只回答:

  • 当前 Agent 打算把哪些工具 schema 传给模型

常见实现包括:

  • StaticToolRegistry
  • CompositeToolRegistry
  • ToolUtilRegistry
  • AgentTeamToolRegistry

这里的核心设计原则是:模型看到什么,不等于系统一定允许执行什么。

3. ToolExecutor 才是执行边界,也是权限边界​

ToolExecutor 同样很窄:

public interface ToolExecutor {
String execute(AgentToolCall call) throws Exception;
}

它解决的是另一个问题:

  • 当模型真的发起工具调用时,系统怎么执行

这也是做以下治理的最佳位置:

  • 权限审批
  • allow-list / deny-list
  • 参数重写
  • 审计日志
  • 远程代理
  • 沙箱执行
  • 重试和超时控制

如果你要解释“ai4j 的工具权限审批是怎么拦截的”,最准确的答案是:

  • 普通 Agent 工具链的稳定拦截点在 ToolExecutor.execute(...)
  • 不是 AgentToolCallSanitizer
  • 也不是某个通用 hook

3.1 RAG-as-tool 的多租户 filter 必须固定在服务端​

如果把知识库检索暴露成 RagTool,模型可见的 tool schema 应只包含用户查询,例如:

{"query": "用户问题"}

租户、部门、项目、权限范围这类 filter 不应该放进 tool input schema,让模型自己传:

{"query": "用户问题", "filter": {"tenant": "tenant_a"}}

正确做法是在宿主侧构造 tool 时固定绑定:

Map<String, Object> tenantFilter = new HashMap<String, Object>();
tenantFilter.put("tenant", tenantIdFromSession);

RagTool ragTool = RagTool.builder(ragService)
.dataset("shared-kb")
.embeddingModel("text-embedding")
.topK(5)
.filter(tenantFilter)
.build();
租户 filter 必须固定在服务端

这样 LLM 只负责提出检索 query,真正的租户边界由服务端执行器写入 RagQuery.filter。 这和普通工具权限一样,属于执行边界,不属于模型可协商的参数。

4. 默认 Builder 装配路径比看起来更具体​

AgentBuilder.build() 的默认逻辑不是“自动帮你把工具接好”,而是一条明确的决策链。

4.1 你没有传 toolRegistry(...)​

默认走:

StaticToolRegistry.empty()

这意味着模型看不到任何工具。

4.2 你用了便捷方法 toolRegistry(List<String> functions, List<String> mcpServices)​

Builder 会创建:

  • ToolUtilRegistry

而 ToolUtilRegistry 背后再通过 ToolUtil.getAllTools(functionList, mcpServerIds) 合并:

  • 本地函数工具
  • MCP 服务工具

也就是说,MCP 在这里不是“tools 的一个子类页面问题”,而是统一工具暴露面的一种来源。

4.3 你没有显式传 toolExecutor(...)​

Builder 会先从“基础 registry”里提取工具名,再尝试创建 ToolUtilExecutor。

这一步有两个重要后果:

  • 默认执行器只允许执行 registry 已经暴露的工具名
  • 如果基础 registry 里解析不出工具名,默认执行器可能是 null

第二点非常容易踩坑。因为 resolveToolNames(...) 只会从 Tool 类型对象里抽名字;如果你提供的是自定义 schema 对象,又没有自己传 ToolExecutor,Builder 并不会神奇地知道怎么执行。

4.4 subagent 是怎么接进来的​

当配置了 subagent registry 后,Builder 会:

  • 用 CompositeToolRegistry 合并原始工具面和 subagent tool surface
  • 用 SubAgentToolExecutor 包装原执行器

这说明 subagent 治理不是注册表层面的例外,而是执行器侧的专门包装器。

5. runtime 如何消费工具调用​

主链在 BaseAgentRuntime.runInternal() 里。

5.1 模型先返回 toolCalls​

模型客户端把响应折叠成 AgentModelResult,其中可包含:

  • outputText
  • memoryItems
  • toolCalls

5.2 runtime 先做归一化​

normalizeToolCalls(...) 会补齐缺失的 callId,格式默认是:

  • tool_step_<step>_<index>

这一步的意义不是好看,而是保证工具结果回写 memory 时有稳定引用。

5.3 再做结构校验​

AgentToolCallSanitizer.validationError(...) 只做结构合法性验证,例如:

  • 工具名不能为空
  • arguments 必须是 JSON object
  • bash 不同 action 的必要字段是否存在
  • read_file.path 是否为空
  • apply_patch.patch 是否为空

它解决的是“这个调用像不像一个可执行调用”,不是“你允不允许它执行”。

5.4 然后进入执行器​

通过校验的调用最终统一进入:

toolExecutor.execute(call)

工具异常不会默认把整个 Agent 打崩。BaseAgentRuntime.executeTool(...) 会把异常包成:

  • TOOL_ERROR: {"errorType":"...","error":"...","tool":"...","callId":"..."}

默认不带 stack trace。然后继续把这个错误结果回灌给 memory 和后续轮次。这是典型的 “模型可恢复失败语义”。

5.5 最后重新进入 memory​

不管是正常结果还是包装后的错误结果,都会调用:

memory.addToolOutput(callId, output)

工具执行因此不是 loop 外围动作,而是 loop 自身的一部分。

6. 审批、拦截、Hook 到底该放哪层​

这是最容易被写模糊的地方。

6.1 普通 Agent 的正确拦截点​

如果你要做审批或访问控制,优先包在执行器外层:

ToolExecutor guarded = call -> {
approvalService.check(call.getName(), call.getArguments());
auditService.record(call);
return delegate.execute(call);
};

这层的优势非常直接:

  • 已经拿到规范化后的工具名
  • 已经拿到结构合法的参数 JSON
  • 仍然处在统一 Agent loop 中,错误、审计、trace、memory 回写都不会失真

6.2 为什么不放在 AgentToolCallSanitizer​

因为 sanitizer 的职责太窄。把业务授权逻辑塞进去会造成:

  • 结构错误和权限拒绝混成一类错误
  • 不同执行器无法共享同一套授权逻辑
  • 代码层次错位,后续更难扩展

6.3 为什么不是靠一个通用 hook​

当前 ai4j-agent 并没有为普通 Agent 提供统一的“工具审批 hook”抽象。

有审批/策略概念的是:

  • Team 层的 planApproval 和 hooks
  • SubAgent 层的 HandoffPolicy

而普通 Agent 的工具治理,本质上就是执行器包装问题。

7. SubAgentToolExecutor 和 AgentTeamToolExecutor 说明了什么​

这两个类非常能代表 ai4j 的工具设计哲学。

7.1 SubAgentToolExecutor​

它不是把 handoff 逻辑塞进 registry,而是在执行期做更强治理,例如:

  • allowedTools
  • deniedTools
  • maxDepth
  • timeoutMillis
  • inputFilter
  • onDenied
  • onError

也就是说,subagent 并没有推翻“registry 负责暴露、executor 负责治理”的边界。

7.2 AgentTeamToolExecutor​

它只拦截 team_* 工具:

  • team_send_message
  • team_broadcast
  • team_list_tasks
  • team_claim_task
  • team_release_task
  • team_reassign_task
  • team_heartbeat_task

其他工具会直接委托给原始执行器。如果没有 delegate,而成员又调用了非 team 工具,它会直接抛错。

这再次证明:

  • Team 工具不是独立 runtime 魔法
  • 而是统一工具执行链上的一层包装

8. parallelToolCalls 的真实含义​

BaseAgentRuntime.runInternal() 会在满足两个条件时并行执行工具:

  • context.getParallelToolCalls() == true
  • 当前轮合法 tool call 数量大于 1

这会对执行器提出一个硬要求:

  • 你的 ToolExecutor 必须线程安全

如果执行器内部复用可变状态、共享临时文件或依赖单线程顺序,那么一开并行就会出问题。这个问题通常不是模型层报错,而是工具层 race condition。

8.1 并发上限与单调用超时​

并行派发由两个可选旋钮控制(Blueprint $.model.options 同名可用,也接受 max_parallel_tool_calls / tool_call_timeout_millis 蛇形键):

Agent agent = Agents.react()
.modelClient(modelClient)
.model("gpt-4.1")
.parallelToolCalls(true)
.maxParallelToolCalls(4) // 同一轮最多并发 4 个工具执行
.toolCallTimeoutMillis(30_000L) // 单个工具 30s 未返回即记为 FAILED
.build();
  • maxParallelToolCalls:每轮并发池大小收紧为 min(本轮 call 数, cap);null 或 <= 0 保持历史行为(每个 call 一个线程)。
  • toolCallTimeoutMillis:超时 call 被 cancel(true) 中断并记录为 FAILED 结果(ok=false,output 为 TOOL_ERROR JSON),不影响同批其它 call——与工具自身抛异常的语义一致。null 或 <= 0 不启用。
  • 结果仍按模型返回的 call 顺序写回 memory,事件契约顺序不变。
  • 派发池 worker 是 daemon 线程(ai4j-tool-exec-N),不会阻止 JVM 退出。

9. 典型接入方式​

9.1 用 ToolUtil + MCP 快速拼出统一工具面​

Agent agent = Agents.react()
.modelClient(modelClient)
.model("gpt-4.1")
.toolRegistry(
java.util.Arrays.asList("queryWeather", "read_file"),
java.util.Arrays.asList("github", "filesystem")
)
.build();

适合:

  • 工具已经在 ToolUtil 或 MCP 服务注册好
  • 只想给当前 Agent 暴露最小白名单

9.2 自定义执行器做审批和审计​

ToolExecutor guardedExecutor = call -> {
approvalService.requireApproved(call.getName(), call.getArguments());
auditService.record(call);
return ToolUtil.invoke(call.getName(), call.getArguments());
};

Agent agent = Agents.builder()
.modelClient(modelClient)
.toolRegistry(java.util.Arrays.asList("queryWeather"), null)
.toolExecutor(guardedExecutor)
.build();

适合:

  • 权限审批不想散落在各个工具函数内部
  • 想把拒绝、审计、失败信息统一纳入 loop

9.3 schema 和执行完全分离​

AgentToolRegistry registry = new StaticToolRegistry(myToolSchemas);

ToolExecutor executor = call -> gateway.execute(call);

Agent agent = Agents.builder()
.modelClient(modelClient)
.toolRegistry(registry)
.toolExecutor(executor)
.build();

适合:

  • schema 来源不是 ToolUtil
  • 真正执行要走远程网关、沙箱或代理进程

10. 失败边界和容易误判的点​

10.1 工具结果默认是字符串语义​

ToolExecutor.execute(...) 返回值是 String。复杂对象最终都要被你自己序列化成字符串,再让模型消费。

10.2 默认执行器只对默认工具体系友好​

如果你用自定义 registry,但没有显式提供 executor,Builder 很可能帮不了你。因为默认执行器的创建逻辑假设自己能理解 registry 里的工具对象。

10.3 工具白名单只约束默认执行器​

ToolUtilExecutor 会校验 allowedToolNames,但只对它自己生效。你换成自定义执行器后,白名单、黑名单、审批全部由你负责。

10.4 工具失败默认不终止 Agent​

这通常是正确的,因为模型还能根据 TOOL_ERROR 决定重试、改参或换路线。但如果你的 业务要求“某些工具失败必须立即中止”,那就应该在执行器里显式实现,而不是假设 runtime 会替你做。默认错误 payload 只有 errorType、error、tool、callId,没有 stack trace。

11. 调试优先看这些入口​

出现“模型能看到工具但调不起来”“审批逻辑失效”“工具返回了但下一轮没用上”时,先看:

  • AgentBuilder.build() 最终生成的 toolRegistry 和 toolExecutor 是什么
  • AgentToolCallSanitizer.validationError(...) 是否把调用拦成了结构错误
  • ToolExecutor.execute(...) 是否真的执行到了目标逻辑
  • BaseAgentRuntime.executeTool(...) 是否把异常包装成了 TOOL_ERROR
  • memory.addToolOutput(...) 是否拿到了稳定的 callId

这几处比看最终自然语言输出更接近根因。

12. 继续阅读​

  1. 记忆与状态
  2. Minimal ReAct Agent
  3. Subagent Handoff Policy
  4. Agent 团队
  5. Trace Observability