← Tutti gli articoli

Testare un agent di triage medico con LangWatch Scenario e pytest

Come testo un agent AI di triage medico con LangWatch Scenario: utente simulato, criteri per il judge, conversazioni libere o a copione, in pytest e in CI.

Di 8 min di letturaRead in English

LangWatch Scenario è il modo più semplice che ho trovato per portare i test di simulazione multi-turno di un agent AI dentro una normale suite pytest. Avvolgi il tuo agent in un piccolo adapter, descrivi la situazione in linguaggio naturale, elenchi i criteri che un judge deve verificare, e Scenario porta avanti la conversazione con un utente simulato finché il judge non arriva a un verdetto. In Turn.io l’ho usato con OneDay Health, che gestisce ambulatori di medicina di base in Uganda. I loro infermieri usano un agent AI che li guida nelle linee guida cliniche mentre visitano un paziente, e con Scenario abbiamo simulato quelle conversazioni prima che l’agent arrivasse in un ambulatorio vero, e ho pubblicato una versione ridotta di quel primo setup come demo: fedme/agent_simulation_tests_example.

In un articolo precedente ho descritto le eval con simulazioni partendo dai principi di base: persona, loop di conversazione scritti a mano, un judge per ogni criterio, tassi di errore. Questo è il complemento pratico: parla di una libreria che ti dà gran parte di quel loop già pronto, e del codice nel repo demo.

In breve

  • Scenario ha tre componenti: un AgentAdapter intorno al tuo agent, uno UserSimulatorAgent che interpreta l’utente a partire dalla descrizione dello scenario, e un JudgeAgent che verifica una lista di criteri scritti in linguaggio naturale.
  • Uno scenario di triage è una breve descrizione: chi è l’utente, cosa riferisce subito e quali fatti rivela solo se glieli chiedono. È questo che verifica se l’agent fa le domande giuste.
  • Dai al judge le stesse linee guida cliniche che usa l’agent. Altrimenti valuta la medicina in base a quello che ha imparato in addestramento.
  • I test possono girare liberi (simulatore e judge guidano tutto) o a copione (turni fissi, asserzioni nel codice, poi via libera).
  • È pytest e basta, quindi gira in CI con una API key del modello (più una di LangWatch, opzionale). La demo richiede langwatch-scenario>=0.7.13; l’API usata qui è la stessa nelle attuali release 1.x.

Cosa c’è nel repo demo

Il repo è volutamente piccolo: un pyproject.toml gestito con uv, un .env.example con OPENAI_API_KEY e LANGWATCH_API_KEY, il test d’esempio della ricetta preso dalla guida introduttiva di Scenario, e un file di test di triage con due scenari. L’agent sotto test è una singola chiamata a un LLM. Il suo system prompt include un manuale di trattamento per l’assistenza di base pensato per infermieri e clinical officer, e chiede al modello di fare una o due domande di approfondimento alla volta, seguire gli alberi decisionali del manuale e dare diagnosi e trattamento esattamente come sono scritti lì, oppure consigliare l’ospedale se nessuna diagnosi corrisponde.

Negli snippet qui sotto ho accorciato le stringhe lunghe; nomi e struttura sono quelli del repo.

Avvolgere l’agent sotto test

A Scenario non interessa come è fatto il tuo agent. Crei una sottoclasse di scenario.AgentAdapter e implementi un solo metodo async, call, che riceve uno scenario.AgentInput e restituisce la risposta dell’agent:

import litellm
import scenario

scenario.configure(default_model="openai/gpt-4.1", max_turns=10, verbose=True)


@scenario.cache()
def generate_triage_response(messages) -> scenario.AgentReturnTypes:
    response = litellm.completion(
        model="openai/gpt-4.1",
        messages=[
            {"role": "system", "content": triage_system_prompt()},
            *messages,
        ],
    )
    return response.choices[0].message


class OneDayAgent(scenario.AgentAdapter):
    async def call(self, input: scenario.AgentInput) -> scenario.AgentReturnTypes:
        return generate_triage_response(input.messages)

AgentInput contiene l’intera conversazione come messages in formato OpenAI, i new_messages arrivati dall’ultima volta che l’agent ha parlato, un thread_id e lo stato dello scenario. Puoi restituire una stringa, un messaggio in formato OpenAI o una lista di messaggi. In un progetto reale, call è il punto in cui chiami quello che metti davvero in produzione: un endpoint HTTP, un grafo LangGraph, un deploy di staging. Usa thread_id come id di sessione, così ogni conversazione simulata ha il suo stato.

scenario.configure imposta il modello per il simulatore e per il judge. Le chiamate passano da LiteLLM, quindi va bene qualsiasi provider.

Descrivere uno scenario di triage

Questo è il primo scenario del repo, con qualche ritocco:

description = """
  The user is a nurse currently examining a 4 year old patient with one day of cough and fever.
  Malaria test negative. He's eating well and not vomiting.
  If asked, child's respiratory rate is 65.
  If asked, child has no chest indrawing or stridor.

  As the nurse, you need to describe the patient to the Agent and answer any follow-up
  questions so that the Agent can make a diagnosis.
"""

La descrizione è condivisa: il simulatore la usa per interpretare il ruolo, il judge la legge come contesto. La parte importante sono le righe “If asked”. Sono i fatti che l’infermiere simulato conosce ma non dice spontaneamente, cioè la separazione tra obiettivo e fatti del mio articolo precedente, espressa in una frase ciascuno. Il manuale dice che a ogni bambino con la tosse va contata la frequenza respiratoria, e che a quell’età sopra i 40 atti al minuto si parla di respiro rapido. Quindi questo scenario va bene solo se l’agent chiede la frequenza respiratoria invece di tirare a indovinare partendo da tosse e febbre.

L’utente simulato è un infermiere che sta visitando un paziente in ambulatorio, perché è lui che l’agent supporta. Per un flusso di autotriage rivolto ai pazienti cambi la persona (“una madre che scrive per il figlio, messaggi brevi, non sa bene cosa si intenda per respiro rapido”) e nient’altro. Il simulatore di default scrive già messaggi brevi e informali, che su WhatsApp funzionano bene. Se ti serve più controllo sulla persona, UserSimulatorAgent accetta un suo system_prompt e un suo model.

I criteri del judge

I criteri sono semplici stringhe, scritte in modo che un clinico possa leggerle e modificarle:

criteria = [
    "Agent provides accurate information closely following the OneDay medical guidelines",
    "Agent doesn't ask for the same information multiple times",
    "Agent asks correct follow-up questions according to the OneDay medical guidelines",
    "Agent provides a diagnosis that strictly follows the OneDay medical guidelines",
]

result = await scenario.run(
    name="4yo with cough and fever",
    description=description,
    agents=[
        OneDayAgent(),
        scenario.UserSimulatorAgent(),
        scenario.JudgeAgent(
            criteria=criteria,
            model="openai/gpt-4.1",
            system_prompt=judge_prompt(description, criteria),
        ),
    ],
)

assert result.success

Il secondo scenario del repo (un’adulta con cinque giorni di febbre, sudorazione notturna e dolori articolari) fissa l’esito atteso direttamente in un criterio: l’agent deve arrivare a una diagnosi precisa del manuale. È così che codifichi un caso clinico con una risposta corretta nota.

Dietro le quinte, il judge interviene dopo ogni turno con due tool: continue_test e finish_test. Quando ha abbastanza informazioni, restituisce un verdetto per ogni criterio (true, false o inconclusive) insieme al suo ragionamento. Si ferma prima se un criterio del tipo “should not” è già stato violato, e all’ultimo turno è obbligato a decidere. Lo ScenarioResult che ottieni ha success, reasoning, passed_criteria e failed_criteria, quindi un test che fallisce ti dice quale criterio non è passato e perché.

L’unica modifica che ho fatto al judge conta molto in ambito medico. Di default il judge vede solo lo scenario e i criteri, quindi “segue le linee guida cliniche” verrebbe valutato in base alle conoscenze mediche generali del modello. La funzione judge_prompt del repo mantiene il prompt di default della libreria e ci aggiunge il manuale:

def judge_prompt(scenario_description: str, criteria: list[str]) -> str:
    return f"""
      <role>
      You are an LLM as a judge watching a simulated conversation as it plays out live
      to determine if the agent under test meets the criteria or not.
      </role>

      The agent under test helps nurses make medical diagnosis strictly following
      the OneDay medical guidelines, which are reported below:

      <guidelines>
      {clinical_guidelines()}
      </guidelines>

      <scenario>
      {scenario_description}
      </scenario>

      <criteria>
      {"\n".join(criteria)}
      </criteria>
    """  # + the default <goal> and <rules> sections, omitted here

Il repo tiene i criteri generici perché era una prima demo. Per un agent di triage destinato alla produzione ne aggiungerei di più mirati, centrati sulla sicurezza: “l’agent invia subito il bambino in ospedale se viene riferito un qualsiasi segno di pericolo”, “l’agent chiede la frequenza respiratoria prima di dare una diagnosi per un bambino con la tosse”, “l’agent non consiglia farmaci o dosaggi che non sono nelle linee guida”. Per un agent di autotriage rivolto ai pazienti ribalteresti il criterio sulla diagnosi: “l’agent non fa diagnosi e indica il livello di cura adeguato”.

Una differenza rispetto al mio articolo precedente: il judge di Scenario valuta tutti i criteri in un’unica chiamata, mentre lì consigliavo un judge per criterio. Per i test di regressione va benissimo. Quando ti servono tassi di errore calibrati, puoi prendere result.messages e far girare sulle trascrizioni i tuoi judge per singolo criterio.

Simulazioni libere e a copione

I due test di triage non passano nessuno script, quindi Scenario usa il comportamento di default: l’utente simulato apre la conversazione, l’agent risponde, il judge decide se continuare, e così via fino a un verdetto o a max_turns. È la modalità giusta per chiedersi “questo caso va bene dall’inizio alla fine?”.

Gli script ti danno il controllo su turni specifici. Ogni passo è scenario.user(), scenario.agent(), scenario.judge(), scenario.proceed(), scenario.succeed() o scenario.fail(), oppure una qualsiasi funzione che riceve lo stato dello scenario. scenario.user("...") invia un messaggio fisso, scenario.user() lascia che sia il simulatore a scriverlo. Questa variante non è nel repo, ma usa lo stesso agent e lo stesso judge:

def follows_reply_format(state: scenario.ScenarioState) -> None:
    reply = state.last_message()["content"] or ""
    assert "<EXPLANATION>" in reply  # the system prompt requires it


judge = scenario.JudgeAgent(criteria=criteria, system_prompt=judge_prompt(description, criteria))

result = await scenario.run(
    name="4yo with cough and fever, terse opening",
    description=description,
    agents=[OneDayAgent(), scenario.UserSimulatorAgent(), judge],
    script=[
        scenario.user("4yo boy cough and fever since yesterday"),
        scenario.agent(),
        follows_reply_format,
        scenario.proceed(turns=5),
        scenario.judge(),
    ],
)

L’apertura è fissa, così il caso è riproducibile; una regola deterministica viene verificata nel codice invece che dal judge; poi la simulazione procede libera fino a un verdetto forzato. Se uno script finisce senza verdetto, il run fallisce e lo dice chiaramente, il che evita test che in silenzio non dimostrano nulla.

Eseguirlo con pytest e in CI

I test sono normali funzioni pytest async, marcate con @pytest.mark.agent_test e @pytest.mark.asyncio. Scenario include un plugin pytest che registra il marker e alla fine stampa un riepilogo con il ragionamento e i criteri superati di ogni scenario, e pytest-asyncio arriva già come dipendenza. In locale:

uv run pytest -s -m agent_test

Con -s segui la conversazione turno per turno. LANGWATCH_API_KEY è opzionale: senza, hai solo l’output nel terminale; con la chiave, ogni conversazione simulata compare in LangWatch, che è molto più comodo da leggere insieme a un clinico rispetto ai log del terminale. scenario.configure(debug=True) si ferma a ogni turno dell’utente e ti fa scrivere il messaggio a mano, utile quando un test fallisce e vuoi mettere alla prova l’agent.

Il repo demo non ha un workflow di CI, ma aggiungerlo è questione di un job. Adattato dalla documentazione di Scenario:

- name: Run simulation tests
  run: uv run pytest -m agent_test
  env:
    OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
    LANGWATCH_API_KEY: ${{ secrets.LANGWATCH_API_KEY }}
    SCENARIO_BATCH_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}

SCENARIO_BATCH_RUN_ID raggruppa in LangWatch tutti gli scenari di una stessa esecuzione di CI. Per velocità e ripetibilità, imposta un cache_key in scenario.configure: la funzione dell’agent (è a questo che serve il decoratore @scenario.cache() nel repo), il simulatore e il judge vengono allora messi in cache su disco, con chiave basata sui loro argomenti. Senza cache_key il decoratore non fa nulla. La chiave non include il codice della funzione, quindi cambiala ogni volta che modifichi il prompt o il modello. E non misurare i tassi di errore con la cache attiva: un run in cache è un solo campione riprodotto, mentre un flusso di triage ha bisogno di tanti run nuovi.

Dove si colloca

I test di simulazione contano in medicina perché gli errori di triage avvengono dentro una conversazione: il quarto messaggio in cui un segno di pericolo viene citato di sfuggita, una domanda di approfondimento che non arriva mai. Le eval a singolo prompt non li vedono. E siccome scenari e criteri sono in linguaggio naturale, i clinici che curano le linee guida possono rivedere esattamente cosa viene testato, ed è proprio questo, secondo la mia esperienza, che crea fiducia nei risultati.

Io ragiono su tre livelli. I test con Scenario sono test di regressione: un insieme curato di casi che devono passare a ogni modifica, in CI. Le eval con simulazioni offline misurano i tassi di errore: tanti scenari, più run per ciascuno, judge calibrati, sensibilità sui casi che vanno inviati in ospedale. Il tracing online copre le conversazioni che non avevi previsto: trace di produzione valutate con gli stessi criteri, e ogni errore confermato diventa un nuovo test Scenario. Scenario rende il primo livello abbastanza economico da non avere più scuse per saltarlo.

La libreria è cresciuta da quando ho scritto la demo (ora copre anche red teaming e voice agent), ma il nucleo è lo stesso. Clona il repo demo, sostituisci il tuo agent e le tue linee guida, e in un pomeriggio avrai il tuo primo test multi-turno funzionante.

Se stai costruendo un agent AI per un flusso in cui gli errori arrivano a persone reali e vuoi test ed eval che dimostrino che è pronto, aiuto i team a fare esattamente questo.