目录 CONTENTS
问题复现:一个"看起来正常"的失效现场 REPRODUCE
先把这个坑说清楚,否则后面所有讨论都是空中楼阁。典型现场是这样的:一个 Java 后端项目,用 Spring AI 接 OpenAI 兼容接口,同时配置了 ChatMemory(比如 MessageWindowChatMemory)和 @Tool 注解的工具方法。单轮调用工具时一切正常;一旦进入多轮,模型在第二轮就"忘了"第一轮工具返回的数据,反复调用同一个工具,或者干脆编造答案。
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 角色消息没有出现在记忆里。这就是本文要解释的核心现象。
三元错位模型:把失效链路抽象成一条主线 MODEL
市面上对这个问题的解释大多停在"升级到某个版本就好了"。笔者认为这种解释无法迁移:换个模型、换个记忆实现、换个 Advisor 顺序,问题会以另一种面貌复现。因此本文提出一条贯穿全文的分析主线——三元错位模型:失效不是单点 bug,而是三个"时间/边界"维度同时错位的结果。
本文评述:这个模型的价值在于它把"版本 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 链(后置)→ 返回。
└─ AdvisorChain.before() ← MessageChatMemoryAdvisor 在此读取历史
└─ ChatModel.call(prompt)
└─ ToolCallingManager 循环
├─ 模型返回 tool_calls
├─ 执行工具 → ToolResponse
└─ 组装新 Prompt 再调模型(内部循环,对外不可见)
└─ AdvisorChain.after() ← MessageChatMemoryAdvisor 在此写入
└─ 写入内容 = 用户消息 + 最终 assistant 文本
关键点在于:工具执行循环发生在 ChatModel 内部,循环中产生的中间消息(assistant tool_calls、tool 结果)在多数版本里不会回流到 Advisor 层。Advisor 的 after() 拿到的只是最终 ChatResponse,于是记忆里只留下"用户问 + 模型答",中间的工具证据链断裂。
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 | 是(默认) | 可用 | 仍受窗口裁剪影响 |
本文评述:从表中能读出一个重要信号——修复方向是"把中间消息透出到 Advisor 层",而非"改记忆实现"。这解释了为什么单纯换 ChatMemory 实现类(比如从窗口换成 JDBC 版)毫无作用:错位发生在写入点与生命周期之间,不在存储介质。
路线一:升级版本——能修多少,修不了什么 UPGRADE
这是成本最低、也最容易被推荐为"标准答案"的路线。但笔者认为,升级只修复了三元错位中的②(生命周期透出),①和③的问题依然存在,必须配合配置与验证。
4.1 操作步骤
操作:执行
mvn dependency:tree -Dincludes=org.springframework.ai。预期结果:看到
spring-ai-core、spring-ai-openai 等模块版本一致。常见错误:BOM 未统一导致 core 与 starter 版本错配,出现"类存在但方法签名不符"的
NoSuchMethodError。
操作:在
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>
操作:检查
ToolCallingChatOptions 或 ChatClient 构建处是否开启了内部消息返回(不同小版本属性名不同,需对照 Release Notes)。预期结果:DEBUG 日志中第二轮请求体包含
tool 角色消息。常见错误:照抄旧版本教程的属性名,配置静默失效(Spring Boot 对未知属性默认不报错)。
操作:跑 1.1 节的两轮用例,打印第二轮请求体。
预期结果:第二轮请求体包含第一轮的 tool 消息与 tool_call_id。
常见错误:只看模型回答"看起来对了"就收工,未验证请求体,导致换模型后复发。
4.2 升级修不了什么
即便升级到工具消息可入记忆的版本,仍有三类问题残留:其一,记忆窗口按消息条数裁剪,工具调用会加速窗口耗尽;其二,tool_call_id 与 assistant(tool_calls) 必须成对出现,窗口裁剪若把一对拆开,部分模型服务端会直接返回 400;其三,跨会话持久化时,工具消息的序列化格式在版本间可能不兼容。本文评述:升级是必要条件而非充分条件,工程上仍需一层适配来兜住裁剪与配对问题。
路线二:自包 Memory 适配层——最稳的工程解 ADAPTER
如果项目对版本升级敏感(比如多团队共用、灰度周期长),或者需要跨模型供应商保持一致行为,自包一层 Memory 适配是更可控的选择。它的本质是:不依赖框架是否透出中间消息,而是自己在工具执行侧把结果"记账",再在记忆写入侧合并。这同时修复了三元错位中的①和②。
5.1 设计思路
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();
}
}
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、配置绑定等便利让渡出去,团队需要自行补齐可观测性与配置管理。是否值得,取决于项目对"框架一致性"与"行为确定性"的权重分配。
三条路线的量化对比与选型决策表 COMPARE
把三条路线放回三元错位模型里打分,结论会清晰很多。下表为笔者基于工程经验的评估,评分为 1–5(5 最好),属主观评估而非基准测试数据。
| 维度 | 升级版本 | 自包适配层 | 迁移 LangChain4j |
|---|---|---|---|
| 修复①写入点 | 2 | 5 | 5 |
| 修复②生命周期 | 4 | 5 | 5 |
| 修复③抽象边界 | 2 | 3 | 5 |
| 迁移成本(低=好) | 5 | 3 | 1 |
| 生态一致性 | 5 | 5 | 3 |
| 可观测性 | 3 | 4 | 4 |
验证方法与排错清单 VERIFY
8.1 三层验证法
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。当前:未登录
微信扫一扫分享
打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。
💬 评论 (0)
评论功能已关闭

