降SpringAI阿里第6掌-利涉大川-御器工具

降SpringAI阿里第6掌-利涉大川-御器工具

会说只是门票,会办事才是本事;而第一次放权,它改错了标题。

[读者] 已跑通 ChatClient 与 Memory 的 Java 后端
[痛点] 数字人只会念稿:问业务数据答不上,改配置全靠人工后台
[现在读] 要把业务能力交到模型手上,第一次触碰「写」这条线
[读完] 三个最小 Tool + 读写分治 + 确认/幂等/审计/超时围栏
[旧方案] 数字人 = System Prompt + 知识库,只产出文本
    |
    v
[新需求] 用户要它自己查数据、自己改标题、自己办业务
    |
    v
[冲突] 工具一旦可写,模型就能在你没点头时改你的库
    |
    v
[后果] 误写、重放、越权、事后无法追责

我是老李,一个在深圳写了十几年后端的工程师。前五掌我们把路铺得不短:第3掌的数字人底座已经能注册登录、能对数字人项目做 CRUD、能配标题主题色背景图、能填开场白结束语、能选 Agent 模型写 System Prompt;第4掌把 ChatClient 接上,第5掌把记忆和流式补齐。用起来是顺的,但它始终只是一个「会说话的壳」。

拐点出现在一次运营例会上。运营同学问:能不能直接对数字人说一句「把 dh_1001 的标题改成客服小壹」,就改完了?我说能。她接着又问:那顺便问一句「上周这个数字人聊了多少轮」,行不行?我说,也行。会后我盯着代码看了十分钟,发现这两件事的分量完全不一样——查数据是只读,改标题是写库。而我们手里唯一能表达「模型该干什么」的东西,是一段 System Prompt。

这一掌的任务是把业务能力真正交到模型手上。工具调用的本质,是把 Java 方法的签名投影成一份 JSON Schema,然后把「选哪个方法、填什么参数」的决策权让渡给模型。让渡出去的是决策权,不是执行权——方法体永远在你自己这边跑。

那么,模型说它要调用工具,这到底是一项能力,还是一次授权?

三个 Tool 是一股脑全塞给模型,还是按读和写拆成两条路走?

如果写操作就这么裸执行,第一次线上事故会长成什么样子?

01、故事

场景是数字人运营平台。dh_1001 是给一家做智能客服的客户做的数字人,配置项不多:标题、主题色、背景图、开场白、结束语、模型、System Prompt。运营的日常是——客户在群里说「标题换个说法」,运营登录后台、找到项目、改字段、保存、截图发群。一来一回十分钟。

这一掌的任务,是让这些高频小动作变成一句话。用户侧的自然语言是:「dh_1001 的主题色是什么」「查一下这个数字人上周的会话数」「把 dh_1001 的标题改成客服小壹」。系统侧要做的事,是把这三句话路由到三个 Java 方法上,并且让方法拿到的参数是可信的。

先说清楚我们当时手里的方案。数字人只有一个入口:ChatClient + System Prompt。项目信息是以文本形式拼进提示词的,改标题是人工后台。它是能用的,而且足够安全——因为模型从头到尾没有任何副作用能力,它只能输出字符串。

变化来自两个方向。第一,运营不想再登后台了,他们要「说话办事」。第二,客户开始问业务数据,而业务数据的口径每天在变,不可能靠拼提示词来解决。

冲突在这里:只要我们把 updateProjectTitle 这样的方法暴露出去,模型就获得了写能力。而模型是一个概率系统,它判断「用户想改标题」这件事,永远不是 100%。

我当时的判断是:这一步必须走,但走法很关键。工具调用不是「让模型更聪明」,而是「让模型的手可以伸进你的业务系统」。手的边界,必须由我们在代码里画死。

能言只是入场
能办才是本事
一放权先立界
未立界莫开闸

02、问题

旧方案失效得很干脆。System Prompt + 文本知识库这条链路,有三个顶不住的地方。

第一,数据会过期。项目信息被拼进提示词之后,它与数据库之间就断了联系。运营在后台改了标题,聊天窗口里的数字人还在用旧标题回答问题。

第二,模型不认识「实时」。客户问「上周会话数」,模型只知道提示词里有没有写这个数字,没有就只能编一个听起来合理的。

第三,写操作完全没有通道。所有变更都得回到人工后台。

业务影响是直接的:数字人从「看起来挺聪明」变成「不敢让它对外」,因为它说的每一句话都无法被验证。

技术上的表现是三类:一是模型输出的答案与库中事实不一致;二是运营侧的人力被消耗在机械操作上;三是当你想开放写能力时,发现系统里根本没有「谁在什么时间以什么身份改了什么」的记录。

所以本篇需要一个可验证的完成标准,而不是「能调通就行」这种描述。标准如下:

  • 模型能从自然语言中正确选到 getProjectInfo 与 queryBusinessData 两个只读工具,并填对 projectId、metric、时间区间。
  • 参数必须经过 JSON Schema 校验后才进入方法体。类型不对、枚举越界、必填缺失,都不能靠方法体里的 if 兜底。
  • updateProjectTitle 在人类明确确认之前,不允许产生任何写库动作。
  • 每一次工具调用都有审计记录:租户、用户、工具名、参数、结果、耗时、追踪号。
  • 同一个确认令牌重复使用,只能生效一次。
  • 工具执行有超时边界,下游卡住不能把整个对话线程拖死。

这六条里,前两条是「能用」,后四条是「敢用」。本篇的绝大部分篇幅,会花在后四条上。

旧法止于应答
新需落于执行
标准先于代码
边界先于能力

03、原理

Tool Calling 的机制并不神秘。你把一个带 @Tool 注解的方法交给 ChatClient,框架会做三件事:从方法签名生成一份 JSON Schema;把这份 Schema 连同工具描述一起发给模型;模型返回 tool_calls 时,框架把参数反序列化,反射调用你的方法,再把返回值塞回对话上下文。

关键的认知在这里:模型从来不执行代码,它只写参数。 这是整篇最重要的一条。很多人第一次看到「工具调用」这四个字,下意识会觉得是模型在跑你的函数。不是。模型输出的只是一段 JSON,是你的进程在跑函数。所以所有安全边界,都只能画在你自己的代码里。

由此推出第一个反直觉判断:提示词不是安全边界。 在 System Prompt 里写「请不要随意修改数据」,看起来是一条规则,实际上只是一个概率约束。它降低出事的概率,但不改变出事的能力。能力面的收窄必须靠结构,而不是靠语气。

第二个反直觉判断:工具描述不是文档,是接口契约本身。 模型的「接口文档」只有你写在 description 里的那几行字。你写 @ToolParam(description = "指标名称"),模型就只能靠猜;你把它写成一个 Java enum,框架生成的 Schema 里就带上了 enum 白名单,模型能填的值域被硬性收窄。前面那个「接口没有类型」的说法不是比喻——描述写得含糊,等价于你交出去一个 Object 参数。

第三个要点是 ToolContext。它不是工具入参,不进 Schema,模型看不见也填不了。它是你从调用侧塞进去的旁路数据,比如 tenantId、userId、traceId。这一点非常重要:身份永远不能由模型填。 只要 tenantId 出现在工具参数列表里,模型就有机会把它填成别人的。

第四个要点是 returnDirect。把工具标记为直接返回后,工具结果不再回到模型做二次加工,而是直接作为最终响应输出。对于「查一下会话数」这种确定性数据,这个开关的价值在于:模型没有再改写数字的机会。

关于 1.1.2.x 这条版本线上 agent framework 里的异步工具、returnDirect、工具重试等能力,本篇给出的是它们要解决的问题与验证方法;具体注解名、配置项与默认值,请以 v1.1.2.2 的源码与官方文档为准(需按当前官方文档核验)。

模型只写参数
代码才动数据
描述即是契约
身份不入 Schema

04、架构

[输入] 用户自然语言 + 会话身份(tenantId/userId/traceId)
    |
    v
[模块] ChatClient + 只读工具集 / 写工具集(两个独立 Bean)
    |
    v
[数据/状态] ToolContext 旁路身份 + 待确认变更表 + 工具审计表
    |
    v
[处理] Schema 校验 -> 权限断言 -> 写操作:令牌校验与幂等 -> 执行 -> 审计
    |
    v
[输出] 只读结果(可 returnDirect) / 待确认摘要(回模型或前端)

架构的边界很清楚:只读工具进 ChatModel 自由的对话循环,模型想调几次调几次;写工具进一条带门禁的通道,门禁包括权限、令牌、幂等、审计四道。

收益是显而易见的:模型从「只会说」变成「能查能改」,而运营不用再登后台。代价是你要多维护三样东西——一份精确的 Schema 描述、一张待确认变更表、一张审计表。适用条件是:业务操作本身是可枚举的、参数是可结构化的。如果一件事连接口都定义不清楚,那它不适合做成工具。

还有一个容易被忽略的边界:工具数量不是越多越好。每个工具都会占用上下文,也会增加模型选错的概率。三个工具是这一掌的合理起点,不是终点。

读走一条明路
写走一道关隘
上下文带身份
审计留铁证

05、实战一次

环境与版本

以下代码未在当前环境实测,以下为预期结果。

依赖与配置

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.alibaba.cloud.ai</groupId>
      <artifactId>spring-ai-alibaba-bom</artifactId>
      <version>1.1.2.2</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
spring:
  ai:
    dashscope:
      api-key: ${DASHSCOPE_API_KEY}
      chat:
        options:
          model: qwen-plus

领域模型与仓储

public class DigitalHumanProject {
    private final String id;
    private final String tenantId;
    private final String ownerId;
    private volatile String title;
    private final String themeColor;
    private final String backgroundUrl;
    private final String openingRemark;
    private final String closingRemark;
    private final String model;

    public DigitalHumanProject(String id, String tenantId, String ownerId, String title,
                               String themeColor, String backgroundUrl, String openingRemark,
                               String closingRemark, String model) {
        this.id = id;
        this.tenantId = tenantId;
        this.ownerId = ownerId;
        this.title = title;
        this.themeColor = themeColor;
        this.backgroundUrl = backgroundUrl;
        this.openingRemark = openingRemark;
        this.closingRemark = closingRemark;
        this.model = model;
    }

    public String id() { return id; }
    public String tenantId() { return tenantId; }
    public String ownerId() { return ownerId; }
    public String title() { return title; }
    public String themeColor() { return themeColor; }
    public String backgroundUrl() { return backgroundUrl; }
    public String openingRemark() { return openingRemark; }
    public String closingRemark() { return closingRemark; }
    public String model() { return model; }

    public void rename(String newTitle) { this.title = newTitle; }
}
@Repository
public class DigitalHumanProjectRepository {

    private final Map<String, DigitalHumanProject> store = new ConcurrentHashMap<>();

    public DigitalHumanProjectRepository() {
        store.put("dh_1001", new DigitalHumanProject(
                "dh_1001", "tenant_sz", "u_100", "客服小壹",
                "#2F6BFF", "https://cdn.example.com/bg/1001.png",
                "你好,我是客服小壹,有什么可以帮你?",
                "本次服务结束,感谢咨询。", "qwen-plus"));
    }

    public Optional<DigitalHumanProject> findById(String projectId) {
        return Optional.ofNullable(store.get(projectId));
    }

    public boolean updateTitle(String projectId, String newTitle) {
        DigitalHumanProject project = store.get(projectId);
        if (project == null) {
            return false;
        }
        project.rename(newTitle);
        return true;
    }
}

V1:三个最小 Tool(裸执行)

public enum BusinessMetric {
    SESSIONS,
    DURATION_SECONDS,
    AVG_TURNS
}
public record ProjectInfoVO(String projectId, String title, String themeColor,
                            String backgroundUrl, String openingRemark,
                            String closingRemark, String model) {
}

public record BusinessMetricVO(String projectId, BusinessMetric metric, String from,
                               String to, double value, long sampleSize) {
}
@Component
public class ProjectToolsV1 {

    private static final Logger log = LoggerFactory.getLogger(ProjectToolsV1.class);

    private final DigitalHumanProjectRepository repository;

    public ProjectToolsV1(DigitalHumanProjectRepository repository) {
        this.repository = repository;
    }

    @Tool(name = "getProjectInfo",
          description = "根据项目ID查询数字人项目的标题、主题色、背景图、开场白、结束语与绑定模型。只读操作。")
    public ProjectInfoVO getProjectInfo(
            @ToolParam(description = "数字人项目ID,形如 dh_1001") String projectId) {
        DigitalHumanProject project = repository.findById(projectId)
                .orElseThrow(() -> new IllegalArgumentException("项目不存在: " + projectId));
        return new ProjectInfoVO(project.id(), project.title(), project.themeColor(),
                project.backgroundUrl(), project.openingRemark(), project.closingRemark(),
                project.model());
    }

    @Tool(name = "queryBusinessData",
          description = "查询数字人项目在指定时间区间内的业务指标。只读操作。")
    public BusinessMetricVO queryBusinessData(
            @ToolParam(description = "数字人项目ID,形如 dh_1001") String projectId,
            @ToolParam(description = "指标名称") String metric,
            @ToolParam(description = "开始日期") String from,
            @ToolParam(description = "结束日期") String to) {
        log.info("queryBusinessData projectId={} metric={} from={} to={}", projectId, metric, from, to);
        return new BusinessMetricVO(projectId, BusinessMetric.valueOf(metric), from, to, 0d, 0L);
    }

    @Tool(name = "updateProjectTitle", description = "修改数字人项目标题。")
    public String updateProjectTitle(
            @ToolParam(description = "数字人项目ID,形如 dh_1001") String projectId,
            @ToolParam(description = "新的项目标题") String newTitle) {
        boolean ok = repository.updateTitle(projectId, newTitle);
        return ok ? "标题已更新为: " + newTitle : "项目不存在";
    }
}

ChatClient 与入口

@Configuration
public class ChatClientConfig {

    @Bean
    ChatClient chatClient(ChatModel chatModel, ProjectToolsV1 toolsV1) {
        return ChatClient.builder(chatModel)
                .defaultSystem("""
                        你是数字人运营助手,可以调用工具查询项目信息与业务数据。
                        涉及业务数据时,必须以工具返回值为准,不得自行估算。
                        """)
                .defaultTools(toolsV1)
                .build();
    }
}
public record ChatRequest(String userId, String message) {
}

@RestController
@RequestMapping("/api/chat")
public class ChatController {

    private final ChatClient chatClient;

    public ChatController(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @PostMapping
    public Map<String, Object> chat(@RequestBody ChatRequest request) {
        String answer = chatClient.prompt()
                .user(request.message())
                .toolContext(Map.of(
                        "tenantId", "tenant_sz",
                        "userId", request.userId(),
                        "traceId", UUID.randomUUID().toString()))
                .call()
                .content();
        return Map.of("answer", answer);
    }
}

启动与验证

./mvnw spring-boot:run
curl -s -X POST http://localhost:8080/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"userId":"u_100","message":"dh_1001 的主题色是什么"}'

示例输出:

{"answer":"dh_1001 的主题色是 #2F6BFF。"}
curl -s -X POST http://localhost:8080/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"userId":"u_100","message":"帮我看看 dh_1001 上周的会话数"}'

示例输出:

{"answer":"查询失败,请确认指标名称是否正确。"}

只读那一条跑通了,第二条没跑通。这不是环境问题,是设计问题——而这正是下一章要处理的东西。

先跑通最小集
再留下真问题
版本对官方核
结果标示例出

06、排查

现象一:模型填的指标名是中文,工具直接抛异常。

怀疑是模型选错了工具。检查方式是打开工具调用的 DEBUG 日志,把模型回传的 tool_calls 参数打出来,同时把框架生成的那份 JSON Schema 一起打出来。

证据很清楚:模型回传的 metric 是字符串「会话数」,而 Schema 里 metric 只有 {"type":"string"},没有任何枚举约束;@ToolParam(description = "指标名称") 这行描述,对模型来说等同于没写。

根因是:Schema 的取值域描述不足。模型没有错,是接口定义得太松。

修复方向有两个:一是把 metric 从 String 改成 Java enum,让框架自动生成 enum 白名单;二是在描述里显式写出中文别名到枚举值的映射。两个一起做。

错误尝试: 我第一反应是回提示词里加一句「指标名称必须使用英文枚举值 SESSIONS、DURATION_SECONDS、AVG_TURNS」。这是错的。它把结构问题当成了沟通问题——提示词里的约束是概率性的,模型会遵守大部分时候,但不会永远遵守;而 Schema 里的 enum 是框架层强制的,不符合的值连方法签名都进不去。用一句可能被忽略的话,去替代一个必然生效的类型,这是典型的修错层次。

现象二:用户在群里讨论「标题是不是叫客服小壹更好」,模型直接把库改了。

这次的怀疑方向是权限。检查方式是打印该次请求的完整对话与工具调用链。

证据是:updateProjectTitle 被调用了一次,参数来自模型对用户那句疑问句的理解。而 V1 的写工具没有任何前置门禁——同一个 defaultTools 里同时挂着读工具和写工具,模型只要判断「用户想改标题」,就直接落库。

根因两条:一是读写工具同权限暴露;二是写操作缺少人类确认这一环。工具粒度等于业务原子操作,但模型对「讨论」和「指令」的区分,本来就是概率性的。

修复方向:把读工具和写工具拆成两个 Bean、两个 ChatClient;写工具改成两阶段——不带令牌时只生成待确认变更,带令牌时才落库。

现象三:运营连点两次确认,第二次把标题写回了旧值。

怀疑是并发。检查方式是查看待确认记录的状态流转与写库时间戳。

证据是:同一个确认令牌被消费了两次,第二次携带的是第一次的旧参数。

根因是令牌不是一次性消费的,且写操作没有幂等键。修复方向:令牌用 SET NX + TTL 落 Redis,消费即删除;写操作以「追踪号 + 项目ID + 新标题」为幂等键,落审计表唯一索引。

现象不猜先证
错误尝试记下
根因落在边界
修复才不返工

07、优化

第 06 章给出了三条证据,V2 的每一处修改都对应其中一条。

修改一:把 metric 换成枚举,描述里补上中文别名。

原因是 Schema 的约束力远强于描述。新行为是模型填出非枚举值时,框架层直接拒绝,方法体拿到的一定是合法值。验证方式:故意让模型填「会话数」,观察它是否在第一次就被纠正、并在第二次带上 SESSIONS 重试。

修改二:读写分治,写工具走两阶段令牌。

public record PendingTitleChange(String token, String projectId, String tenantId,
                                 String userId, String newTitle, Instant expiresAt) {
}

public record TitleUpdateResult(String status, String message, String confirmToken,
                                String projectId, String title) {

    public static TitleUpdateResult pending(String token, String message) {
        return new TitleUpdateResult("PENDING_CONFIRM", message, token, null, null);
    }

    public static TitleUpdateResult applied(String projectId, String title) {
        return new TitleUpdateResult("APPLIED", "标题已更新为: " + title, null, projectId, title);
    }

    public static TitleUpdateResult rejected(String message) {
        return new TitleUpdateResult("REJECTED", message, null, null, null);
    }
}
@Component
public class ProjectWriteTools {

    private static final Duration TOKEN_TTL = Duration.ofMinutes(5);
    private static final Duration TOOL_TIMEOUT = Duration.ofSeconds(3);

    private final DigitalHumanProjectRepository repository;
    private final PendingChangeStore pendingChangeStore;
    private final ToolAuditRepository auditRepository;
    private final PermissionChecker permissionChecker;

    public ProjectWriteTools(DigitalHumanProjectRepository repository,
                             PendingChangeStore pendingChangeStore,
                             ToolAuditRepository auditRepository,
                             PermissionChecker permissionChecker) {
        this.repository = repository;
        this.pendingChangeStore = pendingChangeStore;
        this.auditRepository = auditRepository;
        this.permissionChecker = permissionChecker;
    }

    @Tool(name = "updateProjectTitle",
          description = """
                  修改数字人项目标题。这是写操作,必须分两步:
                  第一步:只传 projectId 与 newTitle,不传 confirmToken。系统只生成待确认变更,
                          不会修改任何数据,返回 confirmToken 与变更摘要。
                  第二步:用户明确表示确认后,把第一步的 confirmToken 原样回传,才会真正落库。
                  令牌 5 分钟有效且只能用一次。用户未确认时,禁止传入 confirmToken。
                  """)
    public TitleUpdateResult updateProjectTitle(
            @ToolParam(description = "数字人项目ID,形如 dh_1001") String projectId,
            @ToolParam(description = "新的项目标题,1~60 个字符,不得为空") String newTitle,
            @ToolParam(description = "确认令牌;仅当用户明确确认后传入", required = false)
            String confirmToken,
            ToolContext toolContext) {

        String tenantId = (String) toolContext.getContext().get("tenantId");
        String userId = (String) toolContext.getContext().get("userId");
        String traceId = (String) toolContext.getContext().get("traceId");

        if (newTitle == null || newTitle.isBlank() || newTitle.length() > 60) {
            auditRepository.record(traceId, tenantId, userId, projectId,
                    "updateProjectTitle", "REJECTED_ARG", newTitle, null);
            return TitleUpdateResult.rejected("标题长度必须在 1~60 个字符之间");
        }

        if (!permissionChecker.canWrite(tenantId, userId, projectId)) {
            auditRepository.record(traceId, tenantId, userId, projectId,
                    "updateProjectTitle", "DENIED", newTitle, null);
            return TitleUpdateResult.rejected("当前用户没有修改该项目的权限");
        }

        if (confirmToken == null || confirmToken.isBlank()) {
            String token = UUID.randomUUID().toString();
            PendingTitleChange pending = new PendingTitleChange(token, projectId, tenantId,
                    userId, newTitle, Instant.now().plus(TOKEN_TTL));
            return withTimeout(() -> {
                pendingChangeStore.saveOnce(pending);
                auditRepository.record(traceId, tenantId, userId, projectId,
                        "updateProjectTitle", "PENDING", newTitle, token);
                return TitleUpdateResult.pending(token,
                        "待确认:将 " + projectId + " 的标题改为《" + newTitle + "》,5 分钟内有效");
            }, TitleUpdateResult.rejected("生成待确认变更超时,请重试"));
        }

        Optional<PendingTitleChange> consumed =
                pendingChangeStore.consumeOnce(confirmToken, tenantId, userId);
        if (consumed.isEmpty()) {
            auditRepository.record(traceId, tenantId, userId, projectId,
                    "updateProjectTitle", "TOKEN_REJECTED", newTitle, confirmToken);
            return TitleUpdateResult.rejected("确认令牌无效、已过期或已被使用,请重新发起变更");
        }

        PendingTitleChange pending = consumed.get();
        if (!pending.projectId().equals(projectId) || !pending.newTitle().equals(newTitle)) {
            auditRepository.record(traceId, tenantId, userId, projectId,
                    "updateProjectTitle", "TOKEN_MISMATCH", newTitle, confirmToken);
            return TitleUpdateResult.rejected("令牌与本次变更内容不匹配,请重新发起");
        }

        return withTimeout(() -> {
            boolean firstTime = auditRepository.applyOnce(traceId, projectId, newTitle, userId);
            if (!firstTime) {
                return TitleUpdateResult.applied(projectId, newTitle);
            }
            boolean ok = repository.updateTitle(projectId, newTitle);
            auditRepository.record(traceId, tenantId, userId, projectId,
                    "updateProjectTitle", ok ? "APPLIED" : "NOT_FOUND", newTitle, confirmToken);
            return ok ? TitleUpdateResult.applied(projectId, newTitle)
                      : TitleUpdateResult.rejected("项目不存在: " + projectId);
        }, TitleUpdateResult.rejected("写操作超时,未确认是否落库,请查询项目当前标题"));
    }

    private <T> T withTimeout(Callable<T> task, T fallback) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            Future<T> future = executor.submit(task);
            return future.get(TOOL_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            return fallback;
        } catch (Exception e) {
            throw new IllegalStateException("工具执行失败", e);
        }
    }
}

修改的原因是:确认、幂等、审计、超时这四件事,都属于「必须在代码里做」的事。新行为是第一次调用返回 PENDING_CONFIRM 且库不变,第二次带上令牌才落库,重复令牌直接拒绝,超过 3 秒返回一个「不确定结果」而不是无限等待。

修改三:只读工具启用直返,写工具不启用。

对于纯查询类结果,把结果直接作为最终输出,避免模型对确定性数字做二次生成。验证方式:对比同一次查询在开关前后的回答,开启后回答里的数字应与工具返回值逐字一致。

以上验证均为方案描述,未在当前环境实测,需在分支 chapter/06-tools 上自行跑通并记录结果。

有证才改代码
无证只算猜测
令牌一次即焚
审计一笔不落

08、演进

[同一句:把 dh_1001 的标题改成客服小壹]
    |
    +--[V1] 模型直接调用 updateProjectTitle -> 立即落库 / 代价:无确认、无审计、无幂等
    |
    +--[V2] 先返回待确认令牌 -> 人类确认 -> 校验令牌与幂等 -> 落库并审计 / 代价:多一轮交互
    |
[Trade-off]
得到:写操作不可能裸执行,重复提交与越权被结构性阻断
失去:一句话直达的爽感,单次变更变成两轮对话
适用边界:可枚举、可参数化、参数能被人类复核的业务写操作

正确性上,V1 在「用户明确指令」场景下是对的,在「用户讨论」场景下是错的,而这两种场景在自然语言里长得几乎一样。V2 把判断权从模型手里拿走了一半:模型只负责生成待确认摘要,人类负责点头。

稳定性上,V1 的问题在于下游异常会直接冒泡成对话失败;V2 给写操作加了超时与兜底返回,且兜底内容是「不确定,请查询当前标题」,而不是假装成功。

复杂度上,V2 明显更重:多了两张表、一个 Redis 键空间、一次人类交互。这是真实成本,不能假装没有。

成本上,V2 的单次变更交互轮次翻倍。所以判断依据是频率与风险:一天改一次标题的配置类操作,值得上确认;一天几万次的埋点上报,就不该走这条路。

适用范围上,V2 的做法适用于「原子、可复核、低频」的写操作。对于高频写操作,正确的做法通常不是确认,而是抽成幂等的后端接口,让模型只负责发起。

遗留问题有两个:一是待确认变更的过期清理还没做,需要一个定时任务;二是当工具数量继续增长时,如何给模型做工具检索与裁剪,这要留到多 Agent 那一掌。

同一句自然语
两条路不同价
得的是确定性
失的是自由度

09、洞见

9.1 工具不是函数,是权限面

把一个方法注册成 @Tool,表面上是暴露了一个函数,实际上是在系统上开了一个被自然语言驱动的入口。函数是技术概念,入口是安全概念。这两个视角的差别,决定了你会不会在写工具上加门禁。凡是把工具当成函数的团队,最后都会在某次事故里重新认识这件事。

反直觉判断:工具的风险等级,不由它的实现复杂度决定,而由它是否产生副作用决定。 一个十行的 updateProjectTitle 比一个两百行的 RAG 检索管

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