AgentScope第12式·护体权限
AgentScope第12式·权限边界
工具能调用,不等于它被允许调用。
[读者] 正在把数字人 Demo 推到内部试用的 Java 后端
[痛点] Agent 已经能连着调工具,却没有一层管它该不该调
[现在读] 内测一旦落到真实数据上,靠提示词求它别乱来是不可接受的
[读完] 你能把 ALLOW / DENY / ASK 落到工具粒度,并跑通 HITL 三条路径
[旧方案] 工具全量注册,systemPrompt 里写一句「不要擅自修改客户资料」
|
v
[新需求] 查订单自动放行,改客户资料和提工单必须人工确认,资金动作直接拒绝
|
v
[冲突] 模型只产出「意图 + 参数」,整条链路里没有任何一层做确定性授权
|
v
[后果] 一句多轮对话里的「顺手调用」,就能改掉一条真实客户主数据
我是老李,这个系列一直用一个数字人 Demo 当贯穿案例,从第 3 式冻结之后就没换过场景。
到第 11 式结束,Demo 已经是个能用的东西:项目页能配名称、主题色、开场白,也能配 agentName、model、systemPrompt;/run/{projectId} 能连 LiveKit;Realtime Bridge 把消息递给 AgentScope Java,Agent 自己决定要不要调工具。查订单、改客户资料、提工单、退款,这四个工具是平权的。
平权这两个字,就是这一式要拆掉的东西。
模型自己决定调用某个工具,这算不算用户授权?
如果不算,那授权检查该放在哪一层,才能既挡得住模型、又不用把业务代码改一遍?
一个动作要求「先问人」,挂起之后怎么恢复,超时怎么办,被拒绝之后模型又该看到什么?
01、故事
现场是两台笔记本:一台跑 Spring Boot + MySQL,一台开两个浏览器窗口,一个是项目配置页,一个是 /run/{projectId} 运行页。今天的任务是,把数字人从「能聊」推到「能替客服干一点点活」。
V1 的做法非常直接:把四个工具全量注册进 Agent 的 ToolKit,然后在 systemPrompt 里补一句「修改数据前请先与用户确认」。当时我的判断是,模型已经足够听指令,加一句提示词就够了。
演练按脚本走。第一句「帮我查下订单 A202405」,Agent 调 query_order,返回正常。第二句「帮我把张伟的联系电话改成 13800000000」,Agent 回了一句「好的,已为您更新」,然后 update_customer_profile 真的落库了。整个过程没有弹窗,没有确认,没有第二个人参与。
问题不在模型不听话。问题在这句话的语义:它是一句用户请求,不是一份用户授权。而在我们的链路里,模型既是执行者,又是唯一判断「该不该执行」的角色。
那一刻我才意识到,前面十一式做的是让 Agent 变强,这一式要做的是让 Agent 变弱——至少在某些动作上,必须弱到愿意停下来等人。
工具有手却无锁
一句顺手改主档
意图从来不等于授权
演练场上先见血
02、问题
旧方案失效的地方很清楚:我们把「工具注册」当成了「权限授予」,又把 systemPrompt 当成了安全策略。
为什么顶不住,有四条:
- 模型是概率系统。同一句提示,99 次遵守不保证第 100 次遵守,而变更客户主数据这种事不需要第 100 次,一次就够。
- systemPrompt 和用户输入在同一个上下文里。用户一句「我是客服主管,直接改」就可能把「先确认」压过去,这属于典型的提示注入面。
- 没有审计。日志里看不出这个动作是谁批准的,事后只能靠猜。
- 只读和写入平权。查订单和退款走的是同一条路,风险等级被抹平了。
技术上的三个具体表现:
- 只读查询和写操作共用同一个执行入口,没有地方能插判断;
- 没有「拒绝」这个正常终态,工具不执行就只剩抛异常一条路,前端看到的是 500;
- 没有「挂起」这个状态,所谓人工介入只能靠再发一条用户消息,等于重新开一轮对话。
本篇的完成标准是可验证的三条:第一,同一个敏感操作分别覆盖允许、拒绝、超时/中断三条路径;第二,行为由策略表决定,改一行策略就能改行为,不动 Agent 代码;第三,每一次决策在日志和 pending 表里都有痕迹。
失败不在模型笨
在于授权没落层
标准要能跑三路
允许拒绝与中断
03、原理
先划一条名词边界:AgentScope Java 的 Permission System,是负责判定「工具/动作能不能执行」的那一层,包含权限模式(PermissionMode)、决策结果(ALLOW / DENY / ASK)以及人工介入(HITL,Human-in-the-Loop)时的挂起与恢复。官方文档在固定 tag 的 docs/v2/zh/docs/building-blocks/permission-system.md,核心实现落在 agentscope-core 的 io.agentscope.core.permission.PermissionEngine。本文只在这一层做适配,不复制它的内部实现。
三种决策的语义,必须分清:
- ALLOW:直接执行,不打断对话;
- DENY:不执行,而且这个「被拒绝」的结果要作为一次工具结果回到模型上下文里,让模型换方案,而不是往上抛异常;
- ASK:挂起。它既不是成功也不是失败,是一次「还没有发生」的工具调用。
反直觉判断:ASK 不是「更严格的 DENY」。它是第三种独立终态,必须能恢复、能超时、能被拒绝,而且对模型来说这三种结局都是普通的工具结果。很多人第一版会把 ASK 实现成「抛一个需要确认的异常」,结果就是恢复时整个对话重跑一遍。
规则优先级也要说清楚,否则策略表迟早被绕过:显式 DENY 优先于任何 ALLOW;精确匹配优先于通配匹配;没有任何规则命中时,默认拒绝(fail-closed)。如果实现里无匹配默认放行,那这套策略在某个时刻一定会被绕过——通常是新增了一个没写进策略表的工具。
最后是位置。为什么闸门必须放在工具真正执行之前,而不是放在模型输出之后?因为模型的自然语言输出不可判定。「已为您更新」这句话本身不携带任何关于是否真的执行的保证,它只是一段文本。只有把检查放在真正调用工具的那一行代码前面,授权才是确定性的。这个位置选择直接决定了第 04 章的架构,也决定了第 07 章 fail-closed 和一次性恢复这两个优化的必要性。
提示词不是授权书
概率模型守不住边界
检查必须落在调用前
先判拒绝再判放行
04、架构
[输入] Realtime Bridge 上报的工具调用请求(sessionId + toolName + args)
|
v
[模块] PermissionGate(教程侧策略解析)+ PermissionEngine(框架侧决策)
|
v
[数据/状态] tool_policy 策略表 + pending_permission 挂起表
|
v
[处理] 归一化工具名 → 规则匹配 → ALLOW 执行 / DENY 回注 / ASK 挂起等确认
|
v
[输出] 工具结果回注模型 / 前端确认弹窗 / 拒绝与超时的中断结果
边界:这一层只回答「这个动作能不能发生」,不负责业务校验、不负责参数合法性、不负责限流。参数是否合法属于工具自己的事,权限层不应该替它做判断。
收益:授权判据从模型上下文里搬到了代码里,模型看得见工具,但改不了策略;只读和写操作被分开;每次决策可审计。
代价:多了一次判定和一次数据库写入;ASK 路径上多了一次人机往返,对连续对话的流畅度有直接影响。
适用条件:任何会改变外部状态的动作——写库、发消息、提交工单、调用第三方系统、花钱。
不适用条件:纯粹的只读查询、幂等且无副作用的计算工具,以及延迟敏感、人类根本无法在几百毫秒内做决定的实时语音回合。这些场景全量 ASK 只会把体验毁掉。
涉及框架内部机制的部分,证据位置是 v2.0.3 的 PermissionEngine 源码:https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/agentscope-core/src/main/java/io/agentscope/core/permission/PermissionEngine.java ,对应的行为说明在 https://github.com/agentscope-ai/agentscope-java/blob/v2.0.3/docs/v2/zh/docs/building-blocks/permission-system.md 。具体方法签名与枚举名请以这两个位置为准,本文下面的代码是教程侧的适配实现,只依赖「决策三态」这个契约。
输入先过策略层
命中规则才出门
状态留在待确认
输出执行或落库
05、实战一次
环境与版本:JDK 17、Spring Boot 3.x、MySQL 8、AgentScope Java v2.0.3。本章分支是 chapter/12-permission-hitl,从 chapter/11 继续创建。
先建挂起表,这是 ASK 能落地的前提:
CREATE TABLE pending_permission (
id VARCHAR(64) PRIMARY KEY,
session_id VARCHAR(64) NOT NULL,
project_id VARCHAR(64) NOT NULL,
tool_name VARCHAR(128) NOT NULL,
args_json TEXT NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING / APPROVED / REJECTED / EXPIRED
reason VARCHAR(255),
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
decided_at DATETIME,
KEY idx_session_status (session_id, status)
);
策略解析器,注意规则顺序不参与决策,优先级在排序里显式表达:
public enum PolicyDecision { ALLOW, DENY, ASK }
public record ToolPolicy(String toolPattern, PolicyDecision decision, String reason) {}
@Component
public class ToolPermissionPolicy {
private static final ToolPolicy DEFAULT_DENY =
new ToolPolicy("*", PolicyDecision.DENY, "无匹配规则,默认拒绝");
private final List<ToolPolicy> rules = List.of(
new ToolPolicy("query_order", PolicyDecision.ALLOW, "只读订单查询"),
new ToolPolicy("update_customer_profile", PolicyDecision.ASK, "写客户主数据"),
new ToolPolicy("create_ticket", PolicyDecision.ASK, "对外提交工单"),
new ToolPolicy("refund*", PolicyDecision.DENY, "资金动作本期不开放")
);
public ToolPolicy resolve(String rawToolName) {
String name = normalize(rawToolName);
return rules.stream()
.filter(r -> matches(r.toolPattern(), name))
.sorted(Comparator
.comparingInt((ToolPolicy r) ->
r.decision() == PolicyDecision.DENY ? 0 : 1)
.thenComparingInt(r -> r.toolPattern().contains("*") ? 1 : 0))
.findFirst()
.orElse(DEFAULT_DENY);
}
private String normalize(String rawName) {
if (rawName == null || rawName.isBlank()) {
return "";
}
int idx = rawName.lastIndexOf("__");
String name = idx >= 0 ? rawName.substring(idx + 2) : rawName;
return name.trim().toLowerCase(Locale.ROOT);
}
private boolean matches(String pattern, String name) {
if ("*".equals(pattern)) {
return true;
}
if (pattern.endsWith("*")) {
return name.startsWith(pattern.substring(0, pattern.length() - 1));
}
return pattern.equals(name);
}
}
闸门本体,返回三态而不是抛异常:
public record GateResult(PolicyDecision decision, String toolName,
String pendingId, String reason) {
public static GateResult allow(String toolName) {
return new GateResult(PolicyDecision.ALLOW, toolName, null, null);
}
public static GateResult deny(String toolName, String reason) {
return new GateResult(PolicyDecision.DENY, toolName, null, reason);
}
public static GateResult ask(String toolName, String pendingId, String reason) {
return new GateResult(PolicyDecision.ASK, toolName, pendingId, reason);
}
public boolean executable() {
return decision == PolicyDecision.ALLOW;
}
}
@Component
public class PermissionGate {
private final ToolPermissionPolicy policy;
private final PendingPermissionRepository pendingRepository;
public PermissionGate(ToolPermissionPolicy policy,
PendingPermissionRepository pendingRepository) {
this.policy = policy;
this.pendingRepository = pendingRepository;
}
public GateResult beforeToolCall(String sessionId, String projectId,
String toolName, String argsJson) {
ToolPolicy matched = policy.resolve(toolName);
switch (matched.decision()) {
case ALLOW:
return GateResult.allow(toolName);
case DENY:
return GateResult.deny(toolName, matched.reason());
case ASK:
PendingPermission pending = pendingRepository.create(
sessionId, projectId, toolName, argsJson, Duration.ofSeconds(60));
return GateResult.ask(toolName, pending.id(), matched.reason());
default:
return GateResult.deny(toolName, "未知决策,按拒绝处理");
}
}
}
恢复与中断,双向都要能落到会话上:
@Service
public class AgentResumeService {
private final PendingPermissionRepository pendingRepository;
private final ToolExecutor toolExecutor;
private final AgentSessionRegistry sessionRegistry;
public AgentResumeService(PendingPermissionRepository pendingRepository,
ToolExecutor toolExecutor,
AgentSessionRegistry sessionRegistry) {
this.pendingRepository = pendingRepository;
this.toolExecutor = toolExecutor;
this.sessionRegistry = sessionRegistry;
}
public void resume(String pendingId) {
PendingPermission pending = pendingRepository.find(pendingId);
AgentSession session = sessionRegistry.get(pending.sessionId());
String result = toolExecutor.execute(pending.toolName(), pending.argsJson());
session.appendToolResult(pending.toolName(), result);
session.continueReasoning();
}
public void interrupt(String pendingId, String reason) {
PendingPermission pending = pendingRepository.find(pendingId);
AgentSession session = sessionRegistry.get(pending.sessionId());
session.appendToolResult(pending.toolName(), "DENIED_BY_USER: " + reason);
session.continueReasoning();
}
}
控制器与超时收口:
@RestController
@RequestMapping("/api/agent")
public class AgentPermissionController {
private final PendingPermissionRepository pendingRepository;
private final AgentResumeService resumeService;
public AgentPermissionController(PendingPermissionRepository pendingRepository,
AgentResumeService resumeService) {
this.pendingRepository = pendingRepository;
this.resumeService = resumeService;
}
@PostMapping("/pending/{id}/decision")
public ResponseEntity<Map<String, Object>> decide(@PathVariable String id,
@RequestBody DecisionRequest request) {
int updated = pendingRepository.decide(id, request.decision());
if (updated == 0) {
return ResponseEntity.status(409)
.body(Map.of("error", "pending 已被处理或已超时", "pendingId", id));
}
if ("APPROVED".equals(request.decision())) {
resumeService.resume(id);
return ResponseEntity.ok(Map.of("status", "RESUMED", "pendingId", id));
}
resumeService.interrupt(id, "用户拒绝执行");
return ResponseEntity.ok(Map.of("status", "REJECTED", "pendingId", id));
}
@Scheduled(fixedDelay = 5000)
public void sweep() {
pendingRepository.expireOverdue();
}
public record DecisionRequest(String decision) {}
}
启动与三条请求(以下为示例输出,未在当前环境实测,仅表示预期结果):
curl -s -X POST localhost:8080/api/agent/chat -H 'Content-Type: application/json' \
-d '{"projectId":"p-1001","sessionId":"s-1","message":"帮我查一下订单 A202405"}'
# 预期:{"toolCalls":[{"tool":"query_order","decision":"ALLOW","status":"DONE"}]}
curl -s -X POST localhost:8080/api/agent/chat -H 'Content-Type: application/json' \
-d '{"projectId":"p-1001","sessionId":"s-1","message":"帮我把张伟的电话改成 13800000000"}'
# 预期:{"toolCalls":[{"tool":"update_customer_profile","decision":"ASK","status":"PENDING","pendingId":"pp-8f3c1a20"}]}
curl -s -X POST localhost:8080/api/agent/pending/pp-8f3c1a20/decision \
-H 'Content-Type: application/json' -d '{"decision":"APPROVED"}'
# 预期:{"status":"RESUMED","pendingId":"pp-8f3c1a20"}
拒绝和超时分别把 decision 换成 REJECTED、或者干脆不回调等 60 秒,预期结果分别是 status=REJECTED 和 status=EXPIRED,两条路径都会把中断结果回注给模型。
三行策略定生死
一表挂起等人答
同一动作三路走
预期结果要标清
06、排查
证据链一:策略写了 ASK,行为却是直接执行。
现象:策略表里明明配了 update_customer_profile = ASK,演练时 Agent 依然直接改了库。
怀疑:策略没加载 / 缓存了旧的 / 工具名不匹配。
检查:在闸门入口打一行 log,把入参工具名和解析结果一起打出来,再和工具注册名做对比。示例输出如下:
[gate] toolName=mcp__crm__update_customer_profile -> ALLOW(reason=无匹配规则,默认拒绝)
等等,这行日志里 reason 和 decision 明显矛盾——实际那一版默认分支是 ALLOW,reason 写的是「无匹配规则,默认放行」。这就是根因:MCP 注册的工具名带 server 前缀,而策略 key 写的是裸名,模式匹配失败,直接落到了默认分支;而默认分支当时是放行。
修复两处:工具名归一化(截取最后一段 __ 之后的部分并小写),默认分支改成 DENY。
证据链二:前端点了确认,Agent 却从头又跑了一遍。
现象:前端确认后接口返回 200,但这次会话里 query_order 被调了两次,用户看到开头那句话被重复回复。
怀疑:前端重复提交 / 工具自动重试。
检查:查 pending 表的状态流转,再看 resume 入口的日志和 sessionId 是否一致。
根因:第一版把「恢复」实现成了「再发一条用户消息」,于是模型重新走了一遍推理,历史里的工具结果被覆盖,副作用工具被二次触发。这不是重试问题,是语义问题:ASK 的恢复应该续上「那次还没发生的调用」,而不是开一轮新对话。
修复:decide 用带状态条件的 UPDATE(status = 'PENDING'),更新行数为 0 直接返回 409;resume 只消费一次 pending,不写入新的用户消息。
错误尝试: 第一版我在 systemPrompt 里加了一整段「严禁修改客户资料,必须先向用户确认」。这是错的,原因是它把授权判据交回给了被授权对象:模型遵守提示是概率性的;提示词和用户输入在同一个上下文里,用户换一种说法就可能压过去;而且没有任何一条日志能证明「是模型判断错了还是压根没判断」。正确的做法是把这个判据移出上下文,放进模型看不见、也改不了的代码里。
现象先记再怀疑
证据不足不下结论
错误尝试当路标
根因落到匹配键
07、优化
基于第 06 章两条证据,V2 做了四处修改。
第一处,默认 fail-closed。默认分支从 ALLOW 改成 DENY,并把默认拒绝的原因写清楚。原因是新增工具时最容易忘记更新策略表,默认放行等于给未来的自己埋雷。新行为:任何未登记的工具一律不执行,模型会收到一次明确的拒绝结果。验证方法是临时注册一个没写进策略表的工具,预期看到 decision=DENY。
第二处,优先级显式化。用排序表达「DENY 优先,其次精确匹配优先于通配」,而不是依赖规则声明顺序。原因是顺序依赖是隐式契约,改一行规则顺序就能静默改变行为,这在权限系统里是最不该出现的脆弱性。验证方法是把 refund* 规则挪到列表末尾,预期决策不变。
第三处,挂起一次性。decide 只在 status = 'PENDING' 时更新成功,其余一律 409。原因是防止确认弹窗被重复点击、或者网络重试导致同一次动作被执行两次。验证方法是同一条 pending 连点两次确认,预期第二次返回 409。
第四处,超时与中断走同一条出口。超过 expires_at 的 pending 由定时任务置为 EXPIRED,并且和用户主动拒绝走同一个 interrupt 方法,把中断结果以工具结果的形式回注给模型。原因是模型需要知道「这次没执行」,它才能换方案或者向用户解释,而不是卡在一个永远不会返回的调用上。
这里有一个值得强调的细节:DENY 和 EXPIRED 都不应该变成 HTTP 500 或者框架异常。它们是一次正常的工具结果,只是内容是「被拒绝了」。
默认拒绝最便宜
规则优先级写死
挂起不是失败态
超时要能自动收
08、演进
[同一句「帮我把张伟的电话改了」]
|
+--[V1] 直接执行 update_customer_profile,无回执;代价:授权完全交给模型
|
+--[V2] 命中 ASK,前端弹确认;确认后恢复,拒绝或 60 秒超时后中断;代价:一次额外的人机往返
|
[Trade-off]
得到:确定性边界 + 可审计的确认记录 + 三条路径都有明确结局
失去:连续对话的零摩擦体验,ASK 动作必然要等一个人
适用边界:任何会改变外部状态的动作;纯只读与无副作用的计算类工具不要走 ASK
把两版放在一起比:
正确性上,V1 的正确性取决于模型的当次输出,V2 的正确性取决于策略表,是确定性的。
稳定性上,V1 遇到没写进提示词的新工具会静默放行,V2 会静默拒绝——同样是默认行为,Fail-closed 的默认错在保守侧。
复杂度上,V2 多了一张表、一个定时任务、一个前端确认组件和一个恢复服务,这是实打实的成本。
成本上,主要不是机器成本,而是人的成本:每一次 ASK 都是一次人类注意力的占用,策略定宽了等于没有,定窄了等于把数字人变成需要盯着的工具。
适用范围上,V2 覆盖写库、对外提交、外部系统调用;V1 只适合纯演示。
遗留问题有三个:策略现在是硬编码在 Java 里的,项目级差异化配置还没做;确认界面还没区分「本次允许」和「本会话允许」;审计只落在 pending 表,还没有和会话日志打通。
同一句话两条路
快的代价是风险
稳的代价是延迟
边界之内再谈体验
09、洞见
9.1 授权判据必须离开模型上下文
反直觉判断:让 Agent 更安全的方式,不是给它更多安全提示,而是让它对安全决策一无所知。
模型能看见工具描述、能生成参数、能解释结果,但它不应该有能力改变「什么可以执行」这件事。判据一旦写进 systemPrompt,它就变成了上下文的一部分,和用户输入处在同一个可被影响的空间里;写进策略表,它才是一份模型无法触及的契约。这也直接决定了实现位置:闸门必须在工具执行的那一行之前,而不是在模型输出之后做后置校验。
9.2 挂起是第三种状态,不是异常
反直觉判断:ASK 最容易被写错的地方,是把它当成一次「需要确认的失败」。
它既不是失败也不是成功,是一次尚未发生的调用。把它当异常处理,就会自然导出「重新走一遍请求」的实现,进而带来重复副作用;把它当状态处理,就会自然导出「落库 + 一次性消费 + 恢复」的实现。判断一个 HITL 实现是否合格,有一个很简单的测试:同一条 pending 被重复确认时,系统会不会执行两次。
9.3 什么时候不应该用本章能力
反直觉判断:权限层的价值不在拦得多,而在拦得准,拦多了它自己会变成噪音,最后被所有人绕过。
有三种情况不该上 ASK:第一,纯只读、无副作用的工具,一律 ALLOW;第二,实时语音回合里人类不可能在几百毫秒内做判断的动作,硬上 ASK 只会把对话打断成碎片,这时候应该降级为事后审计或者直接 DENY;第三,高频、低风险、可回滚的动作,比如生成一条草稿,让它在草稿态里发生比每次都问人更划算。ASK 只留给那一类「错了要花很大代价去修」的动作。
9.4 策略表的顺序依赖是负债
规则列表天然长得像配置,而配置的读法是「从上往下第一条命中」。这在权限系统里很危险:有人调整了规则顺序,行为就静默变了,而且没有任何测试会失败。把优先级写成代码里的显式排序(DENY 优先、精确优先、无匹配拒绝),规则表就退化成一份无序的数据,顺序不再承载语义。
能用不等于敢用
越像人越要设闸
不是所有场景都该问
实时语音先分层
10、系统落地
原来有什么:一个能聊、能调工具、有记忆的数字人 Demo,工具全量注册,systemPrompt 里靠一句叮嘱撑着安全感,工具调用的成败只体现在返回结果里。
本篇新增了什么:一个策略解析器(ToolPermissionPolicy)、一个闸门(PermissionGate)、一张挂起表(pending_permission)、一个恢复/中断服务(AgentResumeService)、两个接口(chat 与 pending 决策)以及一个超时收口任务。查订单自动放行,改客户资料和提工单走 ASK,退款类动作直接 DENY,未登记工具默认 DENY。
现在能做什么:同一个敏感操作可以分别演示三条路径,而且三条路径都是可复现的;改一行策略就能改行为,不用碰 Agent 代码;每一次被拦下的动作在 pending 表里都有一条带时间戳的记录。数字人第一次有了真正的动作边界,而不是靠自觉。
还缺什么:策略还是硬编码,项目级覆盖没做;审计没有和会话日志合并;没有配额和频率限制;前端确认框还没有「本次 / 本会话」的粒度选择;多 Agent 场景下策略该按 Agent 还是按项目生效,还没定。
下一步如何演进:把这些边界放到可观测与评测的视角下看——哪些动作被拒了、ASK 的平均响应时间是多少、有多少 ASK 最后被拒绝,这些指标比「有没有权限系统」更能说明它是不是真的在工作。
开源参与点:本章未发现明确上游问题。如果你在复现 ASK 超时路径时看到与 permission-system.md 描述不一致的行为,请先在教程分支里做最小复现,再对照 v2.0.3 的 PermissionEngine 源码和当前 main,确认不是旧版本已修复的问题后再决定是否进入 Issue 流程;没有真实复现证据之前,不要为了「有贡献」而提交 PR。
原来工具全放行
现在闸门有回执
缺的是审计与配额
下一步补可观测
11、小结
Q1 → Agent 说「我帮你改一下」不是授权,授权只能来自策略表加人的确认
Q2 → 检查层必须在工具真正执行之前,且无匹配规则时默认拒绝
Q3 → 挂起要落库拿回执,恢复走一次性状态转换,超时按中断收口
状态 → chapter/12-permission-hitl 上,数字人第一次具备了可验证的动作边界
本篇交付的能力,是一个可以被别人验证的东西:拿同一个敏感操作,分别跑允许、拒绝、超时/中断三条路径,结果符合预期。它不依赖模型心情好,也不依赖 review 时有没有人记得提醒它。
三问各有落点
三路各有证据
能力已进主干
边界开始生效
12、作业
12.1 理解题:为什么 ALLOW / DENY / ASK 里的 ASK 不能实现成抛异常?
参考答案:抛异常的语义是「这次调用失败了」,而 ASK 的语义是「这次调用还没有发生」。把它当异常处理,恢复时的自然写法就是重新发一条用户消息、重跑一遍推理,副作用工具会被二次触发;当成状态处理,才能得到「落库 → 一次性消费 → 恢复」的正确结构。另外,DENY 和中断结果都应该作为正常的工具结果回注给模型,让它能换方案或向用户解释,而不是变成一个 500。
12.2 实战题:在本章的 Demo 上补一条「本会话允许」的粒度。
参考答案:在 decide 接口上增加 scope 参数(ONCE / SESSION),当 scope=SESSION 时,往一张 session_allow 表写入 (sessionId, toolName),策略解析器在规则匹配之前先查这张表;会话结束时清空。要注意两点:一是 session_allow 只对 ASK 类工具生效,绝不能覆盖 DENY 规则;二是这张表的生命周期必须跟会话绑定,否则等于把临时授权变成了永久授权。
12.3 排障题:内测反馈说「点了确认没反应,但过一会儿又执行了」,请给出排查顺序。
参考答案:先看 pending 表的状态流转,确认是不是被超时任务置成了 EXPIRED 之后又被别的路径消费;再看 decide 是否带了 status = 'PENDING' 条件,没有条件的 UPDATE 会让重复确认都成功;接着看 resume 是否走在同一个 sessionId 上,跨会话恢复会导致结果回注到错误的上下文;最后看前端是不是对同一个 pending 发了两次请求。核心判断点只有一个:这一次工具调用到底被执行了几次。
12.4 架构判断题:请打开 AgentScope Java 官方仓库,对照 v2.0.3 的 permission-system.md 与 agentscope-core 下的 PermissionEngine 源码,判断 PermissionMode 与单条工具规则之间的关系,并说明 README、文档与源码三者若出现描述差异时,你以哪个为准、为什么。
参考答案:先到 https://github.com/agentscope-ai/agentscope-java/tree/v2.0.3 找到 permission-system.md 与 io/agentscope/core/permission/PermissionEngine.java,逐条对照文档描述的决策流程和源码里的实际分支;再确认该路径在 main 上是否已经变化。结论通常应当是:固定版本源码与同版本测试是最高优先级证据,文档说明「设计意图」,源码说明「当前行为」;如果二者不一致,先按源码行为写结论并注明差异,再去 Issues / Discussions 搜索是否已有人报告。这一题的训练目标不是背出枚举值,而是养成「有争议就回源码」的习惯。
先看源码再动手
复现三条路径
别拿提示词当权限
提交前跑一遍测试
13、思考
这一式的冲突,说到底是一个边界问题:模型有动作能力,不代表它同时拥有动作授权。前十一式我们一直在给数字人加能力,这一式第一次给它加约束,而且约束的位置很讲究——不能放在它的上下文里,只能放在它执行动作的那一行代码前面。
三条路径——允许、拒绝、超时中断——之所以必须都能跑通,是因为只跑通允许路径的权限系统等于没有权限系统,它只是一个装饰。真正让边界成立的,是拒绝和中断这两条路径也都有确定的、可复现的结局。
反过来说,权限层也不该无限扩张。当每一个动作都要问人,人的注意力就成了瓶颈,绕过它的动机也就出现了。好的边界是窄而硬的:覆盖真正不可逆的动作,其余一律放行并留痕。会用框架的人知道 ALLOW 和 DENY 怎么写;能参与框架演进的人,会去追一个 ASK 的中断结果到底该长什么样。
一念辨边界
一闸定授权
一改知取舍
一役见真章

浙公网安备 33010602011771号