From 394919e06e983b7cc69fd898af8e242114e46978 Mon Sep 17 00:00:00 2001 From: guslegend <1670547022@qq.com> Date: Fri, 9 Oct 2026 11:15:04 +0800 Subject: [PATCH 1/3] fix(ollama): use native schemas for structured agent output --- .../model/ollama/OllamaChatModel.java | 3 + .../model/ollama/options/OllamaOptions.java | 11 + .../ollama/OllamaStructuredOutputTest.java | 238 ++++++++++++++++++ .../ollama/options/OllamaOptionsTest.java | 34 +++ docs/v2/en/integration/model/ollama.md | 18 ++ docs/v2/zh/integration/model/ollama.md | 18 ++ 6 files changed, 322 insertions(+) create mode 100644 agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java index 502a88f1c6..20a60f8c93 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java @@ -102,6 +102,9 @@ public OllamaChatModel( defaultOptions != null ? defaultOptions : OllamaOptions.builder().build(); this.formatter = formatter != null ? formatter : new OllamaChatFormatter(); this.stream = stream; + setNativeStructuredOutput(true); + // Keep tool-assisted structured output on the synthetic tool path. + setNativeStructuredOutputWithTools(false); HttpTransport transport = httpTransport != null ? httpTransport : HttpTransportFactory.getDefault(); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/options/OllamaOptions.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/options/OllamaOptions.java index 1c94912599..2db650d375 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/options/OllamaOptions.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/options/OllamaOptions.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; +import io.agentscope.core.formatter.ResponseFormat; import io.agentscope.core.model.ExecutionConfig; import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.util.JsonUtils; @@ -392,6 +393,16 @@ public static OllamaOptions fromGenerateOptions(GenerateOptions genOptions) { builder.thinkOption(ThinkOption.ThinkBoolean.ENABLED); } + ResponseFormat responseFormat = genOptions.getResponseFormat(); + if (responseFormat != null) { + if ("json_schema".equals(responseFormat.getType())) { + // Ollama expects the schema itself, without the OpenAI response-format wrapper. + builder.format(responseFormat.getJsonSchema().getSchema()); + } else if ("json_object".equals(responseFormat.getType())) { + builder.format("json"); + } + } + // Map additional parameters from map if (genOptions.getAdditionalBodyParams() != null) { applyAdditionalParams(builder, genOptions.getAdditionalBodyParams()); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java new file mode 100644 index 0000000000..46eb4a2cd1 --- /dev/null +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java @@ -0,0 +1,238 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * 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 + * + * https://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 io.agentscope.extensions.model.ollama; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.JsonNode; +import io.agentscope.core.ReActAgent; +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.ThinkingBlock; +import io.agentscope.core.model.transport.HttpRequest; +import io.agentscope.core.model.transport.HttpResponse; +import io.agentscope.core.model.transport.HttpTransport; +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.util.JsonUtils; +import io.agentscope.extensions.model.ollama.options.OllamaOptions; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Flux; + +class OllamaStructuredOutputTest { + private static final String JSON = "{\"description\":[\"a red apple\"],\"name\":\"apple\"}"; + private static final String IMAGE = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aG1kAAAAASUVORK5CYII="; + + public static class ImageDescription { + public List description; + public String name; + } + + public static class Tools { + @Tool(name = "describe", description = "Describe an image") + public String describe() { + return "a red apple"; + } + } + + @Test + void constructorsDeclareNativeOutputWithoutTools() { + HttpTransport transport = mock(HttpTransport.class); + OllamaChatModel model = + new OllamaChatModel("qwen3.5:9b", "http://localhost:11434", null, null, transport); + assertTrue(model.supportsNativeStructuredOutput()); + assertFalse(model.supportsNativeStructuredOutputWithTools()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void structuredImageResponsePreservesJsonAndFinalUsage(boolean streaming) { + HttpTransport transport = mock(HttpTransport.class); + if (streaming) { + when(transport.stream(any(HttpRequest.class))) + .thenReturn( + Flux.just( + frame( + Map.of( + "role", + "assistant", + "thinking", + "Inspecting the image."), + false), + frame( + Map.of( + "role", + "assistant", + "content", + JSON.substring(0, 23)), + false), + frame( + Map.of( + "role", + "assistant", + "content", + JSON.substring(23)), + false), + frame(Map.of("role", "assistant", "content", ""), true))); + } else { + when(transport.execute(any(HttpRequest.class))) + .thenReturn( + HttpResponse.builder() + .statusCode(200) + .body(frame(Map.of("role", "assistant", "content", JSON), true)) + .build()); + } + // Per-call schema must replace a default JSON-only format. + OllamaChatModel model = + OllamaChatModel.builder().modelName("qwen3.5:9b").stream(streaming) + .httpTransport(transport) + .defaultOptions(OllamaOptions.builder().format("json").build()) + .build(); + Msg input = + Msg.builder() + .role(MsgRole.USER) + .content( + List.of( + TextBlock.builder().text("Describe this image").build(), + ImageBlock.builder() + .source( + Base64Source.builder() + .mediaType("image/png") + .data(IMAGE) + .build()) + .build())) + .build(); + try (ReActAgent agent = ReActAgent.builder().name("vision").model(model).build()) { + Msg result = + agent.call(List.of(input), ImageDescription.class) + .block(Duration.ofSeconds(10)); + assertNotNull(result); + ImageDescription data = result.getStructuredData(ImageDescription.class); + assertEquals("apple", data.name); + assertEquals(List.of("a red apple"), data.description); + assertEquals(JSON, result.getTextContent()); + assertNotNull(result.getUsage()); + assertEquals(23, result.getUsage().getInputTokens()); + assertEquals(12, result.getUsage().getOutputTokens()); + assertEquals(true, result.getMetadata().get("done")); + assertEquals("qwen3.5:9b", result.getMetadata().get("model")); + if (streaming) { + assertEquals( + "Inspecting the image.", + result.getContentBlocks(ThinkingBlock.class).get(0).getThinking()); + } + } + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + if (streaming) verify(transport).stream(captor.capture()); + else verify(transport).execute(captor.capture()); + JsonNode request = + JsonUtils.getJsonCodec().fromJson(captor.getValue().getBody(), JsonNode.class); + assertEquals("object", request.path("format").path("type").asText()); + assertTrue(request.path("format").path("properties").has("name")); + assertTrue(request.path("format").path("properties").has("description")); + assertFalse(request.path("format").has("json_schema")); + assertFalse(request.has("tools")); + assertTrue( + request.path("messages").findValues("images").stream() + .anyMatch(images -> images.get(0).asText().equals(IMAGE))); + } + + @Test + void structuredOutputWithBusinessToolsRetainsSyntheticToolPath() { + HttpTransport transport = mock(HttpTransport.class); + Map call = + Map.of( + "function", + Map.of( + "name", + "generate_response", + "arguments", + Map.of( + "response", + Map.of( + "name", + "apple", + "description", + List.of("a red apple"))))); + when(transport.stream(any(HttpRequest.class))) + .thenReturn( + Flux.just( + frame( + Map.of( + "role", + "assistant", + "content", + "", + "tool_calls", + List.of(call)), + false), + frame(Map.of("role", "assistant", "content", ""), true))); + OllamaChatModel model = + OllamaChatModel.builder().modelName("qwen3.5:9b").httpTransport(transport).build(); + Toolkit toolkit = new Toolkit(); + toolkit.registerTool(new Tools()); + try (ReActAgent agent = + ReActAgent.builder().name("tools").model(model).toolkit(toolkit).build()) { + Msg result = + agent.call("Describe an image", ImageDescription.class) + .block(Duration.ofSeconds(10)); + assertNotNull(result); + assertEquals("apple", result.getStructuredData(ImageDescription.class).name); + assertEquals(12, result.getUsage().getOutputTokens()); + } + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + verify(transport).stream(captor.capture()); + JsonNode request = + JsonUtils.getJsonCodec().fromJson(captor.getValue().getBody(), JsonNode.class); + assertFalse(request.has("format")); + List names = request.path("tools").findValues("name"); + assertTrue(names.stream().anyMatch(n -> "generate_response".equals(n.asText()))); + assertTrue(names.stream().anyMatch(n -> "describe".equals(n.asText()))); + } + + private static String frame(Map message, boolean done) { + return JsonUtils.getJsonCodec() + .toJson( + Map.of( + "model", + "qwen3.5:9b", + "message", + message, + "done", + done, + "prompt_eval_count", + done ? 23 : 0, + "eval_count", + done ? 12 : 0)); + } +} diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/options/OllamaOptionsTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/options/OllamaOptionsTest.java index 1057458ea2..27a8279c18 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/options/OllamaOptionsTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/options/OllamaOptionsTest.java @@ -22,6 +22,8 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.formatter.JsonSchema; +import io.agentscope.core.formatter.ResponseFormat; import io.agentscope.core.model.ExecutionConfig; import io.agentscope.core.model.GenerateOptions; import java.time.Duration; @@ -108,6 +110,38 @@ void testFromGenerateOptions() { assertEquals(Integer.valueOf(123), options.getSeed()); } + @Test + void testJsonSchemaResponseFormat() { + Map schema = + Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string"))); + GenerateOptions options = + GenerateOptions.builder() + .responseFormat( + ResponseFormat.jsonSchema( + JsonSchema.builder() + .name("result") + .schema(schema) + .strict(true) + .build())) + .build(); + + assertEquals(schema, OllamaOptions.fromGenerateOptions(options).getFormat()); + } + + @Test + void testJsonObjectResponseFormat() { + GenerateOptions options = + GenerateOptions.builder().responseFormat(ResponseFormat.jsonObject()).build(); + assertEquals("json", OllamaOptions.fromGenerateOptions(options).getFormat()); + } + + @Test + void testTextResponseFormat() { + GenerateOptions options = + GenerateOptions.builder().responseFormat(ResponseFormat.text()).build(); + assertNull(OllamaOptions.fromGenerateOptions(options).getFormat()); + } + @Test @DisplayName("Should handle null GenerateOptions in fromGenerateOptions") void testFromNullGenerateOptions() { diff --git a/docs/v2/en/integration/model/ollama.md b/docs/v2/en/integration/model/ollama.md index bd95ba4784..808d6aa7bd 100644 --- a/docs/v2/en/integration/model/ollama.md +++ b/docs/v2/en/integration/model/ollama.md @@ -39,6 +39,24 @@ OllamaChatModel model = OllamaChatModel.builder() .build(); ``` +## Structured output + +For `agent.call(messages, Output.class)` without application tools, the Ollama provider sends the output class's JSON Schema in the native `format` field. The returned message contains `_structured_output`, so `getStructuredData(Output.class)` reads the result while preserving the response text, thinking, and usage. + +```java +public record ImageDescription(java.util.List description, String name) {} + +try (ReActAgent agent = ReActAgent.builder() + .name("image-analysis") + .model(model) // Use a local vision model for image inputs. + .build()) { + Msg result = agent.call(messages, ImageDescription.class).block(); + ImageDescription description = result.getStructuredData(ImageDescription.class); +} +``` + +The same format works for text and image inputs, in streaming and non-streaming mode. With application tools registered, structured output continues to use the synthetic `generate_response` tool. Do not set a JSON-only default `format` for that tool path. Native JSON Schema output requires a local Ollama server that supports `format`; Ollama Cloud does not currently support it. + ## Spring Boot Spring Boot applications can use the Ollama starter: diff --git a/docs/v2/zh/integration/model/ollama.md b/docs/v2/zh/integration/model/ollama.md index 799bbf9359..0a15107cda 100644 --- a/docs/v2/zh/integration/model/ollama.md +++ b/docs/v2/zh/integration/model/ollama.md @@ -39,6 +39,24 @@ OllamaChatModel model = OllamaChatModel.builder() .build(); ``` +## 结构化输出 + +调用 `agent.call(messages, Output.class)` 且没有注册业务工具时,Ollama provider 会把输出类的 JSON Schema 写入原生 `format` 字段。返回消息包含 `_structured_output`,可通过 `getStructuredData(Output.class)` 读取结果,同时保留响应正文、思考内容和用量统计。 + +```java +public record ImageDescription(java.util.List description, String name) {} + +try (ReActAgent agent = ReActAgent.builder() + .name("image-analysis") + .model(model) // 图片输入需要使用本地视觉模型。 + .build()) { + Msg result = agent.call(messages, ImageDescription.class).block(); + ImageDescription description = result.getStructuredData(ImageDescription.class); +} +``` + +文本和图片输入、流式和非流式模式均使用这一格式。注册业务工具后,结构化输出继续使用合成的 `generate_response` 工具;此工具路径不应设置仅输出 JSON 的默认 `format`。原生 JSON Schema 输出需要支持 `format` 的本地 Ollama 服务,Ollama Cloud 当前不支持此功能。 + ## Spring Boot Spring Boot 应用可以使用 Ollama starter: From fcf439faca44c569ac8245c53bf57dcd51757a8d Mon Sep 17 00:00:00 2001 From: guslegend <1670547022@qq.com> Date: Fri, 9 Oct 2026 16:09:13 +0800 Subject: [PATCH 2/3] fix(ollama): allow native structured output with tools --- .../model/ollama/OllamaChatModel.java | 2 - .../ollama/OllamaStructuredOutputTest.java | 164 +++++++++++++++--- docs/v2/en/integration/model/ollama.md | 4 +- docs/v2/zh/integration/model/ollama.md | 4 +- 4 files changed, 141 insertions(+), 33 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java index 20a60f8c93..25e9a7ac10 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java @@ -103,8 +103,6 @@ public OllamaChatModel( this.formatter = formatter != null ? formatter : new OllamaChatFormatter(); this.stream = stream; setNativeStructuredOutput(true); - // Keep tool-assisted structured output on the synthetic tool path. - setNativeStructuredOutputWithTools(false); HttpTransport transport = httpTransport != null ? httpTransport : HttpTransportFactory.getDefault(); diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java index 46eb4a2cd1..d31ba20b24 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java @@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -42,6 +43,7 @@ import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -59,19 +61,22 @@ public static class ImageDescription { } public static class Tools { + private final AtomicInteger calls = new AtomicInteger(); + @Tool(name = "describe", description = "Describe an image") public String describe() { + calls.incrementAndGet(); return "a red apple"; } } @Test - void constructorsDeclareNativeOutputWithoutTools() { + void constructorsDeclareNativeOutputWithAndWithoutTools() { HttpTransport transport = mock(HttpTransport.class); OllamaChatModel model = new OllamaChatModel("qwen3.5:9b", "http://localhost:11434", null, null, transport); assertTrue(model.supportsNativeStructuredOutput()); - assertFalse(model.supportsNativeStructuredOutputWithTools()); + assertTrue(model.supportsNativeStructuredOutputWithTools()); } @ParameterizedTest @@ -167,8 +172,67 @@ void structuredImageResponsePreservesJsonAndFinalUsage(boolean streaming) { .anyMatch(images -> images.get(0).asText().equals(IMAGE))); } - @Test - void structuredOutputWithBusinessToolsRetainsSyntheticToolPath() { + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void structuredOutputWithBusinessToolsUsesNativeSchema(boolean streaming) { + HttpTransport transport = mock(HttpTransport.class); + stubResponses( + transport, streaming, frame(Map.of("role", "assistant", "content", JSON), true)); + Tools tools = new Tools(); + try (ReActAgent agent = agentWithTools(transport, streaming, tools)) { + Msg result = + agent.call("Describe an image", ImageDescription.class) + .block(Duration.ofSeconds(10)); + assertNotNull(result); + assertEquals("apple", result.getStructuredData(ImageDescription.class).name); + assertEquals(JSON, result.getTextContent()); + assertEquals(12, result.getUsage().getOutputTokens()); + } + assertEquals(0, tools.calls.get()); + assertNativeRequest(captureRequests(transport, streaming, 1).get(0)); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void businessToolExecutesBeforeNativeStructuredResponse(boolean streaming) { + HttpTransport transport = mock(HttpTransport.class); + Map toolCall = + Map.of("function", Map.of("name", "describe", "arguments", Map.of())); + stubResponses( + transport, + streaming, + frame( + Map.of("role", "assistant", "content", "", "tool_calls", List.of(toolCall)), + true), + frame(Map.of("role", "assistant", "content", JSON), true)); + Tools tools = new Tools(); + try (ReActAgent agent = agentWithTools(transport, streaming, tools)) { + Msg result = + agent.call("Use describe to inspect the image", ImageDescription.class) + .block(Duration.ofSeconds(10)); + assertNotNull(result); + assertEquals("apple", result.getStructuredData(ImageDescription.class).name); + assertEquals( + List.of("a red apple"), + result.getStructuredData(ImageDescription.class).description); + assertEquals(JSON, result.getTextContent()); + assertEquals(12, result.getUsage().getOutputTokens()); + } + assertEquals(1, tools.calls.get()); + List requests = captureRequests(transport, streaming, 2); + requests.forEach(OllamaStructuredOutputTest::assertNativeRequest); + assertEquals(requests.get(0).path("format"), requests.get(1).path("format")); + assertTrue( + requests.get(1).path("messages").findValues("role").stream() + .anyMatch(role -> "tool".equals(role.asText()))); + assertTrue( + requests.get(1).path("messages").findValues("content").stream() + .anyMatch(content -> content.asText().contains("a red apple"))); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void nativeRequestFailureFallsBackToSyntheticTool(boolean streaming) { HttpTransport transport = mock(HttpTransport.class); Map call = Map.of( @@ -184,25 +248,21 @@ void structuredOutputWithBusinessToolsRetainsSyntheticToolPath() { "apple", "description", List.of("a red apple"))))); - when(transport.stream(any(HttpRequest.class))) - .thenReturn( - Flux.just( - frame( - Map.of( - "role", - "assistant", - "content", - "", - "tool_calls", - List.of(call)), - false), - frame(Map.of("role", "assistant", "content", ""), true))); - OllamaChatModel model = - OllamaChatModel.builder().modelName("qwen3.5:9b").httpTransport(transport).build(); - Toolkit toolkit = new Toolkit(); - toolkit.registerTool(new Tools()); - try (ReActAgent agent = - ReActAgent.builder().name("tools").model(model).toolkit(toolkit).build()) { + String response = + frame( + Map.of("role", "assistant", "content", "", "tool_calls", List.of(call)), + true); + if (streaming) { + when(transport.stream(any(HttpRequest.class))) + .thenReturn( + Flux.error(new IllegalArgumentException("Native schema unavailable")), + Flux.just(response)); + } else { + when(transport.execute(any(HttpRequest.class))) + .thenThrow(new IllegalArgumentException("Native schema unavailable")) + .thenReturn(HttpResponse.builder().statusCode(200).body(response).build()); + } + try (ReActAgent agent = agentWithTools(transport, streaming, new Tools())) { Msg result = agent.call("Describe an image", ImageDescription.class) .block(Duration.ofSeconds(10)); @@ -210,16 +270,66 @@ void structuredOutputWithBusinessToolsRetainsSyntheticToolPath() { assertEquals("apple", result.getStructuredData(ImageDescription.class).name); assertEquals(12, result.getUsage().getOutputTokens()); } - ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); - verify(transport).stream(captor.capture()); - JsonNode request = - JsonUtils.getJsonCodec().fromJson(captor.getValue().getBody(), JsonNode.class); + List requests = captureRequests(transport, streaming, 2); + assertNativeRequest(requests.get(0)); + JsonNode request = requests.get(1); assertFalse(request.has("format")); List names = request.path("tools").findValues("name"); assertTrue(names.stream().anyMatch(n -> "generate_response".equals(n.asText()))); assertTrue(names.stream().anyMatch(n -> "describe".equals(n.asText()))); } + private static ReActAgent agentWithTools( + HttpTransport transport, boolean streaming, Tools tools) { + OllamaChatModel model = + OllamaChatModel.builder().modelName("qwen3.5:9b").stream(streaming) + .httpTransport(transport) + .build(); + Toolkit toolkit = new Toolkit(); + toolkit.registerTool(tools); + return ReActAgent.builder().name("tools").model(model).toolkit(toolkit).build(); + } + + private static void stubResponses( + HttpTransport transport, boolean streaming, String... responses) { + AtomicInteger index = new AtomicInteger(); + if (streaming) { + when(transport.stream(any(HttpRequest.class))) + .thenAnswer(invocation -> Flux.just(responses[index.getAndIncrement()])); + } else { + when(transport.execute(any(HttpRequest.class))) + .thenAnswer( + invocation -> + HttpResponse.builder() + .statusCode(200) + .body(responses[index.getAndIncrement()]) + .build()); + } + } + + private static List captureRequests( + HttpTransport transport, boolean streaming, int count) { + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + if (streaming) verify(transport, times(count)).stream(captor.capture()); + else verify(transport, times(count)).execute(captor.capture()); + return captor.getAllValues().stream() + .map( + request -> + JsonUtils.getJsonCodec() + .fromJson(request.getBody(), JsonNode.class)) + .toList(); + } + + private static void assertNativeRequest(JsonNode request) { + assertEquals("object", request.path("format").path("type").asText()); + assertTrue(request.path("format").path("properties").has("name")); + assertTrue(request.path("format").path("properties").has("description")); + assertFalse(request.path("format").has("json_schema")); + List names = request.path("tools").findValues("name"); + assertTrue(names.stream().anyMatch(name -> "describe".equals(name.asText()))); + assertFalse(names.stream().anyMatch(name -> "generate_response".equals(name.asText()))); + } + private static String frame(Map message, boolean done) { return JsonUtils.getJsonCodec() .toJson( diff --git a/docs/v2/en/integration/model/ollama.md b/docs/v2/en/integration/model/ollama.md index 808d6aa7bd..9e51a56e34 100644 --- a/docs/v2/en/integration/model/ollama.md +++ b/docs/v2/en/integration/model/ollama.md @@ -41,7 +41,7 @@ OllamaChatModel model = OllamaChatModel.builder() ## Structured output -For `agent.call(messages, Output.class)` without application tools, the Ollama provider sends the output class's JSON Schema in the native `format` field. The returned message contains `_structured_output`, so `getStructuredData(Output.class)` reads the result while preserving the response text, thinking, and usage. +For `agent.call(messages, Output.class)`, the Ollama provider sends the output class's JSON Schema in the native `format` field. The returned message contains `_structured_output`, so `getStructuredData(Output.class)` reads the result while preserving the response text, thinking, and usage. ```java public record ImageDescription(java.util.List description, String name) {} @@ -55,7 +55,7 @@ try (ReActAgent agent = ReActAgent.builder() } ``` -The same format works for text and image inputs, in streaming and non-streaming mode. With application tools registered, structured output continues to use the synthetic `generate_response` tool. Do not set a JSON-only default `format` for that tool path. Native JSON Schema output requires a local Ollama server that supports `format`; Ollama Cloud does not currently support it. +The same format works for text and image inputs, in streaming and non-streaming mode. With application tools registered, requests include both `tools` and `format`; the agent can execute tool calls before reading the final JSON response. Use a model that supports tool calling and a local Ollama version that supports this combination. If the native call raises an error, the agent falls back to the synthetic `generate_response` tool; avoid a JSON-only default `format` when relying on that fallback. See the [chat API](https://docs.ollama.com/api/chat) for both request fields. [Ollama Cloud currently does not support structured outputs](https://docs.ollama.com/capabilities/structured-outputs). ## Spring Boot diff --git a/docs/v2/zh/integration/model/ollama.md b/docs/v2/zh/integration/model/ollama.md index 0a15107cda..990855fa63 100644 --- a/docs/v2/zh/integration/model/ollama.md +++ b/docs/v2/zh/integration/model/ollama.md @@ -41,7 +41,7 @@ OllamaChatModel model = OllamaChatModel.builder() ## 结构化输出 -调用 `agent.call(messages, Output.class)` 且没有注册业务工具时,Ollama provider 会把输出类的 JSON Schema 写入原生 `format` 字段。返回消息包含 `_structured_output`,可通过 `getStructuredData(Output.class)` 读取结果,同时保留响应正文、思考内容和用量统计。 +调用 `agent.call(messages, Output.class)` 时,Ollama provider 会把输出类的 JSON Schema 写入原生 `format` 字段。返回消息包含 `_structured_output`,可通过 `getStructuredData(Output.class)` 读取结果,同时保留响应正文、思考内容和用量统计。 ```java public record ImageDescription(java.util.List description, String name) {} @@ -55,7 +55,7 @@ try (ReActAgent agent = ReActAgent.builder() } ``` -文本和图片输入、流式和非流式模式均使用这一格式。注册业务工具后,结构化输出继续使用合成的 `generate_response` 工具;此工具路径不应设置仅输出 JSON 的默认 `format`。原生 JSON Schema 输出需要支持 `format` 的本地 Ollama 服务,Ollama Cloud 当前不支持此功能。 +文本和图片输入、流式和非流式模式均使用这一格式。注册业务工具后,请求同时包含 `tools` 和 `format`,Agent 可以先执行工具调用,再读取最终的 JSON 响应。需要使用支持工具调用的模型,以及支持这一组合的本地 Ollama 版本。如果原生调用抛出错误,Agent 会回退到合成的 `generate_response` 工具;依赖该回退路径时,应避免设置仅输出 JSON 的默认 `format`。两个请求字段见[官方聊天 API](https://docs.ollama.com/api/chat)。[Ollama Cloud 当前不支持结构化输出](https://docs.ollama.com/capabilities/structured-outputs)。 ## Spring Boot From 1e5e2728e8b7b2e272dc52b743c639483b97f58e Mon Sep 17 00:00:00 2001 From: jujn <2087687391@qq.com> Date: Sun, 11 Oct 2026 15:00:24 +0800 Subject: [PATCH 3/3] fix: improve --- .../model/ollama/OllamaChatModel.java | 36 +++ .../ollama/OllamaStructuredOutputTest.java | 212 +++++++----------- docs/v2/en/integration/model/ollama.md | 18 -- docs/v2/zh/integration/model/ollama.md | 18 -- 4 files changed, 112 insertions(+), 172 deletions(-) diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java index 25e9a7ac10..8ab972664d 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/main/java/io/agentscope/extensions/model/ollama/OllamaChatModel.java @@ -103,6 +103,7 @@ public OllamaChatModel( this.formatter = formatter != null ? formatter : new OllamaChatFormatter(); this.stream = stream; setNativeStructuredOutput(true); + setNativeStructuredOutputWithTools(false); HttpTransport transport = httpTransport != null ? httpTransport : HttpTransportFactory.getDefault(); @@ -273,6 +274,8 @@ public static class Builder { private ProxyConfig proxyConfig; private int contextWindowSize = -1; private boolean stream = true; + private boolean nativeStructuredOutput = true; + private boolean nativeStructuredOutputWithTools = false; public Builder modelName(String modelName) { this.modelName = modelName; @@ -365,6 +368,37 @@ public Builder contextWindowSize(int contextWindowSize) { return this; } + /** + * Sets whether this model supports native structured output via Ollama's + * {@code format} field. + * + *

Defaults to {@code true}. Set to {@code false} for endpoints that do not + * support JSON Schema-constrained output; the agent will use the synthetic + * {@code generate_response} tool instead. + * + * @param nativeStructuredOutput false to disable native structured output + * @return this builder instance + */ + public Builder nativeStructuredOutput(boolean nativeStructuredOutput) { + this.nativeStructuredOutput = nativeStructuredOutput; + return this; + } + + /** + * Sets whether this model handles native structured output alongside tool calling. + * + *

Defaults to {@code false} because Ollama does not document guaranteed behavior + * for combining {@code format} and {@code tools}. Set to {@code true} for models and + * endpoints where that combination has been verified. + * + * @param nativeStructuredOutputWithTools true to use native output with tools + * @return this builder instance + */ + public Builder nativeStructuredOutputWithTools(boolean nativeStructuredOutputWithTools) { + this.nativeStructuredOutputWithTools = nativeStructuredOutputWithTools; + return this; + } + public OllamaChatModel build() { OllamaOptions finalOptions = defaultOptions != null @@ -382,6 +416,8 @@ public OllamaChatModel build() { OllamaChatModel model = new OllamaChatModel( modelName, baseUrl, finalOptions, formatter, transport, stream); + model.setNativeStructuredOutput(nativeStructuredOutput); + model.setNativeStructuredOutputWithTools(nativeStructuredOutputWithTools); if (contextWindowSize >= 0) { model.setContextWindowSize(contextWindowSize); } diff --git a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java index d31ba20b24..cd2dcbbe1d 100644 --- a/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java +++ b/agentscope-extensions/agentscope-extensions-model/agentscope-extensions-model-ollama/src/test/java/io/agentscope/extensions/model/ollama/OllamaStructuredOutputTest.java @@ -21,7 +21,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -43,7 +42,6 @@ import java.time.Duration; import java.util.List; import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -61,22 +59,29 @@ public static class ImageDescription { } public static class Tools { - private final AtomicInteger calls = new AtomicInteger(); - @Tool(name = "describe", description = "Describe an image") public String describe() { - calls.incrementAndGet(); return "a red apple"; } } @Test - void constructorsDeclareNativeOutputWithAndWithoutTools() { + void builderExposesNativeStructuredOutputConfiguration() { HttpTransport transport = mock(HttpTransport.class); OllamaChatModel model = - new OllamaChatModel("qwen3.5:9b", "http://localhost:11434", null, null, transport); + OllamaChatModel.builder().modelName("qwen3.5:9b").httpTransport(transport).build(); assertTrue(model.supportsNativeStructuredOutput()); - assertTrue(model.supportsNativeStructuredOutputWithTools()); + assertFalse(model.supportsNativeStructuredOutputWithTools()); + + OllamaChatModel configured = + OllamaChatModel.builder() + .modelName("qwen3.5:9b") + .httpTransport(transport) + .nativeStructuredOutput(false) + .nativeStructuredOutputWithTools(true) + .build(); + assertFalse(configured.supportsNativeStructuredOutput()); + assertTrue(configured.supportsNativeStructuredOutputWithTools()); } @ParameterizedTest @@ -117,7 +122,7 @@ void structuredImageResponsePreservesJsonAndFinalUsage(boolean streaming) { .body(frame(Map.of("role", "assistant", "content", JSON), true)) .build()); } - // Per-call schema must replace a default JSON-only format. + OllamaChatModel model = OllamaChatModel.builder().modelName("qwen3.5:9b").stream(streaming) .httpTransport(transport) @@ -142,24 +147,24 @@ void structuredImageResponsePreservesJsonAndFinalUsage(boolean streaming) { agent.call(List.of(input), ImageDescription.class) .block(Duration.ofSeconds(10)); assertNotNull(result); - ImageDescription data = result.getStructuredData(ImageDescription.class); - assertEquals("apple", data.name); - assertEquals(List.of("a red apple"), data.description); + assertEquals("apple", result.getStructuredData(ImageDescription.class).name); assertEquals(JSON, result.getTextContent()); assertNotNull(result.getUsage()); assertEquals(23, result.getUsage().getInputTokens()); assertEquals(12, result.getUsage().getOutputTokens()); - assertEquals(true, result.getMetadata().get("done")); - assertEquals("qwen3.5:9b", result.getMetadata().get("model")); if (streaming) { assertEquals( "Inspecting the image.", result.getContentBlocks(ThinkingBlock.class).get(0).getThinking()); } } + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); - if (streaming) verify(transport).stream(captor.capture()); - else verify(transport).execute(captor.capture()); + if (streaming) { + verify(transport).stream(captor.capture()); + } else { + verify(transport).execute(captor.capture()); + } JsonNode request = JsonUtils.getJsonCodec().fromJson(captor.getValue().getBody(), JsonNode.class); assertEquals("object", request.path("format").path("type").asText()); @@ -172,67 +177,8 @@ void structuredImageResponsePreservesJsonAndFinalUsage(boolean streaming) { .anyMatch(images -> images.get(0).asText().equals(IMAGE))); } - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void structuredOutputWithBusinessToolsUsesNativeSchema(boolean streaming) { - HttpTransport transport = mock(HttpTransport.class); - stubResponses( - transport, streaming, frame(Map.of("role", "assistant", "content", JSON), true)); - Tools tools = new Tools(); - try (ReActAgent agent = agentWithTools(transport, streaming, tools)) { - Msg result = - agent.call("Describe an image", ImageDescription.class) - .block(Duration.ofSeconds(10)); - assertNotNull(result); - assertEquals("apple", result.getStructuredData(ImageDescription.class).name); - assertEquals(JSON, result.getTextContent()); - assertEquals(12, result.getUsage().getOutputTokens()); - } - assertEquals(0, tools.calls.get()); - assertNativeRequest(captureRequests(transport, streaming, 1).get(0)); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void businessToolExecutesBeforeNativeStructuredResponse(boolean streaming) { - HttpTransport transport = mock(HttpTransport.class); - Map toolCall = - Map.of("function", Map.of("name", "describe", "arguments", Map.of())); - stubResponses( - transport, - streaming, - frame( - Map.of("role", "assistant", "content", "", "tool_calls", List.of(toolCall)), - true), - frame(Map.of("role", "assistant", "content", JSON), true)); - Tools tools = new Tools(); - try (ReActAgent agent = agentWithTools(transport, streaming, tools)) { - Msg result = - agent.call("Use describe to inspect the image", ImageDescription.class) - .block(Duration.ofSeconds(10)); - assertNotNull(result); - assertEquals("apple", result.getStructuredData(ImageDescription.class).name); - assertEquals( - List.of("a red apple"), - result.getStructuredData(ImageDescription.class).description); - assertEquals(JSON, result.getTextContent()); - assertEquals(12, result.getUsage().getOutputTokens()); - } - assertEquals(1, tools.calls.get()); - List requests = captureRequests(transport, streaming, 2); - requests.forEach(OllamaStructuredOutputTest::assertNativeRequest); - assertEquals(requests.get(0).path("format"), requests.get(1).path("format")); - assertTrue( - requests.get(1).path("messages").findValues("role").stream() - .anyMatch(role -> "tool".equals(role.asText()))); - assertTrue( - requests.get(1).path("messages").findValues("content").stream() - .anyMatch(content -> content.asText().contains("a red apple"))); - } - - @ParameterizedTest - @ValueSource(booleans = {true, false}) - void nativeRequestFailureFallsBackToSyntheticTool(boolean streaming) { + @Test + void businessToolsDefaultToSyntheticStructuredOutput() { HttpTransport transport = mock(HttpTransport.class); Map call = Map.of( @@ -248,21 +194,29 @@ void nativeRequestFailureFallsBackToSyntheticTool(boolean streaming) { "apple", "description", List.of("a red apple"))))); - String response = - frame( - Map.of("role", "assistant", "content", "", "tool_calls", List.of(call)), - true); - if (streaming) { - when(transport.stream(any(HttpRequest.class))) - .thenReturn( - Flux.error(new IllegalArgumentException("Native schema unavailable")), - Flux.just(response)); - } else { - when(transport.execute(any(HttpRequest.class))) - .thenThrow(new IllegalArgumentException("Native schema unavailable")) - .thenReturn(HttpResponse.builder().statusCode(200).body(response).build()); - } - try (ReActAgent agent = agentWithTools(transport, streaming, new Tools())) { + when(transport.execute(any(HttpRequest.class))) + .thenReturn( + HttpResponse.builder() + .statusCode(200) + .body( + frame( + Map.of( + "role", + "assistant", + "content", + "", + "tool_calls", + List.of(call)), + true)) + .build()); + Toolkit toolkit = new Toolkit(); + toolkit.registerTool(new Tools()); + OllamaChatModel model = + OllamaChatModel.builder().modelName("qwen3.5:9b").stream(false) + .httpTransport(transport) + .build(); + try (ReActAgent agent = + ReActAgent.builder().name("tools").model(model).toolkit(toolkit).build()) { Msg result = agent.call("Describe an image", ImageDescription.class) .block(Duration.ofSeconds(10)); @@ -270,61 +224,47 @@ void nativeRequestFailureFallsBackToSyntheticTool(boolean streaming) { assertEquals("apple", result.getStructuredData(ImageDescription.class).name); assertEquals(12, result.getUsage().getOutputTokens()); } - List requests = captureRequests(transport, streaming, 2); - assertNativeRequest(requests.get(0)); - JsonNode request = requests.get(1); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); + verify(transport).execute(captor.capture()); + JsonNode request = + JsonUtils.getJsonCodec().fromJson(captor.getValue().getBody(), JsonNode.class); assertFalse(request.has("format")); List names = request.path("tools").findValues("name"); - assertTrue(names.stream().anyMatch(n -> "generate_response".equals(n.asText()))); - assertTrue(names.stream().anyMatch(n -> "describe".equals(n.asText()))); + assertTrue(names.stream().anyMatch(name -> "generate_response".equals(name.asText()))); + assertTrue(names.stream().anyMatch(name -> "describe".equals(name.asText()))); } - private static ReActAgent agentWithTools( - HttpTransport transport, boolean streaming, Tools tools) { + @Test + void businessToolsCanOptIntoNativeStructuredOutput() { + HttpTransport transport = mock(HttpTransport.class); + when(transport.execute(any(HttpRequest.class))) + .thenReturn( + HttpResponse.builder() + .statusCode(200) + .body(frame(Map.of("role", "assistant", "content", JSON), true)) + .build()); + Toolkit toolkit = new Toolkit(); + toolkit.registerTool(new Tools()); OllamaChatModel model = - OllamaChatModel.builder().modelName("qwen3.5:9b").stream(streaming) + OllamaChatModel.builder().modelName("qwen3.5:9b").stream(false) .httpTransport(transport) + .nativeStructuredOutputWithTools(true) .build(); - Toolkit toolkit = new Toolkit(); - toolkit.registerTool(tools); - return ReActAgent.builder().name("tools").model(model).toolkit(toolkit).build(); - } - - private static void stubResponses( - HttpTransport transport, boolean streaming, String... responses) { - AtomicInteger index = new AtomicInteger(); - if (streaming) { - when(transport.stream(any(HttpRequest.class))) - .thenAnswer(invocation -> Flux.just(responses[index.getAndIncrement()])); - } else { - when(transport.execute(any(HttpRequest.class))) - .thenAnswer( - invocation -> - HttpResponse.builder() - .statusCode(200) - .body(responses[index.getAndIncrement()]) - .build()); + try (ReActAgent agent = + ReActAgent.builder().name("tools").model(model).toolkit(toolkit).build()) { + Msg result = + agent.call("Describe an image", ImageDescription.class) + .block(Duration.ofSeconds(10)); + assertNotNull(result); + assertEquals("apple", result.getStructuredData(ImageDescription.class).name); } - } - private static List captureRequests( - HttpTransport transport, boolean streaming, int count) { ArgumentCaptor captor = ArgumentCaptor.forClass(HttpRequest.class); - if (streaming) verify(transport, times(count)).stream(captor.capture()); - else verify(transport, times(count)).execute(captor.capture()); - return captor.getAllValues().stream() - .map( - request -> - JsonUtils.getJsonCodec() - .fromJson(request.getBody(), JsonNode.class)) - .toList(); - } - - private static void assertNativeRequest(JsonNode request) { + verify(transport).execute(captor.capture()); + JsonNode request = + JsonUtils.getJsonCodec().fromJson(captor.getValue().getBody(), JsonNode.class); assertEquals("object", request.path("format").path("type").asText()); - assertTrue(request.path("format").path("properties").has("name")); - assertTrue(request.path("format").path("properties").has("description")); - assertFalse(request.path("format").has("json_schema")); List names = request.path("tools").findValues("name"); assertTrue(names.stream().anyMatch(name -> "describe".equals(name.asText()))); assertFalse(names.stream().anyMatch(name -> "generate_response".equals(name.asText()))); diff --git a/docs/v2/en/integration/model/ollama.md b/docs/v2/en/integration/model/ollama.md index 9e51a56e34..bd95ba4784 100644 --- a/docs/v2/en/integration/model/ollama.md +++ b/docs/v2/en/integration/model/ollama.md @@ -39,24 +39,6 @@ OllamaChatModel model = OllamaChatModel.builder() .build(); ``` -## Structured output - -For `agent.call(messages, Output.class)`, the Ollama provider sends the output class's JSON Schema in the native `format` field. The returned message contains `_structured_output`, so `getStructuredData(Output.class)` reads the result while preserving the response text, thinking, and usage. - -```java -public record ImageDescription(java.util.List description, String name) {} - -try (ReActAgent agent = ReActAgent.builder() - .name("image-analysis") - .model(model) // Use a local vision model for image inputs. - .build()) { - Msg result = agent.call(messages, ImageDescription.class).block(); - ImageDescription description = result.getStructuredData(ImageDescription.class); -} -``` - -The same format works for text and image inputs, in streaming and non-streaming mode. With application tools registered, requests include both `tools` and `format`; the agent can execute tool calls before reading the final JSON response. Use a model that supports tool calling and a local Ollama version that supports this combination. If the native call raises an error, the agent falls back to the synthetic `generate_response` tool; avoid a JSON-only default `format` when relying on that fallback. See the [chat API](https://docs.ollama.com/api/chat) for both request fields. [Ollama Cloud currently does not support structured outputs](https://docs.ollama.com/capabilities/structured-outputs). - ## Spring Boot Spring Boot applications can use the Ollama starter: diff --git a/docs/v2/zh/integration/model/ollama.md b/docs/v2/zh/integration/model/ollama.md index 990855fa63..799bbf9359 100644 --- a/docs/v2/zh/integration/model/ollama.md +++ b/docs/v2/zh/integration/model/ollama.md @@ -39,24 +39,6 @@ OllamaChatModel model = OllamaChatModel.builder() .build(); ``` -## 结构化输出 - -调用 `agent.call(messages, Output.class)` 时,Ollama provider 会把输出类的 JSON Schema 写入原生 `format` 字段。返回消息包含 `_structured_output`,可通过 `getStructuredData(Output.class)` 读取结果,同时保留响应正文、思考内容和用量统计。 - -```java -public record ImageDescription(java.util.List description, String name) {} - -try (ReActAgent agent = ReActAgent.builder() - .name("image-analysis") - .model(model) // 图片输入需要使用本地视觉模型。 - .build()) { - Msg result = agent.call(messages, ImageDescription.class).block(); - ImageDescription description = result.getStructuredData(ImageDescription.class); -} -``` - -文本和图片输入、流式和非流式模式均使用这一格式。注册业务工具后,请求同时包含 `tools` 和 `format`,Agent 可以先执行工具调用,再读取最终的 JSON 响应。需要使用支持工具调用的模型,以及支持这一组合的本地 Ollama 版本。如果原生调用抛出错误,Agent 会回退到合成的 `generate_response` 工具;依赖该回退路径时,应避免设置仅输出 JSON 的默认 `format`。两个请求字段见[官方聊天 API](https://docs.ollama.com/api/chat)。[Ollama Cloud 当前不支持结构化输出](https://docs.ollama.com/capabilities/structured-outputs)。 - ## Spring Boot Spring Boot 应用可以使用 Ollama starter: