降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、实战一次
环境与版本
- JDK 21 LTS
- Spring Boot 3.x(具体版本以官方 BOM 为准,需按当前官方文档核验)
- spring-ai-alibaba 1.1.2.2
- 模型:DashScope qwen-plus
- 练习仓库:https://github.com/lifuchun522/springaialibabapractice ,分支
chapter/06-tools
以下代码未在当前环境实测,以下为预期结果。
依赖与配置
<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 检索管

浙公网安备 33010602011771号