Skip to content

[BUG] DeepSeek/OpenAI 工具调用后下一轮对话 HTTP 400 (由于 Conversation 缺失工具链校验导致历史被写脏) 还有咱们这个我看好像没有配置式 MCP注册 #172

Description

@jijiutong

1. 简要描述

在使用 Spring AI 结合 DeepSeek-V3/R1 启用 MCP 或本地工具调用时,工具执行后的下一轮对话常遭遇 HTTP 400 Bad Request
根本原因Conversation / MessageWindowConversation 写入历史时缺乏工具链完整性校验。流式聚合产生的异常消息或悬空的 ToolResponseMessage 写入了历史,破坏了 DeepSeek 的严苛格式限制:任何 ToolResponseMessage 前面必须紧邻一个包含匹配 tool_callsAssistantMessage


2. 错误日志

org.springframework.web.reactive.function.client.WebClientResponseException$BadRequest: 400 Bad Request from POST https://api.deepseek.com/v1/chat/completions
    at org.springframework.web.reactive.function.client.WebClientResponseException.create(WebClientResponseException.java:321)
	...
	at org.springframework.ai.chat.model.MessageAggregator.aggregate(MessageAggregator.java:188)

API 报错详情messages with role "tool" must be a response to a preceding message with "tool_calls"


3. 影响范围 (Impact)

  • 大模型:DeepSeek-V3 / DeepSeek-R1 (对工具调用历史顺序校验极严的对象)
  • 场景:Spring AI 流式对话 + MCP / 本地 Function Calling
  • 后果:当前轮工具调用正常,但下一轮用户发送消息时直接报错 400。一旦历史写脏,会话将持续失败,除非清空历史。

4. 关键缺陷代码 (Code Issue)

Conversation#add (无条件追加,无校验)

// com/xiaozhi/ai/llm/memory/Conversation.java
public synchronized void add(Message message) {
    if(message instanceof UserMessage userMsg){ ... }
    if(message instanceof AssistantMessage assistantMessage){ messages.add(assistantMessage); return; }
    if(message instanceof ToolResponseMessage toolResponseMessage){ messages.add(toolResponseMessage); }
}
  • 问题:不校验 ToolResponseMessage 之前是否存在合法的 AssistantMessage(tool_calls),允许悬空响应直接入库。

Conversation#addToolCallChain (添加工具链绕过校验)

public synchronized void addToolCallChain(AssistantMessage toolCallMsg, ToolResponseMessage toolResponse) {
    messages.add(toolCallMsg);
    messages.add(toolResponse);
}
  • 问题:直接调用 messages.add 绕过了 add(Message) 的潜在校验。未校验 toolCallMsg 内是否真的有 tool_calls,未校验 toolResponse 的有效性,也未校验两者之间的 id/name 是否匹配。

MessageWindowConversation#add (覆写后绕过父类)

// com/xiaozhi/ai/llm/memory/MessageWindowConversation.java
@Override
public synchronized void add(Message message) {
    if (message instanceof UserMessage || message instanceof AssistantMessage || message instanceof ToolResponseMessage) {
        messages.add(message); // 直接写入,绕过了父类 Conversation 的任何拦截修复逻辑
    }
}

5. 预期 vs 实际历史链 (Expected vs Actual)

  • 期望的合法历史
    UserMessage -> AssistantMessage(带 tool_calls) -> ToolResponseMessage -> AssistantMessage(最终文本回复,不带 tool_calls)
    
  • 实际写脏的历史(触发 400)
    UserMessage -> AssistantMessage(丢失 tool_calls) -> ToolResponseMessage -> AssistantMessage(最终文本)
    或者:
    UserMessage -> ToolResponseMessage(悬空) -> AssistantMessage(最终文本)
    

6. 建议修复方案 (设计思路与方向性伪代码)

6.1 设计原则 (Design Principles)

  1. 统一收敛校验:将所有消息写入逻辑收敛到统一的安全方法中(如 appendValidated(Message)),确保 add()addToolCallChain() 以及子类 MessageWindowConversation#add 都复用相同的校验。
  2. 拒绝误杀最终回复:工具调用后最终的 AssistantMessage(final answer) 本身是不带 tool_calls 的。校验时绝不能因为“当前写入消息无 tool_calls 且上一条消息有 tool_calls”就粗暴拦截,必须允许正常流转,避免丢弃最终回复。
  3. ToolResponse 前置强校验:写入 ToolResponseMessage 之前,必须校验前置消息是否为合法的、携带匹配 tool_callsAssistantMessage
  4. 工具链原子性写入校验addToolCallChain 写入前,必须验证入参的非空性、tool_calls 存在性,以及 ToolResponsetool_callsid/name 匹配性。

6.2 方向性伪代码 (Directional Pseudo-code)

// com/xiaozhi/ai/llm/memory/Conversation.java

/**
 * 统一的消息写入合法性校验(仅作为方向性设计参考,需处理最终回复等边界情况)
 */
protected synchronized boolean appendValidated(Message message) {
    if (message instanceof UserMessage) {
        return messages.add(message);
    }

    if (message instanceof AssistantMessage assistantMessage) {
        // 实现注意:此处不可粗暴拦截无 tool_calls 的 AssistantMessage,
        // 必须允许工具执行完后的 final answer (不带 tool_calls) 正常写入。
        // 可主要用于防止完全等价的聚合消息重复写入。
        return messages.add(assistantMessage);
    }

    if (message instanceof ToolResponseMessage toolResponse) {
        // 核心防御:ToolResponse 前置强校验
        if (messages.isEmpty()) return false;
        Message last = messages.get(messages.size() - 1);
        if (last instanceof AssistantMessage lastAssistant) {
            // 校验前置大模型消息是否声明了工具调用,且响应列表非空
            boolean hasCalls = lastAssistant.getToolCalls() != null && !lastAssistant.getToolCalls().isEmpty();
            boolean hasResponses = toolResponse.getResponses() != null && !toolResponse.getResponses().isEmpty();
            if (hasCalls && hasResponses) {
                return messages.add(toolResponse);
            }
        }
        log.warn("拒绝写入悬空或非法的 ToolResponseMessage");
        return false; 
    }
    return false;
}

public synchronized void add(Message message) {
    appendValidated(message);
}

/**
 * 规范 addToolCallChain 的入参校验并复用统一写入逻辑
 */
public synchronized void addToolCallChain(AssistantMessage toolCallMsg, ToolResponseMessage toolResponse) {
    if (toolCallMsg == null || toolCallMsg.getToolCalls() == null || toolCallMsg.getToolCalls().isEmpty()) {
        log.warn("拒绝写入非法工具链:AssistantMessage 缺少 tool_calls");
        return; 
    }
    if (toolResponse == null || toolResponse.getResponses() == null || toolResponse.getResponses().isEmpty()) {
        log.warn("拒绝写入非法工具链:ToolResponseMessage 为空");
        return;
    }
    
    // 校验 toolCallMsg 与 toolResponse 之间的 id / name 匹配性
    if (!matchesToolCalls(toolCallMsg, toolResponse)) {
        log.warn("拒绝写入非法工具链:ToolResponse 与 tool_calls 不匹配");
        return;
    }

    // 复用 append 逻辑,确保安全写入
    if (appendValidated(toolCallMsg)) {
        appendValidated(toolResponse);
    }
}

6.3 修复 MessageWindowConversation#add

// com/xiaozhi/ai/llm/memory/MessageWindowConversation.java
@Override
public synchronized void add(Message message) {
    // 严禁在子类中直接 messages.add(message) 绕过父类校验
    super.add(message); 
}

7. 验收标准 (Acceptance Criteria)

  1. 链路自愈与强校验Conversation 中绝不允许写入悬空、乱序或与 tool_calls 不匹配的 ToolResponseMessage
  2. 确保回复完整:校验逻辑不会误杀工具调用后合法的最终文本回复(即使 addToolCallChain 因异常未能写入,也不能吞掉最终文本)。
  3. 子类行为一致MessageWindowConversation 不再通过覆写绕过父类的安全过滤网。
  4. 单元测试覆盖
    • 验证正常工具链 + 最终文本回复可以顺利写入。
    • 验证悬空的 ToolResponseMessage 无法写入。
    • 验证 addToolCallChain 能够有效识别并拦截参数不匹配、空属性的非法调用链。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions