W tekście otwierającym serię o ADK napisałem, że docstring narzędzia jest specyfikacją, na podstawie której model decyduje, czy i kiedy wywołać funkcję. To zdanie łatwo przyjąć i trudno zastosować, dopóki nie zobaczy się, co model dokładnie dostaje.
Sprawdziłem to w kodzie. Wziąłem ADK w wersji 2.9.0, zbudowałem kilka wariantów tej samej funkcji i wypisałem deklarację, którą framework przekazuje modelowi. W trzech miejscach wynik jest inny, niż podpowiada intuicja programisty Pythona.
Dokumentacja ADK mówi to jednym zdaniem: docstring funkcji służy jako opis narzędzia i jest wysyłany do modelu. Z sygnatury framework buduje schemat parametrów, czyli nazwy, typy, wartości domyślne i listę wymaganych. Parametr jest wymagany, jeśli ma adnotację typu i nie ma wartości domyślnej.
Przykład, od którego zaczyna prawie każdy:
def sprawdz_stan(sku: str, magazyn: Literal["WAW", "KRK"] = "WAW") -> dict:
"""Sprawdza stan magazynowy."""
Z tej funkcji model dostaje taką deklarację (pominąłem dwa pola techniczne ze schematu):
{
"name": "sprawdz_stan",
"description": "Sprawdza stan magazynowy.",
"parameters_json_schema": {
"properties": {
"sku": { "title": "Sku", "type": "string" },
"magazyn": {
"default": "WAW",
"enum": ["WAW", "KRK"],
"title": "Magazyn",
"type": "string"
}
},
"required": ["sku"]
}
}
Spójrz na to z perspektywy modelu. Wie, że istnieje funkcja, która „sprawdza stan magazynowy”, przyjmuje napis sku i jeden z dwóch kodów magazynu. Nie wie, czym jest sku w Twojej firmie ani w jakim formacie go podać. Nie wie, co znaczy „stan”: sztuki na półce, sztuki razem z towarem w drodze, a może status produktu w katalogu. Nie wie też, kiedy wybrać tę funkcję zamiast innej i co dostanie z powrotem.
Deklaracja to wszystko, co model wie o Twojej funkcji, a docstring jest jedyną jej częścią, którą piszesz słowami.
Jedna rzecz działa dobrze od razu. Typ Literal zamienia się w listę dozwolonych wartości, a wartość domyślna trafia do schematu. Opłaca się z tego korzystać, zamiast wymieniać dozwolone wartości w opisie.
Cały docstring idzie jako jeden tekst. Jeśli piszesz w stylu Google, z sekcjami Args: i Returns:, ADK nie rozbiera ich na opisy poszczególnych parametrów. Wysyła cały docstring, łącznie z nagłówkami sekcji i wcięciami, jako opis funkcji, a schemat parametrów zostaje bez opisów. Model przeczyta sekcję Args:, ale tylko jako fragment tekstu, a nie jako strukturę przypiętą do parametru.
Opis w Field znika. Programiści znający Pydantic odruchowo piszą Annotated[str, Field(description="...")]. W moim teście na wersji 2.9.0 ten opis nie trafił do deklaracji: schemat parametru miał tylko tytuł i typ. Kto opisuje parametry wyłącznie w ten sposób, wysyła modelowi funkcję z nieopisanymi argumentami i nie dostaje żadnego ostrzeżenia.
Typ zwracany widzi tylko Vertex AI. Adnotacja -> dict zamienia się w schemat odpowiedzi wyłącznie wtedy, gdy ADK pracuje z Vertex AI. W kodzie frameworka stoi przy tym komentarz, że schemat odpowiedzi jest dodawany tylko dla tego wariantu. Przy Gemini API model nie dowie się z deklaracji, co funkcja zwraca. Jeśli ma to znaczenie dla jego następnej decyzji, musi to przeczytać w docstringu.
Do tego rzecz oczywista, ale łatwa do przeoczenia w dużym projekcie: funkcja bez docstringa trafia do modelu bez pola opisu. Model zna tylko jej nazwę i parametry. Z dokumentacji warto jeszcze zapamiętać, że wynik, który nie jest słownikiem, ADK opakowuje w słownik z jednym kluczem result, a zalecany format to słownik z kluczem status.
Anthropic w dokumentacji narzędzi dla Claude'a pisze, że szczegółowy opis jest zdecydowanie najważniejszym czynnikiem wpływającym na to, jak model korzysta z narzędzi. Zaleca co najmniej trzy, cztery zdania na narzędzie i wymienia, co powinny zawierać: co narzędzie robi, kiedy go używać, a kiedy nie, co znaczy każdy parametr i jakie ma ograniczenia. To dokumentacja innego dostawcy, ale mechanizm jest ten sam, bo opis to jedyny tekst, z którego model wie, do czego służy funkcja.
Ta sama funkcja napisana według tych wskazówek:
def sprawdz_stan(sku: str, magazyn: Literal["WAW", "KRK"] = "WAW") -> dict:
"""Zwraca liczbę sztuk produktu dostępnych od ręki w jednym magazynie.
Używaj, gdy klient pyta, czy produkt jest dostępny albo ile go jest.
Nie używaj do pytań o termin dostawy: stan nie obejmuje towaru w drodze.
Args:
sku: Ośmiocyfrowy kod produktu, np. "40012345". Nie nazwa produktu.
magazyn: "WAW" (Warszawa) albo "KRK" (Kraków).
Returns:
{"status": "ok", "sztuki": liczba} albo {"status": "brak_sku"},
gdy kod nie istnieje.
"""
Schemat parametrów jest identyczny jak wcześniej. Zmienił się tylko opis, który w całości, razem z sekcjami Args: i Returns:, trafia do pola description. Każde zdanie odpowiada na pytanie, które model musiałby inaczej rozstrzygnąć, zgadując:
| Zdanie w opisie | Na jakie pytanie odpowiada |
|---|
| „liczbę sztuk dostępnych od ręki w jednym magazynie” | Co dokładnie znaczy „stan” |
| „Używaj, gdy klient pyta…” | Kiedy wybrać tę funkcję |
| „Nie używaj do pytań o termin dostawy” | Kiedy wybrać inną |
| „Ośmiocyfrowy kod… Nie nazwa produktu” | Co wpisać w parametr |
Returns: z dwoma wariantami | Jak odróżnić brak towaru od błędnego kodu |
Ostatni wiersz ma praktyczne skutki. Jeśli dla nieistniejącego kodu funkcja zwraca zero sztuk, model powie klientowi, że towaru nie ma. Osobny status w odpowiedzi i jego opis w docstringu pozwalają mu zamiast tego dopytać o poprawny kod.
W tekście o pliku instrukcji dla agenta radziłem pisać krócej i wycinać wszystko, co model wie bez nas. Tu radzę pisać dłużej. Kryterium jest to samo: czy model może to wiedzieć bez Ciebie? O tym, co znaczy sku w Twoim systemie i czym stan różni się od dostępności, nie może. Dlatego opisy narzędzi są w praktyce zwykle za krótkie, a pliki reguł za długie.
Dłuższy opis ma swoją cenę. Deklaracje narzędzi jadą w każdym zapytaniu, razem z instrukcją systemową, więc trzy dodatkowe zdania przy dziesięciu narzędziach to kilkaset tokenów w każdej turze. To dobry wydatek, dopóki każde zdanie odpowiada na prawdziwe pytanie. Stały początek zapytania dobrze się przy tym buforuje, o czym pisałem przy cache'u po stronie dostawcy.
Sprawdź, co naprawdę wysyłasz
Deklarację, którą dostaje model, wypiszesz kilkoma liniami:
import json
from google.adk.tools import FunctionTool
deklaracja = FunctionTool(sprawdz_stan)._get_declaration()
print(json.dumps(deklaracja.model_dump(exclude_none=True, mode="json"),
ensure_ascii=False, indent=2))
Metoda z podkreśleniem na początku nazwy jest wewnętrzna i może się zmienić między wersjami. Do sprawdzania wystarcza, do kodu produkcyjnego nie. Wszystko, co opisuję wyżej, sprawdzałem na wersji 2.9.0, więc po aktualizacji frameworka powtórz ten test.
- Wypisz deklaracje wszystkich swoich narzędzi fragmentem z ramki wyżej i przeczytaj je tak, jakbyś nie znał kodu.
- Znajdź funkcje bez docstringa oraz te, w których parametry opisane są tylko w
Field. Przenieś opisy do docstringa.
- Dopisz do każdego opisu zdanie „kiedy nie używać”, zwłaszcza tam, gdzie dwie funkcje są do siebie podobne. To tam model najczęściej wybiera źle.
- Opisz w docstringu, co funkcja zwraca, szczególnie przypadki błędów, jeśli nie pracujesz na Vertex AI.
- Sprawdź efekt na rozmowach testowych, czy model częściej sięga po właściwe narzędzie. Przypadki testowe możesz zapisać tak, jak opisałem przy testowaniu agentów.
Docstring w narzędziu agenta pełni tę samą rolę co dokumentacja publicznego API. Różnica polega na tym, że czyta go wyłącznie model, który nie zadzwoni do autora z pytaniem.