一、问题背景

最近在重构一个 AI 个人记账助手项目时,我希望保留原项目中的 Agent 思路:

用户输入自然语言
↓
AgentController 接收请求
↓
AgentService 调度
↓
AgentPlanService 调用大模型解析用户意图
↓
AIService 返回 JSON
↓
ObjectMapper 转成 AgentPlan
↓
后续再进行意图判断和业务分发

其中,AIService 负责调用大模型,AgentPlanService 负责把大模型返回的 JSON 字符串转换成后端可用的 AgentPlan 对象。

最开始的核心代码如下:

String json = this.aiService.plan(sessionId, message);
System.out.println("AI Agent 计划结果:" + json);

json = cleanJson(json);

return this.objectMapper.readValue(json, AgentPlan.class);

但是测试时发现,代码执行到这一行:

String json = this.aiService.plan(sessionId, message);

就直接进入异常流程,后面的打印语句根本没有执行。

这说明问题并不是出在:

cleanJson()
ObjectMapper.readValue()
AgentPlan 字段映射

而是出在:

AIService.plan() 调用阶段

也就是 LangChain4j 代理对象调用大模型之前,就已经发生了异常。


二、接口设计

当时的 AIService 接口大致如下:

package com.test.service;

import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;

public interface AIService {

    @SystemMessage(fromResource = "agent-plan-prompt.txt")
    String plan(@MemoryId String sessionId, @UserMessage String message);

    String chat(@UserMessage String message);
}

这里使用了:

@MemoryId String sessionId

它的作用是让 LangChain4j 根据不同的 sessionId 区分不同会话的上下文记忆。

例如:

session_001 -> 一份聊天记忆
session_002 -> 另一份聊天记忆
session_003 -> 另一份聊天记忆

也就是说,@MemoryId 并不是普通参数注解,而是告诉 LangChain4j:

请根据这个参数的值,去获取对应的 ChatMemory。

三、错误配置写法

最开始我的配置类是这样写的:

package com.test.config;

import com.test.service.AIService;
import dev.langchain4j.memory.ChatMemory;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.service.AiServices;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AgentServiceFactory {

    @Bean
    public AIService aiService(ChatModel chatModel) {
        ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);

        return AiServices.builder(AIService.class)
                .chatModel(chatModel)
                .chatMemory(chatMemory)
                .build();
    }
}

看起来这段代码没有问题:

ChatMemory chatMemory = MessageWindowChatMemory.withMaxMessages(10);

意思是创建一个最多保留 10 条消息的会话记忆窗口。

但是问题出在:

.chatMemory(chatMemory)

这个配置表示:

整个 AIService 共用同一份固定 ChatMemory。

而我在 AIService 方法里又使用了:

@MemoryId String sessionId

这表示:

我希望不同 sessionId 使用不同 ChatMemory。

这两个思路是不一致的。


四、报错现象

取消 try-catch 后,接口返回:

{
  "timestamp": "2026-05-29T02:44:54.978+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/agent/chat"
}

控制台日志中关键报错位置是:

java.lang.NullPointerException
at dev.langchain4j.service.memory.ChatMemoryService.getOrCreateChatMemory
at dev.langchain4j.service.DefaultAiServices$1.invoke
at jdk.proxy2.$Proxy80.plan
at com.test.service.impl.AgentPlanServiceImpl.parsePlan

这说明:

LangChain4j 在执行 AIService.plan() 时,
尝试根据 @MemoryId 获取或创建 ChatMemory,
但是由于配置不匹配,最终出现了空指针异常。

所以问题不是:

AI 额度不足
API Key 错误
提示词文件找不到
ObjectMapper 解析失败

而是:

@MemoryId 和 ChatMemory 配置方式不匹配。

五、chatMemory 和 chatMemoryProvider 的区别

1. chatMemory

.chatMemory(chatMemory)

表示给整个 AIService 配置一份固定记忆。

可以理解为:

所有用户、所有 sessionId 共用同一个聊天记录本。

这种方式适合简单单会话场景,例如:

String chat(@UserMessage String message);

也就是没有使用 @MemoryId 的情况。


2. chatMemoryProvider

.chatMemoryProvider(memoryId -> ...)

表示根据不同的 memoryId 动态提供不同的记忆对象。

可以理解为:

每个 sessionId 都有自己的聊天记录本。

例如:

session_001 -> ChatMemory A
session_002 -> ChatMemory B
session_003 -> ChatMemory C

这种方式适合多用户、多会话场景。

只要接口里使用了:

@MemoryId String sessionId

就应该优先使用:

.chatMemoryProvider(...)

而不是:

.chatMemory(...)

六、正确配置方式

修改后的配置类如下:

package com.test.config;

import com.test.service.AIService;
import dev.langchain4j.memory.ChatMemory;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.service.AiServices;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Configuration
public class AgentServiceFactory {

    private final Map<Object, ChatMemory> memoryStore = new ConcurrentHashMap<>();

    @Bean
    public AIService aiService(ChatModel chatModel) {
        return AiServices.builder(AIService.class)
                .chatModel(chatModel)
                .chatMemoryProvider(memoryId ->
                        memoryStore.computeIfAbsent(
                                memoryId,
                                id -> MessageWindowChatMemory.withMaxMessages(10)
                        )
                )
                .build();
    }
}

这段代码的核心是:

private final Map<Object, ChatMemory> memoryStore = new ConcurrentHashMap<>();

它用于保存不同会话的记忆。

例如:

session_test_backend_001 -> ChatMemory
session_web_001          -> ChatMemory
session_mobile_001       -> ChatMemory

然后通过:

memoryStore.computeIfAbsent(
        memoryId,
        id -> MessageWindowChatMemory.withMaxMessages(10)
)

实现:

如果当前 sessionId 已经有 ChatMemory,就直接复用;
如果没有,就创建一份新的 ChatMemory。

这样就能保证:

同一个 sessionId 能共享上下文;
不同 sessionId 之间互不干扰。

七、AIService 推荐写法

为了让计划解析和普通聊天都支持会话记忆,可以把 AIService 统一写成:

package com.test.service;

import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;

public interface AIService {

    @SystemMessage(fromResource = "agent-plan-prompt.txt")
    String plan(@MemoryId String sessionId, @UserMessage String message);

    String chat(@MemoryId String sessionId, @UserMessage String message);
}

其中:

@SystemMessage(fromResource = "agent-plan-prompt.txt")

表示读取 resources 目录下的提示词文件。

文件路径应该是:

src/main/resources/agent-plan-prompt.txt

plan() 方法负责让大模型返回结构化 JSON,例如:

{
  "intent": "RECORD_EXPENSE",
  "amount": 18,
  "category": "饮品",
  "description": "奶茶",
  "valid": true,
  "reason": ""
}

八、AgentPlanService 中的调用逻辑

修复记忆配置后,AgentPlanServiceImpl 可以继续保持如下逻辑:

package com.test.service.impl;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.test.dto.AgentPlan;
import com.test.service.AIService;
import com.test.service.AgentPlanService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class AgentPlanServiceImpl implements AgentPlanService {

    @Autowired
    private AIService aiService;

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public AgentPlan parsePlan(String sessionId, String message) {
        try {
            System.out.println("准备调用 AI,sessionId=" + sessionId + ",message=" + message);

            String json = this.aiService.plan(sessionId, message);

            System.out.println("AI Agent 计划结果:" + json);

            json = cleanJson(json);

            return this.objectMapper.readValue(json, AgentPlan.class);
        } catch (Exception e) {
            e.printStackTrace();

            AgentPlan plan = new AgentPlan();
            plan.setIntent(null);
            plan.setValid(false);
            plan.setReason("AI解析失败:" + e.getClass().getSimpleName() + ":" + e.getMessage());
            return plan;
        }
    }

    private String cleanJson(String json) {
        if (json == null) {
            return "{}";
        }

        json = json.trim();
        json = json.replace("```json", "");
        json = json.replace("```", "");
        json = json.trim();

        int start = json.indexOf("{");
        int end = json.lastIndexOf("}");

        if (start >= 0 && end >= 0 && end > start) {
            return json.substring(start, end + 1);
        }

        return json;
    }
}

这里要注意:

String json = this.aiService.plan(sessionId, message);

这一步如果成功,说明 LangChain4j 已经成功调用大模型。

后续才会进入:

cleanJson(json);
objectMapper.readValue(json, AgentPlan.class);

所以排查问题时要先判断异常发生在哪一行。


九、排查思路总结

本次问题的排查顺序如下:

1. 接口返回 500
2. 取消 try-catch,查看控制台完整异常
3. 发现异常发生在 aiService.plan()
4. 说明还没有拿到 AI 返回值
5. 排除 cleanJson 和 ObjectMapper 问题
6. 查看堆栈,发现 ChatMemoryService.getOrCreateChatMemory 报空指针
7. 判断是 @MemoryId 和 ChatMemory 配置方式不匹配
8. 将 .chatMemory(chatMemory) 改为 .chatMemoryProvider(...)
9. 问题解决

核心判断点是:

如果 System.out.println("AI Agent 计划结果:" + json) 没有执行,
说明问题发生在 AIService.plan() 调用阶段。

如果这句打印了,但 ObjectMapper 报错,
才说明问题发生在 JSON 格式或字段映射阶段。

十、最终结论

在 LangChain4j 中:

不使用 @MemoryId,可以使用 .chatMemory(chatMemory)
使用 @MemoryId,应该使用 .chatMemoryProvider(...)

两者的区别是:

.chatMemory(chatMemory)
表示整个 AIService 共用一份固定记忆。

.chatMemoryProvider(...)
表示根据 memoryId 动态获取或创建不同会话记忆。

本项目中前端请求包含:

{
  "userId": 1,
  "sessionId": "session_test_backend_001",
  "message": "今天奶茶18"
}

因此后端 AIService 使用了:

@MemoryId String sessionId

这时配置类就应该使用:

.chatMemoryProvider(...)

否则 LangChain4j 在根据 sessionId 获取会话记忆时,可能会出现空指针异常。

本质上,这次问题不是大模型本身的问题,而是:

AIService 方法注解设计和 AiServices 配置方式没有匹配。

修复后,Agent 调用链路可以正常进入:

AIService.plan()
↓
大模型返回 JSON
↓
cleanJson()
↓
ObjectMapper 转 AgentPlan
↓
AgentService 后续调度
Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐