AgentScope 是阿里开源的多智能体开发框架,在 AI 应用圈子里已经不算陌生了,但官方 SDK 的示例主要集中在 Python 生态,Java 工程师想啃源码、抄作业,往往得先过一层语言转换。所以我开了 dream-scope 这个坑:不碰任何官方绑定,用 Java 把一个最小内核重新实现一遍,用来理解 AgentScope 到底在解决什么问题。这篇是第 01 篇,暂时不聊分布式、不聊复杂编排,只把三块最核心的骨头立起来:Message、Agent、Memory,外加一个能跑通的 ReAct 循环。如果你正准备接触 AgentScope,或者想找一个同时练 Java 并发和 AI 应用架构的实战项目,这篇文章应该能给你一个足够干净的切入点。
1. 为什么非要写一个 Java 版最小内核
1.1 先说清楚 AgentScope 解决的是什么问题
先回到本质:AgentScope 到底在解决什么问题?一句话概括,它把“一堆 LLM 应用组件之间的协作”抽象成了非常统一的一层模型,让开发者可以像搭积木一样去搭智能体。
在实际开发里,你一定会遇到这些破事。第一,每个 Agent 内部要调用大模型,外部要跟其他 Agent 通信,消息格式不一样,协议五花八门,到最后代码里到处都是 if-else 判断消息类型;第二,多轮对话的上下文散落在各个模块,今天你用一个 List 存,明天他用一个 Redis 存,谁也管不住谁;第三,多个 Agent 之间的调用关系一旦复杂起来,就变成了面条式代码。AgentScope 的思路是,所有 Agent 之间的交流都统一成一种消息对象,所有 Agent 的行为都统一成“收到消息 -> 处理 -> 回复消息”,然后把记忆、工具调用、模型调用都作为 Agent 身上的可插拔部件。
这一套设计其实并不复杂,但它特别适合用代码来验证。你如果只读文档,很容易觉得“不就是加一层消息封装吗”;而当你真的用 Java 把这些类写出来,让两个 Agent 跑起来对话时,你才会真正理解这个抽象的价值:业务逻辑和通信机制解耦,加一个新的 Agent 不需要改动任何现有 Agent 的代码,就像往系统里加一个员工,大家只认工单,不认人。
1.2 用 Java 重写,不是重复造轮子,而是拆解轮子
很多人一听“自己重写一个框架”就皱眉,觉得是重复造轮子。但这里的目标根本不是生产可用的框架,而是学习脚手架。Python 版文档我看过不少,动态类型让很多设计看起来轻飘飘的,比如消息里随便塞 dict,一个函数可以返回任意结构,可一旦落到 Java 这种强类型语言,你必须把所有东西都显式建模:消息有哪些字段?Agent 的回复方法签名是什么?记忆体怎么存取?这些“必须想清楚”的地方,恰恰是理解一个系统最好的抓手。
我做这个项目还有个执念:只靠读源码,你记住的是别人的答案,而不是自己的理解。当我用 Java 把Message、Agent、Memory这些概念一个一个设计出来,再对比官方实现时,我能清晰地感觉到哪些地方官方设计得更优,哪些地方我的简化版本反而更直观。这种“先写再对照”的学习方式,效率远高于对着源码一行行看。
另外,这个项目也能顺便练一手 Java 基本功。多智能体系统天然涉及异步调用、线程池、消息顺序、超时控制,这些和 Java 并发模型配合起来,几乎是把面试里那些抽象概念全部落到了实处。
2. 最小内核的核心抽象:Message、Agent、Memory
2.1 Message:所有沟通都是一个消息对象
在 AgentScope 的设计里,Message 是绝对的核心,它承担的角色相当于公司里流转的工单。不管你是用户、是 Agent、是工具调用返回值,最终都要变成一个消息对象。
我自己设计的 Java 版本里,消息是一个不可变对象,用 Record 实现,字段包括 id、name、content、type、metadata、timestamp。这里不可变非常重要,因为消息会在多个 Agent 之间流转,如果它是可变的,A 改了字段,B 拿到的就是脏数据,排查起来极其痛苦。Record 天然不可变,而且自带equals、hashCode、toString,省事。
public enum MsgType { MSG, REPLY, BROADCAST, FINISH } public record Msg( String id, String name, String content, MsgType type, Map<String, Object> metadata, long timestamp ) { public Msg { id = id == null ? UUID.randomUUID().toString() : id; timestamp = timestamp == 0 ? System.currentTimeMillis() : timestamp; } public static Msg user(String content) { return new Msg(null, "user", content, MsgType.MSG, null, 0); } public static Msg assistant(String content) { return new Msg(null, "assistant", content, MsgType.MSG, null, 0); } public Msg reply(String content) { return new Msg(null, this.name(), content, MsgType.REPLY, this.metadata(), System.currentTimeMillis()); } }这里有个小设计心得:静态工厂方法比直接 new 方便得多,调用处写Msg.user("你好")比new Msg(null, "user", "你好", ...)清晰很多。另外 metadata 字段用来兜底各种扩展信息,比如 Agent 想传个 sessionId 进去,或者想把工具调用的 token 消耗带上,就不需要改消息类本身。这个字段在初期最容易被人忽略,但没有它,后面加需求就得改消息结构,破坏面很大。
2.2 Agent:有记忆、能回复消息的执行单元
Agent 是整个系统里最核心的抽象。在 AgentScope 里,Agent 更像一个“有知觉的节点”:它能接收消息、调用模型、使用工具,然后把回复传回给调用者。
我在 Java 实现里把 Agent 设计成两层。接口层定义最小约定:每个 Agent 有名字,能处理一条消息并返回一条消息。抽象类层负责公共能力,比如记忆读写、日志输出。
public interface Agent { String name(); Msg reply(Msg input); default void onRegister(AgentRuntime runtime) { } }public abstract class AgentBase implements Agent { private final String name; private final Memory memory = new Memory(10); protected AgentBase(String name) { this.name = name; } @Override public String name() { return name; } public Memory memory() { return memory; } public void observe(Msg msg) { memory.record(msg); } @Override public Msg reply(Msg input) { observe(input); Msg output = generateReply(input); observe(output); return output; } protected abstract Msg generateReply(Msg input); }设计这里时我纠结过一件事:reply方法是同步好还是异步好。AgentScope 的 Python 版里很多地方是同步的,但真实智能体应用里,Agent 可能要等大模型几秒甚至几十秒,这个时间不应该让调用线程干等。我在接口层先保持同步,避免过早引入异步复杂度,后续在运行时层再包一层CompletableFuture封装。理由很简单:核心逻辑越简单越不容易出错,性能问题在需要的时候再用并发手段解决,不要一上来就把回调地狱铺满整个项目。
2.3 Memory:把上下文管理从业务里剥出来
Memory 是 Agent 内部状态的载体。在 AgentScope 的模型里,每个 Agent 拥有自己的记忆,它记录了两类东西:自己收到的消息,自己回复过的消息。这样在生成回复时,Agent 只需要从 Memory 里拿最近 N 条消息拼成上下文,不需要业务代码手动维护。
我用一个环形队列实现了一个精巧程度刚好的 Memory:
public class Memory { private final int maxSize; private final Deque<Msg> messages = new LinkedList<>(); public Memory(int maxSize) { this.maxSize = maxSize; } public synchronized void record(Msg msg) { messages.addLast(msg); while (messages.size() > maxSize) { messages.removeFirst(); } } public synchronized List<Msg> snapshot() { return new ArrayList<>(messages); } public synchronized String toChatFormat() { return snapshot().stream() .map(m -> m.name() + ": " + m.content()) .collect(Collectors.joining("\n")); } }最大容量限制非常关键。大模型上下文窗口是有限的,如果不限制 Memory 大小,聊一个小时后把所有历史都塞进上下文,费用和延迟都会失控。我在代码里默认设为 10 条,真实项目可以根据模型窗口动态调整。另外注意所有方法都加了synchronized,因为一个 Agent 很可能同时被多个调用方触发,双线程同时写队列会导致数据错乱。这个细节在单线程 Demo 里永远暴露不出来,但一旦上异步就必踩。
3. 工程落地:dream-scope 的最小骨架是怎么搭的
3.1 工程选型与目录结构
dream-scope 的定位是学习型项目,所以技术选型的原则是“能用 JDK 自带的就不引第三方”。我用的构建工具是 Gradle,JDK 17,只引入了一个 Gson 用于 JSON 序列化,因为调用大模型接口实在是离不开它。
plugins { id 'java' id 'application' } group = 'com.dreamscope' version = '0.1.0' repositories { mavenCentral() } java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } dependencies { implementation 'com.google.code.gson:gson:2.11.0' } application { mainClass = 'com.dreamscope.Main' }如果用 Maven 也完全没问题,核心只是那两个坐标。选 Gradle 纯粹是我个人习惯,Gradle 构建脚本更简洁,而且 application 插件跑 main 方法很方便。
目录结构我按“领域包 + 运行时包”的方式组织:
src/main/java/com/dreamscope/ ├── Main.java ├── core/ │ ├── Msg.java │ ├── MsgType.java │ └── Memory.java ├── agent/ │ ├── Agent.java │ ├── AgentBase.java │ └── ReActAgent.java ├── llm/ │ ├── ChatModel.java │ ├── ChatMessage.java │ └── OpenAiCompatibleChatModel.java └── tool/ ├── Tool.java └── NowTool.javacore包是最小内核,agent包是智能体行为,llm包隔离模型调用,tool包放工具。这个分层思路很朴素:核心不依赖任何外部库,agent 层依赖 core,llm 层只向上提供服务。后面加扩展时,只要不破坏依赖方向,项目就不会腐化。
3.2 消息驱动的主循环:从 UserAgent 到 ReActAgent
有了 Message、Agent、Memory,最小内核的“消息驱动”到底是怎么运转的?你可以把整个过程想象成一台自动化流水线。用户输入是一条消息,它被送到某个 Agent 的reply方法里;Agent 先把这条消息写入自己的 Memory,然后交给generateReply,由内部逻辑决定是直接回答、还是调用工具、还是把消息转发给另一个 Agent;最终产出消息再次写入 Memory,同时返回给调用方。
在最小内核阶段,我用两个 Agent 来演示这个循环:一个是普通的UserAgent(在完整系统中它负责接收用户输入,这里简化成触发入口),另一个是ReActAgent,它内置了“思考-行动-观察”的循环能力。ReActAgent 的核心逻辑会在下一节详细拆解,这里先看它的骨架:
public class ReActAgent extends AgentBase { private final ChatModel model; private final Map<String, Tool> tools; private final int maxIterations; public ReActAgent(String name, ChatModel model, List<Tool> tools, int maxIterations) { super(name); this.model = model; this.tools = tools.stream().collect(Collectors.toMap(Tool::name, t -> t)); this.maxIterations = maxIterations; } @Override protected Msg generateReply(Msg input) { // 走 ReAct 循环 return null; } }Tool是一个极简函数式接口:
@FunctionalInterface public interface Tool { String name(); String apply(String input); }把工具设计成函数式接口有一个好处:零成本注册新工具。写一个 lambda 或者方法引用就能成为工具,不需要像某些框架那样搞一大堆注解和注册中心。这其实也是我对照 AgentScope 设计后的体会:工具系统的本质就是一张“名字到函数”的映射表,不要把它复杂化。
4. 跑通第一段对话:手写一个 ReAct 循环
4.1 定义 ChatModel 接口,先不关心 HTTP
在任何智能体系统里,LLM 调用都是最底层的依赖。为了让 ReActAgent 不绑定某个具体模型供应商,我定义了一个极简的ChatModel接口:
@FunctionalInterface public interface ChatModel { String chat(List<ChatMessage> messages); } public record ChatMessage(String role, String content) { }这个接口简单到不能再简单:你给我一堆历史消息,返回模型生成的文本。加了@FunctionalInterface,意味着调用方可以直接传 lambda,测试时甚至可以传一个死数据模拟器,完全不需要真实网络请求。这就是接口抽象的价值:后来我再写OpenAiCompatibleChatModel时,只需要实现这一个方法。
4.2 实现 ReActAgent:Thought、Action、Observation 三步走
ReActAgent 不是简单的“收到消息就调一次模型”,而是可以反复调用模型,直到它给出最终答案。这个模式的本质是让大模型“边想边做”:先输出一个 Thought,然后决定调用哪个工具,拿到工具结果后再思考下一步。整个过程就是一个循环。
我在generateReply里完整实现了这个循环:
@Override protected Msg generateReply(Msg input) { String sysPrompt = """ 你是一个能调用工具解决用户问题的助手。 你的回复必须严格遵循以下格式: Thought: 你的思考过程 Action: 工具名 或 FINISH Action Input: 工具入参 或 最终答案 """; List<Msg> history = memory().snapshot(); List<ChatMessage> chatMessages = new ArrayList<>(); chatMessages.add(new ChatMessage("system", sysPrompt)); for (Msg m : history) { String role = "user".equals(m.name()) ? "user" : "assistant"; chatMessages.add(new ChatMessage(role, m.content())); } for (int i = 0; i < maxIterations; i++) { String raw = model.chat(chatMessages); System.out.println("[ReAct] 第" + (i + 1) + "轮模型输出: " + raw); String thought = extract("Thought", raw); String action = extract("Action", raw); String actionInput = extract("Action Input", raw); if ("FINISH".equalsIgnoreCase(action)) { return Msg.assistant(actionInput == null ? thought : actionInput); } Tool tool = tools.get(action); if (tool == null) { chatMessages.add(new ChatMessage("assistant", raw)); chatMessages.add(new ChatMessage("user", "工具 " + action + " 不存在,请检查并重新选择可用工具。")); continue; } String observation = tool.apply(actionInput == null ? "" : actionInput); chatMessages.add(new ChatMessage("assistant", raw)); chatMessages.add(new ChatMessage("user", "工具返回:" + observation + "\n请继续。")); } return Msg.assistant("抱歉,达到最大迭代次数,无法完成请求。"); }这里有几个设计细节值得说。
第一,模型输出格式我用的是纯文本协议,用Thought:、Action:、Action Input:三个字段来做结构化解析。这个方案简单直观,但实际用起来会遇到格式漂移问题,模型偶尔会在 Thought 里提前写Action:,导致正则解析错位。后面我在 5.2 节会讲怎么规避。
第二,工具不存在的时候我没有直接返回错误,而是把错误信息作为一条系统消息追加到 chatMessages 里,让模型自己纠正。这个设计模仿了 AgentScope 的“错误回传再思考”思路,比直接抛异常优雅得多。
第三,extract方法我在这版用了正则,粗糙但能用:
private String extract(String key, String text) { Pattern p = Pattern.compile(key + ":\\s*(.+?)(?=\n[A-Z]\\w*\\s*:|$)", Pattern.DOTALL); Matcher m = p.matcher(text); return m.find() ? m.group(1).trim() : null; }这个正则在输出格式规范时基本够用。但我要强调:真实项目中我更推荐让模型输出 JSON,然后直接用 Gson 解析,JSON 的可解析性比自由文本稳定一个量级。
4.3 串起来跑:看一次完整消息流转
写一个最简单的工具NowTool,然后写个 Main 串起来跑一遍。
public class NowTool implements Tool { @Override public String name() { return "getNow"; } @Override public String apply(String input) { return LocalDateTime.now().toString(); } }为了让第一次跑通不需要真实 API key,我在 Main 里先用一个模拟的 ChatModel,它按关键词返回固定结果:
public class Main { public static void main(String[] args) { ChatModel mockModel = messages -> { String last = messages.get(messages.size() - 1).content(); if (last.contains("工具返回")) { return "Thought: 我已经拿到当前时间。\nAction: FINISH\nAction Input: 当前时间是 2026-01-01 12:00:00"; } return "Thought: 我需要查询当前时间。\nAction: getNow\nAction Input: 无"; }; ReActAgent agent = new ReActAgent("react-demo", mockModel, List.of(new NowTool()), 5); Msg result = agent.reply(Msg.user("现在几点了?")); System.out.println("最终回答: " + result.content()); } }运行后你会看到完整流程:
[ReAct] 第1轮模型输出: Thought: 我需要查询当前时间。 Action: getNow Action Input: 无 [ReAct] 第2轮模型输出: Thought: 我已经拿到当前时间。 Action: FINISH Action Input: 当前时间是 2026-01-01 12:00:00 最终回答: 当前时间是 2026-01-01 12:00:00这里注意一个关键点:NowTool返回的是真实时间,但 mock 模型返回的“当前时间”是写死的。真实接入模型后,模型会把工具返回的时间拼进最终回复。这就是完整的 ReAct 最小循环:模型思考 -> 工具行动 -> 观察结果 -> 再思考 -> 终于回答。
如果你有真实的大模型 API,可以把 mockModel 换成OpenAiCompatibleChatModel试试。这里给出一个对接 OpenAI 兼容协议的实现,它用的是 JDK 自带的 HttpClient,不依赖 Spring 生态:
public class OpenAiCompatibleChatModel implements ChatModel { private final String apiKey; private final String endpoint; private final String modelName; private final HttpClient http = HttpClient.newHttpClient(); public OpenAiCompatibleChatModel(String apiKey, String endpoint, String modelName) { this.apiKey = apiKey; this.endpoint = endpoint; this.modelName = modelName; } @Override public String chat(List<ChatMessage> messages) { JsonArray arr = new JsonArray(); for (ChatMessage m : messages) { JsonObject obj = new JsonObject(); obj.addProperty("role", m.role()); obj.addProperty("content", m.content()); arr.add(obj); } JsonObject payload = new JsonObject(); payload.addProperty("model", modelName); payload.add("messages", arr); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(endpoint)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + apiKey) .timeout(Duration.ofSeconds(60)) .POST(HttpRequest.BodyPublishers.ofString(payload.toString())) .build(); try { HttpResponse<String> resp = http.send(request, HttpResponse.BodyHandlers.ofString()); JsonObject json = JsonParser.parseString(resp.body()).getAsJsonObject(); return json.getAsJsonArray("choices") .get(0).getAsJsonObject() .getAsJsonObject("message") .get("content").getAsString(); } catch (Exception e) { throw new RuntimeException("LLM 调用失败", e); } } }不要小看这个简单的实现,它已经把真实项目里最核心的调用链路跑通了。至于重试、熔断、流式输出,那都是后续可以叠加的增强能力,基础骨架没问题,加什么都容易。
5. 实战中的坑与我的排查笔记
5.1 LLM 调用层:超时、重试、JSON 解析
真实环境比 mock 复杂得多,我踩过几个坑,每一个都值得单独记一笔。
第一个坑是超时设置。刚开始我没有设置timeout,本地跑没问题,一到真实模型接口就挂死,一个请求能卡 10 分钟。后来给 HttpClient 加上了 60 秒超时,又给整个 chat 方法套了一层超时控制。建议特别留意:大模型接口慢是常态,超时时间的合理范围是 30 到 120 秒,过短频繁失败,过长拖垮线程。
第二个坑是 JSON 解析。不同模型返回结构大体兼容,但总有个别模型的content字段可能是数组而不是字符串。更诡异的是,有些模型在流式输出时会在 JSON 里带data:前缀,直接解析必然失败。我的解决方案是写一个健壮的响应解析工具,先尝试字符串类型,失败再尝试数组拼接。这种兼容性处理在真实项目中跑一个月都不一定触发一次,但一旦触发,没有提示的话排查成本极高。
第三个坑是重试与退避。大模型接口偶发 5xx 或限流是很正常的,我一度看到报错就抛异常,导致整个任务失败。后来按照常见的重试策略,对 429 和 5xx 分别做指数退避,最多重试 3 次。这里的一个教训是:重试逻辑一定要区分错误类型,网络错误可以重试,但 400 参数错误重试一万遍也没用。
5.2 循环失控与上下文膨胀
ReAct 循环里最容易出的问题是“模型永远不输出 FINISH”。我在调试时遇到过两次:一次是模型始终去调用同一个不存在的工具,一次是模型已经拿到答案却还在重复调用工具。排查后我做了两件事。
第一,设置maxIterations硬上限。这个值一般设 5 到 10,超过即终止,并返回一个兜底文案。这在任何智能体系统里都是必须的安全阀。
第二,把每次模型输出都追加到 chatMessages 里。这个做法造成了上下文膨胀问题:每轮循环都会把模型输出和工具返回拼进去,迭代 5 次后 prompt 就比最初长了很多,token 开销直线上升。我后来做了一个简化处理:不把全量历史塞进上下文,只保留最近 2 轮的工具调用记录和最初的问题描述。这种“滑窗式上下文”在单 Agent 场景里效果很好。
另外还有个常见问题:模型输出里出现了Thought:和Action:在同一行的情况,我的正则只能匹配到第一段。后来我改成了先按行分割,再按前缀精确匹配,不再依赖跨行正则,稳定性大幅提升。给大家一个建议:能用简单字符串处理,就别上复杂正则,大模型输出永远不会像你预期那么老实。
5.3 多 Agent 消息顺序问题
最小内核版本是单线程串行,没有消息顺序问题。但当你把多个 Agent 接到同一个运行时,每个 Agent 的reply可能被不同线程调用,而每个 Agent 内部的 Memory 又是队列,并发出入会导致顺序错乱。
我的处理办法是让Memory的record方法synchronized,同时在 Agent 之间传递消息时不共享可变对象,消息是 Record,天然不可变。这两条规则足以确保:即使两个 Agent 并发调用,每条消息的写入顺序也是确定的,不会出现 A 消息写到 B 消息后面导致上下文顺序错乱的情况。
这里我整理了一张快速排查表:
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 模型回复格式解析失败 | 输出不按给定格式 | 多次重试或改为 JSON 输出 |
| Agent 一直调用同一工具 | 模型没看到工具返回 | 检查工具返回是否拼入上下文 |
| 对话上下文越聊越乱 | Memory 超限或被并发改写 | 限制容量、加同步锁 |
| 调用超时卡死 | 未设置 HttpClient timeout | 显式加入 60~120 秒超时 |
| 工具抛出异常导致任务中断 | 无异常捕获 | 工具调用处 try-catch,把错误写入观察消息 |
6. 下一步想动的地方与一点个人体会
6.1 dream-scope 2.0 的目标
最小内核立起来之后,我的计划是围绕这个骨架做几个真实场景需要用到的扩展。第一个是加一个简单的Pipeline编排能力,让多个 Agent 按顺序、按条件执行,解决“先搜索再总结再翻译”这类多阶段任务。第二个是给运行时加上CompletableFuture异步封装,让一个 Agent 调用另一个 Agent 时不再阻塞当前线程。第三个是加一个 HTTP 接口层,把 Agent 暴露成服务,这一步我会考虑接入 Spring Boot,毕竟真实项目里没人不用 Web 框架。
这些扩展每个都不算难,但难点在于不破坏核心抽象。我在设计 2.0 时的一条铁律是:所有新功能都通过新增接口实现,而不是往AgentBase里堆字段。比如 Pipeline 不改变 Agent 本身,它只负责调度已有 Agent;异步封装也不动 Agent 的reply方法,而是在调用处包一层 Future。只要守住这条原则,dream-scope 就不会变成一个大泥球。
6.2 最后说点大实话
这个项目做完第一遍后,我对“多智能体框架”的祛魅程度比读十篇论文都大。AgentScope 的核心设计本质就是一套高质量的消息驱动抽象,它真正难的地方不是思想有多高深,而是在各种边界情况下都能保持这个抽象不崩。比如消息字段怎么设计才能兼容工具调用和用户消息,Memory 怎么取舍才能平衡上下文长度与可用性,Agent 怎么抽象才能既支持最简单的聊天又支持复杂的 ReAct 循环。
对 Java 工程师来说,这类项目确实是个不可多得的练手机会。它同时涉及函数式接口、泛型、不可变对象、并发控制、外部 API 对接,每一个点都是面试里常考的东西。而且它不像 CRUD 那类项目,做完一遍你会对“怎么设计一个可扩展的抽象”有实打实的体感。dream-scope 我还在继续迭代,每完成一版就会把代码整理好放到仓库里。如果你也想动手,建议直接从本文的骨架开始,先跑通 mock 模型,再接入真实模型,遇到坑了回来对照第五节的排查表,大概率能少走一半弯路。