1. 从一次消息丢失说起:Java 手搓 OpenManus 的领域模型设计
如果你正在用 Java 复刻 OpenManus,或者想搞懂 Agent 底层到底怎么存对话、怎么管上下文,那 Message 和 Memory 这两个类就是你绕不过去的第一道坎。我见过太多人一上来就写 Agent 调度逻辑,结果跑三轮对话就发现历史消息丢了、工具调用结果对不上号、Redis 里存进去的 JSON 反序列化直接报错。问题不在 LLM,而在领域模型没设计好。
OpenManus 原版是 Python 写的,它的 Message 和 Memory 看起来简单,但里面藏着几个关键决策:消息角色用枚举还是字符串、工具调用怎么嵌套、记忆是纯内存还是走 Redis、上下文窗口超了怎么截断。这些决策直接决定了你后面接 LangChain4j、接 OpenAI 兼容接口时顺不顺。这篇就聚焦 Java 领域模型设计,把 Message 与 Memory 的建模实践拆开讲,给你可复制的实体类、接口定义、Redis 实现和单元测试,目标是让你照着敲完就能验证消息流转和记忆存取的正确性。
适合谁看?有 Java 基础、想理解 Agent 底层数据结构的开发者。不需要你懂 Python,但最好写过 Spring Boot。全文围绕 com.openmanus.domain.memory 这个包展开,代码可以直接放进你的项目。我试过把 Message 设计成 Record,结果 Jackson 序列化工具调用链时踩了坑,后面会细说为什么最终选了 Lombok 的 @Data + @Builder。
核心检索词先摆出来:Java 领域模型设计、Message 建模、Memory 记忆存取、OpenManus Java 复刻。你如果是搜“Java Agent 消息模型怎么设计”进来的,这篇就是你要的。
2. 前置准备:TaoToken 接入与项目依赖配置
在动手写实体类之前,得先把 LLM 调用通道准备好,不然你模型设计得再漂亮,也没法验证消息能不能真正发给大模型。这里我用 TaoToken 作为 API 接入层,它兼容 OpenAI 接口格式,Java 侧用 LangChain4j 或者直接 HTTP 调用都行。官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 端点统一用 https://taotoken.net/api ,注意 API 地址后面不加 UTM 参数,保持干净。
你需要先去控制台创建一个 API Key,地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。创建完 Key 之后,在项目里配置环境变量,别硬编码到代码里。我一般用 .env 或者 IDE 的运行配置注入:
export TAOTOKEN_API_KEY="sk-你的实际key"
export TAOTOKEN_BASE_URL="https://taotoken.net/api"
模型 ID 这块,你可以先用模型对话页面测试一下哪个模型响应快,地址是 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。选好之后把 Model ID 记下来,后面 LangChain4j 配置要用。如果你打算长期跑编码类 Agent,可以看看 Coding Plan,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,它针对代码场景做了优化。
项目依赖方面,Maven 里至少要有这几样:Lombok、Jackson、Spring Data Redis、JUnit 5。pom.xml 关键片段如下:
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
</dependencies>
Redis 配置放在 application.yml 里,注意 redis-enabled 默认给 false,这样你本地没装 Redis 也能跑内存版:
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 3000ms
openmanus:
memory:
redis-enabled: ${REDIS_MEMORY_ENABLED:false}
conversation-ttl-days: 7
max-messages: 100
这里有个细节:max-messages 控制上下文窗口的消息条数,不是 token 数。OpenManus 原版也是按条数截断的,简单粗暴但有效。你后面接 LLM 时,如果发现上下文还是超了,再在 LLMService 层做 token 级裁剪。前置准备就这些,接下来直接进 Message 建模。
3. 可复制配置:Message 实体类与 Memory 接口完整实现
3.1 Message 模型:为什么用 @Data + @Builder 而不是 Record
Java 14+ 的 Record 确实简洁,但 Message 这个类有几个特殊需求:第一,它需要 Builder 模式,因为工具调用消息的构造参数比较多;第二,它需要可变性,某些场景下要修改 content;第三,Jackson 序列化时要忽略 null 字段,Record 对 @JsonInclude 的支持不够灵活;第四,需要工厂方法比如 userMessage()、systemMessage()。所以最终选 Lombok 的 @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor。
角色用枚举而不是字符串,这是类型安全的基本要求。你写 Role.USER 编译期就能检查,写 "user" 拼错了要到运行时才报错。枚举里用 @JsonValue 标注 getValue(),这样 Jackson 序列化出来就是小写字符串,跟 OpenAI API 要求一致。
完整 Message 类放在 com.openmanus.domain.memory 包下:
package com.openmanus.domain.memory;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Message {
public enum Role {
SYSTEM("system"),
USER("user"),
ASSISTANT("assistant"),
TOOL("tool");
private final String value;
Role(String value) {
this.value = value;
}
@JsonValue
public String getValue() {
return value;
}
}
private Role role;
private String content;
private List<ToolCall> toolCalls;
private String name;
private String toolCallId;
private String base64Image;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ToolCall {
private String id;
private String type;
private Function function;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Function {
private String name;
private String arguments;
}
}
public static Message userMessage(String content, String base64Image) {
return Message.builder()
.role(Role.USER)
.content(content)
.base64Image(base64Image)
.build();
}
public static Message userMessage(String content) {
return userMessage(content, null);
}
public static Message systemMessage(String content) {
return Message.builder()
.role(Role.SYSTEM)
.content(content)
.build();
}
public static Message assistantMessage(String content, String base64Image) {
return Message.builder()
.role(Role.ASSISTANT)
.content(content)
.base64Image(base64Image)
.build();
}
public static Message assistantMessage(String content) {
return assistantMessage(content, null);
}
public static Message fromToolCalls(String content, List<ToolCall> toolCalls) {
return Message.builder()
.role(Role.ASSISTANT)
.content(content)
.toolCalls(toolCalls)
.build();
}
public static Message toolMessage(String content, String name, String toolCallId, String base64Image) {
return Message.builder()
.role(Role.TOOL)
.content(content)
.name(name)
.toolCallId(toolCallId)
.base64Image(base64Image)
.build();
}
public static Message toolMessage(String content, String name, String toolCallId) {
return toolMessage(content, name, toolCallId, null);
}
public Map<String, Object> toMap() {
return Map.of(
"role", role.getValue(),
"content", content != null ? content : ""
);
}
}
注意 toMap() 这里只做了基础实现,完整的 LLM API 调用转换会在 LLMService 里处理,因为工具调用消息的格式更复杂。@JsonInclude(NON_NULL) 保证序列化时 null 字段不出现,这样 Redis 里存的 JSON 更干净。
3.2 Memory 接口定义
Memory 接口要提供基本 CRUD、批量添加、获取最近 N 条、最大消息数限制。接口放在 domain 层,实现可以放 infrastructure 层,这样依赖方向是对的:
package com.openmanus.domain.memory;
import java.util.List;
public interface Memory {
void addMessage(Message message);
void addMessages(List<Message> messages);
List<Message> getMessages();
void clear();
List<Message> getRecentMessages(int n);
int getMaxMessages();
void setMaxMessages(int maxMessages);
}
3.3 MemoryImpl 内存实现
内存版用 ArrayList,不用 CopyOnWriteArrayList,因为 Agent 运行在单线程里,ArrayList 性能更好。如果未来要多线程,再换也不迟。关键点是 getMessages() 返回副本,防止外部修改内部状态:
package com.openmanus.domain.memory;
import lombok.Getter;
import lombok.Setter;
import java.util.ArrayList;
import java.util.List;
@Getter
@Setter
public class MemoryImpl implements Memory {
private List<Message> messages;
private int maxMessages;
public MemoryImpl() {
this.messages = new ArrayList<>();
this.maxMessages = 100;
}
public MemoryImpl(int maxMessages) {
this.messages = new ArrayList<>();
this.maxMessages = maxMessages;
}
@Override
public void addMessage(Message message) {
if (message == null) {
return;
}
messages.add(message);
if (messages.size() > maxMessages) {
messages = new ArrayList<>(messages.subList(
messages.size() - maxMessages, messages.size()));
}
}
@Override
public void addMessages(List<Message> messages) {
if (messages == null || messages.isEmpty()) {
return;
}
this.messages.addAll(messages);
if (this.messages.size() > maxMessages) {
this.messages = new ArrayList<>(this.messages.subList(
this.messages.size() - maxMessages, this.messages.size()));
}
}
@Override
public List<Message> getMessages() {
return new ArrayList<>(messages);
}
@Override
public void clear() {
messages.clear();
}
@Override
public List<Message> getRecentMessages(int n) {
if (n <= 0 || messages.isEmpty()) {
return new ArrayList<>();
}
int start = Math.max(0, messages.size() - n);
return new ArrayList<>(messages.subList(start, messages.size()));
}
}
3.4 Redis 记忆实现:ConversationService 与 ConversationMemory
生产环境需要持久化、多实例共享、自动过期,所以加 Redis 实现。先写 ConversationService,负责对话历史的读写:
package com.openmanus.infrastructure.memory;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.openmanus.domain.memory.Message;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Slf4j
@Service
public class ConversationService {
private static final String CONVERSATION_PREFIX = "conversation:";
private static final String CURRENT_CONVERSATION_PREFIX = "user:current_conversation:";
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ObjectMapper objectMapper;
@Value("${openmanus.memory.conversation-ttl-days:7}")
private int conversationTtlDays;
public String getOrCreateConversationId(String userId) {
String key = CURRENT_CONVERSATION_PREFIX + userId;
String conversationId = redisTemplate.opsForValue().get(key);
if (conversationId == null || conversationId.isEmpty()) {
conversationId = UUID.randomUUID().toString();
redisTemplate.opsForValue().set(key, conversationId,
Duration.ofDays(conversationTtlDays));
}
return conversationId;
}
public List<Message> getConversationHistory(String conversationId) {
try {
String key = CONVERSATION_PREFIX + conversationId;
String historyJson = redisTemplate.opsForValue().get(key);
if (historyJson == null || historyJson.isEmpty()) {
return new ArrayList<>();
}
return objectMapper.readValue(historyJson,
new TypeReference<List<Message>>() {});
} catch (Exception e) {
log.error("Failed to get conversation history: " + conversationId, e);
return new ArrayList<>();
}
}
public void updateConversationHistory(String conversationId,
List<Message> messages, int maxMessages) {
try {
List<Message> history = getConversationHistory(conversationId);
history.addAll(messages);
if (history.size() > maxMessages) {
history = new ArrayList<>(history.subList(
history.size() - maxMessages, history.size()));
}
String key = CONVERSATION_PREFIX + conversationId;
String historyJson = objectMapper.writeValueAsString(history);
redisTemplate.opsForValue().set(key, historyJson,
Duration.ofDays(conversationTtlDays));
} catch (Exception e) {
log.error("Failed to update conversation history: " + conversationId, e);
}
}
public void clearConversationHistory(String conversationId) {
redisTemplate.delete(CONVERSATION_PREFIX + conversationId);
}
public void clearCurrentConversation(String userId) {
redisTemplate.delete(CURRENT_CONVERSATION_PREFIX + userId);
}
}
ConversationMemory 实现 Memory 接口,conversationId 作为参数传入而不是成员变量固定,这样同一个实例可以服务不同对话。它带本地缓存减少 Redis 访问:
package com.openmanus.infrastructure.memory;
import com.openmanus.domain.memory.Memory;
import com.openmanus.domain.memory.Message;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
@Slf4j
public class ConversationMemory implements Memory {
private final ConversationService conversationService;
private int maxMessages;
private String conversationId;
private List<Message> localCache;
private boolean cacheDirty = false;
private boolean cacheInitialized = false;
public ConversationMemory(ConversationService conversationService,
String conversationId, int maxMessages) {
this.conversationService = conversationService;
this.conversationId = conversationId;
this.maxMessages = maxMessages;
this.localCache = new ArrayList<>();
}
public void setConversationId(String conversationId) {
if (this.conversationId != null && !this.conversationId.equals(conversationId)) {
saveToRedis();
}
this.conversationId = conversationId;
this.cacheInitialized = false;
this.localCache.clear();
this.cacheDirty = false;
}
private void loadFromRedis() {
if (conversationService != null && conversationId != null && !cacheInitialized) {
List<Message> messages = conversationService.getConversationHistory(conversationId);
this.localCache = new ArrayList<>(messages);
this.cacheDirty = false;
this.cacheInitialized = true;
}
}
private void saveToRedis() {
if (conversationService != null && conversationId != null && cacheDirty) {
conversationService.updateConversationHistory(conversationId, localCache, maxMessages);
this.cacheDirty = false;
}
}
@Override
public void addMessage(Message message) {
if (message == null) {
return;
}
if (conversationId != null && !cacheInitialized) {
loadFromRedis();
}
localCache.add(message);
if (localCache.size() > maxMessages) {
localCache = new ArrayList<>(localCache.subList(
localCache.size() - maxMessages, localCache.size()));
}
cacheDirty = true;
if (conversationId != null) {
saveToRedis();
}
}
@Override
public void addMessages(List<Message> messages) {
if (messages == null || messages.isEmpty()) {
return;
}
if (conversationId != null && !cacheInitialized) {
loadFromRedis();
}
localCache.addAll(messages);
if (localCache.size() > maxMessages) {
localCache = new ArrayList<>(localCache.subList(
localCache.size() - maxMessages, localCache.size()));
}
cacheDirty = true;
if (conversationId != null) {
saveToRedis();
}
}
@Override
public List<Message> getMessages() {
if (conversationId != null && !cacheInitialized) {
loadFromRedis();
}
if (cacheDirty && conversationId != null) {
saveToRedis();
}
return new ArrayList<>(localCache);
}
@Override
public void clear() {
localCache.clear();
cacheDirty = true;
if (conversationService != null && conversationId != null) {
conversationService.clearConversationHistory(conversationId);
cacheDirty = false;
cacheInitialized = false;
}
}
@Override
public List<Message> getRecentMessages(int n) {
if (n <= 0) {
return new ArrayList<>();
}
if (conversationId != null && !cacheInitialized) {
loadFromRedis();
}
if (localCache.isEmpty()) {
return new ArrayList<>();
}
if (cacheDirty && conversationId != null) {
saveToRedis();
}
int start = Math.max(0, localCache.size() - n);
return new ArrayList<>(localCache.subList(start, localCache.size()));
}
@Override
public int getMaxMessages() {
return maxMessages;
}
@Override
public void setMaxMessages(int maxMessages) {
this.maxMessages = maxMessages;
if (localCache.size() > maxMessages) {
localCache = new ArrayList<>(localCache.subList(
localCache.size() - maxMessages, localCache.size()));
cacheDirty = true;
if (conversationId != null) {
saveToRedis();
}
}
}
}
MemoryFactory 根据配置决定创建哪种实现:
package com.openmanus.infrastructure.memory;
import com.openmanus.domain.memory.Memory;
import com.openmanus.domain.memory.MemoryImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class MemoryFactory {
@Autowired(required = false)
private ConversationService conversationService;
@Value("${openmanus.memory.max-messages:100}")
private int maxMessages;
@Value("${openmanus.memory.redis-enabled:false}")
private boolean redisEnabled;
public Memory createMemory(String conversationId) {
if (redisEnabled && conversationService != null) {
return new ConversationMemory(conversationService, conversationId, maxMessages);
}
return new MemoryImpl(maxMessages);
}
public Memory createMemoryForUser(String userId) {
if (redisEnabled && conversationService != null) {
String conversationId = conversationService.getOrCreateConversationId(userId);
return new ConversationMemory(conversationService, conversationId, maxMessages);
}
return new MemoryImpl(maxMessages);
}
}
两种实现的对比很清晰:MemoryImpl 存内存、不持久化、不支持多实例、性能快,适合开发和单实例;ConversationMemory 存 Redis、持久化、支持多实例、自动过期、性能稍慢,适合生产。你按场景选就行。
4. 验证请求:单元测试与 Redis 存取实测
写完代码不验证等于没写。先写单元测试覆盖 Message 工厂方法和 Memory 截断逻辑。测试类放在 src/test/java/com/openmanus/domain/memory 下:
package com.openmanus.domain.memory;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class MessageMemoryTest {
@Test
void testUserMessageFactory() {
Message msg = Message.userMessage("你好");
assertEquals(Message.Role.USER, msg.getRole());
assertEquals("你好", msg.getContent());
assertNull(msg.getBase64Image());
}
@Test
void testToolMessageFactory() {
Message msg = Message.toolMessage("结果", "search", "call_123");
assertEquals(Message.Role.TOOL, msg.getRole());
assertEquals("search", msg.getName());
assertEquals("call_123", msg.getToolCallId());
}
@Test
void testMemoryTruncation() {
MemoryImpl memory = new MemoryImpl(3);
memory.addMessage(Message.userMessage("1"));
memory.addMessage(Message.userMessage("2"));
memory.addMessage(Message.userMessage("3"));
memory.addMessage(Message.userMessage("4"));
List<Message> messages = memory.getMessages();
assertEquals(3, messages.size());
assertEquals("2", messages.get(0).getContent());
assertEquals("4", messages.get(2).getContent());
}
@Test
void testGetRecentMessages() {
MemoryImpl memory = new MemoryImpl(10);
for (int i = 1; i <= 5; i++) {
memory.addMessage(Message.userMessage(String.valueOf(i)));
}
List<Message> recent = memory.getRecentMessages(2);
assertEquals(2, recent.size());
assertEquals("4", recent.get(0).getContent());
assertEquals("5", recent.get(1).getContent());
}
@Test
void testGetMessagesReturnsCopy() {
MemoryImpl memory = new MemoryImpl(10);
memory.addMessage(Message.userMessage("原始"));
List<Message> copy = memory.getMessages();
copy.clear();
assertEquals(1, memory.getMessages().size());
}
}
跑 mvn test,五个测试全绿说明 Message 工厂和 Memory 截断逻辑没问题。接下来验证 Redis 存取。把 application.yml 里 redis-enabled 改成 true,启动类里加一行日志:
@Slf4j
@SpringBootApplication
@EnableConfigurationProperties(OpenManusConfig.class)
public class OpenManusApplication implements CommandLineRunner {
@Autowired
private MemoryFactory memoryFactory;
public static void main(String[] args) {
SpringApplication.run(OpenManusApplication.class, args);
}
@Override
public void run(String... args) throws Exception {
Memory memory = memoryFactory.createMemoryForUser("testuser123");
memory.addMessage(Message.userMessage("第一条消息"));
memory.addMessage(Message.assistantMessage("收到"));
log.info("当前消息数: {}", memory.getMessages().size());
}
}
启动后看日志,应该输出 当前消息数: 2。然后去 Redis 里查:
redis-cli
> keys user:current_conversation:*
1) "user:current_conversation:testuser123"
> get user:current_conversation:testuser123
"a1b2c3d4-..."
> keys conversation:*
1) "conversation:a1b2c3d4-..."
> get conversation:a1b2c3d4-...
"[{\"role\":\"user\",\"content\":\"第一条消息\"},{\"role\":\"assistant\",\"content\":\"收到\"}]"
看到 JSON 里 role 是小写、content 正常,说明 Jackson 序列化和 @JsonValue 都生效了。再重启应用,调用 getMessages(),应该能从 Redis 加载回两条消息,证明持久化没问题。这一步验证通过,你的领域模型就算立住了。
5. 本篇常见错排查:401、local proxy failed、reading choices 与 OAuth 报错
接入过程中最容易撞的几个报错,我按真实日志给你对照排查。
第一个是 401 Unauthorized。如果你在 LLMService 里调 TaoToken API 返回 401,先检查 API Key 有没有正确注入。常见原因是环境变量名写错,比如代码里读 TAOTOKEN_API_KEY,但你 export 的是 TAOTOKEN_KEY。另一个原因是 Key 前后有空格,复制的时候带上了换行。排查命令:
echo $TAOTOKEN_API_KEY | head -c 10
确认前缀是 sk- 且没有多余字符。如果 Key 没问题还是 401,去控制台确认这个 Key 有没有被禁用或额度耗尽。
第二个是 local proxy failed 或连接超时。这个报错通常出现在你本地网络环境有额外代理设置时。检查你的 JVM 启动参数有没有 -Dhttp.proxyHost 之类的配置,有的话去掉。另外确认 TAOTOKEN_BASE_URL 写的是 https://taotoken.net/api,不要多加路径或者少写 /api。如果你用 LangChain4j,Base URL 配置项名字可能是 baseUrl 而不是 apiBase,看具体版本。
第三个是 reading choices 相关报错,完整信息类似 Cannot deserialize value of type ... from Array value (token JsonToken.START_ARRAY) 或者 reading choices 字段解析失败。这是因为 LLM 返回的 JSON 结构和你的响应类对不上。OpenAI 兼容接口的响应里 choices 是数组,每个元素有 message 字段。如果你的响应类把 choices 定义成了对象而不是 List,就会报这个。检查你的 ChatCompletionResponse 类:
public class ChatCompletionResponse {
private List<Choice> choices;
public static class Choice {
private Message message;
private String finishReason;
}
}
第四个是 OAuth 相关报错,比如 OAuth token exchange failed 或 invalid_grant。如果你用的是 Claude Code 或者某些需要 OAuth 的客户端,注意 TaoToken 的 API Key 模式不需要走 OAuth 流程。在 Claude Code 里配置时,Base URL 填 https://taotoken.net/api,API Key 填你的 sk- 开头的 Key,Model ID 填你在模型对话页面选好的那个。三件套缺一不可,少一个就会报认证失败。如果你用 CC Switch 或 Cline MCP,同样确保 Base URL、Key、Model ID 三项都填对。
还有一个隐蔽的坑:Redis 里存的消息反序列化失败。报错类似 Cannot construct instance of Message。这是因为 Message 类缺少无参构造函数,Jackson 反序列化时需要它。检查你有没有加 @NoArgsConstructor。另外 Role 枚举的反序列化,Jackson 默认按枚举名匹配,但 Redis 里存的是小写 "user",所以要么加 @JsonCreator 静态方法,要么确保 @JsonValue 和反序列化对称。最稳妥的方式是给 Role 加一个 @JsonCreator:
@JsonCreator
public static Role fromValue(String value) {
for (Role role : values()) {
if (role.value.equals(value)) {
return role;
}
}
throw new IllegalArgumentException("Unknown role: " + value);
}
加上这段,Redis 存取就不会再报枚举解析错了。这几个错排查完,你的消息流转和记忆存取基本就稳了。
6. 下一步:接入 LLM 服务与 Coding Plan 长期验证
领域模型立住之后,下一步就是接 LLM 服务封装,用 LangChain4j 把 Message 列表转成 API 请求,把响应转回 Message 存进 Memory。这一步的验证建议用真实模型跑几轮多轮对话,观察上下文截断是否符合预期。如果你要长期跑编码类 Agent,可以用 Coding Plan 做持续验证,地址是 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,它针对代码场景的模型做了优化,适合反复调试工具调用链。
API Key 管理和接入文档在这里:API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。模型对话测试页面还是 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite ,换模型对比响应速度很方便。
最后留一个实用技巧:Memory 的 maxMessages 不要设太大,100 条消息如果每条平均 200 token,上下文就 2 万 token 了,很多模型扛不住。我一般设 50 条,然后在 LLMService 层再做一次 token 级裁剪。另外 ConversationMemory 的本地缓存和 Redis 之间有一致性窗口,多实例部署时如果对一致性要求高,可以把 saveToRedis() 改成每次读写都同步,代价是性能下降。单实例场景下当前实现够用。



被折叠的 条评论
为什么被折叠?



