跳到主要内容
mcpd
无守护进程
源码

Python

给同步代码和异步代码用的上下文管理器,一个基于 contextvars 的活动 span,以及完全没有必需依赖。

安装

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 把提示词和补全留在进程里:span 照样 记录,带着它的时间、状态、用量、模型、关联信息和错误,而 input 和 output 在进入队列之前就被丢掉。单个 span 可以在自己的启动 字段里用 capture_payloads=True 说自己不一样,反过来也可以。

记录一次运行

mcpd.trace(name) 开一次运行,mcpd.span(name) 在 当前活动的那个东西下面开一个步骤。两者都是上下文管理器:代码块出去时结束 这个 span,块里抛了异常就把异常记下来,然后原样放它过去。你自己的错误处理 不会因为被观测而改变。

记录一次运行

# 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()。不管哪种写法, span 在开始时就已经导出过一次,所以一个永远没结束的 span,看起来就是一个 永远没结束的 span,而它通常正是你在找的那一个。

同步与异步

# 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()

上下文

活动的 span 是一个 contextvars.ContextVar。它会跟着一次 await 进到任务里,也不会在线程之间泄漏,所以往下三层的一个 辅助函数,不需要任何调用方把参数一路传下去,就能开出挂在正确父级下面的 子 span。

嵌套

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 请求头。格式不对的头会开 一条新的 trace,而不是让携带它的那个请求失败。进来的头说不采样,就照它办, 因为一条有的服务留下、有的服务扔掉的 trace,算不上一条 trace。

跨过服务边界

# 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 在摄取时就是按 token 数定价的,所以 trace 上的成本就是你被计费的那个 成本。请上报供应商告诉你的数字。set_error 接受一个字符串或者 一个异常,同时把 status 设成 error,并且一定会填上 一条消息:“出了点问题”没法拿来做任何事。

一个 span 带着什么

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。它们都返回这个 span, 其中任何一个也都可以在 span 开始时作为关键字参数传进去。 external_parent_span_id= 指的是一个来自非 mcpd 的 tracing 系统的父级,只带着用于关联,绝不用来决定父子关系。

载荷、sink 与已经结束的 span

sink 就是一个批次被排空之后去的地方,默认是 HTTP 传输层。 RecordingSink 把每个批次留在内存里,于是一个测试不需要服务器 就能断言接入代码记下了什么;ConsoleSink 则把每个信号打印成 一行 JSON 到标准错误。抛异常的 sink 算一个失败的批次,会被计数、被上报, 绝不会变成你代码里的异常。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、父级和时间都按你手上的样子来:也就是另一套 tracing 系统的 适配器或者一次批量导入手上有的那些东西。它绝不会被重新指定父级、重新计时 或者重新采样,而一条格式不对的记录会被丢弃,并计入 stats().rejected,绝不抛出。

持久化运行

同一个客户端也服务守护进程的持久化运行。workflow() 构建定义 并持有那些步骤主体,可以写成调用,也可以写成装饰器;一个 Worker 注册它,在一个线程池上用长轮询要租约并逐份服务,而 mcpd.runs、 mcpd.events 和 mcpd.definitions 与运行接口一一 对应。步骤主体抛出带 class_="retryable" 的 StepFailure,表示要在这个步骤的预算内重试,并通过 StepContext 把 span 记在自己这个步骤下面。这些 span 花掉的, 就是这个步骤的用量:每个 span 结束时把 token 加进去,看到的最后一个模型 胜出,所以守护进程是按 trace 显示出来的东西给这个步骤定价的; 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,在 工作流、 Worker 和 运行这几页上。

评分与反馈

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",
)

不指定 trace 时,两者都会挂到当前活动 span 所在的那条 trace 上。日志和指标 也一样,所以图上的一个尖峰能打开引起它的那次运行。

刷出与关闭

记录就是往内存里的一个队列追加一条然后返回;HTTP 由一个守护线程来做。 flush() 把等着的东西发出去,如果在它的超时之内没做完就返回 False。shutdown() 先刷出,再停掉 worker 并释放 传输层。两者都是幂等的。在一个事件循环里请调 aflush() 和 ashutdown(),它们做的是同样的事,但不阻塞这个循环。

在一个 serverless 函数里,这件事比在别处都更要紧。你的处理函数一返回,运行时 就把进程冻住,worker 线程再也不会跑,于是还排在队列里的那个批次就丢了。

一个 serverless 处理函数

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 记下它们的数量,并且每个间隔 记一条警告。一个没有上限的缓冲区,会把一次网络故障变成一次内存耗尽被杀。
  • 采样决定只在根上做一次,依据是 trace id,然后由每个子级继承。进来的 traceparent 永远胜出。
  • 一个 span 在开始时就导出,在结束时更新,所以一次跑到半路崩掉的运行仍然是 看得见的。
  • 时间戳取自 time.time_ns(),在传输时是带九位数字的 UTC。用 本地时间会让一条分布式 trace 没法读。

把它读回来

读取方法对应 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}")

查找一次运行把过滤条件 讲全了。数据模型 列出了这些类型带的每一个字段。