Podręcznik uczestnika z objaśnieniami, przykładami i ćwiczeniami do samodzielnej nauki do Modułu 5

6. Kontrolowane wykonanie narzędzia

Tekst modelu nie może wybierać dowolnej funkcji Pythona. Wykonawca korzysta z jawnego rejestru, sprawdza argumenty i stan, a po wywołaniu kontroluje wynik oraz przechwytuje błędy.

1. Walidacja to więcej niż obecność pola

Do pliku kod/moj_m5.py: validate_answer_arguments()

def validate_answer_arguments(arguments, state):
    _fields(arguments, {'question', 'top_k', 'similarity_threshold'}, {'question'})
    try:
        q = validate_chat_message(arguments['question'], MAX_QUESTION_LENGTH)
    except (TypeError, ValueError) as e:
        raise ToolValidationError('invalid_question') from e
    k = arguments.get('top_k', DEFAULT_TOP_K)
    th = arguments.get('similarity_threshold', DEFAULT_SIMILARITY_THRESHOLD)
    if type(k) is not int or not 1 <= k <= MAX_TOP_K:
        raise ToolValidationError('invalid_top_k')
    if th is not None and (type(th) not in (int, float) or not math.isfinite(th) or (not -1 <= th <= 1)):
        raise ToolValidationError('invalid_threshold')
    return {'question': q, 'top_k': k, 'similarity_threshold': th}

Gotowe _fields() wymaga słownika, odrzuca nieznane klucze i wykrywa brak wymaganych. top_k musi być dokładnie int od 1 do 5: bool nie jest dopuszczalny mimo dziedziczenia po int w Pythonie. Próg to None lub skończona liczba od -1 do 1. Sprawdzenie math.isfinite odrzuci NaN również przy bezpośrednim wywołaniu, które ominęło parser modelu.

Test R5: niepoprawne argumenty, bez RAG

state = m.create_initial_state()
for args in [
    {"question": "Stan?", "top_k": True},
    {"question": "Stan?", "top_k": 6},
    {"question": "Stan?", "command": "whoami"}
]:
    try:
        m.validate_answer_arguments(args, state)
    except m.ToolValidationError as error:
        print(error.code)

Odczyt źródeł wymaga pustych argumentów i istniejących last_sources. Metadane wymagają znanego document_id. Te walidatory są już gotowe. Brak źródeł jest brakiem danych potrzebnych do działania, a nie dowodem nieistnienia narzędzia.

2. Ustal postać i limit wyniku

Do pliku kod/moj_m5.py: normalize_tool_output()

def normalize_tool_output(output):
    if not isinstance(output, dict):
        raise ToolValidationError('invalid_output')
    try:
        text = json.dumps(output, ensure_ascii=False, allow_nan=False)
    except (ValueError, TypeError) as e:
        raise ToolValidationError('invalid_output') from e
    if len(text) > MAX_RESULT_CHARS:
        raise ToolValidationError('result_too_large')
    return deepcopy(output)

Wynik ma być słownikiem zapisywalnym w JSON bez NaN i Infinity. MAX_RESULT_CHARS=12000 ogranicza długość serializowanego tekstu. Kontrola następuje po wykonaniu funkcji: nie ogranicza czasu generacji ani wcześniej zużytej pamięci. Limit znaków nie jest limitem tokenów.

3. Jedna próba, również odrzucona

Do pliku kod/moj_m5.py: execute_tool_call()

def execute_tool_call(tool_call, state):
    start = time.perf_counter()
    name = None
    args = {}

    def finish(status, data=None, code=None):
        return make_tool_result(name, status, data, {'code': code} if code else None, round((time.perf_counter() - start) * 1000, 3), args)
    if state['tool_attempted_this_turn']:
        return finish('tool_rejected', code='attempt_limit')
    state['tool_attempted_this_turn'] = True
    if not isinstance(tool_call, dict) or set(tool_call) != {'action', 'arguments'}:
        return finish('invalid_arguments', code='invalid_envelope')
    name = tool_call['action']
    if not isinstance(name, str) or name not in TOOL_REGISTRY:
        name = name if isinstance(name, str) else None
        return finish('unknown_tool', code='unknown_tool')
    try:
        args = TOOL_VALIDATORS[name](tool_call['arguments'], state)
    except ToolStateError as e:
        return finish('clarification_needed', code=e.code)
    except ToolValidationError as e:
        return finish('invalid_arguments', code=e.code)
    try:
        output = TOOL_REGISTRY[name](state=state, **args) if name == 'show_last_sources' else TOOL_REGISTRY[name](**args)
        output = normalize_tool_output(output)
        return finish('tool_completed', data=output)
    except ToolValidationError as e:
        return finish('tool_error', code=e.code)
    except Exception:
        logger.exception('Błąd narzędzia %s', name)
        return finish('tool_error', code='tool_execution_failed')

Znacznik ustawiamy przed sprawdzeniem koperty i nazwy. Po błędnej próbie nie wykonamy po cichu kolejnej w tej samej turze. Nazwa jest kluczem słownika, nie treścią do eval(). Dla show_last_sources wykonawca przekazuje zaufany stan aplikacji; model nie dostarcza własnego state.

Sytuacja tool.status Wywołanie funkcji
Nazwa spoza rejestru unknown_tool Nie.
Złe argumenty invalid_arguments Nie.
Brak źródeł clarification_needed Nie.
Poprawne dane i wynik tool_completed Tak, raz.
Wyjątek lub zbyt duży wynik tool_error Tak; błąd w trakcie lub po wywołaniu.
Następna próba w turze tool_rejected Nie ponownie.

4. Jak dowieść braku wykonania

Sam napis „odrzucono” nie dowodzi, że funkcja nie została wcześniej wywołana. Podmień RAG na dubler dopisujący argumenty do listy. Sprawdź status i długość listy. Przywróć funkcję w finally, także gdy test się nie powiedzie. Wynik dublera nie jest wynikiem modelu.

Test: odrzucona próba także zużywa limit

calls = []
original = m.TOOL_REGISTRY["answer_from_documents"]
def fake_rag(**kwargs):
    calls.append(kwargs)
    return {"status": "answered", "answer": "Dubler", "sources": []}
try:
    m.TOOL_REGISTRY["answer_from_documents"] = fake_rag
    state = m.create_initial_state()
    m.begin_turn(state)
    bad = m.execute_tool_call({
        "action": "answer_from_documents",
        "arguments": {"question": "Stan?", "top_k": True}
    }, state)
    assert bad["status"] == "invalid_arguments"
    assert calls == []
    again = m.execute_tool_call({
        "action": "answer_from_documents",
        "arguments": {"question": "Stan?"}
    }, state)
    assert again["status"] == "tool_rejected"
    assert calls == []
finally:
    m.TOOL_REGISTRY["answer_from_documents"] = original

execute_tool_call() zwraca wynik, ale sam nie zapisuje tool_trace.jsonl. Robi to wywołujący kod, znający ID rozmowy, tury i wyniku. Dzięki temu również wczesny return, np. unknown_tool, ma ślad. W R6 append_tool_trace() występuje bezpośrednio po wykonawcy; w macierzy R5 po każdej testowanej próbie.

Ćwiczenie 5. Zbuduj granicę wykonania

  1. Uzupełnij trzy funkcje R5 i uruchom test etapu.
  2. Przeanalizuj w macierzy R5 nieznane narzędzie, bool, dodatkowe pole, ścieżkę zamiast ID, wyjątek i duży wynik.
  3. Wyjaśnij różnicę między tool_error i invalid_arguments na podstawie momentu wywołania.
  4. Uzasadnij 14 wpisów śladu przy 13 przypadkach oraz wspólny result_id dwóch prób.

ENHANCER+ · Politechnika Warszawska · podręcznik do samodzielnej pracy · M5