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",
) 代码优先于环境变量,环境变量优先于默认值。
| 参数 | 环境变量 | 默认值 |
|---|---|---|
endpoint | MCPD_ENDPOINT | http://localhost:4317 |
api_key | MCPD_API_KEY | 无 |
project | MCPD_PROJECT | default |
environment | MCPD_ENVIRONMENT | 无 |
service_name | MCPD_SERVICE_NAME | 无 |
service_version | MCPD_SERVICE_VERSION | 无 |
sample | MCPD_SAMPLE | 1.0 |
enabled | MCPD_ENABLED | true |
capture_payloads | MCPD_CAPTURE_PAYLOADS | true |
log_level | MCPD_LOG_LEVEL | debug |
batch_size | MCPD_BATCH_SIZE | 128 |
flush_interval | MCPD_FLUSH_INTERVAL_MS | 2.0 秒 |
max_queue | MCPD_MAX_QUEUE | 8192 |
timeout | MCPD_TIMEOUT_MS | 10.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?")) 出来的就是这棵树,时间和载荷都挂在上面。
- agent support_agent fail
- model chat
- tool returns_policy
- model chat
- tool returns_policy
- model chat
- 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}")