AgentScope第9式·御器工具

AgentScope第9式·工具集

工具能不能被调对,不由提示词决定,由 Toolkit 决定。

[读者] 正把数字人从"能聊"做成"能办事"的 Java 后端
[痛点] 工具塞进 Prompt 后选错、参数错、异常崩线程
[现在读] 第9式要把企业接口收编成受控的 Agent 工具
[读完] 会用 Toolkit 注册、分组、按需激活并兜住异常
[旧方案] systemPrompt 贴工具清单,模型吐 JSON,后端 if-else 路由
    |
    v
[新需求] 订单、CRM、用户查询都要能被一句自然语言触发
    |
    v
[冲突] 路由靠字符串像不像,参数没 schema,异常直接穿透线程
    |
    v
[后果] 工具越多越不准,一次下游超时拖垮整段会话

我是老李,带一个 Java 小组做企业数字人。第3式我们把最小数字人 Demo 冻结在 chapter/03-digital-human-demo 上:Spring Boot + MySQL 管数字人项目,LiveKit 管实时音视频,Realtime Bridge 做媒体消息适配,AgentScope Java 在最后一跳做推理。到第8式为止,这个数字人已经能稳定地说话,但它只会说话。

转折来自一次业务对齐。客户成功团队说,客服数字人回答"我的订单到哪了"时,永远只会说"请提供订单号,我帮您查询"——因为它确实什么都查不了。他们的诉求很朴素:让数字人自己去查。订单系统有 HTTP 接口,CRM 有接口,用户中心也有接口,都是现成的 Java 服务。看起来,这就是"给 Agent 加几个工具"。

真加进去才知道,"加几个工具"是这一式最容易被低估的一句话。我们第一版把三个接口的工具说明拼进 systemPrompt,让模型输出 JSON 再由后端分发。测试环境跑了一周,问题集中在三类:选错工具、参数填错、工具报错之后整段会话没有回复。

第一个问题:工具多了以后,模型凭什么还能选对?

第二个问题:工具报错,是让它自己吞掉,还是原样交给 Agent 处理?

第三个问题:哪些工具该常驻,哪些工具应该等到真正需要时再激活?

前两个选错,第三个就没得谈:你会开始往 Prompt 里堆限制、堆例子、堆"你只能选一个",最后把 systemPrompt 养成一个没人敢改的巨型文本。

那就继续加固 Prompt?还是把工具做成一套有契约、有上下文、有边界的运行时对象?

01、故事

会议室里就四个人:客户成功的负责人、我、后端小周,还有负责 LiveKit 那一侧的同事。任务是让数字人在一次实时会话里听懂"帮我查下上周那个订单发到哪了",自己选工具、填参数、拿结果,然后用一句人话回给用户。这句话写在需求单第一行,也是那一周唯一没被改过的需求。

我们当时的 V1 是这样的:在数字人项目的 Agent 配置里,systemPrompt 后面拼一段"可用工具清单",一共两条,用自然语言写。模型被要求输出一段 JSON,形如 {"tool":"queryOrder","orderId":"..."}。后端用正则把 JSON 抠出来,switch 到对应方法,调下游 HTTP,再把结果拼进下一轮消息。

这套东西在两条工具的时候能用。加到八条就不行了。模型开始把 queryOrder 和 queryCrmContact 混着用,理由是"这两个描述里都有查询"。参数更离谱,orderId 有时候给字符串,有时候给一个对象,有时候干脆给空。最麻烦的是第三条:订单服务偶发超时,RestTemplate 直接抛 ResourceAccessException,它沿着调用栈一路冒上来,Agent 那一轮没有输出,用户在数字人页面上看到的是静默。

我们意识到,问题不在模型聪明不聪明,而在我们其实没有"工具"这个东西,只有"一段描述 + 一次字符串分发"。工具是要有契约的。

提示词里排队
谁像谁先来
工具一多起
路由就失态

02、问题

旧做法的病灶有三条,而且是层层递进的。

第一条,描述即路由。工具清单写在 systemPrompt 里,模型判断"用哪个"的唯一依据是这段自然语言像不像。工具一多,描述之间的语义距离就会重叠,"订单查询"和"CRM 联系人查询"都带"查询",模型没有更强的信号可用。

第二条,参数即字符串。模型吐出来的 JSON 没有 schema 约束,服务端也没有校验。orderId 是数字还是字符串、必填还是可选、长度多少,全靠手写 if 去兜。每加一个工具,就要多写一段兜底代码。

第三条,异常即崩溃。工具方法直接抛 RuntimeException,异常的语义(下游超时、下游 4xx、参数非法、无权限)在往上传的过程中全部丢失,只剩一个"没回复"。

技术表现可以归纳成一句:工具没有契约,Agent 与业务之间就没有稳定的接口,只有一份约定俗成的字符串。

业务影响也很直接。客户成功那边的验收标准是"用户说一句,数字人给一个准确答案",我们交付的是"用户说一句,数字人问一句"。这不是体验问题,是能不能上线的问题。

所以这一式结束的时候,要能验证四件事:

  1. 用户自然语言说"查询我的订单",Agent 选中 query_order 而不是别的工具;
  2. 参数不合法(例如 orderId 格式不对)时,工具没有真正打到下游,Agent 收到的是可读的错误;
  3. 下游超时或报错时,Agent 拿到结构化的失败结果,能自己决定"重新问用户"还是"告诉用户稍后再试";
  4. 整个过程中,业务线程池没有因为一次工具异常而被打崩。

契约不落地
参数全凭猜
异常一穿透
会话就停摆

03、原理

只讲本篇真正需要的四条。

一、工具 = 声明 + 注册 + 上下文,三件套缺一不可。

在 AgentScope Java 里,一个工具至少要回答三个问题:它叫什么、要什么参数(声明,对外表现为 JSON Schema);它在哪里被找到(注册,Toolkit 是工具注册表,位于 io.agentscope.core.tool 包,固定 tag v2.0.3 下的 Toolkit.java 是这套机制的中心);它被调用时处于什么环境(上下文,ToolExecutionContext 承载这次调用的会话、用户、权限等运行时信息)。

ToolBase 是工具实现的统一基类,负责把这三种信息收敛成 Agent 能消费的对象;AgentTool 是面向 Agent 侧的工具抽象;@Tool 是给普通 Java 方法套上这层契约的注解,框架通过反射读取方法名、参数类型与描述,生成 JSON Schema。这几个词第一次出现,只需记住边界:它们描述的是"工具长什么样、怎么被找到、被调用时知道什么",不描述业务。

二、反直觉判断:工具越多,Agent 越笨。

这是本篇第一个反直觉判断,也是后面所有工程决策的起点。直觉上,给模型更多工具,它能做的事更多,应该更强。实际相反:工具选择是一次在候选描述之间做比较的过程,候选越多,描述之间的差异越被稀释,选错的概率越高。所以"工具组 + 按需激活"不是锦上添花的优化,而是可用性的前提。

理解这一点,就能理解 Toolkit 为什么要按组组织工具:组是一次性暴露给模型的候选集边界。查询订单的时候,CRM 和用户画像根本不该出现在候选里。

三、反直觉判断:工具的价值一半在"调不动的时候怎么回"。

第二个反直觉判断:很多人评估工具,看的是成功路径跑得顺不顺。但线上真正消耗人的,是失败路径。工具是分布式调用,下游一定会超时、会 4xx、会限流。工具层必须把"下游发生了什么"翻译成"Agent 能理解什么",而不是把异常原样往上抛。

一个设计得好的工具,失败时返回的是带错误码和可读原因的失败结果,Agent 拿到它之后仍然有能力组织语言回答用户。一个设计得差的工具,失败时抛异常,Agent 拿不到任何东西,用户拿到的是沉默。

四、只读和写入,是两组工具,不是两个参数。

只读工具可以随便调,调错了顶多浪费一次调用。写入工具调用一次就是一次副作用。把两者用同一个工具名、加一个 dryRun 参数区分,等于把判断权交给模型。正确做法是在注册层面就分成不同的组,写入组默认不激活,需要时由会话显式打开,并且在执行层做幂等键与超时控制。

超时和幂等不是框架能替业务决定的事,它们属于工具的实现契约:同一个幂等键重复调用只生效一次;超过约定时间没有结果就当作失败返回,不占用会话线程。

工具非提示
注册才成形
上下文随身
边界要写清

04、架构

[输入] 用户自然语言:帮我查下上周那个订单
    |
    v
[模块] Toolkit:注册表 + JSON Schema + 工具组 / 按需激活
    |
    v
[数据/状态] ToolExecutionContext:会话、用户身份、权限、调用链
    |
    v
[处理] Agent 选工具 → 参数按 Schema 校验 → 执行(幂等 / 超时 / 异常归一)
    |
    v
[输出] ToolResult → 回灌模型 → 自然语言答复

边界:AgentScope Java 负责工具的声明与注册(Toolkit)、Schema 生成(@Tool 反射)、工具分组与激活、调用时的上下文注入(ToolExecutionContext)、把工具结果回灌进推理循环。业务负责工具方法内部的真实语义:鉴权、幂等、超时、重试、只读与写入的划分、错误码定义。

官方源码位置:工具相关代码在 agentscope-core/src/main/java/io/agentscope/core/tool/,其中 Toolkit.java 是注册与 Schema 组织的中心;官方说明在 v2.0.3 的 docs/v2/zh/docs/building-blocks/tool.md。本系列从第8式开始建立源码研究方法,本篇所有关于"框架做了什么"的判断,都应回到这两个位置核对,而不是只看文档结论。

收益:工具从"一段描述"变成"有类型、有校验、有上下文的可调用对象",错误可以被分类,权限可以按组收口。

代价:多了一层注册与描述的维护成本;工具描述写得不清楚,问题会从"代码 bug"变成"模型行为问题",排查方式完全不同。

适用条件:工具数量超过 3 个;工具之间有语义重叠;工具中存在写入操作;工具调用需要知道"谁在调"。

不适用条件:只有一两个无副作用的纯函数查询,且 Agent 只有一个。这种情况下直接写死调用更简单,引入注册层是净负担。

一表收工具
一组管开关
一境带身份
一果回模型

05、实战一次

环境:JDK 17、Maven、AgentScope Java v2.0.3(教程基线)。分支:chapter/09-toolkit,从第8式分支继续创建。建议提交信息:feat(ch09): add typed enterprise tools with toolkit。

先说明一点:下面的代码以 v2.0.3 的 io.agentscope.core.tool 包为蓝本,重点放在契约与边界上;具体注解属性名与 Toolkit 的注册方法签名,落地时请打开固定 tag 下的 Toolkit.java 与 tool.md 对照,不要凭本文抄写。本次未在作者当前环境实测,以下为预期结果。

一、依赖

<dependency>
    <groupId>io.agentscope</groupId>
    <artifactId>agentscope-core</artifactId>
    <version>2.0.3</version>
</dependency>

二、三个企业工具,先只做只读

package com.example.dh.tool.order;

import com.example.dh.tool.ToolGuard;
import com.example.dh.tool.ToolResult;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolExecutionContext;
import org.springframework.stereotype.Component;

@Component
public class OrderTools {

    private final OrderClient orderClient;
    private final ToolGuard guard;

    public OrderTools(OrderClient orderClient, ToolGuard guard) {
        this.orderClient = orderClient;
        this.guard = guard;
    }

    @Tool(
        name = "query_order",
        description = "查询当前会话用户自己的订单物流与状态。只读,不修改任何数据。"
                    + "orderId 可省略;省略时返回该用户最近一笔订单。不用于查询他人订单,不用于退款。"
    )
    public ToolResult<OrderView> queryOrder(String orderId, ToolExecutionContext ctx) {
        String userId = ctx.get("userId", String.class);
        if (userId == null || userId.isBlank()) {
            return ToolResult.fail("NO_IDENTITY", "当前会话未绑定用户身份,请先登录后再查询订单。");
        }
        if (orderId != null && !orderId.matches("[A-Za-z0-9-]{6,32}")) {
            return ToolResult.fail("BAD_ARGUMENT", "订单号格式不正确,应为 6-32 位字母、数字或短横线。");
        }
        return guard.readOnly("query_order:" + userId + ":" + orderId,
                () -> orderClient.findLatest(userId, orderId));
    }
}

CRM 与用户查询同构:

@Component
public class CrmTools {

    private final CrmClient crmClient;
    private final ToolGuard guard;

    public CrmTools(CrmClient crmClient, ToolGuard guard) {
        this.crmClient = crmClient;
        this.guard = guard;
    }

    @Tool(name = "query_crm_contact",
          description = "按手机号或公司名查询 CRM 联系人档案。只读。仅用于销售与客户成功场景,不用于订单问题。")
    public ToolResult<ContactView> queryContact(String keyword, ToolExecutionContext ctx) {
        String tenantId = ctx.get("tenantId", String.class);
        if (keyword == null || keyword.isBlank()) {
            return ToolResult.fail("BAD_ARGUMENT", "请提供手机号或公司名。");
        }
        return guard.readOnly("query_crm_contact:" + tenantId + ":" + keyword,
                () -> crmClient.search(tenantId, keyword));
    }
}

@Component
public class UserTools {

    private final UserClient userClient;
    private final ToolGuard guard;

    public UserTools(UserClient userClient, ToolGuard guard) {
        this.userClient = userClient;
        this.guard = guard;
    }

    @Tool(name = "query_user_profile",
          description = "查询当前会话用户在数字人项目所属租户下的基础画像:昵称、会员等级、注册时间。只读。")
    public ToolResult<UserView> queryProfile(ToolExecutionContext ctx) {
        String userId = ctx.get("userId", String.class);
        return guard.readOnly("query_user_profile:" + userId,
                () -> userClient.profile(userId));
    }
}

三、ToolGuard:幂等、超时、异常归一

package com.example.dh.tool;

import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.concurrent.*;

@Component
public class ToolGuard {

    private static final Duration READ_TIMEOUT = Duration.ofSeconds(3);

    private final ExecutorService pool = Executors.newFixedThreadPool(16, r -> {
        Thread t = new Thread(r, "dh-tool-" + System.nanoTime());
        t.setDaemon(true);
        return t;
    });

    public <T> ToolResult<T> readOnly(String idemKey, Callable<T> action) {
        Future<T> future = pool.submit(action);
        try {
            return ToolResult.ok(future.get(READ_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS));
        } catch (TimeoutException e) {
            future.cancel(true);
            return ToolResult.fail("DOWNSTREAM_TIMEOUT", "下游系统响应超时,请稍后再试。");
        } catch (ExecutionException e) {
            return ToolResult.fail("DOWNSTREAM_ERROR", "下游系统返回异常,请稍后再试。");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return ToolResult.fail("INTERRUPTED", "本次查询被中断。");
        } catch (Exception e) {
            return ToolResult.fail("UNKNOWN", "工具执行失败。");
        }
    }
}

四、ToolResult:给 Agent 的结构化结果

package com.example.dh.tool;

public final class ToolResult<T> {

    private final boolean success;
    private final String code;
    private final String message;
    private final T data;

    private ToolResult(boolean success, String code, String message, T data) {
        this.success = success;
        this.code = code;
        this.message = message;
        this.data = data;
    }

    public static <T> ToolResult<T> ok(T data) {
        return new ToolResult<>(true, "OK", "success", data);
    }

    public static <T> ToolResult<T> fail(String code, String message) {
        return new ToolResult<>(false, code, message, null);
    }

    public boolean isSuccess() { return success; }
    public String getCode() { return code; }
    public String getMessage() { return message; }
    public T getData() { return data; }
}

五、注册与分组:按需激活

package com.example.dh.agent;

import io.agentscope.core.tool.Toolkit;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ToolkitConfig {

    @Bean
    public Toolkit digitalHumanToolkit(OrderTools orderTools,
                                       CrmTools crmTools,
                                       UserTools userTools) {
        Toolkit toolkit = new Toolkit();
        toolkit.register("user", userTools);
        toolkit.register("order", orderTools);
        toolkit.register("crm", crmTools);
        return toolkit;
    }

    /** 会话开始时,按数字人项目的用途只激活需要的工具组 */
    public void activateForSession(Toolkit toolkit, String scene) {
        switch (scene) {
            case "order_service" -> {
                toolkit.activate("user");
                toolkit.activate("order");
            }
            case "sales_assist" -> {
                toolkit.activate("user");
                toolkit.activate("crm");
            }
            default -> toolkit.activate("user");
        }
    }
}

六、启动与请求

mvn -pl digital-human-web spring-boot:run

curl -X POST http://localhost:8080/api/agent/chat \
  -H "Content-Type: application/json" \
  -d '{"projectId":"1001","sessionId":"s-1","userId":"u-42","text":"查询我的订单"}'

示例输出(未在当前环境实测,以下为预期结果):

{
  "sessionId": "s-1",
  "toolCalls": [
    {"name": "query_order", "arguments": {"orderId": null}, "success": true}
  ],
  "reply": "您最近一笔订单 20240612-7788 已于昨天从深圳仓发出,预计明天下午送达。"
}

验证清单:说"查询我的订单",命中的是 query_order,不是 query_crm_contact;说"帮我查这个订单 12ab"(格式不合法),命中 query_order 但返回 BAD_ARGUMENT,没有打到下游;把 OrderClient 指向一个黑洞地址,返回 DOWNSTREAM_TIMEOUT,会话仍能收到回复;说"查一下这个客户的联系人",在 order_service 场景下模型明确表示当前场景不支持该查询,而不是错误调用 query_order。

三个只读具
一组按需开
一验参数错
一测异常回

06、排查

这一式有两条必须走完的证据链。

证据链一:为什么"查询我的订单"会被路由到 CRM 工具。

现象:早期版本里,用户说"查询我的订单",模型返回的 tool call 是 query_crm_contact,参数是用户的手机号。

误判:第一反应是"模型太笨",于是往 systemPrompt 里加了一句"订单相关一律用 query_order"。

错误尝试: 我们在 systemPrompt 里补了一条硬规则。为什么错?因为这条规则只覆盖了"订单"两个字,第二次用户说"我那个包裹到哪了",模型又选了 CRM。真正的原因是两条工具的描述都太宽:query_order 的描述里写了"查询用户相关的订单信息",query_crm_contact 的描述里写了"查询用户相关的联系人信息","用户"和"查询"是重叠的。

正确做法是回到 Schema 本身。@Tool 注解里的 description 最终会变成模型看到的工具描述,它是唯一的路由信号。修正方式:把 query_order 的描述收敛成"查询当前会话用户自己的订单物流与状态",把 query_crm_contact 收敛成"按手机号或公司名查询 CRM 联系人档案,仅用于销售与客户成功场景",再配合工具组——order_service 场景下 CRM 组根本不激活,候选项里没有它。

证据:把 Toolkit 生成的工具定义打印出来,对比两个工具 description 的词汇重叠度;再对比激活与不激活 crm 组时,模型给出的 tool call 分布。这两步都不需要猜。

证据链二:工具异常到底该谁兜。

现象:订单服务偶发超时,用户在数字人页面上得到的是空白回复,日志里能看到 ResourceAccessException 一路冒到 Agent 的调用栈。

怀疑:框架没有兜住工具异常。

检查:回到 v2.0.3 的 agentscope-core,看工具调用链上对异常的约定——ToolBase / AgentTool 这一层,工具方法的返回值与异常各自意味着什么。我们的判断是:框架负责把工具结果带回推理循环,但"工具内部下游失败"属于业务语义,框架没有能力把它翻译成"应该告诉用户稍后再试"还是"应该让模型重新问订单号"。

证据:日志里异常发生的位置在工具方法内部(orderClient.findLatest),而不是框架的调度代码里;同一时刻其他会话正常。

根因:我们把"下游失败"和"工具 bug"混为一谈了。前者是正常的业务分支,应该以失败结果的形式返回;后者才是异常。修正:在 ToolGuard 里把 TimeoutException / ExecutionException 归一为 ToolResult.fail,只有编程错误(NPE、类型转换失败)才允许抛出。

修复后,同一场景下会话返回的是"订单系统暂时响应较慢,请稍后再试",工具线程池没有被打崩——ToolGuard 用的是独立的守护线程池,超时后 future.cancel 会触发中断。

上游贡献判断:是否可在最小 Demo 复现,可以,只需要一个会超时的假下游;v2.0.3 是否存在相关限制,这更像约定而非缺陷,工具异常语义属于业务边界;main 是否仍存在,尚未核验;是否已有 Issue/PR,未检索到,需按当前官方 Issues 核验;当前只能记录候选。

证据不看脸
调用链上看
错猜先认错
根因才立稳

07、优化

基于第06章的两条根因,V2 做四处修改。

修改一:工具描述即路由信号,描述必须写"边界"。

把每个 @Tool 的 description 按"能做什么 + 只读/写入 + 不适用于什么"三段式写。例如 query_order 的描述末尾加一句"不用于查询他人订单,不用于退款"。描述不是文档,是模型的路由依据。

修改二:所有工具统一返回 ToolResult,不抛业务异常。

工具方法签名统一为 ToolResult<T>,只有编程错误允许抛出。这样 Agent 永远能拿到一个可以叙述的结果对象。

修改三:只读组常驻,写入组按需激活。

user 组常驻,order 与 crm 按场景激活;未来的 cancel_order、refund_order 等写入工具单独成组,默认关闭,必须由会话显式打开,并且要求传入幂等键。

修改四:超时与幂等下沉到 ToolGuard。

读写分离:readOnly 用 3 秒超时,不做重试;writeOnce(idempotencyKey, action) 用幂等键去重,超时后不自动重试——写入重试是业务决策,不是框架决策。

新行为:工具调用失败时,Agent 收到 success=false 加 code 与 message,可以自己组织语言;写入类工具在没有激活的情况下不会出现在候选集里,模型看不到也就选不到;会话线程与工具线程隔离,一次超时不再影响下一轮对话。

验证方法:用一个可控的假下游(固定延迟、固定抛错)跑三组用例——正常、超时、格式错误;每组检查响应里的 toolCalls[].success 与 toolCalls[].code;检查 /actuator/metrics 中工具线程池的活跃线程数是否在超时后回落。

三态收结果
幂等写进键
超时自己管
写入要双查

08、演进

[同一输入] 用户:查询我的订单
    |
    +--[V1] 工具说明塞进 systemPrompt + 正则抠 JSON + if-else 分发
    |      代价:描述重叠导致误选;参数无校验;异常穿透导致静默
    |
    +--[V2] Toolkit 注册 + @Tool Schema + 工具组按需激活 + ToolGuard 归一
    |      代价:多一层注册与描述维护;描述质量直接影响行为,排查方式变化
    |
[Trade-off]
得到:可解释的工具选择、可分类的失败、可收敛的候选集、可隔离的线程
失去:写死直接调用那份简单;工具描述需要像接口文档一样被评审

逐项比较:正确性上,V1 依赖字符串匹配,V2 依赖 Schema 与候选集,参数校验从手写 if 变成框架与业务双重校验;稳定性上,V1 一次下游超时影响整轮会话,V2 隔离在工具线程池;复杂度上,V2 多了注册层、结果类型、Guard,代码量上升;成本上,多了一层描述维护,且描述需要评审;适用范围上,工具数量大于等于 3、有写入操作、需要身份上下文时 V2 明显更优;遗留问题是工具组如何与数字人项目的配置联动(配置界面暴露哪些组)尚未设计,Agent 对失败结果的叙述质量仍需 Prompt 侧配合。

旧路靠字符串
新路靠注册表
得到的是秩序
失去的是随性

09、洞见

9.1 反直觉判断:工具描述属于接口契约,不属于文案

工具描述会被模型当成唯一的路由依据,它的地位等同于 HTTP 接口的路径与参数说明。它应该进 Code Review,应该有评审标准,应该和代码一起版本化。把工具描述当成"顺手写一句",等于把路由逻辑交给措辞。

9.2 反直觉判断:异常不该被"消化掉",应该被"翻译"

"工具里 catch 住异常然后返回 null"是另一种极端。null 对 Agent 来说是不可解释的结果,模型只能编。正确做法是翻译:把 TimeoutException 翻译成"下游超时,可重试",把 404 翻译成"对象不存在,请用户确认",把 403 翻译成"当前会话无权访问"。翻译后的结果才是模型能用来组织语言的素材。

9.3 只读与写入必须是注册层的事,不是参数层的事

用 dryRun 参数区分读写,等于把安全开关交给了模型。写在 Toolkit 的组边界上,模型看不见写入工具,就不可能误调。安全边界应该放在模型看不见的地方。

9.4 什么时候不应该用本章这套机制

当你的"工具"只有一个、无副作用、参数是单个字符串、且调用结果不需要进入推理循环时,直接在业务代码里调用它,比注册成工具更简单也更可控。Toolkit 解决的是"多个工具之间的选择与边界"问题,只有一个工具的时候,这个问题不存在,引入注册层只会增加描述维护成本和一次不必要的模型决策。

另一个不该用的场景是:工具本身是一个长耗时任务(分钟级)。这种情况不应该把它做成一次同步工具调用,而应该拆成"提交任务 + 查询状态"两个工具,或者交给后续的 Plan 与异步机制处理。

9.5 ToolExecutionContext 是权限载体,不是便利贴

很多人把 ToolExecutionContext 当成一个 Map 来传数据。它真正的作用是让工具在执行时知道"谁在调、在哪个租户下、这次会话被允许做什么"。把它当便利贴,权限校验就会散落在每个工具方法里,最终无法审计。把身份、租户、场景统一从这里取,写入类工具必须校验,这是后面做权限与状态的地基。

边界即能力
少即是更多
失败要设计
上下文即权限

10、系统落地

原来有什么:chapter/03 冻结的最小数字人 Demo,一个能对话、能通过 LiveKit 实时交互、但什么都办不了的数字人。

本篇新增什么:chapter/09-toolkit 分支上的工具运行时——@Tool 声明的三个企业工具(订单、CRM、用户),Toolkit 注册与工具组,会话级按需激活,ToolGuard 统一处理幂等、超时与异常归一,ToolResult 统一返回语义。

现在能做什么:用户说一句"查询我的订单",数字人能够选中正确的工具、带上会话身份、拿到结构化的结果或失败原因,并把它变成一句人话。

还缺什么:一是权限仍写在工具方法内部,哪些角色能调 query_crm_contact 还没有统一模型;二是写入类工具(退款、改地址)还没有落地,对应的幂等键和审批边界没有设计;三是工具的耗时分布没有可观测数据;四是工具调用的完整链路还进不了后端日志检索。

下一步如何演进:把工具调用的耗时、成功率、失败码变成可观测指标;把工具组的激活策略从"按场景写死"变成由数字人项目配置驱动;再往后才是权限与状态的统一收口。

开源参与点:本章未发现明确上游问题。工具异常语义在我们这里最终被判定为业务边界而非框架缺陷,因此不构成 Issue 候选。如果你按 v2.0.3 复现时,发现 Toolkit 在工具组激活后仍会把未激活组的工具描述暴露给模型,那是一个值得先做最小复现、再对照 main 与已有 Issues 的候选问题。

底座已冻结
本章加工具
还缺权限层
下一步再收

11、小结

[Q1] 工具多了以后模型凭什么选对?
    |
    +--[A1] 描述即 Schema,候选集靠工具组与按需激活收敛
    |
[Q2] 工具报错谁来兜?
    |
    +--[A2] 工具内部归一为 ToolResult,异常被翻译成 Agent 能叙述的失败
    |
[Q3] 常驻还是按需激活?
    |
    +--[A3] 高频只读常驻,低频与写入按组激活,写入默认关闭
    |
[状态] chapter/09-toolkit 可跑通:查询我的订单 → 选对工具 → 参数校验 → 异常回收到 Agent

Q1 → 靠描述写清边界加工具组收敛候选集,而不是靠往 Prompt 里加规则。
Q2 → 业务失败由工具内部归一成 ToolResult,编程错误才抛异常,会话线程与工具线程隔离。
Q3 → 只读高频常驻,低频与写入按需激活,写入默认关闭。
状态 → chapter/09-toolkit 上,数字人第一次具备了"调用企业系统"的受控能力,且失败路径可控。

一问选得准
二问兜得住
三问按需开
状态可继续

12、作业

12.1 理解题:为什么说"工具越多,Agent 越笨",它能推出什么工程结论?

参考答案:工具选择是在候选描述之间做比较,候选越多,描述之间的差异越被稀释,误选概率上升。由此推出的结论是:工具组与按需激活属于可用性前提,而不是可选优化;工具描述需要像接口文档一样被评审;不要用"加工具"去解决"业务范围大"的问题。

12.2 实战题:在 chapter/09-toolkit 上新增一个写入工具,并保证它默认不可被调用。

参考答案:新增 CancelOrderTools,用 @Tool 声明 cancel_order,注册到独立的 order_write 组;Toolkit 初始化时不激活该组;工具内部要求传入幂等键,走 ToolGuard 的 writeOnce 分支;在会话层增加一个显式的"允许写入"开关,只有打开时才 activate。验收:开关未打开时,用户说"帮我取消订单",模型在候选集里看不到该工具。

12.3 排障题:用户说"查询我的订单",Agent 返回"我没有相关能力"。请给出排查顺序。

参考答案:先确认 order 组是否在该会话被激活(打印 Toolkit 当前激活的工具列表);再确认 query_order 是否返回了 NO_IDENTITY(会话里的 userId 是否透传到了 ToolExecutionContext);再确认工具描述是否被正确生成(Schema 里的 description 是否为空);最后确认模型侧看到的是不是完整的工具定义。顺序不能颠倒,因为"看不到工具"和"看到了但调用失败"在用户那里表现完全一样。

12.4 架构判断题:工具执行放在 Agent 的推理线程里,和放在独立工具线程池里,各自适用于什么场景?请打开固定 tag v2.0.3 的 agentscope-core 与 docs/v2/zh/docs/building-blocks/tool.md,说明工具调用链中"框架负责的边界"在哪里。

参考答案:推理线程内执行适合毫秒级、无网络依赖的纯计算工具,实现简单但没有超时保护,一次阻塞会拖慢整轮推理。独立线程池适合所有涉及网络下游的工具,代价是线程模型变复杂,需要处理中断与上下文传递。框架负责的边界是工具的注册、Schema 生成、候选集组织、调用与结果回灌;不负责的是下游的超时、重试、幂等与错误码语义。核对方式:看 Toolkit 如何组织工具定义与调用,看工具调用返回后结果如何进入推理循环。

读懂不算懂
跑通才算数
改过才算会
提过才算深

13、思考

工具这一层的核心冲突其实只有一句话:我们想给 Agent 更多能力,但每多一份能力,模型的选择空间和系统的失败面都在变大。

V1 的错不在 Prompt 写得不够狠,而在于我们把"工具"当成了文案。V2 的答案是把工具当成接口:有 Schema、有上下文、有边界、有失败语义。工具组与按需激活看起来是一个功能,本质上是在替模型管理注意力预算;ToolResult 归一看起来是一个返回类型,本质上是在替会话管理失败面。

真正的分水岭在异常处理那一步。愿意把"工具报错"当成一个需要设计的产品分支,而不是一个需要 catch 掉的意外,是"会用框架"和"能参与框架演进"的第一道分界。

工具不在多
边界在写清
失败先设计
演进才从容

posted @ 2026-10-11 11:17  李福春  阅读(3)  评论(0)    收藏  举报