AgentScope Java 实战 03:知识与工具层——给 Agent 装上手和书架

ReAct 循环会推理,但推理不能凭空产生事实。模型训练截止后的产品文档、企业内部的接口约定、用户上传的 PDF,都不在参数记忆里。02 篇结尾说过一句话:工具是 Agent 的手,检索是 Agent 的书架。本篇拆开看这两样东西在 AgentScope Java 里的实现------@Tool 注解驱动的工具系统,三层 RAG 检索链路,MCP 三种传输,以及子 Agent 的两种声明方式。dream-scope 的知识与工具体系也在这篇完整亮相。

先看一条贯穿全文的主线:框架给能力,模块定边界。AgentScope 的 Toolkit、SimpleKnowledge、McpClientBuilder 都是框架侧的积木;dream-scope 要回答的是另一个问题------这些积木放在六边形架构的哪一层,谁组装它们,失败时按什么顺序降级。能力清单可以抄官方文档,边界取舍才是工程文章该写的。

一、工具系统:@Tool 注解驱动的函数调用

AgentScope v2 的工具体系围绕两个类型展开:@Tool 注解标在普通 Java 方法上,Toolkit 是注册中心。一个最小的工具长这样:

bash 复制代码
public class WeatherTools {

    @Tool(name = "get_weather",
          description = "查询指定城市的天气,返回温度与天气状况")
    public String getWeather(
            @ToolParam(name = "city",
                       description = "城市名称,例如:深圳",
                       required = true)
            String city,
            @ToolParam(name = "unit",
                       description = "温度单位:celsius 或 fahrenheit",
                       required = false)
            String unit) {
        String u = (unit == null) ? "celsius" : unit;
        return "{\"city\":\"" + city + "\",\"temp\":26,\"condition\":\"多云\"}";
    }
}

框架侧发生了三件事。第一,Toolkit.registerTool(new WeatherTools()) 反射扫描对象上所有 @Tool 方法,逐个登记。第二,方法签名加 @ToolParam 的描述被组装成 OpenAPI 风格的 JSON Schema,随每次推理发给模型------模型看到的不是 Java 方法,而是一份工具目录。第三,模型决定调用时,框架把 JSON 参数反序列化成方法入参并反射执行,返回值转回消息块进入下一轮推理。

@Tool 注解的属性值得过一遍,每个都对应一个运行期行为:

属性 默认 作用
name 为空时回退方法名 模型侧可见的工具标识
description 空 模型决定是否调用的首要依据
strict false 强约束参数 JSON Schema
readOnly false 只读标记,权限与沙箱会参考
concurrencySafe true 并发安全标记
externalTool false 模型只生成调用,由外部系统执行
stateInjected false 向工具注入 Agent 状态

有一个设计细节值得对照 Spring AI:AgentScope 强制每个 @ToolParam 写 name。原因是 Java 编译产物默认不含方法参数名,框架拿不到 city 还是 unit 这种信息;显式声明后,schema 生成不依赖 -parameters 编译参数,构建环境少一个隐性前提。工程上这属于"把不确定性消灭在注解里"的做法。

1.1 别把业务上下文交给模型

工具经常需要 userId、sessionId 这类调用上下文。常见的错误做法是让模型传------把 userId 写进工具参数,模型可能填错、可能被提示词注入诱导填别人的。AgentScope 的约定是:方法参数里不带 @ToolParam 的参数,框架按类型从 RuntimeContext 注入,模型全程看不到:

bash 复制代码
public record UserContext(String tenantId, String userId) {}

@Tool(name = "list_my_orders", description = "查询当前用户的订单")
public String listOrders(UserContext ctx,
                         @ToolParam(name = "status", required = false) String status) {
    return query(ctx.tenantId(), ctx.userId(), status);
}

这个机制和 02 篇的 RuntimeContext 一脉相承:调用身份从 HTTP 层构建,穿过中间件,最终在工具执行点被消费。模型负责"做什么",业务上下文由框架注入,两条信道物理分开。

1.2 异步返回与工具组

工具方法可以直接返回 Mono<String>,框架自动适配,配合 Schedulers.boundedElastic() 把阻塞查询挪出事件循环。对无法改造的阻塞工具,HarnessAgent 还提供异步 offload,把执行丢到独立调度器。

工具数量上来之后,AgentScope 提供 ToolGroup:把一批工具打包成组,默认不激活,再注册一个 meta tool 让模型按需"装备/卸载"工具组。价值在于上下文预算------几十个工具的 schema 全量常驻,每次推理都占 token;按组激活让模型只在需要时看到那批目录。

二、dream-scope 的工具面:三个演示工具与一个检索工具

框架能力看完,回到项目。dream-scope 的 chat 工具集中在一个纯 POJO 类 ChatTools,无 Spring 依赖,由 Toolkit 扫描注册:

bash 复制代码
@Tool(name = "getCurrentTime", description = "返回服务器当前日期时间(含时区偏移)")
public String getCurrentTime() {
    return OffsetDateTime.now().toString();
}

@Tool(name = "calculate", description = "计算四则运算表达式,支持括号与小数,例如 1+2*3")
public String calculate(
        @ToolParam(name = "expression", description = "数学表达式,仅允许数字、小数点、+ - * / 与括号", required = true)
        String expression) { ... }

@Tool(name = "httpGet", description = "对 http/https URL 发起 GET,返回响应体前 N 个字符")
public String httpGet(...) { ... }

三个工具都是演示位:时间、计算、HTTP GET。真正的主角是第四个------retrieve:

bash 复制代码
@Tool(name = "retrieve", description = "从知识库检索带编号的参考资料。回答产品、架构或调用方式时先调用,再按 [1][2] 引用")
public String retrieve(
        @ToolParam(name = "query", description = "检索问句,尽量包含专有名词", required = true)
        String query,
        @ToolParam(name = "topK", description = "返回条数,默认 3,上限 8", required = false)
        Integer topK) {
    if (retrievePort == null) {
        return "知识库未配置";
    }
    int limit = topK == null || topK <= 0 ? DEFAULT_TOP_K : Math.min(topK, HARD_MAX_TOP_K);
    var hits = retrievePort.retrieve(query, limit);
    return RetrieveCitations.format(hits);
}

三个细节:

  • description 写的是使用时机,不是功能罗列。"回答产品、架构或调用方式时先调用,再按 12 引用"------前半句给模型的触发条件,后半句直接规定引用格式。工具描述是模型路由工具的依据,把使用规范写进描述,比指望系统提示词约束更近。
  • topK 有硬上限。模型传 100 也只取 8,防御性钳制在工具边界完成,不信任模型输出。
  • retrievePort 为 null 时返回提示语而不是抛异常。知识库未配置是合法运行态(本地无 Key 演示),不是错误。

片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/tool/ChatTools.java

权限方面呼应 02 篇:chat 主路径 PermissionMode.BYPASS,文件与 Shell 工具关闭,工具面收敛到这四个演示工具加 MCP 挂载。工具越少,模型路由越稳,这是刻意的减法。

三、三层 RAG:一条跨模块的检索链路

retrieve 工具背后的 retrievePort 是整条 RAG 链路的门面,但它只是接口。实现分布在三个模块,各管一段:

bash 复制代码
HTTP / ChatTools.retrieve
  → RetrievePort(再包 Hybrid + Advanced)
    → SimpleKnowledgeRetrievePort        ← adapter 模块
      → SimpleKnowledge(embed + search)
      → InMemoryStore 或 PgVectorStore   ← 向量库二选一
层 类 职责
adapter SimpleKnowledgeRetrievePort 适配官方 SimpleKnowledge,管 embed、入库、切块、向量检索
knowledge HybridRetrievePort 向量与关键词双路召回,RRF 融合
knowledge AdvancedRetrievePort 叠加问句改写与精排,失败退回内层
web RagPortsConfig 组合根:按配置装配唯一 RetrievePort Bean

分层的依据是依赖方向。adapter 里的类才允许 import io.agentscope(六边形边界,01 篇的 enforcer 在构建期强制);knowledge 模块定义自己的 RetrievePort 接口,写 RRF 融合时看不到 AgentScope 的任何类型;web 的 RagPortsConfig 是组合根,决定生产环境用哪条实现链。检索逻辑因此可以脱离框架单测------给 HybridRetrievePort 注入假端口,RRF 融合的正确性不依赖任何向量库。

3.1 adapter 层:SimpleKnowledgeRetrievePort

这一层做的是"官方组件的工程化包装"。SimpleKnowledge 是 AgentScope 官方的知识抽象,管 embed 和相似度检索;适配器补齐生产要用的部分:

  • 向量库二选一 :不传 store 按 embedding 维度建 InMemoryStore(单测、无 PG 的本地演示);传配置则建官方 PgVectorStore,build() 自动 CREATE EXTENSION vector 并建表;
  • 文件入库分批:大 PDF 整本一次 embed 容易超时,按每批 8 块写入,进度经 ThreadLocal 回调上报(reading / embedding 两个阶段);
  • 稳定 id 与来源索引:AgentScope Reader 自带的块 id 不可靠,入库前统一盖自有 docId,payload 写入 source 与 docType------按来源列表、按来源覆盖删除、重启后从 pg 重建索引,都靠这套约定;
  • 失败时保正文:embedding 失败抛 EmbeddingIngestException,把已经从 PDF 抽出的正文带上------额度不足时整份文档的解析成果不白费,上层拿正文降级写关键词索引。

切片参数也在这层生效:chunkSize 2000、chunkOverlap 200(配置项 dream-scope.rag.*),只影响文件入库,addText 整段一块不切。

3.2 knowledge 层:HybridRetrievePort 的 RRF 融合

纯向量检索的短板很老:专有名词、型号、代号这类强字面信号,embedding 不一定拉开距离。HybridRetrievePort 的答案是双路召回 + RRF(Reciprocal Rank Fusion):

bash 复制代码
int fetch = Math.min(topK * 3, Math.max(topK, 32));
List<RetrieveHit> vector = safeRetrieve(primary, query, fetch, source);
List<RetrieveHit> kw = safeRetrieve(keyword, query, fetch, source);

每路多取(约 3 倍候选),RRF 用排名倒数融合,常数 K 取 60------行业里验证过的默认值,让两路的排名平滑互补而不是互相碾压。双路不是摆设,降级逻辑内建在检索流程里:向量这条路空了就用关键词结果,关键词空了就用向量结果,两路全空才返回空。

关键词索引是进程内的 InMemoryKeywordIndex,由 Hybrid 自己维护:向量检索成功后把返回正文镜像进关键词索引;embedding 失败则只写关键词。向量端口不反向持有索引,写权集中在 Hybrid 一处,不会出现两处各写一半的状态。

3.3 knowledge 层:AdvancedRetrievePort 的改写与精排

Hybrid 之上还可以再包一层 AdvancedRetrievePort,叠加两个可选增强:

  • 问句改写(DashScopeQueryRewritePort):用便宜的小模型(默认 qwen-turbo)把口语问句扩成检索友好的表述。口语和文档用词经常对不上------"怎么部署"扩成"部署 安装 启动 配置",召回率立刻不同;
  • 精排(DashScopeRerankPort):召回负责"别漏",精排负责"排对"。粗排出 top 24,精排模型逐对打分重排,取前 topK。默认模型 gte-rerank-v2,配置默认开启。

两步都遵循同一条纪律:失败退回内层结果。改写服务挂了用原句检索,精排挂了用粗排顺序------增强能力必须表现为增益而不是单点故障。这也解释了配置里 rewrite 默认关、rerank 默认开的取舍:改写收益依赖问句风格,精排收益对文档问答几乎稳定,默认值按风险不对称来定。

四、组合根与降级链:provider 四档

RagPortsConfig 是 web 模块的组合根,决定生产环境检索栈的形态。核心是一个 provider 配置(dream-scope.rag.provider),四档语义:

provider 行为
auto(默认) 有 jdbc + embedding Key → pg;仅 Key → 内存向量;否则或失败 → 关键词
pg / pgvector 强制 pg,缺 jdbc 或 Key 直接抛错,不静默降级
simple 强制内存向量,缺 Key 抛错
keyword 进程内关键词,不调 embedding

auto 档的降级链值得展开:pg 失败 → 试内存向量 → 再失败 → 关键词。每一档成功后都过 enhance,按开关挂精排与改写。运行期的 embedding 额度类错误(如免费额度用尽)走另一条路:关闭半开的向量端口,切换到关键词索引,检索继续可用------用户搜到的是关键词命中,而不是一条 500。

降级不是无脑兜底。显式指定 pg 却缺配置时直接抛错不启动------配置写明了意图,静默降级反而掩盖问题;只有 auto 档才允许悄悄往下走。自动降级是给"没表态"的容错,显式配置要的是"说到做到",两种语义分开处理。

还有一个重启恢复的细节:sourceIds(来源 → 块 id 索引)在内存里,进程重启即空。组合根启动时检查 pg 表,有数据则从 payload 重建 source 索引、镜像关键词,列表/删除/混合检索照常可用;表为空才灌演示语料,避免重复 upsert。

检索链路整体如下:

片段出处:dream-scope-web/src/main/java/com/zhu/scope/web/config/RagPortsConfig.java

五、刻意不挂 knowledge():单通道检索的取舍

框架其实内置了一条更"自动"的路:ReActAgent.knowledge() 把知识源挂到 Agent 上,每轮推理前自动检索注入(旧版 GenericRAGHook,已标 @Deprecated)。dream-scope 刻意不用,原因写在两处源码注释里:避免与 retrieve 工具各搜一次、引用两套。

双通道的实际问题不是"多花一次检索",而是一致性:自动注入走一套检索配置,工具调用走另一套,同一轮里模型可能看到两份来源不同、格式不同的资料,引用出处无法归一。收敛成单通道后,检索只发生在 retrieve 工具里,配置、降级、引用格式只有一份,模型看到的资料永远带着 12 编号。

这条取舍顺带回答了"Agentic RAG 还是 Application RAG":AgentScope 官方也在文档里把工具化检索列为推荐形态------RAG 退化为普通工具调用后,权限、压缩、子 Agent 隔离这些机制全部免费复用,框架不需要为检索单开一条隐式注入路径。

5.1 knowledge Agent:只检索,不调模型

单通道之外,dream-scope 还内置了一个特殊的 Agent:id 为 knowledge 的 ScopeKnowledgeAgent。它不走模型,handle 方法就是一次检索:

bash 复制代码
@Override
public AgentInvokeResult handle(AgentInvokeRequest request) {
    String query = request == null ? "" : request.input();
    var hits = retrievePort.retrieve(query, DEFAULT_TOP_K);   // topK 5
    String formatted = RetrieveCitations.format(hits);
    return new AgentInvokeResult(id(), formatted);
}

它实现的是与 chat Agent 同一个 StreamingAgentHandler 接口,HTTP 侧看来两者无异------请求进、事件出。差别在于没有模型调用:不推理、不消耗 token,检索 top 5 直接返回带编号结果。定位是给前端或调用方一个纯检索出口:搜索页面、调试知识库内容、给第三方系统供数,都不该为此烧一次 LLM。它与 chat 工具共用同一个 RetrievePort Bean,检索口径完全一致。

片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/ScopeKnowledgeAgent.java

六、MCP:三传输与运行态收敛

工具的第二来源是 MCP(Model Context Protocol)------把外部 MCP Server 的工具挂进 Toolkit。AgentScope 在 core 内置了 MCP 客户端,McpClientBuilder 提供三种传输:

传输 方法 场景
stdio stdioTransport(command, args...) 本地拉起子进程(npx filesystem 等)
SSE sseTransport(url) 旧版远端服务
Streamable HTTP streamableHttpTransport(url) 新版远端服务,主流方向

接入只要两步:builder 建连拿到 McpClientWrapper,Toolkit.registerMcpClient 注册全部工具:

bash 复制代码
McpClientWrapper mcp = McpClientBuilder.create("demo")
        .streamableHttpTransport("http://127.0.0.1:8093/mcp")
        .timeout(Duration.ofSeconds(30))
        .buildAsync()
        .block();                                  // 异步建连,block 等握手完成

toolkit.registerMcpClient(mcp).block();            // 拉取工具 schema 并注册

之后 MCP 工具与本地 @Tool 在模型侧无差别。生命周期上 McpClientWrapper 持有连接与子进程,应用关闭时逐个 close------dream-scope 的 ChatMcp.closeQuietly 按打开的逆序回收,失败不掩盖主异常。

6.1 web 为什么拒绝 stdio

框架三传输都支持,dream-scope 的 web 模块却在运行态把 stdio 拒了。写 tools.json 时直接抛错:

bash 复制代码
if ("stdio".equals(normalized)) {
    throw new IllegalArgumentException("mcp stdio transport is not allowed");
}

理由是部署形态。stdio 传输要在宿主机上拉起子进程,适合开发者本机;web 模块的目标环境是容器与服务器,npx 拉子进程意味着镜像里要预装 Node、子进程逃逸出 JVM 的资源管理、故障排查多一层进程树。运行态只留 sse 与 streamableHttp 两种 HTTP 传输,工具来源全部走网络,边界干净。

值得注意的是拒绝的位置:不是在 MCP 建连时才报错,而是启动装配写 tools.json 时就抛------配置错误在启动期暴露,不带病运行。这与 02 篇"条件分支只出现在装配点"的取舍同源。

片段出处:dream-scope-web/src/main/java/com/zhu/scope/web/util/WorkspaceSubagentSeed.java

6.2 工具名用服务端原名

dream-scope 自带一个演示 MCP Server(DemoMcpServer,随 web 端口起在 8093),提供一个 echo 工具。模型看到的工具名是 mcp__demo__echo------这不是框架加的前缀,而是服务端公布的原名,AgentScope 原样注册。

这个细节的工程含义:工具名冲突的治理责任在服务端命名,客户端不做二次加工。多 Server 挂载时各自的名字空间靠服务端自己保证,模型侧看到的目录与 MCP 协议层完全一致,排查问题时不用在"框架改名"和"服务端起名"之间来回对。

七、Subagent:两路径声明与委派工具

第三个能力是子 Agent。AgentScope 的 Subagent 体系解决三件事:上下文隔离(子任务的过程内容不污染主上下文)、专职化(独立提示词与工具面)、后台并行。声明方式有两条路径:

编程式:builder 里直接构造 SubagentDeclaration。dream-scope 只放了一个 summarizer:

bash 复制代码
static SubagentDeclaration summarizer() {
    return SubagentDeclaration.builder()
            .name(SUMMARIZER_ID)
            .description("把用户给出的文本缩成不超过三句的中文摘要。")
            .inlineAgentsBody("你是摘要子 Agent。禁止调用任何工具。只输出摘要正文,不要标题或前缀。")
            .maxIters(4)
            .build();
}

声明式 :在 workspace 的 subagents/ 目录写 Markdown,文件名即 agent_id。weather-agent 与 flight-agent 两个演示子 Agent 就是这种形态------YAML front matter 写 name、description、maxIters、工具白名单,正文写系统提示词(含固定的假数据格式,不调外部 API)。

两路在 HarnessAgent.build() 时合并注册。规则只有一条硬约束:同一个 agent_id 不能既出现在代码里又出现在文件里------重复声明直接冲突,不留"文件覆盖代码"的隐式优先级。

子 Agent 的运行靠五个内置工具,模型自主调用:

工具 作用
agent_spawn 创建/复用子 Agent 并派任务(timeout_seconds=0 转后台)
agent_send 向已有子 Agent 实例继续发消息
task_output 按 task_id 取后台任务结果
wait_async_results barrier 等待多个后台任务结果
task_cancel / task_list 取消/列举后台任务

分配取舍的思路和 MCP 拒 stdio 一致:确定性高的放代码,内容型的放文件。summarizer 是系统行为的一部分(摘要出口),参数稳定,代码声明便于重构时一起评审;天气与航班是演示位,提示词与数据格式会经常调整,Markdown 文件改完重启即生效,不需要碰 Java。声明式路径的文件还天然是给用户/部署方留的配置位------不写代码也能加子 Agent。

片段出处:dream-scope-adapter/src/main/java/com/zhu/scope/adapter/subagent/ChatSubagents.java、dream-scope-web/src/main/resources/workspace/subagents/weather-agent.md

八、本篇学到了什么与 04 预告

框架侧,本篇完成了知识与工具层的四个认知:@Tool 注解驱动 schema 自动生成,无 @ToolParam 的参数由 RuntimeContext 按类型注入------业务上下文不过模型的手;Toolkit 是统一注册中心,本地工具、工具组、MCP 工具在模型侧无差别;RAG 的推荐形态是工具化检索,框架的 knowledge() 自动注入让位于单通道的工具调用;MCP 三传输由 McpClientBuilder 统一提供,stdio 适合本机、HTTP 传输适合服务端。

工程侧,dream-scope 给出了三层检索链路的完整样板:adapter 适配官方组件、knowledge 做 RRF 融合与改写精排、web 组合根按 provider 四档装配------pg、内存向量、关键词逐级降级,失败路径全部有归宿;检索只有一个入口,knowledge Agent 提供不调模型的纯检索出口;MCP 运行态拒绝 stdio、工具名用服务端原名;子 Agent 两路声明、build 合并、同 id 冲突即报错。工具、检索、子 Agent 三块的共同点是:能力全部来自框架,边界全部自己划。

下一篇是主干最后一篇,进入互通层:A2A 协议与 Agent Card------服务怎么被别的 Agent 发现与调用;Nacos 提示词热更新的接线方式与版本兼容的现实限制。

项目信息:

有问题评论区见,欢迎交流~

相关推荐
海宇AI2 小时前
零信任架构实战:基于海宇学历核验版构建自动化高并发资信评估网关
人工智能·微服务·架构·自动化
阳明山水2 小时前
因果嵌入与流水线范式的本质差异
人工智能·深度学习·算法·机器学习·架构
代数狂人2 小时前
机器学习数学基础──第 2 章 函数 机器学习的积木
人工智能·机器学习
用户777636100262 小时前
类Jev项目Kev从入门到实战(2):Kev 的架构:一次前向,多问题隔离作答
人工智能
张忠琳2 小时前
【hermes-agent】Hermes Agent Kanban 流程超深度分析之一
ai·agent·hermes
潇潇潇暮雨2 小时前
让 AI 读到 App 实时日志,结合源码定位问题
react native·开源·ai编程
rhett. li2 小时前
用 AI 写 C++ 桌面 UI:TRAE + nim_duilib 实战指南
c++·人工智能·ui
沛东的认知和实践2 小时前
论文说根层摘要只要原文的 2.6%,我实测是 126%——彻底搞懂GraphRAG第六篇
agent
yaoyuxianggnn2 小时前
我的 Agent 一晚上烧了 500 块,于是我把它的死循环拆成了四种形态
人工智能