AgentScope4J 深度学习指南 - 从狼人杀项目掌握多智能体框架

以狼人杀多智能体游戏为实战案例,系统学习 AgentScope4J 的核心概念,并补充 2026 年最新的 Harness Framework、长记忆、分布式会话与多 Agent 编排能力。

前言

AgentScope4J 是阿里巴巴开源的多智能体框架,提供了完整的 Agent 开发基础设施。本文以 werewolf-hitl 项目为案例,这是一个带人机交互(HITL)的狼人杀多智能体游戏,先从项目里实际出现的通信、结构化输出、人机交互与多智能体协作讲起,再延伸到 AgentScope Java 1.0.10 之后补强的 Nacos、A2A、长记忆、可观测与 1.1 系列 Harness Framework。

学习路径:

  1. Msg 消息体系 — 理解通信单元
  2. Agent 体系 — 理解代理机制
  3. 工具调用与结构化输出 — 理解 LLM 交互
  4. WebUserInput 人机交互 — 理解异步等待
  5. MsgHub 多智能体通信 — 理解协作机制
  6. Hook 生命周期 — 理解执行拦截
  7. Pipeline 编排 — 理解多 Agent 组合
  8. Formatter、流式输出、Prompt、持久化 — 理解框架生产化基础
  9. A2A、MCP、RAG、多模态 — 理解扩展生态
  10. Harness Framework — 理解最近新增的工作区、记忆、会话、沙箱和子 Agent 运行时

一、Msg 消息体系

1.1 Msg 结构总览

Msg 是 AgentScope 的唯一通信单元。Agent 之间、Agent 与 Model 之间、Agent 与 Tool 之间,传递的都是 Msg。

┌─────────────────── Msg 结构 ───────────────────┐
│                                                │
│  id:        UUID(自动生成)                    │
│  name:      发送者名字("张三"、"Moderator")   │
│  role:      USER / ASSISTANT / SYSTEM / TOOL   │
│  content:   List<ContentBlock>(内容块列表)     │
│  metadata:  Map<String, Object>(附带数据)     │
│  timestamp: "2026-05-10 14:30:00.123"          │
│                                                │
└────────────────────────────────────────────────┘

关键设计点:

  • Msg 不可变(immutable) — 构造后不可修改,需要变更时通过 Builder 重新创建
  • content 是 List<ContentBlock> — 支持多模态内容(文本、图片、音频、工具调用等)
  • metadata 是 Map — 承载结构化输出、Token 用量统计等"暗数据"

1.2 MsgRole 四种角色

MsgRole含义狼人杀中的例子
SYSTEM系统提示词每个 Agent 的人设 prompt
USER用户/外部输入真人玩家发言、裁判公告、其他 Agent 的话
ASSISTANTAI Agent 的回复agent.call() 的返回值
TOOL工具执行结果狼人杀中未使用

一个容易混淆的设计:在 MsgHub 广播时,其他 Agent 的消息到达当前 Agent 时,role 仍然是 USER。因为从当前 Agent 的视角来看,“别人说的话"就是"用户输入”。

1.3 ContentBlock 多态内容

ContentBlock 是一个 sealed class,只允许 7 种子类型:

Block 类型JSON type用途狼人杀使用
TextBlock"text"纯文本✅ 到处用
ThinkingBlock"thinking"Agent 推理过程✅ 开启 thinking
ImageBlock"image"图片(URL/Base64)
AudioBlock"audio"音频❌(狼人杀用 TTS 模型直接生成语音,不通过 AudioBlock)
VideoBlock"video"视频
ToolUseBlock"tool_use"工具调用请求
ToolResultBlock"tool_result"工具调用结果

一条 Msg 的 content 可以同时包含多个 Block。比如 LLM 的回复可能同时有 ThinkingBlock(思考过程)+ TextBlock(最终回答)。

1.4 Msg.builder() 实战用法

狼人杀项目中 Msg.builder() 只用了 3 种模式:

模式 1:最简用法 — 纯文本消息(裁判公告)

// MessageSourcePromptProvider.java:46-52
private Msg buildMsg(String text) {
    return Msg.builder()
            .name("Moderator Message")          // 谁说的
            .role(MsgRole.USER)                  // 角色
            .content(TextBlock.builder()
                .text(text).build())             // 内容
            .build();
}

模式 2:人类玩家的消息(HITL 输入)

// 发言 — WerewolfWebGame.java:411-418
Msg.builder()
    .name(humanPlayer.getName())
    .role(MsgRole.USER)
    .content(TextBlock.builder().text(humanInput).build())
    .build()

// 投票 — 需要 metadata 与 AI 投票格式统一
Msg.builder()
    .name(player.getName())
    .role(MsgRole.USER)
    .content(TextBlock.builder().text(voteTarget).build())
    .metadata(Map.of(
        MessageMetadataKeys.STRUCTURED_OUTPUT,
        Map.of("targetPlayer", voteTarget, "reason", "")
    ))
    .build();

模式 3:直接注入 Memory 的裁判密令

// 女巫看到受害者 — WerewolfWebGame.java:521-527
Msg victimMsg = Msg.builder()
    .name("Moderator Message")
    .role(MsgRole.USER)
    .content(TextBlock.builder().text(victimInfo).build())
    .build();
witchAgent.getMemory().addMessage(victimMsg);

1.5 Metadata — Msg 里的暗数据

metadata 字段(Map<String, Object>)承载了很多非内容信息:

Key含义使用场景
agentscope_structured_output结构化输出数据投票、决策
agentscope_chat_usageToken 用量统计成本追踪
agentscope_generate_reason生成原因HITL 中断检测

二、Agent 体系

2.1 接口继承关系

                     Agent (接口,三合一)
                    /      |        \
                   /       |         \
        CallableAgent  StreamableAgent  ObservableAgent
        (能调用)         (能流式)          (能观察)
            |                                |
        call(msgs)                      observe(msg)
        call(msgs, Class)                   ↓
            ↓                           memory.addMessage(msg)
     返回 Mono<Msg>

                    AgentBase (抽象基类)
                   /                \
                  /                  \
          StructuredOutputCapableAgent    UserAgent
                  |
              ReActAgent

Agent 接口是三合一的Agent.java:41):

public interface Agent extends CallableAgent, StreamableAgent, ObservableAgent
能力接口核心方法含义
调用CallableAgentcall(List<Msg>)Mono<Msg>处理消息,生成回复
流式StreamableAgentstream(List<Msg>, StreamOptions)Flux<Event>流式返回执行过程
观察ObservableAgentobserve(Msg)Mono<Void>静默接收消息,不回复

2.2 三种 Agent 的本质区别

ReActAgentUserAgent
本质AI 自动推理+行动人机桥接器
doCall()ReAct 循环(reason→act→observe)调用 UserInputBase 等人输入
doObserve()memory.addMessage(msg)空操作(Mono.empty()
有 Memory?✅ 有❌ 没有
有 Model?✅ 有❌ 没有
有 Toolkit?✅ 有(可以是空的)❌ 没有
创建方式ReActAgent.builder()...build()UserAgent.builder()...build()
狼人杀中AI 玩家(张三、李四…)真人玩家

2.3 ReActAgent — 核心中的核心

ReAct 是什么

ReAct = Reason(推理)+ Act(行动)。循环过程:

                 ┌──────────────────────────┐
                 │                          │
                 ▼                          │
           ┌──────────┐                     │
           │ Reasoning │ ← 调用 LLM 推理     │
           └────┬─────┘                     │
                │                           │
          LLM 返回了工具调用?                │
           /          \                     │
         是             否                  │
         │              │                  │
    ┌────▼────┐    ┌────▼────┐              │
    │ Acting  │    │ 返回结果 │ ← 结束循环    │
    │ 执行工具 │    └─────────┘              │
    └────┬────┘                             │
         │                                  │
    工具结果加入 memory                      │
         │                                  │
         └──────────────────────────────────┘
              继续下一轮 Reasoning

doCall() 的入口逻辑

ReActAgent.java:264-298,这是 call() 的实际实现:

@Override
protected Mono<Msg> doCall(List<Msg> msgs) {
    Set<String> pendingIds = getPendingToolUseIds();

    // 情况 1:没有待处理的工具 → 正常的 Reason→Act 循环
    if (pendingIds.isEmpty()) {
        addToMemory(msgs);           // 输入消息加入 memory
        return executeIteration(0);  // 从第 0 轮开始
    }

    // 情况 2:有待处理工具 + 没有新输入 → 直接执行待处理工具
    if (msgs == null || msgs.isEmpty()) {
        return acting(0);
    }

    // 情况 3:有待处理工具 + 用户提供了工具结果 → 验证并加入
    // ...
}

prepareMessages() — 上下文组装

ReActAgent.java:821-833

private List<Msg> prepareMessages() {
    List<Msg> messages = new ArrayList<>();
    if (sysPrompt != null && !sysPrompt.trim().isEmpty()) {
        messages.add(Msg.builder()
            .name("system")
            .role(MsgRole.SYSTEM)
            .content(TextBlock.builder().text(sysPrompt).build())
            .build());
    }
    messages.addAll(memory.getMessages());  // 所有历史消息
    return messages;
}

2.4 UserAgent — 人机桥接器

UserAgent 的设计极其精简:

// UserAgent.java:101-103
@Override
protected Mono<Msg> doCall(List<Msg> msgs) {
    return getUserInput(msgs, null);
}

// UserAgent.java:246-248
@Override
protected Mono<Void> doObserve(Msg msg) {
    return Mono.empty();  // 空操作!不存储任何消息
}

UserAgent 不管理 Memory。 它的作用只是"等人的输入,转成 Msg 返回"。

2.5 狼人杀中 Agent 的创建

WerewolfWebGame.java:212-242

for (int i = 0; i < roles.size(); i++) {
    AgentBase agent;
    boolean isHuman = (i == humanPlayerIndex);

    if (isHuman) {
        // 真人玩家 → UserAgent
        agent = UserAgent.builder()
            .name(name)
            .inputMethod(userInput)    // WebUserInput
            .build();
    } else {
        // AI 玩家 → ReActAgent
        agent = ReActAgent.builder()
            .name(name)
            .sysPrompt(prompts.getSystemPrompt(role, name))  // 人设
            .model(model)                                      // DashScopeChatModel
            .memory(new InMemoryMemory())                      // 对话记忆
            .toolkit(new Toolkit())                            // 空工具箱
            .structuredOutputReminder(StructuredOutputReminder.PROMPT)  // 结构化输出策略
            .build();
    }
}

三、工具调用与结构化输出

3.1 工具调用的真实流程

关键澄清:LLM 不会直接调用工具,只会返回"工具调用请求"。

LLM 返回: "我想调用 generate_response 工具,参数是 {...}"
    ↓
Agent(应用层)收到这个请求
    ↓
Agent 执行工具,拿到结果
    ↓
把结果发回给 LLM
    ↓
LLM 基于结果继续推理

3.2 Hook 检查机制

StructuredOutputHook.javahandlePostReasoning() 方法检查的是:

boolean hasCall = !msg.getContentBlocks(ToolUseBlock.class).isEmpty();
//                 ↑ 如果为空,说明 LLM 没有请求调用任何工具
//                 ↑ 如果不为空,说明 LLM 请求调用了某个工具

为什么检查 ToolUseBlock 而不是"工具是否执行"?

因为 Hook 在 PostReasoning 阶段触发,此时:

  • LLM 刚返回结果
  • Agent 还没执行工具
  • Hook 需要检查的是"LLM 是否请求了工具调用"

3.3 getContentBlocks 的实现:Java 类型系统检查

不是字符串匹配,是 Java 类型系统(instanceof)检查。

public <T extends ContentBlock> List<T> getContentBlocks(Class<T> blockClass) {
    return content.stream()
            .filter(blockClass::isInstance)  // ← 关键:用 instanceof 检查
            .map(b -> TypeUtils.safeCast(b, blockClass))
            .collect(Collectors.toList());
}

因为 ContentBlock 是 sealed class,每个子类在创建时就有明确的 Java 类型,所以 isInstance() 检查是可靠的。

3.4 LLM 不会直接输出 JSON

关键澄清:LLM 输出的是 token 序列,不是 JSON。 API 层负责把这些 token 解析成结构化的 JSON。

DashScope API 返回的实际格式

{
  "request_id": "xxx-xxx",
  "output": {
    "choices": [{
      "message": {
        "role": "assistant",
        "content": "我觉得应该投张三",
        "tool_calls": [{
          "id": "call_abc123",
          "type": "function",
          "function": {
            "name": "generate_response",
            "arguments": "{\"targetPlayer\":\"张三\",\"reason\":\"...\"}"
          }
        }]
      }
    }]
  }
}

真实流程

调用 LLM API,传入 tools 参数
    ↓
LLM 生成 token 序列(包含特殊 token [TOOL_CALL_START] 和 [TOOL_CALL_END])
    ↓
API 层解析 token 序列,验证 JSON 格式,生成结构化 tool_calls
    ↓
AgentScope 解析 tool_calls,执行工具
    ↓
把工具结果发回给 LLM
    ↓
LLM 基于结果继续推理

如果 LLM 输出的不是有效 JSON?

API 层会处理。 有几种情况:

  1. LLM 输出的 arguments 不是有效 JSON → API 层会尝试修复或报错
  2. LLM 输出的 arguments 不符合 schema → API 层不验证 schema,由工具自己验证
  3. LLM 不想调用工具,只输出文本 → AgentScope 的 StructuredOutputHook 检测到没有工具调用,触发重试

3.5 结构化输出的完整机制

核心思路:把 JSON 验证问题转化为工具调用问题

传统方案(不推荐):

String response = llm.call("请按以下格式输出: {\"name\": \"xxx\", \"age\": n}");
try {
    JSONObject json = new JSONObject(response);
} catch (JSONException e) {
    // 格式不对,重试?很难让 LLM 理解错在哪里
}

AgentScope 的方案:

  1. 生成 JSON Schema
  2. 注册临时工具 generate_response,参数 schema 就是目标格式
  3. LLM 调用工具时,底层会验证参数是否符合 schema
  4. 如果不符合 → 重试/强制 tool_choice

完整流程

agent.call("请投票", VoteModel.class)
    │
    ├─ 生成 JSON Schema
    ├─ 注册临时工具 generate_response
    ├─ 注册 StructuredOutputHook
    │
    └─ 进入 ReAct 循环
         │
         ├─ reasoning: LLM 返回 ToolUseBlock("generate_response", {...})
         │   └─ Hook 检查:✅ 调用了 generate_response
         │
         ├─ acting: 执行工具,拿到结果
         │
         └─ 返回最终结果(metadata 含结构化数据)

两种重试策略

策略第一次失败后第二次失败后第三次失败后
TOOL_CHOICE强制 tool_choice = "generate_response"再强制一次抛异常
PROMPT提醒"请调用工具"再提醒一次抛异常

四、WebUserInput 人机交互

4.1 核心挑战

狼人杀项目的核心挑战是:AI Agent 的 call() 是同步阻塞的,但人的输入要通过 REST API 异步传来。

4.2 Sinks.One 的等待/唤醒机制

游戏线程(阻塞)                  REST 线程(异步)
      │                              │
      ▼                              │
waitForInput("VOTE")                 │
  │ 创建 Sinks.One<String>           │
  │ 存入 Map<"vote_123", sink>       │
  │ emitWaitUserInput("VOTE")        │  ← 前端收到"请投票"提示
  │ sink.asMono().block()            │  ← 游戏线程阻塞在这里!
  │         ↓ 挂起                    │
  │         ↓                        │
  │         ↓                   用户投票,POST /api/game/input
  │         ↓                        │
  │         ↓                   submitInput("VOTE", "张三")
  │         ↓                        │
  │         ↓                   sink.tryEmitValue("张三")  ← 发射值!
  │         ↓                        │
  │  ← Mono 拿到 "张三",解除阻塞    │
  │  继续执行游戏逻辑                │

4.3 核心代码

WebUserInput.waitForInput()(等输入):

// 1. 创建一个只能发射一次的发布者
Sinks.One<String> sink = Sinks.one();

// 2. 用唯一 key 存起来(inputType + timestamp)
String key = "vote_" + System.currentTimeMillis();
pendingInputs.put(key, sink);

// 3. 发 SSE 事件告诉前端"请让用户投票"
emitter.emitWaitUserInput("VOTE", "请选择要投出的玩家", candidates);

// 4. 把 Mono 包装返回,调用方用 .block() 阻塞等待
return sink.asMono();

WerewolfWebController.submitInput()(唤醒):

@PostMapping("/api/game/input")
public Map<String, Object> submitInput(@RequestBody Map<String, String> request) {
    String inputType = request.get("inputType");  // "VOTE"
    String content = request.get("content");      // "张三"
    
    // 找到对应的 sink,发射值
    WebUserInput.matchingSink(inputType, content);
    // 内部调用: sink.tryEmitValue("张三")
    
    return Map.of("success", true);
}

4.4 Sinks.One 的关键特性

特性说明
只能发射一次tryEmitValue() 只能调用一次,第二次会返回 FAIL_TERMINATED
无缓冲没人等时发射,值会丢失(但我们的场景是先等后发)
线程安全可以从任意线程调用 tryEmitValue()
block() 会挂起调用 asMono().block() 会释放当前线程,不占 CPU

五、MsgHub 多智能体通信

5.1 MsgHub 解决什么问题?

没有 MsgHub 时,多 Agent 通信是这样的:

// 狼人讨论:3 个狼人互相交流
Msg a1 = wolfA.call().block();
wolfB.observe(a1).block();
wolfC.observe(a1).block();

Msg b1 = wolfB.call().block();
wolfA.observe(b1).block();
wolfC.observe(b1).block();

Msg c1 = wolfC.call().block();
wolfA.observe(c1).block();
wolfB.observe(c1).block();
// ... 每个 Agent 的消息都要手动广播给其他所有 Agent

有 MsgHub 后:

try (MsgHub hub = MsgHub.builder()
        .name("WolfDiscussion")
        .participants(wolfA, wolfB, wolfC)
        .enableAutoBroadcast(true)  // 自动广播
        .build()) {
    
    hub.enter().block();
    
    wolfA.call().block();  // A 的回复自动广播给 B 和 C
    wolfB.call().block();  // B 的回复自动广播给 A 和 C
    wolfC.call().block();  // C 的回复自动广播给 A 和 B
}

5.2 核心机制:订阅关系

MsgHub 的核心是 resetSubscribers()MsgHub.java:323-333):

private void resetSubscribers() {
    if (enableAutoBroadcast) {
        for (AgentBase agent : participants) {
            // 每个 Agent 订阅其他所有 Agent(不订阅自己)
            List<AgentBase> others = participants.stream()
                    .filter(a -> !a.equals(agent))
                    .collect(Collectors.toList());
            agent.resetSubscribers(name, others);
        }
    }
}

图解:

参与者: [WolfA, WolfB, WolfC]

WolfA 的订阅者: [WolfB, WolfC]  ← A 的消息会发给 B 和 C
WolfB 的订阅者: [WolfA, WolfC]  ← B 的消息会发给 A 和 C
WolfC 的订阅者: [WolfA, WolfB]  ← C 的消息会发给 A 和 B

5.3 autoBroadcast 的工作原理

enableAutoBroadcast=true 时,每个 Agent 的 call() 返回值会自动广播给订阅者。

AgentBase.call() 内部(简化版):

public Mono<Msg> call(List<Msg> msgs) {
    return doCall(msgs)
        .flatMap(result -> {
            // 自动广播给订阅者
            if (autoBroadcastEnabled) {
                return broadcastToSubscribers(result).thenReturn(result);
            }
            return Mono.just(result);
        });
}

5.4 observe(msg) 做了什么?

对于 ReActAgentobserve() 的实现(ReActAgent.java:1048-1050):

@Override
protected Mono<Void> doObserve(Msg msg) {
    return memory.addMessage(msg);  // 就这一行!把消息加入记忆
}

是的,就是添加记忆。 但关键在于:什么时候添加,添加后怎么用。

5.5 Agent 调用 LLM 前怎么知道对方的消息?

答案是 通过 Memory。完整时序图:

时间线:
─────────────────────────────────────────────────────────────────────────────

MsgHub.enter()
    │
    ├─ WolfA.observe(announcement) → WolfA.memory = [announcement]
    ├─ WolfB.observe(announcement) → WolfB.memory = [announcement]
    └─ WolfC.observe(announcement) → WolfC.memory = [announcement]

WolfA.call()
    │
    ├─ prepareMessages()
    │   │
    │   └─ messages = [system_prompt, ...WolfA.memory.getMessages()]
    │               = [system_prompt, announcement]
    │
    ├─ model.stream(messages) → LLM 返回 "我觉得应该杀张三"
    │
    └─ broadcastToSubscribers("我觉得应该杀张三")
        │
        ├─ WolfB.observe("我觉得应该杀张三")
        │   → WolfB.memory = [announcement, "我觉得应该杀张三"]
        │
        └─ WolfC.observe("我觉得应该杀张三")
            → WolfC.memory = [announcement, "我觉得应该杀张三"]


WolfB.call()
    │
    ├─ prepareMessages()
    │   │
    │   └─ messages = [system_prompt, ...WolfB.memory.getMessages()]
    │               = [system_prompt, announcement, "我觉得应该杀张三"]
    │                                             ↑ WolfA 的发言!
    │
    ├─ model.stream(messages) → LLM 返回 "同意,我也觉得应该杀张三"
    │
    └─ broadcastToSubscribers("同意,我也觉得应该杀张三")
        │
        ├─ WolfA.observe("同意,我也觉得应该杀张三")
        │   → WolfA.memory = [announcement, "我觉得应该杀张三", "同意,我也觉得应该杀张三"]
        │
        └─ WolfC.observe("同意,我也觉得应该杀张三")
            → WolfC.memory = [announcement, "我觉得应该杀张三", "同意,我也觉得应该杀张三"]

5.6 前面调用的 Agent 怎么知道后面的人的消息?

答案是:不知道!

这是 MsgHub 的时序设计:

WolfA.call() 时:
    - WolfA.memory = [announcement]
    - WolfA 不知道 WolfB 和 WolfC 会说什么(因为还没发生)

WolfB.call() 时:
    - WolfB.memory = [announcement, WolfA 的发言]
    - WolfB 不知道 WolfC 会说什么(因为还没发生)

WolfC.call() 时:
    - WolfC.memory = [announcement, WolfA 的发言, WolfB 的发言]
    - WolfC 知道所有前面的发言

这是因果关系的保证: Agent 只能看到在它调用之前已经发生的对话,不能看到未来。

5.7 enableAutoBroadcast 的两种模式

模式行为适用场景狼人杀中
trueAgent 的 call() 返回值自动广播给其他参与者公开讨论白天讨论、投票
false需要手动调用 hub.broadcast(msg)受控通信狼人讨论

5.8 狼人杀中的 3 个 MsgHub 实例

1. 狼人讨论 MsgHub(WerewolfWebGame.java:381-428

MsgHub wolfHub = MsgHub.builder()
    .name("WolfDiscussion")
    .participants(wolfAgents)           // 只有狼人参与
    .announcement(wolfAnnouncement)     // "狼人请讨论要杀谁"
    .enableAutoBroadcast(false)         // ← 关闭自动广播!
    .build();

2. 白天讨论 MsgHub(WerewolfWebGame.java:825-898

MsgHub dayHub = MsgHub.builder()
    .name("DayDiscussion")
    .participants(aliveAgents)          // 所有活着的玩家
    .announcement(nightResultMsg)       // "昨晚死的是..."
    .enableAutoBroadcast(true)          // ← 开启自动广播
    .build();

3. 投票 MsgHub(WerewolfWebGame.java:909-998

MsgHub voteHub = MsgHub.builder()
    .name("Voting")
    .participants(aliveAgents)          // 所有活着的玩家
    .announcement(voteStartMsg)         // "请投票"
    .enableAutoBroadcast(true)          // ← 开启自动广播
    .build();

5.9 MsgHub 的生命周期

try (MsgHub hub = MsgHub.builder()
        .name("DayDiscussion")
        .participants(alice, bob, charlie)
        .announcement(announcementMsg)  // 进入时的公告
        .enableAutoBroadcast(true)
        .build()) {
    
    // 1. enter() — 建立订阅关系,广播公告
    hub.enter().block();
    
    // 2. 每个 Agent 独立调用 call()
    //    - A 的回复自动广播给 B 和 C
    //    - B 的回复自动广播给 A 和 C
    //    - ...
    for (Player p : alivePlayers) {
        p.getAgent().call().block();
    }
    
}  // 3. close() — 清理订阅关系

六、Hook 生命周期

6.1 Hook 是什么?

Hook 是 Agent 执行过程中的拦截器,可以在关键节点插入自定义逻辑。

核心接口

public interface Hook {
    <T extends HookEvent> Mono<T> onEvent(T event);
    default int priority() { return 100; }
}

Hook 的设计类似于 Servlet Filter 或 Spring Interceptor,在 Agent 执行流程的特定阶段进行拦截和处理。

6.2 5 个生命周期事件

ReActAgent 的执行过程中有 5 个 Hook 可以拦截的事件:

事件触发时机可修改内容
PreCallEventcall() 开始前inputMessages
PreReasoningEventLLM 推理前inputMessagesgenerateOptions
PostReasoningEventLLM 推理后reasoningMessage,支持 stopAgent()gotoReasoning()
PreActingEvent工具执行前toolUse
PostActingEvent工具执行后toolResult,支持 stopAgent()

执行流程图

agent.call(msgs)
    │
    ├─ PreCallEvent ──────────── 可修改输入消息
    │
    ├─ PreReasoningEvent ─────── 可修改推理输入
    │
    ├─ LLM 推理
    │
    ├─ PostReasoningEvent ────── 可修改推理结果,可中断 Agent
    │
    ├─ PreActingEvent ────────── 可修改工具调用
    │
    ├─ 执行工具
    │
    ├─ PostActingEvent ───────── 可修改工具结果,可中断 Agent
    │
    └─ 返回结果

6.3 事件分类

类别事件可修改
可修改PreCallEvent, PreReasoningEvent, PostReasoningEvent, PreActingEvent, PostActingEvent, PostCallEvent有 setter
只读ReasoningChunkEvent, ActingChunkEvent, ErrorEvent无 setter

可修改事件允许 Hook 改变 Agent 的行为,比如:

  • 修改输入消息(添加上下文、过滤敏感词)
  • 修改推理结果(强制工具调用、格式化输出)
  • 中断 Agent 执行(stopAgent()

只读事件用于监控和日志,不能改变 Agent 行为。

6.4 Hook 优先级

Hook 的 priority() 方法决定执行顺序,数值越小越先执行:

优先级范围用途示例
0-50系统级认证、安全检查
51-100高优先级验证、预处理
101-500普通业务逻辑
501-1000低优先级日志、监控
public class SecurityHook implements Hook {
    @Override
    public <T extends HookEvent> Mono<T> onEvent(T event) {
        // 安全检查逻辑
        return Mono.just(event);
    }
    
    @Override
    public int priority() {
        return 10;  // 高优先级,先执行
    }
}

6.5 狼人杀中的使用

狼人杀项目没有自定义 Hook,但框架内置的 StructuredOutputHook 在幕后工作:

// StructuredOutputHook 的核心逻辑(简化版)
public class StructuredOutputHook implements Hook {
    @Override
    public <T extends HookEvent> Mono<T> onEvent(T event) {
        if (event instanceof PostReasoningEvent) {
            // 检查 LLM 是否调用了 generate_response 工具
            // 如果没有调用,触发重试机制
        }
        return Mono.just(event);
    }
}

这个 Hook 在结构化输出流程中自动注册,确保 LLM 按照指定格式返回结果。


七、Pipeline 编排

7.1 Pipeline 解决什么问题?

当需要组合多个 Agent 协作时,Pipeline 提供了简洁的编排方式。

没有 Pipeline 时

Msg result1 = agentA.call(input).block();
Msg result2 = agentB.call(result1).block();
Msg result3 = agentC.call(result2).block();

有 Pipeline 时

Msg result = Pipelines.sequential(List.of(agentA, agentB, agentC), input).block();

7.2 两种 Pipeline 类型

SequentialPipeline — 串行流水线

Input → Agent1 → Agent2 → ... → Output
// 语法
Msg result = Pipelines.sequential(List.of(agentA, agentB), input).block();

// 等价于
Msg temp = agentA.call(input).block();
Msg result = agentB.call(temp).block();

适用场景

  • 有依赖关系的任务链
  • 需要前一个 Agent 的输出作为后一个 Agent 的输入
  • 流水线式处理(如:提取 → 分析 → 生成)

FanoutPipeline — 并行扇出

        ┌→ Agent1 → Output1
Input → ├→ Agent2 → Output2
        └→ Agent3 → Output3
// 语法
List<Msg> results = Pipelines.fanout(List.of(agentA, agentB, agentC), input).block();

// results = [agentA 的结果, agentB 的结果, agentC 的结果]

适用场景

  • 多个 Agent 独立处理同一输入
  • 需要汇总多个视角的结果
  • 并行执行提高效率

7.3 两种执行模式

Pipeline 支持两种执行模式,通过 ExecutionMode 配置:

模式行为实现方式
Concurrent真并行subscribeOn(scheduler)
Sequential独立但有序Flux.concat()

Concurrent 模式

// 多个 Agent 在不同线程并行执行
Flux.fromIterable(agents)
    .subscribeOn(Schedulers.boundedElastic())
    .flatMap(agent -> agent.call(input))

Sequential 模式

// 多个 Agent 按顺序执行,但不共享状态
Flux.fromIterable(agents)
    .concatMap(agent -> agent.call(input))

7.4 错误处理

两种 Pipeline 对错误的处理策略不同:

SequentialPipeline

任何一个失败,整个 Pipeline 失败。

// AgentA 成功,AgentB 失败
Msg result = Pipelines.sequential(List.of(agentA, agentB, agentC), input).block();
// 抛出 AgentB 的异常,AgentC 不会执行

FanoutPipeline

所有 Agent 都会执行,失败时抛出 CompositeAgentException。

try {
    List<Msg> results = Pipelines.fanout(List.of(agentA, agentB, agentC), input).block();
} catch (CompositeAgentException e) {
    // e.getExceptions() 包含所有失败的异常
    // 成功的 Agent 结果仍然可用
}

7.5 狼人杀为什么不用 Pipeline?

狼人杀项目没有使用 Pipeline,原因是:

  1. MsgHub 更适合平等对话

    • 狼人杀中的 Agent 是平等的参与者,不是流水线上的工人
    • MsgHub 提供了广播机制,所有 Agent 都能收到其他人的发言
  2. 没有依赖关系

    • 每个 Agent 的发言不依赖于其他 Agent 的输出
    • 每个 Agent 基于自己的记忆(Memory)独立决策
  3. 需要 HITL(人机交互)

    • Pipeline 不支持在执行过程中等待人类输入
    • 狼人杀需要在每个 Agent 执行前/后可能等待真人玩家操作

Pipeline 更适合的场景

  • RAG 检索增强生成(检索 → 重排 → 生成)
  • 多步推理(分析 → 规划 → 执行)
  • 并行评估(多个评估 Agent 同时打分)

八、Formatter 格式化

8.1 为什么需要 Formatter?

AgentScope 使用统一的 Msg 格式,但各家 LLM API 的格式都不同。Formatter 自动完成 Msg ↔ LLM API 格式的双向转换。

8.2 4 个核心职责

  1. 消息格式化:Msg → 厂商格式(format 方法)
  2. 响应解析:厂商响应 → ChatResponse(parseResponse 方法)
  3. 参数应用:temperature、max_tokens 等(applyOptions 方法)
  4. 工具应用:ToolSchema → 厂商格式(applyTools 方法)

8.3 完整转换流程

1. Agent 准备消息(prepareMessages)
2. Formatter.format() 转换为厂商格式
3. applyTools() 应用工具 schema
4. applyOptions() 应用生成参数
5. 调用 HTTP API
6. 厂商响应返回
7. Formatter.parseResponse() 解析响应

8.4 ThinkingBlock 的处理

  • ThinkingBlock 存储在 Memory,但不发给 LLM API
  • 原因:节省 token、避免暴露推理过程、保持对话历史整洁

8.5 DashScopeMultiAgentFormatter 的消息合并

  • 把多条消息合并为一条带 <history> 标签的消息
  • 优点:减少消息数量、节省 token、保持对话历史完整性

8.6 各家 Formatter 的差异

Formatter特点
DashScopeMultiAgentFormatter消息合并,适合多 Agent 对话
DashScopeChatFormatter标准 DashScope 格式
OpenAIChatFormatter标准 OpenAI 格式
AnthropicChatFormatterSystem prompt 不在 messages 中
GeminiChatFormatterGoogle 特殊格式

8.7 狼人杀中的使用

model = DashScopeChatModel.builder()
    .formatter(new DashScopeMultiAgentFormatter())  // 一行创建,自动工作
    .build();

8.8 总结

Formatter 是 AgentScope 的翻译层,屏蔽各家 LLM API 的差异,提供统一的接口。


九、流式输出

9.1 为什么需要流式输出?

LLM 生成响应需要时间(几秒到几十秒),流式输出让用户实时看到 LLM 生成的内容,提升用户体验。

9.2 核心接口

  • StreamableAgent.stream() 返回 Flux<Event>
  • Event 包含 type(事件类型)、message(消息内容)、isLast(是否最后一个)
  • StreamOptions 配置事件类型、增量模式、Reasoning 过滤等

9.3 EventType 枚举

事件类型含义
REASONINGLLM 推理过程
TOOL_RESULT工具执行结果
SUMMARY总结生成

9.4 isLast 字段的含义

含义使用场景
true最终结果可以保存到数据库
false中间块用于实时更新 UI

9.5 增量模式 vs 累积模式

模式行为配置
增量模式每次只返回新内容incremental=true
累积模式每次返回所有累积内容incremental=false

9.6 底层原理:SSE(Server-Sent Events)

流式输出的底层是通过 HTTP 的 SSE 协议实现的:

  1. 客户端发送 HTTP 请求,设置请求头 X-DashScope-SSE: enable
  2. 服务器返回持续的响应流,每个事件以 "data:" 前缀开头
  3. 遇到 "[DONE]" 标记表示结束

9.7 实现层次

1. Agent.stream()        — 返回 Event 流
2. Model.stream()        — 调用 HttpClient 发送 HTTP 请求
3. HttpClient.stream()   — 设置 SSE 请求头,解析 SSE 流
4. HttpTransport.stream()— 使用 JDK HttpClient 发送异步请求
5. JDK HttpClient        — sendAsync() 获取 InputStream

9.8 为什么用 SSE 而不是 WebSocket?

协议方向复杂度重连适用场景
SSE单向(服务器→客户端)简单自动LLM 流式输出
WebSocket双向复杂手动聊天室、游戏

LLM 流式输出只需要单向推送,SSE 更简单高效。

9.9 狼人杀中的使用

狼人杀项目关闭了流式输出(stream=false),因为它需要等待完整结果再继续游戏逻辑。

9.10 总结

流式输出是 AgentScope 的实时反馈机制,通过 SSE 协议实现服务器到客户端的单向推送,让用户实时看到 LLM 生成的内容。


十、阶段性总结:狼人杀项目覆盖的核心能力

10.1 核心概念回顾

概念核心要点
Msg统一消息格式,ContentBlock 支持多模态,metadata 承载结构化数据
AgentReActAgent = Memory + Model + Toolkit,UserAgent = 人机桥接器
工具调用LLM 返回 ToolUseBlock(请求),Agent 执行工具,结果发回 LLM
结构化输出把 JSON 验证问题转化为工具调用问题,用 tool_choice 强制保证格式
WebUserInputSinks.One + block() 阻塞游戏线程,REST 端点 tryEmitValue() 唤醒
MsgHub自动广播机制,订阅关系管理,因果时序保证
Hook执行拦截器,5 个生命周期事件,优先级控制,可修改/只读两类
PipelineSequentialPipeline 串行依赖,FanoutPipeline 并行扇出,两种执行模式

10.2 学习路径建议

阶段 1:狼人杀项目覆盖的核心概念(当前)

  • ✅ Msg 消息体系
  • ✅ Agent 体系
  • ✅ Structured Output
  • ✅ 工具调用
  • ✅ WebUserInput
  • ✅ MsgHub
  • ✅ Hook 生命周期
  • ✅ Pipeline 编排

阶段 2:框架核心补充

  • Formatter 格式化(Msg → 各家 LLM API 格式的转换)

阶段 3:能力扩展

  • RAG(检索增强生成)
  • 多模态支持
  • 流式输出

阶段 4:生产化

  • 持久化与状态管理
  • Prompt 管理

基础案例基于 AgentScope Java 的 werewolf-hitl 示例,后半部分补充 AgentScope Java 1.0.10 至 1.1.0-RC1 的公开更新。 代码路径:agentscope-java/agentscope-examples/werewolf-hitl


十一、Prompt 管理

11.1 Prompt 管理解决什么问题?

Agent 需要各种 Prompt(系统提示、任务提示、结果提示等),这些 Prompt 的管理面临以下挑战:

  • 集中管理:所有 Prompt 文本需要集中管理,而不是散落在各个代码文件中
  • 国际化支持:同一个 Prompt 需要支持多种语言(中文、英文等)
  • 动态参数:Prompt 中需要插入变量(如玩家名、游戏状态等)

11.2 PromptProvider 接口

PromptProvider 接口定义了所有 Prompt 的获取方法,是 Prompt 管理的核心抽象:

public interface PromptProvider {
    // 系统提示(角色人设)
    String getSystemPrompt(Role role, String playerName);
    
    // 任务提示(指令)
    String createDiscussionPrompt(GameState state);
    
    // 结果提示(反馈)
    String createSeerResultPrompt(Player target);
}
方法类型用途示例
getSystemPrompt()系统提示定义 Agent 的人设和行为准则“你是狼人,你的目标是…”
createDiscussionPrompt()任务提示告诉 Agent 当前要做什么“请讨论今晚要杀谁”
createSeerResultPrompt()结果提示告诉 Agent 执行结果“你查验的结果是:狼人”

11.3 MessageSourcePromptProvider 实现

MessageSourcePromptProvider 是使用 Spring MessageSource 实现的国际化 Prompt 提供者:

@Component
public class MessageSourcePromptProvider implements PromptProvider {
    
    private final MessageSource messageSource;
    
    @Override
    public String getSystemPrompt(Role role, String playerName) {
        String key = "prompt.system." + role.name().toLowerCase();
        return messageSource.getMessage(key, new Object[]{playerName}, LocaleContextHolder.getLocale());
    }
    
    @Override
    public String createDiscussionPrompt(GameState state) {
        return messageSource.getMessage(
            "prompt.discussion", 
            new Object[]{state.getAlivePlayers()}, 
            LocaleContextHolder.getLocale()
        );
    }
}

核心优势

  • 使用 Spring 的 MessageSource 机制,自动根据 Locale 加载对应语言
  • 支持参数替换({0}, {1} 占位符)
  • 与 Spring 生态无缝集成

11.4 国际化消息文件

Prompt 文本存储在 properties 文件中,按语言区分:

messages_zh_CN.properties(中文)

prompt.system.werewolf=你是狼人 {0},你的目标是在天亮前杀死所有村民。
prompt.system.villager=你是村民 {0},你的目标是找出并投票淘汰所有狼人。
prompt.discussion=当前存活玩家:{0},请发表你的看法。
prompt.seer.result=你查验 {0} 的结果是:{1}。

messages_en_US.properties(英文)

prompt.system.werewolf=You are werewolf {0}, your goal is to kill all villagers before dawn.
prompt.system.villager=You are villager {0}, your goal is to find and vote out all werewolves.
prompt.discussion=Current alive players: {0}, please share your thoughts.
prompt.seer.result=Your investigation on {0} shows: {1}.

参数替换规则

  • {0} → 第一个参数
  • {1} → 第二个参数
  • 以此类推

11.5 Prompt 的 3 种类型

Agent 在执行过程中需要 3 种类型的 Prompt:

类型作用时机示例
系统提示(System Prompt)定义 Agent 的人设和行为准则Agent 创建时“你是狼人,擅长伪装和欺骗”
任务提示(Task Prompt)告诉 Agent 当前要做什么每次调用时“请讨论今晚要杀谁”
结果提示(Result Prompt)告诉 Agent 执行结果工具执行后“你查验的结果是:张三是狼人”

三者的配合

System Prompt(人设) + Task Prompt(任务) + 历史记忆 → LLM 推理 → 输出
                                                          ↓
                                              工具执行 → Result Prompt(反馈)
                                                          ↓
                                                   继续推理或返回结果

11.6 狼人杀中的使用

狼人杀项目完整实现了 PromptProvider,使用 Spring MessageSource 支持中英文:

// WerewolfWebGame.java:212-242
for (int i = 0; i < roles.size(); i++) {
    Role role = roles.get(i);
    String name = playerNames.get(i);
    
    // 从 PromptProvider 获取系统提示
    String systemPrompt = promptProvider.getSystemPrompt(role, name);
    
    AgentBase agent = ReActAgent.builder()
        .name(name)
        .sysPrompt(systemPrompt)  // 使用集中管理的 Prompt
        .model(model)
        .memory(new InMemoryMemory())
        .build();
}

实际使用场景

  1. 狼人杀人任务createWolfKillPrompt() 生成"请讨论今晚要杀谁"的提示
  2. 预言家查验createSeerCheckPrompt() 生成"请查验一名玩家"的提示
  3. 投票阶段createVotePrompt() 生成"请投票淘汰一名玩家"的提示

11.7 总结

Prompt 管理是 AgentScope 的配置化机制

  • 集中管理:所有 Prompt 文本集中在 properties 文件中,便于维护和修改
  • 国际化支持:通过 Spring MessageSource 自动支持多语言切换
  • 动态参数:使用占位符 {0}, {1} 等插入运行时变量
  • 类型清晰:系统提示、任务提示、结果提示三种类型各司其职

设计原则:Prompt 文本不应该硬编码在代码中,而应该通过配置文件管理,这样可以:

  • 快速调整 Agent 行为(修改 properties 文件即可)
  • 支持多语言部署
  • 便于 A/B 测试不同的 Prompt 策略

十二、持久化与状态管理

12.1 持久化解决什么问题?

Agent 的状态默认存储在内存中,这带来了一个严重问题:应用重启后,所有状态都会丢失

内存存储的问题

应用启动 → Agent 创建(内存) → 对话进行中 → 应用崩溃/重启
                                          ↓
                                    所有状态丢失!
                                    对话历史、记忆、配置全部消失

Session 机制的解决方案

应用启动 → Agent 创建 → 对话进行中 → 保存到外部存储
                                          ↓
                              应用崩溃/重启 → 从外部存储加载 → 继续对话

12.2 核心接口

AgentScope 的持久化体系由 4 个核心接口组成:

State — 可持久化的状态对象

public interface State extends Serializable {
    // 标记接口,实现此接口的对象可以被持久化
}

Session — 会话存储接口

public interface Session {
    // 保存状态
    Mono<Void> save(SessionKey key, State state);
    
    // 加载状态
    <T extends State> Mono<T> get(SessionKey key, Class<T> stateClass);
    
    // 删除状态
    Mono<Void> delete(SessionKey key);
}

StateModule — 有状态组件接口

public interface StateModule {
    // 保存当前状态到 State 对象
    State saveTo();
    
    // 从 State 对象加载状态
    void loadFrom(State state);
}

StatePersistence — 状态持久化配置

public class StatePersistence {
    private final Session session;
    private final SessionKey sessionKey;
    
    // 是否自动保存(在 Agent 调用后自动保存)
    private boolean autoSave = true;
    
    // 是否自动加载(在 Agent 创建时自动加载)
    private boolean autoLoad = true;
}

12.3 Session 的实现

AgentScope 提供了多种 Session 实现,适应不同的存储需求:

实现存储方式优点缺点适用场景
JsonSessionJSON 文件简单、无需外部依赖性能一般、不支持分布式开发测试
RedisSessionRedis高性能、支持分布式需要 Redis 服务生产环境
MysqlSessionMySQL可靠、支持复杂查询性能相对较低需要持久化查询

JsonSession 示例

Session session = new JsonSession("/data/agent-states");

// 保存
SessionKey key = new SessionKey("user-123", "chat-456");
State state = agent.saveTo();
session.save(key, state).block();

// 加载
State loadedState = session.get(key, State.class).block();
if (loadedState != null) {
    agent.loadFrom(loadedState);
}

RedisSession 示例

Session session = new RedisSession(redisTemplate);

// 使用方式与 JsonSession 相同
// 底层使用 Redis 的 Hash 结构存储

12.4 完整使用流程

使用持久化的完整流程包括 5 个步骤:

// 1. 创建 Session 和 SessionKey
Session session = new JsonSession("/data/states");
SessionKey sessionKey = new SessionKey(userId, conversationId);

// 2. 创建 Agent(带 StatePersistence 配置)
StatePersistence persistence = StatePersistence.builder()
    .session(session)
    .sessionKey(sessionKey)
    .autoSave(true)
    .autoLoad(true)
    .build();

ReActAgent agent = ReActAgent.builder()
    .name("assistant")
    .model(model)
    .memory(new InMemoryMemory())
    .statePersistence(persistence)
    .build();

// 3. 加载状态(如果存在)
// autoLoad=true 时自动执行
// agent.loadFrom(session.get(sessionKey, State.class).block());

// 4. 使用 Agent
Msg response = agent.call(userMessage).block();

// 5. 保存状态
// autoSave=true 时自动执行
// session.save(sessionKey, agent.saveTo()).block();

自动保存的触发时机

  • Agent 的 call() 方法执行完成后
  • Agent 的 observe() 方法执行完成后
  • 显式调用 agent.save() 方法

12.5 狼人杀中的使用

狼人杀项目没有使用持久化,原因如下:

  1. 游戏是一次性的:每局游戏独立,不需要跨会话保存状态
  2. 状态生命周期短:一局游戏通常在 30 分钟内结束
  3. 无历史对话需求:游戏结束后,对话历史没有保留价值
  4. 简化实现:使用内存存储,避免外部依赖
// 狼人杀项目直接使用内存存储
ReActAgent agent = ReActAgent.builder()
    .name(name)
    .sysPrompt(systemPrompt)
    .model(model)
    .memory(new InMemoryMemory())  // 内存存储,不持久化
    .toolkit(new Toolkit())
    .build();

何时需要持久化

  • 对话机器人(需要记住用户偏好)
  • 客服系统(需要跨会话保持上下文)
  • 长期任务(需要分多次执行)

12.6 总结

持久化与状态管理是 AgentScope 的生产化机制

  • 解决的核心问题:应用重启后状态丢失
  • 核心接口:State、Session、StateModule、StatePersistence
  • 多种存储实现:JsonSession(文件)、RedisSession(Redis)、MysqlSession(MySQL)
  • 灵活配置:支持自动保存/加载,支持自定义存储实现

设计原则

  1. 透明持久化:通过 StatePersistence 配置,业务代码无感知
  2. 可插拔存储:Session 接口抽象,可以轻松切换存储实现
  3. 状态可序列化:State 接口确保对象可以被正确序列化和反序列化

生产环境建议

  • 开发测试:使用 JsonSession(简单、无依赖)
  • 生产环境:使用 RedisSession(高性能、支持分布式)
  • 需要查询:使用 MysqlSession(支持 SQL 查询)

十三、PlanNotebook 任务规划

13.1 PlanNotebook 是什么?

PlanNotebook 是 Agent 的任务计划管理工具,让 Agent 能够创建复杂任务的执行计划、分解为多个子任务、跟踪执行状态。

核心能力

  • 创建计划(Plan)并设定预期结果
  • 将计划分解为多个子任务(SubTask)
  • 按顺序执行子任务,自动跟踪状态
  • 支持历史计划恢复,中断后可继续执行

13.2 核心概念

Plan(计划)

Plan 是任务的顶层容器,包含以下字段:

字段类型含义
nameString计划名称
descriptionString计划描述
expectedOutcomeString预期结果
subtasksList<SubTask>子任务列表
statePlanState计划状态(ACTIVE/DONE/ABANDONED)

SubTask(子任务)

SubTask 是计划的执行单元,包含以下字段:

字段类型含义
nameString子任务名称
descriptionString子任务描述
expectedOutcomeString预期结果
stateSubTaskState子任务状态
outcomeString实际执行结果

SubTaskState(子任务状态)

TODO → IN_PROGRESS → DONE
                   → ABANDONED
状态含义
TODO待执行
IN_PROGRESS执行中
DONE已完成
ABANDONED已放弃

13.3 10 个工具函数

PlanNotebook 为 Agent 提供了 10 个工具函数,覆盖计划的完整生命周期:

工具函数用途说明
create_plan创建计划创建新的执行计划,包含名称、描述、预期结果
update_plan_info更新计划信息修改计划的名称、描述、预期结果
revise_current_plan修订计划添加、修改或删除子任务
update_subtask_state更新子任务状态将子任务状态改为 TODO/IN_PROGRESS/DONE/ABANDONED
finish_subtask完成子任务标记当前子任务为 DONE,自动激活下一个
view_subtasks查看子任务详情查看所有子任务的名称、状态、结果
get_subtask_count获取子任务数量返回当前计划的子任务总数
finish_plan完成或放弃计划标记整个计划为 DONE 或 ABANDONED
view_historical_plans查看历史计划查看之前创建的所有计划
recover_historical_plan恢复历史计划从历史计划中恢复,继续执行

工具函数的调用流程

create_plan("开发网站", "创建一个博客网站", "网站可正常访问")
    │
    ├─ revise_current_plan → 添加子任务: ["设计数据库", "实现后端", "实现前端"]
    │
    ├─ update_subtask_state(0, IN_PROGRESS) → 开始第一个子任务
    │
    ├─ ... 执行任务 ...
    │
    ├─ finish_subtask("数据库设计完成") → 自动激活下一个子任务
    │
    ├─ ... 继续执行 ...
    │
    └─ finish_plan(DONE) → 计划完成

13.4 状态约束

PlanNotebook 对子任务的执行有 3 个关键约束:

约束 1:只能顺序执行

子任务必须按顺序完成,不能跳过前面的子任务直接执行后面的。

✅ 正确:先完成子任务 0,再执行子任务 1
❌ 错误:跳过子任务 0,直接执行子任务 1

约束 2:只能有一个 IN_PROGRESS

同一时刻只能有一个子任务处于 IN_PROGRESS 状态,不允许同时执行多个子任务。

✅ 正确:子任务 0 = IN_PROGRESS,其他 = TODO
❌ 错误:子任务 0 = IN_PROGRESS,子任务 1 = IN_PROGRESS

约束 3:finish_subtask 自动激活下一个

调用 finish_subtask 完成当前子任务后,系统自动将下一个子任务的状态从 TODO 改为 IN_PROGRESS。

执行前:[DONE, IN_PROGRESS, TODO, TODO]
执行 finish_subtask()
执行后:[DONE, DONE, IN_PROGRESS, TODO]
                              ↑ 自动激活

13.5 Hint 自动注入机制

PlanNotebook 通过 Hook 自动注入提示,引导 Agent 按照计划执行。Agent 无需主动查询计划状态,提示会自动出现在每轮推理的上下文中。

注入的提示内容

=== 当前计划 ===
计划名称:开发网站
计划描述:创建一个博客网站
预期结果:网站可正常访问

=== 当前子任务 ===
子任务 2/5:实现后端 API
描述:实现用户注册、登录、文章 CRUD 接口
预期结果:所有 API 可正常调用

=== 执行规则 ===
1. 专注于当前子任务,不要跳到其他子任务
2. 完成当前子任务后,调用 finish_subtask
3. 所有子任务完成后,调用 finish_plan

注入时机:在 PreReasoningEvent 阶段,Hook 将提示注入到 Agent 的输入消息中。

13.6 历史计划恢复

PlanNotebook 支持历史计划恢复,适用于需要分多次执行的长任务。

恢复流程

第 1 次执行:
    create_plan("开发网站")
    ├─ 子任务 0: 设计数据库 → DONE
    ├─ 子任务 1: 实现后端 → DONE
    ├─ 子任务 2: 实现前端 → IN_PROGRESS ← 中断(应用重启)
    └─ 子任务 3: 部署上线 → TODO

第 2 次执行(恢复):
    recover_historical_plan("开发网站")
    ├─ 子任务 0: 设计数据库 → DONE(保持)
    ├─ 子任务 1: 实现后端 → DONE(保持)
    ├─ 子任务 2: 实现前端 → IN_PROGRESS ← 从这里继续
    └─ 子任务 3: 部署上线 → TODO

注意事项

  • 子任务状态不会自动重置:恢复后,已完成的子任务仍然是 DONE
  • 想重新执行已完成的子任务:需要手动调用 update_subtask_state 将状态重置为 TODO
  • 恢复的是最后一次活跃的计划:如果有多个历史计划,恢复最近的一个

13.7 狼人杀中的使用

狼人杀项目没有使用 PlanNotebook,原因是:

  1. 任务简单:狼人杀是回合制投票游戏,每轮的任务是"发言"和"投票",不需要复杂规划
  2. 流程固定:游戏流程由裁判控制(夜晚→白天→投票→结算),Agent 不需要自己规划
  3. 无长任务:每个 Agent 的单次调用都是独立的,不存在需要跨多轮执行的复杂任务

PlanNotebook 适合的场景

  • 开发网站(需要设计、编码、测试、部署)
  • 写研究报告(需要收集资料、分析、撰写、审校)
  • 游戏开发(需要设计、实现、测试、发布)

13.8 总结

PlanNotebook 是 AgentScope 的任务规划系统

  • 核心能力:创建计划、分解子任务、跟踪状态、历史恢复
  • 10 个工具函数:覆盖计划的完整生命周期
  • 状态约束:顺序执行、单一 IN_PROGRESS、自动激活下一个
  • Hint 注入:通过 Hook 自动引导 Agent 执行
  • 适用场景:复杂任务(开发网站、游戏等),不适合简单任务

十四、持久化细节补充

14.1 持久化保存的内容

AgentScope 的持久化系统保存了 4 类状态数据:

保存内容说明示例
Memory(对话历史)包括所有 Msg,包括 ToolUseBlock 和 ToolResultBlock用户提问、AI 回复、工具调用及结果
PlanNotebook(任务状态)包括每个 SubTask 的状态(DONE/IN_PROGRESS/TODO)计划进度、子任务执行结果
Toolkit(工具分组)activeGroups 状态当前激活的工具组
有状态工具工具的内部状态数据库连接状态、缓存内容

Memory 保存的 Msg 类型

Memory 中的 Msg 列表:
├─ SYSTEM: "你是狼人,你的目标是..."
├─ USER: "请讨论今晚要杀谁"
├─ ASSISTANT: "我觉得应该杀张三"(含 ThinkingBlock)
├─ TOOL: ToolUseBlock("generate_response", {...})
├─ TOOL: ToolResultBlock("generate_response", "执行成功")
├─ USER: "张三已被淘汰"
└─ ASSISTANT: "好的,我继续观察..."

14.2 Tool 结果的持久化

关键澄清:Tool 结果是通过 Memory 持久化的,不是单独持久化。

ToolResultBlock 作为 TOOL 角色的 Msg 存储在 Memory 中:

// 工具执行后,结果被封装为 Msg 存入 Memory
Msg toolResultMsg = Msg.builder()
    .name("generate_response")
    .role(MsgRole.TOOL)
    .content(ToolResultBlock.builder()
        .toolName("generate_response")
        .result("执行成功")
        .build())
    .build();

// 存入 Memory
memory.addMessage(toolResultMsg);

持久化时

Session.save(key, agent.saveTo())
    │
    ├─ Memory → List<Msg>(包含所有 ToolUseBlock 和 ToolResultBlock)
    ├─ PlanNotebook → Plan 列表(包含所有 SubTask 状态)
    ├─ Toolkit → activeGroups 状态
    └─ 有状态工具 → 工具内部状态

14.3 为什么这样设计?

将 Tool 结果通过 Memory 持久化,而不是单独存储,有 4 个设计考量:

设计考量说明
一致性工具结果是对话历史的一部分,与其他消息保持一致
简化统一用 Memory 管理所有消息,不需要额外的存储机制
顺序维护保持工具调用的时间顺序,与对话历史自然衔接
完整性整个对话历史作为一个单元保存,不会丢失任何上下文

对比:如果 Tool 结果单独存储

❌ 单独存储方案:
    Memory: [用户提问, AI 回复, AI 回复, AI 回复]
    ToolResults: [工具结果1, 工具结果2, 工具结果3]
    问题:无法知道哪个工具结果对应哪次 AI 回复

✅ Memory 存储方案:
    Memory: [用户提问, AI 回复, ToolUseBlock, ToolResultBlock, AI 回复, ...]
    优势:完整的调用链,顺序清晰,一一对应

14.4 计划恢复后能否重新执行?

恢复计划:从上次停的地方继续

// 恢复历史计划
agent.recoverHistoricalPlan("开发网站");

// 自动恢复到中断时的状态:
// - 已完成的子任务保持 DONE
// - 中断时的 IN_PROGRESS 子任务保持 IN_PROGRESS
// - 后续的 TODO 子任务保持 TODO

重新执行已完成的子任务:不能直接重新执行

恢复计划后,已完成的子任务(DONE)不能直接重新执行。需要手动重置状态:

// 查看当前子任务状态
agent.viewSubtasks();

// 输出:
// 子任务 0: 设计数据库 → DONE
// 子任务 1: 实现后端 → DONE
// 子任务 2: 实现前端 → IN_PROGRESS
// 子任务 3: 部署上线 → TODO

// 如果想重新执行子任务 1,需要手动重置
agent.updateSubtaskState(1, SubTaskState.TODO);

// 重置后:
// 子任务 0: 设计数据库 → DONE
// 子任务 1: 实现后端 → TODO ← 已重置
// 子任务 2: 实现前端 → IN_PROGRESS
// 子任务 3: 部署上线 → TODO

为什么不自动重置?

  • 避免重复工作:已完成的子任务可能已经产出了有价值的结果
  • 保持灵活性:用户可以选择性地重新执行某些子任务
  • 防止意外:自动重置可能导致意外丢失已完成的工作

14.5 总结

持久化保存了完整的执行记录,包括步骤是否执行完毕:

  • Memory 保存所有 Msg:包括 ToolUseBlock 和 ToolResultBlock,保证对话历史完整
  • Tool 结果通过 Memory 持久化:不是单独存储,而是作为 TOOL 角色的 Msg 存入 Memory
  • 设计优势:一致性、简化、顺序维护、完整性
  • 计划恢复:从上次停的地方继续,已完成的子任务不会自动重置
  • 手动重置:需要调用 update_subtask_state 将状态重置为 TODO

关键理解:持久化不仅仅是"保存数据",更是保存了完整的执行上下文,包括对话历史、工具调用链、任务进度,让 Agent 能够在中断后无缝继续。


十五、A2A 协议

15.1 A2A 解决什么问题?

A2A(Agent-to-Agent)协议提供跨进程的 Agent 通信能力。MsgHub 只能用于进程内通信,A2A 支持跨进程/跨机器的 Agent 通信。

对比

通信方式范围协议适用场景
MsgHub进程内内存中的方法调用同一个应用内的多 Agent 协作
A2A跨进程/跨机器HTTP/JSON-RPC分布式 Agent 微服务

15.2 核心概念

A2A 协议有 3 个核心组件:

AgentCard(代理名片)

AgentCard 描述 Agent 的能力、URL、skills 等信息,是 Agent 的"身份证":

AgentCard agentCard = AgentCard.builder()
    .name("weather-agent")
    .description("天气查询 Agent")
    .url("http://localhost:8080")
    .skills(List.of(
        Skill.builder().name("query_weather").description("查询天气").build()
    ))
    .build();

A2aAgent(A2A 客户端)

A2aAgent 用于调用远程 Agent,是 A2A 协议的客户端实现:

A2aAgent remoteAgent = A2aAgent.builder()
    .agentCard(remoteAgentCard)  // 远程 Agent 的名片
    .build();

// 调用远程 Agent
Msg response = remoteAgent.call(userMessage).block();

AgentCardResolver(名片解析器)

AgentCardResolver 用于获取远程 Agent 的名片:

AgentCardResolver resolver = new StaticAgentCardResolver();
AgentCard card = resolver.resolve("http://remote-agent:8080").block();

15.3 通信协议

A2A 使用 HTTP/JSON-RPC 协议通信,支持流式响应:

客户端                          服务端
  │                               │
  │  POST /a2a                    │
  │  Content-Type: application/json
  │  {"jsonrpc":"2.0","method":"send","params":{...}}
  │  ───────────────────────────→ │
  │                               │
  │  200 OK                       │
  │  {"jsonrpc":"2.0","result":{...}}
  │  ←─────────────────────────── │
  │                               │
  │  // 流式响应(SSE)            │
  │  POST /a2a (stream=true)      │
  │  ───────────────────────────→ │
  │                               │
  │  data: {"chunk":"你好"}       │
  │  ←─────────────────────────── │
  │  data: {"chunk":",今天"}     │
  │  ←─────────────────────────── │
  │  data: [DONE]                 │
  │  ←─────────────────────────── │

15.4 使用场景

A2A 协议适用于以下场景:

场景说明示例
微服务架构不同服务间的 Agent 互调订单服务的 Agent 调用库存服务的 Agent
跨组织协作不同组织的 Agent 互调企业 A 的 Agent 调用企业 B 的 Agent
Agent 市场用户可以调用第三方 Agent调用天气 Agent、翻译 Agent 等公共服务

15.5 A2A vs MsgHub

维度MsgHubA2A
通信范围进程内跨进程/跨机器
通信方式内存中的方法调用HTTP/JSON-RPC
性能简单高效有网络开销
分布式不支持支持
适用场景单体应用内的多 Agent 协作分布式 Agent 微服务

15.6 类比 opencode @ 机制

A2A 可以理解为分布式版本的 @ 机制

  • opencode @:进程内的 Agent 互调,通过内存直接通信
  • A2A:跨进程的 Agent 互调,通过 HTTP/JSON-RPC 通信
opencode @ 机制(进程内):
    @explore → 调用探索 Agent
    @librarian → 调用图书管理员 Agent

A2A 协议(跨进程):
    @weather-agent → 调用远程天气 Agent
    @translation-agent → 调用远程翻译 Agent

15.7 狼人杀中的使用

狼人杀项目没有使用 A2A,原因是:

  1. 所有 Agent 都在同一个进程:狼人杀的所有 AI 玩家(张三、李四等)都在同一个 JVM 进程中运行
  2. MsgHub 足够:进程内的多 Agent 通信通过 MsgHub 即可完成
  3. 无分布式需求:单体应用不需要跨进程通信

何时需要 A2A

  • Agent 部署在不同的服务/机器上
  • 需要调用第三方 Agent 服务
  • 构建 Agent 市场/平台

十六、Nacos + A2A 集成

16.1 核心思想

Nacos 提供服务发现和注册功能,A2A 提供 Agent 间通信协议。两者结合实现分布式 Agent 微服务架构。

┌─────────────────────────────────────────────────────────┐
│                      Nacos 注册中心                       │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐      │
│  │ weather-agent│  │ translator  │  │ calculator  │      │
│  │ 192.168.1.1  │  │ 192.168.1.2 │  │ 192.168.1.3 │      │
│  └─────────────┘  └─────────────┘  └─────────────┘      │
└─────────────────────────────────────────────────────────┘
        ↑                   ↑                   ↑
        │                   │                   │
   A2A 协议            A2A 协议            A2A 协议
        │                   │                   │
   ┌────┴────┐         ┌────┴────┐         ┌────┴────┐
   │ Agent A  │         │ Agent B  │         │ Agent C  │
   └─────────┘         └─────────┘         └─────────┘

16.2 核心组件

NacosA2aRegistry

把 AgentCard 和 Endpoint 注册到 Nacos,让其他服务能够发现本 Agent:

NacosA2aRegistry registry = new NacosA2aRegistry(nacosClient);

// 注册 Agent
registry.register(agentCard, endpoint).block();

// 注销 Agent
registry deregister(agentCard).block();

NacosAgentCardResolver

从 Nacos 发现远程 Agent 的 AgentCard:

NacosAgentCardResolver resolver = new NacosAgentCardResolver(nacosClient);

// 发现远程 Agent
AgentCard card = resolver.resolve("weather-agent").block();

// 创建 A2A 客户端
A2aAgent remoteAgent = A2aAgent.builder()
    .agentCard(card)
    .build();

16.3 使用流程

服务端:注册 Agent

// 1. 创建 Agent
ReActAgent agent = ReActAgent.builder()
    .name("weather-agent")
    .model(model)
    .memory(new InMemoryMemory())
    .build();

// 2. 创建 AgentCard
AgentCard agentCard = AgentCard.builder()
    .name("weather-agent")
    .description("天气查询 Agent")
    .skills(List.of(
        Skill.builder().name("query_weather").description("查询天气").build()
    ))
    .build();

// 3. 注册到 Nacos
NacosA2aRegistry registry = new NacosA2aRegistry(nacosClient);
registry.register(agentCard, endpoint).block();

// 4. 启动 A2A Server
A2aServer server = A2aServer.builder()
    .agent(agent)
    .agentCard(agentCard)
    .build();
server.start().block();

客户端:调用远程 Agent

// 1. 创建 NacosAgentCardResolver
NacosAgentCardResolver resolver = new NacosAgentCardResolver(nacosClient);

// 2. 创建 A2aAgent
AgentCard card = resolver.resolve("weather-agent").block();
A2aAgent remoteAgent = A2aAgent.builder()
    .agentCard(card)
    .build();

// 3. 调用远程 Agent
Msg userMsg = Msg.builder()
    .name("user")
    .role(MsgRole.USER)
    .content(TextBlock.builder().text("北京今天天气怎么样?").build())
    .build();

Msg response = remoteAgent.call(List.of(userMsg)).block();
System.out.println(response.getContent());  // "北京今天晴,气温 25°C"

16.4 优势

优势说明
自动发现从 Nacos 自动发现远程 Agent,无需硬编码地址
动态更新订阅机制,AgentCard 更新时自动通知
负载均衡Nacos 自动负载均衡,支持多实例部署
健康检查Nacos 自动健康检查,故障实例自动剔除

16.5 类比微服务

Nacos + A2A 的组合,类比传统微服务架构:

维度传统微服务Agent 微服务
服务发现Nacos + Feign/gRPCNacos + A2A
通信协议HTTP/gRPCHTTP/JSON-RPC
服务描述Swagger/OpenAPIAgentCard
负载均衡Ribbon/LoadBalancerNacos 内置
健康检查Spring Boot ActuatorNacos 心跳
传统微服务架构:
    用户 → API Gateway → 服务A → 服务B → 服务C
                ↓
            Nacos(服务发现)

Agent 微服务架构:
    用户 → Agent Gateway → AgentA → AgentB → AgentC
                ↓
            Nacos(服务发现) + A2A(通信协议)

16.6 狼人杀中的使用

狼人杀项目没有使用 Nacos + A2A,原因是:

  1. 所有 Agent 都在同一个进程:不需要服务发现和跨进程通信
  2. 单体架构足够:单个 Spring Boot 应用即可运行整个游戏
  3. 无分布式需求:不需要负载均衡和健康检查

何时需要 Nacos + A2A

  • Agent 部署在多个服务/机器上
  • 需要动态扩缩容 Agent 实例
  • 构建 Agent 市场/平台
  • 需要高可用和故障转移

16.7 总结

Nacos + A2A = Agent 服务发现 + Agent 间通信,实现分布式 Agent 微服务架构:

  • Nacos:提供服务注册、发现、负载均衡、健康检查
  • A2A:提供跨进程的 Agent 通信协议
  • 组合:类似传统微服务的 Nacos + Feign/gRPC,但面向 Agent 场景

架构演进

单体应用 → MsgHub(进程内通信)
    ↓
分布式应用 → A2A(跨进程通信)
    ↓
Agent 微服务 → Nacos + A2A(服务发现 + 跨进程通信)

十七、Tool 定义与实现

17.1 Tool 解决什么问题?

Agent 需要执行各种任务(搜索、计算、调用 API 等),但 LLM 本身无法执行代码。通过 Tool 机制,让 LLM 能够"调用"外部函数。

LLM(大脑)                    Tool(手脚)
    │                              │
    │  "我想搜索天气"               │
    │  ─────────────────────────→  │
    │                              │  执行搜索
    │  "北京今天晴,25°C"          │
    │  ←─────────────────────────  │
    │                              │
    基于结果继续推理

核心价值:Tool 让 LLM 从"只能说话"变成"能做事"。

17.2 核心注解

AgentScope4J 使用注解来定义工具,两个核心注解:

@Tool — 标记工具方法

@Tool(name = "search_weather", description = "查询指定城市的天气信息")
public String searchWeather(String city) {
    // 调用天气 API
    return weatherService.query(city);
}
属性类型含义
nameString工具名称,LLM 调用时使用
descriptionString工具描述,帮助 LLM 理解工具用途

@ToolParam — 描述工具参数

@Tool(name = "search_weather", description = "查询指定城市的天气信息")
public String searchWeather(
    @ToolParam(name = "city", description = "城市名称", required = true) String city,
    @ToolParam(name = "date", description = "查询日期,格式 yyyy-MM-dd", required = false) String date
) {
    return weatherService.query(city, date);
}
属性类型含义
nameString参数名称
descriptionString参数描述
requiredboolean是否必填

17.3 Toolkit — 工具管理器

Toolkit 是工具的管理中心,负责:

职责说明
注册工具扫描 @Tool 注解,注册到工具注册表
生成 Schema为 LLM 生成工具的 JSON Schema
执行工具调用工具方法,返回结果
管理分组支持工具分组,动态激活/停用

创建 Toolkit

// 空 Toolkit(狼人杀项目使用)
Toolkit toolkit = new Toolkit();

// 注册工具
toolkit.registerTool(new WeatherTool());
toolkit.registerTool(new CalculatorTool());

生成 JSON Schema

Toolkit 会自动为每个工具生成 JSON Schema,供 LLM 理解工具的参数格式:

{
  "name": "search_weather",
  "description": "查询指定城市的天气信息",
  "parameters": {
    "type": "object",
    "properties": {
      "city": {
        "type": "string",
        "description": "城市名称"
      },
      "date": {
        "type": "string",
        "description": "查询日期,格式 yyyy-MM-dd"
      }
    },
    "required": ["city"]
  }
}

17.4 完整执行流程

Tool 的完整执行流程涉及 7 个步骤:

┌─────────────────────────────────────────────────────────────────┐
│                        Tool 执行流程                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. 定义工具类(使用 @Tool 和 @ToolParam 注解)                   │
│     ↓                                                           │
│  2. 注册工具到 Toolkit(扫描注解,生成 Schema)                    │
│     ↓                                                           │
│  3. 创建 Agent(绑定 Toolkit)                                   │
│     │                                                           │
│     │   ReActAgent.builder()                                    │
│     │       .toolkit(toolkit)  ← 绑定 Toolkit                   │
│     │       .build()                                            │
│     ↓                                                           │
│  4. Agent 调用,LLM 返回工具调用请求(ToolUseBlock)               │
│     │                                                           │
│     │   LLM 输出: ToolUseBlock(                                 │
│     │       toolName = "search_weather",                        │
│     │       input = {"city": "北京"}                            │
│     │   )                                                       │
│     ↓                                                           │
│  5. Toolkit 执行工具(调用实际方法)                               │
│     │                                                           │
│     │   String result = toolkit.execute("search_weather",       │
│     │                                   Map.of("city", "北京"));│
│     │   // result = "北京今天晴,25°C"                          │
│     ↓                                                           │
│  6. 构造工具结果(ToolResultBlock),发回给 LLM                    │
│     │                                                           │
│     │   ToolResultBlock(                                        │
│     │       toolName = "search_weather",                        │
│     │       result = "北京今天晴,25°C"                         │
│     │   )                                                       │
│     ↓                                                           │
│  7. LLM 看到工具结果,生成最终回复                                │
│     │                                                           │
│     │   "北京今天天气晴朗,气温 25°C,适合出行。"                 │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

17.5 工具分组(Tool Groups)

Toolkit 支持工具分组,可以动态激活/停用工具:

Toolkit toolkit = new Toolkit();

// 创建分组
toolkit.createGroup("weather");
toolkit.createGroup("calculator");

// 注册工具到分组
toolkit.registerTool(new WeatherTool(), "weather");
toolkit.registerTool(new CalculatorTool(), "calculator");

// 激活/停用分组
toolkit.activateGroup("weather");
toolkit.deactivateGroup("calculator");

分组的典型场景

场景说明
按角色分组不同角色的 Agent 使用不同的工具集
按阶段分组游戏不同阶段激活不同工具
动态切换根据上下文动态启用/禁用工具

17.6 狼人杀中的使用

狼人杀项目使用了空 Toolkit,没有注册任何工具:

// WerewolfWebGame.java:298
ReActAgent agent = ReActAgent.builder()
    .name(name)
    .sysPrompt(systemPrompt)
    .model(model)
    .memory(new InMemoryMemory())
    .toolkit(new Toolkit())  // ← 空 Toolkit,没有注册任何工具
    .build();

为什么狼人杀不需要工具?

原因说明
决策通过结构化输出投票、杀人等决策通过 call(msgs, VoteModel.class) 实现
游戏逻辑在裁判端杀人、查验等逻辑由裁判(WerewolfWebGame)控制,不需要 Agent 调用工具
简单交互模式Agent 只需要"发言"和"投票",不需要复杂的功能调用

对比:需要工具的场景

// 如果狼人杀需要工具,可能是这样的:
public class WerewolfTool {
    @Tool(name = "investigate_player", description = "调查一名玩家的身份线索")
    public String investigate(
        @ToolParam(name = "playerName", description = "要调查的玩家名称") String playerName
    ) {
        // 返回一些模糊的线索,不是直接的身份信息
        return "该玩家昨晚似乎没有睡觉...";
    }
}

17.7 总结

Tool 是 AgentScope 的工具调用机制

  • 核心注解@Tool 标记工具方法,@ToolParam 描述工具参数
  • Toolkit 管理器:负责工具注册、Schema 生成、执行和分组管理
  • 执行流程:定义工具 → 注册到 Toolkit → 绑定 Agent → LLM 返回 ToolUseBlock → Toolkit 执行 → 结果返回 LLM
  • 工具分组:支持动态激活/停用,适用于复杂场景

设计原则

  1. 声明式定义:通过注解声明工具,无需手写 Schema
  2. 类型安全:参数类型由 Java 方法签名保证
  3. 灵活管理:Toolkit 支持分组和动态切换
  4. 与 LLM 解耦:工具定义与 LLM 调用分离,便于测试和复用

十八、MCP Tool vs 普通 Tool

18.1 两种 Tool 类型

AgentScope 支持两种 Tool 类型:

类型定义方式执行位置适用场景
普通 Tool本地 Java 方法,通过 @Tool 注解定义本地进程内执行简单的本地计算、访问本地资源
MCP Tool远程工具,通过 MCP 协议调用远程服务器执行第三方服务、跨组织共享、动态工具发现

18.2 核心区别

普通 Tool

// 本地 Java 方法,通过反射调用
@Tool(name = "calculate", description = "计算数学表达式")
public String calculate(@ToolParam(name = "expression") String expression) {
    return String.valueOf(new ScriptEngine().eval(expression));
}

// 注册到 Toolkit
toolkit.registerTool(new MathTool());

// 执行流程:LLM → Agent → 反射调用 Java 方法 → 返回结果

MCP Tool

// 远程工具,通过 MCP 协议调用
McpTool tool = new McpTool(mcpClientWrapper, toolSchema);

// 执行流程:LLM → Agent → MCP 客户端 → 网络请求 → 远程服务器执行 → 返回结果

执行流程对比

普通 Tool:
    LLM → Agent → Java 反射 → 本地方法执行 → 结果返回

MCP Tool:
    LLM → Agent → MCP Client → HTTP/JSON-RPC → 远程服务器 → 结果返回

18.3 McpTool 实现

McpTool 实现了 AgentTool 接口,核心结构如下:

public class McpTool implements AgentTool {
    private final McpClientWrapper mcpClientWrapper;  // MCP 客户端包装器
    private final ToolSchema toolSchema;                // 工具 Schema
    private final Map<String, Object> presetArguments; // 预设参数(可选)
    
    @Override
    public ToolSchema getSchema() {
        return toolSchema;
    }
    
    @Override
    public Mono<ToolResult> execute(Map<String, Object> args) {
        // 合并预设参数和调用参数
        Map<String, Object> mergedArgs = new HashMap<>(presetArguments);
        mergedArgs.putAll(args);
        
        // 通过 MCP 客户端调用远程工具
        return mcpClientWrapper.callTool(toolSchema.getName(), mergedArgs);
    }
}

核心组件

组件作用
McpClientWrapperMCP 协议客户端,负责与远程 MCP 服务器通信
ToolSchema工具的 Schema 定义,包括名称、描述、参数格式
presetArguments预设参数,每次调用时自动合并,用于固定配置

18.4 使用场景

普通 Tool 适用场景

场景示例
本地计算数学计算、数据格式转换、文本处理
访问本地资源读取文件、查询数据库、调用本地 API
简单逻辑条件判断、数据验证、业务规则
// 普通 Tool 示例:本地文件读取
@Tool(name = "read_file", description = "读取本地文件内容")
public String readFile(@ToolParam(name = "path") String path) {
    return Files.readString(Path.of(path));
}

MCP Tool 适用场景

场景示例
第三方服务天气查询、地图服务、支付接口
跨组织共享企业内部工具市场、合作伙伴服务
动态工具发现运行时发现可用工具,无需预先编码
// MCP Tool 示例:调用远程天气服务
McpTool weatherTool = mcpClientWrapper.discoverTool("query_weather");
Agent.call("北京今天天气怎么样?");  // 自动调用远程天气工具

18.5 狼人杀中的使用

狼人杀项目没有使用 MCP Tool,只用了空 Toolkit:

// WerewolfWebGame.java:298
ReActAgent agent = ReActAgent.builder()
    .name(name)
    .sysPrompt(systemPrompt)
    .model(model)
    .memory(new InMemoryMemory())
    .toolkit(new Toolkit())  // ← 空 Toolkit,没有注册任何工具
    .build();

为什么狼人杀不需要 MCP Tool?

原因说明
单体架构所有组件都在同一个进程,不需要远程调用
无外部服务游戏逻辑完全自包含,不依赖第三方服务
简单交互Agent 只需要发言和投票,不需要调用外部工具

18.6 总结

Tool 有两种类型,根据使用场景选择合适的类型:

维度普通 ToolMCP Tool
定义方式@Tool 注解McpTool
执行位置本地进程远程服务器
通信方式Java 反射MCP 协议(HTTP/JSON-RPC)
适用场景本地计算、简单逻辑第三方服务、跨组织共享
复杂度高(需要 MCP 服务器)
性能高(无网络开销)中(有网络延迟)

选择建议

  • 本地逻辑:用普通 Tool(简单、高效)
  • 远程服务:用 MCP Tool(支持分布式、动态发现)

十九、RAG(检索增强生成)

19.1 RAG 解决什么问题?

LLM 的知识是训练时学习的,无法获取最新的、私有的、特定领域的知识。RAG 让 Agent 能够基于外部知识库回答问题。

没有 RAG 时的问题

用户:公司的请假制度是什么?
LLM:(基于训练数据回答)一般公司请假需要...
     ↑ 可能不准确,因为不知道你们公司的具体制度

有 RAG 后

用户:公司的请假制度是什么?
    ↓
检索:从公司制度文档中找到相关段落
    ↓
LLM:根据公司制度文档,请假需要提前3天申请...
     ↑ 基于实际文档回答,准确可靠

19.2 核心流程

RAG 分为离线和在线两个阶段:

离线阶段:文档索引

文档(PDF/Word/网页)
    ↓
Reader(读取文档内容)
    ↓
TextChunker(把长文档分成小块)
    ↓
EmbeddingModel(把文本转换为向量)
    ↓
VectorStore(存储向量)

在线阶段:检索生成

用户提问
    ↓
EmbeddingModel(把问题转换为向量)
    ↓
VectorStore.search()(检索最相关的文档块)
    ↓
LLM(基于检索结果生成回答)

完整流程图

离线阶段(一次性):
文档 → Reader → TextChunker → EmbeddingModel → VectorStore

在线阶段(每次提问):
用户提问 → EmbeddingModel → VectorStore.search() → LLM 生成回答

19.3 核心组件

RAG 系统由 4 个核心组件组成:

组件作用示例
Reader读取各种格式的文档PDF、Word、网页、Markdown
TextChunker把长文档分成小块按段落、按字符数、按 token 数
EmbeddingModel把文本转换为向量DashScope Embedding、OpenAI Embedding
VectorStore存储和检索向量Milvus、Pinecone、内存向量库

Reader — 文档读取器

Reader 负责读取各种格式的文档,提取纯文本内容:

// 示例:读取 PDF 文档
Reader pdfReader = new PDFReader();
String content = pdfReader.read("company-policy.pdf");

TextChunker — 文本分块器

TextChunker 把长文档分成小块,便于后续向量化和检索:

// 示例:按段落分块
TextChunker chunker = new ParagraphChunker();
List<String> chunks = chunker.chunk(longDocument);

EmbeddingModel — 向量模型

EmbeddingModel 把文本转换为高维向量,用于语义相似度计算:

// 示例:文本转向量
EmbeddingModel model = new DashScopeEmbeddingModel();
float[] vector = model.embed("请假制度");
// vector = [0.123, -0.456, 0.789, ...](1536 维)

VectorStore — 向量存储

VectorStore 存储向量,并支持相似度检索:

// 存储向量
vectorStore.add("chunk_id_1", vector1, metadata);
vectorStore.add("chunk_id_2", vector2, metadata);

// 检索相似向量
List<SearchResult> results = vectorStore.search(queryVector, topK=3);

19.4 文本分块策略

TextChunker 支持多种分块策略:

策略分隔符适用场景
CHARACTER按字符数简单文本,无明显结构
PARAGRAPH\n\n有段落结构的文档
TOKEN按 token 数需要精确控制 token 量
SEMANTIC语义边界高质量分块(目前回退到段落分块)

CHARACTER — 按字符数分块

// 每 500 个字符一块
TextChunker chunker = new CharacterChunker(500);
List<String> chunks = chunker.chunk(document);

特点:简单直接,但可能在句子中间切断。

PARAGRAPH — 按段落分块

// 按 \n\n 分割
TextChunker chunker = new ParagraphChunker();
List<String> chunks = chunker.chunk(document);

特点:保持段落完整性,适合有明确段落结构的文档。

TOKEN — 按 token 数分块

// 每 200 个 token 一块
TextChunker chunker = new TokenChunker(200);
List<String> chunks = chunker.chunk(document);

特点:精确控制 token 量,适合有 token 限制的场景。

SEMANTIC — 按语义分块

// 按语义边界分块(目前回退到段落分块)
TextChunker chunker = new SemanticChunker();
List<String> chunks = chunker.chunk(document);

特点:理论上最优,但实现复杂,目前 AgentScope 回退到段落分块。

19.5 重叠分割(Overlap)

防止关键内容在边界处被切断。相邻块共享部分内容,确保关键词完整包含在至少一个块中。

没有重叠时的问题

块 1: "请假需要提前3天"
块 2: "向直属领导申请"
问题:"提前3天向直属领导申请" 被切断,检索时可能找不到完整信息

有重叠时

块 1: "请假需要提前3天向直属领导"
块 2: "提前3天向直属领导申请并填写"
重叠部分: "提前3天向直属领导"
优势: 无论检索哪个块,都能找到完整信息

推荐配置

// overlapSize = chunkSize 的 10-20%
TextChunker chunker = new CharacterChunker(
    500,    // chunkSize
    50      // overlapSize(500 的 10%)
);

示例代码

// 完整的 RAG 配置
TextChunker chunker = new ParagraphChunker(500, 50);
EmbeddingModel embedding = new DashScopeEmbeddingModel();
VectorStore vectorStore = new InMemoryVectorStore();

// 离线:索引文档
List<String> chunks = chunker.chunk(document);
for (int i = 0; i < chunks.size(); i++) {
    float[] vector = embedding.embed(chunks.get(i));
    vectorStore.add("chunk_" + i, vector, Map.of("text", chunks.get(i)));
}

// 在线:检索
float[] queryVector = embedding.embed(userQuestion);
List<SearchResult> results = vectorStore.search(queryVector, 3);
String context = results.stream()
    .map(r -> r.getMetadata().get("text"))
    .collect(Collectors.joining("\n"));

19.6 狼人杀中的使用

狼人杀项目没有使用 RAG,原因是:

原因说明
规则明确狼人杀的游戏规则是固定的,不需要外部知识
无文档需求游戏逻辑完全由代码控制,不需要检索文档
简单问答Agent 的回答基于游戏状态,不需要知识库

RAG 适合的场景

  • 客服系统(需要检索产品文档)
  • 知识问答(需要检索企业知识库)
  • 法律助手(需要检索法律条文)
  • 技术支持(需要检索技术文档)

二十、多模态

20.1 多模态解决什么问题?

传统 Agent 只能处理文本,无法理解图片、音频、视频等多媒体内容。多模态能力让 Agent 能够处理各种类型的输入和输出。

没有多模态时

用户:[发送一张图片] 这是什么?
Agent:抱歉,我无法看到图片。

有多模态后

用户:[发送一张图片] 这是什么?
Agent:这是一只金毛犬,看起来很高兴。

20.2 支持的类型

AgentScope 支持 4 种 ContentBlock 类型:

Block 类型JSON type用途格式
TextBlock"text"纯文本字符串
ImageBlock"image"图片URL 或 Base64
AudioBlock"audio"音频URL 或 Base64
VideoBlock"video"视频URL 或 Base64

TextBlock — 纯文本

TextBlock text = TextBlock.builder()
    .text("你好,有什么可以帮助你的?")
    .build();

ImageBlock — 图片

// 使用 URL
ImageBlock image = ImageBlock.builder()
    .url("https://example.com/photo.jpg")
    .build();

// 使用 Base64
ImageBlock image = ImageBlock.builder()
    .base64("data:image/jpeg;base64,/9j/4AAQ...")
    .build();

AudioBlock — 音频

// 使用 URL
AudioBlock audio = AudioBlock.builder()
    .url("https://example.com/audio.mp3")
    .build();

// 使用 Base64
AudioBlock audio = AudioBlock.builder()
    .base64("data:audio/mp3;base64,SUQzB...")
    .build();

VideoBlock — 视频

// 使用 URL
VideoBlock video = VideoBlock.builder()
    .url("https://example.com/video.mp4")
    .build();

// 使用 Base64
VideoBlock video = VideoBlock.builder()
    .base64("data:video/mp4;base64,AAAA...")
    .build();

20.3 主要针对输入还是输出?

主要针对输入(用户发送图片/音频/视频给 Agent),但也可以用于输出(Agent 生成图片/音频)。

输入场景(更常见)

用户发送图片 → Agent 理解图片内容 → 生成文本回复
用户发送音频 → Agent 识别语音 → 生成文本回复
用户发送视频 → Agent 分析视频 → 生成文本回复
// 用户发送图片
Msg userMsg = Msg.builder()
    .name("user")
    .role(MsgRole.USER)
    .content(
        TextBlock.builder().text("这是什么动物?").build(),
        ImageBlock.builder().url("https://example.com/cat.jpg").build()
    )
    .build();

// Agent 调用
Msg response = agent.call(List.of(userMsg)).block();
// response: "这是一只橘猫,看起来很可爱。"

输出场景(较少见)

用户请求生成图片 → Agent 调用图像生成工具 → 返回图片
用户请求语音回复 → Agent 调用 TTS 工具 → 返回音频
// Agent 生成图片(需要图像生成工具)
@Tool(name = "generate_image", description = "生成图片")
public String generateImage(@ToolParam(name = "prompt") String prompt) {
    // 调用图像生成 API
    return imageGenerator.generate(prompt);  // 返回图片 URL
}

20.4 纠正:狼人杀项目没有使用 AudioBlock

重要澄清:狼人杀项目使用的是 DashScopeRealtimeTTSModel,不是 AudioBlock

组件用途说明
AudioBlock多模态内容块用于在 Msg 中传递音频数据
DashScopeRealtimeTTSModelTTS 模型用于将文本转换为语音

狼人杀中的 TTS 实现

// 创建 TTS 模型
DashScopeRealtimeTTSModel ttsModel = DashScopeRealtimeTTSModel.builder()
    .apiKey(apiKey)
    .build();

// 使用 TTS 生成语音
String text = "昨晚死的是张三";
byte[] audioData = ttsModel.synthesize(text).block();

关键区别

  • AudioBlock:是 ContentBlock 的一种,用于在 Msg 中传递音频数据(URL 或 Base64)
  • TTS 模型:是 Model 的一种,用于将文本转换为语音,不通过 AudioBlock 传递

20.5 狼人杀中的多模态使用

狼人杀项目的多模态使用非常有限:

输入:没有多模态输入

// 狼人杀只有文本输入
Msg userMsg = Msg.builder()
    .name("player")
    .role(MsgRole.USER)
    .content(TextBlock.builder().text("我觉得张三是狼人").build())
    .build();

输出:使用 TTS 生成语音

// 使用 DashScopeRealtimeTTSModel 生成语音
DashScopeRealtimeTTSModel ttsModel = DashScopeRealtimeTTSModel.builder()
    .apiKey(apiKey)
    .build();

// Agent 的文本回复转换为语音
String agentResponse = "我同意,张三确实可疑";
byte[] audioData = ttsModel.synthesize(agentResponse).block();

// 将语音发送给前端播放
emitter.sendAudio(audioData);

为什么狼人杀只用 TTS,不用其他多模态?

原因说明
游戏性质狼人杀是语言类游戏,核心是文字交流
简化实现只需要 TTS 让 AI 玩家"说话",不需要看图/听音频
性能考虑多模态处理会增加延迟,影响游戏体验

如果狼人杀要用多模态,可能是这样的

// 玩家发送照片作为"证据"
Msg evidenceMsg = Msg.builder()
    .name("player")
    .role(MsgRole.USER)
    .content(
        TextBlock.builder().text("看这张照片,张三昨晚在狼人聚会").build(),
        ImageBlock.builder().url("https://example.com/evidence.jpg").build()
    )
    .build();

// Agent 分析图片
// "我仔细看了这张照片,这似乎是一张PS的图片..."

二十一、学习过程中的关键疑问与解答

疑问 1:工具调用有先后顺序/依赖怎么办?

解答:AgentScope 用 Pipeline 解决工具调用的顺序和依赖问题。

  • SequentialPipeline:串行执行,前一个的输出是后一个的输入
  • FanoutPipeline:并行执行,所有 Agent 同时处理同一输入
// SequentialPipeline 示例:检索 → 重排 → 生成
Msg result = Pipelines.sequential(
    List.of(retrievalAgent, rerankAgent, generationAgent), 
    query
).block();

狼人杀中的替代方案:狼人杀没用 Pipeline,用 MsgHub 广播机制。因为狼人杀的 Agent 是平等参与者,不是流水线上的工人。

疑问 2:WebUserInput 怎么等待?

解答:使用 Sinks.One + block() 阻塞游戏线程,REST 端点 tryEmitValue() 唤醒。

游戏线程(阻塞)                  REST 线程(异步)
      │                              │
      ▼                              │
waitForInput("VOTE")                 │
  │ 创建 Sinks.One<String>           │
  │ sink.asMono().block()            │  ← 游戏线程阻塞
  │         ↓ 挂起                    │
  │         ↓                   用户投票,POST /api/game/input
  │         ↓                        │
  │         ↓                   sink.tryEmitValue("张三")  ← 唤醒!
  │  ← Mono 拿到 "张三",解除阻塞    │

关键点:Sinks.One 只能发射一次,线程安全,block() 不占 CPU。

疑问 3:LLM 输出格式不符合怎么办?

解答:不直接让 LLM 输出 JSON,而是调用工具(参数有 schema 约束),失败时用 tool_choice 强制重试

传统方案(不推荐)

String response = llm.call("请按格式输出 JSON");
try {
    JSONObject json = new JSONObject(response);  // 可能失败
} catch (JSONException e) {
    // 很难让 LLM 理解错在哪里
}

AgentScope 的方案

  1. 注册临时工具 generate_response,参数 schema 就是目标格式
  2. LLM 调用工具时,底层验证参数是否符合 schema
  3. 不符合 → 用 tool_choice = "generate_response" 强制重试

重试策略

策略第一次失败后第二次失败后第三次失败后
TOOL_CHOICE强制 tool_choice再强制一次抛异常
PROMPT提醒"请调用工具"再提醒一次抛异常

疑问 4:Hook 是怎么检查的?

解答:检查 LLM 返回的 Msg 中是否有 ToolUseBlock,用 instanceof 检查(不是字符串匹配)。

// StructuredOutputHook 的检查逻辑
boolean hasCall = !msg.getContentBlocks(ToolUseBlock.class).isEmpty();
//                 ↑ 如果为空,说明 LLM 没有请求调用任何工具

为什么检查 ToolUseBlock 而不是"工具是否执行"?

因为 Hook 在 PostReasoning 阶段触发,此时 LLM 刚返回结果,Agent 还没执行工具。Hook 需要检查的是"LLM 是否请求了工具调用"。

底层实现:Java 类型系统(instanceof)检查,因为 ContentBlock 是 sealed class,每个子类有明确的 Java 类型。

疑问 5:LLM 不会直接输出 JSON?

解答:LLM 输出的是 token 序列,不是 JSON。API 层负责把这些 token 解析成结构化的 JSON。

真实流程

调用 LLM API,传入 tools 参数
    ↓
LLM 生成 token 序列(包含特殊 token [TOOL_CALL_START] 和 [TOOL_CALL_END])
    ↓
API 层解析 token 序列,验证 JSON 格式,生成结构化 tool_calls
    ↓
AgentScope 解析 tool_calls,执行工具

DashScope API 返回的实际格式

{
  "output": {
    "choices": [{
      "message": {
        "tool_calls": [{
          "function": {
            "name": "generate_response",
            "arguments": "{\"targetPlayer\":\"张三\"}"  // ← API 层解析的 JSON
          }
        }]
      }
    }]
  }
}

疑问 6:是否可以重新执行已执行过的计划?

解答:可以恢复计划从上次停的地方继续,但不能直接重新执行已完成的子任务,需要手动重置状态。

恢复计划

agent.recoverHistoricalPlan("开发网站");

// 自动恢复到中断时的状态:
// - 已完成的子任务保持 DONE
// - 中断时的 IN_PROGRESS 子任务保持 IN_PROGRESS
// - 后续的 TODO 子任务保持 TODO

重新执行已完成的子任务

// 查看当前状态
agent.viewSubtasks();
// 子任务 0: 设计数据库 → DONE
// 子任务 1: 实现后端 → DONE
// 子任务 2: 实现前端 → IN_PROGRESS

// 想重新执行子任务 1,需要手动重置
agent.updateSubtaskState(1, SubTaskState.TODO);
// 子任务 1: 实现后端 → TODO ← 已重置

为什么不自动重置? 避免重复工作、保持灵活性、防止意外丢失已完成的工作。

疑问 7:为什么 Tool 结果不单独持久化?

解答:Tool 结果通过 Memory 持久化(作为 TOOL 角色的 Msg),保持对话历史的完整性和一致性

// 工具执行后,结果被封装为 Msg 存入 Memory
Msg toolResultMsg = Msg.builder()
    .name("generate_response")
    .role(MsgRole.TOOL)
    .content(ToolResultBlock.builder()
        .toolName("generate_response")
        .result("执行成功")
        .build())
    .build();

memory.addMessage(toolResultMsg);

设计优势

优势说明
一致性工具结果是对话历史的一部分,与其他消息保持一致
简化统一用 Memory 管理所有消息,不需要额外的存储机制
顺序维护保持工具调用的时间顺序,与对话历史自然衔接
完整性整个对话历史作为一个单元保存,不会丢失任何上下文

疑问 8:A2A 是 agent 调用 agent 吗?

解答:是的,A2A 是分布式版本的 @ 机制,支持跨进程的 Agent 互调。

opencode @ 机制(进程内):
    @explore → 调用探索 Agent
    @librarian → 调用图书管理员 Agent

A2A 协议(跨进程):
    @weather-agent → 调用远程天气 Agent
    @translation-agent → 调用远程翻译 Agent

对比

维度MsgHubA2A
通信范围进程内跨进程/跨机器
通信方式内存中的方法调用HTTP/JSON-RPC
适用场景单体应用内的多 Agent 协作分布式 Agent 微服务

疑问 9:ai-nacos 可以和 A2A 协作吗?

解答:是的,NacosA2aRegistry 注册 AgentCard 到 Nacos,NacosAgentCardResolver 从 Nacos 发现远程 Agent。

服务端:注册 Agent

NacosA2aRegistry registry = new NacosA2aRegistry(nacosClient);
registry.register(agentCard, endpoint).block();

客户端:发现远程 Agent

NacosAgentCardResolver resolver = new NacosAgentCardResolver(nacosClient);
AgentCard card = resolver.resolve("weather-agent").block();

A2aAgent remoteAgent = A2aAgent.builder()
    .agentCard(card)
    .build();

优势:自动发现、动态更新、负载均衡、健康检查。

疑问 10:工具分组的启用和禁用场景?

解答:工具分组适用于以下场景:

场景说明示例
不同任务使用不同工具集根据任务类型激活相应工具翻译任务激活翻译工具,计算任务激活计算器
权限控制不同角色的 Agent 使用不同工具管理员有所有工具,普通用户只有基础工具
动态工具管理运行时根据上下文切换工具游戏不同阶段使用不同工具
MCP 工具管理管理远程 MCP 工具的启用/禁用根据网络状况动态切换本地/远程工具
// 创建分组
toolkit.createGroup("basic");
toolkit.createGroup("advanced");

// 注册工具到分组
toolkit.registerTool(new SearchTool(), "basic");
toolkit.registerTool(new AnalysisTool(), "advanced");

// 激活/停用分组
toolkit.activateGroup("basic");
toolkit.deactivateGroup("advanced");

疑问 11:怎么区分 MCP tool 和普通 tool?

解答:通过定义方式执行位置区分:

维度普通 ToolMCP Tool
定义方式@Tool 注解定义 Java 方法McpTool 类实现
执行位置本地进程内远程服务器
通信方式Java 反射调用MCP 协议(HTTP/JSON-RPC)
注册方式toolkit.registerTool(new MyTool())toolkit.registerTool(mcpTool)

代码示例

// 普通 Tool
@Tool(name = "calculate", description = "计算")
public String calculate(String expr) {
    return eval(expr);
}
toolkit.registerTool(new MathTool());

// MCP Tool
McpTool remoteTool = mcpClientWrapper.discoverTool("query_weather");
toolkit.registerTool(remoteTool);

选择建议

  • 本地逻辑:用普通 Tool(简单、高效)
  • 远程服务:用 MCP Tool(支持分布式、动态发现)

疑问 12:RAG 中句子分块怎么分?

解答:TextChunker 没有专门的句子分块策略,但可以通过 PARAGRAPH 策略按段落分块,或自定义 SentenceChunker 按句号分割。

// 方案 1:使用段落分块
TextChunker chunker = new ParagraphChunker(300, 30);

// 方案 2:自定义句子分块器
public class SentenceChunker implements TextChunker {
    @Override
    public List<String> chunk(String text) {
        // 按句号、问号、感叹号分割
        String[] sentences = text.split("(?<=[。!?.!?])\\s*");
        return Arrays.stream(sentences)
            .filter(s -> !s.trim().isEmpty())
            .collect(Collectors.toList());
    }
}

建议

  • 如果文档有明确段落结构,用 PARAGRAPH
  • 如果需要更细粒度,自定义 SentenceChunker
  • 通常段落级别的分块效果更好,保持了上下文完整性

疑问 13:如何防止关键内容被分割?

解答:使用重叠分割(Overlap),相邻块共享部分内容,确保关键词完整包含在至少一个块中。

// 推荐:overlapSize = chunkSize 的 10-20%
TextChunker chunker = new CharacterChunker(
    500,    // chunkSize
    50      // overlapSize(500 的 10%)
);

示例

没有重叠:
块 1: "请假需要提前3天"
块 2: "向直属领导申请"
问题: "提前3天向直属领导申请" 被切断

有重叠(overlap=50 字符):
块 1: "请假需要提前3天向直属领导"
块 2: "提前3天向直属领导申请并填写"
优势: 无论检索哪个块,都能找到完整信息

最佳实践

  • overlap 太小:关键信息可能在边界被切断
  • overlap 太大:存储空间浪费,检索效率降低
  • 推荐:chunkSize 的 10-20%

疑问 14:多模态是针对输入还是输出?

解答主要针对输入(用户发送图片/音频/视频给 Agent),但也可以用于输出(Agent 生成图片/音频)。

输入场景(更常见)

// 用户发送图片给 Agent
Msg userMsg = Msg.builder()
    .name("user")
    .role(MsgRole.USER)
    .content(
        TextBlock.builder().text("这是什么?").build(),
        ImageBlock.builder().url("https://example.com/photo.jpg").build()
    )
    .build();

// Agent 理解图片并回复
Msg response = agent.call(List.of(userMsg)).block();
// "这是一只金毛犬"

输出场景(较少见)

// Agent 调用图像生成工具
@Tool(name = "generate_image")
public String generateImage(String prompt) {
    return imageGenerator.generate(prompt);  // 返回图片 URL
}

对比

方向使用频率典型场景
输入图片理解、语音识别、视频分析
输出图像生成、TTS 语音合成

疑问 15:狼人杀项目使用了 AudioBlock?

解答没有。狼人杀项目使用的是 DashScopeRealtimeTTSModel,不是 AudioBlock

组件用途说明
AudioBlock多模态内容块用于在 Msg 中传递音频数据(URL/Base64)
DashScopeRealtimeTTSModelTTS 模型将文本转换为语音,直接调用 API

狼人杀中的 TTS 实现

// 创建 TTS 模型
DashScopeRealtimeTTSModel ttsModel = DashScopeRealtimeTTSModel.builder()
    .apiKey(apiKey)
    .build();

// 使用 TTS 生成语音(不是通过 AudioBlock)
String text = "昨晚死的是张三";
byte[] audioData = ttsModel.synthesize(text).block();

// 将语音发送给前端播放
emitter.sendAudio(audioData);

关键区别

  • AudioBlock:是 ContentBlock 的一种,用于在 Msg 中存储音频数据
  • TTS 模型:是 Model 的一种,直接调用 API 生成语音,结果不通过 AudioBlock 传递
  • 狼人杀项目用的是后者,TTS 生成的语音直接发送给前端,不经过 Msg 体系

二十二、MCP(Model Context Protocol)

22.1 MCP 解决什么问题?

每个 LLM 提供商的工具调用格式都不同,开发者需要为每个提供商单独适配。MCP 提供统一的工具协议,开发者只需要实现一次,所有 LLM 提供商都能调用。

核心问题

LLM 提供商 A:工具格式 A
LLM 提供商 B:工具格式 B
LLM 提供商 C:工具格式 C
开发者:需要为每个提供商写适配代码 😩

MCP:统一协议
开发者:实现一次,所有提供商通用 🎉

22.2 核心概念

MCP Server:工具提供方,注册工具并监听请求

// MCP Server 注册工具
McpServer server = McpServer.builder()
    .name("my-tools")
    .version("1.0.0")
    .tool(new WeatherTool())  // 注册天气工具
    .build();

server.start();  // 启动监听

MCP Client:工具调用方,连接 Server 并调用工具

// MCP Client 连接 Server
McpClient client = McpClient.builder()
    .serverUrl("http://localhost:8080")
    .build();

// 调用工具
Object result = client.callTool("get_weather", Map.of("city", "北京"));

通信协议:JSON-RPC

// 请求
{
  "jsonrpc": "2.0",
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": {"city": "北京"}
  },
  "id": 1
}

// 响应
{
  "jsonrpc": "2.0",
  "result": {"temperature": "25°C"},
  "id": 1
}

22.3 工作流程

完整流程

// 1. 启动 MCP Server,注册工具
McpServer server = McpServer.builder()
    .tool(new WeatherTool())
    .tool(new CalculatorTool())
    .build();
server.start();

// 2. MCP Client 连接 Server
McpClient client = McpClient.connect("http://localhost:8080");

// 3. 获取工具列表
List<ToolInfo> tools = client.listTools();
// [WeatherTool, CalculatorTool]

// 4. 注册工具到 AgentScope
AgentScope.registerMcpTools(tools);

// 5. Agent 调用工具,通过 MCP 协议执行
Agent agent = Agent.builder()
    .name("assistant")
    .model(model)
    .tools(AgentScope.getMcpTools())  // 获取 MCP 工具
    .build();

// Agent 调用工具时,自动通过 MCP 协议执行
Msg response = agent.call("北京天气怎么样?").block();

22.4 MCP vs 普通 Tool

特性普通 ToolMCP Tool
执行位置本地执行远程执行
注册方式@Tool 注解MCP Server 注册
调用方式反射调用MCP 协议调用
适用场景单体应用分布式系统

普通 Tool 示例

// 本地执行,直接反射调用
@Tool(name = "get_weather")
public String getWeather(String city) {
    return "晴天 25°C";
}

MCP Tool 示例

// 远程执行,通过 MCP 协议调用
public class WeatherTool implements McpTool {
    @Override
    public String getName() {
        return "get_weather";
    }

    @Override
    public Object execute(Map<String, Object> args) {
        String city = (String) args.get("city");
        return "晴天 25°C";
    }
}

22.5 使用场景

第三方工具服务:调用外部 API

// 调用外部天气 API
McpServer server = McpServer.builder()
    .tool(new ExternalWeatherApiTool())  // 封装外部 API
    .build();

跨组织共享工具:不同组织的工具互调

组织 A 的 MCP Server:提供数据分析工具
组织 B 的 MCP Server:提供机器学习工具
组织 C 的 Client:同时调用 A 和 B 的工具

动态工具发现:运行时动态注册工具

// 运行时动态添加工具
McpServer server = McpServer.builder().build();
server.start();

// 动态注册新工具
server.registerTool(new NewTool());

22.6 狼人杀中的使用

狼人杀项目没有使用 MCP,也没有实际注册普通 Tool。前文提到的投票、杀人、查验等动作,主要由两层机制完成:

  1. Agent 决策层:AI 玩家通过结构化输出返回目标玩家、理由等字段
  2. 游戏裁判层WerewolfWebGame 根据结构化结果更新游戏状态

也就是说,狼人杀里的"工具感"来自业务代码,而不是来自 @Tool 或 MCP Server。这个区别很重要:结构化输出适合让 Agent 做选择,Tool/MCP 适合让 Agent 调用外部能力。

为什么不用 MCP

  • 狼人杀是单体应用,所有游戏逻辑在同一进程
  • Agent 只需要发言和做选择,不需要调用外部服务
  • 游戏规则由裁判端控制,比让 Agent 直接调用 vote / kill 工具更安全

22.7 总结

MCP 是 AgentScope 的外部工具协议,提供统一的工具调用接口,支持跨进程、跨组织的工具共享。

核心价值

  • 统一协议:一次实现,所有 LLM 通用
  • 分布式支持:跨进程、跨组织调用
  • 动态发现:运行时动态注册工具

与普通 Tool 的关系

  • 普通 Tool:本地执行,适合单体应用
  • MCP Tool:远程执行,适合分布式系统
  • 两者互补,根据场景选择

二十三、Harness Framework 与近期更新

23.1 Harness 解决什么问题?

如果说 ReActAgent 解决的是单次调用里的 reason → act → observe,那么 Harness 解决的是生产环境里的连续性问题:

  • 下一轮对话怎么接上上一轮?
  • 明天重启后怎么恢复状态?
  • 上下文爆了怎么办?
  • 工具结果太大怎么办?
  • 多用户、多副本时工作区和记忆放在哪里?
  • 复杂任务要不要拆给子 Agent?异步任务怎么回收结果?
  • 需要执行 Shell 或代码时,怎么不把宿主机暴露给不可信输入?

官方把 agentscope-harness 定义为基于 agentscope-core 的生产运行层,入口类是 HarnessAgent。它不替换 ReActAgent 的推理循环,而是在关键生命周期点注入 Hook,并补齐一套内置工具和工作区约定。

23.2 一句话理解 Harness

Harness = ReActAgent + Workspace + RuntimeContext + 持久化记忆 + 上下文压缩 + 文件系统抽象 + 子 Agent 编排 + 安全执行边界

它把前面分散讲过的能力重新组织成一个运行时:

前文概念Harness 中的落点
HookWorkspaceContextHookMemoryFlushHookCompactionHookSessionPersistenceHook
Toolkit自动注册文件、记忆检索、会话查询、子任务管理、条件性 Shell 执行工具
Memory每日记忆流水账 + MEMORY.md 长期记忆 + 全文检索
State / Session基于 sessionId 的状态快照和会话日志
Tool文件工具、记忆工具、子 Agent 工具、沙箱执行工具
Subagent通过 subagents/ 声明或代码配置,支持同步/异步委派

23.3 快速使用方式

引入依赖:

<dependency>
    <groupId>io.agentscope</groupId>
    <artifactId>agentscope-harness</artifactId>
    <version>${agentscope.version}</version>
</dependency>

最小使用形态:

HarnessAgent agent = HarnessAgent.builder()
    .name("my-agent")
    .model(model)
    .workspace(Paths.get(".agentscope/workspace"))
    .compaction(CompactionConfig.builder()
        .triggerMessages(50)
        .keepMessages(20)
        .build())
    .build();

RuntimeContext ctx = RuntimeContext.builder()
    .sessionId("user-session-001")  // 相同 sessionId 自动续接上下文
    .userId("alice")                // 多用户场景用于命名空间隔离
    .build();

Msg reply = agent.call(userMessage, ctx).block();

RuntimeContext 是这套机制的身份载体:sessionId 决定会话状态和日志路径,userId 决定多租户命名空间。它不是长期状态本身,而是每次 call() 时传给 Hook 和 Tool 的"这一轮是谁"。

23.4 Workspace:Agent 的唯一事实来源

Harness 的核心是工作区。一个典型工作区长这样:

workspace/
├── AGENTS.md              # Agent 人格与行为约定,每轮推理前注入
├── MEMORY.md              # 长期记忆,由后台维护
├── knowledge/             # 领域知识
├── skills/                # 可复用技能
├── subagents/             # 子 Agent 规格
└── agents/<agentId>/
    ├── context/           # 会话状态快照
    ├── sessions/          # 对话 JSONL 与压缩上下文
    └── memory/            # 每日记忆流水账

每轮推理前,WorkspaceContextHook 会把 AGENTS.mdMEMORY.mdknowledge/ 等内容注入 system prompt。对话结束后,MemoryFlushHook 从本轮对话中提炼新事实,写入每日记忆;后台的 MemoryConsolidator 再把流水账合并成更精炼的长期记忆。

这意味着 Agent 不再是"每次重新初始化的聊天窗口",而是有一个会随使用积累经验的工作区。

23.5 上下文管理:压缩、溢出恢复、大工具结果卸载

Harness 把长对话上下文问题拆成三层:

问题Harness 机制
历史消息太多CompactionHook 超阈值后摘要历史,只保留近期尾部
模型真的报 context overflowHarnessAgent 捕获错误,强制压缩后自动重试
单个工具结果太大ToolResultEvictionHook 把大结果写入文件系统,只把 head/tail 预览放回上下文

这和普通 Memory 的区别是:Memory 只回答"历史存在哪里",Harness 进一步回答"什么时候该压缩、压缩前要不要提炼事实、压缩后怎么恢复可检索性"。

23.6 文件系统与沙箱:同一套逻辑适配三种环境

Harness 的所有文件操作都经过 AbstractFilesystem,因此 Agent 逻辑不必绑定本地磁盘。官方文档把它分成三种声明式模式:

模式特点适用场景
本机 + Shell默认模式,工作区在本地目录,可按配置开放 Shell个人助手、本地 Coding Agent、开发测试
远端共享存储记忆和会话日志落到共享存储,默认不注册 Shell 工具多副本在线服务、企业业务 Agent
沙箱执行文件读写和命令执行都在隔离沙箱中完成,状态可恢复DataAgent、SRE Agent、需要执行不可信代码的场景

关键点是:execute 不是永远可用。远端共享存储模式默认不注册 Shell 工具,这是安全设计;只有本地 Shell 或沙箱后端明确支持执行时,框架才暴露执行能力。

23.7 Subagent:声明式子 Agent 与异步任务

Harness 支持把子 Agent 写成工作区里的规格文件,也支持通过代码配置。父 Agent 可以通过工具进行委派:

  • 同步委派:父 Agent 阻塞等待子 Agent 结果,适合必须拿到结果才能继续的任务
  • 异步委派:父 Agent 拿到任务 ID 后继续工作,之后用 task_output 查询结果

为了避免无限递归,子 Agent 默认是叶子形态,框架还会限制最大调用深度。这个设计把"多 Agent 编排"从手写 MsgHub / Pipeline 组合,推进到更工程化的任务分发、状态管理和结果回收。

23.8 最近几个版本的重点变化

截至 2026-05-18,GitHub Releases 中可见的最新版本是 v1.1.0-RC1(2026-05-11),官方博客和文档已经按 AgentScope Java 1.1 Harness 体系介绍。最近几个版本的主线很清晰:从"能写 Agent"走向"能长期运行、能分布式部署、能安全执行工具"。

版本重点更新对开发者的意义
v1.1.0-RC1新增 agentscope-harnessHarnessAgent、Workspace、AbstractFilesystem、会话持久化、长记忆、上下文压缩、子 Agent、内置工具、沙箱模式把生产级 Agent 运行时打包成默认能力
v1.0.12百炼长记忆、MCP 集成增强、内置 tracing、AutoContextMemory 稳定性、大消息 offloading、RAG 修复、依赖升级增强长会话、可观测和工具调用稳定性
v1.0.11优雅关闭、RocketMQ A2A、MySQL Skill Repository、Redis Session Client、流式动态结构化输出、prompt cache、reasoning token 统计、多模态视频工具补齐分布式消息、持久化生态、成本优化和流式结构化能力
v1.0.10Nacos AI Registry、Prompt 运行时绑定、Skill 在线/离线加载、TTS 接入狼人杀、qwen3.5-plus、Hook 排序让 Prompt/Skill 能集中治理和热更新,狼人杀示例也加入语音能力

23.9 Harness 和狼人杀项目的关系

狼人杀示例主要展示的是 AgentScope 的基础机制:MsgReActAgentUserAgent、结构化输出、MsgHub、HITL。Harness 则是这些机制在生产环境里的封装:

  • 如果只是跑一局狼人杀,ReActAgent + MsgHub 足够
  • 如果要把狼人杀变成长期运行的在线房间,需要 Harness 的 sessionId、会话日志和状态恢复
  • 如果要让 AI 玩家长期记住某个真人玩家的风格,需要 Harness 的长期记忆
  • 如果要让 AI 玩家调用脚本分析战局,需要 Harness 的沙箱执行
  • 如果要把"法官"、“策略分析师”、“复盘助手"拆成多个 Agent,需要 Harness 的子 Agent 编排

所以可以把 Harness 理解成:不是替代狼人杀项目里学到的核心概念,而是把这些概念推向长期运行、可恢复、可隔离、可扩展的工程形态。

23.10 官方资料


二十四、剩余主题与进阶学习

24.1 还有什么主题没学?

已覆盖的核心主题:

  • Msg、Agent、Memory、Model、Formatter
  • Tool、Hook、Pipeline、MsgHub
  • 结构化输出、流式输出、Prompt 管理
  • 持久化、PlanNotebook
  • A2A、Nacos 集成、MCP
  • RAG、多模态
  • WebUserInput(HITL)
  • Harness Framework、Workspace、AbstractFilesystem、Session Persistence、Subagent

还没覆盖的主题:

  • 测试:如何测试 Agent(单元测试、集成测试)
  • 监控:如何监控 Agent 的性能(Tracing、Metrics)
  • 安全:如何保护 Agent 的安全(权限、审计)
  • 部署:如何部署 Agent 到生产环境(Docker、K8s)
  • 性能优化:如何优化 Agent 的性能(缓存、并发)
  • 错误处理:如何处理 Agent 的异常(重试、降级)
  • 日志:如何记录 Agent 的日志(结构化日志)

24.2 关于学习 langchain4j 和 spring ai

建议:不需要先学它们,直接参与 agentscope4j 开发

原因:

  1. agentscope4j 已经足够完整
  2. langchain4j 和 spring ai 是不同框架,设计理念、API 风格都不同
  3. 直接参与开发是最好的学习方式

框架对比:

  • agentscope4j:阿里开源,Java 原生,支持 A2A、MCP,适合企业级 Agent 应用
  • langchain4j:LangChain 的 Java 版,社区活跃,适合快速原型开发
  • spring ai:Spring 官方,与 Spring 生态集成,适合 Spring 项目集成

二十五、参与 agentscope4j 开发

25.1 选择贡献方向

方向难度说明
文档完善补充文档、示例、教程
Bug 修复⭐⭐修复已知 Issue
功能增强⭐⭐⭐添加新特性
性能优化⭐⭐⭐优化现有功能
测试补充⭐⭐补充单元测试、集成测试

25.2 推荐学习路径

阶段 1:熟悉项目结构(1-2 天)

  • 阅读 README.md
  • 了解模块划分(core、extensions、examples)
  • 运行示例项目

阶段 2:选择贡献方向(1 天)

  • 浏览 Issues,选择一个简单的任务
  • 阅读相关代码,理解实现
  • 与维护者沟通,确认方向

阶段 3:开始贡献(持续)

  • Fork 仓库
  • 创建分支
  • 实现功能
  • 提交 PR
  • 参与 Code Review

25.3 项目结构

agentscope-java 项目结构:

  • agentscope-core:核心模块(Agent、Msg、Memory、Model、Tool 等)
  • agentscope-extensions:扩展模块(RAG、A2A、MCP、Nacos 等)
  • agentscope-harness:生产级运行时模块(Workspace、长记忆、上下文压缩、会话恢复、沙箱、子 Agent)
  • agentscope-examples:示例模块(狼人杀、A2A 示例等)
  • agentscope-spring-boot-starters:Spring Boot 集成

25.4 如何开始

  1. 克隆仓库:git clone https://github.com/agentscope-ai/agentscope-java.git
  2. 阅读贡献指南:CONTRIBUTING.md
  3. 浏览 Issues:选择一个简单的任务
  4. Fork 仓库,创建分支
  5. 实现功能,提交 PR

25.5 总结

不需要先学 langchain4j 和 spring ai,直接参与 agentscope4j 开发是最好的学习方式。从小任务开始(文档、示例、Bug 修复),逐步深入。


最终总结

已学习的主题

  1. Msg 消息体系 - 消息的构建、传递和处理
  2. Agent 体系 - Agent 的创建、配置和生命周期
  3. Memory 记忆管理 - 短期和长期记忆管理
  4. Model 模型接入 - LLM 模型的接入和调用
  5. Formatter 格式化 - 消息格式化和模板处理
  6. Tool 工具调用 - @Tool 注解和工具调用机制
  7. Hook 生命周期 - Agent 执行前后的钩子函数
  8. Pipeline 编排 - 多 Agent 的流程编排
  9. MsgHub 多智能体通信 - 多 Agent 之间的消息传递
  10. 结构化输出 - JSON Schema 结构化输出
  11. 流式输出 - SSE 流式响应
  12. Prompt 管理 - 提示词模板和管理
  13. 持久化与状态管理 - 状态保存和恢复
  14. PlanNotebook 任务规划 - 复杂任务的规划和执行
  15. A2A 协议 - Agent-to-Agent 通信协议
  16. Nacos 集成 - 服务发现和负载均衡
  17. MCP(Model Context Protocol) - 统一的外部工具协议
  18. RAG(检索增强生成) - 知识检索和增强生成
  19. 多模态 - 图片、音频、视频等多模态内容处理
  20. WebUserInput(HITL) - Web 界面的人机交互
  21. Harness Framework - Workspace、RuntimeContext、上下文压缩、会话恢复、沙箱和子 Agent 编排
  22. 近期版本演进 - Nacos AI Registry、长记忆、优雅关闭、RocketMQ A2A、prompt cache、tracing 等

学习成果

系统性学习了 AgentScope4J 从基础框架到最新 Harness 运行时的核心概念,包括:

  • 基础概念:Msg、Agent、Memory、Model、Formatter
  • 通信机制:MsgHub、A2A、MCP
  • 扩展能力:RAG、多模态、Hook、Tool
  • 生产化:持久化、流式输出、Prompt 管理、PlanNotebook、Harness
  • 分布式:Nacos、A2A、MCP、RocketMQ A2A、Redis Session、远端共享存储
  • 人机交互:WebUserInput(HITL)
  • 长期运行:Workspace、长记忆、上下文压缩、会话恢复、沙箱执行、子 Agent 委派

博客文件

  • 路径/Users/chinazhouwy/doc/articles/content/posts/AgentScope4J 深度学习指南 - 从狼人杀项目掌握多智能体框架.md
  • 大小:扩展后约 3900+ 行
  • 内容:完整的 AgentScope4J 学习指南,从狼人杀实战,到 A2A/MCP/RAG,再到 Harness 生产运行时

下一步

  1. 读 Harness 源码:从 HarnessAgentWorkspaceContextHookCompactionHookSessionPersistenceHook 开始
  2. 改造狼人杀:尝试用 Harness 增加长期房间、玩家画像记忆、复盘子 Agent
  3. 参与开发:围绕文档、示例、Harness 工具、测试补充切入
  4. 分享交流:发布到技术博客平台,并持续跟进 1.1 正式版之后的新能力