Function-Calling mit KI-Assistenten: Interaktion mit der echten Welt

LLMs beibringen, nicht darauf getrimmte Wetterdaten zu interpretieren sowie das Smart Home zu steuern - inkl. Python Prompts.

Function-Calling mit KI-Assistenten: Interaktion mit der echten Welt
... oder: Wie ChatGPT lernte, mein Wohnzimmer zu saugen

oder: Wie ChatGPT lernte, mein Wohnzimmer zu saugen

In diesem Post beschäftige ich mich damit, wie man LLMs beibringt mehr zu tun, als nur per Chat zu antworten. Zunächst wird ein einfacher Wetterdienst abgerufen, um "Function-Calling" zu demonstrieren - das LLM ist dadurch in der Lage, aktuelle Wetterdaten in seine Antwort einzubeziehen. Ich mache mir hier zunutze, dass das Modell Daten interpretieren kann, die nicht darauf getrimmt sind, maschinenlesbar zu sein.
Im zweiten Teil wende ich dasselbe Prinzip an, um dem LLM zu erlauben, mein Smart Home auszulesen und zu steuern.


Das Thema präsentierte unser Kollege Ben auf dem 3. KI-Insights Barcamp. Seine Erfahrungen und Tipps teilt er nun auch hier im AI Product Circle inkl. Prompts in Python. Das 4. KI-Insights Barcamp (click für mehr info) findet schon im September statt.


Large Language Models (LLMs) sind dafür bekannt, dass sie Gespräche führen können: Man sendet Text an das Modell (Prompt) und erhält Text als Antwort zurück.

Wenn die Antwort des Modells strengen Regeln folgen würde, könnten Entwickler, die ein LLM in ihre Programmlogik integrieren möchten, darauf besser reagieren.

Mit Tricks wie z. B. die Angabe im Prompt "Respond only in JSON" oder "Respond in JSON following this scheme: {...}" ist dies durchaus möglich. LangChains "Output Parser" bietet sogar ein generisches Interface dafür.

Eine andere spezielle Form, die Ausgabe des LLM einzuschränken, ist das sogenannte Function-Calling.

Function-Calling

Function-Calling ist eine Möglichkeit, LLMs an externe Anwendungen, Tools und APIs anzubinden; dem Modell zu "erlauben", in bestimmten Fällen anstelle einer direkten Antwort einen Funktionsaufruf anzufordern.

Ein Modell, das z. B. über Informationen zu einer Wetter-API verfügt, wird auf die Frage "Wie ist das Wetter in Barcelona?" anstatt einer direkten Antwort klarstellen, dass es gerne die Wetter-API mit dem Argument "Barcelona" aufrufen möchte.

Modelle wie GPT-4 und GPT-3.5 sind besonders darauf trainiert, solche Funktionsaufrufe zu erkennen, aber auch lokale Modelle wir llama2 sind dazu in der Lage.

Das Wetter in Barcelona

Mit dem OpenAI Python-Paket ist das recht einfach. Zunächst muss beim Erzeugen der LLM-Chat-Antwort eine Liste von "Tools" angegeben werden. Diese enthalten die Definitionen der verfügbaren Funktionen als JSON-Schema. Dabei ist es wichtig, dass die Funktion passend beschrieben wird, damit das Modell weiß, wann es angebracht ist, diese Funktion aufzurufen. Hat unsere Funktion Parameter, wie hier im Beispiel, ist es logischerweise ebenso wichtig, diese gut zu beschreiben.


import json
from openai import OpenAI

client = OpenAI()

messages = [{"role": "user", "content": "How is the weather in Barcelona?"}]

response = client.chat.completions.create(
    model="gpt-3.5-turbo-0125",
    messages=messages,
    tools=[{
        "type": "function",
        "function": {
            "name": "get_weather",
            "parameters":
)

Als Nächstes muss erkannt werden, ob die Antwort des Modells Funktionsaufrufe anfordert. Ist dies der Fall, muss Folgendes geschehen: Die Antwort des Modells muss zum "Chatverlauf", hier "messages", hinzugefügt werden. Als Nächstes müssen die angefragten Funktionen aufgerufen werden. Für jedes Ergebnis eines solchen Funktionsaufrufs wird eine weitere Nachricht mit der Rolle "Tool" und der richtigen "Tool_Call_ID" hinzugefügt.


if response.choices[0].message.tool_calls:
    messages.append(response.choices[0].message)

    for tool_call in response.choices[0].message.tool_calls:
        args = json.loads(tool_call.function.arguments)

        if tool_call.function.name == "get_weather":
            result = requests.get(f'http://wttr.in/{args["location"]}').text

        messages.append({
            "tool_call_id": tool_call.id,
            "role": "tool",
            "name": tool_call.function.name,
            "content": result
        })

Ich mache es mir hier sehr einfach: Ich rufe die URL 'wttr.in' auf, nehme einfach das Ergebnis, welches ein ASCII-formatierter Wetterbericht ist - keine wirklich strukturierten Daten - und werfe das Ganze einfach gegen das Modell. Es funktioniert tatsächlich, GPT kann diese Daten interpretieren.

Dies hat zum Ergebnis, dass sich nun in den Nachrichten widerspiegelt:

  • (User) die Anfrage des Benutzers
  • (Assistent) die Antwort des LLMs, welche Funktionsaufruf-Anweisungen enthält
  • (Tool) Ergebnisse der Funktionsaufrufe

Hiermit kann nun das Modell in einem zweiten Schritt die ursprüngliche Frage beantworten, da es nun genügend Informationen für die Antwort gesammelt hat. Wird nun dieser ganze Verlauf wieder ans LLM geschickt, kommt eine finale Antwort zurück:


second_response = client.chat.completions.create(
    model="gpt-3.5-turbo-0125",
    messages=messages
)

print(second_response.choices[0].message.content)

Das Ergebnis ist vielversprechend:

The weather in Barcelona is currently sunny with a temperature of 20°C. The rest of the day is expected to be sunny with a slight increase in temperature.

Wir haben also unserem LLM beigebracht, externe Informationen abzurufen und mit der "Außenwelt zu interagieren". Diesen Wetterdienst abzurufen soll nur als einfaches Beispiel dienen - alles, was mittels einem Funktionsaufruf geschehen kann, kann auch vom LLM angesteuert werden. Die Einsatzmöglichkeiten sind nahezu unbegrenzt und ebenso besorgniserregend oder gar gefährlich, wird hier doch u.U. die physische Kontrolle über etwas einem LLM überlassen, das oft naiv agiert und halluziniert.

Vorsicht ist also geboten.

Safety Third: Home-Assistant

Mir der Gefahren bewusst, möchte ich nun meine Home-Assistant-Instanz an GPT anschließen - dem LLM einfach so die volle Kontrolle über alle steuerbaren Geräte in meiner Wohnung zu überlassen, klingt logisch.

Home Assistant ist eine Open-Source-Lösung, um Smart-Home-Geräte anzusprechen. Es bietet eine große Anzahl von Integrationen, um praktisch jedes Gerät, bei dem das Sinn macht, an einen zentralen Smart-Home-Hub anzuschließen und zentral steuerbar und auslesbar zu machen. Wer sich ein wenig auskennt, kann sich mit Home Assistant ein Smart Home zusammenbauen, das nur lokal funktioniert und keine Daten an Google, Amazon usw. sendet.

Home Assistant bietet eine einheitliche API, um mit allen im Haus befindlichen Geräten zu kommunizieren. Glühbirnen, Thermostate, Router, Media-Player und -server usw. sind alle über eine API ansprechbar.

Um alles möglichst einfach zu halten, beschränke ich mich auf zwei Grundfunktionen der API: Informationen abrufen (ist das Licht an?) und Änderungen bewirken (schalte das Licht an).

Ich füge also die beiden Tool-Definitionen hierfür den existierenden hinzu:

[...]
        {
            "type": "function",
            "function": {
                "name": "get_entity_info",
                "description": f"Get information about an entity. Always use this to retrieve information.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "entity_id": {
                            "type": "string",
                            "description": "The requested entity. Use only entities that are provided under 'available entities'.",
                        },
                    },
                    "required": ["entity_id"],
                },
            },
        },
        {
            "type": "function",
            "function": {
                "name": "call_service",
                "description": f"Effect a change by calling a service. Use this to set something or to perform an action. Use this for everything that is not information retrieval. Detailed information about the parameters is provided under 'available services'.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "domain": {
                            "type": "string",
                            "description": "The domain to which the service belongs to.",
                        },
                        "service": {
                            "type": "string",
                            "description": "The service that shall be called. Only use services that are provided.",
                        },
                        "service_data": {
                            "type": "object",
                            "description": "Additional data needed to perform the service all. Include an entity when possible. When providing an entity id, provide the field `entity_id` inside service_data.",
"properties": {}
                        },
                    },
                    "required": ["domain", "service"]
                },
            },
        }
[...]

Die Definitionen enthalten wieder eine Beschreibung der jeweiligen Funktion - wann sie einzusetzen ist, und wie. Hier ist ein bisschen experimentieren gefragt, bis das LLM konsistent das tut, was man möchte. Um z.B. eine einheitliche Schreibweise von "entity_id" zu erzielen, "erinnere" ich das LLM explizit daran.

Was hier auch sichtbar wird, ist, dass dies noch nicht alles ist, was wir benötigen, um die Funktionen erfolgreich auszuführen: Ich verweise auf "available entities" und "available services" - um Informationen abzurufen, muss bekannt sein, welche sogenannten "Entities" im System existieren. Um Aktionen auszuführen, müssen wir wissen, welche "Services" verfügbar sind.

Ich mache es mir wieder einfach und hole mir das benötigte einfach von der Home-API:

curl -H "Authorization: Bearer $HATOKEN" -H "Content-Type: application/json" http://homeassistant:8123/api/states | jq -r ".[].entity_id" > entities.json
curl -H "Authorization: Bearer $HATOKEN" -H "Content-Type: application/json" http://homeassistant:8123/api/services | jq . > services.json

Zunächst hole ich mir eine Liste der verfügbaren Entitäten mittels curl und jq und lege sie in der Datei 'entities.json' ab. Dann hole ich mir alle Service-Definitionen, die meine Home-Assistant-Instanz anbietet, und speichere sie in 'services.json'. Für beides ist ein Home-Assistant API Token erforderlich.

Es empfiehlt sich, beide Dateien noch einmal anzusehen und ein bisschen aufzuräumen, d.h. nur Entitäten und Dienste zu behalten, die man auch verwenden möchte, da es insgesamt schon viele Daten sind und die Menge, die wir an das LLM schicken können, begrenzt ist.

Die beiden Dateien werden dann importiert und per Systemnachricht ans Modell gegeben. Zuerst hatte ich das in der Funktionsdefinition versucht, aber dort sind nur kürzere Beschreibungen erlaubt.

Dann fehlt nur noch, die API-Requests zu tätigen, wenn das Modell danach fragt.

Alles zusammen sieht dann in etwa so aus, mit noch ein wenig Feinschliff:

import json
import os

from openai import OpenAI
import requests

HATOKEN = os.environ['HATOKEN']

client = OpenAI()

entities = json.loads(open("entities.json").read())
services = json.loads(open("services.json").read())

messages = [
    {
        "role": "system",
        "content": f"available entities': {entities}"
    },
    {
        "role": "system",
        "content": f"available services': {services}"
    },
    {
        "role": "system",
        "content": "when asked about persons, use the 'person' entities. 'at home' or 'home' refers to the entity 'zone.home'. use 'zone.home' when asked about people at home."
    },
]

print()
prompt = input("-> ")
print()

messages.append(
    {
        "role": "user",
        "content": prompt
    }
)

response = client.chat.completions.create(
    model="gpt-3.5-turbo-0125",
    messages=messages,
    tools=[
        {
            "type": "function",
            "function": {
                "name": "get_entity_info",
                "description": f"Get information about an entity. Always use this to retrieve information.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "entity_id": {
                            "type": "string",
                            "description": "The requested entity. Use only entities that are provided.",
                        },
                    },
                    "required": ["entity_id"],
                },
            },
        },
        {
            "type": "function",
            "function": {
                "name": "call_service",
                "description": f"Effect a change by calling a service. Use this to set something or to perform an action. Use this for everything that is not information retrieval. Detailed information about the parameters is provided under 'available services'.",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "domain": {
                            "type": "string",
                            "description": "The domain to which the service belongs to.",
                        },
                        "service": {
                            "type": "string",
                            "description": "The service that shall be called. Only use service that are provided.",
                        },
                        "service_data": {
                            "type": "object",
                            "description": "Additional data needed to perform the service all. Include an entity when possible. When providing an entity id, provide the field `entity_id` inside service_data.",
                            "properties": {}
                        },
                    },
                    "required": ["domain", "service"]
                },
            },
        }
    ],
    tool_choice="auto"
)

available_tools = ["get_entity_info", "call_service"]

choice = response.choices[0]
tool_calls = choice.message.tool_calls
if tool_calls:
    messages.append(choice.message)
    for tool_call in tool_calls:
        args = json.loads(tool_call.function.arguments)

        if tool_call.function.name in available_tools:
            print(f" ┌ using tool '{tool_call.function.name}'", args, "...")

            if tool_call.function.name == "get_weather":
                result = requests.get(f'http://wttr.in/{args["location"]}').text

            elif tool_call.function.name == "get_entity_info":
                ha_response = requests.get(
                    f"http://homeassistant:8123/api/states/{args['entity_id']}",
                    headers={"Authorization": f"Bearer {ATOKEN}"}
                )
                result = json.dumps(ha_response.json())

            elif tool_call.function.name == "call_service":
                domain = args["domain"]
                service = args["service"]
                service_data = args["service_data"]

                ha_response = requests.post(
                    f"http://homeassistant:8123/api/services/{domain}/{service}",
                    headers={
                        "Authorization": f"Bearer {HATOKEN}",
                        "Content-Type": "application/json"
                    },
                    data=json.dumps(service_data)
                )
                result = ha_response.reason

            print(" └─ done")

            messages.append({
                "tool_call_id": tool_call.id,
                "role": "tool",
                "name": tool_call.function.name,
                "content": result
           })

    response = client.chat.completions.create(
        model="gpt-3.5-turbo-0125",
        messages=messages
    )

print()
print('▶', response.choices[0].message.content)
print()

Das Faszinierende (und Praktische) ist, dass es mit sehr wenig Aufwand möglich ist, die API anzusprechen. Ohne im Detail anzugeben, wie nun genau z.B. ein Licht einzustellen ist oder der Staubsaugerroboter zu bedienen, macht sich das LLM einen Reim auf die API-Definition, die es erhalten hat, und führt recht zuverlässig Befehle aus. Ein wenig "Massage" des Verhaltens ist manchmal notwendig und ist in natürlicher Sprache möglich, wie hier im Code ersichtlich.

Wie erwähnt, ist natürlich Vorsicht geboten, wenn man ohne Sicherheitsmaßnahmen einfach so physikalische (oder virtuelle) Geräte von einem KI-Assistenten kontrollieren lässt. Beim Experimentieren versuchte der Assistent zum Beispiel einmal, das WLAN-Passwort auf "CozyBlue" zu ändern, als ich eigentlich gemütliche blaue Beleuchtung wollte - es hatte eine nicht existierende Funktionalität halluziniert und deswegen keinen Erfolg damit erzielt - die Gefahren werden an diesem Beispiel deutlich.

Hier im Beispiel verwende ich OpenAIs GPT-Modelle - also werden meine Daten ins Netz geschickt. Allerdings ist eine Umstellung auf ein komplett lokales Modell, z.B. mittels Ollama, wirklich kein großes Problem. Es hätte hier nur den Rahmen gesprengt - ich wollte mich auf die Machbarkeit des Ganzen konzentrieren, statt vorzuführen, wie lokale Modelle Function Calling beherrschen.

Zum Abschluss noch ein paar (anonymisierte) Beispiele dafür, wie gut meistens alles funktioniert. Ich muss gestehen, ich verwende das Skript tatsächlich gelegentlich wirklich:


$ python app.py

-> hows the weather?

 ┌ using tool 'get_entity_info' {'entity_id': 'weather.xxxxxxxxxx'} ...
 └─ done

▶ The weather at XXXXXXXXXX is partly cloudy with a temperature of 2.8°C, humidity at 59%, wind speed of 16.2 km/h, and a pressure of 1002.7 hPa.

Would you like more details or the forecast for the upcoming days?

-> who is currently at home?

 ┌ using tool 'get_entity_info' {'entity_id': 'zone.home'} ...
 └─ done

▶ Currently, "xxxx" and "xxxx" are at home.

-> what time did i last walk the dog?

 ┌ using tool 'get_entity_info' {'entity_id': 'person.xxxx'} ...
 └─ done

▶ The last time you walked your dog was on March 25th, 2024 at 21:50 UTC.

-> set the office lights to dark blue

 ┌ using tool 'call_service' {'domain': 'light', 'service': 'turn_on', 'service_data': {'entity_id': 'light.wiz_rgbw_office_ceiling', 'rgb_color': [0, 0, 255]}} ...
 └─ done

▶ The office lights have been set to dark blue.

-> start cleaning the living room

 ┌ using tool 'call_service' {'domain': 'vacuum', 'service': 'start', 'service_data': {'entity_id': 'vacuum.robot'}} ...
 └─ done

▶ The cleaning has been started.


Autor aus dem Geek Space 9 Team:

Ben Dimbeck
Ben schreibt seit 1986 Code und interessiert sich (fast) genauso lange für KI und Computer-generierten Text.