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、Memory2.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, MapString, 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 条消息拼成上下文不需要业务代码手动维护。我用一个环形队列实现了一个精巧程度刚好的 Memorypublic class Memory { private final int maxSize; private final DequeMsg 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 ListMsg 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 自带的就不引第三方”。我用的构建工具是 GradleJDK 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 层依赖 corellm 层只向上提供服务。后面加扩展时只要不破坏依赖方向项目就不会腐化。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 MapString, Tool tools; private final int maxIterations; public ReActAgent(String name, ChatModel model, ListTool 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(ListChatMessage messages); } public record ChatMessage(String role, String content) { }这个接口简单到不能再简单你给我一堆历史消息返回模型生成的文本。加了FunctionalInterface意味着调用方可以直接传 lambda测试时甚至可以传一个死数据模拟器完全不需要真实网络请求。这就是接口抽象的价值后来我再写OpenAiCompatibleChatModel时只需要实现这一个方法。4.2 实现 ReActAgentThought、Action、Observation 三步走ReActAgent 不是简单的“收到消息就调一次模型”而是可以反复调用模型直到它给出最终答案。这个模式的本质是让大模型“边想边做”先输出一个 Thought然后决定调用哪个工具拿到工具结果后再思考下一步。整个过程就是一个循环。我在generateReply里完整实现了这个循环Override protected Msg generateReply(Msg input) { String sysPrompt 你是一个能调用工具解决用户问题的助手。 你的回复必须严格遵循以下格式 Thought: 你的思考过程 Action: 工具名 或 FINISH Action Input: 工具入参 或 最终答案 ; ListMsg history memory().snapshot(); ListChatMessage 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(ListChatMessage 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 { HttpResponseString 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 模型再接入真实模型遇到坑了回来对照第五节的排查表大概率能少走一半弯路。