导读:大模型本身是无状态的,每次调用都是独立的请求-响应。要让 AI 具备"记住上文"的能力,就需要记忆管理;要让 AI 具备"自主决策"的能力,就需要 Agent 执行循环。本文从 LangChain4j 的 ChatMemory 出发,依次讲解内存记忆、持久化记忆与多用户会话隔离,再深入到 ReAct 执行循环、多步推理 Agent,最后梳理生产上线前的必备检查清单。读完本文,你将具备用 LangChain4j 构建一套完整 AI Agent 系统的能力。


一、ChatMemory:为什么 AI 会"失忆"?

1.1 问题场景

用户问:"上一条消息里提到的 JDK 21 虚拟线程,具体特性是什么?"没有记忆的 AI 会一脸茫然地回答:"我没提过。"这在实际项目中非常常见——用户投诉 AI 太笨,很多时候就是因为没有做记忆管理。

1.2 核心原理

大模型每次调用都是无状态的,它不知道上次说了什么。要实现多轮对话,本质就是把历史对话一起发给模型。ChatMemory 的作用就是自动管理这些历史消息列表,包括:

  • 自动追加:每轮对话的用户消息和 AI 回复自动加入历史
  • 自动截断:超过窗口大小时自动丢弃最老的消息
  • 自动保护:System Message 始终保留,不会被截断挤掉

1.3 两种窗口策略

LangChain4j 提供了两种开箱即用的记忆策略:

MessageWindowChatMemory:基于消息条数

ChatMemory memory = MessageWindowChatMemory.withMaxMessages(20);

保留最近 N 条消息,超过就删除最老的一条。适用于消息长度比较均匀的场景,开发快速原型时优先选择。

TokenWindowChatMemory:基于 Token 数量

ChatMemory memory = TokenWindowChatMemory.builder()
    .maxTokens(4096)
    .build();

保留最近不超过指定 Token 数的消息。适用于消息长度差异大的场景,需要精确控制上下文窗口时使用。

选型建议

场景 推荐策略
消息长度均匀 MessageWindow
消息长度差异大 TokenWindow
需要严格控制上下文 TokenWindow
快速原型开发 MessageWindow

1.4 会话隔离:@MemoryId

如果所有用户共用一个 ChatMemory,你刚问完的问题,别人再问时就会"串台"——这显然不行。LangChain4j 通过 @MemoryId 注解实现会话隔离。项目中的 ChatAssistant 接口(com.jichi.langchain4j.service.chatMemory.ChatAssistant)就是这样定义的:

public interface ChatAssistant {
    @SystemMessage("你是一个 Java 技术助手,记住用户在对话中提到的技术栈和问题背景")
    String chat(@MemoryId String sessionId, @UserMessage String message);
}

只要 sessionId 不同,对话历史就是完全隔离的。配合 ChatMemoryProvider 使用,项目中的 ChatMemoryConfigcom.jichi.langchain4j.config.ChatMemoryConfig)展示了最简配置:

@Configuration
public class ChatMemoryConfig {

    // 提供全局 ChatMemoryProvider,所有 @AiService 使用 @MemoryId 时都会用到
    @Bean
    public ChatMemoryProvider chatMemoryProvider() {
        return memoryId -> MessageWindowChatMemory.withMaxMessages(10);
    }
}

它会根据不同的 memoryId 自动创建独立的会话,维护各自的历史消息。

1.5 System Message 的安全性

有人担心 System Message 会不会随着对话越来越多被挤掉。放心,LangChain4j 做了特殊处理:

  • System Message 始终只保留一条,不会累积
  • 窗口满了时只会丢弃 User 或 AI 的消息,System Message 永远不丢

二、持久化记忆:对接数据库与多用户隔离

2.1 为什么需要持久化?

上面的 ChatMemory 默认存在内存里。服务重启、宕机后,所有对话记录全部丢失。要在生产环境中使用,必须把记忆持久化到数据库。

2.2 实现 ChatMemoryStore 接口

LangChain4j 默认使用 InMemoryChatMemoryStore,我们只需实现 ChatMemoryStore 接口即可切换到数据库存储:

public interface ChatMemoryStore {
    List<ChatMessage> getMessages(Object memoryId);
    void updateMessages(Object memoryId, List<ChatMessage> messages);
    void deleteMessages(Object memoryId);
}

三个方法职责清晰:获取消息、更新消息、删除消息。

2.3 JPA 实体与表设计

项目中使用 ChatMessageEntitycom.jichi.langchain4j.model.ChatMessageEntity)作为 JPA 实体,对应 chat_message 表:

@Entity
@Table(name = "chat_message")
@Data
@NoArgsConstructor
public class ChatMessageEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String sessionId;

    @Column(nullable = false)
    private String role;        // SYSTEM / USER / AI / TOOL

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    private String toolName;    // 工具调用消息时的工具名

    @CreationTimestamp
    private LocalDateTime createdAt;
}

核心字段:sessionId 用于会话隔离,role 标识消息来源(SYSTEM、USER、AI、TOOL),content 存储消息内容,toolName 记录工具调用信息。

2.4 Repository 层

ChatMessageRepositorycom.jichi.langchain4j.repository.ChatMessageRepository)定义按 sessionId 查询、删除、以及按前缀查询的方法:

@Repository
public interface ChatMessageRepository extends JpaRepository<ChatMessageEntity, Long> {

    List<ChatMessageEntity> findBySessionIdOrderByCreatedAtAsc(String sessionId);

    void deleteBySessionId(String sessionId);

    // 查询以指定前缀开头的所有不重复 sessionId(用于按用户查历史会话)
    @Query("SELECT DISTINCT e.sessionId FROM ChatMessageEntity e WHERE e.sessionId LIKE :prefix%")
    List<String> findDistinctSessionIdsByPrefix(@Param("prefix") String prefix);
}

2.5 JPA 实现 ChatMemoryStore

项目中的 JpaChatMemoryStorecom.jichi.langchain4j.memory.JpaChatMemoryStore)是持久化的核心实现:

@Component
public class JpaChatMemoryStore implements ChatMemoryStore {

    private final ChatMessageRepository repository;

    public JpaChatMemoryStore(ChatMessageRepository repository) {
        this.repository = repository;
    }

    @Override
    public List<ChatMessage> getMessages(Object memoryId) {
        return repository.findBySessionIdOrderByCreatedAtAsc(memoryId.toString())
                .stream()
                .map(this::toMessage)
                .filter(Objects::nonNull)
                .toList();
    }

    @Override
    @Transactional
    public void updateMessages(Object memoryId, List<ChatMessage> messages) {
        // 全量替换:先删后插
        // 生产环境可以优化为增量更新(只追加新消息),减少写放大
        repository.deleteBySessionId(memoryId.toString());

        List<ChatMessageEntity> entities = messages.stream()
                .map(msg -> toEntity(memoryId.toString(), msg))
                .toList();

        repository.saveAll(entities);
    }

    @Override
    @Transactional
    public void deleteMessages(Object memoryId) {
        repository.deleteBySessionId(memoryId.toString());
    }

    private ChatMessageEntity toEntity(String sessionId, ChatMessage message) {
        ChatMessageEntity entity = new ChatMessageEntity();
        entity.setSessionId(sessionId);

        if (message instanceof SystemMessage m) {
            entity.setRole("SYSTEM");
            entity.setContent(m.text());
        } else if (message instanceof UserMessage m) {
            entity.setRole("USER");
            entity.setContent(m.singleText());
        } else if (message instanceof AiMessage m) {
            entity.setRole("AI");
            entity.setContent(m.text() != null ? m.text() : "");
        } else if (message instanceof ToolExecutionResultMessage m) {
            entity.setRole("TOOL");
            entity.setContent(m.text());
            entity.setToolName(m.toolName());
        }

        return entity;
    }

    private ChatMessage toMessage(ChatMessageEntity entity) {
        return switch (entity.getRole()) {
            case "SYSTEM" -> new SystemMessage(entity.getContent());
            case "USER" -> new UserMessage(entity.getContent());
            case "AI" -> new AiMessage(entity.getContent());
            case "TOOL" -> new ToolExecutionResultMessage(
                    entity.getToolName(), entity.getToolName(), entity.getContent());
            default -> null;
        };
    }
}

这里有几个关键设计:toEntity 方法处理了 LangChain4j 的四种消息类型(SystemMessage、UserMessage、AiMessage、ToolExecutionResultMessage),toMessage 方法使用 Java 17 的 switch 表达式做反向转换。updateMessages 采用"先删后插"的全量替换策略,生产环境可优化为增量更新。

2.6 绑定到 ChatMemoryProvider

PersistentMemoryConfigcom.jichi.langchain4j.config.PersistentMemoryConfig)展示了如何把 JPA Store 绑定到 AI Service:

@Configuration
public class PersistentMemoryConfig {

    @Bean
    public PersistentChatAssistant chatAssistant(ChatModel model, JpaChatMemoryStore memoryStore) {
        return AiServices.builder(PersistentChatAssistant.class)
                .chatModel(model)
                .chatMemoryProvider(memoryId ->
                        MessageWindowChatMemory.builder()
                                .id(memoryId)
                                .maxMessages(20)
                                .chatMemoryStore(memoryStore)  // 关键:绑定持久化 Store
                                .build())
                .build();
    }
}

只需在 chatMemoryStore() 处绑定自定义的 JpaChatMemoryStore,所有聊天记录就会自动持久化到数据库。即使服务重启,只要 sessionId 保持一致,AI 就能"记住"之前的对话。

2.7 多用户会话管理

生产环境中需要管理多个用户的多个会话。项目中的 SessionManagementServicecom.jichi.langchain4j.service.chatMemory.SessionManagementService)给出了完整的实现:

@Service
public class SessionManagementService {

    private final ChatMessageRepository messageRepository;
    private final JpaChatMemoryStore memoryStore;

    public SessionManagementService(ChatMessageRepository messageRepository,
                                    JpaChatMemoryStore memoryStore) {
        this.messageRepository = messageRepository;
        this.memoryStore = memoryStore;
    }

    /**
     * 查询用户的所有历史会话(用第一条 USER 消息作为摘要)
     */
    public List<SessionSummary> getUserSessions(String userId) {
        String prefix = userId + "_";
        return messageRepository.findDistinctSessionIdsByPrefix(prefix)
                .stream()
                .map(sessionId -> {
                    List<ChatMessageEntity> messages =
                            messageRepository.findBySessionIdOrderByCreatedAtAsc(sessionId);
                    String summary = messages.stream()
                            .filter(m -> "USER".equals(m.getRole()))
                            .findFirst()
                            .map(m -> m.getContent().substring(0, Math.min(50, m.getContent().length())))
                            .orElse("新对话");
                    LocalDateTime lastActive = messages.isEmpty()
                            ? LocalDateTime.now()
                            : messages.get(messages.size() - 1).getCreatedAt();
                    return new SessionSummary(sessionId, summary, lastActive);
                })
                .toList();
    }

    /**
     * 删除会话(校验归属权,防止越权删除)
     */
    public void deleteSession(String sessionId, String userId) {
        if (!sessionId.startsWith(userId + "_")) {
            throw new SecurityException("无权删除此会话");
        }
        memoryStore.deleteMessages(sessionId);
    }

    /**
     * 生成新会话 ID
     */
    public String newSession(String userId) {
        return userId + "_" + System.currentTimeMillis();
    }

    public record SessionSummary(String sessionId, String summary, LocalDateTime lastActive) {
    }
}

核心设计思路:userId + "_" + 时间戳 拼接 sessionId。这样做有几个好处:

  1. 天然支持按用户前缀查询:通过 findDistinctSessionIdsByPrefixLIKE 'userId_%' 查到该用户所有会话
  2. 查询用户历史会话列表:取出所有 sessionId,用第一条 USER 消息作为摘要展示
  3. 删除会话时校验归属:检查 sessionId 是否以当前 userId + "_" 开头,防止越权操作

三、Agent 执行循环:ReAct 模式

3.1 普通 AI 调用 vs Agent

  • 普通 AI 调用:请求 -> 模型 -> 响应,一问一答就结束了
  • Agent:模型可以自主决定是否调用工具,工具结果再反馈给模型,模型再决定下一步。这个循环持续进行,直到任务完成

这就是 ReAct(Reasoning + Acting)模式的核心思想:

用户提问 -> 模型推理(Thought) -> 决定调用工具(Action) -> 工具执行返回结果(Observation)
         -> 模型再推理 -> 再调用工具 -> ... -> 给出最终答案

3.2 LangChain4j 中的 Agent 执行流程

  1. 用户消息进入,构建 Message 列表
  2. 所有 Tool 定义转化为模型可理解的格式,一并发送给模型
  3. 模型返回结果:
    - 如果直接回答(无工具调用),流程结束
    - 如果需要调用工具,进入循环
  4. 执行工具调用,获取结果
  5. 将工具结果作为新的 Message 加入列表,再次调用模型
  6. 重复 3-5,直到模型给出最终答案,或达到最大循环次数

3.3 构建一个完整的 Agent

先准备工具类。项目中在 com.jichi.langchain4j.tools.agent 包下定义了三个独立的工具类:

天气工具 CityWeatherTools

@Component
public class CityWeatherTools {
    @Tool("查询城市实时天气,返回天气状况和温度")
    public String getWeather(@P("城市名称") String city) {
        return city + ":晴天,18°C,风力2级";
    }
}

搜索工具 WebSearchTools

@Component
public class WebSearchTools {
    @Tool("搜索互联网获取信息,适用于查询实时新闻、百科知识等")
    public String search(@P("搜索关键词") String query) {
        return "搜索结果:关于 " + query + " 的相关信息:这是一段模拟的搜索结果内容。";
    }
}

计算器工具 ArithmeticMathTools

@Component
public class ArithmeticMathTools {
    @Tool("执行简单的数学计算,仅支持加减乘除")
    public double calculate(
            @P("第一个数字") double a,
            @P("运算符:+、-、*、/") String op,
            @P("第二个数字") double b) {
        return switch (op) {
            case "+" -> a + b;
            case "-" -> a - b;
            case "*" -> a * b;
            case "/" -> b != 0 ? a / b : Double.NaN;
            default -> throw new IllegalArgumentException("不支持的运算符:" + op);
        };
    }
}

然后定义 Agent 接口 SmartAgentcom.jichi.langchain4j.service.agent.SmartAgent),通过 System Prompt 告知模型拥有哪些能力:

public interface SmartAgent {

    @SystemMessage("""
            你是一个智能助手,拥有以下工具:
            - 查天气:可以获取任何城市的实时天气
            - 搜索:可以搜索互联网获取信息
            - 计算:可以执行加减乘除运算

            根据用户的问题,决定是否需要使用工具。
            需要多个信息时,可以多次调用工具。
            综合所有工具的返回结果,给出准确的最终答案。
            """)
    String chat(@MemoryId String sessionId, @UserMessage String message);
}

最后通过 SmartAgentConfigcom.jichi.langchain4j.config.SmartAgentConfig)组装:

@Configuration
public class SmartAgentConfig {

    @Bean
    public SmartAgent smartAgent(
            ChatModel chatModel,
            CityWeatherTools weatherTools,
            WebSearchTools searchTools,
            ArithmeticMathTools mathTools) {

        return AiServices.builder(SmartAgent.class)
                .chatModel(chatModel)
                // unwrap 剥掉 Spring AOP 的 CGLIB 代理,让 LangChain4j 能扫到 @Tool
                .tools(unwrap(weatherTools), unwrap(searchTools), unwrap(mathTools))
                .chatMemoryProvider(memoryId ->
                        MessageWindowChatMemory.withMaxMessages(20))
                .build();
    }

    private Object unwrap(Object bean) {
        if (AopUtils.isAopProxy(bean) && bean instanceof Advised advised) {
            try {
                return advised.getTargetSource().getTarget();
            } catch (Exception e) {
                throw new RuntimeException("无法解包 AOP 代理:" + bean.getClass(), e);
            }
        }
        return bean;
    }
}

注意这里的 unwrap 方法——这是一个容易踩的坑:Spring AOP 会给 @Component 加 CGLIB 代理,而 LangChain4j 通过反射扫描 @Tool 注解时拿不到原始类,必须手动剥掉代理层。

当用户同时问"北京天气怎么样"和"168 乘以 3 等于多少"时,Agent 会依次调用天气工具和计算器工具,然后综合给出结果。

3.4 监听 Agent 执行过程

项目中提供了两种监听方式。一是 ChatModelLoggingConfigcom.jichi.langchain4j.listener.ChatModelLoggingConfig),监控模型的请求和响应:

@Configuration
public class ChatModelLoggingConfig {

    @Bean
    public ChatModelListener loggingListener() {
        return new ChatModelListener() {

            @Override
            public void onRequest(ChatModelRequestContext ctx) {
                System.out.println("\n===== 【发送给模型】 =====");
                ctx.chatRequest().messages().forEach(m ->
                        System.out.println("[" + m.type() + "] " + m));
                if (ctx.chatRequest().toolSpecifications() != null) {
                    System.out.println("可用工具:" +
                            ctx.chatRequest().toolSpecifications().stream()
                                    .map(t -> t.name())
                                    .toList());
                }
            }

            @Override
            public void onResponse(ChatModelResponseContext ctx) {
                System.out.println("\n===== 【模型返回】 =====");
                var response = ctx.chatResponse().aiMessage();
                if (response.text() != null) {
                    System.out.println("回答:" + response.text());
                }
                if (response.hasToolExecutionRequests()) {
                    response.toolExecutionRequests().forEach(t ->
                            System.out.println("调用工具:" + t.name() + ",参数:" + t.arguments()));
                }
            }

            @Override
            public void onError(ChatModelErrorContext ctx) {
                System.out.println("===== 【模型报错】 =====");
                ctx.error().printStackTrace();
            }
        };
    }
}

二是 ToolTraceLoggercom.jichi.langchain4j.listener.ToolTraceLogger),专门追踪每次工具调用的详情:

@Component
@Slf4j
public class ToolTraceLogger implements Consumer<ToolExecution> {

    @Override
    public void accept(ToolExecution execution) {
        System.out.println(String.format(
                ">>> [ToolTraceLogger] 工具名:%s,参数:%s,结果:%s",
                execution.request().name(),
                execution.request().arguments(),
                execution.result()));
        log.info("[工具追踪] 工具名:{},参数:{},结果:{}",
                execution.request().name(),
                execution.request().arguments(),
                execution.result());
    }
}

通过这两层日志,可以清晰看到:模型收到了哪些工具定义、用户问了什么、模型决定调用哪个工具、工具的入参和返回值、最终答案是什么。这对调试 Agent 行为非常有帮助。


四、自主 Agent:多步推理与工具链自动组合

4.1 简单任务 vs 复杂任务

  • 简单任务:查天气,调一个工具就搞定
  • 复杂任务:分析苹果公司最新财报,结合当前股价给出投资建议。需要拆解为多个子任务——搜索最新财报、查询当前股价、查询近期走势、综合分析给建议——并且这些子任务之间存在依赖关系

Agent 需要自主决定调用顺序,逐步收集信息,最后综合判断。这是 LangChain4j 与普通 ChatGPT 调用的最大分水岭

4.2 关键一:精心设计 System Prompt

对于复杂 Agent,System Prompt 不能只写"你是一个专业的 XX 助手",需要明确告诉模型如何思考。项目中的 AnalysisAgentcom.jichi.langchain4j.service.ownerAgent.AnalysisAgent)展示了一个优秀的 System Prompt 设计:

public interface AnalysisAgent {

    @SystemMessage("""
            你是一个专业的财务分析助手,能够进行多步骤的深度分析。

            工作方式:
            1. 分析用户问题,识别需要哪些信息
            2. 按逻辑顺序调用工具收集数据(先收集基础数据,再做依赖前者的分析)
            3. 每次工具调用后,评估信息是否充足
            4. 信息充足后,综合所有数据给出完整分析

            重要原则:
            - 不要基于假设数据,只使用工具返回的真实数据
            - 如果某个工具调用失败,说明该数据暂时不可用,基于现有数据做部分分析
            - 最终答案要有数据支撑,不要说"大约"、"可能"这类模糊表述
            """)
    String analyze(@MemoryId String sessionId, @UserMessage String question);
}

4.3 关键二:工具粒度的黄金法则

工具设计中有一个常见的两难问题:

  • 粒度太粗:一个工具什么都能干,模型容易失控
  • 粒度太细:工具太多,模型不知道选哪个

黄金法则是每个工具只做一件清晰的事。项目中的 FinanceDataToolscom.jichi.langchain4j.tools.ownerAgent.FinanceDataTools)就遵循了这一原则:

@Component
public class FinanceDataTools {

    @Tool("获取指定股票的实时价格")
    public String getStockPrice(@P("股票代码,如 AAPL、600036") String symbol) {
        // 模拟数据,真实项目对接行情 API
        return symbol + " 当前价格:168.42 USD,涨跌:+1.23%";
    }

    @Tool("获取指定股票最近 N 天的价格走势,返回收盘价列表")
    public String getStockHistory(
            @P("股票代码") String symbol,
            @P("查询天数,1-90 之间") int days) {
        return symbol + " 近 " + days + " 天收盘价:[162.5, 164.3, 165.1, 166.8, 168.4](模拟数据)";
    }

    @Tool("获取指定公司的核心财务指标:市盈率、营收、净利润")
    public String getFinancials(@P("公司名称或股票代码") String company) {
        return company + " 财务指标:市盈率 28.5,TTM 营收 3830 亿 USD,净利润 970 亿 USD";
    }

    @Tool("搜索关于指定主题的最新新闻摘要")
    public String searchNews(@P("搜索主题,建议带公司名或股票代码") String topic) {
        return topic + " 近期新闻:(1) 新品发布会获市场正面反应;(2) AI 布局持续加码;(3) 分析师维持买入评级(均为模拟数据)";
    }
}

四个工具各司其职:查实时价格、查历史走势、查财务指标、搜新闻。@Tool 的描述要精确——模型就是靠这段文字来决定该调哪个工具的。

组装时通过 AnalysisAgentConfigcom.jichi.langchain4j.config.AnalysisAgentConfig)将工具绑定到 Agent:

@Configuration
public class AnalysisAgentConfig {

    @Bean
    public AnalysisAgent analysisAgent(ChatModel chatModel, FinanceDataTools financeTools) {
        return AiServices.builder(AnalysisAgent.class)
                .chatModel(chatModel)
                .tools(unwrap(financeTools))
                // 多步推理上下文长,多留一些消息窗口
                .chatMemoryProvider(memoryId ->
                        MessageWindowChatMemory.withMaxMessages(30))
                .build();
    }
    // ... unwrap 方法同上
}

注意这里 maxMessages 设为 30——多步推理场景下每次工具调用都会产生 ToolExecutionRequest 和 ToolExecutionResult 两条消息,窗口设太小会导致前几步的工具结果被丢弃,Agent 后续步骤"失忆"。

4.4 异步提交 + 轮询:优化响应体验

复杂 Agent 执行多步推理时耗时较长,让用户傻等不是好体验。项目中的 AsyncAnalysisServicecom.jichi.langchain4j.service.ownerAgent.AsyncAnalysisService)采用了异步提交 + 轮询的方式:

@Service
public class AsyncAnalysisService {

    private final AnalysisAgent agent;
    private final Map<String, TaskStatus> taskMap = new ConcurrentHashMap<>();

    public AsyncAnalysisService(AnalysisAgent agent) {
        this.agent = agent;
    }

    public String submitTask(String question) {
        String taskId = UUID.randomUUID().toString();
        taskMap.put(taskId, new TaskStatus("RUNNING", null, null));

        CompletableFuture.supplyAsync(() -> agent.analyze(taskId, question))
                .whenComplete((result, ex) -> {
                    if (ex != null) {
                        taskMap.put(taskId, new TaskStatus("FAILED", null, ex.getMessage()));
                    } else {
                        taskMap.put(taskId, new TaskStatus("DONE", result, null));
                    }
                });

        return taskId;
    }

    public TaskStatus getStatus(String taskId) {
        return taskMap.getOrDefault(taskId, new TaskStatus("NOT_FOUND", null, null));
    }

    public record TaskStatus(String status, String result, String error) {
    }
}

Controller 层(AsyncAnalysisController)提供两个端点:

@RestController
@RequestMapping("/analysis/async")
public class AsyncAnalysisController {

    private final AsyncAnalysisService asyncService;

    public AsyncAnalysisController(AsyncAnalysisService asyncService) {
        this.asyncService = asyncService;
    }

    @PostMapping("/submit")
    public Map<String, String> submit(@RequestBody Map<String, String> req) {
        String taskId = asyncService.submitTask(req.get("question"));
        return Map.of("taskId", taskId, "message", "任务已提交,请用 taskId 轮询状态");
    }

    @GetMapping("/status/{taskId}")
    public AsyncAnalysisService.TaskStatus getStatus(@PathVariable String taskId) {
        return asyncService.getStatus(taskId);
    }
}

提交任务立即返回 taskId,前端用 taskId 轮询 /status 端点直到状态变为 DONEFAILED

4.5 调试复杂 Agent 的技巧

  1. 零件隔离测试:先单独测试每个工具是否正常工作,再组装成 Agent
  2. 关注返回格式:90% 的多步推理问题出在工具返回格式上,模型看不懂就会跑偏
  3. 记忆窗口要够大maxMessages 设太小会导致前几步的工具结果被丢弃,Agent 后续步骤"失忆"。建议多步推理场景下设为 30 条以上(如 AnalysisAgentConfig 中所示)
  4. 用 Listener 追踪:通过 ChatModelLoggingConfigToolTraceLogger 记录每一步的输入输出,快速定位问题

五、生产注意事项:上线前的六道防线

把 Agent 应用推到生产环境,需要系统性地考虑以下六个维度。

5.1 安全:API Key 管理

API Key 不要写在代码里,不要写在代码里,不要写在代码里。

也不要明文提交到 Git 仓库。正确做法:

  • 使用环境变量注入
  • 使用公司的密钥管理服务(如 Vault)
  • 本地开发可以用 .env 文件(加入 .gitignore

5.2 限流:防止账单爆炸

不做限流,被恶意攻击时 API 账单会直接炸掉。推荐双重限流策略:

层级 策略 示例
用户级 分钟级限流 每分钟 5 次
全局级 秒级限流 每秒 10 次

可以使用 Guava 的 RateLimiter 实现,基本覆盖大部分场景。

5.3 降级与容灾:模型挂了怎么办

  • 备用模型切换:主模型不可用时自动切换到备用模型
  • 重试机制:对临时性错误进行有限次数的重试
  • 优雅降级:极端情况下返回预设的兜底回答,而不是直接报错

5.4 成本控制:Token 就是钱

每次调用的 Token 消耗 = 历史消息 + 用户消息 + RAG 检索文档片段 + System Prompt。控制成本的方法:

  1. System Prompt 精炼:它每次都在,废话越多成本越高
  2. 记忆窗口别太大:按需设置,不要无脑设 100 条
  3. RAG 检索控制topK 设 3-5 条就够,不要贪多
  4. 模型分级使用:简单任务用便宜的小模型,复杂任务才用旗舰模型
  5. Token 监控告警:设置日/月消耗阈值,超过即告警

5.5 超时配置

Agent 执行多步推理时耗时较长,需要给足思考时间。建议先统计 Agent 的平均响应时间,再设置合理的超时阈值(通常 30-60 秒),避免过早中断正常的多步推理。

5.6 并发安全

LangChain4j 的 AI Service 代理本身是线程安全的,但工具类需要特别注意。一个常见的反例:

// 错误示范:共享状态
@Component
public class SearchTool {
    private String lastQuery;  // 共享变量,多线程会互相覆盖!

    @Tool("搜索")
    public String search(String query) {
        this.lastQuery = query;  // 危险!
        return doSearch(query);
    }
}

正确做法:工具类保持无状态,所有数据都用局部变量或线程安全的容器。

5.7 生产上线检查清单

检查项 要求
API Key 环境变量或密钥管理服务,不在代码中明文出现
限流 用户级 + 全局级双重限流
降级 备用模型切换方案就绪
成本 Token 监控告警已配置
超时 合理的超时配置
并发 工具类无状态,无共享可变变量
版本 LangChain4j 版本锁定(迭代快,避免升级破坏)

5.8 版本管理建议

LangChain4j 迭代速度很快,API 变动频繁。务必在 pom.xmlbuild.gradle锁定版本号,不要使用 LATEST 或范围版本,避免自动升级导致构建失败。


六、总结

本文从 LangChain4j 的记忆管理讲到自主 Agent,再到生产上线的注意事项,完整覆盖了构建一个可靠 AI Agent 系统的全链路:

  1. ChatMemory:通过 MessageWindow 或 TokenWindow 策略管理对话历史,用 @MemoryId 实现会话隔离,System Message 始终受保护
  2. 持久化记忆:通过 JpaChatMemoryStore 实现 ChatMemoryStore 接口对接 MySQL,用 userId + 时间戳 的 sessionId 方案配合 SessionManagementService 实现多用户会话管理
  3. ReAct 执行循环:Agent 的核心——推理、行动、观察的循环,让 AI 从"一问一答"进化为"自主决策"
  4. 多步推理 Agent:精心设计 System Prompt(如 AnalysisAgent)和工具粒度(如 FinanceDataTools),配合 AsyncAnalysisService 异步轮询优化用户体验
  5. 生产六道防线:安全、限流、降级、成本、超时、并发,缺一不可

简单场景用 Spring AI,复杂场景上 LangChain4j——两套方案在手,足以应对绝大多数 AI 应用开发需求。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐