AgentScope Java v2 Lab-1,审批只走有门的腿

摘要:把挂 ASK 规则的 agent 接到 OpenAI 兼容 API 腿上,暂停被翻译成 finish_reason:"tool_calls" 递回客户端,待批状态随请求消失------识别不等于执行。解法是两腿分叉:有人工回路的腿保留全量规则与审批卡;纯 API 腿钉死 BYPASS、只留 deny 白名单,官方明文背书 deny 不可绕过。附第 6 课收尾十题逐题查漏与货代生产对照,证据全部有归档原件。

共约 6458 字 · 阅读约 16 分钟

开场:200、2.4 秒、"一切正常",舱单上没有这票货

  本以为最坏也就是挂死:ASK 卡着等人批,调用方等不到,最后超时,好歹算诚实地卡住。实际打过去是一条 HTTP 200,2.4 秒返回,响应体里 finish_reason:"tool_calls" 排得整整齐齐,怎么读都是一切正常。可服务日志里那条运行流的结尾挂着 cancel,舱单上也没有这票货。陷阱不在报错里,在协议语义里。

  这是 Lab-1 的探底实测(T1)。分叉之前,两条腿共用一份权限表,create_booking 上挂着一条 ASK 规则:面板腿上它弹审批卡,OpenAI 兼容的 API 腿上它会变成什么,改造前没人实测过。

所有的实验,你带着我做。相关的代码、截图、日志等一切内容,全部沉淀到学习资料里。

  ------2026-11-05 学习会话原话,这场 lab 就是这么收卷的。结果是本文每个数字背后都有归档原件可查(evidence/lab-1/,15 件);口径一并交代:AgentScope Java 2.0.3,模型 deepseek-flash(DeepSeek 的线上快模型),两条腿跑在同一个 Spring Boot 进程里;单机 Windows/JDK 21,每条实测只跑了一次(n=1),结论按机制理解,不按统计。

  文中 0006、0007 这类四位数是课程编号,各自对应一份学习资料(docs/0006b 等)。同事要是只带一句话走,我给这句:审批回路只属于有人工回路的腿。同一套权限引擎,两条腿得配两份策略;ASK 挂错腿,门铃照响,屋里没人。下面按顺序走:§2 探底,§3 拆表,§4 回验,§5 看别家,§6 十题收卷,§7 收口。

1、一份权限表为什么撑不住两条腿

1.1 两条腿各自的形状

  workbench 的 agent 只有一个构建点:AgentConfig.buildAgent()。分叉之前,两条腿都从这里出厂,揣着同一份权限上下文:8 条规则,查询四件套 ALLOW,create_booking ASK,cancel_booking DENY,refund 两条参数级(≤10000 ALLOW、>10000 ASK);mode 走 yaml(小写是 yaml 值的写法),可切 explore/dont_ask,对应模式枚举 EXPLORE/DONT_ASK。

  两条腿规则相同,差别在调用方:

  • AG-UI 腿:面板,SSE 事件流,0006 实装的审批卡+resume 回路。门铃响了,屋里有人;
  • chat-completions 腿:OpenAI 兼容的 blocking call(),一次请求一次完整返回。连"等审批再续"的回路都不存在。

  写 Java 的对这种形状不陌生:一个 Service 同时被 MVC controller 和 MQ listener 调用,事务边界、审计粒度天然按入口分开。多入口服务都长这样------面板、开放 API、IM webhook、定时任务,各有各的回路。权限策略要不要跟着入口分?分叉之前我没问过这个问题:两条腿共用一个 buildAgent,表就顺手写成了同一份。

1.2 ASK 挂错腿:门铃响了没人应

  ASK 的语义是"暂停,等人点头",成立前提是有一条回路能把"放不放行"送到人面前、再把裁决带回来。AG-UI 腿有这条回路;chat 腿的协议里没有这个位置。那框架会把 ASK 处理成什么------报错、挂死,还是悄悄放行?我答不上来,这正是先探底的原因。

  呈报方案时摆了选项,最终拍板 A:chat 腿撤人工审批。但拍板的依据不能是直觉,得先看 ASK 在这条腿上的真实下场。T1 就是干这个的。

  【组件卡:两腿对照卡------同一 buildAgent 工厂、同一套 8 条规则,create_booking 的 ASK 两腿都挂着|AG-UI 腿:审批卡+resume 回路,门铃响了屋里有人应答|chat 腿:blocking call(),协议层没有"暂停等人再续"的位置,门铃形同虚设】

2、探底:一条订舱指令,三份证词

  现在把探底完整走一遍。指令和后来回验用的那句话一字不差:"帮我在 MSC AURORA 的 V-2609A 航次订 C-001 这票货"。

2.1 请求与响应:协议递回一份"请你执行"

  请求就是一个最小的 JSON。发送命令(可在任何能连到 127.0.0.1:8080 的终端照抄):

bash 复制代码
curl -s -X POST http://127.0.0.1:8080/v1/chat/completions -H "Content-Type: application/json" -d @evidence/lab-1/t1-request.json

  请求体(t1-request.json 原文,就三个字段:模型名、关流、一条 user 消息):

json 复制代码
{"model":"workbench","stream":false,"messages":[{"role":"user","content":"帮我在 MSC AURORA 的 V-2609A 航次订 C-001 这票货"}]}

  打过去。200,2.4 秒返回(日志 duration=2420ms),不挂死不报错。响应体(t1-response.json 全文):

json 复制代码
{"choices":[{"finish_reason":"tool_calls","index":0,"message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"create_booking","arguments":"{\"voyageId\": \"V-2609A\", \"itemId\": \"C-001\"}"},"id":"call_00_AFM0OlPmcZcB5LdyejrG6102","type":"function"}]}}],"created":1793839212,"id":"8794aa7c-e0be-478c-ae4a-744d54a6a0f8","model":"workbench","object":"chat.completion"}

  content 是空串,tool_calls 数组里躺着 create_booking,航次号 V-2609A、货件 C-001 原样。starter 把模型的订舱意图原样递回了客户端。四样观察摆一桌:

  前两行说"一切正常",后两行说"什么都没发生"。哪份是真话,看日志。

2.2 日志里的 cancel:服务端的真话

  日志关键行(t1-log-extract.log,08:40:12):

ini 复制代码
08:40:12.867  INFO  c.w.a.ActingAuditMiddleware  : [MW-AUDIT|onActing] tool=create_booking args={itemId=C-001, voyageId=V-2609A}
08:40:12.870  INFO  c.w.a.OnionLogMiddleware     : [MW-INNER|onAgent] << post (2419 ms, 256 events, cancel)
08:40:12.870  INFO  c.w.a.OnionLogMiddleware     : [MW-OUTER|onAgent] << post (2419 ms, 256 events, cancel)
08:40:12.870 DEBUG i.a.s.b.c.web.ChatCompletionsController : Request completed: requestId=8794aa7c..., duration=2420ms

  逐行读(引文为排版缩略,逐字原件在 t1-log-extract.log)。第一行:模型确实点了菜,onActing(0007 中间件的审计点)在 acting 门口接住了 create_booking,权限引擎就是在这一步介入的。第二、三行是判决书:整条运行流收尾,256 个事件、2419 毫秒,一场完整的事件流,终点不是执行,是 cancel。ASK 在 acting 门口把运行取消了。第四行 controller 报"请求完成"。响应里的 id 和日志的 requestId 是同一个 8794aa7c,两份证词对得上号。

  这 606 行日志差量(从打点基线到请求完成,转码后的全部行)翻得不轻松:日志是 UTF-16 编码,直接 grep 不动,转码之后才看见 cancel 躺在 597 行(转码命令照抄 evidence/lab-1/README.md 的坑节)。判决:暂停发生在框架上游,chat-completions 那层 starter 把"暂停"翻译成 finish_reason:"tool_calls" 收尾。翻译得干干净净,没有一个"等人"的字眼。复现时的对账出口:你要是拿到的是 stop 加真执行,说明你的权限表里压根没挂 ASK 规则------先查配置,再谈语义。

2.3 判决:协议只管传话

  tool_calls 在 OpenAI 协议里到底是什么意思,不猜,翻规范。OpenAI 官方 OpenAPI 规范(openai-openapi 仓库的 openapi.yaml,ChatCompletionChoice 的 finish_reason 字段,规范原文):

The reason the model stopped generating tokens. This will be stop if the model hit a natural stop point or a provided stop sequence, length if the maximum number of tokens specified in the request was reached, content_filter if content was omitted due to a flag from our content filters, tool_calls if the model called a tool , or function_call (deprecated) if the model called a function.

  中译过来:自然停止或命中停止序列是 stop,到长度上限是 length,内容过滤是 content_filter,模型调用了工具是 tool_calls,函数调用(已弃用)是 function_call。枚举一共五种收尾,没有一种叫"服务端暂停等人批准"。tool_calls 的协议语义是"模型想调工具",按这套协议,下一步是客户端的活:执行工具,回传结果。

  而 T1 的真实状态是服务端权限引擎在等人批。协议语义和服务器真实状态对不上:客户端若照协议办事,就会去执行一个服务端从未批准的订舱。再叠加 chat 腿的形态,prototype 实例、不挂状态账本(0008 的口径),那个"等人批准"的中间态随请求结束一起消失,想批都找不到对象。门铃响了,房子也拆了。

  所以"客户端该怎么处理 tool_calls"的答案分两半:识别是必须的,它确实在说"这里有一个工具意图";执行是有前提的,前提是这条腿配了审批回路、你手里这份意图真等过人。无审批回路的腿,压根不许挂 ASK。这不是客户端能补救的,是服务端配置的纪律。把"照协议处理 tool_calls 就安全了"当默认做法的想法,T1 就是它的边界:协议只管传话,不管门后有没有人。

3、拆表:一个布尔参数,两份权限

  拍板落到代码上只有一处改动:AgentConfig 拆出 buildPermissionContext(boolean interactive),buildAgent(withStore) 调用之,interactive 与 withStore 同源。AG-UI 腿 true,chat 腿 false。此前两条腿共用一份表的写法,就是"权限配置一套走天下"的活样本;T1 那个 200 假象,就是这套写法开出的账单。

3.1 分叉后的 AgentConfig

  方法级完整代码如下(逐字与仓库 src/main/java/cause/workbench/agent/AgentConfig.java 一致):

java 复制代码
/**
 * 权限配置两腿分叉(Lab-1 篇一组装落地,2026-11-05)。
 * 审批回路(ASK + resume)只在 AG-UI 腿成立;chat-completions 腿是 API 形态,
 * 没有能应答审批的人工回路------探底实测(evidence/lab-1/t1):chat 腿挂 ASK 时,
 * 暂停被翻译成 finish_reason=tool_calls 原样回给客户端(OpenAI 语义里那是
 * "请你客户端执行"),且 prototype + 无 store 下待批状态随请求结束湮灭,无人能批。
 *
 * @param interactive true=AG-UI 腿(有人工审批回路);false=chat-completions 腿(无人工回路)
 */
PermissionContextState buildPermissionContext(boolean interactive) {
    PermissionContextState.Builder perm = PermissionContextState.builder();
    if (interactive) {
        // 0006 权限课实验配置(规则与模式全部可在实验台亲手验证):
        //  4 条 ALLOW:查询四件套------不挂会被第 6 步兜底 ASK 拦下(probe06 教训)
        //  1 条 ASK:create_booking 写操作 → HITL 人工审批
        //  1 条 DENY:cancel_booking 全禁------"诚实菜单":模型看得见、调不动、会向用户解释
        //  2 条参数级规则:refund_booking 的 ruleContent 由 RefundTool.matchRule 解释(小额放行/大额审批)
        // mode 可经 application.yaml 的 workbench.agent.permission-mode 切换(explore/dont_ask 等重启对比)
        perm.mode(PermissionMode.fromString(permissionMode));
        for (String tool : List.of("get_current_time", "query_vessel_schedule",
                "query_voyage_cargo", "query_cargo_detail")) {
            perm.addAllowRule(tool, new PermissionRule(tool, null, PermissionBehavior.ALLOW, "userSettings"));
        }
        perm.addAskRule("create_booking", new PermissionRule(
                "create_booking", null, PermissionBehavior.ASK, "userSettings"));
        perm.addDenyRule("cancel_booking", new PermissionRule(
                "cancel_booking", null, PermissionBehavior.DENY, "userSettings"));
        perm.addAllowRule(RefundTool.REFUND_TOOL, new PermissionRule(
                RefundTool.REFUND_TOOL, "amount<=10000", PermissionBehavior.ALLOW, "userSettings"));
        perm.addAskRule(RefundTool.REFUND_TOOL, new PermissionRule(
                RefundTool.REFUND_TOOL, "amount>10000", PermissionBehavior.ASK, "userSettings"));
    } else {
        // chat 腿:无人工回路 → BYPASS 模式钉死,一切直通;只保留不依赖人工裁决的两道约束:
        //  ① deny 规则(cancel_booking 诚实菜单)------第 1 步,排在 BYPASS 之前,照常生效
        //  ② 工具自检(RefundTool 金额≤0 DENY)------第 3 步 bypass-immune 区,照常生效
        // 不采用"把 ASK 翻译成 ALLOW 保留全表"的写法:规则覆盖不全的调用(如 refund 参数非法
        // 两条规则都不命中)会落到第 6 步兜底 ASK,门铃又会响(probe06 教训的同型陷阱)。
        perm.mode(PermissionMode.BYPASS);
        perm.addDenyRule("cancel_booking", new PermissionRule(
                "cancel_booking", null, PermissionBehavior.DENY, "userSettings"));
    }
    return perm.build();
}

  调用点在 buildAgent 里一行:PermissionContextState permissionContext = buildPermissionContext(withStore);。interactive 与 withStore 同源,腿的形态决定策略的形态。

  AG-UI 腿的规则表原样保留:0006 那套 8 条一条没动,mode 继续走 yaml。chat 腿换成 BYPASS 钉死、只留一条 deny(cancel_booking)。表从 8 条缩到 1 条,不是偷懒,是下一小节的裁决。

3.2 一条歧路:把 ASK 翻译成 ALLOW

  为什么不在 chat 腿把 create_booking 的 ASK 改写成 ALLOW、整张表保留?我推演过这条岔路,它有个兜不住的洞:规则表的覆盖是有边界的。refund 挂着两条参数级规则(≤10000 ALLOW、>10000 ASK),但规则要命中得先过 matchRule 这关:请求里缺了 amount 字段,或者 amount 传成非数值,两条规则都不命中(matchRule 对非 Number 的 amount 直接返回 false),评估会落到第 6 步兜底 ASK。门铃又响了,还是响在没有人的腿上。

  这不是假设:第 6 课的 probe06 实测过同型陷阱(那次是只挂 ASK 不挂 allow,连查询工具都被兜底拦下)。要堵住兜底,就得给每个工具补齐 ALLOW,表越长漏网越多。BYPASS 走的是另一头:直接关掉兜底门铃,而排在它前面的约束一根手指都没碰(引擎评估一共六级,哪几道排在 BYPASS 前面,4.3 拆)。

  【组件卡:门铃陷阱卡------歧路:chat 腿把 ASK 翻译成 ALLOW、保留全表|漏洞:覆盖不全的调用(如请求缺 amount 字段,两条参数级规则都不命中)落第 6 步兜底 ASK,门铃再响(probe06 同型陷阱)|正解:BYPASS 只关兜底门铃,deny(第 1 步)与工具自检(第 3 步)排在它之前,照常生效】

3.3 BYPASS 不是裸奔:官方那行加粗字

  "BYPASS 等于裸奔"这个说法我本来也信。AgentScope 官方权限文档(java.agentscope.io/v2 中文站权限系统页;模式表现行版,权限页主体经 2.0.3/2.0.4/master 三版比对,口径见 5.2)里有一行加粗明文:

Deny 规则与危险路径检查是不可绕过的 ------ 即使在 BYPASS 模式下也照常生效。

  同一张表里,BYPASS 行的官方适用场景写着"完全可信的沙箱"。两句拼起来,chat 腿方案的成立前提就清楚了:API 腿所在的环境本身可信、或者有隔离,deny 白名单在这前提下站岗。要交底的是,本 demo 是无鉴权的本机 HTTP,隔离没做,生产要补(7.2 交底)。裸奔论的边界在这:裸不裸,看你留没留 deny,也看环境兜不兜底。

  顺带回答"为什么不是 DONT_ASK":官方表里 DONT_ASK 的定义是"把所有 ASK 转为 DENY",适用"无人值守/计划任务"。那是"没门也别吵"的档位,create_booking 会被拒。这条腿的业务是无人值守订舱,要的是真执行,所以 BYPASS 留真通道、deny 留底线。

4、回验:三发实测,两腿各走各的门

  分叉提交、服务重启,现在回验。三发实测外加一组 JUnit:T2 把 T1 那条指令原样再打 chat 腿,分叉前后同一句话、两种命运,新旧对照就在这一对响应里;T3 让 chat 腿去取消刚订的舱;T4 回面板腿走完整审批;JUnit 那组不起 Spring,直接审引擎。

4.1 T2/T3/T4:直通、拒绝、审批卡

  T2,chat 腿,同款订舱指令。finish_reason=stop,4.5 秒走完全程(t2-log-extract.log:duration=4507ms),最终文本报出 BK-20261105-1。它先发了一对查询(船期+舱单,msgs 从 2 走到 7 的进展与响应内容为证),create_booking 进 acting,ToolExecutor 真执行(日志里给它挂上了 5 分钟执行超时)。T1 那次 tool_calls 空转,这次是真订舱。模型还自己往 remark 填了"MSC AURORA V-2609A 电子产品...":description 驱动的自主填参,0006 的彩蛋在 chat 腿复现。

  T3,chat 腿,"帮我把 BK-20261105-1 取消掉"。模型收到 Permission denied by rules 固定串(t3-response.json),零执行,老实承认被规则拦了,还给了三条替代方案(人工流程、改退款、出示授权)。deny 在 BYPASS 腿上活得好好的,官方那句加粗字在这发实测里兑现。

  T4,面板腿,新会话发同款指令。审批卡弹出(参数框可编辑),批准,resume,最终回答报 BK-20261105-2。分叉没碰 AG-UI 腿任何规则:审批卡、resume、批准后执行,一路照旧。有一处要交代:T4 的拒绝路径这轮没重跑(只走了批准验证);分叉未改该腿规则,0006-E1 已存档的拒绝结论仍有效,不算缺口。

  BK 号还有笔暗账:一个 -1 一个 -2,因为 BOOKING_SEQ 是 static 计数器,同 JVM 共享。两条腿拿到的是同一个工具类里的同一份计数,Java 人看它就像看两个线程共享同一个 IdGenerator。-1 被 chat 腿拿走、-2 归面板腿,顺序就是回验的顺序。三发实测加一组 JUnit,对比如下:

4.2 不起 Spring:把权限引擎单独拎出来审

  运行面三发之外还有一层:权限阶梯要能脱离 Spring、脱离真模型做回归。PermissionForkTest 纯内存直调------不起容器、不发一次模型调用,直接问引擎:

java 复制代码
PermissionDecision decision = new PermissionEngine(context).checkPermission(tool, args).block();
return decision.getBehavior();   // ALLOW / DENY / ASK / PASSTHROUGH

  这就是把引擎内部决策显影的办法:给一张表、一个工具、一组参数,它还你一个判决,中间不隔 HTTP、不隔模型。完整文件是 src/test/java/cause/workbench/agent/PermissionForkTest.java(须与 AgentConfig 同包才能访问其包级方法,抄去别的包要连 AgentConfig 一起搬或改公开),跑法一条:mvnw test -Dtest=PermissionForkTest(JDK 21)。核心断言原文:

java 复制代码
// create_booking:AG-UI 腿等人,chat 腿直通
assertEquals(PermissionBehavior.ASK, decide(agui, createBooking, bookingArgs()));
assertEquals(PermissionBehavior.ALLOW, decide(chat, createBooking, bookingArgs()),
        "chat 腿 create_booking 应直通(BYPASS)");

// 大额退款:AG-UI 腿等人,chat 腿直通
assertEquals(PermissionBehavior.ASK, decide(agui, refund, refundArgs(50000)));
assertEquals(PermissionBehavior.ALLOW, decide(chat, refund, refundArgs(50000)),
        "chat 腿 refund 50000 应直通(ask 规则已撤)");

// 负金额:两腿同判------工具自检在第 3 步 bypass-immune 区,BYPASS 压不掉
assertEquals(PermissionBehavior.DENY, decide(chat, refund, refundArgs(-5)),
        "chat 腿 refund -5 应被工具自检拒(bypass-immune)");
assertEquals(PermissionBehavior.DENY, decide(agui, refund, refundArgs(-5)),
        "AG-UI 腿 refund -5 同样被工具自检拒");

// cancel_booking:两腿同判------deny 规则第 1 步,先于一切 mode
assertEquals(PermissionBehavior.DENY, decide(chat, tool("cancel_booking"),
        Map.of("bookingId", "BK-1")));

  Tests run: 3, Failures: 0, Errors: 0,3 绿。顺带一笔 2.0.3 考古:Toolkit.getTool(name) 取回的对象 cast 成 ToolBase 成功------@Tool 注解方法在 2.0.3 里就是 ToolBase 家族,实测实锤。想给权限阶梯写回归的,这套直调就是现成样板。

4.3 阶梯六步:deny 为什么必须站第一格

  那两条"两腿同 DENY"(cancel、refund -5)是理解引擎的钥匙------它们解释了 BYPASS 为什么压不掉约束。权限引擎的评估是一条六级阶梯,按谁说了算排座次,风险越高的裁决越靠前:

  1. deny 规则。禁令,最刚性;
  2. ask 规则。要等人;
  3. 内置检查区。EXPLORE/ACCEPT_EDITS 读写分性、危险路径、工具自检(checkPermissions),bypass-immune;
  4. allow 规则。用户明示放行;
  5. BYPASS。剩余全放;
  6. 兜底 ASK。没配置即不信任。

  顺序是功能不是惯例。deny 排第一,"总体放行、特例封杀"才表达得了:refund 那对参数级规则(≤10000 ALLOW、>10000 ASK)要是 allow 先评估,大额也被放走,参数级规则整对作废。ask 排在 allow 前面,挂了 allow 的工具才轮得到审批,否则 ASK 规则对它们全体架空。第 3 步为什么又插在 allow 前面?工具作者对危险边界比用户清楚------用户可能误放行,工具自检和模式分性必须压过用户的 allow 规则。

  bypass-immune 的意思 JUnit 里最直观:refund -5 同时命中第 4 步的 allow(-5 ≤ 10000)和第 3 步的工具自检,判决 DENY,allow 救不回。deny 压 BYPASS 有 T3,自检压 allow 有 JUnit,两个方向都有实证。

  这里还有一处文档漂移要记:官方文档画的是 9 步评估,源码(PermissionEngine.java,336 行逐字核读)实为 6 步------"模式放行"实际插在第 3 步内置检查区里。背顺序背源码的 6 步,别背文档的 9 步。

5、抬头看别家:审批都内建,回路各家自己铺

5.1 三家框架加一条协议层

  AgentScope 不是唯一把审批做进框架的。三家框架加一条协议层,官方一手材料(2026-11-05 检索)对比如下:

  同构面相当齐:Claude Code 的 default/acceptEdits/dontAsk/bypassPermissions 与 AgentScope 的 DEFAULT/ACCEPT_EDITS/DONT_ASK/BYPASS 名字都对得上,plan/auto 两个新档位干的是 EXPLORE 的活(只读探索);OpenAI Agents SDK 的暂停-恢复与 0006 那套 ASK+ConfirmResult 走的是同一条路------暂停、裁决、恢复,官方原话:

RunState lets you serialize paused runs and resume them after decisions are made.

  MCP 这条协议层单独说:它把"向人要输入/确认"写成了协议动作 elicitation/create,accept/decline/cancel 三动作,规范原文建议客户端"implement user approval controls"------审批控制是客户端 SHOULD 落实的责任,协议给了动作,落实在人。差异也要如实列:Claude Code 的审批发生在本地界面,围着单机文件与 shell;AgentScope 的 ASK 暂停在服务端运行流里,必须靠腿上配回路;OpenAI SDK 的 RunState 强在暂停可序列化------待批运行能存盘再续,恰好是 §7 要交底的本篇未做项。同构不等于相同,"别家也这样"不能当免责声明。

  无人值守这段还有两条官方原话。Claude Code 给 bypassPermissions 的警告:

bypassPermissions offers no protection against prompt injection or unintended actions.

  连着适用场景"仅限隔离容器"读,和 AgentScope 给 BYPASS 标"完全可信的沙箱"是同一种谨慎:两家都没把全放行当默认姿态。OpenAI 的 guardrails 文档从反面补了一刀,官方提醒:

if the guardrail's tripwire is triggered, the agent may have already consumed tokens and executed tools before being cancelled.

  护栏与执行并发,绊线触发前 agent 可能已经烧了 token、跑了工具。AgentScope 把 deny 放阶梯第 1 步,拦截发生在工具执行之前,没有"取消前已执行"这笔账。4.3 那张阶梯的排序,别家的文档反过来印证了。

5.2 哪些话能引:版本口径先算清

  写这节之前多做了一步版本核对,因为官方文档站不标版本号。逐份比对了 v2.0.3、v2.0.4、master(2.1.0 线)的文档与源码树。比对结果:权限页主体(三决策、五模式、建议规则、RequireUserConfirmEvent)逐项比对,三份一致;权限包 8 个类在 2.0.3 与 main 完全同名。落注结论:本文引的权限明文都按"2.0.3 官方明文"写。但文档站当前多出一节"拦截器与权限引擎":ToolkitConfig.interceptor(...) 在 v2.0.3 源码 grep 计数为 0、master 为 17。这一节是 2.1.0 线新内容,涉及拦截器的表述都得标 2.1.0 线,2.0.3 用户对不上号。另有个 ExtensionPoint,两代源码树都搜不到,不引。

  【组件卡:版本口径卡------能引:权限页主体(三决策/五模式/建议规则等),逐项比对三份一致|要标 2.1.0 线:"拦截器与权限引擎"节、ToolkitConfig.interceptor(...)、onAgentStateReady,2.0.3 里对不上|不引:ExtensionPoint,两代源码树都搜不到,无存在证据】

  这步核对看着较真,实为自保:教程文章最怕把新线 API 写成已有能力,读者拿 2.0.3 一跑就翻车。本文的引句三级口径就此定下------AgentScope 2.0.3 官方明文、2.1.0 线新文档、OpenAI 规范原文,各归各档,出处随句落注。

6、十题收卷:第 6 课欠的账,按题查漏

  先答一关系题:十题和 Lab-1 什么关系?十题是第 6 课(权限系统与 HITL)结课欠下的收尾账;Lab-1 把十题背后的机制放进真实两腿服务里压了一遍,G1、G2 的结论因此升级。讲法分工:第 6 课那篇讲机制怎么运转,这里只讲查漏。G1 已融在 4.3,不重复展开;检索三题按"答案、为什么、依据、易错点"四件套压缩;drill 四题与毕业三题按题给答案和关键依据,从简。题号照学习资料 0006b 编,凭题号可查。

6.1 R1-R3:三条地基题

R1 · 从 @Tool 到真执行,经过哪几步?

  从 @Tool 到真执行,经过哪几步?四步:注册(@Tool 方法进工具池)→递菜单(schema 拼进请求的 tools 字段)→点菜(模型只产出 ToolUseBlock 意图)→做菜(Acting 相执行方法体,ToolResultBlock 回填)。真正执行工具的是框架循环,模型从头到尾只发意图。这条缝隙是 HITL 的物理前提,模型直接执行的话"先问人"在物理上不可能。依据在 T1 日志里可见:第 25 行"Converted tool to OpenAI format"(递菜单),第 596 行 onActing 进门(做菜门口),先后同件可查。易错点:说"LLM 调用了工具"。

R2 · 组五词是什么、13→12→13 说明什么?

  抽屉模型五词:组、激活、开关、成员关系、组内。E4 实测 13→12→13:关组时一个工具整体消失出 schema(模型看不见),重开原样回归、成员关系不丢。它和权限引擎是两层管控:组开关管"看得见吗"(schema 层),DENY 管"看得见禁执行",ASK 管"看得见要人点头"。易错点:把"没激活"当注册失败;反面更险------关组不等于 DENY,被关的工具连"诚实解释被拒"的机会都没有,模型只会装作没这功能。

R3 · readOnly 什么时候被读?

  只在评估第 3 步,消费者是 EXPLORE/ACCEPT_EDITS 的读写分性检查:申报了 readOnly=true 的工具在 EXPLORE 下放行,没申报的一律当写操作拒。DEFAULT 模式它完全不出场------workbench 的 create_booking 要审批靠的是 ASK 规则,不是 readOnly=false 这个默认值。依据是 E2-D 实测:5 个工具全未申报,yaml 切 explore 后查询全拒。易错点:把 readOnly 当 DEFAULT 模式的审批开关。

6.2 D-P1 到 P4:blocking 的四张底牌

  BlockingHitlDrillTest 三幕:E1 感知暂停、E2 恢复、E3 同问题再问,四条预测各有各的答案。

D-P1 · call() 返回的 Msg 是 PERMISSION_ASKING

  call() 返回的 Msg 是 PERMISSION_ASKING。暂停收据不是错误码,metadata 里还带着确认关联回执(agentscope_confirm_request_reply_id)。

D-P2 · 拿待确认工具:筛 ASKING 块

  getContentBlocks(ToolUseBlock) 筛 state==ASKING 的块,实测 1 块、文本空串。暂停 Msg 只有意图块没有正文;要筛不要遍历,同次运行可能混着已放行块。

D-P3 · 恢复挂载与收尾

  List<ConfirmResult>(confirmed 布尔+待确认 ToolUseBlock+可选建议规则)挂 Msg.METADATA_CONFIRM_RESULTS 再 call(),回来的是 MODEL_STOP 正常收尾,工具真执行了。

D-P4 · 建议规则跨调用持久

  建议规则跨调用持久:下一次同问题直接放行,"批准并记住"为真(E3 实测 ASKING=0)。

  D-P4 必须补一句生产提醒:默认建议规则是 (toolName, null, ALLOW, "suggested"),ruleContent=null 等于整工具全放行。用户批一笔大额退款,等于永远放行所有退款。真做审批 UI 的"记住",得展示规则全文或收窄 ruleContent。

6.3 G1-G3:出师三题

G1 · 阶梯顺序为什么 deny 第 1、ask 第 2

  已在 4.3 拆完:风险越高的裁决越靠前,顺序换了参数级规则和审批语义全部失效。见 4.3。

G2 · blocking 与 streaming 拿待确认工具差在哪?

  blocking 看返回 Msg 的 generateReason,streaming 看事件流上的 RequireUserConfirmEvent;恢复协议只有一份(ConfirmResult)------感知通道两种,裁决语义一份。Lab-1 补上第三种命运:无人工回路的 blocking(HTTP API),根本不能挂 ASK,挂了就是 T1。给第 6 课那张双形态表加上这第三列,buildPermissionContext(interactive) 的依据才算合拢。

G3 · resume 为什么强制带回全部未决中断?

  一次运行可以在多个工具上叠加暂停,引擎要凑齐每一个 gate 的裁决才能组装下一轮。resume 数组漏一个直接报 "RunAgentInput.resume must address all of them",status 只有 resolved(可带 editedArgs)与 cancelled 两值。边界也实测过:未决审批时刷新页面报 AGUI_INTERRUPT_CONTRACT_ERROR,重启服务报 INVALID_INPUT_ERROR------待批表在内存里,这两下都过不去。审批不会被静默跳过(安全),但未决中断持久化是真实缺口,已记入 0020/0024 两个观察点。

7、收口:把你自家的入口也盘一遍

7.1 从教学版到货代生产:两份策略的最小样板

  货代场景里这套分叉不是教学道具。客服工作台的 AI 订舱是人点批准------AG-UI 形态,审批卡+resume;上游 TMS 系统的 API 直连订舱是无人值守------BYPASS+deny 白名单。两份策略,不是把一份打折成两份的权宜之计。落到代码就是 buildPermissionContext(interactive) 十几行,最小样板全在 3.1。

  你的系统未必有两条腿,但只要一个 agent 有多于一个入口------面板、开放 API、IM webhook、定时任务------问题就一样:哪个入口有人能应答审批?有人的挂 ASK,没人的别挂,底线收进 deny 和工具自检。这是从 T1 那 606 行日志差量里捞出来的最便宜的一条教训。

7.2 交底:这堂课没做的四件事

  诚实是及格线,账摊开:

  1. 未决中断持久化没做。待批状态在内存,刷新、重启都断(G3 边界实测),已记入 0020/0024 两个观察点;
  2. 审批矩阵没接策略决策点。规则手写在配置里,多角色、多金额档位的生产审批要另接 PDP;
  3. description 没跟策略同步。refund 的 description 还写着"大额需人工审批",chat 腿上已是过期话术;description 是模型的世界观,本课刻意不改,留作观察点;
  4. BYPASS 下建议规则的行为没探。chat 腿没有审批流,建议规则无处附着,记入 0020。

  四件都不动摇本文结论,但拿这套样板进生产之前,四件都得补上。

带走三样,临走三问

  整篇收拢成一句话:同一套权限引擎,审批回路只属于有人工回路的腿。门给有人的腿,底线两条腿都有。

  带走三样:

  1. ASK 挂错腿的下场:starter 把暂停翻译成 finish_reason:"tool_calls" 递回客户端,待批状态随请求消失。识别不等于执行,无审批回路的腿不许挂 ASK。
  2. BYPASS 的边界:官方明文 deny 与危险路径检查不可绕过(第 1、3 步排在生效点之前),纯 API 腿 BYPASS+deny 白名单是完备形态,不是裸奔。
  3. 分叉的最小样板:buildPermissionContext(interactive) 一个布尔两份策略;JUnit 纯内存直调 PermissionEngine 就能回归;description 要跟着策略走。

  临走三问:

  1. 你把 create_booking 的 ASK 规则抄到纯 API 腿上,客户端收到的 finish_reason 是什么?这个值在 OpenAI 规范里的标准含义是什么?
  2. chat 腿钉死 BYPASS 后收到"帮我把 BK-20261105-1 取消掉",模型会拿到什么?是阶梯第几步拦的,为什么 BYPASS 压不掉它?
  3. 同事提议"把 ASK 全翻译成 ALLOW、两条腿共用一张表"------哪一类调用会让门铃再响?落第几步?

  三道都答对了,这一课才算真的带走。

  第 13 篇收笔,Lab-1 收卷,篇一(0001-0008)组装收官,学习进度 13/57。

  下一篇 0014,上下文与状态原理:T1 里那条被 cancel 的运行流、T4 里能接续的会话,状态各自放在哪、上下文里装了什么,下回拆。


如果这篇文章对你有帮助,欢迎转发给需要的同事和朋友。

相关推荐
AI前端工程狮8 分钟前
1 秒超时,3 秒才写完:MCP 写操作的重复提交实测
人工智能·程序员
dyxal26 分钟前
FireRedVAD 深度解析:小红书开源的工业级语音活动检测“小钢炮”
人工智能·开源·语音识别
加宝的起点雷达1 小时前
40 条候选里 38 条来自同一个源,主卡整屏重样
人工智能·算法·创业
紧跟先前的步伐1 小时前
Go AI Agent 实战 28|Agent 的预算、超时与停止条件
开发语言·人工智能·后端·ai·golang
蓝星科学1 小时前
AI Agent 说「已完成」,凭什么信——ChipCode 的证据链与产物门禁设计
人工智能
田里的水稻1 小时前
FA_CNN卷积神经网络二十六---从全连接到卷积PyTorch
人工智能·pytorch·cnn
艺杯羹1 小时前
MCP本地服务集成避坑:从Stdio管道僵死到双向JSON-RPC稳健通信
人工智能·python·ai·系统架构·大语言模型·ai编程·mcp
武子康1 小时前
h3.c 的两个权重槽,怎样把内存压力换成 SSD 等待
c语言·人工智能·agent
大模型真好玩1 小时前
豆包真人分身操作技巧:这次让我来做主演
人工智能·agent·deepseek