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

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 工具:

java 复制代码
@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、文件上下文、规则检查和报告工具:

java 复制代码
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 再带着这个结果进入下一轮推理,直到得到最终回复或者触发停止条件。

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

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

理解这条链路以后,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 和其他入口共同调用?

我更倾向于第三种。

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

java 复制代码
@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 只负责模型这一侧的事情:提供工具描述,把模型传入的字符串转换为领域类型,再把内部结果整理成适合进入模型上下文的内容。

依赖方向也变得简单:

text 复制代码
GitDiffTool -> GitDiffService

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

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

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

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

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

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

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

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

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

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

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

text 复制代码
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 只能说明一个方法被发布给了模型,不能说明它应该拥有领域规则、事务、文件权限或流程状态。

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

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

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

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

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

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

text 复制代码
ReportTool
  -> ReviewReportService
      -> ReportRenderer
      -> 文件输出

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

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

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

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

text 复制代码
ReviewService
  -> GitDiffService
  -> FileContextService
  -> RuleCheckService
  -> ReviewReportService

GitDiffTool
  -> GitDiffService

FileContextTool
  -> FileContextService

RuleCheckTool
  -> RuleCheckService

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

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 把代码写完以后,再回头补答案。

相关推荐
脉动数据行情142 分钟前
Java SpringBoot 国际期货批量采集实践 美原油 / 黄金 / 指数期货定时落库
java·开发语言·spring boot
Zane19941 小时前
写Stream时踩过的坑:中间操作不会真正执行,直到你调用这一个方法
java·后端
半糖程序员1 小时前
从零构建 Agent(4):让模型回复逐字显示
人工智能·agent
EXI-小洲1 小时前
Spring AI (第二章)大模型对话上下文记忆
java·人工智能·spring
扬大平仔1 小时前
# 小深:用 AgentScope Java 2.0 Harness 做私人助手(上)mysql
java·开发语言·mysql
用户976104399211 小时前
第三章 3.2与大语言模型交互
agent
阿里云基础软件1 小时前
一句话看透 JVM,SysOM 诊断 Skill 新增 Java 应用诊断能力
java·开发语言·jvm·人工智能·操作系统·sysom 诊断 skill
Wang's Blog1 小时前
Java框架快速入门: Spring Security+OAuth2之自动化集成测试
java·spring·自动化