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
什么都不阻塞,什么都不抛出
记录一个 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