<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.8.7">Jekyll</generator><link href="https://blog.cronn.de/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.cronn.de/" rel="alternate" type="text/html" /><updated>2026-01-21T07:49:06-06:00</updated><id>https://blog.cronn.de/feed.xml</id><title type="html">wir bloggen über software_</title><subtitle>Im cronn Blog findet ihr Artikel zu Softwareentwicklung mit den neusten Technologien, zu coolen UI/UX-Designs, automatisierten Tests, aber auch zum Life-Style und Work-Life-Balance - alles &quot;the cronn way&quot;.</subtitle><entry xml:lang="en"><title type="html">Setting Up An ML HPC Server (Part 2 – Driver Setup and Running Language Models with llama.cpp)</title><link href="https://blog.cronn.de/en/machinelearning/2025/12/16/setting-up-ml-hpc-server-2.html" rel="alternate" type="text/html" title="Setting Up An ML HPC Server (Part 2 – Driver Setup and Running Language Models with llama.cpp)" /><published>2025-12-16T00:00:00-06:00</published><updated>2025-12-16T00:00:00-06:00</updated><id>https://blog.cronn.de/en/machinelearning/2025/12/16/setting-up-ml-hpc-server-2</id><content type="html" xml:base="https://blog.cronn.de/en/machinelearning/2025/12/16/setting-up-ml-hpc-server-2.html">&lt;h3 id=&quot;recap&quot;&gt;Recap&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.cronn.de/en/machinelearning/2025/12/03/setting-up-ml-hpc-server-1.html&quot;&gt;In Part 1&lt;/a&gt;, we showed you how we set up the hardware for our new HPC server. In Part 2, we will now continue with the software.&lt;/p&gt;

&lt;h3 id=&quot;installing-the-cuda-toolkit-and-nvidia-drivers&quot;&gt;Installing the CUDA Toolkit and NVIDIA Drivers&lt;/h3&gt;
&lt;p&gt;While NVIDIA drivers are provided in the non-free (restricted) Ubuntu repository, they turn out to be outdated. We therefore take the current drivers from NVIDIA:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;NVIDIA provides a package repository for Ubuntu. Installation is thus possible without any special effort.&lt;/li&gt;
  &lt;li&gt;For CUDA, NVIDIA ensures a high degree of backward compatibility. The CUDA version 12.8, which was the most up-to-date at the time, supports the much older GPU architecture “Pascal” of the Tesla P40 GPUs.&lt;/li&gt;
  &lt;li&gt;An up-to-date version of CUDA and all required drivers is a prerequisite for working with the latest AI tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href=&quot;https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html&quot;&gt;NVIDIA Installation Guide&lt;/a&gt; for the CUDA toolkit lists various possibilities and paths:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;em&gt;Package Manager vs. Runfile Installation:&lt;/em&gt; Installation via the Package Manager is more convenient and has better system integration.&lt;/li&gt;
  &lt;li&gt;&lt;em&gt;Local Repo Installation vs. Network Repo Installation:&lt;/em&gt; As long as the machine has internet access, the network repo option is better. This gives us the latest updates from NVIDIA via apt upgrade.&lt;/li&gt;
  &lt;li&gt;We choose the &lt;em&gt;proprietary packages&lt;/em&gt; and not the &lt;em&gt;open source packages&lt;/em&gt;, because in many respects the open source implementation is still lagging behind.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our first step should be to check whether any packages are already installed which could lead to conflicts:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ dpkg -l | grep nvidia
$ dpkg -l | grep cuda
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This output should be empty. If it is not, any existing packages must be uninstalled.&lt;/p&gt;

&lt;p&gt;According to &lt;a href=&quot;https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html#network-repo-installation-for-ubuntu&quot;&gt;section 3.8.3&lt;/a&gt; from the installation guide, the GPG key and the repository can both be set up by installing a deb package. (For $UBUNTU_VERSION, the respective version is followed by the pattern &lt;code class=&quot;highlighter-rouge&quot;&gt;&quot;ubuntu2404&quot;&lt;/code&gt;.)&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ wget https://developer.download.nvidia.com/compute/cuda/repos/$UBUNTU_VERSION/x86_64/cuda-keyring_1.1-1_all.deb

# dpkg -i cuda-keyring_1.1-1_all.deb
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The repository is then automatically created under &lt;code class=&quot;highlighter-rouge&quot;&gt;/etc/apt/sources.list.d/cuda-ubuntu2404-x86_64.list&lt;/code&gt;.
After an &lt;code class=&quot;highlighter-rouge&quot;&gt;apt update&lt;/code&gt;, the only requirement to install the CUDA toolkit is first installing a meta-package:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# apt install cuda-toolkit
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The second step is to install the kernel modules. NVIDIA provides &lt;a href=&quot;https://docs.nvidia.com/datacenter/tesla/driver-installation-guide/index.html&quot;&gt;another guide&lt;/a&gt; for this. The repository is already set up, the meta package can be installed directly:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# apt install cuda-drivers
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;This also installs a whole range of packages that are only needed for desktops with displays. At the moment a compute-only variant is offered for Fedora, Suse and Debian, but not yet for Ubuntu.
That’s it. After a reboot, all drivers should be set up.&lt;/p&gt;

&lt;p&gt;The CUDA binaries are located in /usr/local/cuda-12.8/bin and should &lt;a href=&quot;https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html#environment-setup&quot;&gt;be included in the PATH&lt;/a&gt; as described in Section 10.1.1 Environment Setup.
An extension of &lt;code class=&quot;highlighter-rouge&quot;&gt;LD_LIBRARY_PATH&lt;/code&gt; should not be necessary, as the configuration has already been done by the corresponding Ubuntu package &lt;code class=&quot;highlighter-rouge&quot;&gt;(/etc/ld.so.conf.d/988_cuda-12.conf)&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;verification&quot;&gt;Verification&lt;/h3&gt;

&lt;p&gt;Below you will find a few useful commands which can be used to verify the installation.
To verify that the desired driver version has been loaded:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ cat /proc/driver/nvidia/version

NVRM version: NVIDIA UNIX x86_64 Kernel Module  570.133.20  Sun Apr 13 04:50:56 UTC 2025
GCC version:  gcc version 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To verify that the CUDA-Complier has been installed:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ nvcc –version
nvcc: NVIDIA (R) Cuda compiler driver Copyright (c) 2005-2025 NVIDIA Corporation Built on Fri_Feb_21_20:23:50_PST_2025 Cuda compilation tools, release 12.8, V12.8.93 Build cuda_12.8.r12.8/compiler.35583870_0
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To access the NVIDIA System Management Interface (SMI):
&lt;code class=&quot;highlighter-rouge&quot;&gt;$ nvidia-smi&lt;/code&gt;&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 570.124.06             Driver Version: 570.124.06     CUDA Version: 12.8     |
|-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  Tesla P40                      Off |   00000000:04:00.0 Off |                  Off |
| N/A   23C    P8              9W /  250W |       5MiB /  24576MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
|   1  Tesla P40                      Off |   00000000:05:00.0 Off |                  Off |
| N/A   24C    P8              9W /  250W |       5MiB /  24576MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;getting-started&quot;&gt;Getting Started&lt;/h3&gt;

&lt;p&gt;For a stress test of the graphics cards, we use &lt;code class=&quot;highlighter-rouge&quot;&gt;gpu-burn&lt;/code&gt;. As the name implies, this pushes the power consumption of the GPUs almost to the limit of 250 watts per unit.
In the iDRAC you can see the impressive thermal effects this has: The temperature in the chassis (which has a volume of approx. 16 liters) rapidly rises to 60 °C.  You don’t need maintenance software to notice this: The fans become noticeably louder and it begins to smell iffy.&lt;/p&gt;

&lt;h3 id=&quot;inference-with-llamacpp&quot;&gt;Inference with llama.cpp&lt;/h3&gt;

&lt;p&gt;There is a number of software options which run current open source language models. Examples include KoboldCpp and ollama, both of which have one thing in common: they rely on the library llama.cpp, which does all the “hard work” in the background.&lt;/p&gt;

&lt;p&gt;For initial tests, it is a good idea to work directly with llama.cpp – for several reasons:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;Maximum control during configuration and optimization&lt;/li&gt;
  &lt;li&gt;Detailed output of parameters and hardware properties&lt;/li&gt;
  &lt;li&gt;New models often require up-to-date features – which usually are added to &lt;strong&gt;llama.cpp&lt;/strong&gt; first&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is recommended to compile llama.cpp directly from the sources:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ cmake -B build -DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/opt/llama-cpp -DLLAMA_BUILD_EXAMPLES=ON -DLLAMA_BUILD_SERVER=ON
$ cmake --build build --config Release -j 16
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Optionally, install the binaries according to /opt/llama-cpp:
&lt;code class=&quot;highlighter-rouge&quot;&gt;$ sudo cmake --install build&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;*** Compiling from sources&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;When compiling from sources, it is not uncommon to be confronted by cryptic errors. However, this is not a reason for concern.&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;The basic prerequisite is development tools such as cmake and g++, which must be installed on the system. The build-essential meta-package, which bundles the most important tools, is recommended.&lt;/li&gt;
    &lt;li&gt;More often than not so-called dev packages for required libraries are also missing. Unfortunately, it is often not specified directly which package has to be installed – this information must be &amp;gt; derived from the error message.
For example, llama.cpp expects  the curl development files that are included in the libcurl4-openssl-dev package.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;download-the-models&quot;&gt;Download the models&lt;/h3&gt;
&lt;p&gt;Language models can be found on &lt;a href=&quot;https://huggingface.co/&quot;&gt;https://huggingface.co/&lt;/a&gt;. To use a model in llama.cpp, it must be in gguf format.
You can easily do the conversion yourself. For this purpose, llama.cpp includes the tools convert_hf_to_gguf.py and llama-quantize. For popular models, however, you can often find pre-converted models in gguf format on Huggingface.
Next there is the matter of deciding on quantization. With smaller quantization, the model consumes less vRAM, execution becomes faster, but this comes at a price of reduced performance. You can start with the largest version that fits into the vRAM and then reduce it if the speed is not sufficient. A &lt;strong&gt;4-bit quantization&lt;/strong&gt; is usually a good compromise: the &lt;strong&gt;vRAM&lt;/strong&gt; is used efficiently while the losses from performance remain low.&lt;/p&gt;

&lt;p&gt;We can load finished gguf files with the Python tool &lt;code class=&quot;highlighter-rouge&quot;&gt;huggingface-cli&lt;/code&gt;, which can then be installed with the following command:
&lt;code class=&quot;highlighter-rouge&quot;&gt;$ pip install -U &quot;huggingface_hub[cli]&quot;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Alternatively, one can use uvx  to run the most current version of the tool directly. A corresponding alias might look something like this:
&lt;code class=&quot;highlighter-rouge&quot;&gt;$ alias huggingface-cli=&quot;uvx --from huggingface_hub[cli] huggingface-cli&quot;&lt;/code&gt;
The actual download to the current directory would look something like this:
&lt;code class=&quot;highlighter-rouge&quot;&gt;$ huggingface-cli download bartowski/Qwen_Qwen3-30B-A3B-GGUF --include &quot;Qwen_Qwen3-30B-A3B-Q4_K_M.gguf&quot; --local-dir .&lt;/code&gt;&lt;/p&gt;

&lt;h3 id=&quot;chat-in-the-command-line&quot;&gt;Chat in the command line&lt;/h3&gt;
&lt;p&gt;llama.cpp provides a number of command line tools. With llama-cli you can directly start a chat:&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;$ llama-cli -m ~/models/bartowski/Qwen_Qwen3-30B-A3B-GGUF/Qwen_Qwen3-30B-A3B-Q4_K_M.gguf -co -cnv -fa -ngl 99&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;-m: Path to the GGUF model file.&lt;/li&gt;
  &lt;li&gt;-co: Colored output for better differentiation of input and output.&lt;/li&gt;
  &lt;li&gt;-cnv: Activates Conversation Mode.&lt;/li&gt;
  &lt;li&gt;-fa: Turns on Flash Attention (if supported).&lt;/li&gt;
  &lt;li&gt;-ngl 99: Allows up to 99 model layers to be computed on the GPU.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code class=&quot;highlighter-rouge&quot;&gt;-ngl 99 parameter  tells llama.cpp&lt;/code&gt; to handle up to 99 model layers on the GPU. In practice, this means that all layers are offloaded – as the output also confirms:&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;offloaded 49/49 layers to GPU
If not all layers fit on the GPU, the remaining ones are processed on the CPU – which, as is to be expected, leads to significant performance losses.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;benchmarks&quot;&gt;Benchmarks&lt;/h3&gt;
&lt;p&gt;A main goal of this setup is to get a sufficient amount of vRAM at a reasonable price. But at the end of the day, we don’t just want to load huge models, we also want to execute them quickly. The rack should stand up to comparisons with what friends and colleagues put on their desks in terms of consumer hardware. A front-runner is the Apple Silicon M series, with its unified memory architecture.
We use a detailed &lt;a href=&quot;https://github.com/ggml-org/llama.cpp/discussions/4167#user-content-fn-1-edf54c51be358599387d05c7ba885b49&quot;&gt;community benchmark&lt;/a&gt; of the various Apple products as our reference. Below is a test of a smaller model, Llama 7B v2. It can be run parallel multiple times on our setup, but the maximum speed is still crucial.
With the tool llama-bench we determine two speeds:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;pp (prompt parsing):&lt;/strong&gt; Reading the question/prompt&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;tg (text generation):&lt;/strong&gt; Generating the response&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;$ llama-bench -m llama-2-7b. Q8_0.gguf -m llama-2-7b. Q4_0.gguf -p 512 -n 128 -ngl 99 -fa 1&lt;/code&gt;&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;model&lt;/th&gt;
      &lt;th&gt;size&lt;/th&gt;
      &lt;th&gt;params&lt;/th&gt;
      &lt;th&gt;backend&lt;/th&gt;
      &lt;th&gt;ngl&lt;/th&gt;
      &lt;th&gt;fa&lt;/th&gt;
      &lt;th&gt;test&lt;/th&gt;
      &lt;th&gt;t/s&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q8_0&lt;/td&gt;
      &lt;td&gt;6.67 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;pp512&lt;/td&gt;
      &lt;td&gt;1024.05 ± 0.74&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q8_0&lt;/td&gt;
      &lt;td&gt;6.67 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;tg128&lt;/td&gt;
      &lt;td&gt;36.18 ± 0.01&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q4_0&lt;/td&gt;
      &lt;td&gt;3.56 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;pp512&lt;/td&gt;
      &lt;td&gt;1073.58 ± 0.35&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q4_0&lt;/td&gt;
      &lt;td&gt;3.56 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;tg128&lt;/td&gt;
      &lt;td&gt;55.80 ± 0.83&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;build: 578754b3 (5117)&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img11.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Benchmarkvergleich von Apple‑Chips und einer Nvidia P40 in Tokens pro Sekunde&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img12.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Benchmarkvergleich von Apple‑Chips und einer Nvidia P40 in Tokens pro Sekunde&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img21.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Benchmarkvergleich von Apple‑Chips und einer Nvidia P40 in Tokens pro Sekunde&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img22.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Benchmarkvergleich von Apple‑Chips und einer Nvidia P40 in Tokens pro Sekunde&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;The P40 performs well in the area of prompt parsing. In text production , it is above the Pro series and in the lower range of the Max series.&lt;/p&gt;

&lt;h3 id=&quot;result&quot;&gt;Result&lt;/h3&gt;
&lt;p&gt;We have built a powerful HPC server with a total of 96 GB of vRAM, creating a solid basis for demanding AI and data processing projects – all without blowing the bank.&lt;/p&gt;

&lt;h4 id=&quot;rental-server-alternatives&quot;&gt;Rental server alternatives&lt;/h4&gt;
&lt;p&gt;Depending on the scenario, renting AI GPU servers might be a viable option. If you only need selective training phases or want to test prototypes on short notice, you can benefit from hour-based rental offers. However, long-term, continuous use requires dedicated resources with full access, and then the costs can add up quickly. It is not uncommon to see setups costing up to &lt;strong&gt;EUR 1,000&lt;/strong&gt; per month, which makes setting up your own server more economically attractive in the long run.&lt;/p&gt;

&lt;h3 id=&quot;practical-framework&quot;&gt;Practical framework&lt;/h3&gt;
&lt;p&gt;However, while operating a high-performance server has its advantages, it also has its infrastructural requirements:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Space &amp;amp; volume:&lt;/strong&gt; Under full load, a suitable server room with sufficient ventilation is required.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Energy requirements:&lt;/strong&gt; Continuous occupancy can result in electricity costs in the range of EUR 100 per month or more.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Maintenance &amp;amp; updates:&lt;/strong&gt; You are responsible for driver and software updates, as well as repair and replacement of defective hardware.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Limited driver/software compatibility of used hardware:&lt;/strong&gt; In our case, the P40’s “Pascal” GPU architecture was still supported well enough for us to run current models. However, we are already encoutering deprecation warnings. Before purchasing, the current support for drivers (CUDA) and the important libraries and frameworks (especially PyTorch) should be researched.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&quot;added-value-for-the-team&quot;&gt;Added value for the team&lt;/h3&gt;
&lt;p&gt;For our team, the current setup is a real asset:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Free experimentation:&lt;/strong&gt; No approval processes or time pressure from expensive rental hours.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Data sovereignty:&lt;/strong&gt; Local LLMs and AI models make it possible to securely process confidential data.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Full control:&lt;/strong&gt; We determine the hardware, the software and the access rights. Anything goes, no matter how obscure.&lt;/li&gt;
&lt;/ul&gt;</content><author><name>paulFieteHartmann</name></author><summary type="html">This post demonstrates how to set up a high-performance server for machine learning on a small budget.</summary></entry><entry xml:lang="de"><title type="html">Einrichtung eines ML-HPC-Servers (Teil 2 - Treibereinrichtung und Sprachmodelle)</title><link href="https://blog.cronn.de/de/machinelearning/2025/12/16/einrichtung-ml-hpc-server-2.html" rel="alternate" type="text/html" title="Einrichtung eines ML-HPC-Servers (Teil 2 - Treibereinrichtung und Sprachmodelle)" /><published>2025-12-16T00:00:00-06:00</published><updated>2025-12-16T00:00:00-06:00</updated><id>https://blog.cronn.de/de/machinelearning/2025/12/16/einrichtung-ml-hpc-server-2</id><content type="html" xml:base="https://blog.cronn.de/de/machinelearning/2025/12/16/einrichtung-ml-hpc-server-2.html">&lt;h3 id=&quot;rückblick&quot;&gt;Rückblick&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://blog.cronn.de/de/machinelearning/2025/12/02/einrichtung-ml-hpc-server-1.html&quot;&gt;In Teil 1&lt;/a&gt; haben wir gezeigt, wie wir die Hardware unseres neuen HPC-Servers eingerichtet haben. Im zweiten Teil geht es jetzt mit der Software weiter.&lt;/p&gt;

&lt;h3 id=&quot;installation-des-cuda-toolkits-und-der-nvidia-treiber&quot;&gt;Installation des CUDA-Toolkits und der NVIDIA Treiber&lt;/h3&gt;
&lt;p&gt;Ubuntu stellt im &lt;em&gt;non-free (restricted)&lt;/em&gt; Repository NVIDIA-Treiber bereit, die sich aber als veraltet herausstellen. Wir nehmen daher die aktuellen Treiber von NVIDIA:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;NVIDIA stellt ein Package-Repository für Ubuntu bereit. Die Installation ist damit ohne besonderen Aufwand möglich.&lt;/li&gt;
  &lt;li&gt;Für CUDA stellt NVIDIA ein hohes Maß an Abwärtskompatibilität sicher. Die zu der Zeit aktuelle CUDA-Version 12.8 unterstützt die deutlich ältere GPU-Architektur „Pascal“ der Tesla P40 GPUs.&lt;/li&gt;
  &lt;li&gt;Eine aktuelle Version von CUDA und passenden Treibern ist Voraussetzung, um mit aktuellem KI-Tooling arbeiten zu können.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Der &lt;a href=&quot;https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html&quot;&gt;NVIDIA-Installationsguide&lt;/a&gt; für das CUDA-Toolkit führt diverse Möglichkeiten und Pfade auf:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;em&gt;Package Manager vs. Runfile Installation&lt;/em&gt;: Die Installation über den Package Manager ist bequemer und hat bessere Systemintegration.&lt;/li&gt;
  &lt;li&gt;&lt;em&gt;Local Repo Installation vs. Network Repo Installation&lt;/em&gt;: Solange die Maschine Internetzugang hat, ist die Network-Repo-Option besser. Damit erhalten wir per apt upgrade die aktuellen Updates von NVIDIA.&lt;/li&gt;
  &lt;li&gt;Wir wählen die &lt;em&gt;proprietären Pakete&lt;/em&gt; und nicht die &lt;em&gt;Open-Source-Pakete&lt;/em&gt;, da die Open-Source-Implementation in vielen Punkten noch deutlich zurücksteht.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Im ersten Schritt sollte geprüft werden, ob bereits Pakete installiert sind, die zu Konflikten führen könnten:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ dpkg -l | grep nvidia
$ dpkg -l | grep cuda
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Diese Ausgabe sollte leer sein. Falls nicht, können bestehende Pakte deinstalliert werden.&lt;/p&gt;

&lt;p&gt;Entsprechend &lt;a href=&quot;https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html#network-repo-installation-for-ubuntu&quot;&gt;Abschnitt 3.8.3&lt;/a&gt; aus dem Installationsguide lässt sich der GPG Key und das Repository durch die Installation eines deb-Packages einrichten. (Für $UBUNTU_VERSION wird die jeweilige Version nach dem Muster &lt;code class=&quot;highlighter-rouge&quot;&gt;„ubuntu2404“&lt;/code&gt; eingesetzt.&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ wget https://developer.download.nvidia.com/compute/cuda/repos/$UBUNTU_VERSION/x86_64/cuda-keyring_1.1-1_all.deb

# dpkg -i cuda-keyring_1.1-1_all.deb
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Das Repository wird damit automatisch unter &lt;code class=&quot;highlighter-rouge&quot;&gt;/etc/apt/sources.list.d/cuda-ubuntu2404-x86_64.list&lt;/code&gt; angelegt.&lt;/p&gt;

&lt;p&gt;Nach einem &lt;code class=&quot;highlighter-rouge&quot;&gt;apt update&lt;/code&gt; muss für die Installation des CUDA-Toolkits nur noch ein Meta-Package installiert werden:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# apt install cuda-toolkit
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Der zweite Schritt ist die Installation der Kernel-Module. Dafür stellt NVIDIA eine weitere Anleitung bereit. Das Repository ist schon eingerichtet, es kann direkt das Meta-Package installiert werden:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# apt install cuda-drivers
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Dies installiert auch eine ganze Reihe an Paketen, die eigentlich nur für Desktops mit Display benötigt werden. Eine &lt;code class=&quot;highlighter-rouge&quot;&gt;compute-only&lt;/code&gt;-Variante wird für Fedora, Suse und Debian angeboten, aber zum aktuellen Zeitpunkt nicht für Ubuntu.
Das war’s auch schon. Nach einem Reboot sollten alle Treiber eingerichtet sein.&lt;/p&gt;

&lt;p&gt;Die CUDA Binaries befinden sich in &lt;code class=&quot;highlighter-rouge&quot;&gt;/usr/local/cuda-12.8/bin&lt;/code&gt; und sollten wie im &lt;a href=&quot;https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html#environment-setup&quot;&gt;Abschnitt 10.1.1.&lt;/a&gt; Environment Setup beschrieben in den PATH aufgenommen werden.
Eine Erweiterung von &lt;code class=&quot;highlighter-rouge&quot;&gt;LD_LIBRARY_PATH&lt;/code&gt; sollte nicht nötig sein, da die Konfiguration durch das entsprechende Ubuntu-Package schon erfolgt ist &lt;code class=&quot;highlighter-rouge&quot;&gt;(/etc/ld.so.conf.d/988_cuda-12.conf)&lt;/code&gt;.&lt;/p&gt;

&lt;h3 id=&quot;verifikation&quot;&gt;Verifikation&lt;/h3&gt;
&lt;p&gt;Um die Installation zu verifizieren, führen wir hier ein paar nützliche Befehle auf.&lt;/p&gt;

&lt;p&gt;Prüfen, ob die gewünschte &lt;strong&gt;Treiberversion&lt;/strong&gt; geladen wurde:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ cat /proc/driver/nvidia/version
NVRM version: NVIDIA UNIX x86_64 Kernel Module  570.133.20  Sun Apr 13 04:50:56 UTC 2025
GCC version:  gcc version 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Die Installation des CUDA-Compilers NVCC verifizieren:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ nvcc –version
nvcc: NVIDIA (R) Cuda compiler driver Copyright (c) 2005-2025 NVIDIA Corporation Built on Fri_Feb_21_20:23:50_PST_2025 Cuda compilation tools, release 12.8, V12.8.93 Build cuda_12.8.r12.8/compiler.35583870_0
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Das NVIDIA &lt;strong&gt;System Management Interface (SMI)&lt;/strong&gt; aufrufen:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ nvidia-smi
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 570.124.06             Driver Version: 570.124.06     CUDA Version: 12.8     |
|-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  Tesla P40                      Off |   00000000:04:00.0 Off |                  Off |
| N/A   23C    P8              9W /  250W |       5MiB /  24576MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
|   1  Tesla P40                      Off |   00000000:05:00.0 Off |                  Off |
| N/A   24C    P8              9W /  250W |       5MiB /  24576MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;erste-schritte&quot;&gt;Erste Schritte&lt;/h3&gt;
&lt;p&gt;Für einen Belastungstest der Grafikkarten nehmen wir &lt;code class=&quot;highlighter-rouge&quot;&gt;gpu-burn&lt;/code&gt;. Wie der Name bereits vermuten lässt, treibt dies den Stromverbrauch der GPUs fast ans Limit von 250 Watt pro Einheit.
Im iDRAC sieht man sehr eindrücklich, welche thermischen Auswirkungen das hat: Die Temperatur im Chassis (Volumen ca. 16 Liter) steigt in kürzester Zeit auf 60 °C. Um dies zu bemerken, braucht man allerdings keine Wartungssoftware: Die Lüfter werden markant lauter und schriller und es riecht leicht brenzlig.&lt;/p&gt;

&lt;h3 id=&quot;inferenz-mit-llamacpp&quot;&gt;Inferenz mit llama.cpp&lt;/h3&gt;
&lt;p&gt;Um aktuelle Open-Source-Sprachmodelle auszuführen, gibt es eine Reihe populärer Software wie KoboldCpp und ollama, die eins gemeinsam haben: Sie setzen auf die Bibliothek llama.cpp, die im Hintergrund die eigentliche „harte Arbeit“ übernimmt.
Für erste Tests bietet es sich an, direkt mit llama.cpp zu arbeiten – und das aus mehreren Gründen:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;maximale Kontrolle bei der Konfiguration und Optimierung&lt;/li&gt;
  &lt;li&gt;detaillierte Ausgabe von Parametern und Hardwareeigenschaften&lt;/li&gt;
  &lt;li&gt;neue Modelle erfordern oft aktuelle Features – und die landen meist zuerst in &lt;strong&gt;llama.cpp&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Es empfiehlt sich, llama.cpp direkt aus den Quellen zu kompilieren:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ cmake -B build -DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/opt/llama-cpp -DLLAMA_BUILD_EXAMPLES=ON -DLLAMA_BUILD_SERVER=ON
$ cmake --build build --config Release -j 16
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Optional, Installation der Binaries nach &lt;code class=&quot;highlighter-rouge&quot;&gt;/opt/llama-cpp&lt;/code&gt;:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ sudo cmake --install build
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;blockquote&gt;
  &lt;p&gt;&lt;strong&gt;Kompilieren aus Quellen&lt;/strong&gt;
Beim Kompilieren aus den Quellen wird man nicht selten erstmal mit kryptischen Fehlern konfrontiert. Davon sollte man sich jedoch nicht verunsichern lassen.&lt;/p&gt;
  &lt;ul&gt;
    &lt;li&gt;Grundvoraussetzung sind Entwicklungswerkzeuge wie &lt;code class=&quot;highlighter-rouge&quot;&gt;cmake&lt;/code&gt; und &lt;code class=&quot;highlighter-rouge&quot;&gt;g++&lt;/code&gt;, die auf dem System installiert sein müssen. Empfehlenswert ist das Meta-Paket &lt;code class=&quot;highlighter-rouge&quot;&gt;build-essential&lt;/code&gt;, das die wichtigsten Tools bündelt.&lt;/li&gt;
    &lt;li&gt;In der Regel fehlen zusätzlich sogenannte Dev-Pakete für benötigte Bibliotheken. Leider wird dabei oft nicht direkt angegeben, welches Paket konkret installiert werden muss – diese Information muss man aus der Fehlermeldung ableiten.
So erwartet beispielsweise &lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; die Entwicklungsdateien für &lt;code class=&quot;highlighter-rouge&quot;&gt;curl&lt;/code&gt;, die im Paket &lt;code class=&quot;highlighter-rouge&quot;&gt;libcurl4-openssl-dev&lt;/code&gt; enthalten sind.&lt;/li&gt;
  &lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h3 id=&quot;download-der-modelle&quot;&gt;Download der Modelle&lt;/h3&gt;
&lt;p&gt;Sprachmodelle findet man auf &lt;a href=&quot;https://huggingface.co/&quot;&gt;https://huggingface.co/&lt;/a&gt;. Um ein Model in &lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; zu nutzen, muss es im gguf-Format vorliegen.
Die Konvertierung kann man problemlos selbst durchführen. Dazu liefert &lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; die Tools &lt;code class=&quot;highlighter-rouge&quot;&gt;convert_hf_to_gguf.py&lt;/code&gt; und &lt;code class=&quot;highlighter-rouge&quot;&gt;llama-quantize&lt;/code&gt; mit. Für populäre Modelle findet man aber oft schon vorkonvertierte Modelle im gguf-Format auf Huggingface.&lt;/p&gt;

&lt;p&gt;Jetzt gilt es noch, sich für eine Quantisierung zu entscheiden. Mit kleinerer Quantisierung verbraucht das Modell weniger vRAM, die Ausführung wird schneller, aber die Leistungsfähigkeit ist vermindert. Starten kann man mit der größten Ausführung, die noch in den &lt;strong&gt;vRAM&lt;/strong&gt; passt und dann verringern, falls die Geschwindigkeit nicht ausreichend ist. Eine &lt;strong&gt;4-Bit-Quantisierung&lt;/strong&gt; ist in der Regel ein guter Kompromiss: Der vRAM wird effizient genutzt, während die Leistungseinbußen gering bleiben.&lt;/p&gt;

&lt;p&gt;Fertige gguf-Files können wir mit dem Python-Tool &lt;code class=&quot;highlighter-rouge&quot;&gt;huggingface-cli&lt;/code&gt; laden, das sich mit folgendem Befehl installieren lässt:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ pip install -U &quot;huggingface_hub[cli]&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Alternativ kann die aktuelle Version des Tools auch ohne Installation mit uvx direkt ausgeführt werden. Ein entsprechender Alias könnte so aussehen:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ alias huggingface-cli=&quot;uvx --from huggingface_hub[cli] huggingface-cli&quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Der eigentliche Download in das aktuelle Verzeichnis sieht dann zum Beispiel so aus:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ huggingface-cli download bartowski/Qwen_Qwen3-30B-A3B-GGUF --include &quot;Qwen_Qwen3-30B-A3B-Q4_K_M.gguf&quot; --local-dir .
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;chat-in-der-commandozeile&quot;&gt;Chat in der Commandozeile&lt;/h3&gt;
&lt;p&gt;&lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; stellt eine Reihe von Kommandozeilen-Tools zur Verfügung. Mit &lt;code class=&quot;highlighter-rouge&quot;&gt;llama-cli&lt;/code&gt; lässt sich direkt ein Chat starten:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ llama-cli -m ~/models/bartowski/Qwen_Qwen3-30B-A3B-GGUF/Qwen_Qwen3-30B-A3B-Q4_K_M.gguf -co -cnv -fa -ngl 99
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Die Bedeutung der Commandozeilenparameter:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;-m: Pfad zur GGUF-Modell-Datei.&lt;/li&gt;
  &lt;li&gt;-co: Farbige Ausgabe zur besseren Unterscheidung von Eingaben und Antworten.&lt;/li&gt;
  &lt;li&gt;-cnv: Aktiviert den Gesprächsmodus (Conversation Mode).&lt;/li&gt;
  &lt;li&gt;-fa: Schaltet Flash Attention ein (wenn vom Modell unterstützt).&lt;/li&gt;
  &lt;li&gt;-ngl 99: Lässt bis zu 99 Modell-Layer auf der GPU berechnen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Der Parameter -ngl 99 weist &lt;code class=&quot;highlighter-rouge&quot;&gt;llama.cpp&lt;/code&gt; an, bis zu 99 Modell-Layer auf der GPU zu verarbeiten. In der Praxis bedeutet es, dass sämtliche Layer ausgelagert werden – wie auch die Ausgabe bestätigt:&lt;/p&gt;
&lt;blockquote&gt;
  &lt;p&gt;offloaded 49/49 layers to GPU&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Falls nicht alle Layer auf die GPU passen, werden die verbleibenden auf der CPU verarbeitet – was erwartungsgemäß zu deutlichen Performanceeinbußen führt.&lt;/p&gt;

&lt;h3 id=&quot;benchmarks&quot;&gt;Benchmarks&lt;/h3&gt;
&lt;p&gt;Ein Hauptziel dieses Setups ist es, eine ausreichende Menge vRAM zu einem angemessenen Preis zu bekommen. Aber am Ende des Tages möchten wir nicht nur riesige Modelle laden, sondern diese auch zügig ausführen. Das Rack sollte sich an dem messen lassen, was sich Freunde und Kollegen an Consumer-Hardware so auf den Schreibtisch stellen. Ein Spitzenreiter ist dabei die Apple-Silicon-M-Serie, mit ihrer Unified Memory Architecture.&lt;/p&gt;

&lt;p&gt;Als Referenz nutzen wir ein ausführliches &lt;a href=&quot;https://github.com/ggml-org/llama.cpp/discussions/4167#user-content-fn-1-edf54c51be358599387d05c7ba885b49&quot;&gt;Community Benchmark&lt;/a&gt; der verschiedenen Apple-Produkte. Hier wird ein kleineres Modell (Llama 7B v2) getestet. Es lässt sich auf unserem Setup vielfach parallel ausführen, aber dennoch ist auch die maximale Geschwindigkeit entscheidend.&lt;/p&gt;

&lt;p&gt;Mit dem Tool llama-bench ermitteln wir zwei Geschwindigkeiten&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;pp (prompt parsing)&lt;/strong&gt;: Lesen der Frage/des Prompts, was ja auch mal länger sein kann&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;tg (text generation)&lt;/strong&gt;: Erzeugen der Antwort&lt;/li&gt;
&lt;/ul&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;$ llama-bench -m llama-2-7b.Q8_0.gguf -m llama-2-7b.Q4_0.gguf -p 512 -n 128 -ngl 99 -fa 1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;model&lt;/th&gt;
      &lt;th&gt;size&lt;/th&gt;
      &lt;th&gt;params&lt;/th&gt;
      &lt;th&gt;backend&lt;/th&gt;
      &lt;th&gt;ngl&lt;/th&gt;
      &lt;th&gt;fa&lt;/th&gt;
      &lt;th&gt;test&lt;/th&gt;
      &lt;th&gt;t/s&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q8_0&lt;/td&gt;
      &lt;td&gt;6.67 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;pp512&lt;/td&gt;
      &lt;td&gt;1024.05 ± 0.74&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q8_0&lt;/td&gt;
      &lt;td&gt;6.67 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;tg128&lt;/td&gt;
      &lt;td&gt;36.18 ± 0.01&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q4_0&lt;/td&gt;
      &lt;td&gt;3.56 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;pp512&lt;/td&gt;
      &lt;td&gt;1073.58 ± 0.35&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;llama 7B Q4_0&lt;/td&gt;
      &lt;td&gt;3.56 GiB&lt;/td&gt;
      &lt;td&gt;6.74 B&lt;/td&gt;
      &lt;td&gt;CUDA&lt;/td&gt;
      &lt;td&gt;99&lt;/td&gt;
      &lt;td&gt;1&lt;/td&gt;
      &lt;td&gt;tg128&lt;/td&gt;
      &lt;td&gt;55.80 ± 0.83&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;build: 578754b3 (5117)&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img11.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Benchmarkvergleich von Apple‑Chips und einer Nvidia P40 in Tokens pro Sekunde&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;!-- caption: Prompt-Parsing Geschwindigkeit des P40 im Vergleich zu Produkten der Apple-Silicon-M-Serie für das Llama 7B v2 Modell in 8-Bit Quantisierung (in Token pro Sekunde) --&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img12.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Benchmarkvergleich von Apple‑Chips und einer Nvidia P40 in Tokens pro Sekunde&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;!-- caption: Text-Generierung im Vergleich (Llama 7B v2 Modell in 8-Bit Quantisierung) --&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img21.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Benchmarkvergleich von Apple‑Chips und einer Nvidia P40 in Tokens pro Sekunde&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;!-- caption: Prompt-Parsing im Vergleich (Llama 7B v2 Modell in 4-Bit Quantisierung) --&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-2/img22.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Benchmarkvergleich von Apple‑Chips und einer Nvidia P40 in Tokens pro Sekunde&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;!-- caption: Text-Generierung im Vergleich (Llama 7B v2 Modell in 4-Bit Quantisierung) --&gt;

&lt;p&gt;Im Bereich Prompt-Parsing schlägt sich der P40 hervorragend. Bei der Textproduktion liegt er oberhalb der Pro-Reihe und im unteren Bereich der Max-Reihe.&lt;/p&gt;

&lt;h3 id=&quot;fazit&quot;&gt;Fazit&lt;/h3&gt;
&lt;p&gt;Mit einem überschaubaren Budget haben wir einen leistungsfähigen HPC-Server mit insgesamt 96 GB vRAM aufgebaut und damit eine solide Basis für anspruchsvolle KI-, und Datenverarbeitungsprojekte geschaffen.&lt;/p&gt;

&lt;h4 id=&quot;mietserveralternativen&quot;&gt;Mietserveralternativen&lt;/h4&gt;
&lt;p&gt;Der Einsatz gemieteter KI-GPU-Server kann je nach Szenario eine sinnvolle Option sein. Wer lediglich punktuell Trainingsphasen benötigt oder kurzfristig Prototypen testen möchte, profitiert von stundenbasierten Mietangeboten. Bei langfristigem, kontinuierlichem Einsatz, insbesondere bei dedizierten Ressourcen mit vollem Zugriff, summieren sich die Kosten jedoch schnell. Im Bereich von &lt;strong&gt;1.000 EUR pro Monat&lt;/strong&gt; sind solche Setups keine Seltenheit, was den eigenen Betrieb auf Dauer wirtschaftlich attraktiver macht.&lt;/p&gt;

&lt;h4 id=&quot;praktische-rahmenbedingungen&quot;&gt;Praktische Rahmenbedingungen&lt;/h4&gt;
&lt;p&gt;Der Betrieb eines leistungsstarken Servers bringt jedoch nicht nur Vorteile, sondern auch infrastrukturelle Anforderungen mit sich:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Platz &amp;amp; Lautstärke:&lt;/strong&gt; Unter Volllast ist ein geeigneter Serverraum mit ausreichender Belüftung notwendig.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Energiebedarf:&lt;/strong&gt; Dauerhafte Auslastung kann Stromkosten im Bereich von 100 EUR pro Monat oder mehr verursachen.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Wartung &amp;amp; Updates:&lt;/strong&gt; Treiber- und Softwareupdates, Reparatur und Erneuerung defekter Hardware liegen in der eigenen Verantwortung.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Begrenzte Treiber-/Softwarekompatibilität gebrauchter Hardware:&lt;/strong&gt; In unserem Fall wurde die GPU-Architektur „Pascal“ des P40 noch gut unterstützt und wir können aktuelle Modelle betreiben. Wir sind hier aber gerade an der Grenze und an einigen Stellen gibt es schon Deprecation-Warnungen. Vor der Anschaffung sollte der aktuelle Support für Treiber (CUDA) und den wichtigen Bibliotheken und Frameworks (insbesondere PyTorch) recherchiert werden.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4 id=&quot;mehrwert-für-das-team&quot;&gt;Mehrwert für das Team&lt;/h4&gt;
&lt;p&gt;Für unser Team ist der aktuelle Aufbau ein echter Gewinn:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Freies Experimentieren:&lt;/strong&gt; Keine Genehmigungsprozesse oder Zeitdruck durch teure Mietstunden.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Datensouveränität:&lt;/strong&gt; Lokale LLMs und KI-Modelle ermöglichen es, auch vertrauliche Daten sicher zu verarbeiten.&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Volle Kontrolle:&lt;/strong&gt; Wir bestimmen über Hardware, Software und Zugriffsrechte. Alles kann ausprobiert werden, egal wie obskur.&lt;/li&gt;
&lt;/ul&gt;</content><author><name>paulFieteHartmann</name></author><summary type="html">Im zweiten Teil zeigen wir die Treibereinrichtung und Sprachmodelle für den neuen ML-HPC-Server.</summary></entry><entry xml:lang="en"><title type="html">Setting Up an ML HPC Server (Part 1 - Hardware)</title><link href="https://blog.cronn.de/en/machinelearning/2025/12/03/setting-up-ml-hpc-server-1.html" rel="alternate" type="text/html" title="Setting Up an ML HPC Server (Part 1 - Hardware)" /><published>2025-12-03T00:00:00-06:00</published><updated>2025-12-03T00:00:00-06:00</updated><id>https://blog.cronn.de/en/machinelearning/2025/12/03/setting-up-ml-hpc-server-1</id><content type="html" xml:base="https://blog.cronn.de/en/machinelearning/2025/12/03/setting-up-ml-hpc-server-1.html">&lt;h3 id=&quot;motivation&quot;&gt;Motivation&lt;/h3&gt;
&lt;p&gt;Many powerful AI models such as &lt;em&gt;gpt-oss&lt;/em&gt; or &lt;em&gt;DeepSeek&lt;/em&gt; are now published as open source. Powerful graphics cards (GPUs) are required in order to operate current and larger models at high performance. The decisive criterion here is the available graphics memory (vRAM).
High-end gaming GPUs are equipped with up to 24GB of vRAM. However, this is not sufficient for larger language models. Professional cards such as the NVIDIA H100 Tensor Core GPU have 80 GB of vRAM, but currently cost around €30,000.
Our goal was to build a machine learning computer on which medium-sized models could be operated locally without using cloud providers, which would be as powerful as possible, but on a manageable budget.
The choice fell on a Dell PowerEdge C4130 rack server with two Nvidia Tesla P40 GPUs, 64 Xeon cores, 128GB RAM and 800GB hot-swap disks. The acquisition costs for the used hardware amounted to a total of 1550 €.
In 2020 the P40 GPUs were in the upper performance class and continue to be provided with driver updates by Nvidia. How their performance has stood the test of time is revealed in the benchmarks in the second part of the article. For now we’ll describe the structure of the basic system without starting up the GPUs. The goal is to create a working environment that can be operated completely without physical access. The server hardware has some interesting featutres which allow such access, and we will now take a closer look at these features.&lt;/p&gt;

&lt;h3 id=&quot;initial-assessment&quot;&gt;Initial assessment&lt;/h3&gt;
&lt;p&gt;The chassis of the C4130 is designed for mounting in a 19” rack, with a height unit (1U) and a depth of almost 90cm. It has 2 redundant 2 kW power supplies, one of which unfortunately suffered damage during shipping.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-1/Lieferschaden.jpg&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Photo of the delivery damage to the power supply.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt;Delivery damage to the power supply.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;While we had no issues with the &lt;a href=&quot;https://www.bargainhardware.co.uk/&quot;&gt;seller&lt;/a&gt; exchanging the damaged goods, the matching C19 power cables were not included and had to be reordered.
The machine is completely designed for remote maintenance, so it usually no longer requires on-site presence after installation in the data center. It also has 2 Gigabit Ethernet ports and a maintenance port. It can be accessed via VGA and USB, but we do not use this due to the lack of a suitable VGA adapter. The handbook documents the various access routes.
When switched on for the first time, the LEDs on the front and back of the chassis flash orange. Ideally they should be solid blue, so the system doesn’t feel completely healthy.
The maintenance access (iDRAC) has a somewhat old-fashioned web interface on the factory-set IP &lt;code class=&quot;highlighter-rouge&quot;&gt;192.168.0.120.&lt;/code&gt; Commendably, you can use the maintenance port on a switch as well as on a laptop (auto-sense), for which you have to manually select an IP address on the same LAN as the laptop.
The iDRAC is completely independent of the main system and can be accessed as soon as the chassis receives power. In the diagnostics area, the condition of all components is visible. In our case, as expected, the removed power supply is flagged, and a fan is also defective, which is why the status LEDs flash orange.
Speaking of fans: There are 8 built-in cooling units, each with 2 fans. Due to the low height (1U is about 4.5cm), they already spin at idle at 8,000 rpm. The limit is about 20,000 rpm, which is unpleasantly loud. Colleagues present in the room quickly left after it had been switched on.
Other interior features: a 128 GB main memory, 64 cores in 2 Xeon E5-2697A processors, and two 800 GB hot-swappable SSDs (1.8” uSATA).
When you remove the lid of the chassis, your eye is immediately caught by the 4 GPU bays directly in front of the fans. There are several slots free for more main memory, and there is still room for more hard drives at the back. The opening and reclosing of the chassis is logged by the iDRAC, even when it is switched off.
In the iDRAC there is a VNC console which allows access to the BIOS and other diagnostic tools. We performed a detailed memory test, which ended after several hours without returning any errors.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-1/idrac-oberfläche.jpg&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Screenshot of the iDRAC interface.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt;iDRAC interface has that look and feel of the 90s.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Before the first boot of the main system, we change the boot order in the BIOS and disable the default network start (PXE). Thanks to this we avoid long pauses at startup.&lt;/p&gt;

&lt;p&gt;Before you can turn your attention to GPUs, a basic operating system is required. The choice fell on Ubuntu because it is both commonly used and supplied by Nvidia with current GPU drivers and libraries. We are looking for:
Encryption on both SSDs (cryptsetup + LUKS);
LVM with 2 physical volumes;
and within it logical partitions for &lt;code class=&quot;highlighter-rouge&quot;&gt;/&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;/var&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;/home&lt;/code&gt;.
We decide against RAID1 on the hot-swappable disks in favor of more usable space for our AI models.
We start the Ubuntu server installer from a USB stick and access it via the VNC console in iDRAC. Caution is advised when entering passwords during installation: The keyboard layout of the VNC viewer in the iDRAC console is neither German nor English, but instead a wild mixture.
We noticed that the VNC console didn’t not run stable, with the connection not always working. A &lt;a href=&quot;https://www.dell.com/support/contents/en-uk/videos/videoplayer/how-to-reset-and-drain-power-of-dell-poweredge-server/6301449860001&quot;&gt;cold start&lt;/a&gt; might help.
The Ubuntu installer is somewhat overwhelmed with our partitioning requests: it apparently fails because the two encrypted disks are to be combined into one LVM volume (LVM = Logical Volume Manager). We work around the problem by initially setting up only an encrypted SSD with an LVM root volume. This means that the initial installation is complete within 5 minutes after a reboot.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Logical_Volume_Manager_(Linux)&quot;&gt;LVM&lt;/a&gt; allows us to change volume sizes in the file system relatively easily afterwards, as well as to include additional disks. The necessary connections are already available in the chassis.&lt;/p&gt;

&lt;p&gt;Manual setup of the second hard drive
We would like to have &lt;code class=&quot;highlighter-rouge&quot;&gt;/home&lt;/code&gt; on the second (still unformatted) disk &lt;code class=&quot;highlighter-rouge&quot;&gt;/dev/sdb&lt;/code&gt;, as we want to have plenty of room for our AI models. To do this, we create an encrypted partition:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# parted /dev/sdb mklabel gpt
# parted -a optimal /dev/sdb mkpart primary 0% 100%
# cryptsetup luxFormat /dev/sdb1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To be able to unlock both disks with the same password, we use the script &lt;code class=&quot;highlighter-rouge&quot;&gt;decrypt_keyctl&lt;/code&gt; (included in &lt;code class=&quot;highlighter-rouge&quot;&gt;cryptsetup&lt;/code&gt;). It takes &lt;code class=&quot;highlighter-rouge&quot;&gt;keyctl&lt;/code&gt; from the keyutils package, which we however still need to install manually. Then it is entered in &lt;code class=&quot;highlighter-rouge&quot;&gt;/etc/crypttab&lt;/code&gt; for both disks:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# apt install keyutils
# cat /etc/crypttab
dm_crypt-0 UUID=035c6de5-99df-4e81-ba49-578d6b97c4cf none luks,keyscript=decrypt_keyctl
crypt_sdb1 UUID=97675b26-983a-42f8-8e2c-a5edb0fb051f none luks,keyscript=decrypt_keyctl
# update-initramfs -u
# reboot
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;The next time the machine is restarted, both disks are decoded as planned. We occupy the now available space entirely with /home in another physical LVM volume. In theory, LVM could be dispensed with for a single partition, however it allows us to change the distribution of the disks later if necessary.&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# pvcreate /dev/mapper/crypt_sdb1
# vgcreate data-vg /dev/mapper/crypt_sdb1
# lvcreate -n data-home -l 100%FREE data-vg
# mkfs.ext4 /dev/data-vg/data-home
# cat /etc/fstab
...
/dev/disk/by-uuid/8209347b-0ddd-47f8-a5ba-b505cb822085 /home ext4 defaults 0 1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Normally, the password for encrypted hard drives is required at system startup. However, this will no longer be accessible as soon as the machine is placed in the rack. We therefore install &lt;code class=&quot;highlighter-rouge&quot;&gt;dropbear-initramfs&lt;/code&gt; to be able to unlock the disks via SSH.
Deviating from usual procedure, we convert the existing OpenSSH host keys to Dropbear format and install them in &lt;code class=&quot;highlighter-rouge&quot;&gt;initramfs&lt;/code&gt;, so that we can use the normal SSH port (22) for unlocking without causing any key conflicts.&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# /usr/lib/dropbear/dropbearconvert openssh dropbear \ /etc/ssh/ssh_host_ecdsa_key \ /etc/dropbear/initramfs/dropbear_ecdsa_host_key
# /usr/lib/dropbear/dropbearconvert openssh dropbear \ /etc/ssh/ssh_host_ed25519_key \ /etc/dropbear/initramfs/dropbear_ed25519_host_key
# /usr/lib/dropbear/dropbearconvert openssh dropbear \ /etc/ssh/ssh_host_rsa_key \ /etc/dropbear/initramfs/dropbear_rsa_host_key
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Finally, the public keys of all administrators are entered in /etc/dropbear/initramfs/authorized_keys and the ramdisk is updated:&lt;/p&gt;
&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# update-initramfs -u
# reboot
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Et voilà, after a reboot, the disks can be unlocked via SSH.&lt;/p&gt;

&lt;h3 id=&quot;compulsory-reworking&quot;&gt;Compulsory reworking&lt;/h3&gt;
&lt;p&gt;During the final system cleanup, we stupidly overlooked the fact that &lt;code class=&quot;highlighter-rouge&quot;&gt;cryptsetup-initramfs&lt;/code&gt; is not a manually selected package and it is automatically uninstalled. As a result, the system no longer boots because the root partition cannot be decrypted.
Luckily a rescue system is hidden in the help menu of the Ubuntu installer. From there, we manually mount the installed filesystem and reinstall &lt;code class=&quot;highlighter-rouge&quot;&gt;cryptsetup-initramfs&lt;/code&gt; in the &lt;code class=&quot;highlighter-rouge&quot;&gt;chroot&lt;/code&gt;. Now the machine starts again.&lt;/p&gt;</content><author><name>danielKnauth</name></author><summary type="html">This post demonstrates how to set up a high-performance server for machine learning on a small budget.</summary></entry><entry xml:lang="de"><title type="html">Einrichtung eines ML-HPC-Servers (Teil 1 - Hardware)</title><link href="https://blog.cronn.de/de/machinelearning/2025/12/02/einrichtung-ml-hpc-server-1.html" rel="alternate" type="text/html" title="Einrichtung eines ML-HPC-Servers (Teil 1 - Hardware)" /><published>2025-12-02T00:00:00-06:00</published><updated>2025-12-02T00:00:00-06:00</updated><id>https://blog.cronn.de/de/machinelearning/2025/12/02/einrichtung-ml-hpc-server-1</id><content type="html" xml:base="https://blog.cronn.de/de/machinelearning/2025/12/02/einrichtung-ml-hpc-server-1.html">&lt;h3 id=&quot;motivation&quot;&gt;Motivation&lt;/h3&gt;
&lt;p&gt;Viele mächtige KI-Modelle wie &lt;em&gt;gpt-oss&lt;/em&gt; oder &lt;em&gt;DeepSeek&lt;/em&gt; werden mittlerweile als Open Source veröffentlicht. Um aktuelle und größere Modelle performant zu betreiben, werden leistungsfähige Grafikkarten (GPUs) benötigt. Ein maßgebliches Kriterium ist dabei der verfügbare Grafikspeicher (vRAM).&lt;/p&gt;

&lt;p&gt;Gaming-GPUs der oberen Preisklasse sind mit bis zu 24 GB vRAM ausgestattet. Das ist für größere Sprachmodelle jedoch nicht ausreichend. Professionelle Karten wie die NVIDIA H100 Tensor Core GPU haben 80 GB vRAM, kosten aber derzeit ca. 30.000 €. Unser Ziel war es, mit überschaubarem Budget einen möglichst leistungsfähigen Rechner für Machine-Learning aufzubauen, auf dem mittelgroße Modelle lokal betrieben werden können, ohne Nutzung von Cloud-Anbietern.&lt;/p&gt;

&lt;p&gt;Die Wahl fiel auf einen Dell PowerEdge C4130 Rack Server mit zwei Nvidia Tesla P40 GPUs, 64 Xeon-Kernen, 128GB RAM und 800GB Hot-Swap Platten. Die Anschaffungskosten für die gebrauchte Hardware betragen in Summe 1550 €.
Die P40-GPUs waren um 2020 in der oberen Leistungsklasse und werden weiterhin von Nvidia mit Treiber-Updates versorgt. Was man damit heute noch anfangen kann, verraten die Benchmarks im zweiten Teil des Artikels.&lt;/p&gt;

&lt;p&gt;Der erste Teil beschreibt den Aufbau des Grundsystems ohne Inbetriebnahme der GPUs. Das Ziel ist, eine lauffähige Umgebung zu bekommen, die komplett ohne physischen Zugang betreibbar ist. Dafür hat die bestellte Server-Hardware einige interessante Eigenheiten, die wir näher betrachten.&lt;/p&gt;

&lt;h3 id=&quot;erstbegutachtung&quot;&gt;Erstbegutachtung&lt;/h3&gt;
&lt;p&gt;Das Chassis des C4130 ist für Montage in einem 19” Rack bestimmt, es hat eine Höheneinheit (1U) und eine Tiefe von fast 90cm. Es besitzt 2 redundante 2 kW-Netzteile, von denen eines leider einen unübersehbaren Transportschaden hat.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-1/Lieferschaden.jpg&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Bild, das den Lieferschaden am Netzteil zeigt.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt;Lieferschaden am Netzteil.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Ein Austausch durch den &lt;a href=&quot;https://www.bargainhardware.co.uk/&quot;&gt;Händler&lt;/a&gt; erfolgt problemlos. Die passenden C19-Stromkabel liegen dummerweise nicht bei und müssen ebenfalls nachbestellt werden.
Die Maschine ist komplett für Fernwartung ausgelegt, also erfordert sie nach Einbau im Rechenzentrum (RZ) normalerweise keine Präsenz mehr vor Ort. Dazu hat sie 2 Gigabit Ethernet-Anschlüsse und einen Wartungs-Port. Man kann auch über VGA und USB darauf zugreifen, worauf wir mangels passendem VGA-Adapter jedoch verzichten. Im Handbuch sind die verschiedenen Zugangswege dokumentiert.
Beim erstmaligen Einschalten fallen die orange blinkenden LEDs an Vorder- und Rückseite des Chassis auf. Normalerweise sollten sie konstant blau leuchten, das System fühlt sich also nicht völlig gesund.&lt;/p&gt;

&lt;p&gt;Der Wartungszugang (iDRAC) hat eine etwas altbackene Weboberfläche auf der werksseitig eingestellten &lt;code class=&quot;highlighter-rouge&quot;&gt;IP 192.168.0.120&lt;/code&gt;. Löblicherweise kann man den Wartungs-Port sowohl an einem Switch als auch an einem Laptop benutzen (auto-sense), wofür am Laptop manuell eine IP im selben LAN gewählt werden muss.&lt;/p&gt;

&lt;p&gt;Das iDRAC ist komplett unabhängig vom Hauptsystem und erreichbar, sobald das Chassis Strom bekommt. Im Diagnosebereich ist der Zustand aller Komponenten sichtbar, in unserem Fall wird erwartungsgemäß das ausgebaute Netzteil beanstandet, außerdem ist ein Lüfter defekt, weswegen die Status-LEDs orange blinken. Apropos Lüfter: Eingebaut sind 8 Stück mit jeweils 2 Ventilatoren. Aufgrund der geringen Bauhöhe (1U sind ca. 4,5cm) drehen diese schon im Leerlauf mit 8.000 U/min, das Limit sind ca. 20.000 U/min, also richtig unangenehm laut. Anwesende Kollegen verließen nach dem Einschalten zügig den Raum.&lt;/p&gt;

&lt;p&gt;Weitere Innenausstattung: 128 GB Hauptspeicher, 64 Kerne in 2 Xeon E5-2697A-Prozessoren, zwei 800 GB hot-Swap-fähige SSDs (1,8” uSATA).
Wenn man den Deckel des Chassis abnimmt, fallen sofort die 4 GPU-Einschübe direkt vor den Lüftern ins Auge. Für mehr Hauptspeicher sind etliche Steckplätze frei, hinten ist noch Platz für weitere Festplatten. Das Öffnen und Wiederverschließen des Chassis wird vom iDRAC protokolliert, auch in ausgeschaltetem Zustand.
Im iDRAC gibt es eine VNC-Konsole, die u.a. Zugriff auf das BIOS und weitere Diagnose-Werkzeuge erlaubt. Wir machen einen ausführlichen Speichertest, der nach mehreren Stunden ohne Fehler endet.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/einrichtung-ml-hpc-server-1/idrac-oberfläche.jpg&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Screenshot der iDRAC-Oberfläche.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt;iDRAC-Oberfläche im Look&amp;amp;Feel der 90er Jahre.&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Vor dem ersten Start des Hauptsystems ändern wir noch die Boot-Reihenfolge im BIOS, denn dort ist Netzwerkstart (PXE) voreingestellt. Wir deaktivieren es, um lange Pausen beim Start zu vermeiden.&lt;/p&gt;

&lt;h3 id=&quot;linux-basisinstallation&quot;&gt;Linux-Basisinstallation&lt;/h3&gt;
&lt;p&gt;Bevor man sich den GPUs zuwenden kann, wird ein Basis-Betriebssystem benötigt. Die Wahl fiel auf Ubuntu, weil es gängig ist und von Nvidia mit aktuellen GPU-Treibern und –Bibliotheken versorgt wird.&lt;/p&gt;

&lt;p&gt;Wir hätten gerne:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Verschlüsselung auf beiden SSDs (cryptsetup + LUKS),&lt;/li&gt;
  &lt;li&gt;darüber LVM mit 2 physischen Volumes,&lt;/li&gt;
  &lt;li&gt;und darin logische Partitionen für /, /var und /home.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Auf ein RAID1 der Hot-Swap-Platten verzichten wir zugunsten von mehr nutzbarem Platz für KI-Modelle.
Wir starten den Ubuntu-Server-Installer von einem USB-Stick und greifen über die VNC-Konsole im iDRAC darauf zu. Bei der Eingabe von Kennworten während der Installation ist Vorsicht geboten: Die Tastaturbelegung des VNC-Viewers in der iDRAC-Konsole ist eigenwillig, weder deutsch noch englisch, sondern eine wilde Mixtur.&lt;/p&gt;

&lt;p&gt;Uns fällt auf, dass die VNC-Konsole nicht ganz stabil läuft, manchmal funktioniert der Verbindungsaufbau nicht. Ein &lt;a href=&quot;https://www.dell.com/support/contents/de-de/videos/videoplayer/anleitung-zum-zur%C3%BCcksetzen-und-entladen-des-reststroms-eines-dell-poweredge-servers/6301449860001&quot;&gt;Kaltstart&lt;/a&gt; kann weiterhelfen.&lt;/p&gt;

&lt;p&gt;Der Ubuntu-Installer ist mit unseren Partitionierungswünschen etwas überfordert, es scheitert offenbar an den zwei verschlüsselten Platten, die zu einem LVM-Volume (LVM = Logical Volume Manager) zusammengefasst werden sollen. Wir umgehen das Problem, indem wir zunächst nur eine verschlüsselte SSD mit einem LVM Root-Volume einrichten. Damit ist die Erstinstallation in 5 Minuten nach einem Neustart abgeschlossen.&lt;/p&gt;

&lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Logical_Volume_Manager_(Linux)&quot;&gt;LVM&lt;/a&gt; erlaubt uns, nachträglich die Volume-Größen im Dateisystem relativ einfach zu ändern oder zusätzliche Platten einzubinden. Dafür sind im Chassis die passenden Anschlüsse bereits vorhanden.&lt;/p&gt;

&lt;h3 id=&quot;manuelle-einrichtung-der-zweiten-festplatte&quot;&gt;Manuelle Einrichtung der zweiten Festplatte&lt;/h3&gt;
&lt;p&gt;Wir hätten gerne &lt;code class=&quot;highlighter-rouge&quot;&gt;/home&lt;/code&gt; auf der zweiten (noch unformatierten) Platte &lt;code class=&quot;highlighter-rouge&quot;&gt;/dev/sdb&lt;/code&gt;, da wir reichlich Platz für KI-Modelle haben wollen. Dazu legen wir eine verschlüsselte Partition an:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# parted /dev/sdb mklabel gpt
# parted -a optimal /dev/sdb mkpart primary 0% 100%
# cryptsetup luksFormat /dev/sdb1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Um beide Platten mit demselben Passwort entsperren zu können, benutzen wir das Skript &lt;code class=&quot;highlighter-rouge&quot;&gt;decrypt_keyctl&lt;/code&gt; (in cryptsetup enthalten). Es benötigt &lt;code class=&quot;highlighter-rouge&quot;&gt;keyctl&lt;/code&gt; aus dem Paket &lt;code class=&quot;highlighter-rouge&quot;&gt;keyutils&lt;/code&gt;, das wir noch manuell installieren müssen. Anschließend wird es für beide Platten in &lt;code class=&quot;highlighter-rouge&quot;&gt;/etc/crypttab&lt;/code&gt; eingetragen:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# apt install keyutils
# cat /etc/crypttab
dm_crypt-0 UUID=035c6de5-99df-4e81-ba49-578d6b97c4cf none luks,keyscript=decrypt_keyctl
crypt_sdb1 UUID=97675b26-983a-42f8-8e2c-a5edb0fb051f none luks,keyscript=decrypt_keyctl
# update-initramfs -u
# reboot
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Beim nächsten Neustart der Maschine werden wunschgemäß beide Platten entschlüsselt. Den nun verfügbaren Platz belegen wir vollständig mit &lt;code class=&quot;highlighter-rouge&quot;&gt;/home&lt;/code&gt; in einem weiteren physischen LVM-Volume. Auf LVM könnte man für eine einzelne Partition im Prinzip auch verzichten, aber es erlaubt uns, gegebenenfalls später die Aufteilung der Platten zu ändern.&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# pvcreate /dev/mapper/crypt_sdb1
# vgcreate data-vg /dev/mapper/crypt_sdb1
# lvcreate -n data-home -l 100%FREE data-vg
# mkfs.ext4 /dev/data-vg/data-home
# cat /etc/fstab
...
/dev/disk/by-uuid/8209347b-0ddd-47f8-a5ba-b505cb822085 /home ext4 defaults 0 1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Normalerweise wird beim Systemstart das Kennwort für verschlüsselte Festplatten auf der Konsole verlangt. Diese wird jedoch nicht mehr zugänglich sein, sobald die Maschine ins Rack kommt. Wir installieren daher &lt;code class=&quot;highlighter-rouge&quot;&gt;dropbear-initramfs&lt;/code&gt;, um die Platten über SSH entsperren zu können.
Abweichend von der üblichen Vorgehensweise konvertieren wir die vorhandenen OpenSSH Host Keys ins Dropbear-Format und installierten sie ins &lt;code class=&quot;highlighter-rouge&quot;&gt;initramfs&lt;/code&gt;, so dass wir zur Entsperrung den normalen SSH-Port 22 ohne Schlüsselkonflikte nutzen können.&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# /usr/lib/dropbear/dropbearconvert openssh dropbear \
/etc/ssh/ssh_host_ecdsa_key \
/etc/dropbear/initramfs/dropbear_ecdsa_host_key
# /usr/lib/dropbear/dropbearconvert openssh dropbear \
/etc/ssh/ssh_host_ed25519_key \
/etc/dropbear/initramfs/dropbear_ed25519_host_key
# /usr/lib/dropbear/dropbearconvert openssh dropbear \
/etc/ssh/ssh_host_rsa_key \
/etc/dropbear/initramfs/dropbear_rsa_host_key
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Zuletzt werden öffentliche Schlüssel der Administratoren in &lt;code class=&quot;highlighter-rouge&quot;&gt;/etc/dropbear/initramfs/authorized_keys&lt;/code&gt; eingetragen und die Ramdisk aktualisiert:&lt;/p&gt;

&lt;div class=&quot;highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;# update-initramfs -u
# reboot
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Voilà, nach einem Neustart lassen sich die Platten auch über SSH entsperren.&lt;/p&gt;

&lt;h3 id=&quot;unfreiwillige-nacharbeiten&quot;&gt;Unfreiwillige Nacharbeiten&lt;/h3&gt;
&lt;p&gt;Bei der abschließenden Bereinigung des Systems übersehen wir dummerweise, dass &lt;code class=&quot;highlighter-rouge&quot;&gt;cryptsetup-initramfs&lt;/code&gt; kein manuell gewähltes Paket ist und automatisch deinstalliert wird. Daraufhin startet das System nicht mehr, weil die Root-Partition nicht entschlüsselt werden kann.&lt;/p&gt;

&lt;p&gt;Ein vollständiges Rettungssystem ist im Hilfemenü des Ubuntu-Installers versteckt. Von dort hängen wir das installierte Dateisystem manuell ein und installieren &lt;code class=&quot;highlighter-rouge&quot;&gt;cryptsetup-initramfs&lt;/code&gt; im &lt;code class=&quot;highlighter-rouge&quot;&gt;chroot&lt;/code&gt; noch einmal. Nun startet die Maschine wieder.&lt;/p&gt;

&lt;p&gt;Für den nächsten Schritt montieren wir die P40-GPUs in die Einschübe 1+2. Deren Einrichtung und die Messung der Rechenleistung werden im zweiten Teil beschrieben.&lt;/p&gt;</content><author><name>danielKnauth</name></author><summary type="html">Wir zeigen, wie ihr mit kleinem Budget einen High-Performance-Server für Machine Learning einrichtet.</summary></entry><entry xml:lang="de"><title type="html">Sicherheit automatisiert testen: Mit Playwright zu robuster Web Security</title><link href="https://blog.cronn.de/de/testing/2025/11/13/security-e2e-tests.html" rel="alternate" type="text/html" title="Sicherheit automatisiert testen: Mit Playwright zu robuster Web Security" /><published>2025-11-13T00:00:00-06:00</published><updated>2025-11-13T00:00:00-06:00</updated><id>https://blog.cronn.de/de/testing/2025/11/13/security-e2e-tests</id><content type="html" xml:base="https://blog.cronn.de/de/testing/2025/11/13/security-e2e-tests.html">&lt;h2 id=&quot;einleitung&quot;&gt;Einleitung&lt;/h2&gt;
&lt;p&gt;Mit automatisierten Ende-zu-Ende-Tests lassen sich nicht nur Bugs finden, sondern auch regelmäßig die Einhaltung von Sicherheitsmaßnahmen überprüfen. Das hat eine Reihe von Vorteilen:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;Automatisierte Security-Tests überprüfen zuverlässig, ob Sicherheitsfunktionen wie vorgesehen funktionieren.&lt;/li&gt;
  &lt;li&gt;Sie helfen dabei, Sicherheitsmechanismen während der Weiterentwicklung stabil zu halten und ungewollte Regressionen frühzeitig zu erkennen.&lt;/li&gt;
  &lt;li&gt;Beim Schreiben automatisierter Tests wird die Perspektive potenzieller Angreifer eingenommen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In diesem Artikel zeigen wir anhand konkreter Beispiele, wie sich mit Playwright sicherheitsrelevante Aspekte wie Content Security Policy (CSP), Clickjacking oder Cross-Site Request Forgery (CSRF) zuverlässig testen lassen.&lt;/p&gt;

&lt;h2 id=&quot;ansatz-playwright-ende-zu-ende-security-testing&quot;&gt;Ansatz: Playwright-Ende-zu-Ende-Security-Testing&lt;/h2&gt;
&lt;p&gt;In diesem Artikel werden wir uns auf die Überprüfung ausgewählter Sicherheitsaspekte mithilfe von automatisierten Ende-zu-Ende-Tests konzentrieren. Diese Tests können neben den Ende-zu-Ende-Tests für die Features der Anwendung implementiert werden. Sie können in der gleichen Pipeline laufen wie diese „normalen“ Tests. Daher fühlt sich ihre Entwicklung wie die Entwicklung der Tests für Anwendungsfeatures an. Wir zeigen in diesem Beispiel exemplarisch für Content Security Policy (CSP) wie man einige Aspekte mithilfe von Playwright überprüfen kann. Die CSP wird im Header einer HTML-Antwort verschickt. Sie wird während der Entwicklungsarbeiten des Frontends konfiguriert. Um die CSP zu überprüfen, bietet es sich daher an, im Rahmen eines Tests, die Seite aufzurufen und dort die Checks durchzuführen. Playwright ist für Ende-zu-Ende Tests einer Webapplikation derzeit das gängige Werkzeug. Hier werden wir speziell auf die Besonderheiten beim Testen der CSP mit Playwright eingehen. Im Großen und Ganzen können für die Sicherheitstests die gleichen Ansätze und Methoden verwendet werden wie für Ende-zu-Ende Tests für neue Features.
In unseren Tests für die CSP wollen wir verschiedene Aspekte überprüfen.&lt;/p&gt;

&lt;h2 id=&quot;content-security-policy-überprüfung&quot;&gt;Content-Security-Policy-Überprüfung&lt;/h2&gt;
&lt;p&gt;Der erste Aspekt betrifft das einfache Aufrufen der zu überprüfenden Seite. Hier wollen wir als Erstes sicherstellen, dass keine CSP durch die vorhandene Implementierung verletzt wird. Daher rufen wir die Seite auf und überprüfen, dass keine Warnung in der Konsole des Browsers erscheint. Mit einer kleinen Funktion können wir Playwright anweisen, die Fehlermeldungen der Browserkonsole, die während des Tests erzeugt werden, in ein Array zu schreiben. Dazu übergeben wir die Seite und das Array an die Funktion und deren Implementierung sorgt dafür, dass die Fehlermeldungen in unser Array geschrieben werden.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;logBrowserErrors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[])&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;on&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;console&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;messsage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;messsage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;error&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;push&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;messsage&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;());&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Wir können daher nach dem Aufruf unserer zu überprüfenden Seite validieren, dass keine CSP-Warnungen oder andere Fehlermeldungen auf der Seite ausgelöst wurden. Die Überprüfung kann mit der expect-Funktion von Playwright vorgenommen werden.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nx&quot;&gt;expect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;toHaveLength&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Beim Aufrufen der Seite durch Playwright erhalten wir auch die Antwort auf diesen Aufruf. Diese enthält im Header die CSP-Attribute. Wir schreiben diese Werte in eine sogenannte Validierungsdatei. Diese wird beim ersten Durchlaufen des Tests mit den aktuellen CSP-Attributen gefüllt. Diese Werte müssen initial auf die erwarteten Werte kritisch überprüft werden. Sollte es Abweichungen zu den erwarteten Werten geben, so muss die CSP angepasst werden, damit die Werte in der Validierungsdatei mit den erwarteten Werten übereinstimmen.&lt;/p&gt;

&lt;p&gt;Sobald die Validierungsdatei freigegeben worden ist, wird in jedem weiteren Durchlauf des Tests, ob lokal oder in einer Pipeline, der Inhalt der Datei mit den aktuell erhaltenen Attributen verglichen. Sollte eine Abweichung erkannt werden, schlägt der Test fehl. Auf diese Weise werden zuverlässig alle Änderungen an der CSP erkannt. Bei geplanten Änderungen der CSP kann die Datei angepasst werden. In den restlichen Fällen wird überprüft, warum sich die CSP geändert hat und es kann entschieden werden, ob die Änderung rückgängig gemacht werden muss oder ob sie beibehalten werden kann.&lt;/p&gt;

&lt;p&gt;Hier ist ein Beispiel, wie der Inhalt einer solchen Validierungsdatei aussieht:&lt;/p&gt;

&lt;div class=&quot;language-json highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;nl&quot;&gt;&quot;cspHeaderValues&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;default-src 'self'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;connect-src 'self'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;script-src 'nonce-[NONCE]' 'strict-dynamic' 'wasm-unsafe-eval'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;style-src-elem 'self' 'nonce-[NONCE]'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;style-src-attr 'unsafe-inline'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;img-src 'self' blob: data:&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;font-src 'self' data:&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;object-src 'none'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;base-uri 'self'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;form-action 'self'&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
    &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;frame-ancestors 'none'&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
  &lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Die Nonce-Werte haben wir in dieser Datei maskiert, da sie in jedem Durchlauf neu erzeugt werden und der Test daher nicht auf einen konkreten Nonce-Wert testen kann.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;validateCSPData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;cspHeaderValues&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;allHeaders&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;())[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;content-security-policy&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;??&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;cspHeaderValues&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;===&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Error&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;CSP must not be empty.&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;hasMetaCSP&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;checkMetaCSP&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;expect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;hasMetaCSP&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;toBeFalsy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;snapshot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Record&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{};&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;snapshot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;cspHeaderValues&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;cspHeaderValues&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;split&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sr&quot;&gt;/;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\s&lt;/span&gt;&lt;span class=&quot;sr&quot;&gt;*/&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;filter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!==&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;compareActualWithValidationFile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;snapshot&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In der gezeigten Methode &lt;code class=&quot;highlighter-rouge&quot;&gt;validateCSPData&lt;/code&gt; ist unsere Implementierung für die Validierung der CSP-Attribute zu sehen. Wir müssen der Methode lediglich die Seite (&lt;code class=&quot;highlighter-rouge&quot;&gt;page&lt;/code&gt;) und die Antwort des Aufrufs der Seite (&lt;code class=&quot;highlighter-rouge&quot;&gt;response&lt;/code&gt;) übergeben. Die Methode extrahiert aus der Antwort den Anteil, der die CSP betrifft. In einer ersten Validierung überprüfen wir, dass die CSP nicht leer ist. Wir führen dann eine weitere Überprüfung aus und validieren, dass keine Meta-CSP-Attribute im HTML-Teil der Antwort befindlich sind. Wir haben uns dazu entschieden als eigenen Standard keine Meta-CSP-Attribute zuzulassen und überprüfen das an dieser Stelle, um Konflikte zwischen der CSP im Header und in den Meta-Attributen zu vermeiden. Am Ende der Methode formatieren wir die CSP-Attribute und übergeben sie unserer Methode, die die Werte mit der oben erwähnten Datei vergleicht.&lt;/p&gt;

&lt;h2 id=&quot;csp-warnung-überprüfen&quot;&gt;CSP-Warnung überprüfen&lt;/h2&gt;
&lt;p&gt;In einem weiteren Schritt manipulieren wird den HTML-Teil unserer zu überprüfenden Seite und verifizieren, dass die erwarteten CSP-Warnungen in der Konsole des Browsers erscheinen.
Eine Manipulation enthält zum Beispiel folgende Zeile, die wir dem HTML-Body der Seite hinzufügen:&lt;/p&gt;

&lt;div class=&quot;language-html highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nt&quot;&gt;&amp;lt;script &lt;/span&gt;&lt;span class=&quot;na&quot;&gt;src=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;https://bad.test/evil.js&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;async=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Diese Manipulation simuliert einen Angriff per XSS (Cross-Site-Scripting). Bei einem solchen Angriff wird auf eine Website „bösartiger Code“, meist in Form von JavaScript, eingeschleust. Falls der Code zur Ausführung käme, könnten zum Beispiel sensible Daten abgegriffen werden. Daher ist es wichtig zu überprüfen, dass falls Code in die Seite eingeschleust werden sollte, dieser auf keinen Fall ausgeführt wird.&lt;/p&gt;

&lt;p&gt;Die Manipulation des HTML-Bodys erreichen wir mithilfe der Methode route, die wir auf das &lt;code class=&quot;highlighter-rouge&quot;&gt;page&lt;/code&gt;-Objekt von Playwright anwenden:&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;setupRouteWithModifiedBody&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(),&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
      &lt;span class=&quot;kd&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;bodyForModification&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;bodyForModification&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;bodyForModification&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;replace&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;s2&quot;&gt;`&amp;lt;script src=&quot;https://bad.test/evil.js&quot; async=&quot;&quot;&amp;gt;&amp;lt;/script&amp;gt;&amp;lt;/body&amp;gt;`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
      &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fulfill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;bodyForModification&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In dieser Methode manipulieren wir den Aufruf der zu überprüfenden Seite. Wir wenden die &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt;-Methode auf die URL der Seite an und manipulieren dabei den HTML-Body. In der &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt;-Methode geben wir als ersten Parameter die URL an, die wir manipulieren möchten. Als zweiten Parameter definieren wir die Anweisungen, die dazu führen, dass der Body manipuliert wird. Dazu lassen wir zuerst mittels &lt;code class=&quot;highlighter-rouge&quot;&gt;route.fetch&lt;/code&gt; die eigentliche Antwort auf Anfragen zu der zu testenden Seite in eine Variable speichern. Diese Antwort verändern wird dann, indem wir am Ende ein „böses“ Skript hinzufügen. Mittels &lt;code class=&quot;highlighter-rouge&quot;&gt;route.fulfill&lt;/code&gt; weisen wir Playwright an, beim Aufruf der Seite den manipulierten Body zurückzugeben.&lt;/p&gt;

&lt;p&gt;Nachdem die Methode im Test aufgerufen worden ist, wird jeder Aufruf der Seite von Playwright abgefangen und der HTML-Body der Antwort wird durch den manipulierten Body ersetzt.&lt;/p&gt;

&lt;p&gt;Für den Fall, dass durch eine unzureichende CSP das Skript aufgerufen werden sollte, verwenden wir auch die &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt;-Methode von Playwright. Diese leitet den Aufruf für das Skript auf ein von uns definiertes Skript um:&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;setupRouteForEvilScript&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://bad.test/evil.js&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;jsContent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;`console.log(&quot;Hello world!&quot;);`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fulfill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;200&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;contentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;application/javascript&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;jsContent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Wenn während der Testausführung die Seite mit dem manipulierten Body aufgerufen wird, wird eine Warnung in der Konsole des Browsers ausgegeben und das „böse“ Skript wird nicht geladen.&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/security-e2e-tests-img1.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Screenshot des Browsers mit der zu testenden URL. Das Dev-Tool ist geöffnet und zeigt die Fehlermeldung: „Refused to load the script „https://evildomain.test/evil.js“ because it violates the following Content Security Policy directive: „script-src“ „nonce-N2Q4MWMmMTIyTUzMy0NmQ5LTg5MWYtZmIxZDUSMWUxZjVi“ „strict-dynamic“ „wasm-unsafe-eval““. Note that „script-src-elem“ was not explicitly set, so „script-src“ is used as a fallback.“ (Backticks und Code-Anführungszeichen wurden in diesem Alternativtext aus technischen Gründen durch deutsche Anführungszeichen ersetzt, um der HTML-Syntax im CMS genüge zu tun.)&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Man kann in dem Screenshot, der während der Testausführung erstellt wurde, mehrere verletzte CSP-Regeln sehen. Diese Fehlermeldungen werden in das anfangs erwähnte Array geschrieben. Sie werden wie die CSP im Header der HTML-Antwort in einer separaten Datei validiert. Sollte sich während einer Testausführung die Fehlermeldung ändern oder ganz ausbleiben, schlägt der Test fehl und es muss nach einer Ursache sowie einer Lösung dafür gesucht werden.&lt;/p&gt;

&lt;h2 id=&quot;clickjacking-mittels-csp-verhindern&quot;&gt;Clickjacking mittels CSP verhindern&lt;/h2&gt;
&lt;p&gt;Mithilfe der CSP kann auch verhindert werden, dass „bösartige“ Websites unsere Seite mittels eines iframe Elements in ihre Website einbetten, ein sogenannter Clickjacking-Angriff. Durch die Einbettung der Website wird unsere Seite durch die bösartige Website überlagert und weder die User noch wir als Betreiber erkennen, dass ungewollt Funktionen auf der Seite ausgeführt werden. Um dies zu verhindern, wird der CSP „frame-ancestors `none`“ hinzugefügt. Dies sorgt dafür, dass die Einbettung auf anderen Websites fehlschlägt. Für unseren Test haben wir eine minimale Website erstellt, die ein iframe-Element auf unsere Seite enthält. Wir haben dazu wieder die &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt;-Methode verwendet.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;setupRouteForIframeSite&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;body&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s2&quot;&gt;`&amp;lt;!DOCTYPE html&amp;gt;
    &amp;lt;head&amp;gt;
      &amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;
      &amp;lt;title&amp;gt;ClickJacking Test&amp;lt;/title&amp;gt;
    &amp;lt;/head&amp;gt;
    &amp;lt;body&amp;gt;&amp;lt;iframe src=&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;${&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()}&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&amp;lt;/body&amp;gt;
    &amp;lt;/html&amp;gt;`&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;https://bad.test/clickjacking&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fulfill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;contentType&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;text/html;charset=utf-8&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;body&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}),&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Die Methode &lt;code class=&quot;highlighter-rouge&quot;&gt;setupRouteForIframeSite&lt;/code&gt; führt dazu, dass wenn im Test die URL „https://bad.test/clickjacking“ aufgerufen wird, die in der Methode definierte Seite aufgerufen wird. Wenn die CSP korrekt konfiguriert ist, dann funktioniert das iframe-Element nicht. Zudem wird auf der Seite eine Fehlermeldung in der Konsole ausgegeben.&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/security-e2e-tests-img2.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Screenshot des Browsers in dem die bösartige Clickjacking-Domain geöffnet ist. Das Dev-Tool ist geöffnet und zeigt folgende Fehlermeldung: „Refused to frame „http://localhost:4002/“ because an ancestor violates the following Content Security Policy directive: „frame-ancestors „none““. (Backticks und Code-Anführungszeichen wurden in diesem Alternativtext aus technischen Gründen durch deutsche Anführungszeichen ersetzt, um der HTML-Syntax im CMS genüge zu tun.)&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Das ist in dem obigen Screenshot zu sehen. In der Fehlermeldung wird auch die verletzte CSP „frame-ancestors 'none’“ angegeben. Auch diese
Fehlermeldung wird wie oben beschrieben in eine Validierungsdatei geschrieben und bei jeder Ausführung des Tests überprüft.&lt;/p&gt;

&lt;h2 id=&quot;csrf-angriff-testen&quot;&gt;CSRF-Angriff testen&lt;/h2&gt;
&lt;p&gt;Zum Abschluss stellen wir noch ein CSRF-Szenario vor, welches man mittels Ende-zu-Ende-Tests in Playwright überprüfen kann. In einem ersten Schritt loggt sich der Playwright Test bei der zu testenden Software ein. Wir haben für diesen Test zwei minimale Websites erstellt, die bei dem Klick auf einen Link eine Abfrage an unsere zu testende Software abschicken. Dies ist jedoch auf den ersten Blick für einen Nutzer nicht ersichtlich. Zu Demonstrationszwecken beziehungsweise Testzwecken haben wir dazu einen zustandsändernden GET-Request verwendet.&lt;/p&gt;

&lt;p&gt;Wir testen sowohl einen Cross-Origin- als auch einen Same-Site-Fall.&lt;/p&gt;

&lt;figure&gt;
    &lt;img data-src=&quot;/img/posts/security-e2e-tests-img3.png&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;&quot; /&gt;
    &lt;figcaption class=&quot;long-fig-caption&quot;&gt; 
&lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Die erste Website hat eine von der zu testenden Seite unterschiedliche Domain. Die zweite Website hat eine Subdomain unserer zu testenden Seite als URL. Diese Seite ist oben abgebildet. Sie ist, wie man sieht, für den Test sehr minimal gehalten und enthält im Wesentlichen nur den bösartigen Link. Wenn Playwright im Test auf den Link klickt, überprüfen wir jeweils, dass eine Fehlermeldung beim Aufruf des Links auf unsere zu testende Software erscheint. Zusätzlich überwachen wir mittels der &lt;code class=&quot;highlighter-rouge&quot;&gt;route&lt;/code&gt;-Methode von Playwright den Endpunkt, der durch die bösartigen Aufrufe, also hier das Klicken auf den Link, angegriffen wird.&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;monitorAttackedEndpoint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
  &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;page&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;attackedEndpoint&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&amp;gt;&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fetch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;expect&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()).&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;toBe&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;403&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;route&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;fulfill&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;});&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;Um einen solchen Angriff zu verhindern, werden zum Beispiel CSRF-Cookies verwendet. Auf diese Weise wird verhindert, dass der Endpunkt den bösartigen Request beantwortet, da die bösartige Seite keinen Zugriff auf die CSRF-Cookies hat, die für einen erfolgreichen Request mitgeschickt werden müssen. Es wird in unserer Software bei einem versuchten CSRF-Angriff ein http-403-Fehlercode zurückgegeben. Dies überprüfen wir mit der oben dargestellten Methode.&lt;/p&gt;

&lt;h2 id=&quot;schlussbetrachtung&quot;&gt;Schlussbetrachtung&lt;/h2&gt;
&lt;p&gt;Wir haben hier an einigen Beispielen dargelegt, wie sich Sicherheitsaspekte für Webanwendungen, unter anderem CSP oder CSRF, im Zusammenspiel mit Playwright durch Ende-zu-Ende-Tests automatisiert testen lassen. Es wurde prinzipiell gezeigt, wie sich einige unterschiedliche Aspekte, zum Beispiel das Vorhandensein der erwarteten CSP in der http-Antwort, testen lassen. Die Tests lassen sich an unterschiedliche Webanwendungen anpassen und können auf diese Weise projektübergreifend eingesetzt werden. Die dargestellten Tests sind nur ein kleiner Ausschnitt von möglichen automatisierbaren Sicherheitstests. Weitere Sicherheitsaspekte, wie beispielsweise Zugriffsberechtigungen oder Brute-Force-Angriffe, können auch mithilfe von Ende-zu-Ende-Tests durch Playwright automatisiert getestet werden.&lt;/p&gt;</content><author><name>adrianWeber</name></author><summary type="html">Die Sicherheit von Webanwendungen mit Playwright testen – wir zeigen mit Beispielen wie das geht.</summary></entry><entry xml:lang="en"><title type="html">Using OpenRewrite for large-scale refactoring</title><link href="https://blog.cronn.de/en/java/2025/10/23/openrewrite-for-refactoring.html" rel="alternate" type="text/html" title="Using OpenRewrite for large-scale refactoring" /><published>2025-10-23T00:00:00-05:00</published><updated>2025-10-23T00:00:00-05:00</updated><id>https://blog.cronn.de/en/java/2025/10/23/openrewrite-for-refactoring</id><content type="html" xml:base="https://blog.cronn.de/en/java/2025/10/23/openrewrite-for-refactoring.html">&lt;h2 id=&quot;our-starting-position&quot;&gt;Our Starting Position&lt;/h2&gt;
&lt;p&gt;What makes OpenRewrite so compelling is its automated nature. Migrating your code base between Java versions or upgrading a framework becomes a more relaxed task: You add the corresponding so-called “recipe”, execute &lt;code class=&quot;highlighter-rouge&quot;&gt;rewriteRun&lt;/code&gt;, verify the code with your automated tests and then you’re done. Instead of replacing imports by hand or fighting with Gradle because of a rogue transitive dependency, you can take a coffee break while OpenRewrite works in the background.&lt;/p&gt;

&lt;p&gt;An OpenRewrite recipe contains the logic to do a specific task, like changing &lt;code class=&quot;highlighter-rouge&quot;&gt;org.junit&lt;/code&gt; imports with &lt;code class=&quot;highlighter-rouge&quot;&gt;org.assertj&lt;/code&gt; equivalents. Due to the large user base and the open-source nature of most recipes, you can find recipes for everything from Spring Boot upgrades to switching from &lt;code class=&quot;highlighter-rouge&quot;&gt;JUnit&lt;/code&gt; to &lt;code class=&quot;highlighter-rouge&quot;&gt;AssertJ&lt;/code&gt; in minutes. In some cases, it might also be useful for enforcing code standards – much like an auto-formatter – where OpenRewrite can be integrated into the normal development pipeline, for example as a pre-commit hook.&lt;/p&gt;

&lt;h2 id=&quot;how-does-it-work&quot;&gt;How Does It Work?&lt;/h2&gt;
&lt;p&gt;There are “declarative” and “imperative” recipes which have different purposes. You can imagine declarative recipes like Lego. They are defined in a simple YAML file and typically consist of a list of existing recipes that should be executed together. Many of these recipes are available in OpenRewrite’s public repositories&lt;sup id=&quot;fnref:3&quot;&gt;&lt;a href=&quot;#fn:3&quot; class=&quot;footnote&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; and are designed for common tasks, such as dependency upgrades or framework migrations. For example, the AssertJ&lt;sup id=&quot;fnref:2&quot;&gt;&lt;a href=&quot;#fn:2&quot; class=&quot;footnote&quot;&gt;2&lt;/a&gt;&lt;/sup&gt; recipe I mentioned earlier shows how an entire framework change can be automated with just a single declarative recipe.&lt;/p&gt;

&lt;p&gt;Imperative recipes, on the other hand, are implemented in code. They define the actual logic that transforms your source code; in many cases by replacing old methods with new ones or changing an import. While there are many of these already available, OpenRewrite also provides a comprehensive Java API for writing your own recipes which we’ll explore in more detail next.&lt;/p&gt;

&lt;h2 id=&quot;lossless-semantic-tree-and-visitor-pattern&quot;&gt;Lossless Semantic Tree and Visitor Pattern&lt;/h2&gt;
&lt;p&gt;OpenRewrite builds a Lossless Semantic Tree or LST&lt;sup id=&quot;fnref:4&quot;&gt;&lt;a href=&quot;#fn:4&quot; class=&quot;footnote&quot;&gt;3&lt;/a&gt;&lt;/sup&gt; when it is invoked. An LST, as its name suggests, is a much more detailed version of an AST (Abstract Syntax Tree). While the AST only contains the information necessary for evaluating the logical structure of the program, the LST includes whitespace information as well as a complete representation of the type relations. This means that once OpenRewrite has parsed a source file into an LST it can generate an exact replica from that LST alone. Because of this, local design abnormalities like an unusual indentation will be preserved as OpenRewrite doesn’t assume anything about your code styles. Additionally, because of the extensive type information, it can correctly identify the type of any given field. This is incredibly helpful if a recipe only wants to act on a very specific set of statements, for example for fixing a known vulnerability in a specific method from a package. OpenRewrite also uses this to verify that the new code uses existing types and doesn’t reference unavailable classes.&lt;/p&gt;

&lt;p&gt;Once that LST is built, we get a chance to modify it. OpenRewrite is designed around the visitor pattern&lt;sup id=&quot;fnref:5&quot;&gt;&lt;a href=&quot;#fn:5&quot; class=&quot;footnote&quot;&gt;4&lt;/a&gt;&lt;/sup&gt; which allows us to define the behavior of a “visitor” which is moving along the LST. Different visitor types exist to balance how much you’re able to change vs. what can be validated by OpenRewrite. For example, a &lt;code class=&quot;highlighter-rouge&quot;&gt;JavaIsoVisitor&lt;/code&gt; isn’t allowed to replace a method declaration with a field, however this is possible when using a &lt;code class=&quot;highlighter-rouge&quot;&gt;JavaVisitor&lt;/code&gt;. We would do this by overriding &lt;code class=&quot;highlighter-rouge&quot;&gt;visitX&lt;/code&gt; methods for all kinds of elements of a source file, such as class declarations, method declarations/invocations or conditionals. In each of these methods, we get some representation of that LST node in our code. These are immutable objects which contain the information present in the source file. We can use these when we want to change something for the current element, such as only renaming methods that start with “test”:&lt;/p&gt;

&lt;div class=&quot;language-java highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nd&quot;&gt;@Override&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;J&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;MethodDeclaration&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;visitMethodDeclaration&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;J&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;MethodDeclaration&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ExecutionContext&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;executionContext&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
   &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;getSimpleName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;startsWith&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;test&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
       &lt;span class=&quot;c1&quot;&gt;// TODO: Rename this method&lt;/span&gt;
   &lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
   &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;super&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;visitMethodDeclaration&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;executionContext&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;To allow for more control about how the LST is traversed  , OpenRewrite leaves it up to us to decide if and where we call &lt;code class=&quot;highlighter-rouge&quot;&gt;super.visitX&lt;/code&gt;. OpenRewrite generally recommends starting any &lt;code class=&quot;highlighter-rouge&quot;&gt;visitX&lt;/code&gt; method with the call to &lt;code class=&quot;highlighter-rouge&quot;&gt;super&lt;/code&gt;. Omitting this call entirely will mean that the sub-tree is not traversed  at all. This can be beneficial for improving performance; however, it isn’t needed in most cases.
To further expand upon our example from above, let’s now change the method name. In OpenRewrite, the LST itself should not be mutated. Instead, we build a new “method object” that we then return from our method.&lt;/p&gt;

&lt;div class=&quot;language-java highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;nd&quot;&gt;@Override&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;public&lt;/span&gt; &lt;span class=&quot;no&quot;&gt;J&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;MethodDeclaration&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;visitMethodDeclaration&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;no&quot;&gt;J&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;MethodDeclaration&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;ExecutionContext&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;executionContext&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
   &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;methodName&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;getSimpleName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;();&lt;/span&gt;

   &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;methodName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;startsWith&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;test&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;{&lt;/span&gt;
       &lt;span class=&quot;nc&quot;&gt;String&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;newName&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;methodName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;replaceFirst&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;test&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;check&quot;&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
       &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;withName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;getName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;withSimpleName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;newName&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;));&lt;/span&gt;
   &lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
   &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;super&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;na&quot;&gt;visitMethodDeclaration&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;method&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;executionContext&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;o&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;OpenRewrite detects that we returned an object different to what was passed into   the method. It concludes that we must have changed something about the code and will store this new object in place of the old node in the LST. If you want to instead completely remove a statement, simply return null. In cases where you don’t want to do anything you should return &lt;code class=&quot;highlighter-rouge&quot;&gt;super.visitX&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After the first visitor has traversed  the whole LST, OpenRewrite will run another visitor through our recipe. If it detects any further changes, it will repeat this step, until no changes are made anymore. To make sure that changes from our recipe did not cause a “regression” in another active recipe, it will then re-run all other recipes in a similar pattern. Once that finishes it can confidently assert that all recipes have applied their logic to every single piece of code in the code base and every possible change has been made.&lt;/p&gt;

&lt;h2 id=&quot;lessons-learned&quot;&gt;Lessons learned&lt;/h2&gt;
&lt;p&gt;Because of the inherent complexity in this type of meta programming, a test-driven development approach is highly favorable. It allows you to effectively cover the many possible edge cases.&lt;/p&gt;

&lt;p&gt;Something that OpenRewrite already warns about in their documentation is recipe state. Recipe state      increases the risk of artifacts from previous data unexpectedly changing the behaviour of your recipe. This not only introduces bugs that are difficult to find and fix, it also massively increases the complexity of your recipe. In our above example this can’t be avoided entirely, since we not only need to rename method declarations but also adjust any calls to those methods. This means we need to pass the information about our new names to &lt;code class=&quot;highlighter-rouge&quot;&gt;visitMethodInvocation&lt;/code&gt; so that we can adjust the method calls accordingly.&lt;/p&gt;

&lt;p&gt;The first option we have is the cursor. While the Java API of OpenRewrite itself doesn’t expose explicit methods like &lt;code class=&quot;highlighter-rouge&quot;&gt;enterClass&lt;/code&gt; and &lt;code class=&quot;highlighter-rouge&quot;&gt;exitClass&lt;/code&gt;, the cursor keeps track of where exactly we currently are in a stack-like structure, hence the name. It is cleared between every single cycle of a recipe and is best suited for communicating between two methods inside a visitor that come after each other. This wouldn’t be suitable for our scenario since a method call may come from a completely different place in the code base. Another possible solution is to put our information into the execution context. It is only ever cleared after all recipes have run so it is a much more persistent storage location. There are some limitations that you need to keep track of, however. The execution context does not allow mutating stored data to avoid hard to debug problems that occur due to state conflicts. You also need make sure that you don’t overwrite data from other recipes. The optimal way would be a ScanningRecipe&lt;sup id=&quot;fnref:6&quot;&gt;&lt;a href=&quot;#fn:6&quot; class=&quot;footnote&quot;&gt;5&lt;/a&gt;&lt;/sup&gt; visitor, where we first get the opportunity to scan the whole code base and collect information, after which a second visitor can apply changes.&lt;/p&gt;

&lt;h2 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;With an extensive collection of open-source recipes and a fleshed-out Java API, OpenRewrite is a great way to approach code refactoring at a large scale. While the in-memory nature of the LST naturally will become a bottleneck for bigger projects, this problem is solved by Moderne’s custom solution with which it is possible to split the tree generation and store it more permanently.
While OpenRewrite is primarily focused on Java and the surrounding ecosystem, it also offers recipes for YAML, XML, JSON and even a few other languages like C# or Scala (although in a much more limited capacity).
Further code examples can be found in the cronn github&lt;sup id=&quot;fnref:1&quot;&gt;&lt;a href=&quot;#fn:1&quot; class=&quot;footnote&quot;&gt;6&lt;/a&gt;&lt;/sup&gt;&lt;/p&gt;

&lt;div class=&quot;footnotes&quot;&gt;
  &lt;ol&gt;
    &lt;li id=&quot;fn:3&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://docs.openrewrite.org/recipes&quot; target=&quot;_blank&quot;&gt;OpenRewrite Recipe catalog&lt;/a&gt; &lt;a href=&quot;#fnref:3&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:2&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://docs.openrewrite.org/recipes/java/testing/assertj/junittoassertj&quot; target=&quot;_blank&quot;&gt;Migrate JUnit asserts to AssertJ&lt;/a&gt; &lt;a href=&quot;#fnref:2&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:4&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://docs.openrewrite.org/concepts-and-explanations/lossless-semantic-trees&quot; target=&quot;_blank&quot;&gt;Lossless Semantic Trees (LST)&lt;/a&gt; &lt;a href=&quot;#fnref:4&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:5&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Visitor_pattern&quot; target=&quot;_blank&quot;&gt;Wikipedia: Visitor pattern&lt;/a&gt; &lt;a href=&quot;#fnref:5&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:6&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://docs.openrewrite.org/concepts-and-explanations/recipes#scanning-recipes&quot; target=&quot;_blank&quot;&gt;Scanning Recipes&lt;/a&gt; &lt;a href=&quot;#fnref:6&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:1&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://github.com/cronn/open-rewrite-blog-post-example&quot; target=&quot;_blank&quot;&gt;Demo project&lt;/a&gt; &lt;a href=&quot;#fnref:1&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;
&lt;/div&gt;</content><author><name>jakobRoth</name></author><summary type="html">Automated refactoring with OpenRewrite – efficient and time-saving.</summary></entry><entry xml:lang="en"><title type="html">Performance Testing with k6: A Field Report</title><link href="https://blog.cronn.de/en/testing/2025/07/18/performance-testing-with-k6.html" rel="alternate" type="text/html" title="Performance Testing with k6: A Field Report" /><published>2025-07-18T00:00:00-05:00</published><updated>2025-07-18T00:00:00-05:00</updated><id>https://blog.cronn.de/en/testing/2025/07/18/performance-testing-with-k6</id><content type="html" xml:base="https://blog.cronn.de/en/testing/2025/07/18/performance-testing-with-k6.html">&lt;h3 id=&quot;project-context&quot;&gt;Project context&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://ga-lotse.de/&quot;&gt;GA-Lotse&lt;/a&gt; is a modular web application for health authorities which is intended to simplify internal documentation and external communication with citizens. Different departments are mapped in modules, which then can be configured by the health authorities. To ensure that the application meets highest security standards, the data is stored separately for each module. This and other security features – such as the Zero Trust principle – lead to intrinsic performance losses, which is why performance testing was an important part of the &lt;a href=&quot;https://www.cronn.de/referenzen/digitalisierung-gesundheitsamt-en&quot;&gt;project&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;selecting-the-load-testing-tool&quot;&gt;Selecting the load testing tool&lt;/h3&gt;

&lt;p&gt;It is often the case that you don’t have to implement everything yourself, so we looked for a tool which supports performance testing. Since we want to test a web application, the tool must allow browser testing. Our additional requirements were as follows:&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;The ability to write the test code in TypeScript, as we also use TypeScript for the frontend of the application and the end-to-end tests&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Open-source availability of the tool&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Executability on a self-hosted server (not a pure cloud solution)&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Good reporting to visualize the results of the tests for us and the developers.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After evaluating several tools, we decided on &lt;a href=&quot;https://grafana.com/docs/k6/latest/&quot;&gt;k6&lt;/a&gt;. k6 supports browser tests, enables development in TypeScript and, in combination with Grafana and through individually definable metrics, offers comprehensive reporting.&lt;/p&gt;

&lt;h3 id=&quot;our-setup&quot;&gt;Our setup&lt;/h3&gt;

&lt;p&gt;k6 runs the performance tests and generates some metrics, such as &lt;a href=&quot;https://web.dev/articles/ttfb&quot;&gt;TTFB&lt;/a&gt; or the duration of the individual requests. However, in order to visualize these and other test results, we needed even more tools. We chose &lt;a href=&quot;https://www.influxdata.com/&quot;&gt;InfluxDB&lt;/a&gt; as the database, as it is optimized for storing data in a time-resolved manner. To visualize the results, we used &lt;a href=&quot;https://grafana.com/oss/grafana&quot;&gt;Grafana-Dashboards&lt;/a&gt; because k6 belongs to Grafana and it provides an interface to InfluxDB. To query the data from the InfluxDB, we used the proprietary database query language &lt;a href=&quot;https://docs.influxdata.com/flux/v0/&quot;&gt;Flux&lt;/a&gt;. However, this is not a long-term solution as Flux will probably no longer be supported – or only supported to a limited extent – in the next major version. We decided to use the tools locally and package them in Docker containers in order to be able to run the tests hardware-independently and not be dependent on cloud providers. Alternatively, there is the option of using &lt;a href=&quot;https://grafana.com/products/cloud/k6/&quot;&gt;Grafana Cloud k6&lt;/a&gt;
to avoid installing the tools locally.&lt;/p&gt;

&lt;h3 id=&quot;performance-testing-with-k6&quot;&gt;Performance testing with k6&lt;/h3&gt;

&lt;p&gt;A test with k6 can be executed with a Javascript or TypeScript file (see example script).&lt;/p&gt;
&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Scenario&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;k6/options&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryBrowserTest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@/modules/browser/schoolEntryBrowserTest&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryApiTest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@/modules/api/schoolEntryApiTest&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;scenarios&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Record&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Scenario&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;schoolEntryBrowser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;exec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;schoolEntryBrowserTestFunction&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;executor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;constant-vus&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;vus&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;15m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;browser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;chromium&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;schoolEntryApi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;exec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;schoolEntryApiTestFunction&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;executor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;ramping-vus&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;startVUs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;stages&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Options&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;discardResponseBodies&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;scenarios&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;scenarios&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;systemTags&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;scenario&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;setupTimeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryBrowserTestFunction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryBrowserTest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryApiTestFunction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryApiTest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;This script defines options for the test and the test functions to be executed. The options are defined as JSON. An important option which determines the course of the test is &lt;code class=&quot;highlighter-rouge&quot;&gt;scenarios&lt;/code&gt;. This is where executable scenarios can be defined, thus mapping the actual test.&lt;/p&gt;

&lt;p&gt;To define a scenario one must define a function to be executed, as well as the number of executing parallel users, which in k6 are called Virtual Users (VU). The total duration of the scenario can be determined by specifying time periods. In addition, ramps can be defined to increase or decrease the number of parallel users during the test. Another way to influence the course of the test is to set a time interval in which a specific number of VUs should go through the scenario.&lt;/p&gt;

&lt;p&gt;Several such scenarios can be defined for a test, which are then run using different configurations. To make this definition of the scenarios easier and faster than editing a long JSON file, we have developed a builder that dynamically creates the scenario configuration and makes it available on GitHub: &lt;a href=&quot;https://github.com/cronn/k6-scenario-builder&quot;&gt;https://github.com/cronn/k6-scenario-builder&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;our-findings&quot;&gt;Our findings&lt;/h3&gt;

&lt;p&gt;During testing, we noticed a few things which need to be taken into account. First of all, it makes sense to have a dedicated machine available to run the tests. Since performance is not only affected by the load of many simultaneous users, but also by the amount of data in the database, we created both short spike tests as well as test scenarios that have a runtime of several hours in order to constantly increase the amount of data and simulate a kind of time-lapse of the actual use of the application. These tests can be carried out much more comfortably by an external machine than on your own laptop.&lt;/p&gt;

&lt;p&gt;In addition, the execution of a test requires sufficient resources on the executing machine. Therefore, care should be taken to ensure that there are always free resources available during the execution of a test so as not to unintentionally influence the results. We noticed this when running browser tests with some VUs. Too many browsers open at the same time turned the machine into a bottleneck. Our solution to this is to define both scenarios and browser tests which depict the same user journey, but send the necessary requests directly to the backend in order to increase the load on the backend without accessing the browser. Such API scenarios are also well suited to quickly assemble a scenario and thus get an overview of the backend’s performance.&lt;/p&gt;

&lt;p&gt;Another insight we gained was to test in an environment which was as close to production as possible. After all, the configuration of an environment, especially a complex microservice cluster, can have significant impact on performance. In addition to running the tests from another machine and testing on a production-like environment, it was still important for us to enable testing entirely on our own laptop. This allows developers to independently develop new scenarios and provide easy access to databases and logs.&lt;/p&gt;

&lt;p&gt;It also occurred that we had exceeded professional limits by configuring our scenarios, especially during long tests. For example, we created an unrealistic number of appointments for one day or user, or even had too many users with the same permissions. Many different parameters can influence performance and should therefore be defined as early as possible, allowing us to avoid unnecessary test runs. Nevertheless, it was also important for us to deliberately exceed the known limits to test the limits of the application and then improve it where necessary. After all, the customer may not know their professional limits, or their limits might be reached through technical errors. The application should not become unusable because the user booked one appointment too many. One lesson learned was therefore to clarify professional limits at an early stage and to observe them in the tests.&lt;/p&gt;

&lt;h3 id=&quot;pros-and-cons-of-k6&quot;&gt;Pros and Cons of k6&lt;/h3&gt;

&lt;p&gt;We ran into problems from time to time during testing with k6. A significant limitation of developing performance tests with k6 is a lack of a debugger. k6 uses its own &lt;a href=&quot;https://github.com/grafana/sobek&quot;&gt;JavaScript engine&lt;/a&gt;
to execute the test code, and there is no built-in debugger. The Javascript engine also has other weaknesses which you should be aware of, such as that it does not support the popular fetch API. In the context of browser tests, methods such as &lt;em&gt;goto()&lt;/em&gt; are a weakness, as they do not always work reliably in combination with Chromium, which occasionally leads to timing problems. In addition, locators must be identified via XPaths, which is very susceptible to regression, as well as often unsightly and long. Finally, the documentation of k6 is often relatively short.&lt;/p&gt;

&lt;p&gt;However, k6 also has many advantages. The reporting in combination with InfluxDB and Grafana works very well. Meaningful plots can be quickly created in such a setup without much prior knowledge and then be displayed in a dashboard so that the test results can be analyzed and communicated. In addition, the parallel execution of different scenarios, each of which is also executed with parallel virtual users, works very well. It allows you to create complex scenarios which map different types of performance tests, such as load tests, spike tests, and soak tests. The fact that the test options (and especially the scenarios) are described in JSON is an advantage as it provides a smooth transition to the Typescript code. You also have the option of running the browser tests in headful mode, so that problems can be detected and fixed during execution.&lt;/p&gt;

&lt;h3 id=&quot;summary&quot;&gt;Summary&lt;/h3&gt;

&lt;p&gt;Since we had constantly developed both our tests and setup during the test phase, an iterative approach paid off for us. We started with two simple scenarios for application-critical modules. In these initial scenarios, we realized that we needed more metrics and plots in our reports to analyze the results. Iteratively, we then added metrics to our tests and visualized them in the Grafana board. These metrics included information such as the duration of requests, the loading times of certain pages, or even the CPU and RAM usage of the executing machine. The duration of individual requests was particularly important for us, but which information is relevant depends on the application. Metric types built into k6 allow the collection of information to be flexibly designed. Working with k6 has shown us both strengths and weaknesses of the tool. Whether k6 is the best choice certainly depends on the use case, but for us it was a suitable tool despite some significant weaknesses.&lt;/p&gt;</content><author><name>simonBiwer</name></author><summary type="html">We're sharing our experience with performance testing in the GA-Lotse project – using a setup with k6, Grafana, InfluxDB, and TypeScript.</summary></entry><entry xml:lang="de"><title type="html">Performance-Testing mit k6: Ein Erfahrungsbericht</title><link href="https://blog.cronn.de/de/testing/2025/07/18/performance-testing-mit-k6.html" rel="alternate" type="text/html" title="Performance-Testing mit k6: Ein Erfahrungsbericht" /><published>2025-07-18T00:00:00-05:00</published><updated>2025-07-18T00:00:00-05:00</updated><id>https://blog.cronn.de/de/testing/2025/07/18/performance-testing-mit-k6</id><content type="html" xml:base="https://blog.cronn.de/de/testing/2025/07/18/performance-testing-mit-k6.html">&lt;h3 id=&quot;projektkontext&quot;&gt;Projektkontext&lt;/h3&gt;

&lt;p&gt;&lt;a href=&quot;https://ga-lotse.de/&quot;&gt;GA-Lotse&lt;/a&gt; (Gesundheitsamt-Lotse) ist eine modular aufgebaute Webanwendung für Gesundheitsämter, die die interne Dokumentation und externe Kommunikation mit Bürgerinnen und Bürgern vereinfachen soll. Verschiedene Abteilungen eines Gesundheitsamtes sind in Modulen abgebildet, die für Gesundheitsämter konfiguriert werden können. Damit die Anwendung höchsten Sicherheitsstandards genügt, werden die Daten für jedes Modul separat gespeichert. Dies und weitere Sicherheitsfeatures wie das Zero-Trust-Prinzip führen zu intrinsischen Einbußen der Performance, weshalb das Testen der Performance ein wichtiger Teil des &lt;a href=&quot;https://www.cronn.de/referenzen/digitalisierung-gesundheitsamt&quot;&gt;Projektes&lt;/a&gt; war.&lt;/p&gt;

&lt;h3 id=&quot;auswahl-des-lasttesttools&quot;&gt;Auswahl des Lasttesttools&lt;/h3&gt;

&lt;p&gt;Wie so häufig muss man nicht alles selbst implementieren, daher haben wir uns nach einem Tool umgesehen, das Performance-Testing unterstützt. Da wir eine Webanwendung testen wollen, sollte es Browsertests ermöglichen. Zudem waren unsere Hauptanforderungen folgende:&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;
    &lt;p&gt;Die Möglichkeit den Testcode in TypeScript zu schreiben, da wir TypeScript auch für das Frontend der Anwendung und die Ende-zu-Ende-Tests verwenden&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Open-Source-Verfügbarkeit des Tools&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Ausführbarkeit auf einem selbstgehosteten Server (keine reine Cloud-Lösung)&lt;/p&gt;
  &lt;/li&gt;
  &lt;li&gt;
    &lt;p&gt;Ein gutes Reporting, um die Ergebnisse der Tests für uns und die Entwickler zu visualisieren.&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nach der Evaluation mehrerer Tools haben wir uns für &lt;a href=&quot;https://grafana.com/docs/k6/latest/&quot;&gt;k6&lt;/a&gt; entschieden. k6 unterstützt Browsertests, ermöglicht die Entwicklung in TypeScript und bietet in Kombination mit Grafana sowie durch individuell definierbare Metriken ein umfassendes Reporting.&lt;/p&gt;

&lt;h3 id=&quot;unser-setup&quot;&gt;Unser Setup&lt;/h3&gt;

&lt;p&gt;k6 führt die Performance-Tests aus und erzeugt dabei bereits einige Metriken, wie z.B. &lt;a href=&quot;https://web.dev/articles/ttfb?hl=de#:~:text=Hinweis%3A%20%E2%80%9ETime%20to%20First%20Byte,um%20auf%20Anfragen%20zu%20reagieren.&quot;&gt;TTFB&lt;/a&gt; oder die Dauer der einzelnen Requests. Um diese und weitere Testergebnisse persistieren und visualisieren zu können, benötigten wir noch weitere Tools.&lt;/p&gt;

&lt;p&gt;Als Datenbank haben wir uns für &lt;a href=&quot;https://www.influxdata.com/&quot;&gt;InfluxDB&lt;/a&gt; entschieden, da diese dafür optimiert ist, Daten zeitaufgelöst zu speichern. Zur Visualisierung der Ergebnisse haben wir &lt;a href=&quot;https://grafana.com/oss/grafana&quot;&gt;Grafana-Dashboards&lt;/a&gt; genutzt, unter anderem da k6 zu Grafana gehört und es eine Schnittstelle zur InfluxDB bietet. Zur Abfrage der Daten aus der InfluxDB haben wir die proprietäre Datenbankabfragesprache &lt;a href=&quot;https://docs.influxdata.com/flux/v0/&quot;&gt;Flux&lt;/a&gt; genutzt. Diese wird jedoch vermutlich in der nächsten Major-Version v3 nicht mehr oder nur noch eingeschränkt unterstützt.&lt;/p&gt;

&lt;p&gt;Wir haben uns entschieden, die Tools lokal zu nutzen und sie in Docker-Container zu verpacken, um die Tests hardwareunabhängig ausführen zu können und nicht von Cloud-Anbietern abhängig zu sein. Alternativ besteht die Möglichkeit, &lt;a href=&quot;https://grafana.com/products/cloud/k6/&quot;&gt;Grafana Cloud k6&lt;/a&gt; zu verwenden, um die lokale Installation der Tools zu vermeiden.&lt;/p&gt;

&lt;h3 id=&quot;performance-tests-mit-k6&quot;&gt;Performance-Tests mit k6&lt;/h3&gt;

&lt;p&gt;Ein Test mit k6 lässt sich mit einem Javascript oder TypeScript-File ausführen (s. Beispielskript).&lt;/p&gt;
&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Scenario&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;k6/options&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryBrowserTest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@/modules/browser/schoolEntryBrowserTest&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryApiTest&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;@/modules/api/schoolEntryApiTest&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;scenarios&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Record&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;string&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Scenario&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;schoolEntryBrowser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;exec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;schoolEntryBrowserTestFunction&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;executor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;constant-vus&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;vus&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;15m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;na&quot;&gt;browser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;na&quot;&gt;type&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;chromium&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;schoolEntryApi&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;exec&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;schoolEntryApiTestFunction&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;executor&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;ramping-vus&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;startVUs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;na&quot;&gt;stages&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;duration&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;options&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Options&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;discardResponseBodies&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kc&quot;&gt;true&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;scenarios&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;scenarios&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;systemTags&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;url&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;scenario&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
  &lt;span class=&quot;na&quot;&gt;setupTimeout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;5m&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;'&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryBrowserTestFunction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryBrowserTest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryApiTestFunction&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;schoolEntryApiTest&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;();&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;In diesem Skript werden Optionen für den Test sowie die auszuführenden Testfunktionen definiert. Die Optionen werden als JSON definiert. Eine wichtige Option, die den Testverlauf bestimmt, ist &lt;code class=&quot;highlighter-rouge&quot;&gt;scenarios&lt;/code&gt;. Dort können Szenarien definiert werden, die ausgeführt werden und somit den eigentlichen Test abbilden.&lt;/p&gt;

&lt;p&gt;Für ein solches Szenario wird eine auszuführende Funktion, sowie die Anzahl an ausführenden parallelen Nutzern, die in k6 Virtual User (VU) genannt werden, definiert. Mit der Angabe von Zeiträumen kann die Gesamtdauer des Szenarios bestimmt werden. Außerdem können Rampen definiert werden, um die Anzahl der parallelen User während des Tests zu erhöhen oder zu verringern. Eine andere Möglichkeit den Testverlauf zu beeinflussen, ist, ein Zeitintervall festzulegen, in dem eine konkrete Anzahl an VUs das Szenario durchlaufen sollen.&lt;/p&gt;

&lt;p&gt;Für einen Test können mehrere solcher Szenarien definiert werden, die mit unterschiedlichen Konfigurationen durchlaufen werden. Um diese Definition der Szenarien einfacher und schneller zu gestalten als ein langes JSON-File zu editieren, haben wir einen Builder entwickelt, der die Szenario-Konfiguration dynamisch erstellt und diesen auf GitHub zur Verfügung gestellt: &lt;a href=&quot;https://github.com/cronn/k6-scenario-builder&quot;&gt;https://github.com/cronn/k6-scenario-builder&lt;/a&gt;.&lt;/p&gt;

&lt;h3 id=&quot;unsere-erkenntnisse&quot;&gt;Unsere Erkenntnisse&lt;/h3&gt;

&lt;p&gt;Während des Testens sind uns einige Dinge aufgefallen, die es aus unserer Sicht zu berücksichtigen gilt. Zunächst ist es sinnvoll, eine dedizierte Maschine zur Verfügung zu haben, die die Tests ausführt. Da die Performance nicht nur durch Last vieler gleichzeitiger User beeinträchtigt wird, sondern auch von der Menge der Daten in der Datenbank, haben wir neben kurzen Spike-Tests auch Testszenarien erstellt, die eine Laufzeit über mehrere Stunden haben, um so die Datenmenge stetig zu erhöhen und eine Art Zeitraffer der tatsächlichen Nutzung der Anwendung zu simulieren. Diese Tests sind von einer externen Maschine deutlich komfortabler auszuführen als von dem eigenen Laptop.&lt;/p&gt;

&lt;p&gt;Zudem benötigt die Ausführung eines Tests ausreichend Ressourcen auf der ausführenden Maschine. Daher sollte darauf geachtet werden, dass während der Ausführung eines Tests stets noch freie Ressourcen vorhanden sind, um nicht die Ergebnisse ungewollt zu beeinflussen. Dies haben wir bei der Ausführung von Browsertests mit einigen VUs bemerkt. Eine zu große Anzahl an gleichzeitig geöffneten Browsern hat die auszuführende Maschine zum Bottleneck gemacht. Unsere Lösung dafür ist, neben Browsertests gleichzeitig Szenarien zu definieren, die eine möglichst gleiche User-Journey abbilden, jedoch die nötigen Requests direkt ans Backend schicken, um somit die Last aufs Backend browserunabhängig zu erhöhen. Solche API-Szenarien eignen sich auch gut, um schnell ein Szenario zusammenzubauen und somit browserunabhängig einen Überblick über die Performance des Backends zu bekommen.&lt;/p&gt;

&lt;p&gt;Eine weitere Erkenntnis von uns war, auf einer möglichst produktionsnahen Umgebung zu testen. Denn auch die Konfiguration einer Umgebung, gerade ein komplexer Microservice-Cluster, kann die Performance erheblich beeinflussen. Neben dem Ausführen der Tests von einer anderen Maschine und dem Testen auf einer produktionsähnlichen Umgebung war es für uns dennoch wichtig, auch das Testen vollständig auf dem eigenen Laptop zu ermöglichen. Dies ermöglicht die unabhängige Entwicklung neuer Szenarien durch die Entwickler und einen einfachen Zugang zu Datenbanken und Logs.&lt;/p&gt;

&lt;p&gt;Es ist vorgekommen, dass wir durch die Konfiguration unserer Szenarios, vor allem bei langen Tests, fachliche Limits überschritten haben. Zum Beispiel haben wir unrealistisch viele Termine für einen Tag oder User angelegt, oder sogar zu viele User mit den gleichen Berechtigungen gehabt. Viele Größen können die Performance beeinflussen und sollten deshalb möglichst frühzeitig abgesteckt werden. Dadurch können wenig aussagekräftige Testläufe vermieden werden. Trotzdem war es uns auch wichtig, die bekannten Limits bewusst zu überschreiten, um die Reaktion der Anwendung zu testen und dort dann gegebenenfalls nachzubessern. Denn es ist ja nicht gesagt, dass der Kunde seine fachlichen Limits kennt oder diese durch technische Fehler nicht überschritten werden. Bei einem Termin zu viel sollte die Anwendung nicht unbedienbar werden. Ein Learning war für uns daher, fachliche Limits früh abzuklären und in den Tests zu beachten.&lt;/p&gt;

&lt;h3 id=&quot;vor--und-nachteile-von-k6&quot;&gt;Vor- und Nachteile von k6&lt;/h3&gt;

&lt;p&gt;Während des Testens mit k6 sind wir immer mal wieder auf Probleme gestoßen. Eine erhebliche Einschränkung beim Entwickeln von Performance-Tests mit k6 ist ein fehlender Debugger. k6 nutzt eine eigene &lt;a href=&quot;https://github.com/grafana/sobek&quot;&gt;JavaScript-Engine&lt;/a&gt;, um den Testcode auszuführen, für die es keinen Debugger gibt. Die Javascript-Engine hat auch weitere Schwächen, denen man sich bewusst sein sollte. Beispielsweise unterstützt sie die verbreitete Fetch API nicht. Im Zusammenhang mit Browsertests sind Schwächen von k6, dass Methoden wie &lt;em&gt;goto()&lt;/em&gt;, die darauf warten sollen, dass eine Seite geladen ist, im Zusammenspiel mit Chromium nicht immer zuverlässig funktionieren, was hin und wieder zu Timing-Problemen führt. Darüber hinaus müssen Locator über XPaths identifiziert werden, was sehr regressionsanfällig ist, sowie häufig unschön und lang. Zuletzt ist auch die Dokumentation von k6 häufig relativ knapp.&lt;/p&gt;

&lt;p&gt;Einige andere Dinge haben sich als Vorteile von k6 herausgestellt. Das Reporting im Zusammenspiel mit der InfluxDB und Grafana hat wie erhofft sehr gut funktioniert. Über dieses Setup lassen sich ohne große Vorkenntnisse schnell aussagekräftige Plots erstellen und in einem Dashboard anzeigen, sodass die Testergebnisse analysiert und kommuniziert werden können. Außerdem funktioniert das parallele Ausführen von verschiedenen Szenarien, die jeweils ebenfalls mit parallelen virtuellen Usern ausgeführt werden, sehr gut. Dadurch lassen sich komplexe Szenarien erstellen, die verschiedene Arten von Performance-Tests wie Load-Tests, Spike-Tests und Soak-Tests abbilden. Dass die Testoptionen und insbesondere die Szenarien als JSON beschrieben werden ist sehr angenehm, da es einen fließenden Übergang zum Typescript-Code bietet. Außerdem hat man die Möglichkeit, die Browsertests in einem Headful Mode laufen zu lassen, sodass sich Probleme während der Ausführung erkennen lassen und behoben werden können.&lt;/p&gt;

&lt;h3 id=&quot;zusammenfassung&quot;&gt;Zusammenfassung&lt;/h3&gt;

&lt;p&gt;Da wir während der Testphase unsere Tests und unser Setup stetig weiterentwickelt haben, hat sich für uns ein iterativer Ansatz ausgezahlt. Wir sind mit zwei einfachen Szenarien für Module gestartet, die zu den wichtigsten in der Anwendung gehören. Bei diesen ersten Szenarien haben wir festgestellt, dass wir weitere Metriken und Plots in unseren Reports benötigen, um die Ergebnisse analysieren zu können. Iterativ haben wir dann Metriken zu unseren Tests hinzugefügt und im Grafana-Board visualisiert. Dies waren Informationen wie die Dauer von Requests, die Ladezeiten von bestimmten Seiten oder auch die CPU- und RAM-Auslastung der ausführenden Maschine. Für uns war vor allem die Dauer einzelner Requests von Bedeutung, welche Informationen relevant sind, hängt jedoch von der Anwendung ab. Durch in k6 eingebaute Metrik-Typen lässt sich die Erhebung von Informationen flexibel gestalten.&lt;/p&gt;

&lt;p&gt;Die Arbeit mit k6 hat uns sowohl Stärken als auch Schwächen des Tools gezeigt. Ob k6 passend ist, hängt sicher vom Anwendungsfall ab, für uns war es aber trotz einiger signifikanter Schwächen ein passendes Tool.&lt;/p&gt;</content><author><name>simonBiwer</name></author><summary type="html">Wir teilen unsere Erfahrungen mit Performance-Testing im Projekt GA-Lotse – mit einem Setup aus k6, Grafana, InfluxDB und TypeScript.</summary></entry><entry xml:lang="en"><title type="html">Analyzing Business Reports with LLMs – Part 2</title><link href="https://blog.cronn.de/en/ai/largelanguagemodels/2025/06/24/analyse-von-geschaeftsberichten-mit-llms-2-en.html" rel="alternate" type="text/html" title="Analyzing Business Reports with LLMs – Part 2" /><published>2025-06-24T00:00:00-05:00</published><updated>2025-06-24T00:00:00-05:00</updated><id>https://blog.cronn.de/en/ai/largelanguagemodels/2025/06/24/analyse-von-geschaeftsberichten-mit-llms-2-en</id><content type="html" xml:base="https://blog.cronn.de/en/ai/largelanguagemodels/2025/06/24/analyse-von-geschaeftsberichten-mit-llms-2-en.html">&lt;p&gt;Welcome back to our series on analysing annual reports with AI. In &lt;a href=&quot;https://blog.cronn.de/en/ai/largelanguagemodels/2023/07/26/analyzing-business-reports-with-chatgpt-part1.html&quot;&gt;Part One&lt;/a&gt; we showed how the extraction of key figures from annual reports with LLMs (such as ChatGPT) works. Now we are going deeper and showing the final working solution, which we are using in cooperation with North Data.&lt;/p&gt;

&lt;p&gt;We have already demonstrated how relevant information can be filtered out of the dense text of annual reports in a structured way. But if you want to scale this process in practice, you quickly reach its limits – be it in terms of accuracy across many different documents, the robust processing of complex layouts and tables, or the cost-effectiveness of large-scale analysis.&lt;/p&gt;

&lt;p&gt;This is exactly where there have been many exciting developments. With &lt;strong&gt;Gemini Flash&lt;/strong&gt; from Google, a model is available which reshuffles the cards for automated document analysis in terms of speed, contextual understanding, and the delivery of structured data.&lt;sup id=&quot;fnref:1&quot;&gt;&lt;a href=&quot;#fn:1&quot; class=&quot;footnote&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; In this second part, we will ask: what makes Gemini Flash so more powerful for this specific task than previous approaches or the classic OCR pipelines? How does it make the step from feasibility study to productive tool? Let us look under the hood.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/Analyse-von-Geschaeftsberichten-2-northdata-grafik.webp&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;On the left side: Unstructured sample documents; in the centre an arrow pointing to the right, labelled ‘AI’; the arrow points to JSON code.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt; Gemini extracts structured JSON code from PDFs. &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h3 id=&quot;the-classic-approach-ocr-as-the-basis-but-not-the-whole-solution&quot;&gt;The classic approach: OCR as the basis, but not the whole solution&lt;/h3&gt;
&lt;p&gt;Before we dive into Gemini’s capabilities, it is worth looking at the traditional way of extracting data from PDFs. This most commonly starts with &lt;strong&gt;Optical Character Recognition (OCR)&lt;/strong&gt;. OCR tools generate text from scanned documents or image-only PDFs by converting pixels into letters. The result is not only the raw text content, but often also its position on the page, usually in the form of coordinates or so-called bounding boxes for each recognized word or line.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/Analyse-von-Geschaeftsberichten-2-table.webp&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;In an example table (‘Balance sheet’), terms and figures are marked with bounding boxes.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt; OCR Bounding Boxes from Azure Document Intelligence. &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;However, for a meaningful analysis we need &lt;em&gt;structured&lt;/em&gt; data, not continuous text. This is where the challenges begin.&lt;/p&gt;

&lt;p&gt;The first hurdle lays in the structure in the pure text output being recognized. How do you automatically identify tables, related key-value pairs (such as “revenue: €10 million”) or semantically meaningful blocks? This often requires complex, downstream steps – whether purpose-built parsers, rule-based systems that look for specific patterns, or even separate machine learning models trained on tasks such as table recognition.&lt;/p&gt;

&lt;p&gt;However, these downstream systems are often &lt;strong&gt;susceptible to layout changes&lt;/strong&gt;. Small adjustments in the design of a report from one year to the next or the format differing between companies can throw off painstakingly created rules or parsers and make them unusable.&lt;/p&gt;

&lt;p&gt;In addition, there is a lack of &lt;strong&gt;contextual understanding&lt;/strong&gt;. OCR provides the text but does not understand its meaning. Recognizing that the term “Total Assets” on page 10 refers to the same metric as a detailed breakdown in a table on page 45 is beyond the capabilities of pure text recognition.&lt;/p&gt;

&lt;p&gt;All these factors create complexity and thus lead to a &lt;strong&gt;high development and maintenance effort&lt;/strong&gt;. It can be said that OCR is a valuable tool, but for the &lt;strong&gt;extraction of &lt;em&gt;structured&lt;/em&gt; data&lt;/strong&gt; it is usually only the first step in a complex and often fragile processing chain.&lt;/p&gt;

&lt;h3 id=&quot;our-path-to-productive-use-evaluation-model-selection-and-integration&quot;&gt;Our path to productive use: evaluation, model selection and integration&lt;/h3&gt;

&lt;p&gt;The leap from successful demonstration (as shown in Part 1&lt;sup id=&quot;fnref:2&quot;&gt;&lt;a href=&quot;#fn:2&quot; class=&quot;footnote&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;) to a reliable, scalable production system required a systematic approach and further developments in several areas.&lt;/p&gt;

&lt;p&gt;Firstly, a &lt;strong&gt;solid evaluation&lt;/strong&gt; was essential. To this end we manually curated a dataset of 100 representative English annual reports. For the most important key figures, the correct values (ground truth) were annotated by hand and collected in a table. Only with such a reliable basis can the quality of different models and approaches be objectively measured and tracked over time.&lt;/p&gt;

&lt;p&gt;Secondly, we significantly expanded the scope of extraction. Instead of just a few key figures, the goal was now to reliably extract a wide range of over 20 relevant values per report. This includes, among other things, the wage costs, information on profit and loss, cash flow, but also data such as the average number of employees or the name of the auditor.&lt;/p&gt;

&lt;p&gt;These more demanding goals led us to test different models. In the end, the choice fell on &lt;strong&gt;Gemini 2.0 Flash Lite&lt;/strong&gt;: This model optimally combined all the decisive factors for our application.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/Analyse-von-Geschäftsberichten-2-graph.webp&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Graph, Y-axis: Artificial Analysis Intelligence Index, 0 to 75; X-axis: Price (USD per M tokens), 0 - 8 USD; the graph is divided into four quadrants, Gemini alone is in the upper left quadrant (score 70.49, 3.44 USD); all other models are significantly more expensive or perform worse in the Intelligence Score.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt; LLM comparison based on the parameters &quot;intelligence&quot; and &quot;price&quot;, via  &lt;a href=&quot;https://artificialanalysis.ai/models?models=llama-4-maverick%2Cllama-4-scout%2Cgemini-2-0-flash-lite-001%2Cgemini-2-5-pro%2Cclaude-3-5-haiku%2Cclaude-3-7-sonnet-thinking%2Cpixtral-large-2411%2Cgrok-3%2Cgpt-4o-chatgpt-03-25%2Cgemini-1-5-pro#intelligence-vs-price&quot; target=&quot;_blank&quot;&gt;artificialanalysis.ai&lt;/a&gt;. &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;&lt;strong&gt;Quality &amp;amp; Speed:&lt;/strong&gt; In our tests, Gemini 2.0 Flash Lite showed high accuracy for most of the targeted metrics, often keeping up with that of larger, more expensive models. Google itself positions the Flash models as optimized for tasks where it is important to maintain high speed and efficiency while maintaining high quality &lt;sup id=&quot;fnref:3&quot;&gt;&lt;a href=&quot;#fn:3&quot; class=&quot;footnote&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;. Our experience confirms that the model lives up to its “flash” in its name in terms of processing speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost:&lt;/strong&gt; A decisive factor for large-scale deployment is cost. Gemini 2.0 Flash Lite is significantly cheaper than the larger Pro models. Compared to older models like gpt-3.5-turbo-16k, which still cost about $3 per million input tokens in July 2023 &lt;sup id=&quot;fnref:4&quot;&gt;&lt;a href=&quot;#fn:4&quot; class=&quot;footnote&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;, the Gemini Flash variant we used is cheaper by a factor of 40 &lt;sup id=&quot;fnref:5&quot;&gt;&lt;a href=&quot;#fn:5&quot; class=&quot;footnote&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;! This makes the processing of thousands of reports economically viable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multimodality &amp;amp; Context:&lt;/strong&gt; A significant advantage over plain text models or classic OCR pipelines is Gemini’s multimodality. Put simply, instead of just delivering the raw text and its coordinates (like traditional OCR), Gemini Flash can “read” the text and “see” the page layout at the same time. It “understands” how text is arranged in columns or tables, recognizes headings, and can interpret images or charts in the document. As a result, it is better at capturing context which the pure text order often does not convey. This is a great advantage, especially with the complex and varied layouts of annual reports. Coupled with the long context window, which allows the analysis of large document sections in one go, this is a decisive step forward.&lt;/p&gt;

&lt;p&gt;This combination of good quality, high speed, low cost, and the ability to understand documents holistically made Gemini 2.0 Flash Lite a viable choice for our productive deployment in collaboration with North Data.&lt;/p&gt;

&lt;h3 id=&quot;gemini-flash-in-action-the-workflow-with-structured-outputs&quot;&gt;Gemini Flash in Action: The Workflow with Structured Outputs&lt;/h3&gt;

&lt;p&gt;The core of our approach combines the strengths of Gemini with pragmatic solutions to deal with the peculiarities of large documents.&lt;/p&gt;

&lt;p&gt;A central problem with annual reports is that they often comprise hundreds of pages. While handing over the entire document to Gemini would be ideal for context, it is too expensive for mass use. To get around this problem, we have developed a multi-step approach: First, we still rely on proven &lt;strong&gt;OCR technology&lt;/strong&gt; to extract the plain text of the entire document. This raw text then serves as the basis for a quick &lt;strong&gt;preliminary analysis&lt;/strong&gt; using keywords. We look for terms and phrases that typically indicate relevant sections, such as “Consolidated Balance Sheet”, “Income Statement” or “Notes to the Financial Statements”.&lt;/p&gt;

&lt;p&gt;Based on this analysis we then select the &lt;strong&gt;up to 100 pages&lt;/strong&gt; that are most likely to contain the financial ratios we are looking for. &lt;em&gt;Only this selection&lt;/em&gt; is then passed on to Gemini Flash Lite as a PDF context. This trick not only significantly reduces processing costs but also helps to focus the model on the important parts of the document and minimize the “noise” of irrelevant pages.&lt;/p&gt;

&lt;p&gt;After isolating the relevant pages, we commission Gemini to extract them into a predefined format. Another building block for precise results is the use of so-called &lt;strong&gt;structured outputs&lt;/strong&gt;. Gemini can not only generate text but also provides directly structured JSON data which follows a predetermined scheme.&lt;/p&gt;

&lt;p&gt;To do this, we define a clear target scheme in advance, which in turn defines exactly which data fields we expect and in which format (such as “number”, “text”, “currency symbol”). In Python, we like to use Pydantic for easy definition and validation. We explicitly give this structure to the model as an instruction. This is not only practical for automated further processing, but also demonstrably improves quality: In our tests, this step alone led to an &lt;strong&gt;improvement in the evaluation result of around 4%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here is a simplified Python example to illustrate the principle with the &lt;code class=&quot;highlighter-rouge&quot;&gt;google-genai&lt;/code&gt; library and structured outputs:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google.genai&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pydantic&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;BaseModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;


&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;api_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;GEMINI_API_KEY&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;c1&quot;&gt;# Define the desired output structure using Pydantic
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;FinancialData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BaseModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;revenue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Total revenue reported for the fiscal year.&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;net_income&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Net income or profit after tax.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;total_assets&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Total assets value.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;fiscal_year&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;The ending year of the fiscal period.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;currency_symbol&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Currency symbol used for major values (e.g., $, £, €).&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;c1&quot;&gt;# Upload the relevant PDF pages (assuming 'selected_report_pages.pdf' was created by pre-filtering)
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pdf_file&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;files&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;upload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;'selected_report_pages.pdf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
Please analyze the provided pages from the annual report PDF.
Extract the following financial figures for the main consolidated entity reported:
- Total Revenue
- Net Income (Profit after tax)
- Total Assets
- The Fiscal Year End
- The primary Currency Symbol used for the main financial figures (£, $, € etc.)

Return the data strictly adhering to the provided 'FinancialData' schema.
If a value cannot be found or determined confidently, leave the corresponding field null.
Pay close attention to units (e.g., thousands, millions).
&quot;&quot;&quot;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gemini-2.0-flash-lite-001&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pdf_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GenerateContentConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;response_mime_type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;response_schema&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FinancialData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;extracted_data&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;FinancialData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model_validate_json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;extracted_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;f&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;An error occurred: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;finally&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;files&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pdf_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;a-look-at-the-numbers-how-well-does-it-really-work&quot;&gt;A look at the numbers: How well does it really work?&lt;/h3&gt;

&lt;p&gt;To objectively assess the actual performance of our approach with Gemini Flash, we created a dataset of 100 manually annotated business reports. This serves as ground truth against which we check the extraction results of the model.&lt;/p&gt;

&lt;p&gt;The overall accuracy across all metrics and reports for our approach was &lt;strong&gt;83.5%&lt;/strong&gt;. These were the first feasibility values for the solution we integrated at North Data. This is a solid basis which demonstrates that the approach works. However, it gets more interesting when you look at the accuracy for individual metrics:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&lt;strong&gt;Key figure (parameters)&lt;/strong&gt;&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;Accuracy&lt;/strong&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Overall&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;83.5%&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;capital&lt;/td&gt;
      &lt;td&gt;96.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;cash&lt;/td&gt;
      &lt;td&gt;95.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;employees&lt;/td&gt;
      &lt;td&gt;95.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;revenue&lt;/td&gt;
      &lt;td&gt;95.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;equity&lt;/td&gt;
      &lt;td&gt;98.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;currencySymbol&lt;/td&gt;
      &lt;td&gt;99.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;auditorName&lt;/td&gt;
      &lt;td&gt;89.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;materials&lt;/td&gt;
      &lt;td&gt;89.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;…&lt;/td&gt;
      &lt;td&gt;…&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;liabilities (creditors)&lt;/td&gt;
      &lt;td&gt;75.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;currentAssets&lt;/td&gt;
      &lt;td&gt;64.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;realEstate&lt;/td&gt;
      &lt;td&gt;60.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;receivables&lt;/td&gt;
      &lt;td&gt;52.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;tax&lt;/td&gt;
      &lt;td&gt;41.0%&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;what-does-this-table-tell-us-and-what-are-the-current-hurdles&quot;&gt;What does this table tell us and what are the current hurdles?&lt;/h3&gt;

&lt;p&gt;The results paint a clear picture: The model achieves remarkably high accuracy values for &lt;strong&gt;clearly defined master data or values&lt;/strong&gt;, which are often prominently and relatively uniformly shown in annual reports. These include, for example, &lt;code class=&quot;highlighter-rouge&quot;&gt;capital&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;equity&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;employees&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;cash&lt;/code&gt; or the &lt;code class=&quot;highlighter-rouge&quot;&gt;currency symbol&lt;/code&gt;. Fortunately, &lt;strong&gt;hallucinations&lt;/strong&gt; – for example inventing numbers that do not exist in the document – were not a significant problem in our tests. If errors occurred, it was usually due to misinterpretations of existing figures and not to their free invention.&lt;/p&gt;

&lt;p&gt;It becomes more difficult for the model with more complex key figures. This is where the limitations of the current approach become apparent, especially when it comes to &lt;strong&gt;semantic fuzziness&lt;/strong&gt; and varying levels of detail. Many balance sheet items can be defined, named, or broken down differently in reports. Terms such as “total assets” are not always clear – does it mean the balance sheet total before or after deduction of certain items such as goodwill, for example the intangible value?&lt;/p&gt;

&lt;p&gt;The exact definition of &lt;code class=&quot;highlighter-rouge&quot;&gt;current assets&lt;/code&gt;, &lt;code class=&quot;highlighter-rouge&quot;&gt;receivables&lt;/code&gt; or liabilities varies between companies and reporting standards. This is where the model sometimes reaches its limits in deducing the exact definition valid in the respective report from the immediate context alone.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;dependence on layouts&lt;/strong&gt; and the placement of information also plays a role. Some assets, such as &lt;code class=&quot;highlighter-rouge&quot;&gt;realEstate&lt;/code&gt; (real estate assets), are often not prominently found on the main pages of the balance sheet but are hidden in detail in the “Notes to the Financial Statements” (Appendix). The model’s ability to correctly map such information across different pages and layouts is heavily challenged and results in lower accuracy scores.&lt;/p&gt;

&lt;p&gt;Finally, some metrics require &lt;strong&gt;more complex interpretations or implicit calculations&lt;/strong&gt;. The extraction of values such as &lt;code class=&quot;highlighter-rouge&quot;&gt;tax &lt;/code&gt; is a good example of this. Different types of taxes (income taxes, sales taxes, etc.) and deferred taxes can often be spread over several sections. The correct aggregation and interpretation of this information is challenging, which explains the current accuracy of only 41% for this metric.&lt;/p&gt;

&lt;p&gt;These quantitative results confirm our qualitative observations: the model is excellent at finding clearly labelled information. However, it reaches its limits when dealing with issues such as ambiguities in wording, widely varying or complex layouts, and the need to understand implicit knowledge or contexts across multiple text passages.&lt;/p&gt;

&lt;p&gt;Another important aspect is the &lt;strong&gt;varying accuracy between different companies&lt;/strong&gt;. The standard deviation of accuracy per company is about 9.2%. It is particularly striking that the accuracy of the large, individually designed reports from listed companies (PLCs) such as AstraZeneca (50%), Barclays (65%), HSBC (50%), Shell (70%) or Unilever (55%) tends to be significantly lower than average. Tests with excerpts of different lengths showed that the length of the context to be mastered is not a major difficulty for Gemini, we therefore assume that the uniqueness of the reporting structures of these groups is particularly challenging for the model. While Gemini Flash Lite handles layouts that are often created by smaller companies using off-the-shelf software, these complex cases are a bigger hurdle. One explanation could be that the reports that deviate from the standard rarely made it into Gemini’s training data.&lt;/p&gt;

&lt;p&gt;Another recurring problem is the correct capture of &lt;strong&gt;units and scales&lt;/strong&gt;. Missing or misinterpreting information such as “in thousands of £” or “millions of USD” will result in extracted values that are wrong by factors of 1,000 or 1,000,000. Here, robust downstream validation rules and targeted prompting are necessary to sensitize the model to these details.&lt;/p&gt;

&lt;p&gt;The representation of &lt;strong&gt;negative numbers&lt;/strong&gt;, which is often done by parentheses in annual reports (e.g. “(1.234)” instead of “-1.234”), also requires an explicit note in the prompt so that the model interprets this convention correctly and extracts the numbers with the correct sign. As already mentioned, hallucinations do not pose any major problems here (as it was with older models), it is the interpretation of the numbers that does not always succeed.&lt;/p&gt;

&lt;p&gt;Finally, we are also faced with the classic trade-off between costs and performance in particularly complex cases. More sophisticated reasoning approaches such as Chain-of-Thought (CoT), in which the model makes its “thought steps” explicit, or the use of even larger and more powerful models (for example Gemini 2.5 Pro) could remedy the problems mentioned, especially when analysing the more complex reports.&lt;/p&gt;

&lt;p&gt;However, these are currently often much more expensive. For example, &lt;strong&gt;Gemini 2.5 Pro is currently 16 to 32 times more expensive than the Gemini 2.0 Flash Lite we used&lt;/strong&gt;. The common GPT-4.1, which is used in ChatGPT, also costs $2 per 1 million input tokens – about 27 times as much as Gemini 2.0 Flash Lite. Using our solution to process an average report from our 30-page test dataset costs only about $0.0007!&lt;/p&gt;

&lt;h3 id=&quot;conclusion-gemini-flash-as-a-powerful-addition-to-the-toolbox&quot;&gt;Conclusion: Gemini Flash as a powerful addition to the toolbox&lt;/h3&gt;

&lt;p&gt;Gemini Flash has proven to be a useful building block for us to take the extraction of structured data from annual reports to a new level and bring it into productive use at North Data. It does not necessarily replace the entire classic pipeline (as our OCR pre-filtering shows), but it does provide a powerful, integrated alternative to the core process of intelligent data extraction and structuring.&lt;/p&gt;

&lt;p&gt;The ability to understand layouts, work within a larger context, and deliver structured outputs significantly reduces complexity and maintenance compared to traditional, multi-tiered approaches. The challenges remain, but the progress is clear and opens new opportunities for automated financial data analysis.&lt;/p&gt;

&lt;p&gt;We are excited to see how this technology will develop further and what new solutions will emerge. Have you had similar experiences or developed different strategies? Share your thoughts with us!&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This blog post was written with the support of Gemini 2.5 Pro.&lt;/em&gt;&lt;/p&gt;

&lt;div class=&quot;footnotes&quot;&gt;
  &lt;ol&gt;
    &lt;li id=&quot;fn:1&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://getomni.ai/ocr-benchmark&quot; target=&quot;_blank&quot;&gt;OmniAI OCR Benchmark&lt;/a&gt;, retrieved 17/06/25 &lt;a href=&quot;#fnref:1&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:2&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://blog.cronn.de/en/ai/largelanguagemodels/2023/07/26/analyzing-business-reports-with-chatgpt-part1.html&quot; target=&quot;_blank&quot;&gt;cronn Blog: Analyzing Business Reports with ChatGPT – Part I&lt;/a&gt; &lt;a href=&quot;#fnref:2&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:3&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-0-flash-lite?hl=de&quot; target=&quot;_blank&quot;&gt;Documentation Google Gemini 2.0 Flash-Lite&lt;/a&gt;, retrieved 17/06/25 &lt;a href=&quot;#fnref:3&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:4&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://web.archive.org/web/20230614104237/https://openai.com/pricing&quot; target=&quot;_blank&quot;&gt;Web Archive: OpenAI-Preise vom 14. Juni 2023&lt;/a&gt;, retrieved 17/06/25 &lt;a href=&quot;#fnref:4&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:5&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://ai.google.dev/gemini-api/docs/pricing?hl=de&quot; target=&quot;_blank&quot;&gt;Prices for Gemini Developer API&lt;/a&gt;, retrieved 17/06/25 &lt;a href=&quot;#fnref:5&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;
&lt;/div&gt;</content><author><name>leonardThiele</name></author><summary type="html">An AI use case in action: the extraction of key figures from annual reports using LLM.</summary></entry><entry xml:lang="de"><title type="html">Analyse von Geschäftsberichten mit LLMs – Teil 2</title><link href="https://blog.cronn.de/de/ai/largelanguagemodels/2025/06/24/analyse-von-geschaeftsberichten-mit-llms-2.html" rel="alternate" type="text/html" title="Analyse von Geschäftsberichten mit LLMs – Teil 2" /><published>2025-06-24T00:00:00-05:00</published><updated>2025-06-24T00:00:00-05:00</updated><id>https://blog.cronn.de/de/ai/largelanguagemodels/2025/06/24/analyse-von-geschaeftsberichten-mit-llms-2</id><content type="html" xml:base="https://blog.cronn.de/de/ai/largelanguagemodels/2025/06/24/analyse-von-geschaeftsberichten-mit-llms-2.html">&lt;p&gt;Willkommen zurück zu unserer Serie über die Analyse von Geschäftsberichten mit KI! Im &lt;a href=&quot;https://blog.cronn.de/de/ai/largelanguagemodels/2023/07/26/analyse-von-geschaeftsberichten-mit-chatgpt-1.html&quot; target=&quot;_blank&quot;&gt;ersten Teil&lt;/a&gt; haben wir anhand eines Beispiels gezeigt, wie die Extraktion von Kennzahlen aus Geschäftsberichten mit LLMs wie ChatGPT grundsätzlich funktioniert. Jetzt gehen wir weiter in die Tiefe und zeigen dafür eine Lösung, die wir in Zusammenarbeit mit North Data produktiv einsetzen.&lt;/p&gt;

&lt;p&gt;Wir konnten damals demonstrieren, wie sich relevante Informationen aus den dichten Textwüsten von Geschäftsberichten strukturiert herausfiltern lassen. Doch wer das in der Praxis skalieren will, stößt schnell an Grenzen – sei es bei der Genauigkeit über viele verschiedene Dokumente hinweg, der robusten Verarbeitung komplexer Layouts und Tabellen oder der Wirtschaftlichkeit, die für eine großflächige Analyse nötig ist.&lt;/p&gt;

&lt;p&gt;Genau hier hat sich in der Zwischenzeit aber einiges getan. Mit &lt;strong&gt;Gemini Flash&lt;/strong&gt; von Google steht ein Modell bereit, das die Karten für die automatisierte Dokumentenanalyse in Sachen Geschwindigkeit, Kontextverständnis und dem Ausliefern strukturierter Daten neu mischt.&lt;sup id=&quot;fnref:1&quot;&gt;&lt;a href=&quot;#fn:1&quot; class=&quot;footnote&quot;&gt;1&lt;/a&gt;&lt;/sup&gt; In diesem zweiten Teil wollen wir daher tief eintauchen: Was macht Gemini Flash so viel leistungsfähiger für diese spezifische Aufgabe als frühere Ansätze oder die klassischen OCR-Pipelines? Wie ermöglicht es den Schritt von der Machbarkeitsstudie zum produktiven Werkzeug? Werfen wir einen Blick unter die Haube.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/Analyse-von-Geschaeftsberichten-2-northdata-grafik.webp&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Auf der Linken Seite: Unstrukturierte Beispieldokumente; Mitte ein Pfeil der nach rechts Zeigt, Aufschrift „AI“; der Pfeil Zeigt auf JSON-Code.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt; Gemini extrahiert strukturierten JSON-Code aus PDFs. &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;h3 id=&quot;der-klassische-ansatz-ocr-als-basis-aber-nicht-die-ganze-lösung&quot;&gt;Der klassische Ansatz: OCR als Basis, aber nicht die ganze Lösung&lt;/h3&gt;

&lt;p&gt;Bevor wir uns den Fähigkeiten von Gemini widmen, lohnt sich ein kurzer Blick auf den traditionellen Weg zur Datenextraktion aus PDFs. Dieser beginnt fast immer mit &lt;strong&gt;Optical Character Recognition (OCR)&lt;/strong&gt;. OCR-Tools helfen uns, wenn es darum geht, Text aus gescannten Dokumenten oder reinen Bild-PDFs lesbar zu machen. Sie wandeln Pixel in Buchstaben um. Das Ergebnis ist nicht nur der „rohe“ Textinhalt, sondern oft auch dessen Position auf der Seite, meist in Form von Koordinaten oder sogenannten Bounding Boxes für jedes erkannte Wort oder jede Zeile.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/Analyse-von-Geschaeftsberichten-2-table.webp&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;In einer Beispieltabelle („Balance sheet“) sind Begriffe und Zahlen durch Bounding Boxes markiert.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt; Bounding Boxes bei OCR durch Azure Document Intelligence. &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;Nach diesem rohen Text samt Koordinaten fängt die eigentliche Arbeit oft erst an, denn für eine sinnvolle Analyse brauchen wir &lt;em&gt;strukturierte&lt;/em&gt; Daten, keinen Fließtext. Hier beginnen die Herausforderungen:&lt;/p&gt;

&lt;p&gt;Zuerst muss die Struktur im reinen Text-Output erkannt werden. Wie identifiziert man automatisch Tabellen, zusammengehörige Key-Value-Paare (wie „Umsatz: 10 Mio. €“) oder semantisch sinnvolle Blöcke? Dafür sind häufig komplexe, nachgelagerte Schritte notwendig – seien es speziell entwickelte Parser, regelbasierte Systeme, die auf bestimmte Muster achten, oder sogar separate Machine-Learning-Modelle, die auf Aufgaben wie Tabellenerkennung trainiert wurden.&lt;/p&gt;

&lt;p&gt;Diese nachgelagerten Systeme sind allerdings oft &lt;strong&gt;anfällig für Layout-Änderungen&lt;/strong&gt;. Kleine Anpassungen im Design eines Berichts von einem Jahr zum nächsten, oder die unterschiedlichen Formate verschiedener Unternehmen, können mühsam erstellte Regeln oder Parser aus dem Tritt bringen und unbrauchbar machen.&lt;/p&gt;

&lt;p&gt;Hinzu kommt das fehlende &lt;strong&gt;Kontextverständnis&lt;/strong&gt;. OCR liefert zwar den Text, versteht aber dessen Bedeutung nicht. Zu erkennen, dass sich der Begriff „Total Assets“ auf Seite 10 auf dieselbe Kennzahl bezieht wie eine detaillierte Aufschlüsselung in einer Tabelle auf Seite 45, übersteigt die Fähigkeiten reiner Texterkennung.&lt;/p&gt;

&lt;p&gt;All diese Faktoren führen zu Komplexität und somit zu einem hohen &lt;strong&gt;Entwicklungs- und Wartungsaufwand&lt;/strong&gt;. Es lässt sich feststellen: OCR ist ein wichtiges Werkzeug im Kasten. Aber für das Ziel der &lt;strong&gt;End-to-End-Extraktion &lt;em&gt;strukturierter&lt;/em&gt; Daten&lt;/strong&gt; ist es meist nur der erste Schritt in einer komplexen und oft fragilen Verarbeitungskette.&lt;/p&gt;

&lt;h3 id=&quot;unser-weg-zum-produktiveinsatz-evaluation-modellwahl-und-integration&quot;&gt;Unser Weg zum Produktiveinsatz: Evaluation, Modellwahl und Integration&lt;/h3&gt;

&lt;p&gt;Der Sprung von einer erfolgreichen Demonstration (wie in Teil 1 gezeigt&lt;sup id=&quot;fnref:2&quot;&gt;&lt;a href=&quot;#fn:2&quot; class=&quot;footnote&quot;&gt;2&lt;/a&gt;&lt;/sup&gt;) zu einem zuverlässigen, skalierbaren Produktivsystem erforderte einen systematischen Ansatz und Weiterentwicklungen in mehreren Bereichen.&lt;/p&gt;

&lt;p&gt;Zunächst war eine &lt;strong&gt;solide Evaluation&lt;/strong&gt; unerlässlich. Wir haben also manuell einen Datensatz aus 100 repräsentativen englischen Geschäftsberichten kuratiert. Für die wichtigsten Kennzahlen wurden die korrekten Werte (Ground Truth) von Hand annotiert und in einer Tabelle gesammelt. Nur mit einer solchen verlässlichen Basis lässt sich die Qualität verschiedener Modelle und Ansätze objektiv messen und über die Zeit verfolgen.&lt;/p&gt;

&lt;p&gt;Parallel dazu erweiterten wir den Umfang der Extraktion im Vergleich zur alten Lösung deutlich. Statt nur einiger weniger Kennzahlen war das Ziel nun, eine breite Palette von über 20 relevanten Werten pro Bericht zuverlässig zu extrahieren. Dazu gehören unter anderem die vom Unternehmen ausgewiesenen Lohnkosten, Angaben zu Gewinn und Verlust, Barmittel, aber auch Daten wie die durchschnittliche Mitarbeiterzahl oder der Name des Wirtschaftsprüfers.&lt;/p&gt;

&lt;p&gt;Diese anspruchsvolleren Ziele führten uns zu Tests verschiedener Modelle. Die Wahl fiel schließlich auf &lt;strong&gt;Gemini 2.0 Flash Lite:&lt;/strong&gt; Dieses Modell vereinte für unseren Anwendungsfall alle entscheidenden Faktoren optimal.&lt;/p&gt;

&lt;figure&gt;
&lt;img data-src=&quot;/img/posts/Analyse-von-Geschäftsberichten-2-graph.webp&quot; class=&quot;lazyload img-fluid img-feature&quot; alt=&quot;Graf, Y-Achse: Artificial Analysis Intelligence Index, 0 bis 75; X-Achse: Price (USD per M Tokens), 0 - 8 USD; der Graf ist in vier Quadranten unterteilt, Gemini liegt alleine im oberen linken Quadranten (Score 70.49, 3.44 USD); alle anderen Modelle sind deutlich teurer oder schneiden im Intelligence Score schlechter ab.&quot; /&gt;
&lt;figcaption class=&quot;long-fig-caption&quot;&gt; LLM-Vergleich anhand der Parameter „Intelligenz“ und „Preis“, via &lt;a href=&quot;https://artificialanalysis.ai/models?models=llama-4-maverick%2Cllama-4-scout%2Cgemini-2-0-flash-lite-001%2Cgemini-2-5-pro%2Cclaude-3-5-haiku%2Cclaude-3-7-sonnet-thinking%2Cpixtral-large-2411%2Cgrok-3%2Cgpt-4o-chatgpt-03-25%2Cgemini-1-5-pro#intelligence-vs-price&quot; target=&quot;_blank&quot;&gt;artificialanalysis.ai&lt;/a&gt;. &lt;/figcaption&gt;
&lt;/figure&gt;

&lt;p&gt;&lt;strong&gt;Qualität &amp;amp; Geschwindigkeit:&lt;/strong&gt; In unseren Tests zeigte Gemini 2.0 Flash Lite eine überraschend hohe Genauigkeit für die meisten der anvisierten Kennzahlen, die oft mit der von größeren, teureren Modellen mithalten konnte. Google selbst positioniert die Flash-Modelle als optimiert für Aufgaben, bei denen es auf hohe Geschwindigkeit und Effizienz bei gleichzeitig guter Qualität ankommt&lt;sup id=&quot;fnref:3&quot;&gt;&lt;a href=&quot;#fn:3&quot; class=&quot;footnote&quot;&gt;3&lt;/a&gt;&lt;/sup&gt;. Unsere Erfahrungen bestätigen, dass das Modell seinem „Flash“ im Namen in puncto Verarbeitungsgeschwindigkeit gerecht wird.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kosten:&lt;/strong&gt; Ein entscheidender Faktor für den Einsatz im großen Maßstab sind die Kosten. Gemini 2.0 Flash Lite ist deutlich günstiger als die größeren Pro-Modelle. Im Vergleich zu älteren Modellen wie gpt-3.5-turbo-16k aus dem ersten Teil, das im Juli 2023 noch etwa 3 US-Dollar pro Million Input-Token kostete&lt;sup id=&quot;fnref:4&quot;&gt;&lt;a href=&quot;#fn:4&quot; class=&quot;footnote&quot;&gt;4&lt;/a&gt;&lt;/sup&gt;, ist die von uns genutzte Gemini-Flash-Variante um den Faktor 40 günstiger&lt;sup id=&quot;fnref:5&quot;&gt;&lt;a href=&quot;#fn:5&quot; class=&quot;footnote&quot;&gt;5&lt;/a&gt;&lt;/sup&gt;! Das macht die Verarbeitung tausender Berichte wirtschaftlich tragbar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multimodalität &amp;amp; Kontext:&lt;/strong&gt; Ein wesentlicher Vorteil gegenüber reinen Textmodellen oder klassischen OCR-Pipelines ist die Multimodalität von Gemini. Vereinfacht gesagt bedeutet das: Statt nur den rohen Text und dessen Koordinaten zu liefern (wie traditionelle OCR), kann Gemini Flash gleichzeitig den Text „lesen“ und das Seitenlayout „sehen“. Es „versteht“, wie Text in Spalten oder Tabellen angeordnet ist, erkennt Überschriften und kann Bilder oder Diagramme im Dokument interpretieren. Dadurch erfasst es den Kontext, den die reine Textreihenfolge oft nicht vermittelt, wesentlich besser. Dies ist gerade bei den komplexen und variantenreichen Layouts von Geschäftsberichten ein großer Vorteil. Gepaart mit dem langen Kontextfenster, das die Analyse umfangreicher Dokumentabschnitte am Stück erlaubt, ist dies ein entscheidender Fortschritt.&lt;/p&gt;

&lt;p&gt;Diese Kombination aus guter Qualität, hoher Geschwindigkeit, niedrigen Kosten und der Fähigkeit, Dokumente ganzheitlich zu verstehen, machte Gemini 2.0 Flash Lite zur guten Wahl für unseren produktiven Einsatz in Zusammenarbeit mit North Data.&lt;/p&gt;

&lt;h3 id=&quot;gemini-flash-in-aktion-der-workflow-mit-structured-outputs&quot;&gt;Gemini Flash in Aktion: Der Workflow mit Structured Outputs&lt;/h3&gt;

&lt;p&gt;Der Kern unseres Ansatzes kombiniert die Stärken von Gemini mit pragmatischen Lösungen, um auch mit den Eigenheiten sehr umfangreicher Dokumente umzugehen.&lt;/p&gt;

&lt;p&gt;Ein zentrales Problem stellen &lt;strong&gt;lange Geschäftsberichte&lt;/strong&gt; dar, die oft hunderte von Seiten umfassen. Das gesamte Dokument an Gemini zu übergeben, wäre zwar ideal für den Kontext, ist aber zu teuer für den Masseneinsatz. Um dieses Problem zu umgehen, haben wir einen mehrstufigen Ansatz entwickelt: Zuerst setzen wir nach wie vor auf bewährte &lt;strong&gt;OCR-Technologie&lt;/strong&gt;, um den reinen Text des gesamten Dokuments zu extrahieren. Dieser Rohtext dient uns dann als Basis für eine schnelle &lt;strong&gt;Voranalyse mittels Schlüsselwörtern&lt;/strong&gt;. Wir suchen nach Begriffen und Phrasen, die typischerweise auf relevante Abschnitte hindeuten, wie zum Beispiel „Consolidated Balance Sheet“, „Income Statement“ oder „Notes to the Financial Statements“.&lt;/p&gt;

&lt;p&gt;Basierend auf dieser Analyse wählen wir die &lt;strong&gt;bis zu 100 Seiten&lt;/strong&gt; aus, die am wahrscheinlichsten die gesuchten Finanzkennzahlen enthalten. &lt;em&gt;Nur dieser Auszug&lt;/em&gt; des Berichts wird dann als PDF-Kontext an Gemini Flash Lite übergeben. Dieser Kniff reduziert nicht nur die Verarbeitungskosten erheblich, sondern hilft auch, das Modell auf die wirklich wichtigen Teile des Dokuments zu konzentrieren und das „Rauschen“ irrelevanter Seiten zu minimieren.&lt;/p&gt;

&lt;p&gt;Nachdem wir die relevanten Seiten isoliert haben, beauftragen wir Gemini mit der gezielten Extraktion in ein vordefiniertes Format. Ein weiterer Baustein für präzise Ergebnisse ist hierbei die Nutzung von sogenannten &lt;strong&gt;Structured Outputs&lt;/strong&gt;. Gemini besitzt die Fähigkeit, nicht nur Text zu generieren, sondern direkt strukturierte JSON-Daten zu liefern, die einem vorgegebenen Schema folgen.&lt;/p&gt;

&lt;p&gt;Wir definieren dazu im Vorfeld ein klares Zielschema, das genau festlegt, welche Datenfelder wir erwarten und in welchem Format (wie etwa „Zahl“, „Text“, „Währungssymbol“). In Python nutzen wir dafür gerne Pydantic zur einfachen Definition und Validierung. Diese Struktur geben wir dem Modell explizit als Anweisung mit. Das ist nicht nur praktisch für die automatisierte Weiterverarbeitung, sondern verbessert auch nachweislich die Qualität: In unseren Tests führte allein dieser Schritt zu einer &lt;strong&gt;Verbesserung des Evaluations-Ergebnisses um rund 4 %&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Hier ein vereinfachtes Python-Beispiel zur Illustration des Prinzips mit der &lt;code class=&quot;highlighter-rouge&quot;&gt;google-genai&lt;/code&gt; -Bibliothek und Structured Outputs:&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;google.genai&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pydantic&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;BaseModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;


&lt;span class=&quot;n&quot;&gt;client&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;genai&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;api_key&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;GEMINI_API_KEY&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;c1&quot;&gt;# Define the desired output structure using Pydantic
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;FinancialData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;BaseModel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;revenue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Total revenue reported for the fiscal year.&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;net_income&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Net income or profit after tax.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;total_assets&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Total assets value.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;fiscal_year&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;The ending year of the fiscal period.&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;currency_symbol&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Field&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;description&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;Currency symbol used for major values (e.g., $, £, €).&quot;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;c1&quot;&gt;# Upload the relevant PDF pages (assuming 'selected_report_pages.pdf' was created by pre-filtering)
&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pdf_file&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;files&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;upload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;file&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;'selected_report_pages.pdf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;
Please analyze the provided pages from the annual report PDF.
Extract the following financial figures for the main consolidated entity reported:
- Total Revenue
- Net Income (Profit after tax)
- Total Assets
- The Fiscal Year End
- The primary Currency Symbol used for the main financial figures (£, $, € etc.)

Return the data strictly adhering to the provided 'FinancialData' schema.
If a value cannot be found or determined confidently, leave the corresponding field null.
Pay close attention to units (e.g., thousands, millions).
&quot;&quot;&quot;&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;response&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;models&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;generate_content&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;model&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;gemini-2.0-flash-lite-001&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;contents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;prompt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;pdf_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;config&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;types&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;GenerateContentConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;response_mime_type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;application/json&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;response_schema&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FinancialData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;extracted_data&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;FinancialData&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;model_validate_json&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;response&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;extracted_data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;Exception&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;f&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;An error occurred: &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;finally&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;client&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;files&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pdf_file&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h3 id=&quot;ein-blick-auf-die-zahlen-wie-gut-funktioniert-es-wirklich&quot;&gt;Ein Blick auf die Zahlen: Wie gut funktioniert es wirklich?&lt;/h3&gt;

&lt;p&gt;Um die tatsächliche Leistung unseres Ansatzes mit Gemini Flash objektiv zu bewerten, haben wir, wie erwähnt, einen Datensatz aus 100 manuell annotierten Geschäftsberichten erstellt. Dieser dient als Ground Truth, gegen den wir die Extraktionsergebnisse des Modells prüfen.&lt;/p&gt;

&lt;p&gt;Die Gesamtgenauigkeit über alle Kennzahlen und Berichte hinweg für unseren Ansatz lag bei &lt;strong&gt;83,5 %&lt;/strong&gt;. Dies waren die ersten Machbarkeitswerte für die Lösung, die wir bei North Data integriert haben. Das ist eine solide Basis und zeigt, dass der Ansatz grundsätzlich funktioniert. Interessanter wird es jedoch, wenn man sich die Genauigkeit für einzelne Kennzahlen ansieht:&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;&lt;strong&gt;Kennzahlen (Parameter)&lt;/strong&gt;&lt;/th&gt;
      &lt;th&gt;&lt;strong&gt;Genauigkeit&lt;/strong&gt;&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;strong&gt;Gesamt (Overall)&lt;/strong&gt;&lt;/td&gt;
      &lt;td&gt;&lt;strong&gt;83.5%&lt;/strong&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;capital&lt;/td&gt;
      &lt;td&gt;96.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;cash&lt;/td&gt;
      &lt;td&gt;95.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;employees&lt;/td&gt;
      &lt;td&gt;95.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;revenue&lt;/td&gt;
      &lt;td&gt;95.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;equity&lt;/td&gt;
      &lt;td&gt;98.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;currencySymbol&lt;/td&gt;
      &lt;td&gt;99.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;auditorName&lt;/td&gt;
      &lt;td&gt;89.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;materials&lt;/td&gt;
      &lt;td&gt;89.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;…&lt;/td&gt;
      &lt;td&gt;…&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;liabilities (creditors)&lt;/td&gt;
      &lt;td&gt;75.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;currentAssets&lt;/td&gt;
      &lt;td&gt;64.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;realEstate&lt;/td&gt;
      &lt;td&gt;60.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;receivables&lt;/td&gt;
      &lt;td&gt;52.0%&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;tax&lt;/td&gt;
      &lt;td&gt;41.0%&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;was-verrät-uns-diese-tabelle-und-wo-liegen-die-aktuellen-hürden&quot;&gt;Was verrät uns diese Tabelle und wo liegen die aktuellen Hürden?&lt;/h3&gt;

&lt;p&gt;Die Evaluationsergebnisse zeichnen ein klares Bild: Bei &lt;strong&gt;klar definierten Stammdaten oder Werten&lt;/strong&gt;, die in Geschäftsberichten oft prominent und relativ einheitlich ausgewiesen werden, erzielt das Modell sehr hohe Genauigkeitswerte. Dazu zählen beispielsweise &lt;code class=&quot;highlighter-rouge&quot;&gt;capital&lt;/code&gt; (Eigenkapital), &lt;code class=&quot;highlighter-rouge&quot;&gt;equity&lt;/code&gt; (Reinvermögen), die &lt;code class=&quot;highlighter-rouge&quot;&gt;employees&lt;/code&gt; (Anzahl der Mitarbeiter), &lt;code class=&quot;highlighter-rouge&quot;&gt;cash&lt;/code&gt; (Barmittel) oder das &lt;code class=&quot;highlighter-rouge&quot;&gt;currencySymbol&lt;/code&gt; (Währungssymbol). Erfreulicherweise sind &lt;strong&gt;Halluzinationen&lt;/strong&gt; – also das Erfinden von Zahlen, die im Dokument nicht existieren – in unseren Tests kein signifikantes Problem gewesen. Wenn Fehler auftraten, dann meist durch Fehlinterpretationen vorhandener Zahlen, nicht durch deren freie Erfindung.&lt;/p&gt;

&lt;p&gt;Schwieriger wird es für das Modell bei komplexeren Kennzahlen. Hier zeigen sich die Grenzen des aktuellen Ansatzes, insbesondere wenn es um &lt;strong&gt;semantische Unschärfe&lt;/strong&gt; und variierende Detailgrade geht. Viele Bilanzposten können in Berichten unterschiedlich definiert, benannt oder aufgeschlüsselt sein. Begriffe wie „Total Assets“ sind nicht immer absolut eindeutig – meint es die Bilanzsumme vor oder nach Abzug bestimmter Posten wie Goodwill, also den immateriellen Firmenwert?&lt;/p&gt;

&lt;p&gt;Die genaue Abgrenzung von &lt;code class=&quot;highlighter-rouge&quot;&gt;currentAssets&lt;/code&gt; (kurzfristige Vermögenswerte), &lt;code class=&quot;highlighter-rouge&quot;&gt;receivables&lt;/code&gt; (Forderungen) oder &lt;code class=&quot;highlighter-rouge&quot;&gt;liabilities&lt;/code&gt; (Verbindlichkeiten) variiert zwischen Unternehmen und Berichtsstandards. Hier stößt das Modell manchmal an seine Grenzen, die exakte, im jeweiligen Bericht gültige Definition allein aus dem unmittelbaren Kontext zu erschließen.&lt;/p&gt;

&lt;p&gt;Ebenso spielt die &lt;strong&gt;Abhängigkeit von Layouts&lt;/strong&gt; und der Platzierung von Informationen eine Rolle. Einige Werte, wie beispielsweise &lt;code class=&quot;highlighter-rouge&quot;&gt;realEstate&lt;/code&gt; (Immobilienvermögen), sind oft nicht prominent auf den Hauptseiten der Bilanz zu finden, sondern detailliert in den „Notes to the Financial Statements“ (Anhang) versteckt. Die Fähigkeit des Modells, solche Informationen über verschiedene Seiten und Layouts hinweg korrekt zuzuordnen, ist stark gefordert und führt zu niedrigeren Genauigkeitswerten.&lt;/p&gt;

&lt;p&gt;Schließlich erfordern manche Kennzahlen &lt;strong&gt;komplexere Interpretationen oder implizite Berechnungen&lt;/strong&gt;. Die Extraktion von Werten wie &lt;code class=&quot;highlighter-rouge&quot;&gt;tax&lt;/code&gt; (Steuern) ist hierfür ein gutes Beispiel. Oft spielen verschiedene Steuerarten (Ertragssteuern, Umsatzsteuern etc.) und latente Steuern eine Rolle, die über mehrere Abschnitte verteilt sein können. Die korrekte Zusammenführung und Interpretation dieser Informationen sind anspruchsvoll, was die aktuelle Genauigkeit von nur 41 % für diese Kennzahl erklärt.&lt;/p&gt;

&lt;p&gt;Diese quantitativen Ergebnisse bestätigen unsere qualitativen Beobachtungen: Das Modell ist hervorragend darin, klar benannte Informationen zu finden. Bei Mehrdeutigkeiten, stark variierenden oder komplexen Layouts und der Notwendigkeit, implizites Wissen oder Zusammenhänge über mehrere Textstellen hinweg zu verstehen, stößt es jedoch an Grenzen.&lt;/p&gt;

&lt;p&gt;Ein weiterer wichtiger Aspekt ist die &lt;strong&gt;variierende Genauigkeit zwischen verschiedenen Unternehmen&lt;/strong&gt;. Die Standardabweichung der Genauigkeit pro Unternehmen liegt bei etwa 9,2 %. Besonders auffällig ist, dass die Genauigkeit bei den sehr großen, oft hunderte Seiten umfassenden und individuell gestalteten Berichten von börsennotierten Unternehmen (PLCs) wie AstraZeneca (50 %), Barclays (65 %), HSBC (50 %), Shell (70 %) oder Unilever (55 %) teilweise deutlich abfällt.&lt;/p&gt;

&lt;p&gt;Tests mit unterschiedlich langen Ausschnitten aus den Berichten zeigten, dass die Länge des zu bewältigenden Kontextes für Gemini keine größere Schwierigkeit darstellt. Wir gehen daher davon aus, dass vor allem die Einzigartigkeit der Berichtsstrukturen dieser Konzerne für das Modell herausfordernd sind. Während Gemini Flash Lite gut mit Layouts zurechtkommt, die oft von kleineren Unternehmen mit Standardsoftware erstellt werden, sind diese komplexen Fälle eine größere Hürde. Eine Erklärung könnte sein, dass es die vom Standard abweichenden Berichte seltener in Geminis Trainingsdaten geschafft haben.&lt;/p&gt;

&lt;p&gt;Ein weiteres wiederkehrendes Problem ist die korrekte Erfassung von &lt;strong&gt;Einheiten und Skalierungen&lt;/strong&gt;. Das Übersehen oder die Fehlinterpretation von Angaben wie „in Tausend £“ oder „Millions USD“ führt zu extrahierten Werten, die um Faktoren von 1.000 oder 1.000.000 falsch sind. Hier sind robuste nachgelagerte Validierungsregeln und gezieltes Prompting notwendig, um das Modell für diese Details zu sensibilisieren.&lt;/p&gt;

&lt;p&gt;Auch die Darstellung &lt;strong&gt;negativer Zahlen&lt;/strong&gt;, die in Geschäftsberichten oft durch Klammern erfolgt (z.B. „(1.234)“ statt „-1.234“), erfordert einen expliziten Hinweis im Prompt, damit das Modell diese Konvention korrekt interpretiert und die Zahlen mit dem richtigen Vorzeichen extrahiert. Wie bereits gesagt stellen Halluzinationen (im Gegensatz zu älteren Modellen) hier keine großen Probleme dar, bloß die Interpretation der Zahlen gelingt nicht immer.&lt;/p&gt;

&lt;p&gt;Zu guter Letzt stehen wir auch vor dem klassischen Trade-off zwischen Kosten und Leistung bei besonders komplexen Fällen. Anspruchsvollere Reasoning-Ansätze wie Chain-of-Thought (CoT), bei denen das Modell seine „Gedankenschritte“ explizit macht, oder der Einsatz noch größerer und leistungsfähigerer Modelle (z.B. Gemini 2.5 Pro) könnten bei den genannten Problemen, insbesondere bei den komplexen Berichten, Abhilfe schaffen.&lt;/p&gt;

&lt;p&gt;Diese sind jedoch aktuell oft noch deutlich teurer. So ist beispielsweise &lt;strong&gt;Gemini 2.5 Pro derzeit 16- bis 32-mal so teuer wie das von uns genutzte Gemini 2.0 Flash Lite&lt;/strong&gt;. Auch das sehr gängige GPT-4.1, welches in ChatGPT zum Einsatz kommt, kostet mit 2 $ pro 1 Million Input Tokens ca. 27-mal so viel wie Gemini 2.0 Flash Lite. Die Verarbeitung eines durchschnittlichen Berichts aus unserem Testdatensatz mit 30 Seiten kostet mit unserer Lösung daher nur ca. 0,0007 $!&lt;/p&gt;

&lt;h3 id=&quot;fazit-gemini-flash-als-leistungsstarke-ergänzung-im-werkzeugkasten&quot;&gt;Fazit: Gemini Flash als leistungsstarke Ergänzung im Werkzeugkasten&lt;/h3&gt;

&lt;p&gt;Gemini Flash hat sich für uns als nützlicher Baustein erwiesen, um die Extraktion strukturierter Daten aus Geschäftsberichten auf ein neues Level zu heben und in den produktiven Einsatz bei North Data zu bringen. Es ersetzt nicht zwangsläufig die gesamte klassische Pipeline (wie unsere OCR-Vorfilterung zeigt), aber es bietet eine enorm leistungsfähige, integrierte Alternative für den Kernprozess der intelligenten Datenextraktion und -strukturierung.&lt;/p&gt;

&lt;p&gt;Die Fähigkeit, Layouts zu verstehen, über einen größeren Kontext zu arbeiten und direkt strukturierte Outputs zu liefern, reduziert die Komplexität und den Wartungsaufwand im Vergleich zu traditionellen, mehrstufigen Ansätzen erheblich. Die Herausforderungen bleiben, aber der Fortschritt ist deutlich und eröffnet neue Möglichkeiten für die automatisierte Finanzdatenanalyse.&lt;/p&gt;

&lt;p&gt;Wir sind gespannt, wie sich diese Technologie weiterentwickelt und welche neuen Lösungsansätze sich ergeben. Habt ihr ähnliche Erfahrungen gemacht oder andere Strategien entwickelt? Teilt eure Gedanken mit uns!&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Dieser Blogpost wurde mit Unterstützung von Gemini-2.5-Pro geschrieben.&lt;/em&gt;&lt;/p&gt;

&lt;div class=&quot;footnotes&quot;&gt;
  &lt;ol&gt;
    &lt;li id=&quot;fn:1&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://getomni.ai/ocr-benchmark&quot; target=&quot;_blank&quot;&gt;OmniAI OCR Benchmark&lt;/a&gt;, abgerufen am 17.06.25 &lt;a href=&quot;#fnref:1&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:2&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://blog.cronn.de/de/ai/largelanguagemodels/2023/07/26/analyse-von-geschaeftsberichten-mit-chatgpt-1.html&quot; target=&quot;_blank&quot;&gt;cronn Blog: Analyse von Geschäftsberichten mit ChatGPT – Teil 1&lt;/a&gt; &lt;a href=&quot;#fnref:2&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:3&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-0-flash-lite?hl=de&quot; target=&quot;_blank&quot;&gt;Dokumentation Google Gemini 2.0 Flash-Lite&lt;/a&gt;, abgerufen am 17.06.25 &lt;a href=&quot;#fnref:3&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:4&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://web.archive.org/web/20230614104237/https://openai.com/pricing&quot; target=&quot;_blank&quot;&gt;Web Archive: OpenAI-Preise vom 14. Juni 2023&lt;/a&gt;, abgerufen am 17.06.25 &lt;a href=&quot;#fnref:4&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
    &lt;li id=&quot;fn:5&quot;&gt;
      &lt;p&gt;&lt;a href=&quot;https://ai.google.dev/gemini-api/docs/pricing?hl=de&quot; target=&quot;_blank&quot;&gt;Preise für die Gemini Developer API&lt;/a&gt;, abgerufen am 17.06.25 &lt;a href=&quot;#fnref:5&quot; class=&quot;reversefootnote&quot;&gt;&amp;#8617;&lt;/a&gt;&lt;/p&gt;
    &lt;/li&gt;
  &lt;/ol&gt;
&lt;/div&gt;</content><author><name>leonardThiele</name></author><summary type="html">Wir zeigen einen KI-Use-Case im Einsatz: Extraktion von Kennzahlen aus Geschäftsberichten mittels LLM.</summary></entry></feed>