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、readOnly、concurrencySafe、externalTool、状态注入以及结果转换器。@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,可以先做四层检查:
- 角色和权限:这是不是当前 Agent 应该做、也有权做的事?
- 确定性:必须得到确定结果的规则,是否仍由 Java 系统执行?
- 内容风险:模型能使用哪些资料,哪些内容不能自由发挥?
- 单一职责:一个 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 功能",先看谁拥有业务能力
回到代码审查小助手。
它有 GitDiffTool、FileContextTool、RuleCheckTool 和 ReportTool。这些类都是真实实现,不是为了展示 Tool Calling 临时写的假函数。
其中前三个 Tool 都标记为只读,符合代码审查场景的安全定位。项目还通过 ToolkitConfig 集中注册工具,由 AgentFactory 决定哪一种 Agent 可以拿到这套工具。
这些设计都很合理。
但继续看调用关系,会发现这些类不只被模型使用。
GitDiffTool 中,带 @Tool 的 loadGitDiff() 返回文本,面向模型;另一个 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 把代码写完以后,再回头补答案。