fix: 统一 OpenAI 消息内容块数组格式
- 将 system、user、assistant、tool 及历史上下文 content 统一为数组 - 保留 DeepSeek、GLM 专用格式规则并补充多模态与工具消息测试
This commit is contained in:
@@ -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<OpenAIMessage> doFormat(List<Msg> messages) {
|
||||
return AgentOpenAIChatFormatter.normalizeContent(super.doFormat(messages));
|
||||
}
|
||||
}
|
||||
@@ -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<OpenAIMessage> doFormat(List<Msg> messages) {
|
||||
return AgentOpenAIChatFormatter.normalizeContent(super.doFormat(messages));
|
||||
}
|
||||
}
|
||||
@@ -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<OpenAIMessage> doFormat(List<Msg> messages) {
|
||||
List<OpenAIMessage> formattedMessages = super.doFormat(messages);
|
||||
return normalizeContent(super.doFormat(messages));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 OpenAI 消息中的 content 统一规范为内容块数组。
|
||||
*
|
||||
* @param formattedMessages 已完成供应商规则转换的 OpenAI 消息
|
||||
* @return content 已规范为数组的原消息列表
|
||||
*/
|
||||
static List<OpenAIMessage> normalizeContent(List<OpenAIMessage> 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;
|
||||
|
||||
@@ -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<Model> {
|
||||
.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<Model> {
|
||||
.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<Model> {
|
||||
.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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.easyagents.agent.runtime.model;
|
||||
|
||||
/**
|
||||
* OpenAI-compatible 请求中消息 content 的格式策略。
|
||||
*/
|
||||
public enum AgentMessageContentFormat {
|
||||
|
||||
/** 使用 AgentScope 默认格式,纯文本为字符串,多模态内容为数组。 */
|
||||
STANDARD,
|
||||
|
||||
/** 将全部角色的 content 规范为内容块数组。 */
|
||||
TEXT_PARTS
|
||||
}
|
||||
@@ -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<String, Object> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<OpenAIMessage> 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<OpenAIMessage> 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<OpenAIContentPart> 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<OpenAIMessage> 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<OpenAIMessage> 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<OpenAIMessage> 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<OpenAIContentPart> 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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user