AgentBase

根据类说明给出的几个核心设计原则来看看具体是怎么实现的。

  • 职责分离:AgentBase 提供基础设施支持(钩子、订阅、中断、状态),但不包含业务逻辑
  • 内存管理委托:将内存管理职责交给需要它的具体智能体(例如 ReActAgent)
  • 状态管理:通过实现 StateModule 接口来管理状态
  • 中断机制:使用响应式模式,子类在适当的检查点调用 checkInterruptedAsync(),通过 Mono 链传播 InterruptedException
  • 观察模式:智能体可以接收消息而无需生成回复

职责分离

核心思想: AgentBase 只提供基础设施,不包含具体的业务逻辑。

源码实现:

  1. 抽象方法定义业务边界
1
2
3
4
5
// 第 275 行:只定义接口,不实现具体逻辑
protected abstract Mono<Msg> doCall(List<Msg> msgs);

// 第 458 行:中断处理留给子类实现
protected abstract Mono<Msg> handleInterrupt(InterruptContext context, Msg... originalArgs);
  1. 最终方法提供基础设施
1
2
3
4
5
6
7
8
9
10
11
12
// 第 166 行:call() 方法是 final 的,提供统一的执行框架
@Override
public final Mono<Msg> call(List<Msg> msgs) {
return Mono.using(
() -> { /* 运行状态检查 */ },
resource -> notifyPreCall(msgs) // 在调用前的 prehook => notifyHook
.flatMap(this::doCall) // ← 调用子类实现的 doCall
.flatMap(this::notifyPostCall) // 在调用后的 prehook => notifyHook
.onErrorResume(createErrorHandler(msgs.toArray(new Msg[0]))),
resource -> running.set(false),
true);
}
  1. Hook 机制支持扩展
1
2
3
4
5
6
7
8
9
10
11
12
// 第 95-96 行:支持实例级和系统级 Hook
private final List<Hook> hooks;
private static final List<Hook> systemHooks = new CopyOnWriteArrayList<>();

// 第 522-529 行:在关键节点通知 Hook
private Mono<List<Msg>> notifyPreCall(List<Msg> msgs) {
PreCallEvent event = new PreCallEvent(this, msgs);
for (Hook hook : getSortedHooks()) {
result = result.flatMap(hook::onEvent); // 链式调用所有 Hook
}
return result.map(PreCallEvent::getInputMessages);
}

设计优势:

✅ 可测试性:基础设施代码统一测试,业务逻辑单独测试

为什么能够统一测试基础设施代码?

  1. 所有通用逻辑都在 AgentBase 中实现

    1
    2
    3
    4
    5
    // AgentBase 中的最终方法(final)- 所有子类共享同一套实现
    public final Mono<Msg> call(List<Msg> msgs) // 统一调用流程
    private Mono<List<Msg>> notifyPreCall(List<Msg> msgs) // 统一 Hook 通知
    private Mono<Msg> notifyPostCall(Msg finalMsg) // 统一后置处理
    private Function<Throwable, Mono<Msg>> createErrorHandler() // 统一错误处理
  2. 测试用例只需写一次

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    // 测试 AgentBase 的基础设施(一个测试类覆盖所有 Agent)
    class AgentBaseTest {
    @Test
    void testHookMechanism() {
    // 测试 Hook 是否正确被调用
    MockAgent agent = new MockAgent();
    agent.addHook(mockHook);
    agent.call(msgs).block();

    verify(mockHook).onEvent(any(PreCallEvent.class)); // ✅ Hook 机制正确
    }

    @Test
    void testInterruptMechanism() {
    // 测试中断是否正确传播
    MockAgent agent = new MockAgent();
    agent.interrupt();

    StepVerifier.create(agent.call(msgs))
    .expectError(InterruptedException.class); // ✅ 中断机制正确
    }

    @Test
    void testRunningStateCheck() {
    // 测试并发保护
    MockAgent agent = new MockAgent();
    Mono<Msg> call1 = agent.call(msgs);
    Mono<Msg> call2 = agent.call(msgs); // 应该抛出异常

    StepVerifier.create(call2)
    .expectErrorMessage("Agent is still running") // ✅ 状态检查正确
    .verify();
    }
    }
  3. 业务逻辑可以独立Mock 测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // 测试具体业务逻辑时,不需要关心 Hook、中断等基础设施
    class ReActAgentBusinessLogicTest {
    @Test
    void testReasoningLogic() {
    // 只关注 doCall() 的业务逻辑
    ReActAgent agent = Mockito.spy(new ReActAgent());

    // Mock 掉基础设施依赖
    Mockito.doReturn(Mono.empty())
    .when(agent).checkInterruptedAsync(); // 不真正检查中断

    Mono<Msg> result = agent.doCall(testMsgs);

    // 只验证业务逻辑:推理→工具调用→结果生成
    StepVerifier.create(result)
    .assertNext(msg -> assertThat(msg.getTextContent())
    .contains("工具调用结果"))
    .verifyComplete();
    }
    }
  4. 分层测试策略

    1
    2
    3
    4
    5
    6
    7
    8
    测试金字塔:
    / \
    / 集成测试 \ ← 端到端测试(完整 Agent 集成)
    /-----------\
    / 业务逻辑测试 \ ← 只测试 doCall() 的具体实现
    /----------------\
    / 基础设施单元测试 \ ← 测试 AgentBase 的通用机制
    /____________________\

✅ 可维护性:修改 Hook 机制不影响业务逻辑

为什么修改 Hook 机制不会影响业务逻辑?

  1. 清晰的边界划分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // AgentBase 负责:Hook 管理(基础设施层)
    public abstract class AgentBase {
    private final List<Hook> hooks; // Hook 列表

    private Mono<List<Msg>> notifyPreCall(List<Msg> msgs) {
    // Hook 的实现细节 - 业务层不关心
    for (Hook hook : getSortedHooks()) {
    result = result.flatMap(hook::onEvent);
    }
    return result.map(PreCallEvent::getInputMessages);
    }
    }

    // ReActAgent 负责:业务逻辑(业务层)
    public class ReActAgent extends AgentBase {
    @Override
    protected Mono<Msg> doCall(List<Msg> msgs) {
    // 完全不关心 Hook 如何工作
    // 只需要实现:推理 → 工具调用 → 返回结果
    return reasoning(msgs)
    .flatMap(toolCall -> executeTool(toolCall))
    .map(result -> buildResponse(result));
    }
    }
  2. 实际案例:如果要优化 Hook 执行顺序

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 场景:发现 Hook 执行顺序有问题,需要优化

    // 【修改前】AgentBase.java
    private void sortHooks() {
    this.hooks.sort(Comparator.comparingInt(Hook::priority));
    }

    // 【修改后】AgentBase.java - 改为稳定排序
    private void sortHooks() {
    this.hooks.sort(Comparator
    .comparingInt(Hook::priority)
    .thenComparingInt(hooks::indexOf)); // 保持注册顺序
    }

    // ✅ ReActAgent.java 完全不需要修改!
    // ✅ UserAgent.java 完全不需要修改!
    // ✅ 所有子类都不需要修改!
  3. 反向案例:如果职责不分离会怎样?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    // ❌ 反面教材:混在一起的设计
    public class BadReActAgent {
    public Mono<Msg> call(List<Msg> msgs) {
    // 业务逻辑和基础设施混在一起
    // 1. 检查运行状态(手动实现)
    if (isRunning) throw new Exception();

    // 2. 通知 Hook(每个 Agent 都要写一遍)
    for (Hook hook : hooks) hook.onPreCall(msgs);

    // 3. 业务逻辑
    Msg result = reasoning(msgs);

    // 4. 再次通知 Hook
    for (Hook hook : hooks) hook.onPostCall(result);

    // 5. 错误处理(每个地方都不一样)
    try { ... } catch(Exception e) { ... }
    }
    }

    // 后果:
    // - 修改 Hook 机制?→ 需要改所有 Agent 类!
    // - 发现 Bug?→ 每个类都要检查一遍!
    // - 新增功能?→ 每个类都要加一遍代码!
  4. 维护成本对比

    变更类型 职责分离设计 混合设计
    修改 Hook 执行顺序 改 1 处 (AgentBase) 改 N 处 (所有 Agent)
    修复中断 Bug 改 1 处 改 N 处
    新增 Hook 类型 改 1 处 改 N 处
    添加新的基础设施功能 改 1 处 改 N 处 + 容易遗漏

✅ 可扩展性:子类只需关注 doCall() 的具体实现

为什么子类可以如此专注?

  1. 框架已经铺好了路

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // ReActAgent 只需要写:
    public class ReActAgent extends AgentBase {
    @Override
    protected Mono<Msg> doCall(List<Msg> msgs) {
    // 纯粹的业务逻辑 - 一行基础设施代码都没有!
    return reasoning(msgs) // 思考
    .flatMap(this::chooseTool) // 选择工具
    .flatMap(this::executeTool) // 执行工具
    .flatMap(this::buildResponse); // 构建回答
    }
    }

    // 不需要关心:
    // ❌ 如何管理运行状态
    // ❌ 如何通知 Hook
    // ❌ 如何处理中断
    // ❌ 如何广播给订阅者
    // ❌ 如何追踪日志
    // → AgentBase 全都包办了!
  2. 对比:如果没有这种设计

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    // ❌ 没有框架的情况下
    public class RawAgent {
    private AtomicBoolean running = new AtomicBoolean(false);
    private List<Hook> hooks = new ArrayList<>();

    public Mono<Msg> call(List<Msg> msgs) {
    // 每次都要写一遍模板代码
    if (!running.compareAndSet(false, true)) {
    throw new IllegalStateException("Agent is running");
    }

    // 通知 Hook(容易忘记)
    PreCallEvent event = new PreCallEvent(this, msgs);
    for (Hook hook : hooks) {
    hook.onEvent(event).block(); // 还容易写错
    }

    try {
    // 真正的业务逻辑(被埋在 50 行模板代码下面)
    Msg result = doWork(msgs);

    // 通知 Hook(又容易忘记)
    for (Hook hook : hooks) {
    hook.onEvent(new PostCallEvent(this, result)).block();
    }

    return Mono.just(result);
    } finally {
    running.set(false); // 别忘了放在 finally 里
    }
    }
    }

    // 结果:
    // - 80% 的代码是重复的模板
    // - 容易出错(忘记通知 Hook、忘记释放锁)
    // - 难以阅读(业务逻辑被淹没)
  3. 实际案例对比

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    // ===== 使用 Agentscope 框架 =====
    public class ReActAgent extends AgentBase {
    @Override
    protected Mono<Msg> doCall(List<Msg> msgs) {
    // 100% 专注于业务
    return Mono.justOrEmpty(msgs)
    .flatMap(this::reasoningStep)
    .repeatWhen(completed -> completed.take(maxIterations));
    }

    // 代码行数:~5 行核心逻辑
    // 心智负担:0(不用管基础设施)
    }

    // ===== 不使用框架 =====
    public class ManualReActAgent {
    private final ExecutorService executor = Executors.newCachedThreadPool();
    private final Semaphore lock = new Semaphore(1);

    public CompletableFuture<Msg> call(List<Msg> msgs) {
    return CompletableFuture.supplyAsync(() -> {
    // 手动管理锁
    if (!lock.tryAcquire()) {
    throw new ConcurrentModificationException();
    }
    try {
    // 手动管理状态
    if (msgs == null || msgs.isEmpty()) {
    throw new IllegalArgumentException();
    }

    // 手动通知监听器
    listeners.forEach(l -> l.onPreCall(this, msgs));

    // 终于能写业务逻辑了...
    Msg result = reasoning(msgs);

    // 又要手动通知
    listeners.forEach(l -> l.onPostCall(this, result));

    return result;
    } finally {
    lock.release(); // 千万别忘!
    }
    }, executor);
    }

    // 代码行数:~30 行模板 + 5 行业务
    // 心智负担:重(要记住所有细节)
    }
  4. 扩展新 Agent 的难度对比

    设计模式 开发新 Agent 步骤 代码量 易错点
    职责分离 1.继承 AgentBase
    2.实现 doCall()
    3.实现 handleInterrupt()
    ~20 行 几乎无
    混合设计 1.手动管理状态
    2.手动管理锁
    3.手动通知 Hook
    4.手动错误处理
    5.写业务逻辑
    ~100 行 容易遗漏

总结

这三个优势是相辅相成的:

1
2
3
4
5
职责分离

├─→ 可测试性:基础设施和业务逻辑可以分开测试
├─→ 可维护性:修改一边不影响另一边
└─→ 可扩展性:新开发者只需关注业务逻辑

核心价值:

  • 🎯 降低认知负担:不用同时理解基础设施和业务逻辑
  • 🔧 提高开发效率:复用已有代码,专注创新部分
  • 🛡️ 减少 Bug:边界清晰,错误容易定位
  • 📈 便于演进:框架升级不影响业务代码

内存管理委托

核心思想: AgentBase 不负责记忆管理,交给具体子类(如 ReActAgent)实现。

源码体现:

1
2
3
// 第 49-50 行:类注释明确说明
// It does NOT manage memory - that is the responsibility of
// specific agent implementations like ReActAgent.

为什么这样设计?

  1. 不同场景需要不同的记忆策略

    • 简单问答 Agent:可能只需要短期记忆
    • ReAct Agent:需要完整的历史对话 + 工具调用记录
    • RAG Agent:需要外部向量数据库 + 检索机制
  2. 避免过度设计

    • 如果 AgentBase 实现通用记忆管理,会导致接口复杂化
    • 增加不必要的性能开销
  3. 灵活性

    1
    2
    3
    4
    5
    // ReActAgent 可以根据自己的需要实现:
    // - 滑动窗口记忆
    // - 重要性过滤
    // - 摘要压缩
    // - 外部存储

状态管理

核心思想: 通过实现 StateModule 接口来管理状态。

源码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 第 88 行:类声明直接实现 StateModule
public abstract class AgentBase implements StateModule, Agent {}

// 第 90-92 行:Agent 基本属性字段
private final String agentId;
private final String name;
private final String description;

// 第 142-155 行:提供 getter 方法
@Override
public final String getAgentId() { return agentId; }

@Override
public final String getName() { return name; }

@Override
public final String getDescription() { return description; }

StateModule 接口的能力:

  • 保存/加载状态到 Session
  • 支持智能体的持久化
  • 为分布式部署提供基础

中断机制

核心思想: 使用响应式模式,在适当检查点传播 InterruptedException

源码实现:

  1. 中断状态管理
1
2
3
// 第 100-101 行:中断标志和用户消息
private final AtomicBoolean interruptFlag = new AtomicBoolean(false);
private final AtomicReference<Msg> userInterruptMessage = new AtomicReference<>(null);
  1. 设置中断
1
2
3
4
5
6
7
8
9
10
11
12
13
// 第 320-336 行:两种中断方法
@Override
public void interrupt() {
interruptFlag.set(true);
}

@Override
public void interrupt(Msg msg) {
interruptFlag.set(true);
if (msg != null) {
userInterruptMessage.set(msg);
}
}
  1. 检查中断(响应式)
1
2
3
4
5
6
7
// 第 363-370 行:异步检查中断
protected Mono<Void> checkInterruptedAsync() {
return Mono.defer(
() -> interruptFlag.get()
? Mono.error(new InterruptedException("Agent execution interrupted"))
: Mono.empty());
}
  1. 子类使用示例
1
2
3
4
5
// 第 354-359 行:注释中给出的使用示例
return checkInterruptedAsync()
.then(reasoning()) // 推理
.flatMap(result -> checkInterruptedAsync().thenReturn(result)) // 再次检查
.flatMap(result -> executeTools(result)); // 执行工具
  1. 异常处理
1
2
3
4
5
6
7
8
9
10
// 第 402-410 行:错误处理器
private Function<Throwable, Mono<Msg>> createErrorHandler(Msg... originalArgs) {
return error -> {
if (error instanceof InterruptedException
|| (error.getCause() instanceof InterruptedException)) {
return handleInterrupt(createInterruptContext(), originalArgs);
}
return notifyError(error).then(Mono.error(error));
};
}

设计亮点:

  • 非阻塞:中断检查也是响应式的,不阻塞执行流
  • 优雅退出:给子类机会清理状态、生成摘要
  • 灵活控制:子类决定在哪里检查中断

观察模式

核心思想: 智能体可以接收消息而无需生成回复。

源码实现:

  1. 默认空实现
1
2
3
4
// 第 438-440 行:doObserve 默认为空
protected Mono<Void> doObserve(Msg msg) {
return Mono.empty(); // 简单 agents 可以不做任何处理
}
  1. 公共 API
1
2
3
4
5
6
7
8
9
10
// 第 633-650 行:observe 方法
@Override
public final Mono<Void> observe(Msg msg) {
return doObserve(msg);
}

@Override
public final Mono<Void> observe(List<Msg> msgs) {
return Flux.fromIterable(msgs).flatMap(this::doObserve).then();
}
  1. 广播机制
1
2
3
4
5
6
7
// 第 615-623 行:向订阅者广播消息
private Mono<Void> broadcastToSubscribers(Msg msg) {
return Flux.fromIterable(hubSubscribers.values())
.flatMap(Flux::fromIterable)
.flatMap(subscriber -> subscriber.observe(msg)) // 调用 observe
.then();
}
  1. 订阅者管理
1
2
3
4
5
6
7
// 第 97 行:存储订阅关系
private final Map<String, List<AgentBase>> hubSubscribers = new ConcurrentHashMap<>();

// 第 582-584 行:重置订阅者
public void resetSubscribers(String hubId, List<AgentBase> subscribers) {
hubSubscribers.put(hubId, new ArrayList<>(subscribers));
}

应用场景:

  • 📡 多智能体协作:监控其他智能体的行为
  • 🔍 审计日志:记录对话流程
  • 🧠 共享上下文:构建多智能体的共同知识
  • ⚙️ 流水线模式:上游输出作为下游输入

总结

通过源码分析可以看到,AgentBase 的设计完美体现了这五个核心原则:

原则 实现方式 关键代码
职责分离 抽象方法 + final 方法 doCall(), call()
内存管理委托 注释明确说明,不实现 类注释 L49-50
状态管理 实现 StateModule 接口 implements StateModule
中断机制 响应式中断检查 checkInterruptedAsync()
观察模式 默认空实现 + 广播机制 doObserve(), broadcastToSubscribers()

这种设计使得 Agentscope 既保持了框架的统一性,又为具体实现提供了充分的灵活性!

StructuredOutputCapableAgent

从名字上来看,这是一个在 AgentBase上做了结构化输出的继承拓展。
从它的字段上来看,引入了toolkitstructuredOutput

还是先引入一下官方的类说明:

StructuredOutputCapableAgent 类说明:

支持结构化输出生成的抽象基类。

该类提供了基于 generate_response 工具模式结合 StructuredOutputHook 流程控制来生成结构化输出的基础设施。

核心特性:

  • 自动工具注册:为结构化输出自动注册必要的工具
  • 模式验证:在工具执行前进行 schema 验证
  • 内存压缩:在结构化输出完成后进行记忆压缩
  • 可配置的提醒模式:支持 TOOL_CHOICE(工具选择)或 PROMPT(提示)两种模式

子类要求:

  • 提供 Toolkit:通过构造函数传递工具包
  • 实现 getMemory():提供记忆访问能力
  • 实现 buildGenerateOptions():构建模型配置选项

结构化输出作为一个拓展性的能力我觉得完全 OK,但是原来工具是不会集成在基类中的,而是作为一个拓展出来的能力所存在。
Toolkit这里就先不展开,之后再专门开一章研究一下工具具体是怎么实现的。
其次来看另外一个字段

1
protected final StructuredOutputReminder structuredOutputReminder;

提供了TOOL_CHOICEPROMPT两种枚举

StructuredOutputReminder 枚举详解

这个枚举定义了如何确保模型在结构化输出模式下调用 generate_response 工具的策略。

TOOL_CHOICE 模式(推荐)

核心机制:使用 API 的 tool_choice 参数来强制模型调用工具

工作流程

  1. 第一轮:模型可以自由调用任何工具(包括业务工具)
  2. 如果模型没有调用 generate_response:下一轮会通过 tool_choice 参数强制它调用

优势

  • ✅ 允许代理先完成多步骤任务
  • ✅ 最后再生成结构化输出
  • ✅ 更灵活、更高效

代码逻辑示意

1
2
3
4
5
第一轮:自由调用 → 可能调用业务工具

检查是否调用了 generate_response?
↓ 否
第二轮:tool_choice="generate_response" → 强制调用

PROMPT 模式(传统方式)

核心机制:通过注入提示词来提醒模型调用工具

工作流程

  1. 如果模型没有调用 generate_response 工具
  2. 在对话中注入一条提醒消息
  3. 代理重新进行推理
  4. 可能需要多次迭代才能成功

劣势

  • ❌ 需要多次 API 调用和重试
  • ❌ 效率较低

代码逻辑示意

1
2
3
4
5
6
7
推理 → 未调用 generate_response

注入提示:"请调用 generate_response 工具"

重新推理 → 可能仍未调用

继续注入提示... (循环直到成功)

对比总结

特性 TOOL_CHOICE PROMPT
控制方式 API 参数强制 文本提示提醒
灵活性 高(允许多步骤)
效率 高(少次迭代) 低(可能多次重试)
API 调用次数 较少 较多
推荐度 ✅ 默认推荐 传统方式

形象比喻

  • TOOL_CHOICE:像考试时的”必须交答题纸”要求——你可以先在草稿纸上演算(调用其他工具),但最后必须把答案填到答题纸上(generate_response)
  • PROMPT:像老师不断口头提醒”要交卷了”——学生可能还需要多次提醒才会提交

关键问题:加载其他工具包时的调用路径

问题:如果给 Agent 加载了其他业务工具包,调用路径是否一致?

答案调用路径一致,但工具选择范围不同。分两种情况讨论:

情况 1:仅使用 generate_response 工具(演示案例)
1
模型推理 → 只能调用 generate_response → 生成结构化输出

这是最简单的场景,模型没有其他选择,必须调用 generate_response

情况 2:加载了其他业务工具包
1
2
3
4
5
6
7
8
9
10
// 示例:添加了搜索工具和计算工具
Toolkit toolkit = new Toolkit()
.addTool(new SearchTool()) // 业务工具 1
.addTool(new CalculateTool()) // 业务工具 2
.addTool(new GenerateResponseTool()); // 结构化输出工具

StructuredOutputCapableAgent agent = new MyAgent(
toolkit,
StructuredOutputReminder.TOOL_CHOICE
);

调用路径(TOOL_CHOICE 模式)

1
2
3
4
5
6
7
8
9
第一轮(自由选择):
模型推理 → 可能调用 SearchTool → 获取搜索结果

检查:是否调用了 generate_response?
↓ 否(因为调用了 SearchTool)
第二轮(强制调用):
tool_choice="generate_response" → 必须调用 generate_response

生成结构化输出

关键点

  1. 多轮对话能力:TOOL_CHOICE 允许模型先调用业务工具完成任务,最后再输出
  2. 最终收敛:无论中间调用多少工具,最终都会被强制调用 generate_response
  3. 职责分离:业务工具负责执行具体任务,generate_response 负责格式化输出
调用路径对比表
场景 可用工具 调用路径 轮次
仅 generate_response 只有 generate_response 直接调用 1 轮
有其他业务工具 业务工具 + generate_response 先调用业务工具 → 强制调用 generate_response 2 轮或更多
PROMPT 模式 任意工具 可能需要多次注入提示 N 轮(低效)

实际执行示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 用户提问:"查询北京天气并总结"
// 工具包:SearchTool + GenerateResponseTool

// 第 1 轮:模型自由调用 SearchTool
agent.call("查询北京天气")
→ 调用 SearchTool("北京天气")
→ 返回:北京晴,25°C
→ 未调用 generate_response

// 第 2 轮:TOOL_CHOICE 强制调用 generate_response
tool_choice = "generate_response"
→ 调用 GenerateResponseTool({"location":"北京", "weather":"晴", "temperature":25})
→ 返回结构化结果
→ ✅ 完成

设计哲学:模型自主决策 + 框架强制收敛

核心机制总结

是否调用业务工具取决于基础模型自身的判断,而结构化输出能力通过 API 参数在最后阶段强制模型调用 generate_response 工具以确保输出格式化。

这个设计体现了两层控制逻辑:

1️⃣ 第一层:模型自主决策(自由度)
  • 决策主体:基础大模型(LLM)
  • 决策依据:根据用户输入、上下文语义、任务复杂度自动判断
  • 决策内容:是否需要调用业务工具?调用哪些工具?
  • 控制方式:无强制干预,模型自由选择
1
2
3
4
5
6
7
graph LR
A[用户输入] --> B[模型推理]
B --> C{模型自主判断}
C -->|需要工具 | D[调用业务工具]
C -->|无需工具 | E[直接回答]
D --> F{检查 generate_response}
E --> F
2️⃣ 第二层:框架强制收敛(约束力)
  • 触发条件:检测到模型未调用 generate_response
  • 执行机制:通过 tool_choice API 参数强制指定
  • 目标:确保结构化输出格式统一
  • 控制方式:API 级别的技术强制
1
2
3
4
5
6
graph TB
A[模型完成本轮调用] --> B{是否调用了<br/>generate_response?}
B -->|是 | C[✅ 输出结构化结果]
B -->|否 | D[下一轮设置<br/>tool_choice=generate_response]
D --> E[模型必须调用<br/>generate_response]
E --> C
3️⃣ 两层控制的协同效应
维度 模型自主决策 框架强制收敛
目的 灵活应对复杂任务 保证输出格式统一
时机 任务执行过程中 任务完成后输出前
手段 模型自由推理 API 参数强制
灵活性 高(可多步骤) 低(必须调用)
角色定位 过程导向 结果导向

形象比喻

就像公司项目管理

  • 模型自主决策:员工可以自由选择使用什么工具和方法完成任务(调研、计算、分析等)
  • 框架强制收敛:提交报告时必须使用公司统一的 PPT 模板(generate_response

这样既保证了工作灵活性,又确保了汇报标准化

4️⃣ 技术实现要点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 伪代码展示控制流程
class StructuredOutputCapableAgent {
Mono<Response> call(Request request) {
return doCall(request)
.flatMap(response -> {
// 第 1 步:检查是否调用了 generate_response
if (!response.hasToolCall("generate_response")) {

// 第 2 步:根据模式选择处理策略
if (structuredOutputReminder == TOOL_CHOICE) {
// 下一轮强制调用
return buildOptionsBuilder()
.toolChoice("generate_response")
.build()
.flatMap(options -> doCall(request, options));
} else if (structuredOutputReminder == PROMPT) {
// 注入提示并重试
return injectReminderAndRetry(request);
}
}

// 第 3 步:已完成结构化输出,进行记忆压缩
return compressMemory(response);
});
}
}

关键结论

业务工具调用是概率性的 —— 由模型根据任务需求自主决定
结构化输出是确定性的 —— 无论中间过程如何,最终必然收敛到 generate_response
TOOL_CHOICE 是技术性强制 —— 不依赖模型配合,直接通过 API 参数实现
PROMPT 是语言性引导 —— 通过文本提示期望模型配合,但可能需多次迭代

1
2
3
4
5
// 实际使用示例
StructuredOutputCapableAgent agent = new MyAgent(
toolkit,
StructuredOutputReminder.TOOL_CHOICE // 推荐使用此模式
);

而且我发现,在Agentscope中,结构化输出是通过代码生成了一个临时工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/**
* Create the structured output tool with validation.
*/
private AgentTool createStructuredOutputTool(
Map<String, Object> schema, Class<?> targetClass, JsonNode schemaDesc) {
return new AgentTool() {
@Override
public String getName() {
return STRUCTURED_OUTPUT_TOOL_NAME;
}

@Override
public String getDescription() {
return "Generate the final structured response. Call this function when"
+ " you have all the information needed to provide a complete answer.";
}

@Override
public Map<String, Object> getParameters() {
Map<String, Object> params = new HashMap<>();
params.put("type", "object");
params.put("properties", Map.of("response", schema));
params.put("required", List.of("response"));
return params;
}

@Override
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return Mono.fromCallable(
() -> {
Object responseData = param.getInput().get("response");

// The tool simply stores the raw data
// Validation is done by ToolExecutor before calling this
String contentText = "";
if (responseData != null) {
try {
contentText = JsonUtils.getJsonCodec().toJson(responseData);
} catch (Exception e) {
contentText = responseData.toString();
}
}

log.debug("Structured output generated: {}", contentText);

// Create response message
Msg responseMsg =
Msg.builder()
.name(getName())
.role(MsgRole.ASSISTANT)
.content(TextBlock.builder().text(contentText).build())
.metadata(
responseData != null
? Map.of("response", responseData)
: Map.of())
.build();

Map<String, Object> toolMetadata = new HashMap<>();
toolMetadata.put("success", true);
toolMetadata.put("response_msg", responseMsg);

return ToolResultBlock.of(
List.of(
TextBlock.builder()
.text("Successfully generated response.")
.build()),
toolMetadata);
});
}
};
}

匿名内部类实现:临时工具的精妙设计

这个工具的实现非常巧妙,采用了匿名内部类的方式动态创建一个临时的 AgentTool。让我们分解它的核心逻辑:

1️⃣ 工具定义(L884-905)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private AgentTool createStructuredOutputTool(...) {
return new AgentTool() { // 匿名内部类
// ① 工具名称:固定的常量
public String getName() {
return STRUCTURED_OUTPUT_TOOL_NAME; // "generate_response"
}

// ② 工具描述:告诉模型何时调用
public String getDescription() {
return "当你有所有信息可以提供完整答案时调用此函数";
}

// ③ 参数定义:使用传入的 schema 动态构建
public Map<String, Object> getParameters() {
return {
"type": "object",
"properties": {"response": schema}, // ← 用户自定义的 schema
"required": ["response"]
};
}
};
}

关键点

  • 动态性:schema 是外部传入的参数,可以为不同的结构化输出需求生成不同的工具
  • 一次性:每次创建都是新的匿名类实例,用完即弃
  • 标准化:名称固定为 generate_response,确保后续能被 TOOL_CHOICE 强制调用
2️⃣ 工具执行(L908-949)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public Mono<ToolResultBlock> callAsync(ToolCallParam param) {
return Mono.fromCallable(() -> {

// 第 1 步:提取模型生成的响应数据
Object responseData = param.getInput().get("response");

// 第 2 步:转换为 JSON 字符串(验证已在 ToolExecutor 完成)
String contentText = JsonUtils.getJsonCodec().toJson(responseData);

// 第 3 步:构建响应消息
Msg responseMsg = Msg.builder()
.name(getName()) // "generate_response"
.role(MsgRole.ASSISTANT) // 助手角色
.content(contentText) // JSON 格式的响应
.metadata(Map.of("response", responseData)) // 原始数据
.build();

// 第 4 步:返回结果块
return ToolResultBlock.of(
List.of(TextBlock.of("Successfully generated response.")),
Map.of("success", true, "response_msg", responseMsg)
);
});
}

执行流程图

1
2
3
4
5
6
7
graph LR
A[模型调用工具] --> B[传入 response 参数]
B --> C[提取 responseData]
C --> D[转换为 JSON]
D --> E[构建 Msg 对象]
E --> F[返回 ToolResultBlock]
F --> G[存储到记忆]
3️⃣ 设计亮点分析
特性 实现方式 优势
临时性 匿名内部类 无需单独定义类,按需创建
动态 Schema 参数透传 支持任意类型的结构化输出
职责单一 只负责存储和转换 验证由 ToolExecutor 处理,符合单一职责
响应式 返回 Mono<ToolResultBlock> 与 Agentscope 响应式架构一致
自描述 包含详细的 description 帮助模型理解何时调用
4️⃣ 为什么采用匿名内部类?

对比两种方式

1
2
3
4
5
6
7
8
9
10
// ❌ 方案 1:定义独立类(不够灵活)
class GenerateResponseTool implements AgentTool {
private final Map<String, Object> schema; // 需要构造函数传递

// 每个 Agent 实例都要创建这个类的实例
// 但实际这个工具只能用一次
}

// ✅ 方案 2:匿名内部类(简洁高效)
return new AgentTool() { ... }; // 即用即弃,无需额外类定义

优势总结

  1. 代码量更少:不需要单独定义一个类
  2. 作用域明确:只在当前方法内有效,不会污染命名空间
  3. 闭包捕获:可以直接使用外部方法的参数(如 schema
  4. 语义清晰:一看就知道这是一个”临时工具”
5️⃣ 关键注释解读
1
2
// The tool simply stores the raw data
// Validation is done by ToolExecutor before calling this

这两行注释揭示了 Agentscope 的设计哲学:

工具本身不验证,验证逻辑前置到执行器

这样设计的原因:

  • 职责分离:工具只负责业务逻辑,验证由框架统一处理
  • 性能优化:避免重复验证
  • 统一管理:所有工具的验证规则集中管理

然后在 StructuredOutputHook 里发现了一个很有意思的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* Creates a reminder message to prompt the model to call generate_response.
*
* <p>The message includes metadata to identify it as a reminder and store the
* reminder mode, which is used by {@link #handlePreReasoning} to determine
* whether to force tool_choice on retry.
*
* @param mode The structured output reminder mode
* @return A reminder message with appropriate metadata
*/
private Msg createReminderMessage(StructuredOutputReminder mode) {
Map<String, Object> metadata =
Map.of(
MessageMetadataKeys.STRUCTURED_OUTPUT_REMINDER,
true,
MessageMetadataKeys.STRUCTURED_OUTPUT_REMINDER_TYPE,
mode.toString());

return Msg.builder()
.name("system")
.role(MsgRole.USER)
.content(
TextBlock.builder()
.text(
"Please call the 'generate_response' function to provide"
+ " your response.")
.build())
.metadata(metadata)
.build();
}

在这里是使用了UserMsg来充当了一个临时的SystemPrompt,很有意思,在我使用的过程中我用的是AssistantMsg,不知道这里使用UserMsg还是AssistantMsg会有什么不同。

深度解析:三种消息角色的语义差异与选择

这个问题触及了 Agent 对话设计的一个核心概念:消息角色的语义学。让我们从三个维度来分析:

1️⃣ 消息角色的本质含义

角色 name role 语义定位 典型场景
System "system" SYSTEM 系统级指令、规则制定者 初始化提示、行为约束
User "user" / "system" USER 信息提供者、任务发起者 用户输入、外部数据注入
Assistant "assistant" / 工具名 ASSISTANT 响应执行者、结果输出者 模型回复、工具调用结果

关键洞察

  • name 字段:标识消息的来源身份(谁说的)
  • role 字段:定义消息的对话角色(在对话中扮演什么角色)
  • ✅ 两者可以分离:name="system" + role=USER 是完全合法的组合

2️⃣ 为什么这里选择 role=USER

代码分析

1
2
3
4
5
return Msg.builder()
.name("system") // ← 来源:系统框架
.role(MsgRole.USER) // ← 角色:用户
.content("Please call the 'generate_response' function...")
.build();

设计意图

✅ 选择 USER 的原因
  1. 语义匹配:这是一个外部指令注入

    1
    2
    3
    4
    5
    6
    正常对话流程:
    User: "查询天气"
    Assistant: [调用工具]
    System(作为 User): "请调用 generate_response"

    → 这相当于系统"扮演"用户在提醒
  2. 模型预期:LLM 训练时的模式

    1
    2
    3
    4
    5
    6
    7
    8
    训练数据模式:
    User → Assistant → User → Assistant

    如果插入 Assistant 消息:
    User → Assistant → [Assistant] ← 违反对话轮次预期

    如果插入 User 消息:
    User → Assistant → [User] ✓ 符合交替模式
  3. 元认知位置:提醒是对话的一部分,不是背景设定

    • SYSTEM:设定对话规则和边界(一次性)
    • USER:参与具体对话流程(多轮交互)
❌ 不使用 SYSTEM 的原因
1
2
3
4
5
6
// ❌ 如果使用 SYSTEM
Msg.systemReminder = Msg.builder()
.name("system")
.role(MsgRole.SYSTEM) // ← 问题所在
.content("请调用 generate_response")
.build();

问题分析

  • SYSTEM 消息通常在对话开始时设置一次
  • 中途插入 SYSTEM 消息会显得突兀,破坏对话连贯性
  • LLM 可能不会将 SYSTEM 消息视为”需要回应的请求”
❓ 如果使用 ASSISTANT 会怎样?
1
2
3
4
5
6
// ❓ 如果使用 ASSISTANT(你的实践方式)
Msg.assistantReminder = Msg.builder()
.name("system") // 或 "generate_response"
.role(MsgRole.ASSISTANT)
.content("请调用 generate_response")
.build();

潜在影响

维度 效果 原因分析
对话流 ⚠️ 混乱 连续两个 Assistant 消息违反对话轮次
模型理解 ⚠️ 困惑 “我自己提醒自己?”
实际效果 ⚪ 可能有效 但依赖于模型的容错能力
语义准确性 ❌ 不准确 这不是助手的自主行为

3️⃣ 三种选择的对比实验

假设场景:模型第一轮没有调用 generate_response

方案 A:Agentscope 的选择(role=USER)✅
1
2
3
4
5
6
7
// 第 1 轮
User: "总结这篇文章"
Assistant: [调用了 SearchTool,未调用 generate_response]

// 第 2 轮前插入提醒
System(as User): "Please call the 'generate_response' function"
Assistant: [调用 generate_response] ✓

优势

  • ✅ 对话流畅自然
  • ✅ 符合 LLM 训练模式
  • ✅ 明确的外部指令信号
方案 B:使用 role=ASSISTANT ⚠️
1
2
3
4
5
// 第 1 轮
User: "总结这篇文章"
Assistant: [调用了 SearchTool]
Assistant(system): "Please call generate_response" ← 自己说话?
Assistant: [调用 generate_response]

问题

  • ❌ 违反了”一问一答”的对话模式
  • ❌ 模型可能会困惑:”我为什么要接自己的话?”
方案 C:使用 role=SYSTEM ❌
1
2
3
4
5
6
// 第 1 轮
System: "你是一个助手..."
User: "总结这篇文章"
Assistant: [调用了 SearchTool]
System: "Please call generate_response" ← 突然修改规则?
Assistant: ...

问题

  • ❌ SYSTEM 消息通常只在开头出现
  • ❌ 中途插入会破坏对话上下文的一致性

4️⃣ 为什么 name=”system” 而不是 name=”user”?

这是个精妙的设计:

1
2
.name("system")     // ← 真实来源:框架系统
.role(MsgRole.USER) // ← 对话角色:用户

元数据追踪

1
2
3
4
Map.of(
MessageMetadataKeys.STRUCTURED_OUTPUT_REMINDER, true,
MessageMetadataKeys.STRUCTURED_OUTPUT_REMINDER_TYPE, mode.toString()
)

这样设计的好处:

  1. 可追溯性:后续处理可以识别这是”系统生成的提醒”
  2. 调试友好:日志中清楚显示消息来源
  3. 避免混淆:不会误认为是真实用户的输入

5️⃣ 实战建议

推荐做法(与 Agentscope 保持一致):

1
2
3
4
5
6
7
8
9
// ✅ 标准实现
private Msg createReminderMessage() {
return Msg.builder()
.name("system") // 来源:框架
.role(MsgRole.USER) // 角色:用户(外部指令)
.content("Please call...")
.metadata(Map.of(...)) // 添加追踪元数据
.build();
}

如果你已经使用了 ASSISTANT

1
2
3
4
5
6
// 如果你的实现是这样的
Msg.builder()
.name("assistant")
.role(MsgRole.ASSISTANT)
.content("请调用 generate_response")
.build();

可能的影响

  • 短期:可能也能工作(LLM 有容错能力)
  • ⚠️ 长期:可能导致对话模式混乱,特别是在复杂多轮对话中
  • 建议:改为 role=USER 以获得更好的语义一致性

6️⃣ 总结:消息角色的选择原则

场景 推荐 role 理由
框架初始化指令 SYSTEM 设定对话基调和规则
外部指令注入 USER 模拟用户发起新请求
工具执行结果 ASSISTANT 表示助手的响应
模型自主回复 ASSISTANT 助手的自然输出
提醒/打断 USER 作为外部干预信号

核心原则

消息角色应该反映其在对话流程中的功能定位,而不是消息的来源。

这就是为什么 Agentscope 选择用 name="system" + role=USER 的组合:既保持了来源的可追溯性,又确保了对话语义的正确性!