📚 阅读动机

为什么想要阅读 Agentscope 的源码?主要是好奇这几个问题:

  • 一个 Agent 框架是如何组织”推理 - 行动”循环的?
  • 工具调用是怎么让大模型”感知”并正确执行的?
  • 对话记忆是怎么管理和持久化的?
  • Hook 系统如何优雅地插入到各个执行环节?

带着这些问题,先从官方文档入手,了解一下整体架构。


🎯 整体印象

先放一张我理解的调用流程图(基于文档描述):

agentscope_main_process

我的理解:

整个流程其实就是一个双循环结构

  1. Reasoning(推理环):从 Memory 读取历史 → Formatter 格式化 → Model 调用 LLM → 得到响应
  2. Acting(行动环):如果需要调用工具 → Toolkit 执行 → 结果存回 Memory → 回到推理环

这个设计思想跟 ReAct 论文的思路是一致的:思考(Reasoning)和行动(Acting)交替进行,直到完成任务。

📄 ReAct 论文原文ReAct: Synergizing Reasoning and Acting in Language Models

  • 作者:Yao et al.
  • 发表:ICLR 2023
  • 核心思想:LLM 通过交替进行推理(Reasoning)和行动(Acting)来更好地完成复杂任务

接下来逐一拆解各个模块,看看它们各自扮演什么角色。


💬 消息(Message)

我的疑问

Agent 之间怎么通信?对话历史怎么存储?跟 LLM API 交互用什么数据结构?

答案:Msg 对象

核心设计

Msg 是贯穿整个框架的数据载体,我觉得有几个关键点:

  1. 统一数据格式:无论是用户输入、AI 响应还是工具调用结果,都用同一个 Msg 封装
  2. 原生多模态支持:从设计上看,Msg 可以承载文本、图片、文件等多种内容
  3. 结构化字段:通过 name、content 等字段区分消息来源和类型

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 创建简单的文本消息
Msg msg = Msg.builder()
.name("user")
.textContent("今天北京天气怎么样?")
.build();

// 创建多模态消息(文本 + 图片)
Msg imgMsg = Msg.builder()
.name("user")
.content(List.of(
TextBlock.builder().text("这张图片是什么?").build(),
ImageBlock.builder().source(new URLSource("https://example.com/photo.jpg")).build()
))
.build();

🤔 待验证的点

  • Msg 在内部传递过程中会不会被修改或增强?
  • 多模态内容在不同 LLM 提供商之间怎么保持兼容性?

🤖 智能体(Agent)

我的理解

如果把 Agent 看作一个函数,那它的签名应该是怎样的?

Agentscope 给了一个清晰的答案:

1
2
3
4
5
public interface Agent {
Mono<Msg> call(Msg msg); // 处理单个消息,返回响应
Flux<Msg> stream(Msg msg); // 流式返回响应流
void interrupt(); // 中断执行
}

关键观察

  1. 响应式编程:用了 Reactor 的 MonoFlux,这很符合现代 Java 的异步风格
  2. 有状态对象:每个 agent 实例都是独立的,不能在线程间共享(并发安全问题⚠️)
  3. 中断机制interrupt() 方法的存在说明框架考虑了长时间运行的场景

默认实现:ReActAgent

1
2
3
4
5
6
7
8
9
10
11
12
ReActAgent agent = ReActAgent.builder()
.name("Assistant")
.model(DashScopeChatModel.builder()
.apiKey(System.getenv("DASHSCOPE_API_KEY"))
.modelName("qwen3-max")
.build())
.sysPrompt("你是一个有帮助的助手。")
.toolkit(toolkit) // 可选:添加工具
.build();

// 同步调用
Msg response = agent.call(userMsg).block();

🔍 重点关注

  • ReAct 算法的具体实现:怎么实现”推理 - 行动”循环的?
  • interrupt 怎么用:什么时候会触发中断?资源怎么清理?
  • 并发安全:为什么不能共享?有没有保护机制?

🛠️ 工具(Toolkit)

核心问题

LLM 只能生成文本,但我想让它查数据库、调 API、做计算,怎么办?

答案:把 Java 方法包装成工具

设计亮点

Agentscope 的工具定义方式:

  • @Tool 注解标记方法
  • 支持实例方法、静态方法、类方法
  • 支持同步/异步、流式/非流式返回

使用示例

1
2
3
4
5
6
7
8
9
10
11
12
public class WeatherService {
@Tool(name = "get_weather", description = "获取指定城市的天气")
public String getWeather(
@ToolParam(name = "city", description = "城市名称") String city) {
// 调用天气 API
return "北京:晴,25°C";
}
}

// 注册工具
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new WeatherService());

⚠️ 注意事项

@ToolParam 必须显式指定 name 属性,因为 Java 运行时不保留参数名(这是 Java 的坑😅)

🔍 我的疑问

这才是我最关心的部分:

  1. 工具是怎么让 LLM 知道的?

    • 是通过 prompt 注入工具描述?
    • 还是用了 Function Calling 之类的特性?
  2. 调用流程是怎样的?

    • LLM 返回工具调用请求后,框架怎么解析?
    • 怎么找到对应的 Java 方法并执行?
    • 执行结果怎么反馈给 LLM?

这些得看源码才能知道。


🧠 记忆(Memory)

为什么需要记忆?

如果没有记忆,每轮对话都是独立的,那就成了”金鱼记忆”,根本没法进行有意义的对话。

框架的处理

ReActAgent 会自动管理记忆:

  • ✅ 用户消息 → 加入记忆
  • ✅ 工具调用和结果 → 加入记忆
  • ✅ 智能体响应 → 加入记忆
  • ✅ 推理时 → 读取记忆作为上下文

存储策略

  • 默认实现InMemoryMemory(纯内存,进程重启就没了)
  • 持久化方案:可以用 MysqlSession 等基于数据库的实现

🤔 我的猜测

从之前看过的会话管理代码来看,我猜框架可能是这样做的:

  1. 用一个列表或队列存储历史消息
  2. 每次推理前,把历史消息拼接到 prompt 里
  3. 可能有上下文长度限制,需要裁剪或摘要

但具体实现还得看源码验证,特别是:

  • 记忆的增删改查是怎么封装的?
  • 跨会话持久化是怎么做到的?
  • 上下文窗口超限了怎么处理?

🔄 格式化器(Formatter)

为什么要格式化?

不同的 LLM 提供商,API 格式千差万别:

  • OpenAI 用 messages 数组
  • 阿里云百炼有自己的格式
  • 其他厂商又不一样

难道要为每个厂商写一套代码?当然不是!

适配器模式

Agentscope 用 Formatter 做了适配层:

1
AgentScope 消息 → Formatter → 特定 LLM API 格式

内置实现

  • DashScopeFormatter - 阿里云百炼(通义千问)
  • OpenAIFormatter - OpenAI 及兼容 API

功能范围

不仅仅是格式转换,还包括:

  • 📝 提示词工程(添加系统提示、格式化多轮对话)
  • ✅ 消息验证(确保符合 API 要求)
  • 👥 多智能体身份处理(区分不同角色的消息)

使用体验

对用户来说,通常是无感的:

1
2
.model(DashScopeChatModel.builder()...)
// Formatter 会根据 Model 类型自动选择,不需要手动配置

💡 我的理解

总流程图中提到的 Formatter,本质上就是一个协议转换器,让上层应用不用关心底层 LLM 的差异。

后面可以看看它是如何设计统一抽象的。


🪝 钩子(Hook)

使用场景

如果我想在智能体执行时做一些额外的事:

  • 📝 记录日志
  • 📊 性能监控
  • 🔧 修改消息内容
  • 🚨 异常告警

怎么办?Hook 系统就是干这个的

事件驱动设计

Hook 通过事件机制在 ReAct 循环的关键节点提供扩展点:

事件类型 触发时机 能否修改
PreCallEvent 智能体开始处理前
PostCallEvent 智能体处理完成后
PreReasoningEvent 调用 LLM 前
PostReasoningEvent LLM 返回后
ReasoningChunkEvent LLM 流式输出时
PreActingEvent 执行工具前
PostActingEvent 工具执行后
ActingChunkEvent 工具流式输出时
ErrorEvent 发生错误时

优先级控制

Hook 按优先级执行,数值越小优先级越高(默认 100)

代码示例

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
Hook loggingHook = new Hook() {
@Override
public <T extends HookEvent> Mono<T> onEvent(T event) {
return switch (event) {
case PreCallEvent e -> {
System.out.println("🚀 智能体开始处理...");
yield Mono.just(event);
}
case ReasoningChunkEvent e -> {
System.out.print(e.getIncrementalChunk().getTextContent());
yield Mono.just(event);
}
case PostCallEvent e -> {
System.out.println("✅ 处理完成:" + e.getFinalMessage().getTextContent());
yield Mono.just(event);
}
default -> Mono.just(event);
};
}

@Override
public int priority() {
return 50; // 高优先级
}
};

// 注册 Hook
ReActAgent agent = ReActAgent.builder()
// ... 其他配置
.hook(loggingHook)
.build();

🔍 我的核心疑问

这也是我最好奇的地方:

Hook 是怎么注入到 LLM 调用流程中的?

  • 是通过 AOP 切面?
  • 还是在关键位置手动调用?
  • 事件是怎么产生和传播的?

这个必须看源码才能明白。


Action

基于以上概览,接下来的行动计划:

第一阶段:核心机制探索

  1. ReAct 算法实现分析

    • 追踪 ReActAgent.call() 方法的完整调用链
    • 分析推理 - 行动循环的具体实现逻辑
    • 研究循环终止条件和状态流转
  2. 工具调用机制

    • 查看 Toolkit 如何注册和管理工具
    • 分析 LLM 如何感知可用工具(prompt 注入 or 其他方式)
    • 追踪工具调用的解析和执行流程
    • 研究工具结果的反馈机制
  3. Hook 系统注入方式

    • 查找 Hook 在 ReAct 循环中的注册点
    • 分析事件触发和传播机制
    • 理解 Hook 如何影响消息流转

第二阶段:记忆与状态管理

  1. 记忆管理实现

    • 分析 InMemoryMemory 的内存存储策略
    • 研究 MysqlSession 等持久化实现
    • 探索记忆的增删改查操作封装
    • 理解上下文窗口的管理机制
  2. Formatter 适配器模式

    • 对比 DashScopeFormatterOpenAIFormatter 的实现差异
    • 分析消息转换的统一抽象层设计

第三阶段:并发与中断

  1. interrupt 中断机制

    • 研究中断信号的产生和传递
    • 分析正在进行的操作如何响应中断
    • 探索资源清理和状态恢复机制
  2. 并发安全性

    • 验证 Agent 实例的线程安全问题
    • 查看是否有并发控制或保护机制

预期产出

  • 绘制详细的源码流程图
  • 总结关键设计模式和架构思想
  • 输出各模块的深度分析文章