본문으로 건너뛰기
mcpd
데몬 없음
소스

Python

동기 코드와 비동기 코드를 위한 컨텍스트 매니저, contextvars에 기반한 활성 스팬, 그리고 필수 의존성은 하나도 없습니다.

설치

Python 3.9 이상입니다. 필수 의존성이 없는 것은 의도한 것입니다. 익스포터가 표준 라이브러리의 urllib과 gzip으로 돌아가므로, mcpd를 설치해도 잠금 파일에 아무것도 늘지 않고 에이전트가 이미 의존하고 있는 것과 충돌할 수도 없습니다.

pip install mcpd

httpx 엑스트라가 더해 주는 것은 연결 재사용뿐입니다. 그것이 없어도 SDK의 동작은 똑같습니다.

클라이언트 만들기

프로세스마다 클라이언트 하나입니다. 모든 인자는 환경 변수도 읽으므로, 같은 코드가 고치는 곳 없이 개발과 운영 양쪽에서 돌아갑니다. 인자가 없는 Mcpd()는 http://localhost:4317의 데몬과 대화합니다.

연결

from mcpd import FeedbackKind, Mcpd, SpanKind

# Everything here has an MCPD_* environment variable behind it, so the same code
# runs in development and in production with no edit. `Mcpd()` with no arguments
# talks to a local daemon.
mcpd = Mcpd(
    endpoint=os.environ.get("MCPD_ENDPOINT", "http://localhost:4317"),
    project="support",
    environment="development",
    service_name="support-agent",
    service_version="1.4.2",
)

코드가 환경 변수를 이기고, 환경 변수가 기본값을 이깁니다.

인자환경 변수기본값
endpointMCPD_ENDPOINThttp://localhost:4317
api_keyMCPD_API_KEY없음
projectMCPD_PROJECTdefault
environmentMCPD_ENVIRONMENT없음
service_nameMCPD_SERVICE_NAME없음
service_versionMCPD_SERVICE_VERSION없음
sampleMCPD_SAMPLE1.0
enabledMCPD_ENABLEDtrue
capture_payloadsMCPD_CAPTURE_PAYLOADStrue
log_levelMCPD_LOG_LEVELdebug
batch_sizeMCPD_BATCH_SIZE128
flush_intervalMCPD_FLUSH_INTERVAL_MS2.0초
max_queueMCPD_MAX_QUEUE8192
timeoutMCPD_TIMEOUT_MS10.0초

enabled=False는 모든 기록 호출을 아무 일도 하지 않는 호출로 만들면서도 올바른 객체는 그대로 돌려줍니다. 그래서 시험 대상 코드를 바꾸지 않고도 테스트에서 mcpd를 끌 수 있습니다. capture_payloads=False는 프롬프트와 컴플리션을 프로세스 안에 붙들어 둡니다. 스팬은 시각과 상태와 사용량과 모델과 상관 정보와 오류를 그대로 실은 채 기록되고, input과 output은 큐에 닿기 전에 버려집니다. 스팬 하나는 시작 필드의 capture_payloads=True로 자기 몫만 달리 정할 수 있고, 반대 방향도 됩니다.

실행 기록하기

mcpd.trace(name)은 실행을 시작하고, mcpd.span(name)은 지금 활성 상태인 것 아래에 단계를 시작합니다. 둘 다 컨텍스트 매니저입니다. 블록은 빠져나가는 길에 스팬을 끝내고, 예외가 났다면 그 예외를 기록한 다음 바뀌지 않은 그대로 통과시킵니다. 관측된다고 해서 오류 처리가 달라지지 않습니다.

실행 기록하기

# The root span is the run. Everything opened inside it becomes a child,
# because the active span lives in a `contextvars.ContextVar`.
with mcpd.trace("support_agent", session_id="sess-8121", user_id=user_id,
                tags=["support", "orders"]) as run:
    run.set_input({"question": question})

    # A model call. `set_usage` is what mcpd prices the run from, so the
    # cost on the trace is the cost you were actually billed.
    with mcpd.span("plan", kind=SpanKind.MODEL_GENERATION) as generation:
        generation.set_model("gpt-4o-mini", provider="openai")
        generation.set_input([
            {"role": "system", "content": "You are a support agent."},
            {"role": "user", "content": question},
        ])
        time.sleep(0.12)  # stand-in for the provider round trip
        generation.set_output("I should look the order up first.")
        generation.set_usage(input_tokens=812, output_tokens=96, cache_read=128)

    # A tool call that fails. The `with` block records the exception on the
    # span, marks it as an error, and lets the exception through unchanged.
    order = None
    try:
        with mcpd.span("lookup_order", kind=SpanKind.TOOL_CALL) as tool:
            tool.set_entity("tool", entity_id="orders.lookup", name="Order lookup")
            tool.set_input({"order_id": "A-7741"})
            order = lookup_order("A-7741")
            tool.set_output(order)
    except TimeoutError as exc:
        # The span already carries the error. This is the agent deciding
        # what to do about it, which is a different question.
        mcpd.log("warn", "order lookup failed, answering from context", {"error": str(exc)})
        mcpd.metric("support.tool_failures", 1, "counter", labels={"tool": "orders.lookup"})

    reply = ("I could not reach the order system just now. Your order A-7741 "
             "shipped on Tuesday and tracking should update within the hour.")
    run.set_output(reply)
    run.set_metadata({"used_fallback": order is None})
    return reply, run.trace_id

with 블록 없이 쓴다면 run.end()를 직접 호출하십시오. 어느 쪽이든 스팬은 시작할 때 내보내지므로, 끝나지 않은 스팬은 끝나지 않은 스팬으로 보입니다. 대개는 그것이 찾고 있던 스팬입니다.

동기와 비동기

# Sync and async are the same object. Both end the span on the way out and
# record the exception if the block raised.
with mcpd.trace("nightly_summariser") as run:
    with mcpd.span("fetch", kind=SpanKind.TOOL_CALL) as step:
        step.set_output({"documents": 42})

async with mcpd.trace("agent") as run:
    async with mcpd.span("generate", kind=SpanKind.MODEL_GENERATION) as generation:
        generation.set_output(await model.complete(prompt))
    await mcpd.aflush()

컨텍스트

활성 스팬은 contextvars.ContextVar입니다. await를 따라 태스크 안으로 들어가고 스레드 사이로 새어 나가지 않습니다. 그래서 세 프레임 아래에 있는 헬퍼도, 호출자가 인자를 줄줄이 넘겨 주지 않아도 올바른 부모의 자식을 엽니다.

중첩

def draft(question: str) -> str:
    # Nothing was passed in. The span still lands under the run, because the
    # active span is a contextvars.ContextVar and this call is inside the block.
    with mcpd.span("draft", kind=SpanKind.MODEL_GENERATION) as generation:
        generation.set_input(question)
        return "..."


with mcpd.trace("support_agent") as run:
    run.set_output(draft("where is my order?"))

나오는 것은 시각과 페이로드가 붙은 트리입니다.

Trace b3d9e71a Fault
0ms 2.1s 4.2s 6.3s 8.4s
  1. agent support_agent fail
  2. model chat
  3. tool returns_policy
  4. model chat
  5. tool returns_policy
  6. model chat
  7. tool returns_policy

The model called one tool three times and never answered. That is visible in four seconds here, and invisible in a log file.

서비스 사이에서 컨텍스트는 W3C traceparent 헤더에 실려 갑니다. 형식이 깨진 헤더는 그것을 실어 온 요청을 실패시키는 대신 새 트레이스를 시작합니다. 샘플링하지 않겠다고 들어온 결정은 그대로 따릅니다. 어떤 서비스는 남기고 어떤 서비스는 버린 트레이스는 트레이스가 아니기 때문입니다.

서비스 경계를 넘어

# Outbound: hand the active span to the next service.
requests.post(url, headers={"traceparent": mcpd.traceparent()}, json=payload)

# Inbound: continue that trace rather than starting a new one.
with mcpd.trace("handler", traceparent=request.headers.get("traceparent")) as run:
    ...

모델, 사용량, 오류

mcpd가 수집 시점에 값을 매기는 대상이 토큰 수이므로, 트레이스에 적힌 비용이 곧 청구받은 비용입니다. 제공자가 알려 준 값을 보고하십시오. set_error는 문자열이나 예외를 받고, 같은 동작으로 status를 error로 놓으며, 메시지는 언제나 채웁니다. ‘무언가 실패했다’로는 손쓸 수 있는 것이 없습니다.

스팬이 싣는 것

generation.set_model("gpt-4o-mini", provider="openai")
generation.set_usage(input_tokens=812, output_tokens=96, cache_read=128)

# A failure you caught and decided about yourself.
generation.set_error("the provider timed out", kind="TimeoutError", retryable=True)

# A point in time inside the span, with no duration of its own.
generation.event("first_token")

나머지 세터는 set_input, set_output, set_metadata, set_attributes, set_tags, set_entity, set_cost, set_status, add_link입니다. 모두 스팬을 돌려주고, 스팬이 시작할 때 키워드로 넘길 수도 있습니다. external_parent_span_id=는 mcpd가 아닌 트레이싱 시스템의 부모를 가리키며, 상관 정보로 실려 갈 뿐 부모 관계를 맺는 데는 쓰이지 않습니다.

페이로드, 싱크, 끝난 스팬

싱크는 비워 낸 배치가 가는 곳입니다. 기본값은 HTTP 전송 계층입니다. RecordingSink는 모든 배치를 메모리에 들고 있어서, 테스트가 서버 없이 계측이 무엇을 기록했는지 검증합니다. ConsoleSink는 시그널마다 JSON 한 줄을 stderr로 출력합니다. 예외를 일으키는 싱크는 실패한 배치이고, 세어서 보고할 뿐 호출한 코드에서 나는 예외가 되지는 않습니다. stats()는 클라이언트가 만들어진 뒤로 무엇을 했는지 답합니다.

기록된 것을 검증하기

from mcpd import Mcpd, RecordingSink

sink = RecordingSink()
mcpd = Mcpd(sink=sink)
with mcpd.trace("answer") as run:
    run.set_usage(input_tokens=12)
mcpd.flush()
assert sink.spans[0].usage.input_tokens == 12
print(mcpd.stats())  # queued, dropped, capacity, exported, failed, rejected

mcpd.record(span)은 이미 끝난 SpanRecord를, 들고 있는 id와 부모와 시각 그대로 큐에 넣습니다. 다른 트레이싱 시스템의 어댑터나 일괄 가져오기가 가지고 있는 것이 바로 그것입니다. 부모가 다시 매겨지지도, 시각이 다시 매겨지지도, 샘플링이 다시 결정되지도 않습니다. 형식이 깨진 레코드는 버려지고 stats().rejected에 세며, 예외를 일으키지는 않습니다.

지속성 실행

같은 클라이언트가 데몬의 지속성 실행도 다룹니다. workflow()는 정의를 만들고 단계 본문을 호출이나 데코레이터의 형태로 들고 있습니다. Worker는 그 정의를 등록하고, 스레드 풀에서 리스를 롱 폴링으로 기다렸다가 하나씩 처리합니다. mcpd.runs, mcpd.events, mcpd.definitions는 실행 표면을 하나씩 그대로 옮겨 놓은 것입니다. 본문은 단계의 예산 안에서 다시 시도되도록 class_="retryable"을 붙인 StepFailure를 일으키고, StepContext를 통해 자기 단계 아래에 스팬을 기록합니다. 그 스팬들이 쓴 것이 곧 그 단계의 사용량입니다. 토큰은 스팬이 끝날 때마다 더해지고 마지막으로 본 모델이 이깁니다. 그래서 데몬은 트레이스가 보여 주는 것으로 단계에 값을 매깁니다. ctx.set_usage는 그것을 필드별로 덮어씁니다. Worker는 on_error 옆에 훅 넷을 더 받고 (on_lease, on_step_start, on_step_end, on_conflict), worker.stats()는 확보한 리스, 진행 중인 본문, 완료, 분류별 실패, 사유별 충돌, 처리할 수 없는 리스를 셉니다.

워크플로 하나

support = workflow("support_ticket")


@support.step("classify")
def classify(ctx, input, prior):
    return {"kind": "question" if input["ticket"].startswith("Q") else "bug"}


@support.agent("draft", input={"kind": "${steps.classify.output.kind}"})
def draft(ctx, input, prior):
    # Your own model call, recorded under this step in the run's trace. What
    # the span spends is the step's usage: the daemon prices it from here.
    with ctx.span("model_call", kind="model_generation") as span:
        span.set_model("claude-sonnet-4", "anthropic")
        span.set_usage(input_tokens=812, output_tokens=37)
        text = f"an answer to a {input['kind']}"
    return text


support.wait_for("approval", event="approved", key="${input.ticket}",
                 timeout_ms=7 * 24 * 3_600_000,
                 when=when("steps.classify.output.kind", "eq", "bug"))


@support.step("send", input={"text": "${steps.draft.output}"}, effect="at_most_once",
              retry=RetryPolicy(max_attempts=3, backoff="exponential", initial_ms=500))
def send(ctx, input, prior):
    if not deliver(input["text"], ctx.idempotency_key):
        raise StepFailure("mail relay busy", class_="retryable", retry_after_ms=2_000)
    return {"sent": True}


support.output({"sent": "${steps.send.output.sent}"})

각 구성 요소가 무엇을 하는지, 리스 프로토콜, 실행 API는 워크플로, 워커, 실행에 있습니다.

점수와 피드백

min과 max는 키워드 전용이고 기본값이 없습니다. 척도를 밝히지 않은 맨 숫자는 무엇과도 비교할 수 없으므로, 척도는 나중에 짐작하는 대신 호출하는 자리에서 강제합니다. 피드백은 사람의 판단이고 점수와 따로 둡니다. 둘은 서로 다른 질문에 답하는 것이라 함께 평균 내서는 안 됩니다.

점수와 피드백

# A machine evaluation. `min` and `max` are required: a bare number with no
# declared scale cannot be compared to anything.
mcpd.score(
    "answer_relevance",
    value=round(random.uniform(0.6, 0.95), 2),
    min=0.0,
    max=1.0,
    trace_id=trace_id,
    reason="named the order and gave a delivery window, without the live lookup",
)

# A human evaluation. Kept separate from a score, because a person's
# judgement and a model's must never be averaged together.
mcpd.feedback(
    FeedbackKind.THUMBS,
    "up",
    trace_id=trace_id,
    comment="fine given the outage",
    author_kind="end_user",
)

대상을 지정하지 않으면 둘 다 활성 스팬의 트레이스에 붙습니다. 로그와 메트릭도 마찬가지입니다. 그래서 차트 위의 치솟은 봉우리에서 그것을 일으킨 실행을 열 수 있습니다.

플러시와 종료

기록은 메모리 안의 큐에 덧붙이고 바로 돌아옵니다. HTTP는 데몬 스레드가 합니다. flush()는 기다리고 있던 것을 보내고, 제한 시간 안에 보내지 못했으면 False를 돌려줍니다. shutdown()은 플러시하고, 워커를 멈추고, 전송 계층을 놓아 줍니다. 둘 다 멱등합니다. 이벤트 루프 안에서는 aflush()와 ashutdown()을 호출하십시오. 같은 일을 루프를 막지 않고 합니다.

서버리스 함수에서는 이것이 다른 어디에서보다 중요합니다. 런타임은 핸들러가 돌아오는 순간 프로세스를 얼리고 워커 스레드는 다시 돌지 않으므로, 아직 큐에 남아 있던 배치를 잃습니다.

서버리스 핸들러

def handler(event, context):
    with mcpd.trace("answer") as run:
        run.set_input(event["body"])
        answer = agent(event["body"])
        run.set_output(answer)

    # The batch is still in memory here. The runtime freezes this process the
    # moment the handler returns, and the worker thread never runs again.
    mcpd.flush()
    return {"statusCode": 200, "body": answer}

클라이언트는 atexit 훅도 등록합니다. 그래서 종료를 잊은 평범한 스크립트도 마지막 배치를 보냅니다. 이 훅은 os._exit()나 치명적인 종료 신호에서는 돌지 않습니다. 안전망이지 대체물이 아닙니다.

믿어도 되는 것

  • 계측은 절대 예외를 일으키지 않습니다. 모든 기록 호출에는 보호막이 있고, 실패는 한 주기에 많아야 한 번 로그로 남기고 삼킵니다. 관측 가능성이 관측 대상을 무너뜨리는 일은 절대 없어야 합니다. 읽기 호출만은 예외이고 실제로 예외를 일으킵니다. 답을 기다리고 있는 쿼리는 사정이 다르기 때문입니다.
  • 기록은 블로킹하지 않습니다. 에이전트의 지연 시간이 mcpd가 빠른지, 살아 있는지에 달리는 일이 없습니다.
  • 메모리에는 상한이 있습니다. 시그널 max_queue개, 기본값은 8192입니다. 가득 차면 가장 오래된 것부터 버리고, mcpd.dropped가 그 수를 세며, 한 주기에 경고 하나를 로그로 남깁니다. 상한 없는 버퍼는 네트워크 장애를 메모리 부족으로 인한 강제 종료로 바꿔 놓습니다.
  • 샘플링 결정은 루트에서 트레이스 id로부터 한 번 내리고, 모든 자식이 물려받습니다. 들어온 traceparent가 언제나 이깁니다.
  • 스팬은 시작할 때 내보내고 끝날 때 갱신하므로, 도중에 죽은 실행도 그대로 보입니다.
  • 타임스탬프는 time.time_ns()에서 나오고, 아홉 자리 UTC로 전송선에 올라갑니다. 지역 시간은 분산 트레이스를 읽을 수 없게 만듭니다.

다시 읽기

읽기 메서드는 HTTP API를 그대로 옮긴 것이고 생성된 타입을 돌려줍니다. 동기 방식이며 실패하면 McpdError를 일으킵니다. from_과 to는 datetime이나 -15m, -1h, -7d 같은 축약형을 받습니다. 비동기 코드에서는 asyncio.to_thread로 루프 바깥에서 돌리십시오.

검색과 분석

# Nothing has been sent yet: recording appends to an in-memory queue and a
# background worker does the I/O. Flush before you read your own writes.
mcpd.flush()

detail = mcpd.get_trace(trace_id, include=["logs", "scores", "feedback"])
print(f"\n{detail.trace.name}  {detail.trace.duration_ns / 1e6:.0f} ms  "
      f"{detail.trace.span_count} spans  {detail.trace.error_count} errors")
for span in detail.spans:
    cost = f"  ${span.cost.total_cost:.6f}" if span.cost else ""
    print(f"  {span.kind.value:<18} {span.name:<14} {span.status.value}{cost}")

summary = mcpd.analytics_summary("-1h", "-0s")
print(f"\nlast hour  traces={summary.trace_count}  errors={summary.error_count}  "
      f"p95={summary.latency.p95:.0f}ms  cost=${summary.total_cost:.4f}")

실행 찾기가 필터 전체를 다룹니다. 데이터 모델은 이 타입들이 싣는 모든 필드를 나열합니다.