From a7e89cee3dfccf3186a84184e1927b5926c24387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Tue, 28 Jul 2026 12:24:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=20OpenAI=20=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=86=85=E5=AE=B9=E5=9D=97=E6=95=B0=E7=BB=84=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 system、user、assistant、tool 及历史上下文 content 统一为数组 - 保留 DeepSeek、GLM 专用格式规则并补充多模态与工具消息测试 --- .../AgentDeepSeekChatFormatter.java | 24 +++ .../agentscope/AgentGLMChatFormatter.java | 24 +++ .../agentscope/AgentOpenAIChatFormatter.java | 23 ++- .../agentscope/AgentScopeModelFactory.java | 12 +- .../model/AgentMessageContentFormat.java | 13 ++ .../agent/runtime/model/AgentModelSpec.java | 22 +-- .../model/AgentSystemContentFormat.java | 13 -- .../AgentOpenAIChatFormatterTest.java | 169 +++++++++++++++++- 8 files changed, 259 insertions(+), 41 deletions(-) create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentDeepSeekChatFormatter.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentGLMChatFormatter.java create mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/model/AgentMessageContentFormat.java delete mode 100644 easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/model/AgentSystemContentFormat.java diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentDeepSeekChatFormatter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentDeepSeekChatFormatter.java new file mode 100644 index 0000000..6736328 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentDeepSeekChatFormatter.java @@ -0,0 +1,24 @@ +package com.easyagents.agent.runtime.agentscope; + +import io.agentscope.core.formatter.openai.DeepSeekFormatter; +import io.agentscope.core.formatter.openai.dto.OpenAIMessage; +import io.agentscope.core.message.Msg; + +import java.util.List; + +/** + * 保留 DeepSeek 专用规则并将全部消息 content 规范为内容块数组。 + */ +public final class AgentDeepSeekChatFormatter extends DeepSeekFormatter { + + /** + * 转换 DeepSeek 消息并在供应商规则之后统一 content 格式。 + * + * @param messages AgentScope 消息 + * @return OpenAI 请求消息 + */ + @Override + protected List doFormat(List messages) { + return AgentOpenAIChatFormatter.normalizeContent(super.doFormat(messages)); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentGLMChatFormatter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentGLMChatFormatter.java new file mode 100644 index 0000000..6937ebb --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentGLMChatFormatter.java @@ -0,0 +1,24 @@ +package com.easyagents.agent.runtime.agentscope; + +import io.agentscope.core.formatter.openai.GLMFormatter; +import io.agentscope.core.formatter.openai.dto.OpenAIMessage; +import io.agentscope.core.message.Msg; + +import java.util.List; + +/** + * 保留 GLM 专用规则并将全部消息 content 规范为内容块数组。 + */ +public final class AgentGLMChatFormatter extends GLMFormatter { + + /** + * 转换 GLM 消息并在供应商规则之后统一 content 格式。 + * + * @param messages AgentScope 消息 + * @return OpenAI 请求消息 + */ + @Override + protected List doFormat(List messages) { + return AgentOpenAIChatFormatter.normalizeContent(super.doFormat(messages)); + } +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentOpenAIChatFormatter.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentOpenAIChatFormatter.java index 8f56602..da0595d 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentOpenAIChatFormatter.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentOpenAIChatFormatter.java @@ -8,22 +8,37 @@ import io.agentscope.core.message.Msg; import java.util.List; /** - * 为 OpenAI-compatible 模型补充 system content 内容块数组兼容能力。 + * 为 OpenAI-compatible 模型补充全部消息 content 内容块数组兼容能力。 */ public final class AgentOpenAIChatFormatter extends OpenAIChatFormatter { /** - * 将 AgentScope 消息转换为 OpenAI 消息,并规范 system 文本的 content 格式。 + * 将 AgentScope 消息转换为 OpenAI 消息,并规范全部角色的 content 格式。 * * @param messages AgentScope 消息 * @return OpenAI 请求消息 */ @Override protected List doFormat(List messages) { - List formattedMessages = super.doFormat(messages); + return normalizeContent(super.doFormat(messages)); + } + + /** + * 将 OpenAI 消息中的 content 统一规范为内容块数组。 + * + * @param formattedMessages 已完成供应商规则转换的 OpenAI 消息 + * @return content 已规范为数组的原消息列表 + */ + static List normalizeContent(List formattedMessages) { for (OpenAIMessage message : formattedMessages) { - if ("system".equals(message.getRole()) && message.getContent() instanceof String text) { + Object content = message.getContent(); + if (content instanceof String text) { message.setContent(List.of(OpenAIContentPart.text(text))); + } else if (content == null) { + message.setContent(List.of(OpenAIContentPart.text(""))); + } else if (!(content instanceof List)) { + throw new IllegalStateException( + "Unsupported OpenAI message content type: " + content.getClass().getName()); } } return formattedMessages; diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeModelFactory.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeModelFactory.java index 2f5ccdb..9002e6e 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeModelFactory.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/agentscope/AgentScopeModelFactory.java @@ -6,7 +6,7 @@ import com.easyagents.agent.runtime.model.AgentHttpVersionPolicy; import com.easyagents.agent.runtime.model.AgentModelFactory; import com.easyagents.agent.runtime.model.AgentModelProviderType; import com.easyagents.agent.runtime.model.AgentModelSpec; -import com.easyagents.agent.runtime.model.AgentSystemContentFormat; +import com.easyagents.agent.runtime.model.AgentMessageContentFormat; import io.agentscope.core.formatter.openai.DeepSeekFormatter; import io.agentscope.core.formatter.openai.GLMFormatter; import io.agentscope.core.model.*; @@ -154,7 +154,7 @@ public class AgentScopeModelFactory implements AgentModelFactory { .stream(Boolean.TRUE.equals(options.getStream())) .httpTransport(httpTransportProvider.getTransport(modelSpec.getHttpVersionPolicy(), baseUrl)) .generateOptions(options); - if (modelSpec.getSystemContentFormat() == AgentSystemContentFormat.TEXT_PARTS) { + if (modelSpec.getMessageContentFormat() == AgentMessageContentFormat.TEXT_PARTS) { builder.formatter(new AgentOpenAIChatFormatter()); } return builder.build(); @@ -210,7 +210,9 @@ public class AgentScopeModelFactory implements AgentModelFactory { .endpointPath(modelSpec.getEndpointPath()) .stream(Boolean.TRUE.equals(options.getStream())) .httpTransport(httpTransportProvider.getTransport(modelSpec.getHttpVersionPolicy(), baseUrl)) - .formatter(new DeepSeekFormatter()) + .formatter(modelSpec.getMessageContentFormat() == AgentMessageContentFormat.TEXT_PARTS + ? new AgentDeepSeekChatFormatter() + : new DeepSeekFormatter()) .generateOptions(options); return builder.build(); } @@ -231,7 +233,9 @@ public class AgentScopeModelFactory implements AgentModelFactory { .endpointPath(modelSpec.getEndpointPath()) .stream(Boolean.TRUE.equals(options.getStream())) .httpTransport(httpTransportProvider.getTransport(modelSpec.getHttpVersionPolicy(), baseUrl)) - .formatter(new GLMFormatter()) + .formatter(modelSpec.getMessageContentFormat() == AgentMessageContentFormat.TEXT_PARTS + ? new AgentGLMChatFormatter() + : new GLMFormatter()) .generateOptions(options); return builder.build(); } diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/model/AgentMessageContentFormat.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/model/AgentMessageContentFormat.java new file mode 100644 index 0000000..0441f57 --- /dev/null +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/model/AgentMessageContentFormat.java @@ -0,0 +1,13 @@ +package com.easyagents.agent.runtime.model; + +/** + * OpenAI-compatible 请求中消息 content 的格式策略。 + */ +public enum AgentMessageContentFormat { + + /** 使用 AgentScope 默认格式,纯文本为字符串,多模态内容为数组。 */ + STANDARD, + + /** 将全部角色的 content 规范为内容块数组。 */ + TEXT_PARTS +} diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/model/AgentModelSpec.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/model/AgentModelSpec.java index ac33ff1..9b3d1cd 100644 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/model/AgentModelSpec.java +++ b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/model/AgentModelSpec.java @@ -16,7 +16,7 @@ public class AgentModelSpec { private boolean supportImage; private boolean supportImageBase64Only; private AgentHttpVersionPolicy httpVersionPolicy = AgentHttpVersionPolicy.AUTO; - private AgentSystemContentFormat systemContentFormat = AgentSystemContentFormat.STRING; + private AgentMessageContentFormat messageContentFormat = AgentMessageContentFormat.STANDARD; private Map metadata = new LinkedHashMap<>(); /** @@ -164,23 +164,23 @@ public class AgentModelSpec { } /** - * 获取 OpenAI-compatible 请求中的 system content 格式。 + * 获取 OpenAI-compatible 请求中的消息 content 格式。 * - * @return system content 格式 + * @return 消息 content 格式 */ - public AgentSystemContentFormat getSystemContentFormat() { - return systemContentFormat; + public AgentMessageContentFormat getMessageContentFormat() { + return messageContentFormat; } /** - * 设置 OpenAI-compatible 请求中的 system content 格式。 + * 设置 OpenAI-compatible 请求中的消息 content 格式。 * - * @param systemContentFormat system content 格式 + * @param messageContentFormat 消息 content 格式 */ - public void setSystemContentFormat(AgentSystemContentFormat systemContentFormat) { - this.systemContentFormat = systemContentFormat == null - ? AgentSystemContentFormat.STRING - : systemContentFormat; + public void setMessageContentFormat(AgentMessageContentFormat messageContentFormat) { + this.messageContentFormat = messageContentFormat == null + ? AgentMessageContentFormat.STANDARD + : messageContentFormat; } /** diff --git a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/model/AgentSystemContentFormat.java b/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/model/AgentSystemContentFormat.java deleted file mode 100644 index f06fd45..0000000 --- a/easy-agents-agent-runtime/src/main/java/com/easyagents/agent/runtime/model/AgentSystemContentFormat.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.easyagents.agent.runtime.model; - -/** - * OpenAI-compatible 请求中 system 消息的 content 格式。 - */ -public enum AgentSystemContentFormat { - - /** 使用字符串 content,保持 OpenAI Chat Completions 的常规格式。 */ - STRING, - - /** 使用仅包含 text 内容块的数组 content。 */ - TEXT_PARTS -} diff --git a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentOpenAIChatFormatterTest.java b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentOpenAIChatFormatterTest.java index 3e4816e..18a1533 100644 --- a/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentOpenAIChatFormatterTest.java +++ b/easy-agents-agent-runtime/src/test/java/com/easyagents/agent/runtime/agentscope/AgentOpenAIChatFormatterTest.java @@ -1,19 +1,25 @@ package com.easyagents.agent.runtime.agentscope; import com.easyagents.agent.runtime.model.AgentGenerationOptions; +import com.easyagents.agent.runtime.model.AgentMessageContentFormat; import com.easyagents.agent.runtime.model.AgentModelProviderType; import com.easyagents.agent.runtime.model.AgentModelSpec; -import com.easyagents.agent.runtime.model.AgentSystemContentFormat; import io.agentscope.core.formatter.openai.dto.OpenAIContentPart; import io.agentscope.core.formatter.openai.dto.OpenAIMessage; +import io.agentscope.core.message.Base64Source; +import io.agentscope.core.message.ImageBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; import io.agentscope.core.model.OpenAIChatModel; import org.junit.Assert; import org.junit.Test; import java.lang.reflect.Field; import java.util.List; +import java.util.Map; /** * Agent OpenAI Chat 消息格式兼容测试。 @@ -21,21 +27,96 @@ import java.util.List; public class AgentOpenAIChatFormatterTest { /** - * 验证 system 文本转换为 text 内容块数组,普通 user 文本保持字符串。 + * 验证多轮上下文中全部纯文本消息都转换为 text 内容块数组。 */ @Test - public void shouldConvertOnlySystemTextToContentParts() { + public void shouldConvertAllTextMessagesToContentParts() { AgentOpenAIChatFormatter formatter = new AgentOpenAIChatFormatter(); List messages = formatter.format(List.of( Msg.builder().role(MsgRole.SYSTEM).textContent("system prompt").build(), - Msg.builder().role(MsgRole.USER).textContent("hello").build())); + Msg.builder().role(MsgRole.USER).textContent("hello").build(), + Msg.builder().role(MsgRole.ASSISTANT).textContent("hi").build(), + Msg.builder().role(MsgRole.USER).textContent("follow up").build())); + + assertTextContent(messages.get(0), "system prompt"); + assertTextContent(messages.get(1), "hello"); + assertTextContent(messages.get(2), "hi"); + assertTextContent(messages.get(3), "follow up"); + } + + /** + * 验证图片加文本消息保持已有内容块数组和顺序。 + */ + @Test + public void shouldPreserveMultimodalContentParts() { + AgentOpenAIChatFormatter formatter = new AgentOpenAIChatFormatter(); + ImageBlock image = ImageBlock.builder() + .source(Base64Source.builder() + .mediaType("image/png") + .data("aW1hZ2U=") + .build()) + .build(); + + List messages = formatter.format(List.of( + Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text("describe").build(), image) + .build())); - Assert.assertTrue(messages.get(0).getContent() instanceof List); List contentParts = messages.get(0).getContentAsList(); - Assert.assertEquals(1, contentParts.size()); + Assert.assertNotNull(contentParts); + Assert.assertEquals(2, contentParts.size()); Assert.assertEquals("text", contentParts.get(0).getType()); - Assert.assertEquals("system prompt", contentParts.get(0).getText()); - Assert.assertEquals("hello", messages.get(1).getContent()); + Assert.assertEquals("describe", contentParts.get(0).getText()); + Assert.assertEquals("image_url", contentParts.get(1).getType()); + } + + /** + * 验证工具调用和工具结果的附属字段在 content 数组化后保持不变。 + */ + @Test + public void shouldPreserveToolCallFieldsAndConvertToolResult() { + AgentOpenAIChatFormatter formatter = new AgentOpenAIChatFormatter(); + ToolUseBlock toolUse = ToolUseBlock.builder() + .id("call-1") + .name("lookup") + .input(Map.of("query", "weather")) + .build(); + ToolResultBlock toolResult = ToolResultBlock.builder() + .id("call-1") + .name("lookup") + .output(TextBlock.builder().text("sunny").build()) + .build(); + + List messages = formatter.format(List.of( + Msg.builder().role(MsgRole.ASSISTANT).content(toolUse).build(), + Msg.builder().role(MsgRole.TOOL).content(toolResult).build())); + + assertTextContent(messages.get(0), ""); + Assert.assertNotNull(messages.get(0).getToolCalls()); + Assert.assertEquals(1, messages.get(0).getToolCalls().size()); + Assert.assertEquals("call-1", messages.get(0).getToolCalls().get(0).getId()); + assertTextContent(messages.get(1), "sunny"); + Assert.assertEquals("call-1", messages.get(1).getToolCallId()); + } + + /** + * 验证专用 Formatter 的供应商规则执行后仍会统一 content 数组。 + */ + @Test + public void shouldPreserveDeepSeekAndGlmRulesWithContentParts() { + List deepSeekMessages = new AgentDeepSeekChatFormatter().format(List.of( + Msg.builder().role(MsgRole.SYSTEM).textContent("system prompt").build())); + Assert.assertEquals("user", deepSeekMessages.get(0).getRole()); + assertTextContent(deepSeekMessages.get(0), "system prompt"); + + List glmMessages = new AgentGLMChatFormatter().format(List.of( + Msg.builder().role(MsgRole.SYSTEM).textContent("system prompt").build())); + Assert.assertEquals(2, glmMessages.size()); + Assert.assertEquals("system", glmMessages.get(0).getRole()); + Assert.assertEquals("user", glmMessages.get(1).getRole()); + assertTextContent(glmMessages.get(0), "system prompt"); + assertTextContent(glmMessages.get(1), ""); } /** @@ -50,7 +131,7 @@ public class AgentOpenAIChatFormatterTest { spec.setModelName("vlm-test"); spec.setBaseUrl("http://model.example.com/v1"); spec.setApiKey("test-key"); - spec.setSystemContentFormat(AgentSystemContentFormat.TEXT_PARTS); + spec.setMessageContentFormat(AgentMessageContentFormat.TEXT_PARTS); OpenAIChatModel model = (OpenAIChatModel) new AgentScopeModelFactory() .create(spec, new AgentGenerationOptions()); @@ -59,4 +140,74 @@ public class AgentOpenAIChatFormatterTest { Assert.assertTrue(formatterField.get(model) instanceof AgentOpenAIChatFormatter); } + + /** + * 验证默认策略继续使用 AgentScope 原生 Formatter。 + * + * @throws Exception 反射读取 Formatter 失败时抛出 + */ + @Test + public void standardPolicyShouldKeepDefaultOpenAIFormatter() throws Exception { + AgentModelSpec spec = new AgentModelSpec(); + spec.setProviderType(AgentModelProviderType.OPENAI_COMPATIBLE); + spec.setModelName("chat-test"); + spec.setBaseUrl("http://model.example.com/v1"); + spec.setApiKey("test-key"); + + OpenAIChatModel model = (OpenAIChatModel) new AgentScopeModelFactory() + .create(spec, new AgentGenerationOptions()); + Field formatterField = OpenAIChatModel.class.getDeclaredField("formatter"); + formatterField.setAccessible(true); + + Assert.assertFalse(formatterField.get(model) instanceof AgentOpenAIChatFormatter); + } + + /** + * 验证严格数组策略为专用 Provider 安装保留供应商规则的 Formatter。 + * + * @throws Exception 反射读取 Formatter 失败时抛出 + */ + @Test + public void textPartsPolicyShouldInstallProviderSpecificFormatters() throws Exception { + Assert.assertTrue(formatterFor(AgentModelProviderType.DEEPSEEK) + instanceof AgentDeepSeekChatFormatter); + Assert.assertTrue(formatterFor(AgentModelProviderType.GLM) + instanceof AgentGLMChatFormatter); + } + + /** + * 创建指定 Provider 的严格数组模型并读取其 Formatter。 + * + * @param providerType Provider 类型 + * @return 模型 Formatter + * @throws Exception 反射读取 Formatter 失败时抛出 + */ + private Object formatterFor(AgentModelProviderType providerType) throws Exception { + AgentModelSpec spec = new AgentModelSpec(); + spec.setProviderType(providerType); + spec.setModelName("provider-test"); + spec.setApiKey("test-key"); + spec.setMessageContentFormat(AgentMessageContentFormat.TEXT_PARTS); + + OpenAIChatModel model = (OpenAIChatModel) new AgentScopeModelFactory() + .create(spec, new AgentGenerationOptions()); + Field formatterField = OpenAIChatModel.class.getDeclaredField("formatter"); + formatterField.setAccessible(true); + return formatterField.get(model); + } + + /** + * 断言消息只包含一个指定文本内容块。 + * + * @param message OpenAI 请求消息 + * @param expectedText 预期文本 + */ + private void assertTextContent(OpenAIMessage message, String expectedText) { + Assert.assertTrue(message.getContent() instanceof List); + List contentParts = message.getContentAsList(); + Assert.assertNotNull(contentParts); + Assert.assertEquals(1, contentParts.size()); + Assert.assertEquals("text", contentParts.get(0).getType()); + Assert.assertEquals(expectedText, contentParts.get(0).getText()); + } }