Kotlin
以协程为先。活动的 span 存在协程上下文里,取消被记成取消而不是失败,而这里没有任何东西会朝你的智能体抛异常。
安装
使用 Kotlin 2.4 构建,需要 JVM 17 及以上。依赖只有 kotlinx 的 coroutines 和 serialization。HTTP 用的是 JDK 自带的客户端,所以对一个已经对 HTTP 有主张的服务来说,什么都不会多加。
build.gradle.kts
dependencies {
implementation("dev.mcpd:mcpd-kotlin:0.1.0")
} 构造客户端
一个进程一个实例。McpdConfig 的每个字段都以一个环境变量作为
默认值,也都可以在代码里设置,所以同一份代码不用改一个字,就能在开发环境和
生产环境里跑。不带参数的 Mcpd() 对着一个本地的 mcpd 就能用。
连接
// Every field also reads an environment variable (MCPD_ENDPOINT,
// MCPD_PROJECT, MCPD_API_KEY, ...), so `Mcpd()` on its own works too.
val mcpd = Mcpd(
McpdConfig(
endpoint = "http://localhost:4317",
project = "support-desk",
environment = "development",
serviceName = "support-agent",
serviceVersion = "1.4.0",
)
) 代码优先于环境变量,环境变量优先于默认值。
| 字段 | 环境变量 | 默认值 |
|---|---|---|
endpoint | MCPD_ENDPOINT | http://localhost:4317 |
apiKey | MCPD_API_KEY | 无 |
project | MCPD_PROJECT | default |
environment | MCPD_ENVIRONMENT | 无 |
serviceName | MCPD_SERVICE_NAME | 无 |
serviceVersion | MCPD_SERVICE_VERSION | 无 |
sample | MCPD_SAMPLE | 1.0 |
enabled | MCPD_ENABLED | true |
capturePayloads | MCPD_CAPTURE_PAYLOADS | true |
logLevel | MCPD_LOG_LEVEL | debug |
batchSize | MCPD_BATCH_SIZE | 128 |
flushInterval | MCPD_FLUSH_INTERVAL_MS | 2s |
maxQueue | MCPD_MAX_QUEUE | 8192 |
timeout | MCPD_TIMEOUT_MS | 10s |
enabled = false 让每次记录调用都变成空操作,但仍然返回合法的
span,所以在测试里可以把 mcpd 关掉,而不用碰被测代码。读取仍然走服务器:
一次查询是调用方正等着的东西,给它一个空答案就是在骗人。
记录一次运行
span 是地道的那种写法。它会结束这个 span,让它对块里调用的一切
都是活动的 span,并把块返回的东西返回出来。抛异常的块会留下一个带着这个异常
的 error span,并把异常原样再抛出去。
带作用域的写法
// The block's value is the value of span, so instrumentation does not force
// you to restructure the code around it.
val answer = mcpd.span("retrieve_context", SpanKind.RETRIEVAL) { span ->
span.setInput(query)
val docs = index.search(query)
span.setOutput(docs.map { it.id })
span.setAttribute("retrieval.hits", docs.size)
docs
}
被取消的块不一样,记法也不一样。一个被自己的调用方或者被超时放弃掉的协程,
结束时这个 span 是 cancelled 而不是 error,因为
一次有意的放弃并不是这个操作的失败,在 trace 列表里也绝不该读成失败。
记录一次运行
val question = "where is my order 41?"
// `span` starts the operation, ends it, and records anything it throws.
// Inside the block the span is the active one, so the helpers below find
// their parent through the coroutine context rather than being handed it.
val traceId = mcpd.span(
"support_agent",
SpanKind.AGENT_RUN,
input = question,
sessionId = "session-8f21",
userId = "customer-4477",
tags = listOf("support", "email"),
) { run ->
mcpd.log(LogLevel.INFO, "handling a question", mapOf("channel" to "email"))
val plan = planAnswer(mcpd, question)
val order = lookUpOrder(mcpd, 41)
val estimate = estimateDelivery(mcpd, 41)
val answer = buildString {
append(plan).append(' ').append(order)
if (estimate != null) append(" It should arrive ").append(estimate).append('.')
}
run.setOutput(answer)
mcpd.metric("support.answers", 1.0, MetricKind.COUNTER, unit = "{answer}")
// The block's value is the value of `span`.
run.traceId
} 块这个形状不合适时,请自己开始和结束这个 span。
不用块
val run = mcpd.trace("nightly_reindex", SpanKind.WORKFLOW_RUN, tags = listOf("batch"))
try {
reindex()
run.end()
} catch (e: Exception) {
run.setError(e)
run.end()
} 上下文
活动的 span 是协程上下文的一个元素,所以它跟着协程走,而不是跟着线程走。
往下好几层的一个挂起函数,不用哪个调用方把父级传给它,就能开出挂在正确父级
下面的子 span;跑在同一个调度器上的两次运行,也永远看不到对方的 span。用
mcpd.currentSpan() 读它,用 mcpd.traceparent()
拿它的请求头。
嵌套
mcpd.span("support_agent", SpanKind.AGENT_RUN) { run ->
// draft is handed nothing. It finds the run through the coroutine context,
// however many suspend calls down it is.
run.setOutput(draft(question))
}
suspend fun draft(question: String): String =
mcpd.span("draft_answer", SpanKind.MODEL_GENERATION) { generation ->
generation.setInput(question)
"..."
}
跨服务时,上下文走 W3C traceparent 请求头,它把采样决定一起
带着走,所以一条 trace 绝不会只记了一半。格式不对的头会开一条新的 trace,
而不是让携带它的那个请求失败。
跨过服务边界
// Outgoing: hand the header to the next service.
request.header("traceparent", span.traceparent())
// Incoming: continue that trace instead of starting a new one.
mcpd.span("checkout", traceparent = headers["traceparent"]) { span ->
charge(span)
} 模型、用量与错误
mcpd 在摄取时把 token 数换算成成本,所以 trace 上的那个数字就是你被计费的
那个数字。记录一个错误的同时会把这个 span 的状态设成 error,
而且一定带着一条消息:“出了点问题”没法拿来做任何事。
一个 span 带着什么
generation.setModel("gpt-4o", provider = "openai")
generation.setUsage(inputTokens = 1_180, outputTokens = 96)
// setError takes the throwable you are already holding, so the message, the
// class and the stack all come off it.
tool.setError(e, retryable = true)
// Or a message directly, which is required either way.
tool.setError("the shipping service returned 503")
其余的是 setInput、setOutput、
setMetadata、setAttribute、
setAttributes、setTags、
setCorrelation 和 addLink,另外还有开一个显式子
span 的 child,以及记一个时间点的 event。它们在
任何线程上调用都是安全的,而且都不抛异常。属于另一个 tracer 的树的 span,
用 externalParentSpanId 指出它来,这个值只带着用于关联,绝不会
在 mcpd 自己的树里当作父级用。
载荷、sink 与已经结束的 span
capturePayloads = false 把 input 和
output 留在进程里:它们在这条记录进入队列之前就被丢掉,所以
一次刷出不会带走任何敏感内容,而单个 span 可以为自己覆盖这个设置。导出器
把每个批次交给一个 Sink:默认是 HTTP 传输层,也可以是把批次
留在内存里供测试用的 RecordingSink,或者每个信号写一行 JSON
的 ConsoleSink。抛异常的 sink 算一个失败的批次,每个间隔上报
一次,绝不会朝调用方抛出。
捕获、记录、断言
// Keep prompts and completions in the process: the span still carries its
// timing, status, usage, model, correlation and errors.
val quiet = Mcpd(McpdConfig(capturePayloads = false))
quiet.span("draft_answer", input = question, capturePayloads = true) { } // this one, kept
// A span another tracer already finished, queued as given: never re-parented,
// re-timed or re-sampled. A malformed id is dropped and counted, never thrown.
mcpd.record(finishedSpan)
// Prove what was recorded without a server.
val recorder = RecordingSink()
val local = Mcpd(McpdConfig(sink = recorder))
local.span("support_agent") { run -> run.setUsage(inputTokens = 12, outputTokens = 3) }
local.flush()
check(recorder.spans.single().usage?.inputTokens == 12L)
// queued, dropped, capacity, exported, failed, rejected.
val stats = local.stats() 持久化运行
同一个客户端也服务守护进程的持久化运行。Workflow 构建定义,
并把那些步骤主体作为挂起 lambda 持有;一个 Worker 注册它,用
长轮询要租约,并在一个协程上逐份服务,而 startRun、
watchRun、publishEvent 以及其余的,都是客户端上的
扩展函数,与运行接口一一对应。步骤主体抛出 RetryableException
表示要在这个步骤的预算内重试,并通过 StepContext 把 span 记在
自己这个步骤下面。通过这个上下文开出来的每一个 span,结束时都会把自己的
用量加到这个步骤上,所以一个会记录自己模型调用的步骤主体,从不用手工去加
token;setUsage 会覆盖这些 span 累加出来的结果。一个
Worker 在 onError 之外还接受四个可选钩子
(onLease、onStepStart、onStepEnd、
onConflict),并回答 stats():领取数、正在跑的
步骤主体数、完成数、按类别分的失败数、按原因分的冲突数,以及服务不了的租约。
一个工作流
val support = Workflow("support_ticket")
.step("classify") { ctx ->
val ticket = ctx.input<JsonObject>()["ticket"]!!.jsonPrimitive.content
mapOf("kind" to if (ticket.startsWith("Q")) "question" else "bug")
}
.agent("draft", StepOptions(input = mapOf("kind" to "\${steps.classify.output.kind}"))) {
ctx ->
// Your own model call, recorded under this step in the run's trace.
// Its usage is summed into the step, so the daemon prices the run.
ctx.span("model_call", SpanKind.MODEL_GENERATION) { span ->
span.setModel("claude-sonnet-4", "anthropic")
span.setUsage(inputTokens = 812, outputTokens = 37)
}
"an answer to a " + ctx.input<JsonObject>()["kind"]!!.jsonPrimitive.content
}
.waitFor(
"approval", "approved", "\${input.ticket}", 7.days,
StepOptions(guard = predicate("steps.classify.output.kind", "eq", "bug")),
)
.step(
"send",
StepOptions(
input = mapOf("text" to "\${steps.draft.output}"),
effect = Effect.AT_MOST_ONCE,
retry = RetryPolicy(
maxAttempts = 3, backoff = Backoff.EXPONENTIAL, initial = 500.milliseconds,
),
),
) { ctx ->
val text = ctx.input<JsonObject>()["text"]!!.jsonPrimitive.content
if (!deliver(text, ctx.idempotencyKey)) {
throw RetryableException("mail relay busy", retryAfter = 2.seconds)
}
mapOf("sent" to true)
}
.output(mapOf("sent" to "\${steps.send.output.sent}")) 每种构造做什么、租约协议和运行 API,在 工作流、 Worker 和 运行这几页上。
评分与反馈
min 和 max 是必填参数。七分,满分十分是优秀,
满分一百分是很差,所以一个没有声明量程的数字没法和任何东西比较。反馈是人的
判断,和评分分开存放,因为这两者回答的是不同的问题,绝不能被平均到一起。
评分与反馈
// 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(
traceId = traceId,
scorerId = "answer_relevance",
value = 0.86,
min = 0.0,
max = 1.0,
reason = "answered the question asked, without the delivery estimate",
)
// A person's judgement, from the thumbs-up control in your own product.
mcpd.feedback(
traceId = traceId,
kind = FeedbackKind.THUMBS,
value = "up",
comment = "fast and clear",
authorId = "customer-4477",
)
// Nothing above touched the network. This is where it goes.
mcpd.flush() 日志和指标会挂到活动的那个 span 上,所以图上的一个尖峰能打开引起它的那次 运行。
刷出与关闭
记录就是往内存里一个有上限的队列追加一条然后返回;一个 worker 协程负责
攒批、压缩和发送。flush() 把排着队的东西发出去,并等它发完。
shutdown() 停掉 worker,把剩下的发出去,而且调两次也是安全的。
最后这次导出是不可取消的,因为关闭通常是在取消的过程中从一个
finally 里走到的,而最后那个批次,恰恰是解释这个进程为什么要走
的那一个。
Mcpd 是 AutoCloseable 的,所以 use
和一个关闭钩子都管用。一个两者都不调的 serverless 函数,或者一个短命的
worker,会丢掉自己最后那个批次,因为处理函数一返回,运行时就不再调度了。
关闭
// A JVM service: shut down on the way out.
Runtime.getRuntime().addShutdownHook(Thread { mcpd.close() })
// A short-lived worker: use, and it shuts down at the closing brace.
Mcpd(McpdConfig(project = "batch")).use { mcpd ->
mcpd.span("reindex") { run -> reindex(run) }
} 你拿回来的东西
读取方法与 HTTP API 一一对应,返回由规范生成的类型。它们是唯一会抛异常的
一组方法,抛的是 McpdException:一次你正等着结果的查询,不是
一个可以吞掉的东西。
$ gradle run> Task :runsupport_agent: 4 spans, ok agent_run support_agent 79ms model_generation plan_answer 41ms tool_call lookup_order 16ms tool_call estimate_delivery 11ms shipping service returned 503 for order 41 检索与分析
val detail = mcpd.getTrace(traceId, include = listOf("scores", "feedback"))
println("${detail.trace.name}: ${detail.spans.size} spans, ${detail.trace.status}")
for (span in detail.spans) {
val ms = (span.durationNs ?: 0) / 1_000_000
println(" ${span.kind} ${span.name} ${ms}ms ${span.error?.message ?: ""}")
}
val summary = mcpd.analyticsSummary(
from = Clock.System.now() - 1.hours,
to = Clock.System.now(),
)
println(
"last hour: ${summary.traceCount} runs, " +
"${summary.errorCount} failed, ${summary.totalCost} USD",
) 你可以依赖的东西
-
接入代码从不抛异常。一次失败的导出、一个不可达的服务器,或者一个序列化
不了的值,都会通过
java.util.logging每 30 秒最多上报一次, 其余时候被吞掉。可观测性绝不能把它观测的那个东西搞垮。 - 记录不阻塞,所以你的服务的延迟,从不取决于 mcpd 快不快,或者活没活着。
-
内存有上限。队列最多放
maxQueue个信号;超过之后最旧的会被 丢弃并计数,可以从droppedSignals和stats()里读到。一个没有上限的缓冲区,会把一次网络故障变成一次内存耗尽被杀。 - 采样决定只在根上做一次,然后往下继承。
- 一个 span 在开始时导出一次,在结束时再导出一次,所以一次跑到半路崩掉的 运行仍然是看得见的。当一个 span 在它的开始记录发出去之前就结束时,这条 记录会被就地更新,只发出去一条。
- 超过 1 KiB 的批次会用 gzip 压缩,并带一个能挺过重试的幂等键,所以一个在 服务器已经存下之后才超时的请求,不会被重复计数。
- 耗时是用一个单调时钟量出来的,所以一次 NTP 跳变量不出一个负数。