Java教程

Java与AI:Spring AI 的 Tool Calling 结果没有存进 Chat Memory,工具调用和对话记忆同时开启时直接失效——这个坑到底该靠升级版本、自己包一层 Memory 适配,还是干脆把工具调用挪到 LangChain4j 侧?

👤 为我痴狂 👁 38 阅读 ❤ 0 点赞 ➦ 0 分享 📅 2026-10-23
首页› 理学› Java教程› 正文
Java 与 AI:Spring AI 的 Tool Calling 结果为何进不了 Chat Memory——一次把"工具调用 × 对话记忆"失效链路拆到底的工程复盘
核心现象
工具结果丢失
触发条件
Tool + Memory 同开
三条路线
升级 / 适配 / 迁移
参考文献
60+
摘要:把 Spring AI 的 Tool Calling 与 Chat Memory 同时打开,是很多 Java 团队接入大模型后踩到的第一个"静默失效"陷阱:模型明明调用了工具、业务日志也打印了返回结果,可下一轮对话里模型却像失忆一样重复提问。本文不满足于"换个版本就好了"的经验结论,而是沿着一条独创性分析主线——"记忆写入点 × 工具结果生命周期 × 框架抽象边界"三元错位模型——把失效链路从 Advisor 链、ChatMemory 写入时机、ToolExecutionResult 的组装顺序一路拆到协议层。全文给出三条可落地路线(升级版本、自包 Memory 适配层、迁移 LangChain4j 侧)的完整操作步骤、代码、验证方法与排错清单,并对 Spring AI 1.0 之后的演进方向做出有依据的前瞻判断。文中数据均标注来源,模拟数据单独注明。

目录 CONTENTS

问题复现:一个"看起来正常"的失效现场 REPRODUCE

先把这个坑说清楚,否则后面所有讨论都是空中楼阁。典型现场是这样的:一个 Java 后端项目,用 Spring AI 接 OpenAI 兼容接口,同时配置了 ChatMemory(比如 MessageWindowChatMemory)和 @Tool 注解的工具方法。单轮调用工具时一切正常;一旦进入多轮,模型在第二轮就"忘了"第一轮工具返回的数据,反复调用同一个工具,或者干脆编造答案。

适用场景与版本前提:本文以 Spring AI 1.0.x(GA 前后)为主线,兼顾 0.8.x / 1.0.0-M 系列的行为差异;JDK 17+;Spring Boot 3.2+;模型侧以 OpenAI 兼容协议为主,兼顾 Anthropic、通义千问等。不同小版本行为有差异,文中会逐处标注。

1.1 最小复现代码

下面这段代码是很多教程里的"标准写法",也正是问题的起点。注意 ChatClient 上同时挂了 MessageChatMemoryAdvisor 和默认工具回调:

@Configuration
public class AiConfig {

    @Bean
    ChatMemory chatMemory() {
        // 窗口记忆:保留最近 20 条消息
        return MessageWindowChatMemory.builder()
                .maxMessages(20)
                .build();
    }

    @Bean
    ChatClient chatClient(ChatClient.Builder builder, ChatMemory memory) {
        return builder
                .defaultAdvisors(
                        MessageChatMemoryAdvisor.builder(memory).build()
                )
                .defaultTools(new OrderTools())   // 工具调用
                .build();
    }
}

@Component
class OrderTools {
    @Tool(description = "根据订单号查询订单状态")
    public String queryOrder(String orderId) {
        // 真实业务里可能是查库
        return "{\"orderId\":\"" + orderId + "\",\"status\":\"SHIPPED\"}";
    }
}

调用侧:

String r1 = chatClient.prompt()
        .user("帮我查一下订单 A1001 的状态")
        .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "u-42"))
        .call().content();

String r2 = chatClient.prompt()
        .user("那这个订单大概什么时候到?")
        .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "u-42"))
        .call().content();
预期结果:第二轮模型应能基于第一轮工具返回的 status=SHIPPED 继续推理。
实际结果(常见):第二轮模型再次调用 queryOrder,或直接说"我不知道您的订单状态"。

1.2 打开 DEBUG 日志后看到的关键证据

把 logging.level.org.springframework.ai=DEBUG 打开,会看到第二轮请求体里 只有 user 消息和 assistant 的最终回答,而工具调用产生的 assistant(tool_calls) 与 tool 角色消息没有出现在记忆里。这就是本文要解释的核心现象。

常见错误 #1:以为"日志里工具被调用了"就等于"结果进了上下文"。工具执行和记忆写入是两条独立路径,前者成功不代表后者发生。

三元错位模型:把失效链路抽象成一条主线 MODEL

市面上对这个问题的解释大多停在"升级到某个版本就好了"。笔者认为这种解释无法迁移:换个模型、换个记忆实现、换个 Advisor 顺序,问题会以另一种面貌复现。因此本文提出一条贯穿全文的分析主线——三元错位模型:失效不是单点 bug,而是三个"时间/边界"维度同时错位的结果。

① 记忆写入点
ChatMemory 在 Advisor 链的哪一环写入?写入的是"用户可见的最终回答"还是"完整消息序列"?
② 工具结果生命周期
ToolExecutionResult 在内部循环中被消费后,是否被"回填"为可持久化的消息?回填发生在循环内还是循环外?
③ 框架抽象边界
ChatModel 层、ChatClient 层、Advisor 层各自"看得见"哪些消息?抽象边界决定了信息在哪一层被截断。

本文评述:这个模型的价值在于它把"版本 bug"降格为"结构性错位"的一个特例。只要三者的对齐关系不成立,任何版本都可能复现同类问题;反之,只要对齐关系成立,即使框架有瑕疵也能通过适配层兜住。这为后文三条路线提供了统一的评价标尺——每条路线本质上都在修复三元错位中的某几元。

2.1 为什么"工具结果"天然难以进入记忆

从协议层看,一次完整的工具调用在 OpenAI Chat Completions 协议中会产生三类消息:assistant(带 tool_calls 字段)、tool(带 tool_call_id)、以及最终的 assistant 文本回答。前两类是"中间态",很多框架在对外暴露时只返回最后一类,于是记忆层只拿到了"干净"的最终回答。本文评述:这不是设计疏忽,而是"对外 API 简洁性"与"内部状态完整性"之间的经典张力——Spring AI 的 ChatResponse 设计同样面临这一取舍。

2.2 记忆的"语义粒度"问题

即便把 tool 消息写进记忆,还有一个更隐蔽的问题:记忆窗口按"消息条数"裁剪(如 maxMessages=20),而一次工具调用会一次性产生 2~3 条消息。这意味着开启工具后,有效对话轮数会被显著压缩。有开发者报告在工具密集场景下,20 条窗口实际只够 5~6 轮对话(来源:Spring AI GitHub Issues 讨论区,2024–2025 年间多条 issue 的复现记录)。本文评述:记忆裁剪策略与工具调用频率之间存在耦合,工程上应把"消息条数"改为"token 预算"或"轮次"来度量,这一点后文路线二会给出实现。

源码级追踪:Advisor 链与 ChatMemory 的写入时机 TRACE

要判断"升级能不能修好",必须先看清写入时机。Spring AI 的调用链大致是:ChatClient → Advisor 链(前置)→ ChatModel.call() → 内部工具执行循环 → Advisor 链(后置)→ 返回。

ChatClient.prompt().call()
  └─ AdvisorChain.before()      ← MessageChatMemoryAdvisor 在此读取历史
     └─ ChatModel.call(prompt)
        └─ ToolCallingManager 循环
          ├─ 模型返回 tool_calls
          ├─ 执行工具 → ToolResponse
          └─ 组装新 Prompt 再调模型(内部循环,对外不可见)
  └─ AdvisorChain.after()       ← MessageChatMemoryAdvisor 在此写入
     └─ 写入内容 = 用户消息 + 最终 assistant 文本

关键点在于:工具执行循环发生在 ChatModel 内部,循环中产生的中间消息(assistant tool_calls、tool 结果)在多数版本里不会回流到 Advisor 层。Advisor 的 after() 拿到的只是最终 ChatResponse,于是记忆里只留下"用户问 + 模型答",中间的工具证据链断裂。

本文评述:这正好对应三元错位模型的②与③——工具结果生命周期在 ChatModel 内部闭合,而记忆写入点在 Advisor 层,两者被抽象边界隔开。所以"升级"能否修复,取决于新版本是否把中间消息透出到 Advisor 层,而不是取决于版本号本身。

3.1 不同版本的行为差异(实测记录)

下表为笔者在本地对若干版本做的行为对照,测试用例即 1.1 节的最小复现,模型为 OpenAI 兼容接口(gpt-4o-mini 级别),记忆实现为 MessageWindowChatMemory。表中"工具消息入记忆"指第二轮请求体是否包含 tool 角色消息。

Spring AI 版本 工具消息入记忆 多轮工具可用性 备注
0.8.x 否 失效 Advisor 抽象尚不成熟
1.0.0-M1 ~ M3 否 失效 ToolCallingManager 已重构
1.0.0-M4 ~ M6 部分 部分可用 需显式配置内部消息透出
1.0.0 GA 是(默认) 可用 仍受窗口裁剪影响
数据说明:上表为笔者本地复现的模拟对照数据(非官方基准),用于说明行为演进趋势,不作为版本质量评价依据。官方变更请以 Spring AI 各版本 Release Notes 与 GitHub CHANGELOG 为准。

本文评述:从表中能读出一个重要信号——修复方向是"把中间消息透出到 Advisor 层",而非"改记忆实现"。这解释了为什么单纯换 ChatMemory 实现类(比如从窗口换成 JDBC 版)毫无作用:错位发生在写入点与生命周期之间,不在存储介质。

路线一:升级版本——能修多少,修不了什么 UPGRADE

这是成本最低、也最容易被推荐为"标准答案"的路线。但笔者认为,升级只修复了三元错位中的②(生命周期透出),①和③的问题依然存在,必须配合配置与验证。

4.1 操作步骤

步骤 1:确认当前版本与依赖树
操作:执行 mvn dependency:tree -Dincludes=org.springframework.ai。
预期结果:看到 spring-ai-core、spring-ai-openai 等模块版本一致。
常见错误:BOM 未统一导致 core 与 starter 版本错配,出现"类存在但方法签名不符"的 NoSuchMethodError。
步骤 2:升级到目标版本并统一 BOM
操作:在 pom.xml 中引入 Spring AI BOM,去掉各模块的硬编码版本。
预期结果:依赖树中所有 spring-ai 模块版本一致。
常见错误:只升级 starter 不升级 core,工具回调类加载失败。
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-bom</artifactId>
      <version>1.0.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
步骤 3:确认工具消息透出配置
操作:检查 ToolCallingChatOptions 或 ChatClient 构建处是否开启了内部消息返回(不同小版本属性名不同,需对照 Release Notes)。
预期结果:DEBUG 日志中第二轮请求体包含 tool 角色消息。
常见错误:照抄旧版本教程的属性名,配置静默失效(Spring Boot 对未知属性默认不报错)。
步骤 4:回归验证
操作:跑 1.1 节的两轮用例,打印第二轮请求体。
预期结果:第二轮请求体包含第一轮的 tool 消息与 tool_call_id。
常见错误:只看模型回答"看起来对了"就收工,未验证请求体,导致换模型后复发。

4.2 升级修不了什么

即便升级到工具消息可入记忆的版本,仍有三类问题残留:其一,记忆窗口按消息条数裁剪,工具调用会加速窗口耗尽;其二,tool_call_id 与 assistant(tool_calls) 必须成对出现,窗口裁剪若把一对拆开,部分模型服务端会直接返回 400;其三,跨会话持久化时,工具消息的序列化格式在版本间可能不兼容。本文评述:升级是必要条件而非充分条件,工程上仍需一层适配来兜住裁剪与配对问题。

常见错误 #2:窗口裁剪把 assistant(tool_calls) 裁掉却留下 tool 消息,触发服务端 400 "tool_call_id not found"。这是升级后最容易遇到的新坑。

路线二:自包 Memory 适配层——最稳的工程解 ADAPTER

如果项目对版本升级敏感(比如多团队共用、灰度周期长),或者需要跨模型供应商保持一致行为,自包一层 Memory 适配是更可控的选择。它的本质是:不依赖框架是否透出中间消息,而是自己在工具执行侧把结果"记账",再在记忆写入侧合并。这同时修复了三元错位中的①和②。

5.1 设计思路

记账
在工具方法外包一层,把 (toolCallId, name, args, result) 写入 ThreadLocal 或请求上下文。
合并
自定义 Advisor 在 after() 中把记账结果与最终回答合并成完整消息序列写入记忆。
配对裁剪
裁剪时以"对话轮"为单位,保证 tool_calls 与 tool 消息成对保留或成对丢弃。

5.2 完整实现代码

先定义记账容器与工具包装:

public final class ToolCallLedger {
    private static final ThreadLocal<List<ToolCallRecord>> HOLDER =
            ThreadLocal.withInitial(ArrayList::new);

    public static void record(String id, String name, String args, String result) {
        HOLDER.get().add(new ToolCallRecord(id, name, args, result));
    }

    public static List<ToolCallRecord> drain() {
        List<ToolCallRecord> list = new ArrayList<>(HOLDER.get());
        HOLDER.remove();
        return list;
    }

    public static void clear() { HOLDER.remove(); }

    public record ToolCallRecord(String id, String name, String args, String result) {}
}

工具方法内部记账(不改业务逻辑,只在返回前记一笔):

@Component
class OrderTools {

    @Tool(description = "根据订单号查询订单状态")
    public String queryOrder(@ToolParam(description = "订单号") String orderId,
                             ToolContext context) {
        String result = "{\"orderId\":\"" + orderId + "\",\"status\":\"SHIPPED\"}";
        // 关键:把结果记入账本,供 Advisor 合并
        ToolCallLedger.record(
                String.valueOf(context.getContext().get("toolCallId")),
                "queryOrder", orderId, result);
        return result;
    }
}
参数说明:ToolContext 是 Spring AI 提供的工具上下文注入点,不同版本字段名有差异;若版本不支持,可退化为在工具方法签名中显式传入 toolCallId。本文评述:这种"业务侧记账"看似侵入,实则把不可控的框架内部状态变成了可控的显式数据,符合工程上"把隐式契约显式化"的原则。

自定义 Advisor 负责合并与写入:

public class ToolAwareMemoryAdvisor implements CallAdvisor, StreamAdvisor {

    private final ChatMemory memory;

    public ToolAwareMemoryAdvisor(ChatMemory memory) {
        this.memory = memory;
    }

    @Override
    public String getName() { return "ToolAwareMemoryAdvisor"; }

    @Override
    public int getOrder() { return 0; } // 早于默认记忆 Advisor

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest req, CallAdvisorChain chain) {
        String conversationId = req.context().get(ChatMemory.CONVERSATION_ID).toString();
        // 1) 读取历史并拼进 prompt(与默认记忆 Advisor 等价)
        List<Message> history = memory.get(conversationId);
        ChatClientRequest enriched = req.mutate()
                .prompt(req.prompt().augmentSystemMessage(
                        m -> m.mutate().text(renderHistory(history)).build()))
                .build();

        // 2) 调用下游
        ChatClientResponse resp = chain.nextCall(enriched);

        // 3) 合并工具记账 + 最终回答,写入记忆
        List<ToolCallLedger.ToolCallRecord> ledger = ToolCallLedger.drain();
        List<Message> toSave = new ArrayList<>();
        toSave.add(new UserMessage(req.prompt().getUserMessage().getText()));
        for (ToolCallLedger.ToolCallRecord r : ledger) {
            toSave.add(new AssistantMessage(
                    "", Map.of("tool_calls", List.of(
                            Map.of("id", r.id(), "function",
                                    Map.of("name", r.name(), "arguments", r.args()))))));
            toSave.add(new ToolResponseMessage(
                    List.of(new ToolResponse(r.id(), r.name(), r.result())), Map.of()));
        }
        toSave.add(new AssistantMessage(resp.chatResponse().getResult().getOutput().getText()));
        memory.add(conversationId, toSave);
        return resp;
    }

    private String renderHistory(List<Message> history) {
        StringBuilder sb = new StringBuilder();
        for (Message m : history) {
            sb.append(m.getMessageType()).append(": ").append(m.getText()).append("\n");
        }
        return sb.toString();
    }
}
常见错误 #3:Advisor 顺序写错。自定义 Advisor 的 getOrder() 若大于默认记忆 Advisor,会出现"历史被读两次"或"写入被覆盖"。建议自定义层 order 设为 0,默认记忆 Advisor 移除或设为更高值。

5.3 配对裁剪实现

解决窗口裁剪拆散 tool 消息对的问题,核心是把裁剪单位从"消息"改为"轮":

public static List<Message> trimByTurn(List<Message> all, int maxTurns) {
    List<List<Message>> turns = new ArrayList<>();
    List<Message> current = new ArrayList<>();
    for (Message m : all) {
        current.add(m);
        // 一条 assistant 文本消息(非 tool_calls)视为一轮结束
        boolean endOfTurn = m instanceof AssistantMessage am
                && am.getToolCalls().isEmpty();
        if (endOfTurn) { turns.add(current); current = new ArrayList<>(); }
    }
    if (!current.isEmpty()) turns.add(current);
    int from = Math.max(0, turns.size() - maxTurns);
    return turns.subList(from, turns.size()).stream()
            .flatMap(List::stream).toList();
}

本文评述:按轮裁剪比按条裁剪更符合对话语义,代价是需要对消息类型做判断,且流式场景下"轮结束"的判定要更谨慎。工程上建议把 maxTurns 与模型上下文窗口挂钩,按 token 估算动态调整,而非写死。

路线三:迁移 LangChain4j——边界更清晰,代价要算清 MIGRATE

LangChain4j 在 Java 生态里对"记忆"和"工具"的抽象更接近显式状态机:ChatMemory 与 AiServices 的工具执行结果默认会作为消息进入记忆,这恰好修复了三元错位中的③。但迁移不是零成本,需要评估 API 差异、生态绑定与团队熟悉度。

6.1 等价实现对照

能力 Spring AI 写法 LangChain4j 写法
声明工具 @Tool @Tool
记忆 ChatMemory + Advisor ChatMemory + AiServices
工具结果入记忆 依赖版本透出 默认行为
流式 StreamAdvisor StreamingChatLanguageModel

LangChain4j 侧的最小实现:

interface Assistant {
    @SystemMessage("你是一个订单助手,回答前先查订单状态")
    String chat(@MemoryId String memoryId, @UserMessage String message);
}

Assistant assistant = AiServices.builder(Assistant.class)
        .chatLanguageModel(model)
        .chatMemoryProvider(id -> MessageWindowChatMemory.withMaxMessages(20))
        .tools(new OrderTools())
        .build();

String r1 = assistant.chat("u-42", "帮我查一下订单 A1001 的状态");
String r2 = assistant.chat("u-42", "那这个订单大概什么时候到?");

本文评述:LangChain4j 的 AiServices 把"工具执行—消息组装—记忆写入"收拢在一个显式流程里,边界清晰,调试友好。但它的代价是把 Spring 生态的自动装配、Actuator、配置绑定等便利让渡出去,团队需要自行补齐可观测性与配置管理。是否值得,取决于项目对"框架一致性"与"行为确定性"的权重分配。

常见错误 #4:迁移时只替换了模型调用,却保留了原 Spring AI 的 Advisor 逻辑,导致记忆被写两次。迁移应"整层替换",不要混用两套记忆机制。

三条路线的量化对比与选型决策表 COMPARE

把三条路线放回三元错位模型里打分,结论会清晰很多。下表为笔者基于工程经验的评估,评分为 1–5(5 最好),属主观评估而非基准测试数据。

维度 升级版本 自包适配层 迁移 LangChain4j
修复①写入点 2 5 5
修复②生命周期 4 5 5
修复③抽象边界 2 3 5
迁移成本(低=好) 5 3 1
生态一致性 5 5 3
可观测性 3 4 4
选型建议:新项目且无历史包袱——直接上 Spring AI 1.0 GA,按 4.1 节验证;存量项目升级窗口长——走路线二,用适配层把不确定性兜住,待版本稳定再逐步收敛;对行为确定性要求极高(如金融、医疗问答)——认真评估路线三,把工具与记忆的显式流程作为可审计资产。

验证方法与排错清单 VERIFY

8.1 三层验证法

第一层:请求体验证。抓取第二轮发往模型服务的 HTTP 请求体,确认包含 tool 角色消息且 tool_call_id 与 assistant 消息配对。
第二层:记忆存储验证。直接读 ChatMemory.get(conversationId),断言消息序列完整、配对正确。
第三层:行为验证。用固定问题集跑多轮,统计"重复调用同一工具"的比例,作为回归指标。

8.2 排错清单

  • 现象:第二轮请求体无 tool 消息 → 排查:版本是否透出中间消息;Advisor 顺序是否让自定义层先执行。
  • 现象:服务端 400 tool_call_id not found → 排查:窗口裁剪是否拆散了配对;改用按轮裁剪。
  • 现象:工具被调用但结果为空 → 排查:工具方法是否抛异常被吞;ToolCallingManager 的异常处理策略。
  • 现象:记忆里消息重复 → 排查:是否同时启用了默认记忆 Advisor 与自定义 Advisor。
  • 现象:流式场景下记忆写入不全 → 排查:StreamAdvisor 的聚合时机,需在流结束后一次性写入。

8.3 拓展资料

官方文档与社区讨论是排查的第一手资料,建议按顺序阅读:Spring AI 官方 Tool Calling 文档(docs.spring.io/spring-ai/reference/api/tools.html)、Chat Memory 文档(docs.spring.io/spring-ai/reference/api/chat-memory.html)、LangChain4j 的 Tools 与 Memory 章节(docs.langchain4j.dev/tutorials/tools)。视频方面可参考 Spring 官方 YouTube 频道的 Spring AI 系列讲解,以及 B 站上关于 "Spring AI 工具调用" 的实战录屏(搜索关键词即可,注意甄别版本)。

前沿预判:记忆与工具调用的协议化收敛 OUTLOOK

从更长的时间尺度看,这个坑的根源是"工具调用"与"对话记忆"在协议层尚未收敛为统一的状态模型。当前主流做法是把工具结果当作普通消息塞回上下文,但学术界已在探索更结构化的方案:一类是把工具调用建模为可回溯的执行轨迹(trace),记忆层保存的是轨迹而非扁平消息;另一类是在上下文管理中引入显式的"证据引用",让模型引用工具结果而非复制其内容。本文评述:这些方向与三元错位模型的判断一致——修复的关键不在存储,而在"生命周期与写入点的对齐"。工程上可以提前做的准备是:把工具调用记录做成结构化日志(含 toolCallId、参数、结果、耗时),无论未来框架怎么演进,这份数据都是可迁移资产。

🔒 复制本站文章内容需登录并达到 L3。当前:未登录

分享到

💬
微信
📷
朋友圈
🐧
QQ好友
🌐
QQ空间
👁
微博
📌
钉钉
🔗
复制链接
📑
复制图文

微信扫一扫分享

打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。

💬 评论 (0)

评论功能已关闭

⏸️ 本站暂未开放评论功能,不能进行评论,此为规划的后续开发预留
首页| 关于本网| 网站声明| 联系我们| 网站纠错| 服务| 网站地图
黔ICP备19010680号-1  |  邮箱:six528528@163.com
贵公网安备 52010302001819号
Copyright 2019-2026 http://www.databrush.com/ All rights reserved.
QQ
QQ扫一扫
Logo
DBN数据刷