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

查找一次运行

一次排查是从一条抱怨开始的,不是从一块仪表盘开始的。这一页讲的是你怎么把一条抱怨变成引起它的那一次运行。

搜索载荷,而不是标签

多数追踪工具只索引名称和属性。mcpd 把提示词和补全也一起索引,因为人抱怨的 是模型说过的某句话,而他们回敬给你的那几个词,是你手上唯一的抓手。 q 参数就是这个索引。

Search
shipped on Tuesdayscore_below=0.5min_duration_ms=5000model=gpt-4o AND status=error
  • 3 hits the run whose completion said it
  • 12 hits every answer a scorer marked down
  • 7 hits runs slower than five seconds
  • 2 hits failures on one model

q 到底覆盖什么

索引为每个 span 保存三个字段:它的名称、它的输入和它的输出。输入和输出都是 JSON,所以在建索引之前,mcpd 会走一遍载荷,只留下里面的字符串和数字,把 花括号、引号和键都丢掉。直接索引原始 JSON 会让每一个标点都变成一个词元, 把你真正要找的那些词埋起来。

在依赖它之前,有六个结论值得知道:

  • 只要一次运行里有任何一个 span 命中,这次运行就命中。你 拿回的是整次运行;打开它才看得到是哪一个步骤。
  • 多个词之间是 AND。q=refund delayed 找的是同时包含这两个词的 span,位置在被索引文本的任何地方,不要求彼此相邻。
  • 匹配按整词进行,不区分大小写,变音符号会被折叠,所以 resume 找得到 résumé。没有词干还原: refund 找不到 refunds。
  • 输入和输出各自的前 64 KiB 进入索引。更长的载荷会被完整存储和完整显示; 只有一个非常大的提示词的尾部搜不到。
  • 脱敏在存储之前进行,所以脱敏器去掉的东西也从来没有进过索引。参见 脱敏与保留期。
  • 一个被写了不止一次的 span,比如一个长时间运行的 span 在结束时那次,会替换 掉自己的索引行,所以搜索永远反映的是当前这个版本。

一个多余的引号是一次搜索,不是一个错误

底下的索引有它自己的查询语言:一个双引号开启一个短语,而 NEAR、 OR 和 * 都是运算符。把你的文本原样交给它,就意味着 一条带引号的客户消息换回来的是一个语法错误,而不是一份结果;而一个会抛错的 搜索框,是一个大家会不再用的搜索框。

所以你的文本根本到不了那个解析器。mcpd 按空白把你输入的东西切开,把每个词 里的双引号去掉,把每个词包成一个字面词项,再用 AND 连起来。 q=say "no" 搜的是 say 和 no 这两个词。 q=NEAR 搜的是 NEAR 这个词。你能输入的任何东西都不会是语法错误, 而你也用不上任何运算符。这是一笔交换,并且是有意朝这个方向做的。

只包含空白的 q 会被丢掉,而不是什么都匹配不上;空的 q= 被当作没有给。每一个参数都是这样,所以一个把空过滤框也提交 上来的表单,不会误按空字符串去过滤。

缩小一份运行列表

GET /v1/api/traces 接受下面这些。你没有给的,就不会被应用。

参数它选出什么
q 在每个 span 的名称、输入和输出上做全文检索。
status unset、ok、error 或 cancelled。只要一次运行里有任何一个 span 失败了,这次运行 就是 error,哪怕那个错误已经被接住了。
kind 这次运行的根 span 的类型,比如 agent_run 或 workflow_run。
name 根 span 的名称,完全相等。不是前缀,也不是模式。
tags 用逗号分隔。一次运行必须带上列出的每一个标签,而不是其中任意 一个。
session_id同一次会话里的每一次运行。
thread_id同一个对话线程里的每一次运行。
user_id同一个最终用户的每一次运行。
environment某一个环境,比如 production。
model 至少有一个 span 用了这个模型 id 的那些运行。
from、to 时间窗口,按这次运行的开始时间来比。
min_duration_ms 至少这么慢的运行,单位是毫秒。
min_cost 至少花了这么多钱的运行。一次没有任何 span 被定价的运行算作零,所以 任何正值都会把它排除掉。
has_score 带着这个评分器 id 给出的评分的那些运行。
score_below 和 has_score 一起用:那个评分器打到这个值以下的运行。
sort start_time、duration、cost、 span_count 或 score。默认是 start_time。score 按任何一个评分器给一次运行 的最低分排序,所以升序会把最差的那次运行排在最前;没有评分的运行按零 计。
order asc 或 desc。默认是 desc。
limit 每页大小,会被夹在 1 到 500 之间。默认是 50。
cursor 上一页给出的那个不透明游标。
project 要读哪个项目。默认是守护进程自己的默认项目,所以只有一个项目的安装 从来不用传它。

name、那些关联 id 和 environment 都是相等判断, 这也是它们便宜的原因。如果你想对名称做部分匹配,用 q:span 的名称也在文本索引里。

一行列表的代价

这里没有第二份更轻的运行列表,因为第一份已经足够轻了。存下来的 trace 投影 完全不带输入、不带输出、不带任何属性:只有名称、计数、时间、成本、标签和 关联信息,没有一句模型说过的话。打开一次运行,才是去取载荷的时候。

在一次真实的运行上量过:一个八步的客服智能体,检索三份政策文档,调用两个 工具,做两次带真实提示词和补全的模型调用。它的列表行是 577 字节,而整次运行是 10,717 字节, 减少了 94.6%。这是这一个守护进程、在那一次运行上、经过真实 HTTP 接口测出来的。你的运行会不一样,重要的是方向:提示词越大,省下的越多, 因为长大的那一部分正是列表不带的那一部分。

这个数字是由一个测试产生的,而不是手写在这里的,所以它不可能在一处过时而 另一处不失败。运行 cargo test -p mcpd-server --test payload_size -- --nocapture, 它会把两个字节数都打印出来。往投影里加一个带载荷的字段,那个测试就会失败。

其余的一律具名拒绝

那张表就是全部的词汇。不在表里的参数会以一个 400 被拒绝,并把它的名字说 出来,而不是接受了再丢掉。这一点变过:mcpd 从前会忽略它不认识的参数,于是 ?statuss=error 会以 200 返回项目里的每一次运行,而响应里没有 任何东西说明这个过滤条件没有生效。一个看着合理的错误答案比一个错误更糟, 因为没有东西让人察觉。

这里做的取舍值得点明。一个客户端发出较新的守护进程认识的过滤条件时,从较旧 的那个拿到的现在是 400,而不是一个更宽的结果集。这是两种失败里比较好的 那种:这个 400 会说出参数名,然后停下。

有些拒绝说的不止“未知”两个字。mcpd 在每一条 trace 上都记录 service_name、service_version、 resource_id、run_id、request_id、 experiment_id 和 release,但运行列表还不能按它们 过滤。这七个里,只有 service_name 是 发现接口的一个维度,所以 发现接口只会列出它的取值,其他几个的不会列。这七个会被拒绝,并附上一条说明 这件事的消息,因为 service_name=checkout-api 不是打错字,告诉 别人这是个未知参数,会让他们去找一个根本不存在的拼写错误。

这条规则不是运行列表独有的。GET /v1/api/spans、 /v1/api/logs、/v1/api/scores、 /v1/api/metrics 和 /v1/api/feedback 各自用同样的 方式守着自己的词汇。有两点值得在遇到之前就知道:评分列表不接受 order,因为它是最新的在前,而它的游标只朝一个方向比较;反馈 列表要求给出 trace_id,因为 mcpd 还不能列出整个项目的反馈, 而一份空列表读起来会像是一条反馈都没有。

把过滤框填起来

一个过滤条件只有在你知道该往里面填什么的时候才好用,而这些值不是一份固定的 清单:它们是向一个项目上报的那些服务恰好发过来的东西。 GET /v1/api/discovery 回答这个问题。用 facet 一次问一个维度,它会返回这个维度在这个项目里取过的那些值,按值排序,每个值 还带着最后一次有信号携带它的时间。

分面它列出什么
entity_kind智能体、工作流、工具、模型、评分器、处理器、流水线或服务。
entity_name这些东西的名字,比如被调用过的那些工具。
service_name上报过数据的那些服务。
environment见过的那些环境,比如 production。
tag任何一次运行带过的每一个标签。
metric_name记录过的指标名,包括 mcpd 自己推导出来的那些。
metric_label_key这个项目里见过的每一个标签键。
metric_label_value某一个标签的那些值。需要 key。

prefix 为输入联想缩小这份列表,它按字面匹配并且区分大小写, 因为这些值就是过滤条件将要拿去比对的东西,在这里做折叠会给出一个之后什么 都匹配不上的值。limit 把答案封顶在 1000。值是按值排序的,不按 时间新旧,所以有人正在读的一份列表,不会在两次轮询之间在他眼皮底下重排。

有一个限制值得在动手之前知道。metric_label_key 列的是这个项目 里任何地方见过的每一个标签键,而不是某一个指定指标的键:这份去重值列表是按 标签建的索引,不是按携带它的那个指标。

刷新一份列表,而不重新查一遍

GET /v1/api/deltas 返回自某个游标以来到达的东西,最早的在前, 可用于 traces、spans 或 logs。留着 上一次轮询给的游标,把它发回去,再把这些行应用到你已经拿着的那份列表上。

一次轮询不是一页,这两种游标是有意做成不可互换的。分页是在你选定的排序里, 沿着一个固定的结果集往回走;轮询是按到达顺序往前走,而且永远不结束。水位线 是一行的到达时间加上它的 id,因为一个批次里的每一行都写着同一个时间戳,而 一条只能说“在这一纳秒之后”的水位线,要么会把整个批次重放一遍,要么会把这个 批次剩下的部分跳过去。一次空的轮询会原样返回那个游标:把它往前挪,会漏掉 稍后才落地、而到达时间更早的一行。

评分和反馈不能轮询。它们打的是调用方发来的那个时间戳,不带服务端设置的到达 时间,所以在它们上面做水位线,要么会跳过一行迟到的,要么会永远重复每一行。 signal=scores 会被拒绝,而不是回一个空页。

这套部署能做什么

mcpd 只有一个存储 trait,它背后有三个后端,而它们并不等价。在 ClickHouse 上, 一次删除被接受后,要等调用返回之后才执行,所以它答复里的计数说的是匹配到了 什么,而不是什么已经离开了磁盘。在 SQLite 和 Postgres 上,调用返回时这些行 就已经没了。GET /v1/api/capabilities 就是把这件事写下来的 地方:守护进程背后是哪个后端,q 匹配什么,ASCII 之外的大小写 是否折叠,一次删除在调用返回时是否已经落盘,以及发现接口和增量轮询到底答 不答。

在连接时读一次,然后只提供这套部署答得上的东西。一个能力为 false 的端点会 以 501 拒绝,响应体里 capability 字段写的词,和 能力集里发布的是同一个词,所以两者永远不会互相矛盾;碰到这个拒绝的客户端 可以去查它,而不是去重试一件永远不会变的事。

今天发布的三个后端,发现接口和增量轮询都是答的,所以下面那个 501 是之后 新增的某个后端拒绝时会有的形状,而不是你会从 SQLite、Postgres 或 ClickHouse 看到的响应。能力集才是告诉你这是哪一种的东西,而且它现在就值得一读:这三个 后端在删除返回时是否已经落盘上、以及在保留期怎么执行上,都不一样。ASCII 之外的大小写,三个后端都会折叠。

我能按什么过滤?

# 这个项目上报过的每一个环境,用来填一个过滤框。
curl -s --get http://localhost:4317/v1/api/discovery \
  --data-urlencode 'project=default' \
  --data-urlencode 'facet=environment'

# 输入联想:以 "check" 开头的那些服务名。
curl -s --get http://localhost:4317/v1/api/discovery \
  --data-urlencode 'facet=service_name' \
  --data-urlencode 'prefix=check'

# 某一个指标标签取过的那些值。
curl -s --get http://localhost:4317/v1/api/discovery \
  --data-urlencode 'facet=metric_label_value' \
  --data-urlencode 'key=model'

时间窗口

from 和 to 接受一个 RFC 3339 时间戳,或者一个相对 的简写:一个减号、一个数字,再加上 s、m、 h 或 d 之一。-15m、-1h 和 -7d 都可以。这个简写之所以存在,是因为为了看最近一小时而 手敲一个 ISO 时间戳,是没人该付的摩擦。

from 含边界,to 不含,两者比的都是这次运行 开始的时间。一次在你的窗口之前就开始、并且在窗口里还在跑的长运行, 不会被返回。如果你在找 09:00 那一刻正在跑的东西,请把 from 往前放宽,而不是把它收窄。

一个既不是时间戳也不是时长的值会被具名拒绝,而不是被忽略。每一个列表端点上 的每一个参数都是这样失败的,因为一个被悄悄丢掉的过滤条件会返回一个看着合理 的错误答案,而一个看着合理的错误答案比一个错误更糟。

from=yesterday

{
  "error": "bad_parameter",
  "field": "from",
  "message": "\"yesterday\" is not an RFC 3339 timestamp or a relative duration like -1h"
}

找出糟糕回答的那个查询

has_score=answer_relevance&score_below=0.5 返回那个评分器 打到 0.5 以下的每一次运行。当抱怨是关于质量而不是关于失败时,就用这一个, 因为一个糟糕的回答,通常出自一次成功的运行。

只给 has_score,返回的是那个评分器看过的每一次运行。只给 score_below 则什么也不做:一个不挂在任何评分器上的阈值没有 意义,mcpd 只能去猜你指的是哪个评分器。它不猜。 评分与反馈讲了这些值 是从哪里来的。

搜索步骤,而不是运行

GET /v1/api/spans 一次跨所有运行搜索单个步骤。用它来问关于 一个步骤而不是一次会话的问题:哪一次工具调用慢,对某个模型的哪些调用出了 错,一次检索要花多久。

它接受 q、kind、status、 name、model、trace_id、 from、to、min_duration_ms、 sort、order、limit 和 cursor,含义和默认值都一样。这里没有 tags、 session_id 或 user_id 过滤条件,因为那些属于运行; 这三个都会被具名拒绝,而不是接受了再丢掉。

sort 在这里能取的值比在运行列表上少:start_time、 duration 或 cost,此外没有别的。一个 span 不带 span 数,也不带评分汇总,所以 span_count 和 score 没有东西可排,会被具名拒绝,而不是被忽略。

在一次运行上,model 问的是有没有任何一个 span 用过它。在一个 span 上,model 就是这个 span 自己的模型。同一个词,两个问题。

把它找出来

# 最近一天里失败的运行,且在 span 名称、提示词或补全中的任何位置
# 同时提到这两个词。
curl -s --get http://localhost:4317/v1/api/traces \
  --data-urlencode 'q=Leeds depot' \
  --data-urlencode 'status=error' \
  --data-urlencode 'from=-24h'

# 一次跨所有运行,找出调用了某个模型并且失败了的每一个步骤。
curl -s --get http://localhost:4317/v1/api/spans \
  --data-urlencode 'model=gpt-4o-mini' \
  --data-urlencode 'status=error' \
  --data-urlencode 'min_duration_ms=2000'

分页,以及游标为什么是不透明的

一份列表最多返回 limit 行,外加一个 page 对象。 还有更多时 page.next_cursor 就在那里,而 page.has_more 用一个布尔值说同一件事。

分页是键集分页,不是偏移分页。游标指的是给过你的最后一行,下一页从它之后 严格开始。偏移分页会漂移:trace 是持续到达的,所以等你去要第 50 到 99 行 时,窗口已经在你脚下移动了,于是你把一次运行读了两遍,而另一次根本没出现。 在一次排查里,你漏掉的那一次运行,正是你要找的那一次。

游标是排序值和行 id 这一对经十六进制编码后的结果,你应该把它当作没有含义的 东西。它之所以不透明,是为了让编码可以改变而不弄坏每一个调用方,也因为 它里面的值在每种排序下都是不同的量:start_time 和 duration 是纳秒,cost 是一个缩放过的整数, span_count 是一个普通计数,score 是这次运行的 最低分。

这件事带着一条规则。把游标发回去时,要配上产生它的那些过滤条件、 那个 sort 和那个 order。mcpd 不会检查你 是否照做了,而一个在不同排序下重放的游标,比的是错的那一列,会跳过一些行。 一个被改过或者被截断的游标会以 invalid_query 被拒绝,消息是 malformed cursor。

没有总数。page.total 是给某个能便宜地数出匹配数的后端留的, 而内置的 SQLite 存储不会填它,所以请按 has_more 循环,而不是 朝着一个数字去数。

一页一页翻

# 第一页会在 page.next_cursor 里交回一个游标。
curl -s --get http://localhost:4317/v1/api/traces \
  --data-urlencode 'q=refund' \
  --data-urlencode 'sort=cost' \
  --data-urlencode 'limit=100'

# 下一页:同样的过滤条件、同样的排序,再加上那个游标。
curl -s --get http://localhost:4317/v1/api/traces \
  --data-urlencode 'q=refund' \
  --data-urlencode 'sort=cost' \
  --data-urlencode 'limit=100' \
  --data-urlencode 'cursor=MzEzNzQyfDBhZjM...'

拿到那次运行之后

上面任何一个查询给出的 trace id,都可以交给 GET /v1/api/traces/{trace_id},它返回这次运行和它里面的 每一个 span,并且已经排好序,画一张瀑布图不需要第二个请求。加上 include=logs,scores,feedback,就能在同一个响应里拿到附在它上面 的一切。

从这里往下, 成本核算解释每个步骤上的那些 数字,而 HTTP API 参考按规范 声明的样子列出每一个端点和字段。