diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatConfig.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatConfig.java index 55b8445..4e4bea5 100644 --- a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatConfig.java +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatConfig.java @@ -27,6 +27,9 @@ public class ChatConfig extends BaseModelConfig { protected Boolean supportToolMessage; protected Boolean supportThinking; + /** OpenAI-compatible 消息 content 的序列化格式。 */ + protected ChatMessageContentFormat messageContentFormat = ChatMessageContentFormat.STANDARD; + // 在调用工具的时候,是否需要推理结果作为 reasoning_content 传给大模型, 比如 Deepseek // 参考文档: https://api-docs.deepseek.com/zh-cn/guides/thinking_mode#%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8 protected Boolean needReasoningContentForToolMessage; @@ -135,6 +138,35 @@ public class ChatConfig extends BaseModelConfig { return supportThinking == null || supportThinking; } + /** + * 获取消息 content 的序列化格式。 + * + * @return 消息 content 格式 + */ + public ChatMessageContentFormat getMessageContentFormat() { + return messageContentFormat; + } + + /** + * 设置消息 content 的序列化格式。 + * + * @param messageContentFormat 消息 content 格式;null 时回退为标准格式 + */ + public void setMessageContentFormat(ChatMessageContentFormat messageContentFormat) { + this.messageContentFormat = messageContentFormat == null + ? ChatMessageContentFormat.STANDARD + : messageContentFormat; + } + + /** + * 判断是否需要将纯文本 content 序列化为内容块数组。 + * + * @return 配置为内容块数组时返回 true + */ + public boolean isTextPartsMessageContent() { + return messageContentFormat == ChatMessageContentFormat.TEXT_PARTS; + } + public Boolean getNeedReasoningContentForToolMessage() { return needReasoningContentForToolMessage; } diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatMessageContentFormat.java b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatMessageContentFormat.java new file mode 100644 index 0000000..4a84c64 --- /dev/null +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/chat/ChatMessageContentFormat.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023-2026, Easy-Agents (fuhai999@gmail.com). + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.easyagents.core.model.chat; + +/** + * OpenAI-compatible 消息 content 的序列化格式。 + */ +public enum ChatMessageContentFormat { + + /** 保持供应商默认格式,纯文本 content 使用字符串。 */ + STANDARD, + + /** 将各角色的纯文本 content 统一序列化为文本内容块数组。 */ + TEXT_PARTS +} diff --git a/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatMessageSerializer.java b/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatMessageSerializer.java index 88fe800..82b4750 100644 --- a/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatMessageSerializer.java +++ b/easy-agents-core/src/main/java/com/easyagents/core/model/client/OpenAIChatMessageSerializer.java @@ -58,11 +58,39 @@ public class OpenAIChatMessageSerializer implements ChatMessageSerializer { } else if (message instanceof ToolMessage) { buildToolMessageObject(objectMap, (ToolMessage) message, config); } + normalizeMessageContent(objectMap, config); messageList.add(objectMap); }); return messageList; } + /** + * 根据模型配置将纯文本 content 规范为 OpenAI 文本内容块数组。 + * 已经是多模态内容块数组的 content 保持不变。 + * + * @param objectMap 已完成角色字段构建的消息 + * @param config 模型配置 + */ + protected void normalizeMessageContent(Map objectMap, ChatConfig config) { + if (config == null + || !config.isTextPartsMessageContent() + || !objectMap.containsKey("content")) { + return; + } + + Object content = objectMap.get("content"); + if (content instanceof List) { + return; + } + if (content == null || content instanceof String) { + String text = content == null ? "" : (String) content; + objectMap.put("content", List.of(Maps.of("type", "text").set("text", text))); + return; + } + throw new IllegalStateException( + "Unsupported OpenAI message content type: " + content.getClass().getName()); + } + protected void buildToolMessageObject(Map objectMap, ToolMessage message, ChatConfig config) { if (config.isSupportToolMessage()) { objectMap.put("role", "tool"); @@ -289,4 +317,3 @@ public class OpenAIChatMessageSerializer implements ChatMessageSerializer { } } } - diff --git a/easy-agents-core/src/test/java/com/easyagents/core/test/model/client/OpenAIChatMessageSerializerTest.java b/easy-agents-core/src/test/java/com/easyagents/core/test/model/client/OpenAIChatMessageSerializerTest.java index 6fa0b24..0d93579 100644 --- a/easy-agents-core/src/test/java/com/easyagents/core/test/model/client/OpenAIChatMessageSerializerTest.java +++ b/easy-agents-core/src/test/java/com/easyagents/core/test/model/client/OpenAIChatMessageSerializerTest.java @@ -1,7 +1,12 @@ package com.easyagents.core.test.model.client; +import com.easyagents.core.message.AiMessage; +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.easyagents.core.model.chat.ChatConfig; +import com.easyagents.core.model.chat.ChatMessageContentFormat; import com.easyagents.core.model.client.OpenAIChatMessageSerializer; import org.junit.Assert; import org.junit.Test; @@ -14,6 +19,50 @@ import java.util.Map; */ public class OpenAIChatMessageSerializerTest { + /** + * 验证标准模式继续使用原有纯文本字符串格式。 + */ + @Test + public void shouldKeepStringContentInStandardMode() { + ToolMessage toolMessage = toolMessage("call-1", "工具结果"); + + List> messages = new OpenAIChatMessageSerializer() + .serializeMessages(List.of( + SystemMessage.of("系统提示"), + new UserMessage("用户问题"), + new AiMessage("助手回答"), + toolMessage + ), new ChatConfig()); + + Assert.assertEquals("系统提示", messages.get(0).get("content")); + Assert.assertEquals("用户问题", messages.get(1).get("content")); + Assert.assertEquals("助手回答", messages.get(2).get("content")); + Assert.assertEquals("工具结果", messages.get(3).get("content")); + } + + /** + * 验证内容块模式会转换全部纯文本消息角色。 + */ + @Test + public void shouldSerializeAllTextRolesAsContentParts() { + ChatConfig config = textPartsConfig(); + ToolMessage toolMessage = toolMessage("call-1", "工具结果"); + + List> messages = new OpenAIChatMessageSerializer() + .serializeMessages(List.of( + SystemMessage.of("系统提示"), + new UserMessage("用户问题"), + new AiMessage("助手回答"), + toolMessage + ), config); + + assertTextPart(messages.get(0), "系统提示"); + assertTextPart(messages.get(1), "用户问题"); + assertTextPart(messages.get(2), "助手回答"); + assertTextPart(messages.get(3), "工具结果"); + Assert.assertEquals("call-1", messages.get(3).get("tool_call_id")); + } + /** * 验证 Data URI 会写入标准的 image_url.url 字段。 */ @@ -33,4 +82,71 @@ public class OpenAIChatMessageSerializerTest { Assert.assertEquals("image_url", imageContent.get("type")); Assert.assertEquals(dataUri, imageUrl.get("url")); } + + /** + * 验证内容块模式保留多模态数组和工具调用结构字段。 + */ + @Test + public void shouldPreserveStructuredFieldsInTextPartsMode() { + String dataUri = "data:image/png;base64,AQID"; + UserMessage userMessage = new UserMessage("识别图片"); + userMessage.addImageUrl(dataUri); + AiMessage assistantMessage = new AiMessage(null); + assistantMessage.setReasoningContent("先分析"); + assistantMessage.setToolCalls(List.of( + new ToolCall("call-1", "image_search", "{\"query\":\"license\"}"))); + ToolMessage toolMessage = toolMessage("call-1", "工具结果"); + + List> messages = new OpenAIChatMessageSerializer() + .serializeMessages(List.of(userMessage, assistantMessage, toolMessage), textPartsConfig()); + + List userContent = (List) messages.get(0).get("content"); + Assert.assertEquals(2, userContent.size()); + Assert.assertEquals("image_url", ((Map) userContent.get(1)).get("type")); + assertTextPart(messages.get(1), ""); + Assert.assertEquals("先分析", messages.get(1).get("reasoning_content")); + Assert.assertTrue(messages.get(1).containsKey("tool_calls")); + assertTextPart(messages.get(2), "工具结果"); + Assert.assertEquals("call-1", messages.get(2).get("tool_call_id")); + } + + /** + * 创建内容块数组模式配置。 + * + * @return 内容块数组模式配置 + */ + private ChatConfig textPartsConfig() { + ChatConfig config = new ChatConfig(); + config.setMessageContentFormat(ChatMessageContentFormat.TEXT_PARTS); + config.setNeedReasoningContentForToolMessage(Boolean.TRUE); + return config; + } + + /** + * 创建工具结果消息。 + * + * @param toolCallId 工具调用标识 + * @param content 工具结果文本 + * @return 工具结果消息 + */ + private ToolMessage toolMessage(String toolCallId, String content) { + ToolMessage message = new ToolMessage(); + message.setToolCallId(toolCallId); + message.setContent(content); + return message; + } + + /** + * 断言消息 content 只包含指定文本内容块。 + * + * @param message 已序列化消息 + * @param expectedText 预期文本 + */ + private void assertTextPart(Map message, String expectedText) { + List content = (List) message.get("content"); + Assert.assertEquals(1, content.size()); + Map textPart = (Map) content.get(0); + Assert.assertEquals("text", textPart.get("type")); + Assert.assertEquals(expectedText, textPart.get("text")); + } }