文章目录
- [1. 向用户暴露子 Agent](#1. 向用户暴露子 Agent)
-
- [1.1 客户端:监听暴露事件并直接对话](#1.1 客户端:监听暴露事件并直接对话)
- [1.2 怎么开启:agent.channel(...)](#1.2 怎么开启:agent.channel(...))
- [2. 用代码控制是否暴露](#2. 用代码控制是否暴露)
-
- [2.1 通过 RuntimeContext 按调用覆盖](#2.1 通过 RuntimeContext 按调用覆盖)
- [2.2 通过声明设置按类型策略](#2.2 通过声明设置按类型策略)
- [3. 跨重启与多副本](#3. 跨重启与多副本)
- [4. 让 agent 自己写新的子 agent spec](#4. 让 agent 自己写新的子 agent spec)
- [5. 子 Agent 流式](#5. 子 Agent 流式)
-
- [5.1 消费事件流](#5.1 消费事件流)
- [5.2 转成 SSE](#5.2 转成 SSE)
- [5.3 行为边界](#5.3 行为边界)
- [5.4 错误处理](#5.4 错误处理)
- [6. 在 Plan Mode 下委派子 agent](#6. 在 Plan Mode 下委派子 agent)
1. 向用户暴露子 Agent
通常子 agent 对用户不可见 ,它们在幕后作为父 agent 的内部工具运行。
但有时我们想做"分支对话 ":父 agent 派出一个专家子 agent,然后让用户直接和这个专家继续聊 ,绕过父 agent。这就是 expose_to_user。
主 agent 在推理时这样调用:
agent_spawn agent_id="researcher" task="调研 AI 趋势" expose_to_user=true
它做了两件事:
- 在 Gateway 里注册该子
agent,使其成为用户可寻址的入口; - 向流式事件流发出一个
SubagentExposedEvent,携带subagentId句柄。
1.1 客户端:监听暴露事件并直接对话
用户客户端收到 SubagentExposedEvent 后,就能直接向子 agent 发消息,完全绕过父 agent:
java
import io.agentscope.core.event.SubagentExposedEvent;
import io.agentscope.harness.agent.gateway.channel.chatui.SendOptions;
// 1) 在事件流中监听"被暴露的子 agent"
chat.sendStream(SendOptions.userId("user-1"), "派一个研究员调查 AI 趋势")
.doOnNext(event -> {
if (event instanceof SubagentExposedEvent se) {
se.getSubagentId(); // → 用来直接和子 agent 对话的句柄
se.getAgentId(); // → 子 agent 类型(如 "researcher")
se.getLabel(); // → 可选的人类可读名称
}
})
.blockLast();
// 2) 直接向暴露的子 agent 发消息(不经过父 agent)
chat.sendToSubagent(subagentId, "重点关注 LLM agent").block();
1.2 怎么开启:agent.channel(...)
暴露能力依赖一个 Channel (内部 Gateway)。用 agent.channel(...) 一行接好,零配置:
java
HarnessAgent agent = HarnessAgent.builder()
.name("orchestrator")
.model("dashscope:qwen-plus")
.build();
// channel() 创建内部 gateway 并自动接好 bridge ------ expose_to_user 直接可用
ChatUiChannel chat = agent.channel(ChatUiChannel.create());
⚠️ 没有绑定 Channel 时 ,
agent_spawn里的expose_to_user=true会被静默忽略 ------子agent照常工作,只是不会暴露给用户。多agent场景用GatewayBootstrap接,见官方GatewayBootstrap下暴露子Agent。
2. 用代码控制是否暴露
完全依赖 LLM 传 expose_to_user=true 有时不够灵活。可以从应用代码侧 覆盖这个决策。最终生效值按以下优先级解析(从高到低):
RuntimeContext按调用覆盖 ------ 作用于当前这次调用里的所有agent_spawn;SubagentDeclaration按类型策略 ------ 该子 agent 类型的静态默认值;- LLM 传入的
expose_to_user工具参数; - 以上都没表态 → 默认
false。
2.1 通过 RuntimeContext 按调用覆盖
在 AgentSpawnTool.CTX_EXPOSE_TO_USER 这个 key 下放一个 Boolean(或其字符串形式):
java
import io.agentscope.harness.agent.tool.AgentSpawnTool;
RuntimeContext ctx = RuntimeContext.builder()
.userId("user-1")
.put(AgentSpawnTool.CTX_EXPOSE_TO_USER, true) // 强制开启;传 false 则禁止暴露
.build();
该常量值经核实为
"agentscope.subagent.expose_to_user"。
2.2 通过声明设置按类型策略
exposeToUser 是三态:
TRUE总是暴露FALSE永不暴露(即使LLM传了expose_to_user=true也被覆盖)null(默认)则交给context覆盖、再交给LLM参数决定:
java
SubagentDeclaration decl = SubagentDeclaration.builder()
.name("researcher")
.description("调研主题并返回汇总报告。")
.exposeToUser(true) // 这个子 agent 类型始终对用户可直接寻址
.build();
或在 Markdown spec 的 front matter 里(同样三态------不写表示"不表态"):
markdown
---
description: 调研主题并返回汇总报告。
expose_to_user: true
---
这样无论模型怎么决定,你都能强制或禁止 暴露;两侧都不表态时,仍交给 LLM 自行选择。
3. 跨重启与多副本
默认情况下,暴露只存在于创建它的进程里 :subagentId 只在那个节点有效,重启即失效。要让暴露的子 agent 在任意副本、重启之后 都能解析,给 agent 配上 distributedStore(...) 即可:
java
HarnessAgent agent = HarnessAgent.builder()
.name("orchestrator")
.model("dashscope:qwen-plus")
.distributedStore(RedisDistributedStore.fromJedis(jedis)) // 一行接入分布式存储
.build();
ChatUiChannel chat = agent.channel(ChatUiChannel.create()); // 恢复能力自动接好
subagentId 会持久化到后端,子 agent 自己的对话按 session 从分布式 AgentStateStore 重新加载,即使后续消息落到不同节点,用户面对的仍是同一个子 agent。
多
agent的GatewayBootstrap传.distributedStore(...)(不传则继承 main agent 的)。生产部署建议------包括把某个subagentId路由回它活实例所在节点的"粘性路由"------见官方"上生产"。
4. 让 agent 自己写新的子 agent spec
agent_generate 工具(默认关闭 )可以让 LLM 起草一份新的子 agent spec,并直接写到 workspace/subagents/<name>.md:
java
// 开启方法(构建期):拿到 builder 内部的 SubagentsMiddleware 引用,
// 调用其 enableAgentGenerateTool() 打开 agent_generate 工具。
适合 agent 跑到一半发现自己需要一类新的助手"的场景。
⚠️ 生产环境慎用 :通常先让
agent把方案写出来、人工review之后再落文件,避免模型自行写入未经审查的子agent定义。
5. 子 Agent 流式
父 agent 通过 agent_spawn/agent_send 同步 调用子 agent 时,子 agent 的中间事件会实时转发 到父的 streamEvents() 流中。每个子事件都带一个 source 字段 (/ 分隔的路径,如 "main/researcher"),父事件的 source 为 null。
caller
└─ parent.streamEvents(msg, ctx)
├─ AGENT_START ← 父 agent 启动
├─ TEXT_BLOCK_DELTA ... ← 父推理
├─ TOOL_CALL_START "agent_spawn"
│ [子 agent 创建]
├─ AGENT_START (source="main/researcher") ← 子启动
├─ TEXT_BLOCK_DELTA ... (source="main/researcher") ← 子推理
├─ TOOL_CALL_START ... (source="main/researcher")
├─ TOOL_RESULT_END ... (source="main/researcher")
├─ AGENT_END (source="main/researcher") ← 子结束
│ [agent_spawn 返回,子结果作为 TOOL_RESULT 传给父]
├─ TOOL_RESULT_END ← 父收到工具结果
├─ TEXT_BLOCK_DELTA ... ← 父第二轮推理
└─ AGENT_END ← 父结束
5.1 消费事件流
推荐 streamEvents() :
java
import io.agentscope.core.event.AgentEventType;
import io.agentscope.core.event.TextBlockDeltaEvent;
import io.agentscope.core.event.ToolCallStartEvent;
parent.streamEvents(new UserMessage(message), ctx)
.doOnNext(event -> {
String src = event.getSource();
String prefix = (src != null) ? "[" + src + "] " : ""; // 子事件带来源前缀
if (event.getType() == AgentEventType.TEXT_BLOCK_DELTA) {
System.out.print(prefix + ((TextBlockDeltaEvent) event).getDelta());
} else if (event.getType() == AgentEventType.TOOL_CALL_START) {
System.out.println(prefix + "[tool] " + ((ToolCallStartEvent) event).getToolCallName());
} else if (event.getType() == AgentEventType.AGENT_START) {
if (src != null) System.out.println("── 子 agent 启动: " + src);
} else if (event.getType() == AgentEventType.AGENT_END) {
if (src != null) System.out.println("── 子 agent 结束: " + src);
}
})
.blockLast();
按来源区分父子事件:
java
events.filter(e -> e.getSource() == null).subscribe(...); // 只看父事件
events.filter(e -> e.getSource() != null).subscribe(...); // 只看子事件
events.filter(e -> e.getSource() != null
&& e.getSource().contains("researcher")).subscribe(...); // 只看某子 agent
5.2 转成 SSE
把事件流映射成 SSE 推给前端。下面是文档式写法(Spring MVC 也支持直接返回 Flux<ServerSentEvent>):
java
@GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chat(@RequestParam String message,
@RequestParam String sessionId) {
RuntimeContext ctx = RuntimeContext.builder().sessionId(sessionId).build();
return agent.streamEvents(new UserMessage(message), ctx)
.map(event -> {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("type", event.getType().name());
payload.put("id", event.getId());
if (event.getSource() != null) {
payload.put("source", event.getSource()); // 关键:把来源透给前端,便于分栏显示
}
if (event instanceof TextBlockDeltaEvent delta) {
payload.put("delta", delta.getDelta());
} else if (event instanceof ToolCallStartEvent start) {
payload.put("toolName", start.getToolCallName());
}
return ServerSentEvent.<String>builder()
.data(objectMapper.writeValueAsString(payload))
.build();
});
}
衔接【34】:那篇我们用的是
SseEmitter+ 手动subscribe(POST 端点)。这里返回Flux<ServerSentEvent>是等价的另一种写法(GET端点、由MVC适配响应式返回值)。两者都可用;前端要做的额外一步,就是按source把子agent的输出单独分栏/缩进展示。
5.3 行为边界
| 场景 | 是否实时流转发? |
|---|---|
streamEvents() + 同步本地子 agent(timeout_seconds > 0) |
✔ |
call() 模式(非流式) |
✗(子结果以 tool_result 字符串返回) |
timeout_seconds = 0 后台任务 |
✗(终态通过反向通知在父 agent 下一轮给出) |
| 远程子 agent(Agent Protocol) | ✗ |
5.4 错误处理
子 agent 内部出错时,框架会把错误捕获并写成一条 TOOL_RESULT 给父 ,不会把 onError 传播到父流 ,父流不会被子 agent 的失败打断。如果父流本身 出错(比如父的模型调用失败),按标准 Reactor 语义处理(onErrorResume 等)。
6. 在 Plan Mode 下委派子 agent
这一点把【34】【35】的计划模式和子 agent 串了起来:
父
agent处于 Plan Mode 时spawn的子agent,会自动继承只读限制 ------子agent在spawn时就被置入Plan Mode,无法执行写操作。
也就是说,安全边界在委派链上不会断 :父在只读规划阶段,派出去的子 agent 也只能调研、不能动手。配合【36】里讲的"权限 DENY 继承",整条委派链的安全约束是一致向下传递的。