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

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

代码优先于环境变量,环境变量优先于默认值。

字段环境变量默认值
endpointMCPD_ENDPOINThttp://localhost:4317
apiKeyMCPD_API_KEY无
projectMCPD_PROJECTdefault
environmentMCPD_ENVIRONMENT无
serviceNameMCPD_SERVICE_NAME无
serviceVersionMCPD_SERVICE_VERSION无
sampleMCPD_SAMPLE1.0
enabledMCPD_ENABLEDtrue
capturePayloadsMCPD_CAPTURE_PAYLOADStrue
logLevelMCPD_LOG_LEVELdebug
batchSizeMCPD_BATCH_SIZE128
flushIntervalMCPD_FLUSH_INTERVAL_MS2s
maxQueueMCPD_MAX_QUEUE8192
timeoutMCPD_TIMEOUT_MS10s

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 跳变量不出一个负数。

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