本文へスキップ
mcpd
デーモンなし
ソース

Kotlin

コルーチンが前提です。アクティブなスパンはコルーチンコンテキストに入り、キャンセルは失敗ではなくキャンセルとして記録され、ここから例外がエージェントに飛ぶことはありません。

インストール

Kotlin 2.4 でビルドされ、JVM 17 以降で動きます。依存は kotlinx の coroutines と serialization だけです。HTTP は JDK 自身のクライアントを使うので、 HTTP について既に方針のあるサービスに、何かを足すことはありません。

build.gradle.kts

dependencies {
    implementation("dev.mcpd:mcpd-kotlin:0.1.0")
}

クライアントの構築

インスタンスは 1 プロセスに 1 つです。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 にすると、記録の呼び出しはすべて何もしなくなり ますが、返るスパンは有効なままです。テスト対象のコードに触れずに、テストの あいだ mcpd を止められます。読み出しは、それでもサーバーに届きます。クエリは 呼び出し側が待っているものであり、空の答えを返すのは嘘になるからです。

実行の記録

span が Kotlin らしい書き方です。スパンを終了し、ブロックが呼ぶ すべての処理に対してそれをアクティブなスパンにし、ブロックが返した値を そのまま返します。ブロックが例外を投げた場合は、その例外を持つ error のスパンが残り、例外はそのまま再送出されます。

スコープ付きの書き方

// ブロックの値がそのまま span の値になるので、計装のためにまわりのコードを
// 組み替える必要はない。
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
}

キャンセルされたブロックは別扱いで、記録も変わります。呼び出し側やタイムアウトに よって打ち切られたコルーチンは、スパンを error ではなく cancelled として終了します。意図して打ち切ったことは処理の失敗 ではないので、トレースの一覧で失敗のように見えてはいけないからです。

実行の記録

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
}

ブロックの形が合わないときは、スパンの開始と終了を自分で書きます。

ブロックを使わない場合

val run = mcpd.trace("nightly_reindex", SpanKind.WORKFLOW_RUN, tags = listOf("batch"))
try {
    reindex()
    run.end()
} catch (e: Exception) {
    run.setError(e)
    run.end()
}

コンテキスト

アクティブなスパンはコルーチンコンテキストの要素なので、スレッドではなく コルーチンに付いていきます。何層か下の suspend 関数でも、呼び出し側から 渡されずに正しい親の子を開けますし、同じディスパッチャーで動く 2 つの実行が 互いのスパンを見ることもありません。読むには mcpd.currentSpan()、 そのヘッダーを取るには mcpd.traceparent() を使います。

入れ子

mcpd.span("support_agent", SpanKind.AGENT_RUN) { run ->
    // draft には何も渡していない。何段 suspend を下っても、コルーチン
    // コンテキストから実行を見つける。
    run.setOutput(draft(question))
}

suspend fun draft(question: String): String =
    mcpd.span("draft_answer", SpanKind.MODEL_GENERATION) { generation ->
        generation.setInput(question)
        "..."
    }

サービスの間では、コンテキストは W3C の traceparent ヘッダーで 運ばれます。ヘッダーはサンプリングの判断も一緒に運ぶので、トレースが半分だけ 記録されることはありません。形式の壊れたヘッダーは、それを運んできた リクエストを失敗させるのではなく、新しいトレースを始めます。

サービス境界をまたぐ

// 送信側: ヘッダーを次のサービスに渡す。
request.header("traceparent", span.traceparent())

// 受信側: 新しいトレースを始めず、そのトレースを続ける。
mcpd.span("checkout", traceparent = headers["traceparent"]) { span ->
    charge(span)
}

モデル、使用量、エラー

mcpd は取り込み時にトークン数をコストに変えるので、トレースに出る数字は実際に 請求された数字になります。エラーを記録すると、同時にスパンのステータスも error になり、メッセージは必ず入ります。「何かが失敗した」では、 打つ手が決まりません。

スパンが持つもの

generation.setModel("gpt-4o", provider = "openai")
generation.setUsage(inputTokens = 1_180, outputTokens = 96)

// setError はすでに持っている throwable を取るので、メッセージ、クラス、
// スタックはすべてそこから取れる。
tool.setError(e, retryable = true)

// あるいはメッセージを直接。どちらにしてもメッセージは必須。
tool.setError("the shipping service returned 503")

そのほかは setInput、setOutput、 setMetadata、setAttribute、 setAttributes、setTags、 setCorrelation、addLink と、明示的に子を作る child、時点を記録する event です。どれもどの スレッドから呼んでも安全で、例外を投げるものはありません。別のトレーサーの ツリーに属するスパンは、externalParentSpanId でそれを指します。 これは相関のために運ばれるだけで、mcpd 自身のツリーの中で親として使われる ことはありません。

ペイロード、シンク、完了済みスパン

capturePayloads = false にすると、input と output はプロセスの中に留まります。レコードがキューに入る前に 捨てられるので、フラッシュで機密が外に出ることはありません。個々のスパンは、 自分の分だけこれを上書きできます。エクスポーターは、すべてのバッチを Sink に渡します。既定は HTTP トランスポート、テスト用に バッチをメモリに保持する RecordingSink、シグナル 1 件につき JSON を 1 行書く ConsoleSink があります。例外を投げるシンクは バッチの失敗として 1 間隔につき 1 回報告されるだけで、呼び出し側に例外が 届くことはありません。

取得、記録、検証

// プロンプトと補完はプロセスの中に留める。スパンは所要時間、ステータス、
// 使用量、モデル、相関、エラーをそのまま持つ。
val quiet = Mcpd(McpdConfig(capturePayloads = false))
quiet.span("draft_answer", input = question, capturePayloads = true) { }  // これだけは残す

// 別のトレーサーがすでに終了させたスパン。渡されたままキューに入り、親の
// 付け替えも、時刻の打ち直しも、サンプリングのやり直しもしない。形式の壊れた
// id は捨てられて数えられるだけで、例外にはならない。
mcpd.record(finishedSpan)

// サーバーなしで、記録された内容を確かめる。
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 が定義を 組み立て、ステップの本体を suspend するラムダとして保持します。 Worker がそれを登録し、リースをロングポーリングで受け取って 1 件ずつコルーチンの上で処理します。startRun、 watchRun、publishEvent などはクライアントの拡張関数で、 実行の API を 1 対 1 で写しています。本体は RetryableException を 投げるとステップの予算の範囲でリトライされ、StepContext を通して 自分のステップの下にスパンを記録します。このコンテキストから開いたスパンは、 終了するたびに使用量をステップの使用量に足し込むので、モデルの呼び出しを 記録している本体がトークンを手で合計することはありません。 setUsage は、スパンが合計した値を上書きします。 Worker は onError のほかに 4 つのフック (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 は ワークフロー、 ワーカー、 実行のページにあります。

スコアとフィードバック

min と max は必須のパラメーターです。7 は 10 点満点 なら優秀で、100 点満点なら低い点です。スケールの宣言がない数値は、何とも 比べられません。フィードバックは人の判断であり、スコアとは別に保たれます。 2 つは別の問いに答えるものなので、まとめて平均してはいけません。

スコアとフィードバック

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

ログとメトリクスはアクティブなスパンに付くので、チャート上のスパイクから、 それを起こした実行を開けます。

フラッシュとシャットダウン

記録は、上限のあるメモリ上のキューに追加して戻ります。バッチにまとめ、圧縮して 送るのはワーカーのコルーチンです。flush() はキューにあるものを 送って待ちます。shutdown() はワーカーを止め、残っているものを 送ります。2 回呼んでも問題ありません。最後のエクスポートはキャンセル不能で 動きます。シャットダウンにたどり着くのは、たいていキャンセル中の finally からであり、最後のバッチこそが、なぜこのプロセスが消える のかを説明するものだからです。

Mcpd は AutoCloseable なので、use でも シャットダウンフックでも構いません。どちらも呼ばないサーバーレス関数や短命な ワーカーは、最後のバッチを失います。ハンドラーが戻った瞬間に、ランタイムが スケジューリングを止めるからです。

シャットダウン

// JVM のサービス: 終了時にシャットダウンする。
Runtime.getRuntime().addShutdownHook(Thread { mcpd.close() })

// 短命なワーカー: use を使えば、閉じ括弧でシャットダウンされる。
Mcpd(McpdConfig(project = "batch")).use { mcpd ->
    mcpd.span("reindex") { run -> reindex(run) }
}

返ってくるもの

読み出しのメソッドは HTTP API を 1 対 1 で写したもので、生成された型を返します。 例外を投げるのはこれらのメソッドだけで、投げるのは 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 秒に 多くても 1 回報告され、あとは飲み込まれます。可観測性が、観測している相手を 落としてはいけません。
  • 記録はブロックしないので、サービスのレイテンシが、mcpd の速さや、mcpd が 動いているかどうかに左右されることはありません。
  • メモリには上限があります。キューが保持するシグナルは maxQueue 件で、それを超えると古いものから捨てられ、数えられます。その数は droppedSignals と stats() で読めます。上限のない バッファは、ネットワーク障害をメモリ不足による強制終了に変えてしまいます。
  • サンプリングの判断はルートで一度だけ行われ、以降は引き継がれます。
  • スパンは開始時と終了時の 2 回エクスポートされるので、途中でクラッシュした 実行も見えます。開始のレコードが送られる前にスパンが終了した場合は、その レコードがその場で更新され、送られるのは 1 件だけです。
  • バッチは 1 KiB を超えると gzip で圧縮され、リトライしても変わらない冪等キーを 持ちます。サーバーが保存したあとにタイムアウトしたリクエストが、二重に数え られることはありません。
  • 所要時間は単調増加のクロックで測るので、NTP による時刻の飛びで負の値になる ことはありません。

実行を探すに、フィルターの 全体があります。データモデル には、これらの型が持つすべてのフィールドが並んでいます。