Vai al contenuto
mcpd
nessun daemon
Codice

Elixir

Un GenServer per il worker, il processo per il contesto, e niente aggiunto al tuo albero delle dipendenze.

Installazione

Aggiungilo a mix.exs. Non si porta dietro niente.

defp deps do
  [{:mcpd, "~> 0.1"}]
end

L’elenco delle dipendenze è tutto qui, ed è voluto. OTP 27 porta un codec JSON (:json) e un client HTTP (:inets), quindi una libreria il cui mestiere è guardare il tuo sistema non deve diventare il motivo per cui la build del tuo sistema cambia. Gli altri cinque SDK fanno la stessa promessa, ognuno nel proprio linguaggio.

Costruire il client

Ogni opzione ricade sulla propria variabile d’ambiente, e il codice vince. Mcpd.start_link() senza argomenti parla con un daemon locale.

connettersi

# Every option also reads an environment variable (MCPD_ENDPOINT, MCPD_PROJECT,
# MCPD_API_KEY, ...), so `Mcpd.start_link()` on its own works too.
{:ok, mcpd} =
  Mcpd.start_link(
    endpoint: "http://localhost:4317",
    project: "support-desk",
    environment: "development",
    service_name: "support-agent",
    service_version: "1.4.0"
  )

Mettilo nel tuo albero di supervisione e si comporta come qualunque altro worker:

children = [
  {Mcpd, project: "support-desk", environment: "production"}
]

Supervisor.start_link(children, strategy: :one_for_one)

Registrare un’esecuzione

span è una macro, quindi un’esecuzione è un blocco e il suo valore è il valore del blocco.

registrare un’esecuzione

question = "where is my order 41?"

# The block's value is the value of `span`. Inside it, this span is the active
# one for the process, so the helpers below find their parent without being
# handed it.
trace_id =
  Mcpd.span mcpd, "support_agent",
    kind: :agent_run,
    input: question,
    session_id: "session-8f21",
    user_id: "customer-4477",
    tags: ["support", "email"] do
    Mcpd.log(mcpd, :info, "handling a question", %{"channel" => "email"})

    plan = plan_answer.(mcpd, question)
    order = look_up_order.(mcpd, 41)
    estimate = estimate_delivery.(mcpd, 41)

    _answer =
      [plan, order, estimate && "It should arrive #{estimate}."]
      |> Enum.reject(&is_nil/1)
      |> Enum.join(" ")

    Mcpd.metric(mcpd, "support.answers", 1, kind: :counter, unit: "{answer}")
    Mcpd.Context.current().trace_id
  end

Le macro vanno richieste prima di poter essere chiamate, ed è una regola del linguaggio, non una scelta presa qui. use Mcpd lo fa per te:

defmodule MyAgent do
  use Mcpd

  def answer(mcpd, question) do
    Mcpd.span mcpd, "agent", kind: :agent_run, input: question do
      do_the_work(question)
    end
  end
end

Se il corpo è già una funzione anziché un blocco, chiama Mcpd.with_span/4. È la stessa cosa senza la macro.

L’albero si costruisce da solo

Lo span attivo vive nel process dictionary, e in Elixir nello specifico quello è il posto giusto dove tenerlo, non una scorciatoia. Qui l’unità di concorrenza è il processo, quindi due richieste servite nello stesso momento non possono vedere lo span dell’altra, e non c’è niente da far passare per ogni funzione per tenere insieme l’albero.

Un figlio avviato tre chiamate più in basso trova il proprio padre da solo:

Mcpd.span mcpd, "agent", kind: :agent_run do
  plan(mcpd)          # apre un figlio di "agent" senza che glielo si dica
end

defp plan(mcpd) do
  Mcpd.span mcpd, "plan_answer", kind: :model_generation, model: "gpt-4o" do
    call_the_model()
  end
end

La conseguenza che vale la pena conoscere: uno span aperto dentro Task.async fa partire una traccia nuova, perché quello è un altro processo. Passa la traccia di là con Mcpd.traceparent/0 e Mcpd.continue/2, la stessa coppia che useresti attraverso un confine HTTP.

  • Spans

    What ran, nested, with timings

  • Logs

    Lines the run emitted

  • Metrics

    Numbers, with exemplars back to a run

  • Scores

    Machine evaluation

  • Feedback

    What a person thought

  • Aspect notes

    A sentence a model wrote about the run

trace_id Store Search Export

Niente blocca, e niente solleva eccezioni

Registrare uno span manda un messaggio al worker e ritorna. La rete avviene su quel worker, quindi la latenza del tuo agente non dipende mai da quanto mcpd sia veloce, né dal fatto che sia raggiungibile, né dal fatto che stia girando.

Un’eccezione sollevata dentro un blocco viene registrata sullo span come errore e poi risollevata identica. Il mestiere di mcpd è vedere che cosa è successo, mai decidere che cosa ne fa il tuo codice.

La coda ha un tetto, e quando si riempie i segnali più vecchi vengono scartati. Quella direzione è scelta: quando il daemon è irraggiungibile, i segnali più recenti raccontano che cosa sta succedendo adesso, e sono quelli che qualcuno vorrà quando il daemon torna. Mcpd.stats/1 risponde che cosa ha fatto il client: queued, dropped, capacity, exported, failed e rejected.

log_level:, oppure MCPD_LOG_LEVEL, impone una soglia minima a Mcpd.log/4. L’impostazione predefinita è debug, che tiene tutto. Una riga sotto la soglia viene scartata prima che il record venga costruito, quindi una riga di debug in un deploy di produzione costa un confronto. La soglia vale solo per i log: span, metriche, punteggi e feedback non ne risentono.

Tenere i payload dentro il processo

capture_payloads: false, oppure MCPD_CAPTURE_PAYLOADS=false, tiene i prompt e le completion dove sono. Gli span escono lo stesso con i loro tempi, lo stato, il consumo, il modello, la correlazione e gli errori; input:, output: e il valore catturato di un blocco vengono scartati prima di arrivare alla coda, così niente di sensibile va in coda e niente di sensibile esce con un flush. Un singolo span può dire il contrario per sé con capture_payloads: true.

Verificare quello che hai registrato

Un sink è il posto dove finisce un batch svuotato. Il trasporto HTTP è quello predefinito; Mcpd.Sink.Recorder tiene in memoria ogni batch, così un test rilegge gli span, i log, le metriche, i punteggi e il feedback che la strumentazione ha registrato senza un daemon, e Mcpd.Sink.Console stampa una riga JSON per segnale su stderr. Un sink tuo implementa Mcpd.Sink; uno che solleva un’eccezione è un batch fallito, contato e riferito, mai sollevato verso chi ha chiamato.

Mcpd.record/3 mette in coda uno span già concluso, così com’è: con i suoi identificatori, il suo padre, il suo inizio e la sua fine, senza che gli si cambi mai il padre, i tempi o il campionamento. È quello che ha sottomano un adattatore per un altro sistema di tracing o un’importazione in blocco. Un record i cui identificatori non hanno la forma di OpenTelemetry viene rifiutato e contato, e non solleva mai un’eccezione. external_parent_span_id: su qualunque span porta un padre che viene da un sistema di tracing che non è mcpd, solo per la correlazione.

Esecuzioni durevoli

Lo stesso client serve le esecuzioni durevoli del daemon. Mcpd.Workflow costruisce la definizione e conserva i corpi dei passi come funzioni a tre argomenti; un Mcpd.StepWorker la registra, fa long polling per ottenere i lease e serve ognuno di essi su un processo suo, e Mcpd.start_run/3, Mcpd.watch_run/3, Mcpd.publish_event/4 e gli altri rispecchiano uno a uno la superficie delle esecuzioni. Un corpo solleva Mcpd.StepException con class: :retryable per essere ritentato entro il budget del suo passo, e registra span sotto il proprio passo attraverso Mcpd.StepContext.span/4. Ogni span che il corpo chiude somma il proprio consumo a quello del passo, e vince l’ultimo modello visto, così il daemon valorizza quello che la traccia mostra; set_usage/2 è l’override esplicito. on_lease, on_step_start, on_step_end e on_conflict stanno accanto a on_error sul worker, e StepWorker.stats/1 conta quello che ha preso in carico, quello che ha completato, quello che è fallito per classe e quello che ha abbandonato per motivo.

un workflow

support =
  Workflow.new("support_ticket")
  |> Workflow.step("classify", fn _ctx, input, _prior ->
    kind = if String.starts_with?(input["ticket"], "Q"), do: "question", else: "bug"
    %{"kind" => kind}
  end)
  |> Workflow.agent(
    "draft",
    fn ctx, input, _prior ->
      # Your own model call, recorded under this step in the run's trace. Its
      # usage is the step's: the daemon prices what the trace shows.
      opts = [kind: :model_generation, model: "claude-sonnet-4", provider: "anthropic",
        usage: %{"input_tokens" => 812, "output_tokens" => 37}]
      StepContext.span(ctx, "model_call", opts, fn -> :ok end)

      "an answer to a #{input["kind"]}"
    end,
    input: %{"kind" => "${steps.classify.output.kind}"}
  )
  |> Workflow.wait_for("approval",
    event: "approved",
    key: "${input.ticket}",
    timeout_ms: 7 * 24 * 3_600_000,
    when: where("steps.classify.output.kind", :eq, "bug")
  )
  |> Workflow.step(
    "send",
    fn ctx, input, _prior ->
      unless deliver.(input["text"], ctx.idempotency_key) do
        raise Mcpd.StepException,
          message: "mail relay busy", class: :retryable, retry_after_ms: 2_000
      end

      %{"sent" => true}
    end,
    input: %{"text" => "${steps.draft.output}"},
    effect: :at_most_once,
    retry: [max_attempts: 3, backoff: :exponential, initial_ms: 500]
  )
  |> Workflow.output(%{"sent" => "${steps.send.output.sent}"})

Che cosa fa ogni costrutto, il protocollo dei lease e l’API delle esecuzioni stanno su Workflow, Worker ed Esecuzioni.

Punteggi e feedback

punteggio e feedback

# A machine evaluation. `min` and `max` are required: 7 is excellent out of 10
# and poor out of 100, and a score with no scale cannot be compared.
Mcpd.score(mcpd, trace_id, "answer_relevance", 0.86,
  min: 0,
  max: 1,
  reason: "answered the question asked, without the delivery estimate"
)

# A person's judgement, from the thumbs-up control in your own product.
Mcpd.feedback(mcpd, trace_id, :thumbs, "up",
  comment: "fast and clear",
  author_id: "customer-4477"
)

# Nothing above touched the network. This is where it goes.
:ok = Mcpd.flush(mcpd)

Rileggerla

leggere l’esecuzione

{:ok, detail} = Mcpd.get_trace(mcpd, trace_id, include: ["scores", "feedback"])

IO.puts("#{detail.trace.name}: #{length(detail.spans)} spans")

for span <- detail.spans do
  ms = div(span.duration_ns || 0, 1_000_000)
  error = (span.error && span.error.message) || ""
  IO.puts("  #{span.kind} #{span.name} #{ms}ms #{error}")
end

I processi di breve durata devono fare il flush

Uno script o un task che esce in fretta perde tutto quello che non era ancora partito. Mcpd.shutdown/1 fa il flush e ferma il worker, e si può chiamare due volte senza rischi.

far girare l’esempio contro un daemon locale

mcpd serve --data ./mcpd.db elixir examples/elixir/quickstart.exs

Poi