基于 AgentScope 框架的后端 Tool 与前端 Tool 分工交互原理

大多数 AI Agent 教程止步于"agent 怎么调工具",但企业业务系统真正的架构难题是:agent 的工具,一部分在后端执行(查库、调接口、读写文件),一部分必须由前端完成(弹审批框、收集表单、页面跳转)——两者怎么分工、怎么协作、怎么在 HTTP 的单向约束下完成挂起与恢复?本文以 agentscope 框架的 externalTool 机制为切入点,从源码级别把后端 Tool 与前端 Tool 的分工交互原理彻底拆透,配以五套完整时序图覆盖自动执行、HITL 审批、混合调用、拒绝取消等全部场景。对于企业级 Agent 落地,这是最该先读懂的那块拼图。

本文档用 Mermaid 时序图描述 AG-UI 协议下 agentscope-java agent 与前端交互的全部主要场景,覆盖后端 tool、前端浏览器 tool(自动 / HITL)、混合调用、拒绝取消等。

场景索引

# 场景 是否阻塞用户 是否中断 run 典型 tool
1 后端 Tool 自动执行 queryDBreadFile、调内部 API
2 前端浏览器 Tool 自动执行 是(立即恢复) openPageshowToastnavigatehighlight
3 前端 HITL Tool 中断式交互 是(等用户后恢复) confirmActionrequestApprovalcollectInput
4 一次会话混合调用(后端 + 前端 HITL) 部分 先查单、再请求退款审批
5 HITL 被用户拒绝 / 取消 是(恢复为拒绝) confirmAction → 用户点"拒绝"

核心判定标准:externalTool 标志位

⭐ 这是 agentscope-java 区分"后端 tool"与"前端 tool"的唯一根本判据,贯穿下文全部场景。读每个图时盯住这个标志即可。

agentscope 的工具模型中,每个工具都有一个 boolean externalTool 标志位。工具执行器在调用工具自身的执行逻辑之前先检查它,由此决定是本地执行还是挂起。

① 字段定义 —— 工具基类中的标志位声明:

public abstract class ToolBase implements AgentTool {
    // ...
    private final boolean externalTool;
    // ...
}

② 短路判定 —— 工具执行器在调用执行逻辑之前先检查该标志,命中即挂起、不调用执行逻辑:

// External tool short-circuit: surface the call to the caller without running schema
// validation, preset injection, or scheduling. SchemaOnlyTool and any
// @Tool(externalTool=true) method end up here.
if (tool instanceof ToolBase tb && tb.isExternalTool()) {
    return Mono.just(ToolResultBlock.suspended(toolCall, new ToolSuspendException()));
}

以上源码块即定位锚点:复制其中任意一行(如 External tool short-circuitisExternalTool())到源码中搜索,即可跳到对应位置。

externalTool 含义 执行器行为 结果来源 对应场景
false(默认) 后端 tool:有执行逻辑,服务端可执行 调用执行逻辑,本地执行 → 正常结果 服务端产出 场景 1
true 前端 / 外部 tool:无实现,结果须由外部提供 短路,不调用执行逻辑 → 挂起结果 → 挂起 外部(前端)resume 回传 场景 2 / 3 / 4 / 5

三个关键事实(每条附源码块,便于在源码中定位):

  • runAgent 请求 tools 字段注入的前端工具,会被包装成"仅含 schema 的工具"(schema-only),其构造时硬编码 externalTool=true——这就是"前端 tool 注入后必然挂起"的根因:
// schema-only 工具构造时硬编码 externalTool=true
super(
        ToolBase.builder()
                .name(Objects.requireNonNull(name, "name cannot be null"))
                .description(
                        Objects.requireNonNull(description, "description cannot be null"))
                .inputSchema(
                        parameters != null
                                ? Collections.unmodifiableMap(new HashMap<>(parameters))
                                : Collections.emptyMap())
                .externalTool(true)   // ← 关键:标记为外部工具
                .readOnly(false)
                .concurrencySafe(true));
  • 通过注解把某个后端方法标记为 externalTool=true,同样走短路,机制完全一致:
// 注解式工具读取 externalTool 配置
.externalTool(annotation.externalTool())

// 命中时同样产出挂起结果,不执行方法本体
if (isExternalTool()) {
    return Mono.just(
            ToolResultBlock.suspended(param.getToolUseBlock(), new ToolSuspendException()));
}
  • externalTool=true 的工具,其执行逻辑里抛出的挂起异常只是兜底;正常路径下执行器提前短路,根本不会调用它

因此下文每个场景都会显式标注工具的 externalTool 取值:false → 服务端执行;true → 挂起、结果来自外部(前端)。

参与方

简称 角色
User 终端用户
FE 前端(浏览器,AG-UI 兼容客户端)
Adapter AG-UI 适配器(含请求处理器、恢复协调器)
Agent agentscope Agent,持有工具集
TEx 工具执行器(agent 内部组件,实际调度工具执行)
LLM 大模型
BTool 后端工具(externalTool=false,有执行逻辑)
FTool 前端工具(externalTool=true,仅含 schema,无实现)

协议约束:AG-UI 的 runAgent 是「POST 请求 → 单向 SSE 响应」。前端无法在一次 run 的流式响应中回传数据,因此任何前端 tool 的结果都必须通过"结束本次 run → 发起下一次 run(带 resume)"回传。这就是前端 tool 必然伴随 interrupt 的根本原因,与"是否需要用户参与"无关。

关于端点路径:下文时序图中的 POST /runAgent 是 AG-UI「运行 agent」操作的示意标记,非字面 URL。实际路径由部署项目决定(agentscope-java 默认为 POST /agui/run,AG-UI 协议标准为 POST /run)。


场景 1:后端 Tool 自动执行

LLM 选中带实现的工具,服务端直接执行、产出结果、agent 继续推进。一次 run 完成,不中断、不回前端。

后端工具 (queryDB, externalTool=false) LLM 工具执行器 (agent 内部) Agent(工具集) 适配器 前端 后端工具 (queryDB, externalTool=false) LLM 工具执行器 (agent 内部) Agent(工具集) 适配器 前端 无前端 tool 注入 (请求 tools 为空 或合并模式=AGENT_ONLY) 工具执行器先判 externalTool POST /runAgent {threadId, runId, messages} 1 启动 run 2 RUN_STARTED 3 SSE: RUN_STARTED 4 推理(工具集 schema) 5 tool_call: queryDB(sql) 6 执行(toolCall) 7 externalTool == false ★ 继续,调用执行逻辑(本地执行)★ 8 执行(param) 9 执行业务逻辑 (查库 / 调内部 API / 读写文件) 10 工具结果 11 正常结果(不挂起、不回前端) 12 TOOL_CALL_START / ARGS / END + TOOL_CALL_RESULT 13 SSE: TOOL_CALL_* + RESULT 14 带 tool 结果继续推理 15 生成最终回复 16 TEXT_MESSAGE_* + Agent 结束 17 SSE: TEXT_MESSAGE_* + RUN_FINISHED 18

场景 2:前端浏览器 Tool 自动执行(无需用户等待)

LLM 选中前端 tool(如页面跳转、弹 toast、高亮元素)。这类动作前端可以立即程序化执行,无需用户决策。机制上仍走"挂起→interrupt→resume",但前端收到 interrupt 后立即执行、立即回传,用户基本无感知。

前端工具(schema-only) (openPage, externalTool=true) LLM 工具执行器 (agent 内部) Agent(工具集) 适配器 前端(浏览器) 前端工具(schema-only) (openPage, externalTool=true) LLM 工具执行器 (agent 内部) Agent(工具集) 适配器 前端(浏览器) 注入前端工具 openPage → schema-only 工具 (run 作用域, externalTool=true) 工具执行器先判 externalTool 执行逻辑里的抛异常只是兜底 正常路径下根本执行不到 执行循环检测到挂起 → 构造挂起消息 → 状态置为 TOOL_SUSPENDED → 停止迭代 事件转换层 检测 TOOL_SUSPENDED → Interrupt ★ 收到 interrupt,无需用户决策 ★ 动作完成即回传 resume 几乎瞬时返回 用户不感知"中断" 恢复协调器校验 映射 interruptId → toolCallId resume → 工具结果 run 结束 → 清理注入作用域 移除注入的前端工具 POST /runAgent {threadId, runId, messages, tools:[openPage schema]} 1 注入 + 启动 run 2 RUN_STARTED 3 SSE: RUN_STARTED 4 推理(含前端 tool schema) 5 tool_call: openPage(url) 6 执行(toolCall) 7 externalTool == true ★ 短路,不调用执行逻辑 ★ 8 挂起的工具结果 (内部标记为挂起) 9 结果事件(TOOL_SUSPENDED) 10 SSE: TOOL_CALL_START / ARGS / END SSE: RUN_FINISHED{ outcome.interrupts:[ {reason:"tool_call", toolCallId, metadata:{toolName:"openPage", toolInput:{url}}}] } 11 立即执行浏览器动作 (router.push / window.open / toast / 高亮元素 ...) 12 POST /runAgent {threadId, resume:[{interruptId, status:"resolved", payload:{ok:true, url}}]} 13 恢复挂起的 tool call 14 带 tool 结果继续推理 15 生成回复 16 TEXT_MESSAGE_* + Agent 结束 17 SSE: TEXT_MESSAGE_* + RUN_FINISHED 18

场景 3:前端 HITL Tool 中断式交互(需用户决策)

LLM 选中需要人类判断的前端 tool(审批、确认、收集输入)。前端收到 interrupt 后渲染 UI 并阻塞等待用户操作,用户决策后再回传 resume。这是最典型的 human-in-the-loop。

前端工具(schema-only) (confirmAction, externalTool=true) LLM Agent(工具集) 适配器 前端(浏览器) 用户 前端工具(schema-only) (confirmAction, externalTool=true) LLM Agent(工具集) 适配器 前端(浏览器) 用户 工具执行器见 externalTool=true → 短路 产出挂起的工具结果(不调用执行逻辑) 检测 TOOL_SUSPENDED → Interrupt(reason=tool_call) ★ 需要用户决策,流程在此暂停 ★ 用户阅读、思考 恢复协调器校验 ① resume 必须覆盖所有未决 interrupt ② status ∈ {resolved, cancelled} ③ 无重复 interruptId ④ 映射 interruptId → toolCallId POST /runAgent {threadId, messages, tools:[confirmAction schema]} 1 注入 + 启动 run 2 RUN_STARTED 3 SSE: RUN_STARTED 4 推理 5 tool_call: confirmAction(detail) 6 执行循环检测到挂起 → TOOL_SUSPENDED,停止迭代 7 结果事件(TOOL_SUSPENDED) 8 SSE: TOOL_CALL_START / ARGS / END SSE: RUN_FINISHED(interrupt: confirmAction) 9 渲染审批 / 确认弹框 (展示 detail、风险说明) 10 点击"批准" / 填写表单 / 选择选项 11 POST /runAgent {threadId, resume:[{interruptId, status:"resolved", payload:{approved:true, comment:"..."}}]} 12 resume → 工具结果 恢复 13 带 approved=true 继续 14 执行后续动作(如真正发起退款) 15 TEXT_MESSAGE_* + Agent 结束 16 SSE: TEXT_MESSAGE_* + RUN_FINISHED 17

场景 4:一次会话混合调用(后端 Tool → 前端 HITL Tool)

真实业务中最常见的形态:agent 先用后端 tool 查数据(自动执行),再根据结果调用前端 HITL tool 请求用户审批。展示两种 tool 在同一会话中的衔接。

前端工具(schema-only) (requestRefundApproval, externalTool=true) 后端工具 (queryOrder, externalTool=false) LLM Agent 适配器 前端 前端工具(schema-only) (requestRefundApproval, externalTool=true) 后端工具 (queryOrder, externalTool=false) LLM Agent 适配器 前端 第 1 步:先查订单(后端 tool) externalTool=false → 本地执行 第 2 步:基于订单详情,请求退款审批(前端 HITL) 工具执行器见 externalTool=true → 短路 → 挂起 → TOOL_SUSPENDED → 停止 渲染退款审批弹框,用户批准 恢复协调器校验 + 桥接 第 3 步:审批通过,执行退款 POST /runAgent {messages:"帮我把订单 1 注入前端 tool + 启动 run 2 SSE: RUN_STARTED 3 推理 4 tool_call: queryOrder(id=123) 5 执行 → 服务端执行 6 工具结果(订单详情) 7 SSE: TOOL_CALL_* + RESULT(queryOrder) 8 带订单详情继续推理 9 tool_call: requestRefundApproval(order) 10 结果事件(TOOL_SUSPENDED) 11 SSE: TOOL_CALL_* + RUN_FINISHED(interrupt) 12 POST /runAgent {threadId, resume:[{interruptId, status:"resolved", payload:{approved:true}}]} 13 resume → 工具结果 恢复 14 带 approved=true 继续 15 调用退款服务、生成回复 16 TEXT_MESSAGE_* + Agent 结束 17 SSE: TEXT_MESSAGE_* + RUN_FINISHED 18

场景 5:HITL 被用户拒绝 / 取消

用户在审批弹框中点"拒绝"。resume 可以用 status:"resolved" + payload.approved:false(推荐,表达业务决策),或 status:"cancelled"(表达中断本身被取消)。agent 拿到拒绝结果后应优雅终止,不强行执行。

前端工具(schema-only) (confirmAction, externalTool=true) LLM Agent 适配器 前端 用户 前端工具(schema-only) (confirmAction, externalTool=true) LLM Agent 适配器 前端 用户 工具执行器见 externalTool=true → 短路 → TOOL_SUSPENDED 两种合法表达: ① resolved + payload.approved=false ← 推荐 ② cancelled ← 表达中断本身被取消 恢复协调器校验通过 resume → 工具结果(approved=false) 模型理解拒绝意图 不执行原操作 POST /runAgent {messages, tools:[confirmAction]} 1 注入 + 启动 run 2 推理 3 tool_call: confirmAction(detail) 4 SSE: RUN_FINISHED(interrupt) 5 审批弹框 6 点击"拒绝" 7 POST /runAgent {threadId, resume:[{interruptId, status:"resolved", payload:{approved:false}}]} 8 恢复 9 带"用户拒绝"继续 10 礼貌回复 + 说明已取消 11 TEXT_MESSAGE_* + Agent 结束 12 SSE: TEXT_MESSAGE_* + RUN_FINISHED 13

底层机制:挂起为什么导致"前端执行"

本节回答三个底层问题:① "无实现"是什么意思?② 挂起和"前端工具"有关系吗?③ 为什么走到挂起就由前端执行?

① "无实现"是什么意思

前端工具被包装成"仅含 schema 的工具"(schema-only):只携带 name + description + parameters(JSON Schema),刚好够拼成发给 LLM 的工具定义;但它没有任何业务逻辑——不查库、不调 API、不读写文件。它是"空壳":LLM 能看见、能选择,但服务端无法执行。

② 挂起和"前端"无关——它是通用的"需要外部提供结果"信号

到达挂起状态有两条独立路径,殊途同归:

路径 触发点 谁会用
A. externalTool=true 标志 工具执行器短路(不调用执行逻辑 schema-only 工具、被注解标记为外部工具的后端方法
B. 执行逻辑运行中主动抛挂起信号 工具执行器的异常捕获分支 后端工具执行到一半发现需人工输入

两路都产出同一个"挂起的工具结果"(内部标记为挂起)。所以挂起 ⊃ “前端工具”——纯后端工具也能挂起(路径 B)。"前端 tool"只是"用 schema-only 工具(路径 A)+ 由前端 resume 提供结果"的一种用法。

⚠️ 对 schema-only 工具,执行逻辑里抛出的挂起异常只是兜底;正常路径下执行器因 externalTool=true 提前短路,根本不会调用执行逻辑

③ 为什么结果最终来自前端(协议约定,非框架驱动)

框架并不会"把工具送到前端"。它只是逐级降级,最后把球停在 HTTP 边界上,由 AG-UI 协议约定消费方(前端)来接:

普通后端工具

externalTool=true
或执行逻辑抛挂起信号

AG-UI 场景

CLI 或其他后端

LLM 选中工具

工具执行器 判断

调用执行逻辑
本地执行

正常工具结果

挂起的工具结果
内部标记为挂起

agent 继续迭代

执行循环检测到挂起

构造挂起消息
状态 TOOL_SUSPENDED
停止迭代,交还控制权

事件转换层
TOOL_SUSPENDED → Interrupt

RUN_FINISHED outcome=interrupt
经 SSE 发出,run 结束

谁来提供结果?
框架不知道
AG-UI 协议约定消费方接球

前端执行/弹框/审批
→ 下次 run 带 resume 回传

其他消费方提供结果
→ resume 回传
框架代码一行不改

核心结论:框架只说"这个工具我本地跑不了,结果需要外部提供",然后停下来。"前端执行"是 AG-UI 协议消费方(浏览器)的职责,不是框架驱动的。 同一套机制挂在 CLI 或另一个后端服务里,接球的就是它们——框架代码一行不用改。


三类 Tool 核心对照

维度 后端 Tool 前端自动 Tool 前端 HITL Tool
externalTool false(默认) true true
注册位置 agent 工具集(有实现) 请求 tools 字段 → schema-only 工具 请求 tools 字段 → schema-only 工具
执行触发 工具执行器调用执行逻辑,本地执行 externalTool 短路(不调用执行逻辑) externalTool 短路(不调用执行逻辑)
是否中断 run
前端收到 interrupt 后 立即执行,瞬时 resume 渲染 UI,等用户操作
结果来源 服务端执行产出 前端程序化执行 → resume.payload 用户决策 → resume.payload
用户感知 几乎无(看到页面变化) 明确暂停,需主动操作
run 次数 1 次 2 次(挂起 + 恢复) 2 次(挂起 + 恢复)

合并模式对注入的影响

MERGE_FRONTEND_PRIORITY
默认

FRONTEND_ONLY

AGENT_ONLY

请求 tools 字段到达

合并模式

前端 + 后端 tool 都可用
同名时前端优先

临时隐藏后端 tool
只用前端 tool

忽略前端 tool
只用 agent 工具集

schema-only 工具注入工具集
run 作用域,结束后还原

不注入,跳过


参考


(END)

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐