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

Elixir

worker 用一个 GenServer,上下文靠进程,而你的依赖树里不会多出任何东西。

安装

把它加到 mix.exs 里。它不会带来别的任何东西。

defp deps do
  [{:mcpd, "~> 0.1"}]
end

这就是全部的依赖列表,而且是有意为之。OTP 27 自带一个 JSON 编解码器 (:json)和一个 HTTP 客户端(:inets),所以一个 以观察你的系统为职责的库,不必成为你的系统构建发生变化的原因。另外五个 SDK 在各自的语言里做的是同样的承诺。

构造客户端

每个选项都回退到它对应的环境变量,而代码优先。不带参数的 Mcpd.start_link() 和一个本地的守护进程通信。

连接

# Every option also reads an environment variable (MCPD_ENDPOINT, MCPD_PROJECT,
# MCPD_API_KEY, ...), so `Mcpd.start_link()` on its own works too.
{:ok, mcpd} =
  Mcpd.start_link(
    endpoint: "http://localhost:4317",
    project: "support-desk",
    environment: "development",
    service_name: "support-agent",
    service_version: "1.4.0"
  )

把它放进你的监督树里,它的行为和其他任何一个 worker 一样:

children = [
  {Mcpd, project: "support-desk", environment: "production"}
]

Supervisor.start_link(children, strategy: :one_for_one)

记录一次运行

span 是一个宏,所以一次运行就是一个块,而它的值就是这个块的值。

记录一次运行

question = "where is my order 41?"

# The block's value is the value of `span`. Inside it, this span is the active
# one for the process, so the helpers below find their parent without being
# handed it.
trace_id =
  Mcpd.span mcpd, "support_agent",
    kind: :agent_run,
    input: question,
    session_id: "session-8f21",
    user_id: "customer-4477",
    tags: ["support", "email"] do
    Mcpd.log(mcpd, :info, "handling a question", %{"channel" => "email"})

    plan = plan_answer.(mcpd, question)
    order = look_up_order.(mcpd, 41)
    estimate = estimate_delivery.(mcpd, 41)

    _answer =
      [plan, order, estimate && "It should arrive #{estimate}."]
      |> Enum.reject(&is_nil/1)
      |> Enum.join(" ")

    Mcpd.metric(mcpd, "support.answers", 1, kind: :counter, unit: "{answer}")
    Mcpd.Context.current().trace_id
  end

宏必须先 require 才能调用,这是这门语言的规则,不是这里的选择。 use Mcpd 会替你做这件事:

defmodule MyAgent do
  use Mcpd

  def answer(mcpd, question) do
    Mcpd.span mcpd, "agent", kind: :agent_run, input: question do
      do_the_work(question)
    end
  end
end

如果主体本来就是一个函数而不是一个块,请改调 Mcpd.with_span/4。它是同一件事,只是不用宏。

这棵树自己拼起来

活动的 span 存在进程字典里,而在 Elixir 里,这恰恰是对的存放处,不是一条 捷径。在这里,进程就是并发的单位,所以同时处理的两个请求看不到对方的 span, 也不需要把什么东西一路穿过每一个函数,才能把这棵树保持在一起。

在往下三层调用的地方开出来的一个子 span,自己就能找到它的父级:

Mcpd.span mcpd, "agent", kind: :agent_run do
  plan(mcpd)          # opens a child of "agent" without being told
end

defp plan(mcpd) do
  Mcpd.span mcpd, "plan_answer", kind: :model_generation, model: "gpt-4o" do
    call_the_model()
  end
end

有一个后果值得知道:在 Task.async 里面开的 span 会开一条新的 trace,因为那是另一个进程。请用 Mcpd.traceparent/0 和 Mcpd.continue/2 把这条 trace 递过去,这一对也正是你跨越一个 HTTP 边界时会用的。

  • Spans

    What ran, nested, with timings

  • Logs

    Lines the run emitted

  • Metrics

    Numbers, with exemplars back to a run

  • Scores

    Machine evaluation

  • Feedback

    What a person thought

  • Aspect notes

    A sentence a model wrote about the run

trace_id Store Search Export

什么都不阻塞,什么都不抛出

记录一个 span,就是给 worker 发一条消息然后返回。网络工作发生在那个 worker 上,所以你的智能体的延迟,从不取决于 mcpd 快不快、活没活着,或者压根有没有 在跑。

块里抛出的异常,会作为一个错误记在这个 span 上,然后原样再抛出去。mcpd 的 职责是看见发生了什么,绝不是替你的代码决定要拿它怎么办。

队列有上限,装满时最旧的信号会被丢弃。这个方向是选出来的:守护进程不可达 时,最新的那些信号讲的是现在正在发生什么,而等它回来之后,有人想要的正是 这些。Mcpd.stats/1 回答这个客户端做过什么: queued、dropped、capacity、 exported、failed 和 rejected。

log_level:,或者 MCPD_LOG_LEVEL,为 Mcpd.log/4 设一个下限。默认是 debug,也就是全都 留下。低于这个下限的一行,会在记录被构造出来之前就丢掉,所以在一个生产部署 里,一行 debug 日志只花一次比较。这个下限只管日志;span、指标、评分和反馈 都不受影响。

把载荷留在进程里

capture_payloads: false,或者 MCPD_CAPTURE_PAYLOADS=false,把提示词和补全留在原地。span 照样发出去,带着它的时间、状态、用量、模型、关联信息和错误; input:、output: 以及一个块捕获到的值,会在进入 队列之前就被丢掉,所以没有敏感内容会排进队列,一次刷出也带不走任何敏感 内容。单个 span 可以用 capture_payloads: true 说自己不一样。

断言你记下了什么

sink 就是一个批次被排空之后去的地方。默认是 HTTP 传输层; Mcpd.Sink.Recorder 把每个批次留在内存里,于是一个测试不需要 守护进程,就能把接入代码记下的 span、日志、指标、评分和反馈读回来,而 Mcpd.Sink.Console 把每个信号打印成一行 JSON 到标准错误。你 自己的 sink 实现 Mcpd.Sink 就可以;抛异常的 sink 算一个失败的 批次,会被计数、被上报,绝不会抛到调用方那里。

Mcpd.record/3 把一个已经结束的 span 按给的样子排进队列:它 自己的 id、父级、开始和结束时间,绝不重新指定父级、重新计时或者重新采样。 这正是另一套 tracing 系统的适配器,或者一次批量导入手上有的那些东西。id 不是 OpenTelemetry 那个形状的记录会被拒绝并计数,而且绝不抛出。任何 span 上的 external_parent_span_id: 携带的都是一个来自非 mcpd 的 tracing 系统的父级,只用于关联。

持久化运行

同一个客户端也服务守护进程的持久化运行。Mcpd.Workflow 构建 定义,并把那些步骤主体作为三元函数持有;一个 Mcpd.StepWorker 注册它,用长轮询要租约,并在各自的一个进程上逐份服务,而 Mcpd.start_run/3、Mcpd.watch_run/3、 Mcpd.publish_event/4 以及其余的,与运行接口一一对应。步骤 主体抛出带 class: :retryable 的 Mcpd.StepException,表示要在这个步骤的预算内重试,并通过 Mcpd.StepContext.span/4 把 span 记在自己这个步骤下面。步骤 主体关掉的每一个 span,都会把自己的用量加到这个步骤上,看到的最后一个模型 胜出,所以守护进程按 trace 显示出来的东西定价;set_usage/2 是那个显式的覆盖。worker 上,on_lease、 on_step_start、on_step_end 和 on_conflict 就在 on_error 旁边,而 StepWorker.stats/1 统计它领取了多少、完成了多少、按类别分 失败了多少,以及按原因分放弃了多少。

一个工作流

support =
  Workflow.new("support_ticket")
  |> Workflow.step("classify", fn _ctx, input, _prior ->
    kind = if String.starts_with?(input["ticket"], "Q"), do: "question", else: "bug"
    %{"kind" => kind}
  end)
  |> Workflow.agent(
    "draft",
    fn ctx, input, _prior ->
      # Your own model call, recorded under this step in the run's trace. Its
      # usage is the step's: the daemon prices what the trace shows.
      opts = [kind: :model_generation, model: "claude-sonnet-4", provider: "anthropic",
        usage: %{"input_tokens" => 812, "output_tokens" => 37}]
      StepContext.span(ctx, "model_call", opts, fn -> :ok end)

      "an answer to a #{input["kind"]}"
    end,
    input: %{"kind" => "${steps.classify.output.kind}"}
  )
  |> Workflow.wait_for("approval",
    event: "approved",
    key: "${input.ticket}",
    timeout_ms: 7 * 24 * 3_600_000,
    when: where("steps.classify.output.kind", :eq, "bug")
  )
  |> Workflow.step(
    "send",
    fn ctx, input, _prior ->
      unless deliver.(input["text"], ctx.idempotency_key) do
        raise Mcpd.StepException,
          message: "mail relay busy", class: :retryable, retry_after_ms: 2_000
      end

      %{"sent" => true}
    end,
    input: %{"text" => "${steps.draft.output}"},
    effect: :at_most_once,
    retry: [max_attempts: 3, backoff: :exponential, initial_ms: 500]
  )
  |> Workflow.output(%{"sent" => "${steps.send.output.sent}"})

每种构造做什么、租约协议和运行 API,在 工作流、 Worker 和 运行这几页上。

评分与反馈

评分与反馈

# 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(mcpd, trace_id, "answer_relevance", 0.86,
  min: 0,
  max: 1,
  reason: "answered the question asked, without the delivery estimate"
)

# A person's judgement, from the thumbs-up control in your own product.
Mcpd.feedback(mcpd, trace_id, :thumbs, "up",
  comment: "fast and clear",
  author_id: "customer-4477"
)

# Nothing above touched the network. This is where it goes.
:ok = Mcpd.flush(mcpd)

把它读回来

读这次运行

{:ok, detail} = Mcpd.get_trace(mcpd, trace_id, include: ["scores", "feedback"])

IO.puts("#{detail.trace.name}: #{length(detail.spans)} spans")

for span <- detail.spans do
  ms = div(span.duration_ns || 0, 1_000_000)
  error = (span.error && span.error.message) || ""
  IO.puts("  #{span.kind} #{span.name} #{ms}ms #{error}")
end

短命的进程必须刷出

一个很快就退出的脚本或者任务,会丢掉所有还没发出去的东西。 Mcpd.shutdown/1 先刷出,再停掉 worker,而且调两次也是安全的。

对着一个本地的守护进程跑这个示例

mcpd serve --data ./mcpd.db elixir examples/elixir/quickstart.exs

接下来