fix: 统一适配 DeepSeek 工具与思考历史回传

- 结构化持久化 assistant/tool 历史,恢复真实消息链回放

- 为 DeepSeek 显式开启 thinking 协议配置,并补齐 public-api 与工具英文名兜底

- 增加聊天历史回放、tool 名称与请求参数解析相关测试
This commit is contained in:
2026-05-11 21:24:20 +08:00
parent c1590b0d8a
commit e27834ee0c
18 changed files with 1441 additions and 42 deletions

View File

@@ -87,6 +87,7 @@ public class ChatStreamListener implements StreamResponseListener {
sendToolCallEnvelope(toolCall);
}
}
applyToolCallHistorySnapshot(aiMessage);
aiMessage.setContent(null);
memoryPrompt.addMessage(aiMessage);
List<ToolMessage> toolMessages = aiMessageResponse.executeToolCallsAndGetToolMessages();
@@ -275,11 +276,29 @@ public class ChatStreamListener implements StreamResponseListener {
message.setContentType("TEXT");
String fullContent = context != null && context.getFullMessage() != null ? context.getFullMessage().getContent() : null;
message.setContentText(StringUtil.hasText(fullContent) ? fullContent : assistantAccumulator.getContent());
message.setContentPayload(assistantAccumulator.buildPayload());
message.setContentPayload(assistantAccumulator.buildPayload(message.getContentText()));
message.setCreatedAt(new Date());
message.setSenderId(runtimeContext.getAssistantId());
message.setSenderName(runtimeContext.getAssistantName());
return message;
}
/**
* 在 tool call assistant 写入临时 memory 前,把 reasoning/content 快照回填到消息对象中,
* 以便前端 history 透传和 DeepSeek 下一轮请求都能拿到完整链路。
*
* @param aiMessage tool call assistant 消息
*/
private void applyToolCallHistorySnapshot(AiMessage aiMessage) {
if (aiMessage == null) {
return;
}
if (!StringUtil.hasText(aiMessage.getReasoningContent())) {
aiMessage.setReasoningContent(assistantAccumulator.getLatestToolCallReasoning());
}
if (!StringUtil.hasText(aiMessage.getTextContent())) {
aiMessage.setFullContent(assistantAccumulator.getLatestToolCallContent());
}
}
}

View File

@@ -0,0 +1,145 @@
package tech.easyflow.ai.easyagents.memory;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.message.Message;
import com.easyagents.core.message.ToolCall;
import com.easyagents.core.message.ToolMessage;
import tech.easyflow.core.runtime.ChatRuntimeHistoryPayloadHelper;
import tech.easyflow.core.runtime.ChatRuntimeMessage;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 负责把聊天运行时持久化 payload 恢复为 easy-agents 消息链。
*/
public final class ChatRuntimeHistoryMessageMapper {
private ChatRuntimeHistoryMessageMapper() {
}
/**
* 从运行时消息中恢复 assistant / tool 历史消息链。
*
* @param runtimeMessage 运行时消息
* @return 恢复后的消息列表
*/
public static List<Message> toStructuredMessages(ChatRuntimeMessage runtimeMessage) {
if (runtimeMessage == null) {
return Collections.emptyList();
}
Map<String, Object> payload = runtimeMessage.getContentPayload();
List<Map<String, Object>> messageChain = ChatRuntimeHistoryPayloadHelper.getMessageChain(payload);
if (!messageChain.isEmpty()) {
return toMessages(messageChain);
}
if (!ChatRuntimeHistoryPayloadHelper.hasStructuredHistory(payload)) {
return Collections.emptyList();
}
List<Message> messages = new ArrayList<>();
Map<String, Object> assistantMessage = ChatRuntimeHistoryPayloadHelper.getAssistantMessage(payload);
if (!assistantMessage.isEmpty()) {
AiMessage aiMessage = toAiMessage(assistantMessage);
if (aiMessage != null) {
messages.add(aiMessage);
}
}
List<Map<String, Object>> toolMessages = ChatRuntimeHistoryPayloadHelper.getToolMessages(payload);
messages.addAll(toMessages(toolMessages));
Map<String, Object> finalAssistantMessage = ChatRuntimeHistoryPayloadHelper.getFinalAssistantMessage(payload);
if (!finalAssistantMessage.isEmpty()) {
AiMessage finalAiMessage = toAiMessage(finalAssistantMessage);
if (finalAiMessage != null) {
messages.add(finalAiMessage);
}
} else if (!messages.isEmpty()
&& (runtimeMessage.getContentText() != null && !runtimeMessage.getContentText().isBlank())
&& assistantMessage.containsKey("toolCalls")) {
messages.add(new AiMessage(runtimeMessage.getContentText()));
}
return messages;
}
private static List<Message> toMessages(List<Map<String, Object>> messageChain) {
List<Message> messages = new ArrayList<>();
for (Map<String, Object> item : messageChain) {
String role = String.valueOf(item.get("role"));
if ("assistant".equalsIgnoreCase(role)) {
AiMessage aiMessage = toAiMessage(item);
if (aiMessage != null) {
messages.add(aiMessage);
}
} else if ("tool".equalsIgnoreCase(role)) {
ToolMessage toolMessage = toToolMessage(item);
if (toolMessage != null) {
messages.add(toolMessage);
}
}
}
return messages;
}
private static AiMessage toAiMessage(Map<String, Object> item) {
if (item == null || item.isEmpty()) {
return null;
}
String content = stringValue(item.get("content"));
String reasoningContent = stringValue(firstNonNull(item.get("reasoningContent"), item.get("reasoning_content")));
List<ToolCall> toolCalls = toToolCalls(ChatRuntimeHistoryPayloadHelper.getMapList(firstNonNull(item.get("toolCalls"), item.get("tool_calls"))));
boolean hasContent = content != null && !content.isBlank();
boolean hasReasoning = reasoningContent != null && !reasoningContent.isBlank();
boolean hasToolCalls = toolCalls != null && !toolCalls.isEmpty();
if (!hasContent && !hasReasoning && !hasToolCalls) {
return null;
}
AiMessage aiMessage = new AiMessage(hasContent ? content : null);
if (hasReasoning) {
aiMessage.setReasoningContent(reasoningContent);
aiMessage.setFullReasoningContent(reasoningContent);
}
if (hasToolCalls) {
aiMessage.setToolCalls(toolCalls);
}
return aiMessage;
}
private static ToolMessage toToolMessage(Map<String, Object> item) {
if (item == null || item.isEmpty()) {
return null;
}
String content = stringValue(item.get("content"));
String toolCallId = stringValue(firstNonNull(item.get("toolCallId"), item.get("tool_call_id")));
if ((content == null || content.isBlank()) && (toolCallId == null || toolCallId.isBlank())) {
return null;
}
ToolMessage toolMessage = new ToolMessage();
toolMessage.setContent(content);
toolMessage.setToolCallId(toolCallId);
return toolMessage;
}
private static List<ToolCall> toToolCalls(List<Map<String, Object>> rawToolCalls) {
if (rawToolCalls == null || rawToolCalls.isEmpty()) {
return null;
}
List<ToolCall> toolCalls = new ArrayList<>(rawToolCalls.size());
for (Map<String, Object> rawToolCall : rawToolCalls) {
ToolCall toolCall = new ToolCall();
toolCall.setId(stringValue(rawToolCall.get("id")));
toolCall.setName(stringValue(firstNonNull(rawToolCall.get("name"), rawToolCall.get("toolName"))));
toolCall.setArguments(stringValue(rawToolCall.get("arguments")));
toolCalls.add(toolCall);
}
return toolCalls;
}
private static Object firstNonNull(Object first, Object second) {
return first != null ? first : second;
}
private static String stringValue(Object value) {
return value == null ? null : String.valueOf(value);
}
}

View File

@@ -10,6 +10,9 @@ import tech.easyflow.core.runtime.ChatRuntimeMessage;
import java.util.ArrayList;
import java.util.List;
/**
* 从聊天运行时消息恢复 easy-agents 历史消息。
*/
public class RuntimeChatMemory implements ChatMemory {
private final Object id;
@@ -19,10 +22,7 @@ public class RuntimeChatMemory implements ChatMemory {
this.id = id;
if (runtimeMessages != null) {
for (ChatRuntimeMessage runtimeMessage : runtimeMessages) {
Message message = toMessage(runtimeMessage);
if (message != null) {
this.messages.add(message);
}
this.messages.addAll(toMessages(runtimeMessage));
}
}
}
@@ -55,20 +55,40 @@ public class RuntimeChatMemory implements ChatMemory {
return id;
}
private Message toMessage(ChatRuntimeMessage runtimeMessage) {
if (runtimeMessage == null || runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
return null;
private List<Message> toMessages(ChatRuntimeMessage runtimeMessage) {
if (runtimeMessage == null) {
return List.of();
}
String role = runtimeMessage.getRole();
if ("assistant".equalsIgnoreCase(role)) {
return new AiMessage(runtimeMessage.getContentText());
List<Message> structuredMessages = ChatRuntimeHistoryMessageMapper.toStructuredMessages(runtimeMessage);
if (!structuredMessages.isEmpty()) {
return structuredMessages;
}
if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
return List.of();
}
return List.of(new AiMessage(runtimeMessage.getContentText()));
}
if ("system".equalsIgnoreCase(role)) {
return new SystemMessage(runtimeMessage.getContentText());
if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
return List.of();
}
return List.of(new SystemMessage(runtimeMessage.getContentText()));
}
if ("tool".equalsIgnoreCase(role)) {
return new SystemMessage(runtimeMessage.getContentText());
List<Message> structuredMessages = ChatRuntimeHistoryMessageMapper.toStructuredMessages(runtimeMessage);
if (!structuredMessages.isEmpty()) {
return structuredMessages;
}
if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
return List.of();
}
return List.of(new SystemMessage(runtimeMessage.getContentText()));
}
return new UserMessage(runtimeMessage.getContentText());
if (runtimeMessage.getContentText() == null || runtimeMessage.getContentText().isBlank()) {
return List.of();
}
return List.of(new UserMessage(runtimeMessage.getContentText()));
}
}

View File

@@ -0,0 +1,53 @@
package tech.easyflow.ai.easyagents.tool;
import org.springframework.util.StringUtils;
import java.math.BigInteger;
import java.util.regex.Pattern;
/**
* 聊天工具名称辅助类。
*
* <p>聊天工具名称最终会作为 OpenAI-compatible 协议里的 function.name 发给上游模型,
* 因此在启用英文名称时需要确保名称稳定且满足 ASCII 约束。</p>
*
* @author Codex
* @since 2026-05-11
*/
public final class ChatToolNameHelper {
private static final Pattern SAFE_TOOL_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_-]+$");
private ChatToolNameHelper() {
}
/**
* 解析聊天工具名称。
*
* @param needEnglishName 是否优先使用英文名称
* @param englishName 英文名称
* @param displayName 展示名称
* @param fallbackPrefix 安全兜底名前缀
* @param resourceId 资源 ID
* @return 最终工具名称
*/
public static String resolveToolName(boolean needEnglishName,
String englishName,
String displayName,
String fallbackPrefix,
BigInteger resourceId) {
if (!needEnglishName) {
return StringUtils.hasText(displayName) ? displayName : buildFallbackName(fallbackPrefix, resourceId);
}
if (StringUtils.hasText(englishName) && SAFE_TOOL_NAME_PATTERN.matcher(englishName).matches()) {
return englishName;
}
return buildFallbackName(fallbackPrefix, resourceId);
}
private static String buildFallbackName(String fallbackPrefix, BigInteger resourceId) {
String prefix = StringUtils.hasText(fallbackPrefix) ? fallbackPrefix : "tool";
String suffix = resourceId == null ? "unknown" : resourceId.toString();
return prefix + "_" + suffix;
}
}

View File

@@ -67,11 +67,13 @@ public class DocumentCollectionTool extends BaseTool {
this.knowledgeId = documentCollection.getId();
this.retrievalMode = retrievalMode == null ? RetrievalMode.HYBRID : retrievalMode;
this.chatTimeContext = chatTimeContext;
if (needEnglishName) {
this.name = documentCollection.getEnglishName();
} else {
this.name = documentCollection.getTitle();
}
this.name = ChatToolNameHelper.resolveToolName(
needEnglishName,
documentCollection.getEnglishName(),
documentCollection.getTitle(),
"knowledge",
documentCollection.getId()
);
this.description = documentCollection.getDescription();
this.parameters = getDefaultParameters();
}

View File

@@ -26,11 +26,13 @@ public class WorkflowTool extends BaseTool {
public WorkflowTool(Workflow workflow, boolean needEnglishName, String definitionId) {
this.workflowId = workflow.getId();
this.definitionId = definitionId;
if (needEnglishName) {
this.name = workflow.getEnglishName();
} else {
this.name = workflow.getTitle();
}
this.name = ChatToolNameHelper.resolveToolName(
needEnglishName,
workflow.getEnglishName(),
workflow.getTitle(),
"workflow",
workflow.getId()
);
this.description = workflow.getDescription();
this.parameters = toParameters(workflow, definitionId);
}

View File

@@ -1,8 +1,11 @@
package tech.easyflow.ai.entity;
import com.easyagents.core.message.*;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.easyagents.core.message.AiMessage;
import com.easyagents.core.message.Message;
import com.easyagents.core.message.SystemMessage;
import com.easyagents.core.message.ToolCall;
import com.easyagents.core.message.ToolMessage;
import com.easyagents.core.message.UserMessage;
import com.alibaba.fastjson2.JSONObject;
import com.fasterxml.jackson.annotation.JsonProperty;
@@ -11,6 +14,9 @@ import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
/**
* Public API 聊天请求参数。
*/
public class ChatRequestParams implements Serializable {
private static final long serialVersionUID = 1L;
@@ -24,7 +30,7 @@ public class ChatRequestParams implements Serializable {
@JsonProperty("messages")
public void setMessagesFromJson(List<Object> rawMessages) {
if (rawMessages == null) {
this.messages = null;
this.messages = new ArrayList<>();
return;
}
@@ -57,8 +63,8 @@ public class ChatRequestParams implements Serializable {
return switch (role) {
case "user" -> jsonObj.toJavaObject(UserMessage.class);
case "system" -> jsonObj.toJavaObject(SystemMessage.class);
case "assistant" -> jsonObj.toJavaObject(AiMessage.class);
case "tool" -> jsonObj.toJavaObject(ToolMessage.class);
case "assistant" -> toAiMessage(jsonObj);
case "tool" -> toToolMessage(jsonObj);
default -> {
UserMessage defaultMsg = new UserMessage();
defaultMsg.setContent(content);
@@ -67,6 +73,85 @@ public class ChatRequestParams implements Serializable {
};
}
/**
* 把 JSON 结构恢复为 assistant 消息,兼容 `reasoningContent` / `reasoning_content`
* 以及 `toolCalls` / `tool_calls` 两种写法。
*
* @param jsonObj 原始 JSON
* @return assistant 消息
*/
private AiMessage toAiMessage(JSONObject jsonObj) {
AiMessage aiMessage = jsonObj.toJavaObject(AiMessage.class);
String reasoningContent = jsonObj.getString("reasoningContent");
if (reasoningContent == null || reasoningContent.isBlank()) {
reasoningContent = jsonObj.getString("reasoning_content");
}
if (reasoningContent != null && !reasoningContent.isBlank()) {
aiMessage.setReasoningContent(reasoningContent);
aiMessage.setFullReasoningContent(reasoningContent);
}
List<ToolCall> toolCalls = parseToolCalls(jsonObj);
if (!toolCalls.isEmpty()) {
aiMessage.setToolCalls(toolCalls);
}
return aiMessage;
}
/**
* 把 JSON 结构恢复为 tool 消息,兼容 `toolCallId` / `tool_call_id` 两种写法。
*
* @param jsonObj 原始 JSON
* @return tool 消息
*/
private ToolMessage toToolMessage(JSONObject jsonObj) {
ToolMessage toolMessage = jsonObj.toJavaObject(ToolMessage.class);
if (toolMessage.getToolCallId() == null || toolMessage.getToolCallId().isBlank()) {
toolMessage.setToolCallId(jsonObj.getString("tool_call_id"));
}
return toolMessage;
}
/**
* 解析 assistant 上的 tool calls。
*
* @param jsonObj 原始 JSON
* @return tool call 列表
*/
private List<ToolCall> parseToolCalls(JSONObject jsonObj) {
Object rawToolCalls = jsonObj.get("toolCalls");
if (rawToolCalls == null) {
rawToolCalls = jsonObj.get("tool_calls");
}
List<ToolCall> toolCalls = new ArrayList<>();
if (!(rawToolCalls instanceof List<?> rawList)) {
return toolCalls;
}
for (Object rawToolCall : rawList) {
JSONObject toolCallJson = JSONObject.from(rawToolCall);
if (toolCallJson == null) {
continue;
}
ToolCall toolCall = new ToolCall();
toolCall.setId(toolCallJson.getString("id"));
String toolName = toolCallJson.getString("name");
if (toolName == null || toolName.isBlank()) {
JSONObject functionJson = toolCallJson.getJSONObject("function");
if (functionJson != null) {
toolName = functionJson.getString("name");
if (toolCallJson.getString("arguments") == null || toolCallJson.getString("arguments").isBlank()) {
toolCall.setArguments(functionJson.getString("arguments"));
}
}
}
toolCall.setName(toolName);
if (toolCall.getArguments() == null || toolCall.getArguments().isBlank()) {
toolCall.setArguments(toolCallJson.getString("arguments"));
}
toolCalls.add(toolCall);
}
return toolCalls;
}
public List<Message> getMessages() {
return messages;
}
@@ -83,7 +168,11 @@ public class ChatRequestParams implements Serializable {
this.botId = botId;
}
public String getConversationId() {return conversationId;}
public String getConversationId() {
return conversationId;
}
public void setConversationId(String conversationId) {this.conversationId = conversationId;}
}
public void setConversationId(String conversationId) {
this.conversationId = conversationId;
}
}

View File

@@ -75,6 +75,12 @@ public class Model extends ModelBase {
deepseekConfig.setApiKey(checkAndGetApiKey());
deepseekConfig.setModel(checkAndGetModelName());
deepseekConfig.setRequestPath(checkAndGetRequestPath());
deepseekConfig.setSupportThinking(Boolean.TRUE);
deepseekConfig.setThinkingProtocol("deepseek");
deepseekConfig.setNeedReasoningContentForToolMessage(Boolean.TRUE);
if (getSupportToolMessage() != null) {
deepseekConfig.setSupportToolMessage(getSupportToolMessage());
}
return new DeepseekChatModel(deepseekConfig);
default:
OpenAIChatConfig openAIChatConfig = new OpenAIChatConfig();

View File

@@ -298,7 +298,7 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
}
UserMessage userMessage = new UserMessage(prompt);
userMessage.addTools(buildFunctionList(Maps.of("botId", botId)
.set("needEnglishName", false)
.set("needEnglishName", true)
.set("bot", chatCheckResult.getAiBot())
.set("chatTimeContext", chatTimeContext)
.set("publishedOnly", chatCheckResult.isPublishedAccess())));
@@ -350,13 +350,15 @@ public class BotServiceImpl extends ServiceImpl<BotMapper, Bot> implements BotSe
BotServiceImpl.ChatCheckResult chatCheckResult, ChatRuntimeContext runtimeContext) {
Map<String, Object> modelOptions = chatCheckResult.getModelOptions();
ChatOptions chatOptions = getChatOptions(modelOptions);
Boolean enableDeepThinking = MapUtil.getBoolean(modelOptions, Bot.KEY_ENABLE_DEEP_THINKING, false);
chatOptions.setThinkingEnabled(enableDeepThinking);
ChatModel chatModel = chatCheckResult.getChatModel();
String systemPrompt = buildSystemPromptWithFaqImageRule(
MapUtil.getString(modelOptions, Bot.KEY_SYSTEM_PROMPT)
);
UserMessage userMessage = new UserMessage(prompt);
userMessage.addTools(buildFunctionList(Maps.of("botId", botId)
.set("needEnglishName", false)
.set("needEnglishName", true)
.set("needAccountId", false)
.set("bot", chatCheckResult.getAiBot())
.set("publishedOnly", chatCheckResult.isPublishedAccess())