AgentScope Java 实战:@Tool 方法里到底该不该写业务逻辑?

1 阅读17分钟

AgentScope Java 实战:@Tool 方法里到底该不该写业务逻辑?

项目跑通以后,Tool 和 Service 的边界才真正开始暴露

AI 很容易给一个 Java 方法加上 @Tool,再注册进 Toolkit。代码能运行,模型也能调用,看起来任务已经做完了。

真正容易埋下问题的,是 @Tool 方法里面那段业务代码。

先看一个具体场景:订单客服 Agent 需要查询订单、查询库存、重新计算金额、发送通知、清理过期订单和查看缓存统计。哪些应该成为 Tool,哪些应该留在 Service?

这件事不能按照实现复杂度来分。

一个操作即使只有一行代码,也不代表它应该交给 Agent 决定。反过来,一项业务规则即使很复杂,只要模型只负责发起调用,真正的计算仍然由 Java 完成,它也不一定永远不能成为 Tool。

真正开始写 AgentScope Java 项目以后,还会遇到更具体的问题:

一项能力已经确定可以暴露给 Agent,是不是就应该把业务实现直接写进 @Tool 方法?

这篇文章不准备只给出一张脱离场景的 Tool 和 Service 对照表。

我们直接进入 AgentScope Java 和代码审查小助手的源码,看 Tool 是怎样被模型看见、选择和执行的,再回答业务代码应该放在哪里。

一、AgentScope Java 的 Tool,首先是一份给模型看的契约

在普通 Java 项目里,我们看到的是一个方法:有方法名、参数、返回值,其他代码可以直接调用。

加上 @Tool 以后,变化的不只是多了一个注解。

在 AgentScope Java 中,@Tool 可以声明工具名称、描述、严格 Schema、readOnlyconcurrencySafeexternalTool、状态注入以及结果转换器。@ToolParam 则继续描述模型能看到的参数名、参数含义和是否必填。

比如代码审查小助手里的 Git diff 工具:

@Tool(
    name = "load_git_diff",
    description = "从本地仓库只读加载 Git diff。",
    readOnly = true
)
public String loadGitDiff(
    @ToolParam(name = "repoPath", description = "本地 Git 仓库路径")
    String repoPath,
    @ToolParam(name = "diffMode", description = "WORKING_TREE, STAGED, or BASE_REF")
    String diffMode,
    @ToolParam(name = "baseRef", description = "BASE_REF 模式使用的基准 ref")
    String baseRef
) {
    return loadDiff(repoPath, DiffMode.from(diffMode), baseRef).diffText();
}

这些信息不是主要写给其他 Java 开发者看的。

工具名和描述会帮助模型判断什么时候调用它;参数信息会被转换成 JSON Schema,告诉模型应该生成哪些参数;readOnly 和并发属性还会继续参与后面的权限判断与执行调度。

所以,@Tool 更接近一次接口发布:

它把一个 Java 方法翻译成模型能够发现、理解和请求调用的接口。

这也是 Tool 和 Service 开始出现差别的地方。Service 的方法签名主要服务于应用内部协作,Tool 的契约还要面对一个新的调用者——模型。

二、从 @Tool 到真正执行,中间还有一条完整调用链

“模型调用了一个 Java 方法”是一种方便理解的说法,但并不准确。

模型不会直接进入 JVM 执行代码。它做的事情,是根据当前消息和工具 Schema 生成一份工具调用请求。真正执行这份请求的,仍然是 AgentScope Java 和我们的应用。

先看注册阶段。

代码审查小助手在 ToolkitConfig 中创建 Toolkit,然后依次注册 Git diff、文件上下文、规则检查和报告工具:

Toolkit toolkit = new Toolkit();
toolkit.registerTool(gitDiffTool);
toolkit.registerTool(fileContextTool);
toolkit.registerTool(ruleCheckTool);
toolkit.registerTool(reportTool);

Toolkit.registerTool() 会反射扫描对象中的 @Tool 方法,读取注解和参数信息,生成参数 Schema,再把方法包装成 ReflectiveFunctionTool,放进工具注册表。

到这里,工具只是“已经注册”。它还没有执行。

构建 ReActAgent 时,项目把 Toolkit 交给 Agent。进入推理阶段以后,ReActAgent 通过 Toolkit.getToolSchemas() 取得当前可见的工具,再把它们连同消息一起交给模型。

如果模型判断需要工具,就会返回 ToolUseBlock。Agent 随后从 reasoning 进入 acting,先经过权限、Hook 和 Middleware,再把获准执行的请求交给 Toolkit.callTools()

后面的 ToolExecutor 还要继续做几件事:

  • 查找工具并确认它当前可用;
  • 校验模型给出的参数;
  • 合并应用预设参数与运行上下文;
  • 根据工具属性安排串行或并行执行;
  • 应用超时和重试配置;
  • 最后调用 AgentTool.callAsync()

执行结果会被转换成 ToolResultBlock,写回当前上下文。ReActAgent 再带着这个结果进入下一轮推理,直到得到最终回复或者触发停止条件。

整条链路可以压缩成下面这样:

agent.call(...)
  -> ReActAgent reasoning
  -> Toolkit 提供 Tool Schema
  -> 模型返回 ToolUseBlock
  -> ReActAgent acting
  -> 权限、Hook、Middleware
  -> Toolkit.callTools(...)
  -> ToolExecutor 校验并执行
  -> ToolResultBlock 写回上下文
  -> 下一轮 reasoning

AgentScope Java Tool 从注册到下一轮推理的完整调用链

理解这条链路以后,Tool 的位置就清楚了一些。

它站在模型与 Java 系统之间,负责把模型产生的调用意图,转换成一次受框架控制的应用调用。

三、基本用法不复杂,真正难的是后面的代码放在哪里

AgentScope Java 提供了两种主要的 Tool 定义方式。

普通业务项目通常使用注解式写法:在 Java 对象的方法上添加 @Tool@ToolParam,再注册到 Toolkit

如果需要动态构造工具,或者想完全控制 Schema 和异步执行过程,也可以直接实现 AgentTool,自己提供名称、描述、参数 Schema 和 callAsync()

无论采用哪一种,最后都会进入 Toolkit 管理的工具体系。

真正容易让人犹豫的,并不是注解怎么写,而是下面这段代码到底应该有多厚。

假设我们要给 Agent 提供读取 Git diff 的能力。一种直接写法,是把路径校验、Git 命令执行、结果解析、异常处理全放进 GitDiffTool

项目刚开始时,这样完全可以运行。代码量也不多,看起来再单独建一个 Service 似乎只是多加一层。

但只要普通 Java 流程也需要读取 Git diff,问题就出现了:

  • 是让 Service 直接依赖 GitDiffTool
  • 是把同样的 Git 逻辑再写一遍?
  • 还是把真正的能力抽出来,让 Tool 和其他入口共同调用?

我更倾向于第三种。

如果按这个边界拆,代码会变成:

@Service
public class GitDiffService {

    public GitDiffResult loadDiff(
        String repoPath,
        DiffMode diffMode,
        String baseRef
    ) {
        // 路径校验、Git 调用、结果解析、异常转换
    }
}

@Component
public class GitDiffTool {

    private final GitDiffService gitDiffService;

    public GitDiffTool(GitDiffService gitDiffService) {
        this.gitDiffService = gitDiffService;
    }

    @Tool(
        name = "load_git_diff",
        description = "从本地仓库只读加载 Git diff。",
        readOnly = true
    )
    public String loadGitDiff(
        @ToolParam(name = "repoPath", description = "本地 Git 仓库路径")
        String repoPath,
        @ToolParam(name = "diffMode", description = "WORKING_TREE、STAGED 或 BASE_REF")
        String diffMode,
        @ToolParam(name = "baseRef", description = "BASE_REF 模式使用的基准 ref")
        String baseRef
    ) {
        return gitDiffService
            .loadDiff(repoPath, DiffMode.from(diffMode), baseRef)
            .diffText();
    }
}

这里的 GitDiffService 拥有稳定的业务能力:它接收强类型参数,完成路径校验、Git 调用和结果解析,返回 GitDiffResult

GitDiffTool 只负责模型这一侧的事情:提供工具描述,把模型传入的字符串转换为领域类型,再把内部结果整理成适合进入模型上下文的内容。

依赖方向也变得简单:

GitDiffTool -> GitDiffService

Tool 可以依赖 Service,Service 不需要知道模型、Tool Schema 和 @ToolParam 的存在。

四、先区分三个问题:是否开放、如何实现、怎样保护

判断一项能力是否适合交给 Agent,可以先做四层检查:

  1. 角色和权限:这是不是当前 Agent 应该做、也有权做的事?
  2. 确定性:必须得到确定结果的规则,是否仍由 Java 系统执行?
  3. 内容风险:模型能使用哪些资料,哪些内容不能自由发挥?
  4. 单一职责:一个 Tool 是否承担了过多含义不同的任务?

这四层依然成立,但它们解决的是能力准入问题。

现在还要把它和另外两个问题分开:

阶段要回答的问题主要依据
能力准入这项能力能不能交给当前 Agent?角色权限、确定性、内容风险、单一职责
代码分层决定开放以后,Tool 和 Service 怎么写?模型选择权、业务所有权、复用与依赖方向
运行保护Tool 真正执行时怎样兜底?只读语义、权限判断、参数校验、超时与重试

从能力准入、代码分层到运行保护的三层设计关系

这四层判断,决定一项能力有没有资格进入 Agent 的工具箱。

这一篇继续解决的是:它进入工具箱以后,业务代码应该放在哪里。

这也意味着,Tool 和 Service 不是对同一项能力做二选一。

例如订单场景可以这样设计:

OrderQueryTool
  -> OrderQueryService

OrderAmountTool(如果产品允许 Agent 发起重算)
  -> PricingService

ExpiredOrderScheduler
  -> ExpiredOrderService

查询订单可以暴露为 Tool,但鉴权、查询规则和数据库访问仍然属于 OrderQueryService

金额计算必须由确定性的 Java 逻辑完成,也不等于 Agent 永远不能发起计算。产品允许时,可以由 Tool 接收订单号,再调用 PricingService,但模型本身不负责计算金额。

清理过期订单即使只有一次存储过程调用,只要它不该由客服 Agent 决定,就不需要 Tool,继续由定时任务调用 Service。

所以,更准确的关系不是“查询是 Tool,计算是 Service”,而是:

Tool 是模型入口,Service 是业务实现。一个能力可以只有 Service,也可以由 Tool 调用 Service。

五、第一层判断:不要看“是不是 Agent 功能”,先看谁拥有业务能力

回到代码审查小助手。

它有 GitDiffToolFileContextToolRuleCheckToolReportTool。这些类都是真实实现,不是为了展示 Tool Calling 临时写的假函数。

其中前三个 Tool 都标记为只读,符合代码审查场景的安全定位。项目还通过 ToolkitConfig 集中注册工具,由 AgentFactory 决定哪一种 Agent 可以拿到这套工具。

这些设计都很合理。

但继续看调用关系,会发现这些类不只被模型使用。

GitDiffTool 中,带 @ToolloadGitDiff() 返回文本,面向模型;另一个 loadDiff() 返回强类型的 GitDiffResult,由 Java 流程直接调用。

FileContextTool 也是类似结构:readFileContext() 面向模型读取单个文件,loadContexts() 则供审查流程批量加载变更文件。

RuleCheckTool 中的 runRuleChecks() 是模型入口,真正生成结构化问题列表的逻辑放在 check() 方法里。

接着,ReviewService.runReview() 按固定顺序直接调用这些方法:读取 diff、生成摘要、加载文件上下文、执行规则检查、调用模型审查、生成报告。

这里出现了一个很有意思的现象:

类名是 Tool,但真正决定调用顺序的不是模型,而是 Service。

这不是说当前写法不能用。对于教学项目或者早期版本,把 Tool 入口和能力实现放在一个类里,可以更快做出完整闭环。

但当一个类开始同时面对模型调用和应用内部调用时,它已经在承担两份契约:

  • 一份是 Agent 调用契约,包括工具名、描述、Schema 和模型友好的返回结果;
  • 一份是应用内部契约,包括强类型参数、结构化结果、异常和复用方式。

只要第二份契约开始稳定下来,Service 的边界也就出现了。

所以第一层判断可以直接落成一句话:

Service 是业务能力的所有者,Tool 是模型调用这项能力的入口。

六、第二层冲突:Agent 会用,不代表调用顺序应该交给 Agent

很多人在划分 Tool 时,会先列出“Agent 需要哪些能力”。

这个问题当然要问,但还不够。

代码审查小助手确实需要读取 diff、加载源码上下文、执行规则检查和生成报告。按照“Agent 需要,所以做成 Tool”的思路,这几步似乎都应该交给模型选择。

但项目源码没有这样做。

ReviewService.runReview() 保留了一条确定性流水线。创建任务、更新状态、发布进度事件、读取 diff、执行规则、合并结果、保存报告和失败处理,都由 Java 代码掌握。

模型可以参与 diff 摘要和代码审查,也可以在拿到工具的 Agent 中根据需要补充读取信息,但它不能随意跳过“必须生成报告”或者“失败后更新任务状态”这些步骤。

这里真正需要区分的是两类调用。

一类适合交给模型选择:

  • 是否还要读取某个额外文件;
  • 多个诊断工具中下一步应该使用哪一个;
  • 是否需要根据上一步结果继续查找证据;
  • 无法在写代码时预先确定的探索路径。

另一类应该留在 Service:

  • 创建任务、更新状态和发布事件;
  • 固定的业务流水线和失败处理;
  • 事务、幂等和持久化;
  • 必须执行、不能由模型自行省略的步骤。

因此,Tool 的价值不是把原有业务编排全部交给模型。

它只是在确实需要动态判断的地方,把有限的选择权开放给模型。剩下的流程仍然由应用负责。

七、第三层纠偏:不要按代码形态分层,要按责任和决策权分层

看到这里,原来那条“有 @Tool 的放 Tool 层,没有注解的放 Service 层”就不够用了。

@Tool 只能说明一个方法被发布给了模型,不能说明它应该拥有领域规则、事务、文件权限或流程状态。

更可靠的划分方式,是同时看责任和决策权。

维度ToolService
调用决策模型可以选择是否调用、调用哪个、参数是什么应用按照确定规则调用
面向对象模型与 Agent RuntimeController、任务、其他 Service 和测试
输入契约Tool Schema、模型容易生成的参数领域对象和强类型参数
输出契约适合写回模型上下文的结果稳定、可复用的领域结果
主要职责暴露能力、转换参数、裁剪结果、声明工具元数据业务规则、流程、事务与持久化
依赖方向可以依赖 Service尽量不依赖 Tool

Tool 作为模型适配层、Service 作为业务层的职责边界

这里还可以用代码审查小助手中的报告逻辑做一次检查。

ReportTool.renderReviewReport() 被标记为 readOnly = false,但这个方法本身只是拼接 Markdown 字符串。真正写入报告文件的是 ReviewReportService.generateAndSave(),而 ReviewReportService 又反过来调用 ReportTool.renderMarkdown()

这段代码可以运行,却会让工具元数据和真实副作用出现距离:Tool 声明自己可写,但它没有写文件;负责写文件的 Service 又依赖 Tool 完成内部渲染。

如果继续拆分,更自然的方向是:

ReportTool
  -> ReviewReportService
      -> ReportRenderer
      -> 文件输出

如果报告生成根本不需要由模型主动触发,那么 ReportTool 甚至可以不注册,继续让 ReviewService 直接调用 ReviewReportService

这个例子说明,readOnly 解决的是一次 Tool 调用的副作用和权限语义,它不能替我们完成业务分层。

八、代码审查小助手可以怎样调整依赖方向

如果继续把当前项目往更清楚的分层推进,我会把结构调整成这样:

ReviewService
  -> GitDiffService
  -> FileContextService
  -> RuleCheckService
  -> ReviewReportService

GitDiffTool
  -> GitDiffService

FileContextTool
  -> FileContextService

RuleCheckTool
  -> RuleCheckService

ReportTool
  -> ReviewReportService(只有模型确实需要触发时才保留)

代码审查小助手从 Service 直接依赖 Tool 到 Tool 依赖 Service 的调整

ReviewService 继续负责任务状态机和固定审查流水线。

GitDiffService 负责路径校验后的 Git 调用、diff 解析和强类型结果;GitDiffTool 只处理模型参数与返回内容。

FileContextService 负责安全地读取文件,继续复用现有的路径和权限策略;FileContextTool 决定哪些参数对模型开放,以及一次返回多少上下文。

RuleCheckService 保存确定性规则和结构化发现项;RuleCheckTool 只是让模型在需要时触发这项检查。

ToolkitConfig 则只负责一件事:决定哪些 Tool 对当前 Agent 可见。

这并不意味着每一个十几行的方法都要配一个 Service。

在 Demo、一次性原型或者没有第二个调用入口的纯函数里,把 Tool 和实现放在一起完全可以接受。分层不是为了增加目录和类,而是为了让依赖关系在需求变化以后仍然清楚。

通常,当下面任何一个信号出现时,就值得把 Service 拆出来:

  • 同一项能力开始被 Tool 之外的入口调用;
  • Tool 内部出现稳定的业务规则或强类型结果;
  • 需要事务、幂等、持久化或统一异常处理;
  • 模型参数和应用内部参数已经不是同一种表达;
  • Tool 返回值需要裁剪,但内部流程需要保留完整结果。

九、三个容易误判的场景

只读操作一定应该做成 Tool 吗?

不一定。

readOnly = true 表示这次工具调用没有可观察的写入副作用,主要影响权限和执行语义。它不负责判断业务归属。

读取客户资料可以是只读 Tool,真正的鉴权、查询和数据最小化仍然应该由 Service 保证。如果当前 Agent 根本不应该看到客户资料,那么它连候选 Tool 都不应该成为。

有副作用的操作只能放 Service 吗?

也不一定。

模型可以通过 Tool 发起一次写操作,例如发送一条已经过审核的通知。但 Tool 后面仍然可以调用 Service,由 Service 处理权限、幂等、事务和审计。

这里要限制的不是“模型能不能触发写操作”,而是模型拥有什么选择权,以及系统如何约束这份选择权。

能不能直接在 Service 方法上加 @Tool

技术上可以。

AgentScope Java 的注册逻辑只关心对象中是否存在 @Tool 方法,并不要求类名必须以 Tool 结尾。

但这样做意味着 Service 的方法签名同时成为模型契约。工具描述、模型参数、返回裁剪和应用内部接口会绑在一起。

当边界很简单、调用者单一时,这种写法能减少样板代码。业务一旦开始复用或变化,单独保留一个薄 Tool 通常更稳。

十、最后,再回到开头的问题

文章开头留下的问题是:项目已经跑通,Tool 和 Service 的边界是否也自然正确?

答案是否定的。AI 可以继续写代码,但有些问题最好先由自己回答。

Tool 和 Service 的边界就是其中一个。

AI 很容易帮我们生成一个带 @Tool 的方法,也能把它注册进 Toolkit。但当前 Agent 是否应该拥有这项能力、哪些选择权可以交给模型、哪些业务规则必须留在确定性系统里,不会因为项目已经跑通就自动得到答案。

这次继续读 AgentScope Java 和代码审查小助手的源码以后,我会把答案再往前推进一步:

先判断能力该不该交给 Agent;决定开放以后,再让 Tool 保持薄,让 Service 保持稳定。

如果只想记住两句话,可以记住这两句:

Service 管事情怎样正确完成,Tool 管模型怎样调用这件事。

固定流程留给应用,动态选择交给模型。

框架可以替我们完成 Tool 的注册、参数校验、权限判断和执行调度,却不会替我们决定应用边界。

这个问题,最好也别等 AI 把代码写完以后,再回头补答案。